bottleneckcrm/backend/cases/time_views.py

555 lines
22 KiB
Python

"""
Time-tracking endpoints (Tier 3 time-tracking).
Case-scoped:
* ``GET /api/cases/<pk>/time-entries/``: list (visible to actor)
* ``POST /api/cases/<pk>/time-entries/``: manual entry
* ``POST /api/cases/<pk>/time-entries/start/``: start a running timer (409 if one active)
* ``GET /api/cases/<pk>/time-summary/``: totals + by-profile breakdown
All four need read access to the ticket (`get_case_or_404`), and a ticket the
caller may not open is a 404. They used to check the org alone, so any member
could log time on, start a timer against, or read the per-person breakdown of
a ticket the detail view refuses them. Logging time and starting a timer are
work on the ticket, so they also take its write rule (the one replying takes,
`comment_permission` on the detail payload): a watcher reads, and gets 403.
Stopping a timer is not gated on the ticket: it is the entry owner's (or an
admin's) and only ends their own clock, and refusing it to someone since
unassigned would strand their one running timer.
Entry-scoped (registered at the project root under ``/api/time-entries/``):
* ``POST /api/time-entries/<pk>/stop/``: stop a running timer
* ``PUT /api/time-entries/<pk>/``: owner or admin
* ``DELETE /api/time-entries/<pk>/``: owner or admin
* ``GET /api/time-entries/timesheet/``: week view, grouped by day
* ``GET /api/time-entries/report/``: totals by agent, ticket or account
* ``GET /api/time-entries/report/export/``: the same window as ``text/csv``
"""
from collections import OrderedDict
from datetime import datetime, timedelta
from django.db import IntegrityError, transaction
from django.db.models import Sum
from django.utils import timezone
from rest_framework import status
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from cases import time_reports
from cases.access import assert_case_write_access, get_case_or_404
from cases.models import TimeEntry
from cases.serializer import (
TimeEntryCreateSerializer,
TimeEntrySerializer,
TimeEntryUpdateSerializer,
)
from cases.time_reports import TimeReportParamError
from common.csv_export import csv_response
from common.models import Profile
from common.modules import module_is_available
from common.permissions import HasOrgContext, is_org_admin
from common.renderers import CSV_RENDERERS
from common.validators import uuid_param
# How far back a report reaches when it is asked for without a window. Long
# enough to cover "what did we do this month", short enough that a stray click
# does not scan a year.
DEFAULT_REPORT_DAYS = 30
def _visible_entry_qs(profile):
"""Org-scoped queryset honouring agent-vs-admin visibility."""
qs = TimeEntry.objects.filter(org=profile.org)
if not is_org_admin(profile):
qs = qs.filter(profile=profile)
return qs.select_related("profile", "profile__user", "case")
def _parse_date(value):
"""``YYYY-MM-DD`` to a date, or None when absent. Raises on anything else.
Shared by the timesheet and the reports so a malformed date is answered
the same way on both, rather than one of them reading it as today.
"""
if not value:
return None
try:
return datetime.strptime(value, "%Y-%m-%d").date()
except (TypeError, ValueError) as exc:
raise ValueError(f"Invalid date {value!r}; expected YYYY-MM-DD.") from exc
def _report_entries(request):
"""Return ``(entries, start, end)`` for a report request.
Only stopped entries, inside the window, narrowed by the optional
``profile``, ``account`` and ``billable`` filters. Visibility is
`_visible_entry_qs`'s: an agent reports on their own time, an admin on the
org's, and asking for somebody else's without being an admin is a 403
rather than an empty report, because a silent empty answer reads as "that
person logged nothing".
Raises :class:`TimeReportParamError`, which the views turn into a response.
"""
profile = request.profile
entries = _visible_entry_qs(profile).filter(ended_at__isnull=False)
target = uuid_param(request.query_params, "profile")
if target:
if target != str(profile.id) and not is_org_admin(profile):
raise TimeReportParamError(
"Only admins can report on another profile's time.",
status.HTTP_403_FORBIDDEN,
)
entries = entries.filter(profile_id=target)
account = uuid_param(request.query_params, "account")
if account:
entries = entries.filter(case__account_id=account)
billable = request.query_params.get("billable")
if billable is not None:
if billable not in ("true", "false"):
raise TimeReportParamError("billable must be 'true' or 'false'.")
entries = entries.filter(billable=(billable == "true"))
try:
start = _parse_date(request.query_params.get("start"))
end = _parse_date(request.query_params.get("end"))
except ValueError as exc:
raise TimeReportParamError(str(exc)) from exc
# Both or neither, the same rule the timesheet uses: half a window is
# more likely a mistake than a request to run from a date to whenever.
if start is None or end is None:
end = timezone.localdate()
start = end - timedelta(days=DEFAULT_REPORT_DAYS - 1)
if end < start:
raise TimeReportParamError("end must be on or after start.")
entries = entries.filter(started_at__date__gte=start, started_at__date__lte=end)
return entries, start, end
class TimeEntryListCreateView(APIView):
permission_classes = (IsAuthenticated, HasOrgContext)
def get(self, request, pk):
case = get_case_or_404(request.profile, pk)
entries = (
_visible_entry_qs(request.profile).filter(case=case).order_by("-started_at")
)
return Response(TimeEntrySerializer(entries, many=True).data)
def post(self, request, pk):
case = get_case_or_404(request.profile, pk)
assert_case_write_access(request.profile, case)
serializer = TimeEntryCreateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
entry = TimeEntry.objects.create(
org=request.profile.org,
case=case,
profile=request.profile,
**serializer.validated_data,
)
except IntegrityError as exc:
# Most likely the partial unique on (profile) for active timers,
# or the end-after-start CheckConstraint as a defense-in-depth.
return Response(
{"detail": str(exc).splitlines()[0][:200]},
status=status.HTTP_400_BAD_REQUEST,
)
return Response(TimeEntrySerializer(entry).data, status=status.HTTP_201_CREATED)
class TimeEntryStartView(APIView):
permission_classes = (IsAuthenticated, HasOrgContext)
@transaction.atomic
def post(self, request, pk):
case = get_case_or_404(request.profile, pk)
assert_case_write_access(request.profile, case)
# Reject if this profile already has a running timer (anywhere). The
# one_active_timer_per_profile partial unique would also catch this
# at INSERT time, but a 409 with the offending case id is friendlier
# than a 400 from IntegrityError.
running = (
TimeEntry.objects.select_for_update()
.filter(profile=request.profile, ended_at__isnull=True)
.first()
)
if running is not None:
return Response(
{
"detail": "You already have a running timer.",
"running_entry_id": str(running.id),
"running_case_id": str(running.case_id),
},
status=status.HTTP_409_CONFLICT,
)
entry = TimeEntry.objects.create(
org=request.profile.org,
case=case,
profile=request.profile,
started_at=timezone.now(),
description=(request.data.get("description") or "").strip(),
billable=bool(request.data.get("billable", False)),
)
return Response(TimeEntrySerializer(entry).data, status=status.HTTP_201_CREATED)
class TimeSummaryView(APIView):
"""``GET /api/cases/<pk>/time-summary/``, totals + per-profile breakdown.
Same shape as ``CaseSerializer.time_summary`` for clients that don't
want to refetch the full case envelope.
"""
permission_classes = (IsAuthenticated, HasOrgContext)
def get(self, request, pk):
case = get_case_or_404(request.profile, pk)
qs = case.time_entries.filter(ended_at__isnull=False)
total = qs.aggregate(total=Sum("duration_minutes"))["total"] or 0
billable = (
qs.filter(billable=True).aggregate(s=Sum("duration_minutes"))["s"] or 0
)
last_entry_at = (
qs.order_by("-started_at").values_list("started_at", flat=True).first()
)
by_profile = []
for row in (
qs.values("profile_id", "profile__user__email")
.annotate(minutes=Sum("duration_minutes"))
.order_by("-minutes")
):
by_profile.append(
{
"profile_id": str(row["profile_id"]),
"name": row.get("profile__user__email") or "",
"minutes": row["minutes"] or 0,
}
)
return Response(
{
"total_minutes": total,
"billable_minutes": billable,
"last_entry_at": last_entry_at,
"by_profile": by_profile,
}
)
class TimeEntryDetailView(APIView):
"""PUT/DELETE for a specific time entry. Owner or admin only."""
permission_classes = (IsAuthenticated, HasOrgContext)
def _get_entry(self, request, pk):
try:
entry = TimeEntry.objects.select_for_update().get(
id=pk, org=request.profile.org
)
except TimeEntry.DoesNotExist:
return None
if entry.profile_id != request.profile.id and not is_org_admin(request.profile):
return False # Sentinel: row exists but caller is not authorized.
return entry
@transaction.atomic
def put(self, request, pk):
entry = self._get_entry(request, pk)
if entry is None:
return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND)
if entry is False:
return Response(
{"detail": "Not authorized to edit this entry."},
status=status.HTTP_403_FORBIDDEN,
)
if entry.invoice_id is not None:
return Response(
{"detail": "Cannot edit an entry that has been invoiced."},
status=status.HTTP_400_BAD_REQUEST,
)
serializer = TimeEntryUpdateSerializer(entry, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
# Apply allowed fields explicitly so we never overwrite invoice/profile/case.
for field, value in serializer.validated_data.items():
setattr(entry, field, value)
entry.save()
return Response(TimeEntrySerializer(entry).data)
@transaction.atomic
def delete(self, request, pk):
entry = self._get_entry(request, pk)
if entry is None:
return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND)
if entry is False:
return Response(
{"detail": "Not authorized to delete this entry."},
status=status.HTTP_403_FORBIDDEN,
)
if entry.invoice_id is not None:
return Response(
{"detail": "Cannot delete an entry that has been invoiced."},
status=status.HTTP_400_BAD_REQUEST,
)
entry.delete()
return Response(status=status.HTTP_204_NO_CONTENT)
class TimeEntryStopView(APIView):
"""``POST /api/time-entries/<pk>/stop/``. Stop a running timer."""
permission_classes = (IsAuthenticated, HasOrgContext)
@transaction.atomic
def post(self, request, pk):
entry = (
TimeEntry.objects.select_for_update()
.filter(id=pk, org=request.profile.org)
.first()
)
if entry is None:
return Response({"detail": "Not found."}, status=status.HTTP_404_NOT_FOUND)
if entry.profile_id != request.profile.id and not is_org_admin(request.profile):
return Response(
{"detail": "Not authorized to stop this entry."},
status=status.HTTP_403_FORBIDDEN,
)
if entry.ended_at is not None:
return Response(
{"detail": "Timer is already stopped."},
status=status.HTTP_400_BAD_REQUEST,
)
entry.ended_at = timezone.now()
entry.save()
return Response(TimeEntrySerializer(entry).data)
class UnbilledEntriesView(APIView):
"""``GET /api/time-entries/unbilled/?account=<uuid>``: list billable,
stopped, not-yet-invoiced entries for an account so the invoice picker
can show them. Org-scoped; admins see all entries, agents only their own.
"""
permission_classes = (IsAuthenticated, HasOrgContext)
def get(self, request):
account_id = uuid_param(request.query_params, "account")
qs = (
_visible_entry_qs(request.profile)
.filter(billable=True, invoice__isnull=True, ended_at__isnull=False)
.order_by("-started_at")
)
if account_id:
qs = qs.filter(case__account_id=account_id)
return Response(TimeEntrySerializer(qs, many=True).data)
class TimesheetView(APIView):
"""``GET /api/time-entries/timesheet/?profile=<id>&start=<date>&end=<date>``.
Returns entries for ``profile`` (defaults to caller) between ``start`` and
``end`` inclusive, grouped into a list of day buckets with totals. Only
admins may pass a ``profile`` other than their own.
"""
permission_classes = (IsAuthenticated, HasOrgContext)
def get(self, request):
profile_param = uuid_param(request.query_params, "profile")
target_profile_id = request.profile.id
if profile_param and profile_param != str(request.profile.id):
if not is_org_admin(request.profile):
return Response(
{"detail": "Only admins can view another profile's timesheet."},
status=status.HTTP_403_FORBIDDEN,
)
target_profile_id = profile_param
try:
start = _parse_date(request.query_params.get("start"))
end = _parse_date(request.query_params.get("end"))
except ValueError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
if start is None or end is None:
today = timezone.localdate()
# Default to this Mon..Sun (ISO week).
start = today - timedelta(days=today.weekday())
end = start + timedelta(days=6)
if end < start:
return Response(
{"detail": "end must be on or after start."},
status=status.HTTP_400_BAD_REQUEST,
)
# Include running timers (ended_at IS NULL) so the timesheet shows
# work-in-progress; the live duration is computed below.
qs = (
TimeEntry.objects.filter(
org=request.profile.org,
profile_id=target_profile_id,
started_at__date__gte=start,
started_at__date__lte=end,
)
.select_related("case", "profile", "profile__user", "invoice")
.order_by("started_at")
)
days = OrderedDict()
cursor = start
while cursor <= end:
days[cursor.isoformat()] = {
"date": cursor.isoformat(),
"entries": [],
"total_minutes": 0,
"billable_minutes": 0,
}
cursor += timedelta(days=1)
now = timezone.now()
running_count = 0
show_invoices = module_is_available(request.profile.org, "invoices")
for entry in qs:
day_key = timezone.localdate(entry.started_at).isoformat()
bucket = days.get(day_key)
if bucket is None:
# Entry's local date may fall outside the window when timezone
# math straddles midnight; skip silently.
continue
data = TimeEntrySerializer(entry).data
# The timesheet page shows each ticket's name and links a billed
# entry to its invoice, so expand these two FKs from the bare ids
# the serializer emits. Both are select_related above, so this adds
# no per-row queries.
data["case"] = {"id": str(entry.case_id), "name": entry.case.name}
data["invoice"] = (
{
"id": str(entry.invoice_id),
"invoice_number": entry.invoice.invoice_number,
}
if entry.invoice_id and show_invoices
else None
)
if entry.ended_at is None:
# Surface a server-side live duration so non-JS clients still
# see the right number; the frontend re-ticks it locally.
live = max(int((now - entry.started_at).total_seconds() // 60), 0)
data["is_running"] = True
data["live_duration_minutes"] = live
bucket["total_minutes"] += live
if entry.billable:
bucket["billable_minutes"] += live
running_count += 1
else:
data["is_running"] = False
bucket["total_minutes"] += entry.duration_minutes or 0
if entry.billable:
bucket["billable_minutes"] += entry.duration_minutes or 0
bucket["entries"].append(data)
week_total = sum(d["total_minutes"] for d in days.values())
billable_total = sum(d["billable_minutes"] for d in days.values())
# Whose timesheet this is, named for the header. Reuse request.profile
# when it's the caller's own week (the common case) to avoid a lookup;
# otherwise resolve the org-scoped target profile. user.name is never
# blank (it falls back to the email local-part on first save).
if str(target_profile_id) == str(request.profile.id):
target_profile = request.profile
else:
target_profile = (
Profile.objects.filter(org=request.profile.org, id=target_profile_id)
.select_related("user")
.first()
)
profile_name = (
target_profile.user.name
if target_profile and target_profile.user_id
else ""
)
return Response(
{
"profile_id": str(target_profile_id),
"profile": {"id": str(target_profile_id), "name": profile_name},
"start": start.isoformat(),
"end": end.isoformat(),
"days": list(days.values()),
"total_minutes": week_total,
"billable_minutes": billable_total,
"running_count": running_count,
"server_now": now.isoformat(),
}
)
class TimeReportView(APIView):
"""``GET /api/time-entries/report/``: where the time went.
``?start=&end=`` (YYYY-MM-DD, inclusive, defaulting to the last 30 days),
``?group_by=agent|ticket|account``, and the optional ``profile``,
``account`` and ``billable`` filters. One row per group with the minutes,
the billable split, the value at each entry's own rate, and how many
entries went into it.
The productivity half of the answer. For the billing half, the same
window comes out of ``report/export/`` as one row per entry.
"""
permission_classes = (IsAuthenticated, HasOrgContext)
def get(self, request):
group_by = request.query_params.get("group_by") or "agent"
try:
entries, start, end = _report_entries(request)
report = time_reports.build_report(entries, group_by)
except TimeReportParamError as exc:
return Response({"detail": exc.detail}, status=exc.status_code)
return Response(
{
"start": start.isoformat(),
"end": end.isoformat(),
"group_by": group_by,
**report,
}
)
class TimeReportExportView(APIView):
"""``GET /api/time-entries/report/export/``: the same window as CSV.
One row per entry, not per group: an invoice is checked against the
entries behind it, and a spreadsheet can group them again in whatever way
the person opening it needs.
Streams, so a year of entries does not have to be built in memory first.
Same filters and the same visibility as the report above.
"""
permission_classes = (IsAuthenticated, HasOrgContext)
# Content negotiation runs before the handler, so a download proxy asking
# for `Accept: text/csv` was answered 406 without this.
renderer_classes = CSV_RENDERERS
def get(self, request):
try:
entries, start, end = _report_entries(request)
except TimeReportParamError as exc:
return Response({"detail": exc.detail}, status=exc.status_code)
return csv_response(
time_reports.csv_rows(
entries, module_is_available(request.profile.org, "invoices")
),
f"time-{start.isoformat()}-to-{end.isoformat()}.csv",
request.profile.org,
)