feat(reports): load user summaries on demand
This commit is contained in:
@@ -6,6 +6,7 @@ from apps.reports.api.views import (
|
|||||||
ReportDayDetailsView,
|
ReportDayDetailsView,
|
||||||
ReportExportJobViewSet,
|
ReportExportJobViewSet,
|
||||||
ReportTableView,
|
ReportTableView,
|
||||||
|
ReportUserSummaryView,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
@@ -15,6 +16,6 @@ urlpatterns = [
|
|||||||
path("chart/", ReportChartView.as_view(), name="report-chart"),
|
path("chart/", ReportChartView.as_view(), name="report-chart"),
|
||||||
path("table/", ReportTableView.as_view(), name="report-table"),
|
path("table/", ReportTableView.as_view(), name="report-table"),
|
||||||
path("day-details/", ReportDayDetailsView.as_view(), name="report-day-details"),
|
path("day-details/", ReportDayDetailsView.as_view(), name="report-day-details"),
|
||||||
|
path("user-summary/", ReportUserSummaryView.as_view(), name="report-user-summary"),
|
||||||
path("", include(router.urls)),
|
path("", include(router.urls)),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from apps.reports.services import (
|
|||||||
build_chart_report,
|
build_chart_report,
|
||||||
build_day_details_report,
|
build_day_details_report,
|
||||||
build_table_report,
|
build_table_report,
|
||||||
|
build_user_summary_report,
|
||||||
load_report_filters,
|
load_report_filters,
|
||||||
)
|
)
|
||||||
from apps.reports.tasks import generate_report_export_task
|
from apps.reports.tasks import generate_report_export_task
|
||||||
@@ -83,6 +84,24 @@ class ReportDayDetailsView(APIView):
|
|||||||
return Response(payload)
|
return Response(payload)
|
||||||
|
|
||||||
|
|
||||||
|
class ReportUserSummaryView(APIView):
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
@extend_schema(responses=dict)
|
||||||
|
def get(self, request):
|
||||||
|
workspace_id = request.query_params.get("workspace")
|
||||||
|
payload = get_or_set_cache_payload(
|
||||||
|
CACHE_NAMESPACE_REPORTS,
|
||||||
|
ttl_seconds=REPORT_CACHE_TTL_SECONDS,
|
||||||
|
builder=lambda: build_user_summary_report(request.user, request.query_params),
|
||||||
|
resource="user-summary",
|
||||||
|
user_id=request.user.id,
|
||||||
|
workspace_id=workspace_id,
|
||||||
|
params=request.query_params,
|
||||||
|
)
|
||||||
|
return Response(payload)
|
||||||
|
|
||||||
|
|
||||||
class ReportExportJobViewSet(
|
class ReportExportJobViewSet(
|
||||||
mixins.CreateModelMixin,
|
mixins.CreateModelMixin,
|
||||||
mixins.ListModelMixin,
|
mixins.ListModelMixin,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from apps.reports.services.aggregation import (
|
|||||||
build_chart_report,
|
build_chart_report,
|
||||||
build_day_details_report,
|
build_day_details_report,
|
||||||
build_table_report,
|
build_table_report,
|
||||||
|
build_user_summary_report,
|
||||||
build_user_scoped_table_reports,
|
build_user_scoped_table_reports,
|
||||||
load_report_filters,
|
load_report_filters,
|
||||||
)
|
)
|
||||||
@@ -10,6 +11,7 @@ __all__ = [
|
|||||||
"load_report_filters",
|
"load_report_filters",
|
||||||
"build_chart_report",
|
"build_chart_report",
|
||||||
"build_table_report",
|
"build_table_report",
|
||||||
|
"build_user_summary_report",
|
||||||
"build_user_scoped_table_reports",
|
"build_user_scoped_table_reports",
|
||||||
"build_day_details_report",
|
"build_day_details_report",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from apps.projects.services.access import user_has_project_access
|
|||||||
from apps.tags.models import Tag
|
from apps.tags.models import Tag
|
||||||
from apps.time_entries.models import TimeEntry
|
from apps.time_entries.models import TimeEntry
|
||||||
from apps.workspaces.models import Workspace
|
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
|
from apps.workspaces.services import WORKSPACE_VIEW, get_workspace_role, has_workspace_capability
|
||||||
|
|
||||||
User = get_user_model()
|
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()
|
unique_rates: set[tuple[str, str]] = set()
|
||||||
for entry in entries:
|
for row in rate_rows:
|
||||||
if not entry.hourly_rate:
|
unique_rates.add((row["amount"], row["currency"]))
|
||||||
continue
|
|
||||||
unique_rates.add((f"{Decimal(entry.hourly_rate).quantize(Decimal('0.01'))}", entry.currency or "USD"))
|
|
||||||
return [
|
return [
|
||||||
{"amount": amount, "currency": currency}
|
{"amount": amount, "currency": currency}
|
||||||
for amount, currency in sorted(unique_rates, key=lambda item: (item[1], Decimal(item[0])))
|
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]:
|
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] = []
|
periods: list[dict] = []
|
||||||
current: dict | None = None
|
current: dict | None = None
|
||||||
|
|
||||||
for entry in sorted_entries:
|
for entry in sorted_entries:
|
||||||
if not entry.hourly_rate:
|
if not entry.hourly_rate or not entry.start_time:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
amount = f"{Decimal(entry.hourly_rate).quantize(Decimal('0.01'))}"
|
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
|
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:
|
def _uncategorized_label(kind: str, language: str) -> str:
|
||||||
resolved_language = language if language in UNCATEGORIZED_LABELS else "en"
|
resolved_language = language if language in UNCATEGORIZED_LABELS else "en"
|
||||||
return UNCATEGORIZED_LABELS[resolved_language][kind]
|
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:
|
def _build_user_summary(user, entries: list[TimeEntry], *, language: str) -> dict:
|
||||||
summary = _summary_from_entries(entries)
|
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)
|
project_rows = _build_breakdown(entries, "projects", language=language)
|
||||||
client_rows = _build_breakdown(entries, "clients", language=language)
|
client_rows = _build_breakdown(entries, "clients", language=language)
|
||||||
tag_rows = _build_breakdown(entries, "tags", 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),
|
"name": _user_display(user),
|
||||||
"mobile": user.mobile,
|
"mobile": user.mobile,
|
||||||
},
|
},
|
||||||
"hourly_rates": _serialize_distinct_rates(entries),
|
"hourly_rates": _serialize_distinct_rates_from_rows(rate_rows),
|
||||||
"rate_periods": _serialize_rate_periods(entries),
|
"rate_periods": rate_rows,
|
||||||
"total_seconds": summary["billable_seconds"],
|
"total_seconds": summary["billable_seconds"],
|
||||||
"total_duration": summary["total_duration"],
|
"total_duration": summary["total_duration"],
|
||||||
"billable_seconds": summary["billable_seconds"],
|
"billable_seconds": summary["billable_seconds"],
|
||||||
@@ -988,11 +1042,12 @@ def build_table_report(actor, raw_filters) -> dict:
|
|||||||
filters = load_report_filters(actor, raw_filters)
|
filters = load_report_filters(actor, raw_filters)
|
||||||
entries = list(_base_queryset(filters))
|
entries = list(_base_queryset(filters))
|
||||||
if filters.is_workspace_scope and not filters.user_id:
|
if filters.is_workspace_scope and not filters.user_id:
|
||||||
return _table_report_payload(
|
payload = _table_report_payload(
|
||||||
filters,
|
filters,
|
||||||
entries,
|
entries,
|
||||||
user_summaries=_build_user_summaries(entries, language=filters.language),
|
user_summaries=_build_user_summaries(entries, language=filters.language),
|
||||||
)
|
)
|
||||||
|
return payload
|
||||||
user_summary = (
|
user_summary = (
|
||||||
_build_user_summary(entries[0].user, entries, language=filters.language)
|
_build_user_summary(entries[0].user, entries, language=filters.language)
|
||||||
if entries and filters.user_id
|
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)
|
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]:
|
def build_user_scoped_table_reports(actor, raw_filters) -> list[dict]:
|
||||||
filters = load_report_filters(actor, raw_filters)
|
filters = load_report_filters(actor, raw_filters)
|
||||||
if not (filters.is_workspace_scope and not filters.user_id):
|
if not (filters.is_workspace_scope and not filters.user_id):
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from apps.projects.models import Project
|
|||||||
from apps.tags.models import Tag
|
from apps.tags.models import Tag
|
||||||
from apps.time_entries.models import TimeEntry
|
from apps.time_entries.models import TimeEntry
|
||||||
from apps.users.models import User
|
from apps.users.models import User
|
||||||
from apps.workspaces.models import Workspace, WorkspaceMembership
|
from apps.workspaces.models import Workspace, WorkspaceMembership, WorkspaceUserRate
|
||||||
|
|
||||||
|
|
||||||
class ReportViewTests(APITestCase):
|
class ReportViewTests(APITestCase):
|
||||||
@@ -320,6 +320,75 @@ class ReportViewTests(APITestCase):
|
|||||||
{"amount": "35.00", "currency": "USD"},
|
{"amount": "35.00", "currency": "USD"},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_user_summary_endpoint_keeps_workspace_rate_history_and_marks_current_row_open(self):
|
||||||
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
|
||||||
|
TimeEntry.objects.create(
|
||||||
|
workspace=self.workspace,
|
||||||
|
user=self.owner,
|
||||||
|
project=None,
|
||||||
|
description="Legacy workspace rate",
|
||||||
|
start_time="2026-04-08T08:00:00+03:30",
|
||||||
|
end_time="2026-04-08T09:00:00+03:30",
|
||||||
|
duration=timedelta(hours=1),
|
||||||
|
is_billable=True,
|
||||||
|
hourly_rate=Decimal("12.00"),
|
||||||
|
currency="USD",
|
||||||
|
)
|
||||||
|
TimeEntry.objects.create(
|
||||||
|
workspace=self.workspace,
|
||||||
|
user=self.owner,
|
||||||
|
project=self.project,
|
||||||
|
description="Current project rate",
|
||||||
|
start_time="2026-04-12T08:00:00+03:30",
|
||||||
|
end_time="2026-04-12T10:00:00+03:30",
|
||||||
|
duration=timedelta(hours=2),
|
||||||
|
is_billable=True,
|
||||||
|
hourly_rate=Decimal("25.00"),
|
||||||
|
currency="USD",
|
||||||
|
)
|
||||||
|
WorkspaceUserRate.objects.create(
|
||||||
|
workspace=self.workspace,
|
||||||
|
user=self.owner,
|
||||||
|
hourly_rate=Decimal("12.00"),
|
||||||
|
currency="USD",
|
||||||
|
effective_from="2026-04-01T00:00:00+03:30",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"apps.reports.services.aggregation.timezone.localdate",
|
||||||
|
return_value=date(2026, 4, 20),
|
||||||
|
):
|
||||||
|
response = self.client.get(
|
||||||
|
"/api/reports/user-summary/",
|
||||||
|
{
|
||||||
|
"workspace": str(self.workspace.id),
|
||||||
|
"period": "this_month",
|
||||||
|
"user": str(self.owner.id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
rate_periods = response.data["user_summary"]["rate_periods"]
|
||||||
|
self.assertEqual(
|
||||||
|
rate_periods,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"amount": "12.00",
|
||||||
|
"currency": "USD",
|
||||||
|
"from_date": "2026-04-08",
|
||||||
|
"to_date": None,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"amount": "25.00",
|
||||||
|
"currency": "USD",
|
||||||
|
"from_date": "2026-04-10",
|
||||||
|
"to_date": "2026-04-12",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
def test_custom_period_longer_than_31_days_is_rejected(self):
|
def test_custom_period_longer_than_31_days_is_rejected(self):
|
||||||
self.client.force_authenticate(user=self.owner)
|
self.client.force_authenticate(user=self.owner)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user