diff --git a/apps/reports/api/urls.py b/apps/reports/api/urls.py index 0972113..2a10296 100644 --- a/apps/reports/api/urls.py +++ b/apps/reports/api/urls.py @@ -6,6 +6,7 @@ from apps.reports.api.views import ( ReportDayDetailsView, ReportExportJobViewSet, ReportTableView, + ReportUserSummaryView, ) router = DefaultRouter() @@ -15,6 +16,6 @@ urlpatterns = [ path("chart/", ReportChartView.as_view(), name="report-chart"), path("table/", ReportTableView.as_view(), name="report-table"), path("day-details/", ReportDayDetailsView.as_view(), name="report-day-details"), + path("user-summary/", ReportUserSummaryView.as_view(), name="report-user-summary"), path("", include(router.urls)), ] - diff --git a/apps/reports/api/views.py b/apps/reports/api/views.py index 40b4824..2da759e 100644 --- a/apps/reports/api/views.py +++ b/apps/reports/api/views.py @@ -20,6 +20,7 @@ from apps.reports.services import ( build_chart_report, build_day_details_report, build_table_report, + build_user_summary_report, load_report_filters, ) from apps.reports.tasks import generate_report_export_task @@ -83,6 +84,24 @@ class ReportDayDetailsView(APIView): 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( mixins.CreateModelMixin, mixins.ListModelMixin, diff --git a/apps/reports/services/__init__.py b/apps/reports/services/__init__.py index f2ae9b2..878222a 100644 --- a/apps/reports/services/__init__.py +++ b/apps/reports/services/__init__.py @@ -2,6 +2,7 @@ from apps.reports.services.aggregation import ( build_chart_report, build_day_details_report, build_table_report, + build_user_summary_report, build_user_scoped_table_reports, load_report_filters, ) @@ -10,6 +11,7 @@ __all__ = [ "load_report_filters", "build_chart_report", "build_table_report", + "build_user_summary_report", "build_user_scoped_table_reports", "build_day_details_report", ] diff --git a/apps/reports/services/aggregation.py b/apps/reports/services/aggregation.py index 276362f..0fc5b7f 100644 --- a/apps/reports/services/aggregation.py +++ b/apps/reports/services/aggregation.py @@ -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): diff --git a/apps/reports/tests/test_views.py b/apps/reports/tests/test_views.py index a76ad2a..1663980 100644 --- a/apps/reports/tests/test_views.py +++ b/apps/reports/tests/test_views.py @@ -10,7 +10,7 @@ from apps.projects.models import Project from apps.tags.models import Tag from apps.time_entries.models import TimeEntry from apps.users.models import User -from apps.workspaces.models import Workspace, WorkspaceMembership +from apps.workspaces.models import Workspace, WorkspaceMembership, WorkspaceUserRate class ReportViewTests(APITestCase): @@ -320,6 +320,75 @@ class ReportViewTests(APITestCase): {"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): self.client.force_authenticate(user=self.owner)