From d4a52d6f3ba96ca48e4462d9e0d4e0e29f3aad20 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Thu, 14 May 2026 17:06:35 +0330 Subject: [PATCH] feat(reports): refine exports and restore project access --- apps/projects/api/serializers.py | 14 + apps/projects/api/views.py | 92 +- .../projects/migrations/0004_projectaccess.py | 38 + apps/projects/models.py | 28 + apps/projects/services/access.py | 144 +++ apps/projects/tests/test_views.py | 52 +- apps/reports/services/aggregation.py | 236 ++++- apps/reports/services/export_i18n.py | 28 +- apps/reports/services/exporters.py | 898 ++++++++++++++++-- apps/reports/tasks.py | 4 +- apps/reports/tests/test_exporters.py | 90 +- apps/reports/tests/test_views.py | 10 + apps/time_entries/api/serializers.py | 12 + apps/time_entries/services/time_entries.py | 21 +- apps/time_entries/tests/test_views.py | 62 +- apps/workspaces/services/permissions.py | 1 + 16 files changed, 1594 insertions(+), 136 deletions(-) create mode 100644 apps/projects/migrations/0004_projectaccess.py create mode 100644 apps/projects/services/access.py diff --git a/apps/projects/api/serializers.py b/apps/projects/api/serializers.py index 0583bdb..5f84123 100644 --- a/apps/projects/api/serializers.py +++ b/apps/projects/api/serializers.py @@ -40,3 +40,17 @@ class ProjectUpdateSerializer(serializers.Serializer): description = serializers.CharField(required=False, allow_blank=True) color = serializers.CharField(max_length=7, required=False, allow_blank=True) is_archived = serializers.BooleanField(required=False) + + +class ProjectAccessQuerySerializer(serializers.Serializer): + workspace = serializers.UUIDField() + user = serializers.UUIDField() + + +class ProjectAccessMutationSerializer(serializers.Serializer): + workspace = serializers.UUIDField() + user = serializers.UUIDField() + project_ids = serializers.ListField( + child=serializers.UUIDField(), + allow_empty=False, + ) diff --git a/apps/projects/api/views.py b/apps/projects/api/views.py index 8bbd839..0f09f74 100644 --- a/apps/projects/api/views.py +++ b/apps/projects/api/views.py @@ -15,8 +15,17 @@ from apps.clients.models import Client from apps.projects.models import Project from apps.projects.api.serializers import ( ProjectSerializer, ProjectCreateSerializer, ProjectUpdateSerializer, + ProjectAccessMutationSerializer, ProjectAccessQuerySerializer, ) from apps.projects.api.permissions import IsProjectMember, IsProjectManager +from apps.projects.services.access import ( + build_project_access_items, + ensure_workspace_project_access, + filter_projects_for_user, + get_access_managed_membership, + grant_project_accesses, + revoke_project_accesses, +) from apps.projects.services.projects import ( create_project, update_project, @@ -67,11 +76,10 @@ class ProjectViewSet(ModelViewSet): if getattr(self, "swagger_fake_view", False) or not self.request.user.is_authenticated: return Project.objects.none() - queryset = Project.objects.filter( - workspace__memberships__user=self.request.user, - workspace__memberships__is_active=True, - is_deleted=False - ).distinct() + queryset = filter_projects_for_user( + self.request.user, + Project.objects.filter(is_deleted=False), + ) client_ids = [client_id for client_id in self.request.query_params.getlist("clients") if client_id] if client_ids: @@ -150,12 +158,76 @@ class ProjectViewSet(ModelViewSet): return Response(status=status.HTTP_204_NO_CONTENT) @action(detail=True, methods=["post"]) - def archive(self, request, pk=None): - """ - Custom endpoint to toggle the archive status of a project. - """ - project = self.get_object() + def archive(self, request, pk=None): + """ + Custom endpoint to toggle the archive status of a project. + """ + project = self.get_object() updated_project = toggle_project_archive(project) output_serializer = ProjectSerializer(updated_project) return Response(output_serializer.data, status=status.HTTP_200_OK) + + @action(detail=False, methods=["get"], url_path="access") + def access(self, request): + serializer = ProjectAccessQuerySerializer(data=request.query_params) + serializer.is_valid(raise_exception=True) + + workspace = get_object_or_404( + Workspace, + id=serializer.validated_data["workspace"], + is_deleted=False, + ) + ensure_workspace_project_access(request.user, workspace) + membership = get_access_managed_membership(workspace, str(serializer.validated_data["user"])) + + return Response( + { + "workspace": {"id": str(workspace.id), "name": workspace.name}, + "user": { + "id": str(membership.user_id), + "name": membership.user.full_name or membership.user.mobile, + "mobile": membership.user.mobile, + "role": membership.role, + }, + "items": build_project_access_items(workspace=workspace, target_user=membership.user), + } + ) + + @action(detail=False, methods=["post"], url_path="access/grant") + def grant_access(self, request): + serializer = ProjectAccessMutationSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + workspace = get_object_or_404( + Workspace, + id=serializer.validated_data["workspace"], + is_deleted=False, + ) + membership = get_access_managed_membership(workspace, str(serializer.validated_data["user"])) + changed = grant_project_accesses( + actor=request.user, + workspace=workspace, + target_user=membership.user, + project_ids=[str(project_id) for project_id in serializer.validated_data["project_ids"]], + ) + return Response({"changed": changed}, status=status.HTTP_200_OK) + + @action(detail=False, methods=["post"], url_path="access/revoke") + def revoke_access(self, request): + serializer = ProjectAccessMutationSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + workspace = get_object_or_404( + Workspace, + id=serializer.validated_data["workspace"], + is_deleted=False, + ) + membership = get_access_managed_membership(workspace, str(serializer.validated_data["user"])) + changed = revoke_project_accesses( + actor=request.user, + workspace=workspace, + target_user=membership.user, + project_ids=[str(project_id) for project_id in serializer.validated_data["project_ids"]], + ) + return Response({"changed": changed}, status=status.HTTP_200_OK) diff --git a/apps/projects/migrations/0004_projectaccess.py b/apps/projects/migrations/0004_projectaccess.py new file mode 100644 index 0000000..2a73dcb --- /dev/null +++ b/apps/projects/migrations/0004_projectaccess.py @@ -0,0 +1,38 @@ +# Generated by Django 5.2.12 on 2026-05-13 12:30 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0003_project_project_ws_arch_upd_idx'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ProjectAccess', + fields=[ + ('id', models.UUIDField(default=uuid.uuid7, primary_key=True, serialize=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('is_deleted', models.BooleanField(default=False)), + ('is_active', models.BooleanField(default=False)), + ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_%(app_label)s_%(class)s_set', to=settings.AUTH_USER_MODEL)), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='access_memberships', to='projects.project')), + ('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_%(app_label)s_%(class)s_set', to=settings.AUTH_USER_MODEL)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_accesses', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'db_table': 'project_access', + 'ordering': ('-created_at',), + 'indexes': [models.Index(fields=['project'], name='project_access_project_idx'), models.Index(fields=['user'], name='project_access_user_idx')], + 'constraints': [models.UniqueConstraint(condition=models.Q(('is_deleted', False)), fields=('project', 'user'), name='unique_project_access')], + }, + ), + ] diff --git a/apps/projects/models.py b/apps/projects/models.py index 8b3e8e9..39cc0a7 100644 --- a/apps/projects/models.py +++ b/apps/projects/models.py @@ -130,3 +130,31 @@ class ProjectUserRate(BaseModel): models.Index(fields=["project"], name="pur_project_idx"), models.Index(fields=["user"], name="pur_user_idx"), ] + + +class ProjectAccess(BaseModel): + project = models.ForeignKey( + Project, + on_delete=models.CASCADE, + related_name="access_memberships", + ) + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="project_accesses", + ) + + class Meta: + db_table = "project_access" + ordering = ("-created_at",) + constraints = [ + models.UniqueConstraint( + fields=["project", "user"], + name="unique_project_access", + condition=models.Q(is_deleted=False), + ) + ] + indexes = [ + models.Index(fields=["project"], name="project_access_project_idx"), + models.Index(fields=["user"], name="project_access_user_idx"), + ] diff --git a/apps/projects/services/access.py b/apps/projects/services/access.py new file mode 100644 index 0000000..dc9de59 --- /dev/null +++ b/apps/projects/services/access.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +from django.contrib.auth import get_user_model +from django.db.models import Q, QuerySet +from django.utils import timezone +from rest_framework.exceptions import PermissionDenied, ValidationError + +from apps.projects.models import Project, ProjectAccess +from apps.workspaces.models import Workspace, WorkspaceMembership +from apps.workspaces.services import PROJECTS_EDIT, get_workspace_role, has_workspace_capability + +User = get_user_model() + +PROJECT_ACCESS_MANAGED_ROLES = { + WorkspaceMembership.Role.MEMBER, + WorkspaceMembership.Role.GUEST, +} +PROJECT_ACCESS_IMPLICIT_ROLES = { + WorkspaceMembership.Role.OWNER, + WorkspaceMembership.Role.ADMIN, +} + + +def user_has_implicit_project_access(user, workspace: Workspace) -> bool: + return get_workspace_role(user, workspace) in PROJECT_ACCESS_IMPLICIT_ROLES + + +def user_has_project_access(user, project: Project) -> bool: + if not user or not getattr(user, "is_authenticated", False): + return False + if user_has_implicit_project_access(user, project.workspace): + return True + return ProjectAccess.objects.filter(project=project, user=user).exists() + + +def filter_projects_for_user(user, queryset: QuerySet[Project] | None = None) -> QuerySet[Project]: + if queryset is None: + queryset = Project.objects.all() + + if not user or not getattr(user, "is_authenticated", False): + return queryset.none() + + return queryset.filter( + Q(workspace__owner=user) + | Q( + workspace__memberships__user=user, + workspace__memberships__is_active=True, + workspace__memberships__role__in=PROJECT_ACCESS_IMPLICIT_ROLES, + ) + | Q( + workspace__memberships__user=user, + workspace__memberships__is_active=True, + workspace__memberships__role__in=PROJECT_ACCESS_MANAGED_ROLES, + access_memberships__user=user, + ) + ).distinct() + + +def ensure_project_access(user, project: Project, *, message: str = "Selected project is unavailable.") -> None: + if not user_has_project_access(user, project): + raise ValidationError({"project_id": message}) + + +def ensure_workspace_project_access(user, workspace: Workspace) -> None: + if not has_workspace_capability(user, workspace, PROJECTS_EDIT): + raise PermissionDenied("You do not have permission to manage project access in this workspace.") + + +def get_access_managed_membership(workspace: Workspace, user_id: str) -> WorkspaceMembership: + membership = WorkspaceMembership.objects.filter( + workspace=workspace, + user_id=user_id, + is_active=True, + is_deleted=False, + ).select_related("user").first() + if not membership: + raise ValidationError({"user": "Selected user is not an active member of this workspace."}) + if membership.role not in PROJECT_ACCESS_MANAGED_ROLES: + raise ValidationError({"user": "Owners and admins have implicit access to all projects."}) + return membership + + +def build_project_access_items(*, workspace: Workspace, target_user) -> list[dict]: + explicit_access_ids = set( + ProjectAccess.objects.filter(project__workspace=workspace, user=target_user).values_list("project_id", flat=True) + ) + projects = ( + Project.objects.filter(workspace=workspace, is_deleted=False) + .select_related("client") + .order_by("client__name", "name") + ) + return [ + { + "id": str(project.id), + "name": project.name, + "description": project.description, + "color": project.color, + "is_archived": project.is_archived, + "client": ( + {"id": str(project.client_id), "name": project.client.name} + if project.client_id and project.client + else None + ), + "has_access": str(project.id) in {str(project_id) for project_id in explicit_access_ids}, + } + for project in projects + ] + + +def grant_project_accesses(*, actor, workspace: Workspace, target_user, project_ids: list[str]) -> int: + ensure_workspace_project_access(actor, workspace) + get_access_managed_membership(workspace, str(target_user.id)) + + projects = list(Project.objects.filter(workspace=workspace, id__in=project_ids, is_deleted=False)) + if len(projects) != len(set(project_ids)): + raise ValidationError({"project_ids": "One or more selected projects do not belong to this workspace."}) + + changed = 0 + for project in projects: + access, created, restored = ProjectAccess.get_or_restore(project=project, user=target_user) + if created or restored: + access.is_active = True + access.updated_at = timezone.now() + access.save(update_fields=["is_active", "updated_at"]) + changed += 1 + return changed + + +def revoke_project_accesses(*, actor, workspace: Workspace, target_user, project_ids: list[str]) -> int: + ensure_workspace_project_access(actor, workspace) + get_access_managed_membership(workspace, str(target_user.id)) + + accesses = list( + ProjectAccess.objects.filter( + project__workspace=workspace, + user=target_user, + project_id__in=project_ids, + ).select_related("project") + ) + changed = 0 + for access in accesses: + access.delete() + changed += 1 + return changed diff --git a/apps/projects/tests/test_views.py b/apps/projects/tests/test_views.py index a1968cc..f878f33 100644 --- a/apps/projects/tests/test_views.py +++ b/apps/projects/tests/test_views.py @@ -1,7 +1,7 @@ from rest_framework.test import APITestCase from apps.clients.models import Client -from apps.projects.models import Project +from apps.projects.models import Project, ProjectAccess from apps.users.models import User from apps.workspaces.models import Workspace, WorkspaceMembership @@ -34,16 +34,19 @@ class ProjectViewTests(APITestCase): client=cls.first_client, name="Alpha", ) - Project.objects.create( + cls.second_project = Project.objects.create( workspace=cls.workspace, client=cls.second_client, name="Beta", ) - Project.objects.create( + cls.third_project = Project.objects.create( workspace=cls.workspace, client=cls.third_client, name="Gamma", ) + cls.first_project = Project.objects.get(name="Alpha") + ProjectAccess.objects.create(project=cls.first_project, user=cls.member) + ProjectAccess.objects.create(project=cls.second_project, user=cls.member) def test_project_list_supports_multi_client_filter(self): self.client.force_authenticate(user=self.member) @@ -68,3 +71,46 @@ class ProjectViewTests(APITestCase): result_ids, {str(self.first_client.id), str(self.second_client.id)}, ) + + def test_project_access_list_and_mutations_require_explicit_member_access(self): + self.client.force_authenticate(user=self.owner) + + access_response = self.client.get( + "/api/projects/access/", + {"workspace": str(self.workspace.id), "user": str(self.member.id)}, + ) + + self.assertEqual(access_response.status_code, 200) + items = access_response.data["items"] + gamma_item = next(item for item in items if item["id"] == str(self.third_project.id)) + self.assertFalse(gamma_item["has_access"]) + + grant_response = self.client.post( + "/api/projects/access/grant/", + { + "workspace": str(self.workspace.id), + "user": str(self.member.id), + "project_ids": [str(self.third_project.id)], + }, + format="json", + ) + self.assertEqual(grant_response.status_code, 200) + + access_response = self.client.get( + "/api/projects/access/", + {"workspace": str(self.workspace.id), "user": str(self.member.id)}, + ) + gamma_item = next(item for item in access_response.data["items"] if item["id"] == str(self.third_project.id)) + self.assertTrue(gamma_item["has_access"]) + + revoke_response = self.client.post( + "/api/projects/access/revoke/", + { + "workspace": str(self.workspace.id), + "user": str(self.member.id), + "project_ids": [str(self.first_project.id)], + }, + format="json", + ) + self.assertEqual(revoke_response.status_code, 200) + self.assertFalse(ProjectAccess.objects.filter(project=self.first_project, user=self.member).exists()) diff --git a/apps/reports/services/aggregation.py b/apps/reports/services/aggregation.py index a510ac9..b3b090c 100644 --- a/apps/reports/services/aggregation.py +++ b/apps/reports/services/aggregation.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections import defaultdict from dataclasses import dataclass, replace from datetime import date, datetime, time, timedelta -from decimal import Decimal +from decimal import Decimal, ROUND_HALF_UP from typing import Iterable import jdatetime @@ -15,6 +15,7 @@ from rest_framework import serializers from apps.clients.models import Client from apps.projects.models import Project +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 @@ -90,6 +91,205 @@ def _serialize_rate(amount: Decimal | None, currency: str | None) -> dict | None } +def _serialize_distinct_rates(entries: list[TimeEntry]) -> 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")) + return [ + {"amount": amount, "currency": currency} + for amount, currency in sorted(unique_rates, key=lambda item: (item[1], Decimal(item[0]))) + ] + + +def _serialize_rate_periods(entries: list[TimeEntry]) -> list[dict]: + sorted_entries = sorted(entries, key=lambda entry: entry.start_time) + periods: list[dict] = [] + current: dict | None = None + + for entry in sorted_entries: + if not entry.hourly_rate: + continue + + amount = f"{Decimal(entry.hourly_rate).quantize(Decimal('0.01'))}" + currency = entry.currency or "USD" + start_date = _localize_datetime(entry.start_time).date() + end_source = entry.end_time or entry.start_time + end_date = _localize_datetime(end_source).date() + + if ( + current + and current["amount"] == amount + and current["currency"] == currency + ): + if end_date > current["to_date"]: + current["to_date"] = end_date + continue + + if current: + periods.append( + { + "amount": current["amount"], + "currency": current["currency"], + "from_date": current["from_date"].isoformat(), + "to_date": current["to_date"].isoformat(), + } + ) + + current = { + "amount": amount, + "currency": currency, + "from_date": start_date, + "to_date": end_date, + } + + if current: + periods.append( + { + "amount": current["amount"], + "currency": current["currency"], + "from_date": current["from_date"].isoformat(), + "to_date": current["to_date"].isoformat(), + } + ) + + return periods + + +def _serialize_percentage_rows(shares: dict[str, dict], total_seconds: int) -> list[dict]: + if total_seconds <= 0: + return [] + rows = [] + for bucket in shares.values(): + percentage = ( + Decimal(bucket["seconds"]) * Decimal("100") / Decimal(total_seconds) + ).quantize(Decimal("1"), rounding=ROUND_HALF_UP) + rows.append( + { + "id": bucket["id"], + "name": bucket["name"], + "percentage": f"{percentage}", + } + ) + rows.sort(key=lambda item: (-Decimal(item["percentage"]), item["name"].lower())) + return rows + + +def _build_user_summary(user, entries: list[TimeEntry]) -> dict: + summary = _summary_from_entries(entries) + project_shares: dict[str, dict] = {} + client_shares: dict[str, dict] = {} + tag_shares: dict[str, dict] = {} + + total_seconds = summary["billable_seconds"] + for entry in entries: + if not entry.is_billable: + continue + duration_seconds = get_entry_duration_seconds(entry) + if duration_seconds <= 0: + continue + + if entry.project_id: + project_bucket = project_shares.setdefault( + str(entry.project_id), + {"id": str(entry.project_id), "name": entry.project.name, "seconds": 0}, + ) + project_bucket["seconds"] += duration_seconds + + if entry.project and entry.project.client_id: + client_bucket = client_shares.setdefault( + str(entry.project.client_id), + {"id": str(entry.project.client_id), "name": entry.project.client.name, "seconds": 0}, + ) + client_bucket["seconds"] += duration_seconds + + tags = list(entry.tags.all()) + if tags: + allocated_seconds = Decimal(duration_seconds) / Decimal(len(tags)) + for tag in tags: + tag_bucket = tag_shares.setdefault( + str(tag.id), + {"id": str(tag.id), "name": tag.name, "seconds": Decimal("0")}, + ) + tag_bucket["seconds"] += allocated_seconds + + return { + "user": { + "id": str(user.id), + "name": _user_display(user), + "mobile": user.mobile, + }, + "hourly_rates": _serialize_distinct_rates(entries), + "rate_periods": _serialize_rate_periods(entries), + "total_seconds": total_seconds, + "total_duration": summary["total_duration"], + "billable_seconds": summary["billable_seconds"], + "billable_duration": summary["billable_duration"], + "non_billable_seconds": summary["non_billable_seconds"], + "non_billable_duration": summary["non_billable_duration"], + "income_totals": summary["income_totals"], + "project_percentages": _serialize_percentage_rows(project_shares, total_seconds), + "client_percentages": _serialize_percentage_rows(client_shares, total_seconds), + "tag_percentages": _serialize_percentage_rows(tag_shares, total_seconds), + } + + +def _build_user_summaries(entries: list[TimeEntry]) -> list[dict]: + grouped: dict[str, list[TimeEntry]] = defaultdict(list) + for entry in entries: + grouped[str(entry.user_id)].append(entry) + + summaries = [_build_user_summary(grouped_entries[0].user, grouped_entries) for grouped_entries in grouped.values() if grouped_entries] + summaries.sort(key=lambda item: item["user"]["name"].lower()) + return summaries + + +def _build_overall_percentage_payload(entries: list[TimeEntry]) -> dict: + project_shares: dict[str, dict] = {} + client_shares: dict[str, dict] = {} + tag_shares: dict[str, dict] = {} + total_seconds = 0 + + for entry in entries: + if not entry.is_billable: + continue + duration_seconds = get_entry_duration_seconds(entry) + if duration_seconds <= 0: + continue + total_seconds += duration_seconds + + if entry.project_id: + project_bucket = project_shares.setdefault( + str(entry.project_id), + {"id": str(entry.project_id), "name": entry.project.name, "seconds": 0}, + ) + project_bucket["seconds"] += duration_seconds + + if entry.project and entry.project.client_id: + client_bucket = client_shares.setdefault( + str(entry.project.client_id), + {"id": str(entry.project.client_id), "name": entry.project.client.name, "seconds": 0}, + ) + client_bucket["seconds"] += duration_seconds + + tags = list(entry.tags.all()) + if tags: + allocated_seconds = Decimal(duration_seconds) / Decimal(len(tags)) + for tag in tags: + tag_bucket = tag_shares.setdefault( + str(tag.id), + {"id": str(tag.id), "name": tag.name, "seconds": Decimal("0")}, + ) + tag_bucket["seconds"] += allocated_seconds + + return { + "project_percentages": _serialize_percentage_rows(project_shares, total_seconds), + "client_percentages": _serialize_percentage_rows(client_shares, total_seconds), + "tag_percentages": _serialize_percentage_rows(tag_shares, total_seconds), + } + + def _add_income(bucket: defaultdict[str, Decimal], entry: TimeEntry): if not entry.is_billable or not entry.hourly_rate: return @@ -251,6 +451,10 @@ def load_report_filters(actor, raw_data) -> ReportFilters: raise serializers.ValidationError("Client does not belong to this workspace.") if project_id and not Project.objects.filter(id=project_id, workspace=workspace).exists(): raise serializers.ValidationError("Project does not belong to this workspace.") + if project_id and not is_workspace_scope: + project = Project.objects.filter(id=project_id, workspace=workspace).first() + if project and not user_has_project_access(actor, project): + raise serializers.ValidationError("Project does not belong to this workspace.") if tag_ids: existing_tag_ids = set(Tag.objects.filter(id__in=tag_ids, workspace=workspace).values_list("id", flat=True)) if len(existing_tag_ids) != len(tag_ids): @@ -471,10 +675,16 @@ def _scope_payload(filters: ReportFilters) -> dict: } -def _table_report_payload(filters: ReportFilters, entries: list[TimeEntry]) -> dict: +def _table_report_payload( + filters: ReportFilters, + entries: list[TimeEntry], + *, + user_summary: dict | None = None, + user_summaries: list[dict] | None = None, +) -> dict: summary = _summary_from_entries(entries) include_latest_rate = not (filters.is_workspace_scope and not filters.user_id) - return { + payload = { "scope": _scope_payload(filters), "summary": summary, "days": _group_daily(entries, include_latest_rate=include_latest_rate), @@ -482,6 +692,13 @@ def _table_report_payload(filters: ReportFilters, entries: list[TimeEntry]) -> d "projects": _build_breakdown(entries, "projects"), "tags": _build_breakdown(entries, "tags"), } + if filters.is_workspace_scope and not filters.user_id: + payload.update(_build_overall_percentage_payload(entries)) + if user_summary is not None: + payload["user_summary"] = user_summary + if user_summaries is not None: + payload["user_summaries"] = user_summaries + return payload def _group_daily(entries: list[TimeEntry], *, include_latest_rate: bool) -> list[dict]: @@ -621,7 +838,10 @@ def _build_breakdown(entries: list[TimeEntry], kind: str) -> list[dict]: def build_table_report(actor, raw_filters) -> dict: filters = load_report_filters(actor, raw_filters) entries = list(_base_queryset(filters)) - return _table_report_payload(filters, entries) + if filters.is_workspace_scope and not filters.user_id: + return _table_report_payload(filters, entries, user_summaries=_build_user_summaries(entries)) + user_summary = _build_user_summary(entries[0].user, entries) if entries and filters.user_id else None + return _table_report_payload(filters, entries, user_summary=user_summary) def build_user_scoped_table_reports(actor, raw_filters) -> list[dict]: @@ -642,7 +862,13 @@ def build_user_scoped_table_reports(actor, raw_filters) -> list[dict]: reports: list[dict] = [] for user_id, user_entries in sorted_groups: user_filters = replace(filters, user_id=user_id) - reports.append(_table_report_payload(user_filters, user_entries)) + reports.append( + _table_report_payload( + user_filters, + user_entries, + user_summary=_build_user_summary(user_entries[0].user, user_entries), + ) + ) return reports diff --git a/apps/reports/services/export_i18n.py b/apps/reports/services/export_i18n.py index 2845e05..c712a60 100644 --- a/apps/reports/services/export_i18n.py +++ b/apps/reports/services/export_i18n.py @@ -24,6 +24,7 @@ TRANSLATIONS = { "en": { "report_title": "Workspace Report", "overall_sheet": "Overall Report", + "users_summary_sheet": "Users Summary", "workspace": "Workspace", "period": "Period", "from_date": "From date", @@ -38,6 +39,18 @@ TRANSLATIONS = { "non_billable_hours": "Non-billable hours", "hourly_rate": "Hourly rate", "income": "Income", + "working_hours": "Working hours", + "non_working_hours": "Non-working hours", + "hourly_rates": "Hourly rates", + "project_percentages": "Project percentages", + "client_percentages": "Client percentages", + "tag_percentages": "Tag percentages", + "summary_by_user": "Summary by user", + "rate_history": "Hourly rate history", + "from": "From", + "to": "To", + "percentage": "Percentage", + "none": "None", "daily_summary": "Daily Summary", "clients": "Clients", "projects": "Projects", @@ -50,6 +63,7 @@ TRANSLATIONS = { "fa": { "report_title": "گزارش فضای کاری", "overall_sheet": "گزارش کلی", + "users_summary_sheet": "خلاصه کاربران", "workspace": "فضای کاری", "period": "بازه", "from_date": "از تاریخ", @@ -63,7 +77,19 @@ TRANSLATIONS = { "billable_hours": "ساعات کاری", "non_billable_hours": "ساعات غیر کاری", "hourly_rate": "نرخ ساعتی", - "income": "درآمد", + "income": "کارکرد", + "working_hours": "ساعات کاری", + "non_working_hours": "ساعات غیرکاری", + "hourly_rates": "نرخ‌های ساعتی", + "project_percentages": "درصد پروژه‌ها", + "client_percentages": "درصد مشتری‌ها", + "tag_percentages": "درصد تگ‌ها", + "summary_by_user": "خلاصه کاربران", + "rate_history": "تاریخچه نرخ ساعتی", + "from": "از", + "to": "تا", + "percentage": "درصد", + "none": "بدون مورد", "daily_summary": "خلاصه روزانه", "clients": "مشتریان", "projects": "پروژه‌ها", diff --git a/apps/reports/services/exporters.py b/apps/reports/services/exporters.py index d8b4224..17db623 100644 --- a/apps/reports/services/exporters.py +++ b/apps/reports/services/exporters.py @@ -14,7 +14,7 @@ from reportlab.lib.units import mm from reportlab.pdfbase import pdfmetrics from reportlab.pdfbase.ttfonts import TTFont from reportlab.platypus import Image as RLImage -from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle +from reportlab.platypus import PageBreak, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle from apps.reports.services.export_i18n import ExportLocale, safe_sheet_title, user_label @@ -28,6 +28,16 @@ BORDER = Border( ) +class BookmarkDocTemplate(SimpleDocTemplate): + def afterFlowable(self, flowable) -> None: # pragma: no cover - reportlab integration hook + bookmark_name = getattr(flowable, "_bookmark_name", None) + bookmark_title = getattr(flowable, "_bookmark_title", None) + if not bookmark_name or not bookmark_title: + return + self.canv.bookmarkPage(bookmark_name) + self.canv.addOutlineEntry(bookmark_title, bookmark_name, level=0, closed=False) + + def _register_pdf_fonts(locale: ExportLocale) -> None: registered = set(pdfmetrics.getRegisteredFontNames()) if "Vazirmatn" not in registered: @@ -39,7 +49,7 @@ def _register_pdf_fonts(locale: ExportLocale) -> None: def _apply_cell_style(cell, *, bold: bool = False, fill=None, rtl: bool = False) -> None: cell.font = Font(name="Calibri", bold=bold, size=11) cell.border = BORDER - cell.alignment = Alignment(horizontal="right" if rtl else "left", vertical="center") + cell.alignment = Alignment(horizontal="right" if rtl else "left", vertical="center", wrap_text=True) if fill is not None: cell.fill = fill @@ -60,7 +70,35 @@ def _money_label(locale: ExportLocale, income_totals: list[dict]) -> str: def _money_label_excel(locale: ExportLocale, income_totals: list[dict]) -> str: - return locale.format_money_label(income_totals, ascii_digits=True) + value = locale.format_money_label(income_totals, ascii_digits=True) + return f"\u202B{value}\u202C" if locale.is_rtl else value + + +def _rates_label(locale: ExportLocale, rates: list[dict], *, ascii_digits: bool = False) -> str: + if not rates: + return locale.t("none") + items = [ + f"{locale.format_amount(rate['amount'], ascii_digits=ascii_digits)} {locale.currency_label(rate['currency'])}" + for rate in rates + ] + return ", ".join(items) + + +def _percentages_label(locale: ExportLocale, rows: list[dict], *, ascii_digits: bool = False) -> str: + if not rows: + return locale.t("none") + items = [ + f"{locale.format_amount(row['percentage'], ascii_digits=ascii_digits)}% {row['name']}" + for row in rows + ] + return ", ".join(items) + + +def _rate_period_label(locale: ExportLocale, row: dict, *, ascii_digits: bool = False) -> str: + return ( + f"{locale.format_amount(row['amount'], ascii_digits=ascii_digits)} " + f"{locale.currency_label(row['currency'])}" + ) def _rate_label(locale: ExportLocale, rate: dict | None) -> str: @@ -72,7 +110,8 @@ def _rate_label(locale: ExportLocale, rate: dict | None) -> str: def _rate_label_excel(locale: ExportLocale, rate: dict | None) -> str: if not rate: return "-" - return f"{locale.format_amount(rate['amount'], ascii_digits=True)} {locale.currency_label(rate['currency'])}" + value = f"{locale.format_amount(rate['amount'], ascii_digits=True)} {locale.currency_label(rate['currency'])}" + return f"\u202B{value}\u202C" if locale.is_rtl else value def _section_headers(locale: ExportLocale) -> list[str]: @@ -90,18 +129,133 @@ def _rtl_row(locale: ExportLocale, row: list[str]) -> list[str]: return list(reversed(row)) if locale.is_rtl else row +def _excel_table_row(row: list[str]) -> list[str]: + return row + + +def _excel_pair_row(row: list[str]) -> list[str]: + return row + + +def _compact_summary_headers(locale: ExportLocale) -> list[str]: + return _excel_table_row( + [ + locale.t("name"), + locale.t("mobile"), + locale.t("working_hours"), + locale.t("non_working_hours"), + locale.t("income"), + ], + ) + + +def _compact_summary_row(locale: ExportLocale, user_summary: dict) -> list[str]: + return _excel_table_row( + [ + user_summary["user"]["name"], + locale.format_number(user_summary["user"]["mobile"], ascii_digits=True), + locale.format_duration(user_summary["billable_duration"], ascii_digits=True), + locale.format_duration(user_summary["non_billable_duration"], ascii_digits=True), + _money_label_excel(locale, user_summary["income_totals"]), + ], + ) + + +def _append_user_summary_block(worksheet, *, locale: ExportLocale, user_summary: dict) -> None: + worksheet.append([]) + worksheet.append([locale.t("summary_by_user")]) + for row in ( + _excel_pair_row([locale.t("user"), user_summary["user"]["name"]]), + _excel_pair_row( + [locale.t("mobile"), locale.format_number(user_summary["user"]["mobile"], ascii_digits=True)], + ), + _excel_pair_row( + [locale.t("working_hours"), locale.format_duration(user_summary["billable_duration"], ascii_digits=True)], + ), + _excel_pair_row( + [locale.t("non_working_hours"), locale.format_duration(user_summary["non_billable_duration"], ascii_digits=True)], + ), + _excel_pair_row([locale.t("income"), _money_label_excel(locale, user_summary["income_totals"])]), + ): + worksheet.append(row) + + +def _percentage_display(locale: ExportLocale, rows: list[dict], row_data: dict, *, ascii_digits: bool = False) -> str: + row_id = str(row_data.get("id")) if row_data.get("id") is not None else None + row_name = row_data.get("name") + for row in rows: + value = f"{locale.format_amount(row['percentage'], ascii_digits=ascii_digits)}%" + if row_id is not None and str(row["id"]) == row_id: + return f"\u202B{value}\u202C" if locale.is_rtl and ascii_digits else value + if row_name and row["name"] == row_name: + return f"\u202B{value}\u202C" if locale.is_rtl and ascii_digits else value + return "-" + + +def _append_rate_history_table_excel(worksheet, *, locale: ExportLocale, user_summary: dict) -> None: + worksheet.append([]) + _append_merged_heading(worksheet, locale=locale, title=locale.t("rate_history"), span=3) + header_row = worksheet.max_row + 1 + worksheet.append( + _excel_table_row( + [locale.t("hourly_rate"), locale.t("from"), locale.t("to")], + ) + ) + for cell in worksheet[header_row]: + _apply_cell_style(cell, bold=True, fill=HEADER_FILL, rtl=locale.is_rtl) + + rate_periods = user_summary.get("rate_periods") or [] + if not rate_periods: + worksheet.append([locale.t("no_data")]) + _apply_cell_style(worksheet.cell(row=worksheet.max_row, column=1), rtl=locale.is_rtl) + return + + for row in rate_periods: + worksheet.append( + _excel_table_row( + [ + _rate_period_label(locale, row, ascii_digits=True), + locale.format_date(row["from_date"], ascii_digits=True), + locale.format_date(row["to_date"], ascii_digits=True), + ], + ) + ) + for cell in worksheet[worksheet.max_row]: + _apply_cell_style(cell, rtl=locale.is_rtl) + + +def _append_percentage_table_excel(worksheet, *, locale: ExportLocale, title_key: str, rows: list[dict]) -> None: + worksheet.append([]) + _append_merged_heading(worksheet, locale=locale, title=locale.t(title_key), span=2) + header_row = worksheet.max_row + 1 + worksheet.append(_excel_table_row([locale.t("name"), locale.t("percentage")])) + for cell in worksheet[header_row]: + _apply_cell_style(cell, bold=True, fill=HEADER_FILL, rtl=locale.is_rtl) + + if not rows: + worksheet.append([locale.t("no_data")]) + _apply_cell_style(worksheet.cell(row=worksheet.max_row, column=1), rtl=locale.is_rtl) + return + + for row in rows: + worksheet.append( + _excel_table_row([row["name"], f"{locale.format_amount(row['percentage'], ascii_digits=True)}%"]) + ) + for cell in worksheet[worksheet.max_row]: + _apply_cell_style(cell, rtl=locale.is_rtl) + + def _append_meta_block(worksheet, *, locale: ExportLocale, report_data: dict) -> None: scope = report_data["scope"] summary = report_data["summary"] - worksheet.append(_rtl_row(locale, [locale.t("report_title"), scope["workspace"]["name"]])) - worksheet.append(_rtl_row(locale, [locale.t("workspace"), scope["workspace"]["name"]])) - worksheet.append(_rtl_row(locale, [locale.t("period"), locale.period_label(scope["period"])])) - worksheet.append(_rtl_row(locale, [locale.t("from_date"), locale.format_date(scope["from_date"], ascii_digits=True)])) - worksheet.append(_rtl_row(locale, [locale.t("to_date"), locale.format_date(scope["to_date"], ascii_digits=True)])) + worksheet.append(_excel_pair_row([locale.t("report_title"), scope["workspace"]["name"]])) + worksheet.append(_excel_pair_row([locale.t("workspace"), scope["workspace"]["name"]])) + worksheet.append(_excel_pair_row([locale.t("period"), locale.period_label(scope["period"])])) + worksheet.append(_excel_pair_row([locale.t("from_date"), locale.format_date(scope["from_date"], ascii_digits=True)])) + worksheet.append(_excel_pair_row([locale.t("to_date"), locale.format_date(scope["to_date"], ascii_digits=True)])) worksheet.append( - _rtl_row( - locale, + _excel_pair_row( [ locale.t("user"), scope["user"]["name"] if scope.get("user") else locale.t("all_users"), @@ -109,21 +263,20 @@ def _append_meta_block(worksheet, *, locale: ExportLocale, report_data: dict) -> ) ) worksheet.append( - _rtl_row( - locale, + _excel_pair_row( [ locale.t("mobile"), locale.format_number(scope["user"]["mobile"], ascii_digits=True) if scope.get("user") and scope["user"].get("mobile") else "-", ], ) ) - worksheet.append(_rtl_row(locale, [locale.t("generated_at"), locale.format_date(datetime.now().date(), ascii_digits=True)])) + worksheet.append(_excel_pair_row([locale.t("generated_at"), locale.format_date(datetime.now().date(), ascii_digits=True)])) worksheet.append([]) - worksheet.append([locale.t("summary")]) - worksheet.append(_rtl_row(locale, [locale.t("total_hours"), locale.format_duration(summary["total_duration"], ascii_digits=True)])) - worksheet.append(_rtl_row(locale, [locale.t("billable_hours"), locale.format_duration(summary["billable_duration"], ascii_digits=True)])) - worksheet.append(_rtl_row(locale, [locale.t("non_billable_hours"), locale.format_duration(summary["non_billable_duration"], ascii_digits=True)])) - worksheet.append(_rtl_row(locale, [locale.t("income"), _money_label_excel(locale, summary["income_totals"])])) + _append_merged_heading(worksheet, locale=locale, title=locale.t("summary"), span=2) + worksheet.append(_excel_pair_row([locale.t("total_hours"), locale.format_duration(summary["total_duration"], ascii_digits=True)])) + worksheet.append(_excel_pair_row([locale.t("billable_hours"), locale.format_duration(summary["billable_duration"], ascii_digits=True)])) + worksheet.append(_excel_pair_row([locale.t("non_billable_hours"), locale.format_duration(summary["non_billable_duration"], ascii_digits=True)])) + worksheet.append(_excel_pair_row([locale.t("income"), _money_label_excel(locale, summary["income_totals"])])) for row_index in range(1, worksheet.max_row + 1): first_cell = worksheet.cell(row=row_index, column=1) @@ -140,11 +293,10 @@ def _append_meta_block(worksheet, *, locale: ExportLocale, report_data: dict) -> def _append_daily_table(worksheet, *, locale: ExportLocale, report_data: dict) -> None: worksheet.append([]) - worksheet.append([locale.t("daily_summary")]) + _append_merged_heading(worksheet, locale=locale, title=locale.t("daily_summary"), span=6) header_row = worksheet.max_row + 1 worksheet.append( - _rtl_row( - locale, + _excel_table_row( [ locale.t("date"), locale.t("billable_hours"), @@ -165,8 +317,7 @@ def _append_daily_table(worksheet, *, locale: ExportLocale, report_data: dict) - for row in report_data["days"]: worksheet.append( - _rtl_row( - locale, + _excel_table_row( [ locale.format_date(row["date"], ascii_digits=True), locale.format_duration(row["billable_duration"], ascii_digits=True), @@ -181,8 +332,7 @@ def _append_daily_table(worksheet, *, locale: ExportLocale, report_data: dict) - _apply_cell_style(cell, rtl=locale.is_rtl) worksheet.append( - _rtl_row( - locale, + _excel_table_row( [ locale.t("total"), locale.format_duration(report_data["summary"]["billable_duration"], ascii_digits=True), @@ -197,11 +347,32 @@ def _append_daily_table(worksheet, *, locale: ExportLocale, report_data: dict) - _apply_cell_style(cell, bold=True, fill=SECTION_FILL, rtl=locale.is_rtl) -def _append_breakdown_table(worksheet, *, locale: ExportLocale, title_key: str, rows: list[dict]) -> None: +def _append_breakdown_table( + worksheet, + *, + locale: ExportLocale, + title_key: str, + rows: list[dict], + percentages: list[dict] | None = None, +) -> None: worksheet.append([]) - worksheet.append([locale.t(title_key)]) + _append_merged_heading( + worksheet, + locale=locale, + title=locale.t(title_key), + span=6 if percentages is not None else 5, + ) header_row = worksheet.max_row + 1 - worksheet.append(_section_headers(locale)) + headers = [ + locale.t("name"), + locale.t("billable_hours"), + locale.t("non_billable_hours"), + locale.t("total_hours"), + locale.t("income"), + ] + if percentages is not None: + headers.append(locale.t("percentage")) + worksheet.append(_excel_table_row(headers)) for cell in worksheet[header_row]: _apply_cell_style(cell, bold=True, fill=HEADER_FILL, rtl=locale.is_rtl) @@ -211,22 +382,315 @@ def _append_breakdown_table(worksheet, *, locale: ExportLocale, title_key: str, return for row in rows: + values = [ + row["name"], + locale.format_duration(row["billable_duration"], ascii_digits=True), + locale.format_duration(row["non_billable_duration"], ascii_digits=True), + locale.format_duration(row["total_duration"], ascii_digits=True), + _money_label_excel(locale, row["income_totals"]), + ] + if percentages is not None: + values.append(_percentage_display(locale, percentages, row, ascii_digits=True)) worksheet.append( - _rtl_row( - locale, - [ - row["name"], - locale.format_duration(row["billable_duration"], ascii_digits=True), - locale.format_duration(row["non_billable_duration"], ascii_digits=True), - locale.format_duration(row["total_duration"], ascii_digits=True), - _money_label_excel(locale, row["income_totals"]), - ], - ) + _excel_table_row(values) ) for cell in worksheet[worksheet.max_row]: _apply_cell_style(cell, rtl=locale.is_rtl) +def _append_user_details_block_excel( + worksheet, + *, + locale: ExportLocale, + report_data: dict, + leading_blank: bool = True, +) -> None: + user_summary = report_data["user_summary"] + if leading_blank: + worksheet.append([]) + _append_merged_heading(worksheet, locale=locale, title=user_summary["user"]["name"], span=6) + _append_rate_history_table_excel(worksheet, locale=locale, user_summary=user_summary) + _append_breakdown_table( + worksheet, + locale=locale, + title_key="clients", + rows=report_data["clients"], + percentages=user_summary["client_percentages"], + ) + _append_breakdown_table( + worksheet, + locale=locale, + title_key="projects", + rows=report_data["projects"], + percentages=user_summary["project_percentages"], + ) + _append_breakdown_table( + worksheet, + locale=locale, + title_key="tags", + rows=report_data["tags"], + percentages=user_summary["tag_percentages"], + ) + + +def _merge_and_style(worksheet, *, row: int, start_col: int, end_col: int, value: str, rtl: bool) -> None: + worksheet.merge_cells(start_row=row, start_column=start_col, end_row=row, end_column=end_col) + cell = worksheet.cell(row=row, column=start_col) + cell.value = value + _apply_cell_style(cell, bold=True, fill=HEADER_FILL, rtl=rtl) + + +def _append_merged_heading(worksheet, *, locale: ExportLocale, title: str, span: int) -> int: + worksheet.append([title]) + row = worksheet.max_row + if span > 1: + worksheet.merge_cells(start_row=row, start_column=1, end_row=row, end_column=span) + _apply_cell_style(worksheet.cell(row=row, column=1), bold=True, fill=HEADER_FILL, rtl=locale.is_rtl) + return row + + +def _write_table_row( + worksheet, + *, + row: int, + start_col: int, + values: list[str | None], + rtl: bool, + bold: bool = False, + fill=None, +) -> None: + for offset, value in enumerate(values): + cell = worksheet.cell(row=row, column=start_col + offset) + cell.value = value + _apply_cell_style(cell, bold=bold, fill=fill, rtl=rtl) + + +def _user_summary_row_payload(locale: ExportLocale, summary: dict) -> tuple[int, list[list[str | None]]]: + rate_rows = [ + [ + _rate_period_label(locale, row, ascii_digits=True), + f"{locale.format_date(row['from_date'], ascii_digits=True)} - {locale.format_date(row['to_date'], ascii_digits=True)}", + ] + for row in (summary.get("rate_periods") or []) + ] + client_rows = [ + [row["name"], f"\u202B{locale.format_amount(row['percentage'], ascii_digits=True)}%\u202C" if locale.is_rtl else f"{locale.format_amount(row['percentage'], ascii_digits=True)}%"] + for row in (summary.get("client_percentages") or []) + ] + project_rows = [ + [row["name"], f"\u202B{locale.format_amount(row['percentage'], ascii_digits=True)}%\u202C" if locale.is_rtl else f"{locale.format_amount(row['percentage'], ascii_digits=True)}%"] + for row in (summary.get("project_percentages") or []) + ] + tag_rows = [ + [row["name"], f"\u202B{locale.format_amount(row['percentage'], ascii_digits=True)}%\u202C" if locale.is_rtl else f"{locale.format_amount(row['percentage'], ascii_digits=True)}%"] + for row in (summary.get("tag_percentages") or []) + ] + span = max(len(rate_rows), len(client_rows), len(project_rows), len(tag_rows), 1) + rows: list[list[str | None]] = [] + for index in range(span): + rows.append( + [ + summary["user"]["name"] if index == 0 else None, + locale.format_number(summary["user"]["mobile"], ascii_digits=True) if index == 0 else None, + locale.format_duration(summary["billable_duration"], ascii_digits=True) if index == 0 else None, + locale.format_duration(summary["non_billable_duration"], ascii_digits=True) if index == 0 else None, + _money_label_excel(locale, summary["income_totals"]) if index == 0 else None, + *(rate_rows[index] if index < len(rate_rows) else [None, None]), + *(client_rows[index] if index < len(client_rows) else [None, None]), + *(project_rows[index] if index < len(project_rows) else [None, None]), + *(tag_rows[index] if index < len(tag_rows) else [None, None]), + ], + ) + return span, rows + + +def _merge_vertical_if_needed(worksheet, *, start_row: int, span: int, column: int, value_present: bool = True) -> None: + if span > 1 and value_present: + worksheet.merge_cells( + start_row=start_row, + start_column=column, + end_row=start_row + span - 1, + end_column=column, + ) + + +def _render_all_users_overall_excel_sheet( + worksheet, + *, + locale: ExportLocale, + report_data: dict, +) -> None: + if locale.is_rtl: + worksheet.sheet_view.rightToLeft = True + + scope = report_data["scope"] + summary = report_data["summary"] + + top_rows = [ + [locale.t("report_title"), scope["workspace"]["name"]], + [locale.t("period"), locale.period_label(scope["period"])], + [locale.t("from_date"), locale.format_date(scope["from_date"], ascii_digits=True)], + [locale.t("to_date"), locale.format_date(scope["to_date"], ascii_digits=True)], + [locale.t("user"), locale.t("all_users")], + [locale.t("mobile"), "-"], + [locale.t("generated_at"), locale.format_date(datetime.now().date(), ascii_digits=True)], + ] + for row_index, values in enumerate(top_rows, start=1): + _write_table_row( + worksheet, + row=row_index, + start_col=1, + values=values, + rtl=locale.is_rtl, + bold=(row_index == 1), + fill=HEADER_FILL if row_index == 1 else None, + ) + + _merge_and_style(worksheet, row=9, start_col=1, end_col=2, value=locale.t("summary"), rtl=locale.is_rtl) + summary_rows = [ + [locale.t("total_hours"), locale.format_duration(summary["total_duration"], ascii_digits=True)], + [locale.t("billable_hours"), locale.format_duration(summary["billable_duration"], ascii_digits=True)], + [locale.t("non_billable_hours"), locale.format_duration(summary["non_billable_duration"], ascii_digits=True)], + [locale.t("income"), _money_label_excel(locale, summary["income_totals"])], + ] + for row_index, values in enumerate(summary_rows, start=10): + _write_table_row(worksheet, row=row_index, start_col=1, values=values, rtl=locale.is_rtl) + + _merge_and_style( + worksheet, + row=15, + start_col=1, + end_col=13, + value=locale.t("users_summary_sheet"), + rtl=locale.is_rtl, + ) + summary_headers = [ + locale.t("name"), + locale.t("mobile"), + locale.t("working_hours"), + locale.t("non_working_hours"), + locale.t("income"), + locale.t("hourly_rate"), + locale.t("period"), + locale.t("clients"), + locale.t("percentage"), + locale.t("projects"), + locale.t("percentage"), + locale.t("tags"), + locale.t("percentage"), + ] + _write_table_row( + worksheet, + row=16, + start_col=1, + values=summary_headers, + rtl=locale.is_rtl, + bold=True, + fill=HEADER_FILL, + ) + current_row = 17 + for user_summary in report_data["user_summaries"]: + span, rows = _user_summary_row_payload(locale, user_summary) + for offset, values in enumerate(rows): + _write_table_row( + worksheet, + row=current_row + offset, + start_col=1, + values=values, + rtl=locale.is_rtl, + ) + for column in range(1, 6): + _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=column) + rate_rows = user_summary.get("rate_periods") or [] + client_rows = user_summary.get("client_percentages") or [] + project_rows = user_summary.get("project_percentages") or [] + tag_rows = user_summary.get("tag_percentages") or [] + if len(rate_rows) == 1: + _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=6, value_present=True) + _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=7, value_present=True) + if len(client_rows) == 1: + _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=8, value_present=True) + _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=9, value_present=True) + if len(project_rows) == 1: + _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=10, value_present=True) + _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=11, value_present=True) + if len(tag_rows) == 1: + _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=12, value_present=True) + _merge_vertical_if_needed(worksheet, start_row=current_row, span=span, column=13, value_present=True) + current_row += span + + current_row += 2 + for title_key, rows, percentages in ( + ("clients", report_data["clients"], report_data.get("client_percentages")), + ("projects", report_data["projects"], report_data.get("project_percentages")), + ("tags", report_data["tags"], report_data.get("tag_percentages")), + ): + _merge_and_style(worksheet, row=current_row, start_col=1, end_col=6, value=locale.t(title_key), rtl=locale.is_rtl) + current_row += 1 + _write_table_row( + worksheet, + row=current_row, + start_col=1, + values=[ + locale.t("name"), + locale.t("billable_hours"), + locale.t("non_billable_hours"), + locale.t("total_hours"), + locale.t("income"), + locale.t("percentage"), + ], + rtl=locale.is_rtl, + bold=True, + fill=HEADER_FILL, + ) + current_row += 1 + if rows: + for row in rows: + _write_table_row( + worksheet, + row=current_row, + start_col=1, + values=[ + row["name"], + locale.format_duration(row["billable_duration"], ascii_digits=True), + locale.format_duration(row["non_billable_duration"], ascii_digits=True), + locale.format_duration(row["total_duration"], ascii_digits=True), + _money_label_excel(locale, row["income_totals"]), + _percentage_display(locale, percentages or [], row, ascii_digits=True), + ], + rtl=locale.is_rtl, + ) + current_row += 1 + else: + _write_table_row( + worksheet, + row=current_row, + start_col=1, + values=[locale.t("no_data"), None, None, None, None, None], + rtl=locale.is_rtl, + ) + current_row += 1 + current_row += 1 + + overall_widths = { + "A": 31.57, + "B": 19.86, + "C": 18.0, + "D": 17.0, + "E": 24.0, + "F": 17.57, + "G": 32.0, + "H": 30.0, + "I": 14.0, + "J": 32.86, + "K": 12.0, + "L": 22.0, + "M": 12.0, + } + for column, width in overall_widths.items(): + worksheet.column_dimensions[column].width = width + + def _render_excel_sheet(worksheet, *, locale: ExportLocale, report_data: dict) -> None: if locale.is_rtl: worksheet.sheet_view.rightToLeft = True @@ -234,25 +698,81 @@ def _render_excel_sheet(worksheet, *, locale: ExportLocale, report_data: dict) - else: worksheet.freeze_panes = "A4" _append_meta_block(worksheet, locale=locale, report_data=report_data) + if report_data.get("user_summaries"): + worksheet.append([]) + _append_all_users_summary_sheet( + worksheet, + locale=locale, + user_summaries=report_data["user_summaries"], + ) + return + if report_data.get("user_summary"): + _append_rate_history_table_excel(worksheet, locale=locale, user_summary=report_data["user_summary"]) _append_daily_table(worksheet, locale=locale, report_data=report_data) - _append_breakdown_table(worksheet, locale=locale, title_key="clients", rows=report_data["clients"]) - _append_breakdown_table(worksheet, locale=locale, title_key="projects", rows=report_data["projects"]) - _append_breakdown_table(worksheet, locale=locale, title_key="tags", rows=report_data["tags"]) + user_summary = report_data.get("user_summary") + _append_breakdown_table( + worksheet, + locale=locale, + title_key="clients", + rows=report_data["clients"], + percentages=user_summary["client_percentages"] if user_summary else None, + ) + _append_breakdown_table( + worksheet, + locale=locale, + title_key="projects", + rows=report_data["projects"], + percentages=user_summary["project_percentages"] if user_summary else None, + ) + _append_breakdown_table( + worksheet, + locale=locale, + title_key="tags", + rows=report_data["tags"], + percentages=user_summary["tag_percentages"] if user_summary else None, + ) _autosize_columns(worksheet) +def _append_all_users_summary_sheet(worksheet, *, locale: ExportLocale, user_summaries: list[dict]) -> None: + if locale.is_rtl: + worksheet.sheet_view.rightToLeft = True + + title_row = worksheet.max_row + 1 + worksheet.append([locale.t("users_summary_sheet")]) + header_row = worksheet.max_row + 1 + headers = _compact_summary_headers(locale) + worksheet.append(headers) + for cell in worksheet[header_row]: + _apply_cell_style(cell, bold=True, fill=HEADER_FILL, rtl=locale.is_rtl) + + for summary in user_summaries: + worksheet.append(_compact_summary_row(locale, summary)) + for cell in worksheet[worksheet.max_row]: + _apply_cell_style(cell, rtl=locale.is_rtl) + + _apply_cell_style(worksheet.cell(row=title_row, column=1), bold=True, fill=HEADER_FILL, rtl=locale.is_rtl) + + def build_excel_report(*, report_data: dict, locale: ExportLocale, per_user_reports: list[dict] | None = None) -> bytes: workbook = Workbook() - overall_sheet = workbook.active - overall_sheet.title = safe_sheet_title(locale.t("overall_sheet"), []) - _render_excel_sheet(overall_sheet, locale=locale, report_data=report_data) + used_titles: set[str] = set() - used_titles = {overall_sheet.title} - for user_report in per_user_reports or []: - user_title = safe_sheet_title(user_label(user_report["scope"].get("user"), locale, ascii_digits=True), used_titles) - worksheet = workbook.create_sheet(title=user_title) - _render_excel_sheet(worksheet, locale=locale, report_data=user_report) - used_titles.add(user_title) + if report_data.get("user_summaries") and per_user_reports: + overall_sheet = workbook.active + overall_sheet.title = safe_sheet_title(locale.t("overall_sheet"), used_titles) + _render_all_users_overall_excel_sheet(overall_sheet, locale=locale, report_data=report_data) + used_titles.add(overall_sheet.title) + for user_report in per_user_reports: + user_title = safe_sheet_title(user_label(user_report["scope"].get("user"), locale, ascii_digits=True), used_titles) + worksheet = workbook.create_sheet(title=user_title) + _render_excel_sheet(worksheet, locale=locale, report_data=user_report) + used_titles.add(user_title) + else: + overall_sheet = workbook.active + overall_sheet.title = safe_sheet_title(locale.t("overall_sheet"), used_titles) + _render_excel_sheet(overall_sheet, locale=locale, report_data=report_data) + used_titles.add(overall_sheet.title) buffer = io.BytesIO() workbook.save(buffer) @@ -263,6 +783,13 @@ def _paragraph(text: str, style: ParagraphStyle, locale: ExportLocale) -> Paragr return Paragraph(locale.shape(text), style) +def _bookmark_paragraph(text: str, style: ParagraphStyle, locale: ExportLocale, bookmark_name: str) -> Paragraph: + paragraph = _paragraph(text, style, locale) + paragraph._bookmark_name = bookmark_name + paragraph._bookmark_title = text + return paragraph + + def _workspace_initial(name: str) -> str: stripped = (name or "").strip() return stripped[0].upper() if stripped else "W" @@ -313,7 +840,158 @@ def _report_table_rows(locale: ExportLocale, rows: list[dict], *, is_daily: bool ] -def build_pdf_report(*, report_data: dict, locale: ExportLocale) -> bytes: +def _build_pdf_user_summary_table(locale: ExportLocale, summary: dict, doc_width: float) -> Table: + summary_data = [ + [locale.t("user"), summary["user"]["name"]], + [locale.t("mobile"), locale.format_number(summary["user"]["mobile"])], + [locale.t("working_hours"), locale.format_duration(summary["billable_duration"])], + [locale.t("non_working_hours"), locale.format_duration(summary["non_billable_duration"])], + [locale.t("income"), _money_label(locale, summary["income_totals"])], + ] + if locale.is_rtl: + summary_data = [_rtl_row(locale, row) for row in summary_data] + summary_data = [[locale.shape(cell) for cell in row] for row in summary_data] + table = Table(summary_data, colWidths=[doc_width * 0.3, doc_width * 0.7]) + table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#EFF6FF")), + ("BOX", (0, 0), (-1, -1), 0.5, colors.HexColor("#BFDBFE")), + ("INNERGRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#BFDBFE")), + ("FONTNAME", (0, 0), (0, -1), "Vazirmatn-Bold" if locale.language == "fa" else "Helvetica-Bold"), + ("FONTNAME", (1, 0), (1, -1), "Vazirmatn" if locale.language == "fa" else "Helvetica"), + ("ALIGN", (0, 0), (-1, -1), "RIGHT" if locale.is_rtl else "LEFT"), + ("TOPPADDING", (0, 0), (-1, -1), 6), + ("BOTTOMPADDING", (0, 0), (-1, -1), 6), + ] + ) + ) + return table + + +def _build_pdf_rate_history_table(locale: ExportLocale, summary: dict, doc_width: float) -> Table: + rows = summary.get("rate_periods") or [] + data = [ + _rtl_row(locale, [locale.t("hourly_rate"), locale.t("from"), locale.t("to")]), + *( + _rtl_row( + locale, + [ + _rate_period_label(locale, row), + locale.format_date(row["from_date"]), + locale.format_date(row["to_date"]), + ], + ) + for row in rows + ), + ] + if not rows: + data.append(_rtl_row(locale, [locale.t("no_data"), "", ""])) + return _styled_table(data, locale=locale, column_widths=[doc_width * 0.34, doc_width * 0.33, doc_width * 0.33]) + + +def _build_pdf_percentage_table(locale: ExportLocale, rows: list[dict], doc_width: float) -> Table: + data = [ + _rtl_row(locale, [locale.t("name"), locale.t("percentage")]), + *( + _rtl_row( + locale, + [row["name"], f"{locale.format_amount(row['percentage'])}%"], + ) + for row in rows + ), + ] + if not rows: + data.append(_rtl_row(locale, [locale.t("no_data"), ""])) + return _styled_table(data, locale=locale, column_widths=[doc_width * 0.7, doc_width * 0.3]) + + +def _append_pdf_report_sections( + *, + story: list, + locale: ExportLocale, + report_data: dict, + doc_width: float, + section_style: ParagraphStyle, + user_summary: dict | None = None, +) -> None: + sections = [ + ("daily_summary", report_data["days"], True), + ("clients", report_data["clients"], False), + ("projects", report_data["projects"], False), + ("tags", report_data["tags"], False), + ] + for title_key, rows, is_daily in sections: + story.append(_paragraph(locale.t(title_key), section_style, locale)) + story.append(Spacer(1, 2 * mm)) + header_values = [ + locale.t("date") if is_daily else locale.t("name"), + locale.t("billable_hours"), + locale.t("non_billable_hours"), + locale.t("total_hours"), + *([locale.t("hourly_rate")] if is_daily else []), + locale.t("income"), + ] + percentage_rows = None + if user_summary and not is_daily: + percentage_rows = user_summary[f"{title_key[:-1]}_percentages"] if title_key != "clients" else user_summary["client_percentages"] + header_values.append(locale.t("percentage")) + header = _rtl_row(locale, header_values) + body_rows = _report_table_rows(locale, rows, is_daily=is_daily) + if percentage_rows is not None: + body_rows = [ + _rtl_row( + locale, + [ + row["name"], + locale.format_duration(row["billable_duration"]), + locale.format_duration(row["non_billable_duration"]), + locale.format_duration(row["total_duration"]), + _money_label(locale, row["income_totals"]), + _percentage_display(locale, percentage_rows, row), + ], + ) + for row in rows + ] or [_rtl_row(locale, [locale.t("no_data"), "", "", "", "", ""])] + table = _styled_table( + [header, *body_rows], + locale=locale, + column_widths=( + [ + doc_width * 0.21, + doc_width * 0.13, + doc_width * 0.15, + doc_width * 0.13, + doc_width * 0.16, + doc_width * 0.22, + ] + if is_daily + else [ + *( + [ + doc_width * 0.24, + doc_width * 0.13, + doc_width * 0.15, + doc_width * 0.12, + doc_width * 0.2, + doc_width * 0.16, + ] + if percentage_rows is not None + else [ + doc_width * 0.26, + doc_width * 0.15, + doc_width * 0.17, + doc_width * 0.14, + doc_width * 0.28, + ] + ) + ] + ), + ) + story.extend([table, Spacer(1, 5 * mm)]) + + +def build_pdf_report(*, report_data: dict, locale: ExportLocale, per_user_reports: list[dict] | None = None) -> bytes: _register_pdf_fonts(locale) font_regular = "Vazirmatn" font_bold = "Vazirmatn-Bold" @@ -337,18 +1015,8 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale) -> bytes: alignment=2 if locale.is_rtl else 0, textColor=colors.HexColor("#0F172A"), ) - body_style = ParagraphStyle( - "ReportBody", - parent=styles["BodyText"], - fontName=font_regular, - fontSize=10, - leading=14, - alignment=2 if locale.is_rtl else 0, - textColor=colors.HexColor("#334155"), - ) - buffer = io.BytesIO() - doc = SimpleDocTemplate( + doc = BookmarkDocTemplate( buffer, pagesize=landscape(A4), leftMargin=14 * mm, @@ -444,49 +1112,85 @@ def build_pdf_report(*, report_data: dict, locale: ExportLocale) -> bytes: ) story.extend([summary_table, Spacer(1, 6 * mm)]) - sections = [ - ("daily_summary", report_data["days"], True), - ("clients", report_data["clients"], False), - ("projects", report_data["projects"], False), - ("tags", report_data["tags"], False), - ] - for title_key, rows, is_daily in sections: - story.append(_paragraph(locale.t(title_key), section_style, locale)) + if report_data.get("user_summaries") and per_user_reports: + story.append(_bookmark_paragraph(locale.t("summary_by_user"), section_style, locale, "users-summary")) story.append(Spacer(1, 2 * mm)) - header = _rtl_row( + user_summary_header = _rtl_row( locale, [ - locale.t("date") if is_daily else locale.t("name"), - locale.t("billable_hours"), - locale.t("non_billable_hours"), - locale.t("total_hours"), - *( [locale.t("hourly_rate")] if is_daily else [] ), + locale.t("name"), + locale.t("mobile"), + locale.t("working_hours"), + locale.t("non_working_hours"), locale.t("income"), ], ) - table = _styled_table( - [header, *_report_table_rows(locale, rows, is_daily=is_daily)], - locale=locale, - column_widths=( + user_summary_rows = [ + _rtl_row( + locale, [ - doc.width * 0.21, - doc.width * 0.13, - doc.width * 0.15, - doc.width * 0.13, - doc.width * 0.16, + summary["user"]["name"], + locale.format_number(summary["user"]["mobile"]), + locale.format_duration(summary["billable_duration"]), + locale.format_duration(summary["non_billable_duration"]), + _money_label(locale, summary["income_totals"]), + ], + ) + for summary in report_data["user_summaries"] + ] + story.append( + _styled_table( + [user_summary_header, *user_summary_rows], + locale=locale, + column_widths=[ + doc.width * 0.24, + doc.width * 0.18, + doc.width * 0.18, + doc.width * 0.18, doc.width * 0.22, - ] - if is_daily - else [ - doc.width * 0.26, - doc.width * 0.15, - doc.width * 0.17, - doc.width * 0.14, - doc.width * 0.28, - ] - ), + ], + ) + ) + story.append(Spacer(1, 5 * mm)) + + for index, user_report in enumerate(per_user_reports): + story.append(PageBreak()) + scope_user = user_report["scope"].get("user") + bookmark_suffix = (scope_user.get("id") or scope_user.get("mobile")) if scope_user else index + bookmark_name = f"user-report-{bookmark_suffix}" + story.append( + _bookmark_paragraph( + user_label(user_report["scope"].get("user"), locale), + section_style, + locale, + bookmark_name, + ) + ) + story.append(Spacer(1, 2 * mm)) + if user_report.get("user_summary"): + story.append(_build_pdf_user_summary_table(locale, user_report["user_summary"], doc.width)) + story.append(Spacer(1, 5 * mm)) + story.append(_paragraph(locale.t("rate_history"), section_style, locale)) + story.append(Spacer(1, 2 * mm)) + story.append(_build_pdf_rate_history_table(locale, user_report["user_summary"], doc.width)) + story.append(Spacer(1, 5 * mm)) + _append_pdf_report_sections( + story=story, + locale=locale, + report_data=user_report, + doc_width=doc.width, + section_style=section_style, + user_summary=user_report.get("user_summary"), + ) + else: + _append_pdf_report_sections( + story=story, + locale=locale, + report_data=report_data, + doc_width=doc.width, + section_style=section_style, + user_summary=report_data.get("user_summary"), ) - story.extend([table, Spacer(1, 5 * mm)]) doc.build(story) return buffer.getvalue() diff --git a/apps/reports/tasks.py b/apps/reports/tasks.py index d3ac32f..ff1ddb9 100644 --- a/apps/reports/tasks.py +++ b/apps/reports/tasks.py @@ -30,13 +30,13 @@ def generate_report_export_task(job_id: str): try: locale = build_export_locale(job.filters.get("language")) report_data = build_table_report(job.requesting_user, job.filters) + per_user_reports = build_user_scoped_table_reports(job.requesting_user, job.filters) if job.export_type == ReportExportJob.ExportType.EXCEL: - per_user_reports = build_user_scoped_table_reports(job.requesting_user, job.filters) content = build_excel_report(report_data=report_data, locale=locale, per_user_reports=per_user_reports) suffix = "xlsx" mime_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" else: - content = build_pdf_report(report_data=report_data, locale=locale) + content = build_pdf_report(report_data=report_data, locale=locale, per_user_reports=per_user_reports) suffix = "pdf" mime_type = "application/pdf" diff --git a/apps/reports/tests/test_exporters.py b/apps/reports/tests/test_exporters.py index aff24f1..4f26aa7 100644 --- a/apps/reports/tests/test_exporters.py +++ b/apps/reports/tests/test_exporters.py @@ -46,15 +46,58 @@ def make_report_data(*, user_name="Owner User", mobile="09129990001", hourly_rat } +def make_user_summary(*, name: str, mobile: str): + return { + "user": {"id": mobile, "name": name, "mobile": mobile}, + "hourly_rates": [{"amount": "15.00", "currency": "USD"}], + "rate_periods": [ + { + "amount": "15.00", + "currency": "USD", + "from_date": "2026-04-01", + "to_date": "2026-04-30", + } + ], + "total_seconds": 7200, + "total_duration": "02:00:00", + "billable_seconds": 7200, + "billable_duration": "02:00:00", + "non_billable_seconds": 0, + "non_billable_duration": "00:00:00", + "income_totals": [{"amount": "30.00", "currency": "USD"}], + "project_percentages": [{"id": "1", "name": "Website", "percentage": "100"}], + "client_percentages": [{"id": "1", "name": "Acme", "percentage": "100"}], + "tag_percentages": [{"id": "1", "name": "Design", "percentage": "100"}], + } + + class ReportExporterTests(TestCase): def test_excel_export_adds_per_user_sheets_and_daily_rate_column(self): locale = build_export_locale("en") report_data = make_report_data( hourly_rate={"amount": "15.00", "currency": "USD"}, ) + report_data["user_summaries"] = [ + make_user_summary(name="Owner User", mobile="09129990001"), + make_user_summary(name="Team Mate", mobile="09129990002"), + ] per_user_reports = [ - make_report_data(user_name="Owner User", mobile="09129990001"), - make_report_data(user_name="Team Mate", mobile="09129990002"), + { + **make_report_data( + user_name="Owner User", + mobile="09129990001", + hourly_rate={"amount": "15.00", "currency": "USD"}, + ), + "user_summary": make_user_summary(name="Owner User", mobile="09129990001"), + }, + { + **make_report_data( + user_name="Team Mate", + mobile="09129990002", + hourly_rate={"amount": "15.00", "currency": "USD"}, + ), + "user_summary": make_user_summary(name="Team Mate", mobile="09129990002"), + }, ] workbook = load_workbook( @@ -71,13 +114,38 @@ class ReportExporterTests(TestCase): self.assertIn("Owner User", workbook.sheetnames[1]) self.assertIn("Team Mate", workbook.sheetnames[2]) - worksheet = workbook.active - values = list(worksheet.iter_rows(values_only=True)) + summary_sheet = workbook[workbook.sheetnames[0]] + summary_values = list(summary_sheet.iter_rows(values_only=True)) - self.assertTrue(any(row[:2] == ("User", "Owner User") for row in values if row)) - self.assertTrue(any(row[:2] == ("Mobile", "09129990001") for row in values if row)) + self.assertEqual(summary_sheet["A1"].value, "Workspace Report") + self.assertEqual(summary_sheet["B1"].value, "Exports") + self.assertEqual(summary_sheet["A15"].value, "Users Summary") + self.assertIn("A15:M15", {str(item) for item in summary_sheet.merged_cells.ranges}) + self.assertEqual( + tuple(summary_sheet.iter_rows(min_row=16, max_row=16, values_only=True))[0][:13], + ( + "Name", + "Mobile", + "Working hours", + "Non-working hours", + "Income", + "Hourly rate", + "Period", + "Clients", + "Percentage", + "Projects", + "Percentage", + "Tags", + "Percentage", + ), + ) + self.assertTrue(any(row and "Owner User" in row for row in summary_values)) + self.assertTrue(any(row and "09129990001" in row for row in summary_values)) - daily_header = next(row[:6] for row in values if row and row[0] == "Date") + user_sheet = workbook[workbook.sheetnames[1]] + user_values = list(user_sheet.iter_rows(values_only=True)) + + daily_header = next(row[:6] for row in user_values if row and "Date" in row) self.assertEqual( daily_header, ( @@ -90,7 +158,7 @@ class ReportExporterTests(TestCase): ), ) - daily_row = next(row[:6] for row in values if row and row[0] == "2026/04/12") + daily_row = next(row[:6] for row in user_values if row and "2026/04/12" in row) self.assertEqual(daily_row[4], "15 USD") def test_pdf_export_supports_persian_locale(self): @@ -98,7 +166,11 @@ class ReportExporterTests(TestCase): report_data = make_report_data( hourly_rate={"amount": "15.00", "currency": "USD"}, ) + report_data["user_summaries"] = [make_user_summary(name="Owner User", mobile="09129990001")] + per_user_reports = [ + {**make_report_data(user_name="Owner User", mobile="09129990001"), "user_summary": make_user_summary(name="Owner User", mobile="09129990001")} + ] - content = build_pdf_report(report_data=report_data, locale=locale) + content = build_pdf_report(report_data=report_data, locale=locale, per_user_reports=per_user_reports) self.assertEqual(content[:4], b"%PDF") diff --git a/apps/reports/tests/test_views.py b/apps/reports/tests/test_views.py index 07e8d8f..fa74740 100644 --- a/apps/reports/tests/test_views.py +++ b/apps/reports/tests/test_views.py @@ -138,8 +138,18 @@ class ReportViewTests(APITestCase): 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"], []) + self.assertEqual(member_summary["client_percentages"], []) + self.assertEqual(member_summary["tag_percentages"], []) def test_daily_rate_uses_latest_billable_entry_snapshot(self): self.client.force_authenticate(user=self.owner) diff --git a/apps/time_entries/api/serializers.py b/apps/time_entries/api/serializers.py index b5923de..4e526da 100644 --- a/apps/time_entries/api/serializers.py +++ b/apps/time_entries/api/serializers.py @@ -3,6 +3,7 @@ from rest_framework import serializers from core.serializers.base import BaseModelSerializer from apps.time_entries.models import TimeEntry from apps.projects.models import Project +from apps.projects.services.access import ensure_project_access from apps.tags.models import Tag @@ -99,6 +100,8 @@ class TimeEntryCreateSerializer(serializers.Serializer): is_billable = serializers.BooleanField(default=False) def validate(self, attrs): + user = self.context.get("request").user if self.context.get("request") else None + workspace_id = attrs.get("workspace_id") project_id = attrs.pop("project_id", serializers.empty) if project_id is not serializers.empty: if project_id is None: @@ -107,6 +110,10 @@ class TimeEntryCreateSerializer(serializers.Serializer): project = Project.objects.filter(id=project_id).first() if not project: raise serializers.ValidationError({"project_id": "Selected project is unavailable."}) + if workspace_id and str(project.workspace_id) != str(workspace_id): + raise serializers.ValidationError({"project_id": "Selected project is unavailable."}) + if user: + ensure_project_access(user, project) attrs["project"] = project tag_ids = attrs.pop("tags", serializers.empty) @@ -134,6 +141,7 @@ class TimeEntryUpdateSerializer(serializers.Serializer): def validate(self, attrs): entry = self.instance + user = self.context.get("request").user if self.context.get("request") else None project_id = attrs.pop("project_id", serializers.empty) if project_id is not serializers.empty: @@ -146,6 +154,10 @@ class TimeEntryUpdateSerializer(serializers.Serializer): project = Project.objects.filter(id=project_id).first() if not project: raise serializers.ValidationError({"project_id": "Selected project is unavailable."}) + if entry and str(project.workspace_id) != str(entry.workspace_id): + raise serializers.ValidationError({"project_id": "Selected project is unavailable."}) + if user: + ensure_project_access(user, project) attrs["project"] = project tag_ids = attrs.pop("tags", serializers.empty) diff --git a/apps/time_entries/services/time_entries.py b/apps/time_entries/services/time_entries.py index 2da66ba..fa6c4c2 100644 --- a/apps/time_entries/services/time_entries.py +++ b/apps/time_entries/services/time_entries.py @@ -1,7 +1,8 @@ -from django.utils import timezone -from rest_framework.exceptions import ValidationError, PermissionDenied - +from django.utils import timezone +from rest_framework.exceptions import ValidationError, PermissionDenied + +from apps.projects.services.access import user_has_project_access from apps.time_entries.models import TimeEntry from apps.time_entries.services.rates import resolve_rate from apps.workspaces.models import Workspace @@ -40,8 +41,10 @@ def create_time_entry(user, workspace_id, start_time, end_time=None, project=Non if start_time and end_time and start_time >= end_time: raise ValidationError({"end_time": "End time must be strictly after start time."}) - if project and project.workspace_id != workspace_id: - raise ValidationError({"project": "Project must belong to the same workspace."}) + if project and project.workspace_id != workspace_id: + raise ValidationError({"project": "Project must belong to the same workspace."}) + if project and not user_has_project_access(user, project): + raise ValidationError({"project_id": "Selected project is unavailable."}) duration = (end_time - start_time) if end_time else None @@ -76,9 +79,11 @@ def update_time_entry(entry, **kwargs): Updates an existing time entry, recalculating duration and rates if necessary. """ # Verify Project Workspace if changing - project = kwargs.get("project", entry.project) - if project and project.workspace_id != entry.workspace_id: - raise ValidationError({"project": "Project must belong to the same workspace."}) + project = kwargs.get("project", entry.project) + if project and project.workspace_id != entry.workspace_id: + raise ValidationError({"project": "Project must belong to the same workspace."}) + if project and not user_has_project_access(entry.user, project): + raise ValidationError({"project_id": "Selected project is unavailable."}) start_time = kwargs.get("start_time", entry.start_time) end_time = kwargs.get("end_time", entry.end_time) diff --git a/apps/time_entries/tests/test_views.py b/apps/time_entries/tests/test_views.py index c7c1b4a..a4782a6 100644 --- a/apps/time_entries/tests/test_views.py +++ b/apps/time_entries/tests/test_views.py @@ -3,10 +3,11 @@ from datetime import datetime from django.utils import timezone from rest_framework.test import APITestCase +from apps.projects.models import Project, ProjectAccess 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 +from apps.workspaces.models import Workspace, WorkspaceMembership def make_aware(year, month, day, hour=9, minute=0, second=0): @@ -139,3 +140,62 @@ class TimeEntryViewTests(APITestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.data["tags"], []) + + def test_member_cannot_create_time_entry_for_inaccessible_project(self): + owner = User.objects.create_user(mobile="09120000001", password="secret123") + member = User.objects.create_user(mobile="09120000002", password="secret123") + workspace = Workspace.objects.create(name="Core", owner=owner) + WorkspaceMembership.objects.create( + workspace=workspace, + user=member, + role=WorkspaceMembership.Role.MEMBER, + is_active=True, + ) + project = Project.objects.create(workspace=workspace, name="Restricted") + + self.client.force_authenticate(user=member) + response = self.client.post( + "/api/time-entries/", + { + "workspace_id": str(workspace.id), + "project_id": str(project.id), + "description": "Blocked", + "start_time": make_aware(2026, 4, 24, 9, 0, 0).isoformat(), + "end_time": make_aware(2026, 4, 24, 10, 0, 0).isoformat(), + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertTrue( + any("Selected project is unavailable." in item["message"] for item in response.data["messages"]) + ) + + def test_member_can_create_time_entry_after_project_access_is_granted(self): + owner = User.objects.create_user(mobile="09120000011", password="secret123") + member = User.objects.create_user(mobile="09120000012", password="secret123") + workspace = Workspace.objects.create(name="Core", owner=owner) + WorkspaceMembership.objects.create( + workspace=workspace, + user=member, + role=WorkspaceMembership.Role.MEMBER, + is_active=True, + ) + project = Project.objects.create(workspace=workspace, name="Accessible") + ProjectAccess.objects.create(project=project, user=member) + + self.client.force_authenticate(user=member) + response = self.client.post( + "/api/time-entries/", + { + "workspace_id": str(workspace.id), + "project_id": str(project.id), + "description": "Allowed", + "start_time": make_aware(2026, 4, 24, 9, 0, 0).isoformat(), + "end_time": make_aware(2026, 4, 24, 10, 0, 0).isoformat(), + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.data["project"], str(project.id)) diff --git a/apps/workspaces/services/permissions.py b/apps/workspaces/services/permissions.py index 1327ee6..fc0d76f 100644 --- a/apps/workspaces/services/permissions.py +++ b/apps/workspaces/services/permissions.py @@ -92,6 +92,7 @@ WORKSPACE_ROLE_CAPABILITIES = { TAGS_VIEW, PROJECTS_VIEW, TIME_ENTRIES_VIEW_OWN, + TIME_ENTRIES_MANAGE_OWN, }, }