from datetime import date, timedelta from decimal import Decimal from unittest.mock import patch from django.core.cache import cache from rest_framework.test import APITestCase from apps.clients.models import Client 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 class ReportViewTests(APITestCase): @classmethod def setUpTestData(cls): cls.owner = User.objects.create_user( mobile="09128880001", password="secret123", first_name="Owner", ) cls.admin = User.objects.create_user( mobile="09128880002", password="secret123", first_name="Admin", ) cls.member = User.objects.create_user( mobile="09128880003", password="secret123", first_name="Member", ) cls.workspace = Workspace.objects.create(name="Reports", owner=cls.owner) WorkspaceMembership.objects.create( workspace=cls.workspace, user=cls.admin, role=WorkspaceMembership.Role.ADMIN, is_active=True, ) WorkspaceMembership.objects.create( workspace=cls.workspace, user=cls.member, role=WorkspaceMembership.Role.MEMBER, is_active=True, ) cls.client_obj = Client.objects.create(workspace=cls.workspace, name="Acme") cls.project = Project.objects.create( workspace=cls.workspace, name="Website", client=cls.client_obj, ) cls.tag = Tag.objects.create( workspace=cls.workspace, name="Design", color="#ffffff", ) entry_owner = TimeEntry.objects.create( workspace=cls.workspace, user=cls.owner, project=cls.project, description="Owner work", start_time="2026-04-10T08:00:00+03:30", end_time="2026-04-10T10:00:00+03:30", duration=timedelta(hours=2), is_billable=True, hourly_rate=Decimal("25.00"), currency="USD", ) entry_owner.tags.add(cls.tag) entry_member = TimeEntry.objects.create( workspace=cls.workspace, user=cls.member, project=cls.project, description="Member work", start_time="2026-04-11T09:00:00+03:30", end_time="2026-04-11T10:00:00+03:30", duration=timedelta(hours=1), is_billable=False, currency="USD", ) entry_member.tags.add(cls.tag) def setUp(self): cache.clear() def test_member_only_sees_own_chart_report(self): self.client.force_authenticate(user=self.member) with patch( "apps.reports.services.aggregation.timezone.localdate", return_value=date(2026, 4, 20), ): response = self.client.get( "/api/reports/chart/", {"workspace": str(self.workspace.id), "period": "this_month"}, ) self.assertEqual(response.status_code, 200) self.assertEqual(response.data["summary"]["total_duration"], "01:00:00") self.assertEqual(len(response.data["series"]), 1) self.assertEqual(response.data["series"][0]["user"]["id"], str(self.member.id)) def test_admin_chart_without_user_filter_returns_series_for_all_users(self): self.client.force_authenticate(user=self.admin) with patch( "apps.reports.services.aggregation.timezone.localdate", return_value=date(2026, 4, 20), ): response = self.client.get( "/api/reports/chart/", {"workspace": str(self.workspace.id), "period": "this_month"}, ) self.assertEqual(response.status_code, 200) self.assertEqual(response.data["summary"]["total_duration"], "03:00:00") self.assertEqual(len(response.data["series"]), 2) self.assertEqual( {series["user"]["id"] for series in response.data["series"]}, {str(self.owner.id), str(self.member.id)}, ) def test_admin_can_request_combined_table_report(self): self.client.force_authenticate(user=self.admin) with patch( "apps.reports.services.aggregation.timezone.localdate", return_value=date(2026, 4, 20), ): response = self.client.get( "/api/reports/table/", {"workspace": str(self.workspace.id), "period": "this_month"}, ) self.assertEqual(response.status_code, 200) self.assertEqual(response.data["summary"]["total_duration"], "03:00:00") self.assertEqual(len(response.data["days"]), 2) self.assertEqual(len(response.data["user_summaries"]), 2) self.assertIsNone(response.data["days"][0]["latest_hourly_rate"]) self.assertIsNone(response.data["days"][1]["latest_hourly_rate"]) summaries = {item["user"]["id"]: item for item in response.data["user_summaries"]} owner_summary = summaries[str(self.owner.id)] member_summary = summaries[str(self.member.id)] self.assertEqual(owner_summary["project_percentages"][0]["percentage"], "100") self.assertEqual(owner_summary["client_percentages"][0]["percentage"], "100") self.assertEqual(owner_summary["tag_percentages"][0]["percentage"], "100") self.assertEqual(member_summary["project_percentages"][0]["percentage"], "0") self.assertEqual(member_summary["client_percentages"][0]["percentage"], "0") self.assertEqual(member_summary["tag_percentages"][0]["percentage"], "0") def test_specific_user_report_includes_uncategorized_rows_and_balanced_percentages(self): self.client.force_authenticate(user=self.owner) TimeEntry.objects.create( workspace=self.workspace, user=self.owner, project=None, description="Uncategorized billable", start_time="2026-04-12T10:00:00+03:30", end_time="2026-04-12T11:00:00+03:30", duration=timedelta(hours=1), is_billable=True, hourly_rate=Decimal("10.00"), currency="USD", ) with patch( "apps.reports.services.aggregation.timezone.localdate", return_value=date(2026, 4, 20), ): response = self.client.get( "/api/reports/table/", { "workspace": str(self.workspace.id), "period": "this_month", "user": str(self.owner.id), "language": "en", }, ) self.assertEqual(response.status_code, 200) summary = response.data["user_summary"] self.assertEqual( sum(int(row["percentage"]) for row in summary["project_percentages"]), 100, ) self.assertEqual( sum(int(row["percentage"]) for row in summary["client_percentages"]), 100, ) self.assertEqual( sum(int(row["percentage"]) for row in summary["tag_percentages"]), 100, ) self.assertEqual( {row["name"] for row in summary["project_percentages"]}, {"Website", "No project"}, ) self.assertEqual( {row["name"] for row in summary["client_percentages"]}, {"Acme", "No client"}, ) self.assertEqual( {row["name"] for row in summary["tag_percentages"]}, {"Design", "No tag"}, ) self.assertEqual( {row["name"] for row in response.data["projects"]}, {"Website", "No project"}, ) self.assertEqual( {row["name"] for row in response.data["clients"]}, {"Acme", "No client"}, ) self.assertEqual( {row["name"] for row in response.data["tags"]}, {"Design", "No tag"}, ) self.assertEqual( sum(int(row["percentage"]) for row in summary["project_income_percentages"]), 100, ) self.assertEqual( sum(int(row["percentage"]) for row in summary["client_income_percentages"]), 100, ) self.assertEqual( sum(int(row["percentage"]) for row in summary["tag_income_percentages"]), 100, ) def test_income_percentages_are_hidden_for_mixed_currency_breakdowns(self): self.client.force_authenticate(user=self.owner) second_project = Project.objects.create( workspace=self.workspace, name="Mobile App", client=self.client_obj, ) TimeEntry.objects.create( workspace=self.workspace, user=self.owner, project=second_project, description="EUR work", start_time="2026-04-13T10:00:00+03:30", end_time="2026-04-13T11:00:00+03:30", duration=timedelta(hours=1), is_billable=True, hourly_rate=Decimal("20.00"), currency="EUR", ) with patch( "apps.reports.services.aggregation.timezone.localdate", return_value=date(2026, 4, 20), ): response = self.client.get( "/api/reports/table/", { "workspace": str(self.workspace.id), "period": "this_month", "user": str(self.owner.id), "language": "en", }, ) self.assertEqual(response.status_code, 200) summary = response.data["user_summary"] self.assertEqual(summary["project_income_percentages"], []) self.assertEqual(summary["client_income_percentages"], []) def test_daily_rate_uses_latest_billable_entry_snapshot(self): self.client.force_authenticate(user=self.owner) TimeEntry.objects.create( workspace=self.workspace, user=self.owner, project=self.project, description="Morning work", start_time="2026-04-15T08:00:00+03:30", end_time="2026-04-15T09:00:00+03:30", duration=timedelta(hours=1), is_billable=True, hourly_rate=Decimal("20.00"), currency="USD", ) TimeEntry.objects.create( workspace=self.workspace, user=self.owner, project=self.project, description="Later work", start_time="2026-04-15T13:00:00+03:30", end_time="2026-04-15T15:00:00+03:30", duration=timedelta(hours=2), is_billable=True, hourly_rate=Decimal("35.00"), currency="USD", ) with patch( "apps.reports.services.aggregation.timezone.localdate", return_value=date(2026, 4, 20), ): response = self.client.get( "/api/reports/table/", { "workspace": str(self.workspace.id), "period": "this_month", "user": str(self.owner.id), }, ) self.assertEqual(response.status_code, 200) target_day = next(day for day in response.data["days"] if day["date"] == "2026-04-15") self.assertEqual( target_day["latest_hourly_rate"], {"amount": "35.00", "currency": "USD"}, ) def test_custom_period_longer_than_31_days_is_rejected(self): self.client.force_authenticate(user=self.owner) response = self.client.get( "/api/reports/chart/", { "workspace": str(self.workspace.id), "period": "period", "from_date": "2026-01-01", "to_date": "2026-02-15", }, ) self.assertEqual(response.status_code, 400) def test_persian_this_month_uses_jalali_month_bounds(self): self.client.force_authenticate(user=self.owner) with patch( "apps.reports.services.aggregation.timezone.localdate", return_value=date(2026, 4, 27), ): TimeEntry.objects.create( workspace=self.workspace, user=self.owner, project=self.project, description="Previous jalali month", start_time="2026-04-20T08:00:00+03:30", end_time="2026-04-20T09:00:00+03:30", duration=timedelta(hours=1), is_billable=False, currency="USD", ) TimeEntry.objects.create( workspace=self.workspace, user=self.owner, project=self.project, description="Current jalali month", start_time="2026-04-21T08:00:00+03:30", end_time="2026-04-21T10:00:00+03:30", duration=timedelta(hours=2), is_billable=False, currency="USD", ) response = self.client.get( "/api/reports/table/", { "workspace": str(self.workspace.id), "period": "this_month", "language": "fa", }, ) self.assertEqual(response.status_code, 200) self.assertEqual(response.data["summary"]["total_duration"], "02:00:00") self.assertEqual(response.data["scope"]["from_date"], "2026-04-21") def test_table_report_cache_stays_until_time_entry_invalidation(self): self.client.force_authenticate(user=self.owner) url = "/api/reports/table/" params = {"workspace": str(self.workspace.id), "period": "this_month"} with patch( "apps.reports.services.aggregation.timezone.localdate", return_value=date(2026, 4, 20), ): first_response = self.client.get(url, params) self.assertEqual(first_response.status_code, 200) self.assertEqual(first_response.data["summary"]["total_duration"], "03:00:00") member_entry = TimeEntry.objects.get(description="Member work") TimeEntry.objects.filter(id=member_entry.id).update(duration=timedelta(hours=5)) with patch( "apps.reports.services.aggregation.timezone.localdate", return_value=date(2026, 4, 20), ): cached_response = self.client.get(url, params) self.assertEqual(cached_response.status_code, 200) self.assertEqual(cached_response.data["summary"]["total_duration"], "03:00:00") member_entry.refresh_from_db() member_entry.description = "Member work updated" member_entry.save(update_fields=["description"]) with patch( "apps.reports.services.aggregation.timezone.localdate", return_value=date(2026, 4, 20), ): fresh_response = self.client.get(url, params) self.assertEqual(fresh_response.status_code, 200) self.assertEqual(fresh_response.data["summary"]["total_duration"], "07:00:00")