feat(reports): load user summaries on demand

This commit is contained in:
2026-05-23 19:48:32 +03:30
parent 0d6c6a4f09
commit 59cf62bc73
5 changed files with 172 additions and 12 deletions

View File

@@ -19,6 +19,7 @@ from apps.projects.services.access import user_has_project_access
from apps.tags.models import Tag
from apps.time_entries.models import TimeEntry
from apps.workspaces.models import Workspace
from apps.workspaces.models import WorkspaceUserRate
from apps.workspaces.services import WORKSPACE_VIEW, get_workspace_role, has_workspace_capability
User = get_user_model()
@@ -110,12 +111,10 @@ def _serialize_rate(amount: Decimal | None, currency: str | None) -> dict | None
}
def _serialize_distinct_rates(entries: list[TimeEntry]) -> list[dict]:
def _serialize_distinct_rates_from_rows(rate_rows: list[dict]) -> list[dict]:
unique_rates: set[tuple[str, str]] = set()
for entry in entries:
if not entry.hourly_rate:
continue
unique_rates.add((f"{Decimal(entry.hourly_rate).quantize(Decimal('0.01'))}", entry.currency or "USD"))
for row in rate_rows:
unique_rates.add((row["amount"], row["currency"]))
return [
{"amount": amount, "currency": currency}
for amount, currency in sorted(unique_rates, key=lambda item: (item[1], Decimal(item[0])))
@@ -123,12 +122,12 @@ def _serialize_distinct_rates(entries: list[TimeEntry]) -> list[dict]:
def _serialize_rate_periods(entries: list[TimeEntry]) -> list[dict]:
sorted_entries = sorted(entries, key=lambda entry: entry.start_time)
sorted_entries = sorted(entries, key=lambda entry: (entry.start_time, entry.end_time or entry.start_time, entry.id))
periods: list[dict] = []
current: dict | None = None
for entry in sorted_entries:
if not entry.hourly_rate:
if not entry.hourly_rate or not entry.start_time:
continue
amount = f"{Decimal(entry.hourly_rate).quantize(Decimal('0.01'))}"
@@ -176,6 +175,58 @@ def _serialize_rate_periods(entries: list[TimeEntry]) -> list[dict]:
return periods
def _serialize_current_rate_rows(*, user, workspace: Workspace) -> list[dict]:
workspace_rate = (
WorkspaceUserRate.objects.filter(
workspace=workspace,
user=user,
is_active=True,
is_deleted=False,
)
.order_by("-effective_from", "-updated_at")
.first()
)
if not workspace_rate or not workspace_rate.effective_from:
return []
return [
{
"amount": f"{Decimal(workspace_rate.hourly_rate).quantize(Decimal('0.01'))}",
"currency": workspace_rate.currency or "USD",
"from_date": _localize_datetime(workspace_rate.effective_from).date().isoformat(),
"to_date": None,
"is_current": True,
}
]
def _merge_rate_history_rows(history_rows: list[dict], current_rows: list[dict]) -> list[dict]:
merged = [dict(row) for row in history_rows]
latest_indexes = {
(row["amount"], row["currency"]): index
for index, row in enumerate(merged)
}
for row in current_rows:
key = (row["amount"], row["currency"])
index = latest_indexes.get(key)
if index is not None:
merged[index]["to_date"] = None
continue
merged.append(dict(row))
latest_indexes[key] = len(merged) - 1
return sorted(
merged,
key=lambda item: (
item["from_date"],
item["currency"],
Decimal(item["amount"]),
item.get("to_date") or "9999-12-31",
),
)
def _uncategorized_label(kind: str, language: str) -> str:
resolved_language = language if language in UNCATEGORIZED_LABELS else "en"
return UNCATEGORIZED_LABELS[resolved_language][kind]
@@ -373,6 +424,9 @@ def _serialize_income_percentage_rows(rows: list[dict], shares: dict[str, dict])
def _build_user_summary(user, entries: list[TimeEntry], *, language: str) -> dict:
summary = _summary_from_entries(entries)
historical_rate_rows = _serialize_rate_periods(entries)
current_rate_rows = _serialize_current_rate_rows(user=user, workspace=entries[0].workspace)
rate_rows = _merge_rate_history_rows(historical_rate_rows, current_rate_rows)
project_rows = _build_breakdown(entries, "projects", language=language)
client_rows = _build_breakdown(entries, "clients", language=language)
tag_rows = _build_breakdown(entries, "tags", language=language)
@@ -386,8 +440,8 @@ def _build_user_summary(user, entries: list[TimeEntry], *, language: str) -> dic
"name": _user_display(user),
"mobile": user.mobile,
},
"hourly_rates": _serialize_distinct_rates(entries),
"rate_periods": _serialize_rate_periods(entries),
"hourly_rates": _serialize_distinct_rates_from_rows(rate_rows),
"rate_periods": rate_rows,
"total_seconds": summary["billable_seconds"],
"total_duration": summary["total_duration"],
"billable_seconds": summary["billable_seconds"],
@@ -988,11 +1042,12 @@ def build_table_report(actor, raw_filters) -> dict:
filters = load_report_filters(actor, raw_filters)
entries = list(_base_queryset(filters))
if filters.is_workspace_scope and not filters.user_id:
return _table_report_payload(
payload = _table_report_payload(
filters,
entries,
user_summaries=_build_user_summaries(entries, language=filters.language),
)
return payload
user_summary = (
_build_user_summary(entries[0].user, entries, language=filters.language)
if entries and filters.user_id
@@ -1001,6 +1056,20 @@ def build_table_report(actor, raw_filters) -> dict:
return _table_report_payload(filters, entries, user_summary=user_summary)
def build_user_summary_report(actor, raw_filters) -> dict:
filters = load_report_filters(actor, raw_filters)
if not filters.user_id:
raise serializers.ValidationError("A user is required.")
entries = list(_base_queryset(filters))
user_summary = (
_build_user_summary(entries[0].user, entries, language=filters.language)
if entries
else None
)
return _table_report_payload(filters, entries, user_summary=user_summary)
def build_user_scoped_table_reports(actor, raw_filters) -> list[dict]:
filters = load_report_filters(actor, raw_filters)
if not (filters.is_workspace_scope and not filters.user_id):