204 lines
7.3 KiB
Python
204 lines
7.3 KiB
Python
"""Grouped totals and the CSV rows behind the time reports.
|
|
|
|
Kept beside ``time_views.py`` for the same reason ``analytics.py`` sits beside
|
|
``analytics_views.py``: the grouping and the money are the parts worth testing
|
|
without an HTTP layer wrapped around them.
|
|
|
|
Two shapes come out of here. :func:`build_report` answers "where did the time
|
|
go", one row per agent, ticket or account. :func:`csv_rows` answers "give me
|
|
the entries", one row per :class:`cases.models.TimeEntry`, which is what a
|
|
finance team pastes into a spreadsheet and what an invoice is checked against.
|
|
|
|
Both read only stopped entries. A running timer has ``duration_minutes`` 0
|
|
until it is stopped, so counting one would report a morning's work as nothing
|
|
and quietly drag an agent's total down.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from decimal import ROUND_HALF_UP, Decimal
|
|
|
|
from django.db.models import (
|
|
Count,
|
|
DecimalField,
|
|
ExpressionWrapper,
|
|
F,
|
|
Q,
|
|
Sum,
|
|
Value,
|
|
)
|
|
from django.utils import timezone
|
|
from rest_framework import status
|
|
|
|
GROUPINGS = ("agent", "ticket", "account")
|
|
|
|
# The columns each grouping needs out of the row. The first is the key.
|
|
_GROUP_FIELDS = {
|
|
"agent": ("profile_id", "profile__user__name", "profile__user__email"),
|
|
"ticket": ("case_id", "case__name"),
|
|
"account": ("case__account_id", "case__account__name"),
|
|
}
|
|
|
|
_BILLABLE = Q(billable=True) & Q(hourly_rate__isnull=False)
|
|
|
|
# Money, computed in the database at the rate stored on each entry rather than
|
|
# today's rate: `hourly_rate` is snapshotted per entry precisely so that a rate
|
|
# change next month does not silently rewrite what last month was worth.
|
|
# `decimal_places=4` because minutes/60 is a third of a cent on odd durations,
|
|
# and rounding is done once, on the sum, not on every row of it.
|
|
_VALUE = ExpressionWrapper(
|
|
F("duration_minutes") * F("hourly_rate") / Value(Decimal("60")),
|
|
output_field=DecimalField(max_digits=16, decimal_places=4),
|
|
)
|
|
|
|
CSV_COLUMNS = (
|
|
"Date",
|
|
"Agent",
|
|
"Ticket",
|
|
"Account",
|
|
"Description",
|
|
"Minutes",
|
|
"Hours",
|
|
"Billable",
|
|
"Rate",
|
|
"Currency",
|
|
"Value",
|
|
"Invoice",
|
|
)
|
|
|
|
|
|
class TimeReportParamError(Exception):
|
|
"""A query parameter the report will not run on.
|
|
|
|
Carries the status as well as the message so the caller does not have to
|
|
guess whether a rejected ``profile`` is a 400 or a 403; reading another
|
|
agent's time without being an admin is the second one.
|
|
"""
|
|
|
|
def __init__(self, detail, status_code=status.HTTP_400_BAD_REQUEST):
|
|
super().__init__(detail)
|
|
self.detail = detail
|
|
self.status_code = status_code
|
|
|
|
|
|
def money(value):
|
|
"""A Decimal rounded to cents, as a string. ``None`` becomes ``"0.00"``."""
|
|
amount = Decimal(value or 0)
|
|
return str(amount.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
|
|
|
|
|
|
def hours(minutes):
|
|
"""Minutes as decimal hours, to two places: 45 minutes is ``"0.75"``."""
|
|
return str(
|
|
(Decimal(minutes or 0) / Decimal(60)).quantize(
|
|
Decimal("0.01"), rounding=ROUND_HALF_UP
|
|
)
|
|
)
|
|
|
|
|
|
def build_report(entries, group_by):
|
|
"""Group ``entries`` and return ``{rows, totals, currencies}``.
|
|
|
|
``entries`` is expected to be scoped and filtered already: this function
|
|
does not know who is asking, and adding an org filter here would read as
|
|
though it did.
|
|
|
|
Rows are ordered by time logged, descending, because the question the
|
|
report answers is which agent, ticket or account took the most of it. A
|
|
row's ``key`` is null for time on tickets with no account; that bucket is
|
|
named rather than dropped, since unattributed time is exactly what someone
|
|
running this report is looking for.
|
|
"""
|
|
if group_by not in _GROUP_FIELDS:
|
|
raise TimeReportParamError(
|
|
f"group_by must be one of {', '.join(GROUPINGS)}, not {group_by!r}."
|
|
)
|
|
|
|
fields = _GROUP_FIELDS[group_by]
|
|
grouped = (
|
|
entries.values(*fields)
|
|
.annotate(
|
|
total_minutes=Sum("duration_minutes"),
|
|
billable_minutes=Sum("duration_minutes", filter=Q(billable=True)),
|
|
billable_value=Sum(_VALUE, filter=_BILLABLE),
|
|
entry_count=Count("id"),
|
|
)
|
|
.order_by("-total_minutes")
|
|
)
|
|
|
|
rows = []
|
|
for row in grouped:
|
|
key = row[fields[0]]
|
|
rows.append(
|
|
{
|
|
"key": str(key) if key else None,
|
|
"name": _row_name(group_by, row, fields),
|
|
"total_minutes": row["total_minutes"] or 0,
|
|
"billable_minutes": row["billable_minutes"] or 0,
|
|
"billable_value": money(row["billable_value"]),
|
|
"entry_count": row["entry_count"],
|
|
}
|
|
)
|
|
|
|
totals = {
|
|
"total_minutes": sum(r["total_minutes"] for r in rows),
|
|
"billable_minutes": sum(r["billable_minutes"] for r in rows),
|
|
"billable_value": money(sum(Decimal(r["billable_value"]) for r in rows)),
|
|
"entry_count": sum(r["entry_count"] for r in rows),
|
|
}
|
|
|
|
# Every currency present in the range, so a client can say "these totals
|
|
# mix currencies" instead of printing one symbol over a sum of two. The
|
|
# value column adds them regardless, which is the same compromise the
|
|
# timesheet page and the shell's pipeline figures already make.
|
|
currencies = sorted(set(entries.values_list("currency", flat=True).distinct()))
|
|
|
|
return {"rows": rows, "totals": totals, "currencies": currencies}
|
|
|
|
|
|
def _row_name(group_by, row, fields):
|
|
if group_by == "agent":
|
|
# `User.name` is never blank (it falls back to the email local-part on
|
|
# first save), but an entry can outlive nothing here, so the email is
|
|
# the backstop rather than an empty cell.
|
|
return row[fields[1]] or row[fields[2]] or "Unknown agent"
|
|
if group_by == "ticket":
|
|
return row[fields[1]] or "Deleted ticket"
|
|
return row[fields[1]] or "No account"
|
|
|
|
|
|
def csv_rows(entries, show_invoices=True):
|
|
"""Yield the header, then one list per entry, for ``csv.writer``.
|
|
|
|
A generator over ``.iterator()`` so a year of entries streams instead of
|
|
being built in memory: the caller wraps this in a ``StreamingHttpResponse``.
|
|
The Invoice column stays, blank, when ``show_invoices`` is false.
|
|
"""
|
|
yield list(CSV_COLUMNS)
|
|
|
|
queryset = entries.select_related(
|
|
"profile__user", "case", "case__account", "invoice"
|
|
).order_by("started_at")
|
|
|
|
for entry in queryset.iterator(chunk_size=200):
|
|
user = entry.profile.user if entry.profile_id else None
|
|
account = entry.case.account if entry.case_id else None
|
|
value = (
|
|
money(Decimal(entry.duration_minutes) * entry.hourly_rate / Decimal(60))
|
|
if entry.billable and entry.hourly_rate is not None
|
|
else ""
|
|
)
|
|
yield [
|
|
timezone.localdate(entry.started_at).isoformat(),
|
|
(user.name or user.email) if user else "",
|
|
entry.case.name if entry.case_id else "",
|
|
account.name if account else "",
|
|
entry.description or "",
|
|
entry.duration_minutes,
|
|
hours(entry.duration_minutes),
|
|
"yes" if entry.billable else "no",
|
|
str(entry.hourly_rate) if entry.hourly_rate is not None else "",
|
|
entry.currency,
|
|
value,
|
|
entry.invoice.invoice_number if entry.invoice_id and show_invoices else "",
|
|
]
|