diff --git a/apps/reports/__init__.py b/apps/reports/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/reports/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/reports/api/__init__.py b/apps/reports/api/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/reports/api/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/reports/api/serializers.py b/apps/reports/api/serializers.py new file mode 100644 index 0000000..10d37b0 --- /dev/null +++ b/apps/reports/api/serializers.py @@ -0,0 +1,39 @@ +from rest_framework import serializers + +from apps.reports.models import ReportExportJob +from apps.reports.services.aggregation import ALLOWED_PERIODS + + +class ReportExportCreateSerializer(serializers.Serializer): + workspace = serializers.UUIDField() + period = serializers.ChoiceField(choices=sorted(ALLOWED_PERIODS)) + from_date = serializers.DateField(required=False, allow_null=True) + to_date = serializers.DateField(required=False, allow_null=True) + user = serializers.UUIDField(required=False, allow_null=True) + client = serializers.UUIDField(required=False, allow_null=True) + project = serializers.UUIDField(required=False, allow_null=True) + tags = serializers.ListField( + child=serializers.UUIDField(), + required=False, + allow_empty=True, + ) + language = serializers.ChoiceField(choices=("en", "fa"), required=False, default="en") + export_type = serializers.ChoiceField(choices=ReportExportJob.ExportType.choices) + + +class ReportExportJobSerializer(serializers.ModelSerializer): + class Meta: + model = ReportExportJob + fields = ( + "id", + "workspace", + "export_type", + "status", + "filters", + "file_name", + "error_message", + "expires_at", + "completed_at", + "created_at", + ) + read_only_fields = fields diff --git a/apps/reports/api/urls.py b/apps/reports/api/urls.py new file mode 100644 index 0000000..0972113 --- /dev/null +++ b/apps/reports/api/urls.py @@ -0,0 +1,20 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from apps.reports.api.views import ( + ReportChartView, + ReportDayDetailsView, + ReportExportJobViewSet, + ReportTableView, +) + +router = DefaultRouter() +router.register(r"exports", ReportExportJobViewSet, basename="report-export-job") + +urlpatterns = [ + path("chart/", ReportChartView.as_view(), name="report-chart"), + path("table/", ReportTableView.as_view(), name="report-table"), + path("day-details/", ReportDayDetailsView.as_view(), name="report-day-details"), + path("", include(router.urls)), +] + diff --git a/apps/reports/api/views.py b/apps/reports/api/views.py new file mode 100644 index 0000000..37cc501 --- /dev/null +++ b/apps/reports/api/views.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +from django.conf import settings +from django.http import FileResponse, Http404 +from django.urls import reverse +from django.utils import timezone +from drf_spectacular.utils import extend_schema +from rest_framework import mixins, status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.reports.api.serializers import ( + ReportExportCreateSerializer, + ReportExportJobSerializer, +) +from apps.reports.models import ReportExportJob +from apps.reports.services import ( + build_chart_report, + build_day_details_report, + build_table_report, + load_report_filters, +) +from apps.reports.tasks import generate_report_export_task + + +class ReportChartView(APIView): + permission_classes = [IsAuthenticated] + + @extend_schema(responses=dict) + def get(self, request): + return Response(build_chart_report(request.user, request.query_params)) + + +class ReportTableView(APIView): + permission_classes = [IsAuthenticated] + + @extend_schema(responses=dict) + def get(self, request): + return Response(build_table_report(request.user, request.query_params)) + + +class ReportDayDetailsView(APIView): + permission_classes = [IsAuthenticated] + + @extend_schema(responses=dict) + def get(self, request): + return Response(build_day_details_report(request.user, request.query_params)) + + +class ReportExportJobViewSet( + mixins.CreateModelMixin, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + viewsets.GenericViewSet, +): + permission_classes = [IsAuthenticated] + serializer_class = ReportExportJobSerializer + + def get_queryset(self): + return ReportExportJob.objects.filter(requesting_user=self.request.user, is_deleted=False) + + @extend_schema(request=ReportExportCreateSerializer, responses=ReportExportJobSerializer) + def create(self, request, *args, **kwargs): + serializer = ReportExportCreateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + filters = load_report_filters(request.user, serializer.validated_data) + + job = ReportExportJob.objects.create( + requesting_user=request.user, + workspace=filters.workspace, + export_type=serializer.validated_data["export_type"], + filters={ + "workspace": str(filters.workspace.id), + "period": filters.period, + "from_date": filters.from_date.isoformat(), + "to_date": filters.to_date.isoformat(), + "user": filters.user_id, + "client": filters.client_id, + "project": filters.project_id, + "tags": filters.tag_ids, + "language": serializer.validated_data.get("language", "en"), + }, + status=ReportExportJob.Status.PENDING, + ) + generate_report_export_task.delay(str(job.id)) + output = ReportExportJobSerializer(job) + return Response(output.data, status=status.HTTP_202_ACCEPTED) + + @action(detail=True, methods=["get"], url_path="download") + def download(self, request, pk=None): + job = self.get_object() + if job.status != ReportExportJob.Status.COMPLETED or not job.file: + raise Http404("Export file is not available.") + if job.expires_at and job.expires_at <= timezone.now(): + raise Http404("Export file has expired.") + response = FileResponse( + job.file.open("rb"), + as_attachment=True, + filename=job.file_name or job.file.name.split("/")[-1], + ) + return response + + +def build_export_action_url(job: ReportExportJob) -> str: + path = reverse("report-export-job-download", kwargs={"pk": job.id}) + if settings.BASE_URL: + return f"{settings.BASE_URL.rstrip('/')}{path}" + return path diff --git a/apps/reports/apps.py b/apps/reports/apps.py new file mode 100644 index 0000000..2168c4f --- /dev/null +++ b/apps/reports/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ReportsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.reports" + diff --git a/apps/reports/assets/fonts/Vazirmatn-Bold.ttf b/apps/reports/assets/fonts/Vazirmatn-Bold.ttf new file mode 100644 index 0000000..efa9b09 Binary files /dev/null and b/apps/reports/assets/fonts/Vazirmatn-Bold.ttf differ diff --git a/apps/reports/assets/fonts/Vazirmatn-Regular.ttf b/apps/reports/assets/fonts/Vazirmatn-Regular.ttf new file mode 100644 index 0000000..64e4a81 Binary files /dev/null and b/apps/reports/assets/fonts/Vazirmatn-Regular.ttf differ diff --git a/apps/reports/migrations/0001_initial.py b/apps/reports/migrations/0001_initial.py new file mode 100644 index 0000000..db7a79e --- /dev/null +++ b/apps/reports/migrations/0001_initial.py @@ -0,0 +1,47 @@ +# Generated by Django 5.2.12 on 2026-04-26 19:23 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('workspaces', '0005_remove_priceunit_priceunit_id_idx_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ReportExportJob', + 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)), + ('export_type', models.CharField(choices=[('excel', 'Excel'), ('pdf', 'PDF')], max_length=16)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('processing', 'Processing'), ('completed', 'Completed'), ('failed', 'Failed'), ('expired', 'Expired')], default='pending', max_length=16)), + ('filters', models.JSONField(default=dict)), + ('file', models.FileField(blank=True, null=True, upload_to='reports/exports/')), + ('file_name', models.CharField(blank=True, max_length=255)), + ('error_message', models.TextField(blank=True)), + ('expires_at', models.DateTimeField(blank=True, null=True)), + ('completed_at', models.DateTimeField(blank=True, null=True)), + ('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)), + ('requesting_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='report_export_jobs', to=settings.AUTH_USER_MODEL)), + ('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)), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='report_export_jobs', to='workspaces.workspace')), + ], + options={ + 'db_table': 'report_export_job', + 'ordering': ('-created_at',), + 'indexes': [models.Index(fields=['requesting_user'], name='report_export_user_idx'), models.Index(fields=['workspace'], name='report_export_workspace_idx'), models.Index(fields=['status'], name='report_export_status_idx'), models.Index(fields=['expires_at'], name='report_export_expires_idx')], + }, + ), + ] diff --git a/apps/reports/migrations/__init__.py b/apps/reports/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/reports/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/reports/models.py b/apps/reports/models.py new file mode 100644 index 0000000..863ef25 --- /dev/null +++ b/apps/reports/models.py @@ -0,0 +1,83 @@ +from django.conf import settings +from django.db import models +from django.utils import timezone + +from core.models.base import BaseModel + + +class ReportExportJob(BaseModel): + class ExportType(models.TextChoices): + EXCEL = "excel", "Excel" + PDF = "pdf", "PDF" + + class Status(models.TextChoices): + PENDING = "pending", "Pending" + PROCESSING = "processing", "Processing" + COMPLETED = "completed", "Completed" + FAILED = "failed", "Failed" + EXPIRED = "expired", "Expired" + + requesting_user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="report_export_jobs", + ) + workspace = models.ForeignKey( + "workspaces.Workspace", + on_delete=models.CASCADE, + related_name="report_export_jobs", + ) + export_type = models.CharField(max_length=16, choices=ExportType.choices) + status = models.CharField( + max_length=16, + choices=Status.choices, + default=Status.PENDING, + ) + filters = models.JSONField(default=dict) + file = models.FileField(upload_to="reports/exports/", blank=True, null=True) + file_name = models.CharField(max_length=255, blank=True) + error_message = models.TextField(blank=True) + expires_at = models.DateTimeField(null=True, blank=True) + completed_at = models.DateTimeField(null=True, blank=True) + + class Meta: + db_table = "report_export_job" + ordering = ("-created_at",) + indexes = [ + models.Index(fields=["requesting_user"], name="report_export_user_idx"), + models.Index(fields=["workspace"], name="report_export_workspace_idx"), + models.Index(fields=["status"], name="report_export_status_idx"), + models.Index(fields=["expires_at"], name="report_export_expires_idx"), + ] + + def mark_processing(self): + self.status = self.Status.PROCESSING + self.error_message = "" + self.save(update_fields=["status", "error_message", "updated_at"]) + + def mark_completed(self, *, file_name: str): + self.status = self.Status.COMPLETED + self.file_name = file_name + self.completed_at = timezone.now() + self.expires_at = self.completed_at + timezone.timedelta(days=7) + self.error_message = "" + self.save( + update_fields=[ + "status", + "file_name", + "completed_at", + "expires_at", + "error_message", + "updated_at", + ] + ) + + def mark_failed(self, message: str): + self.status = self.Status.FAILED + self.error_message = message[:2000] + self.save(update_fields=["status", "error_message", "updated_at"]) + + def mark_expired(self): + self.status = self.Status.EXPIRED + self.save(update_fields=["status", "updated_at"]) + diff --git a/apps/reports/services/__init__.py b/apps/reports/services/__init__.py new file mode 100644 index 0000000..f2ae9b2 --- /dev/null +++ b/apps/reports/services/__init__.py @@ -0,0 +1,15 @@ +from apps.reports.services.aggregation import ( + build_chart_report, + build_day_details_report, + build_table_report, + build_user_scoped_table_reports, + load_report_filters, +) + +__all__ = [ + "load_report_filters", + "build_chart_report", + "build_table_report", + "build_user_scoped_table_reports", + "build_day_details_report", +] diff --git a/apps/reports/services/aggregation.py b/apps/reports/services/aggregation.py new file mode 100644 index 0000000..662c691 --- /dev/null +++ b/apps/reports/services/aggregation.py @@ -0,0 +1,604 @@ +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 typing import Iterable + +import jdatetime +from django.contrib.auth import get_user_model +from django.db.models import Prefetch, QuerySet +from django.utils import timezone +from django.utils.dateparse import parse_date +from rest_framework import serializers + +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.workspaces.models import Workspace +from apps.workspaces.services import WORKSPACE_VIEW, get_workspace_role, has_workspace_capability + +User = get_user_model() + +PERIOD_THIS_WEEK = "this_week" +PERIOD_THIS_MONTH = "this_month" +PERIOD_THIS_YEAR = "this_year" +PERIOD_HALF_YEAR_FIRST = "half_year_first" +PERIOD_HALF_YEAR_SECOND = "half_year_second" +PERIOD_CUSTOM = "period" + +ALLOWED_PERIODS = { + PERIOD_THIS_WEEK, + PERIOD_THIS_MONTH, + PERIOD_THIS_YEAR, + PERIOD_HALF_YEAR_FIRST, + PERIOD_HALF_YEAR_SECOND, + PERIOD_CUSTOM, +} + + +def _start_of_week(local_date: date) -> date: + days_since_sunday = (local_date.weekday() + 1) % 7 + return local_date - timedelta(days=days_since_sunday) + + +def _start_of_persian_week(local_date: date) -> date: + days_since_saturday = (local_date.weekday() + 2) % 7 + return local_date - timedelta(days=days_since_saturday) + + +def _localize_datetime(value: datetime) -> datetime: + if timezone.is_naive(value): + value = timezone.make_aware(value, timezone.get_current_timezone()) + return timezone.localtime(value) + + +def _user_display(user) -> str: + full_name = getattr(user, "full_name", "").strip() + if full_name and full_name != "Anonymous": + return full_name + return getattr(user, "mobile", str(user)) + + +def _format_duration_seconds(total_seconds: int) -> str: + total_seconds = max(int(total_seconds), 0) + hours, remainder = divmod(total_seconds, 3600) + minutes, seconds = divmod(remainder, 60) + return f"{hours:02d}:{minutes:02d}:{seconds:02d}" + + +def _money_map() -> defaultdict[str, Decimal]: + return defaultdict(lambda: Decimal("0.00")) + + +def _serialize_money_totals(values: dict[str, Decimal]) -> list[dict]: + return [ + {"currency": currency, "amount": f"{amount.quantize(Decimal('0.01'))}"} + for currency, amount in sorted(values.items()) + if amount is not None + ] + + +def _add_income(bucket: defaultdict[str, Decimal], entry: TimeEntry): + if not entry.is_billable or not entry.hourly_rate: + return + duration_seconds = get_entry_duration_seconds(entry) + if duration_seconds <= 0: + return + hourly_rate = Decimal(entry.hourly_rate) + income = (hourly_rate * Decimal(duration_seconds) / Decimal(3600)).quantize(Decimal("0.01")) + bucket[(entry.currency or "USD")] += income + + +def get_entry_duration_seconds(entry: TimeEntry) -> int: + if entry.duration is not None: + return max(int(entry.duration.total_seconds()), 0) + if entry.end_time: + return max(int((entry.end_time - entry.start_time).total_seconds()), 0) + return 0 + + +def _parse_id_list(values: Iterable[str]) -> list[str]: + return [str(value).strip() for value in values if str(value).strip()] + + +@dataclass +class ReportFilters: + workspace: Workspace + period: str + from_date: date + to_date: date + user_id: str | None + client_id: str | None + project_id: str | None + tag_ids: list[str] + actor: object + is_workspace_scope: bool + language: str + + +class ReportFilterSerializer(serializers.Serializer): + workspace = serializers.UUIDField() + period = serializers.ChoiceField(choices=sorted(ALLOWED_PERIODS)) + from_date = serializers.DateField(required=False, allow_null=True) + to_date = serializers.DateField(required=False, allow_null=True) + user = serializers.UUIDField(required=False, allow_null=True) + client = serializers.UUIDField(required=False, allow_null=True) + project = serializers.UUIDField(required=False, allow_null=True) + tags = serializers.ListField( + child=serializers.UUIDField(), + required=False, + allow_empty=True, + ) + language = serializers.ChoiceField(choices=("en", "fa"), required=False, default="en") + + +def _resolve_period_bounds(period: str, from_date: date | None, to_date: date | None, *, language: str) -> tuple[date, date]: + today = timezone.localdate() + if language == "fa": + today_jalali = jdatetime.date.fromgregorian(date=today) + if period == PERIOD_THIS_WEEK: + start = _start_of_persian_week(today) + return start, start + timedelta(days=6) + if period == PERIOD_THIS_MONTH: + start_j = jdatetime.date(today_jalali.year, today_jalali.month, 1) + if today_jalali.month == 12: + next_month_j = jdatetime.date(today_jalali.year + 1, 1, 1) + else: + next_month_j = jdatetime.date(today_jalali.year, today_jalali.month + 1, 1) + return start_j.togregorian(), next_month_j.togregorian() - timedelta(days=1) + if period == PERIOD_THIS_YEAR: + start = jdatetime.date(today_jalali.year, 1, 1).togregorian() + if jdatetime.date(today_jalali.year, 1, 1).isleap(): + end = jdatetime.date(today_jalali.year, 12, 30).togregorian() + else: + end = jdatetime.date(today_jalali.year, 12, 29).togregorian() + return start, end + if period == PERIOD_HALF_YEAR_FIRST: + start = jdatetime.date(today_jalali.year, 1, 1).togregorian() + end = jdatetime.date(today_jalali.year, 6, 31).togregorian() + return start, end + if period == PERIOD_HALF_YEAR_SECOND: + start = jdatetime.date(today_jalali.year, 7, 1).togregorian() + if jdatetime.date(today_jalali.year, 1, 1).isleap(): + end = jdatetime.date(today_jalali.year, 12, 30).togregorian() + else: + end = jdatetime.date(today_jalali.year, 12, 29).togregorian() + return start, end + if period == PERIOD_THIS_WEEK: + start = _start_of_week(today) + return start, start + timedelta(days=6) + if period == PERIOD_THIS_MONTH: + start = today.replace(day=1) + if start.month == 12: + next_month = start.replace(year=start.year + 1, month=1, day=1) + else: + next_month = start.replace(month=start.month + 1, day=1) + return start, next_month - timedelta(days=1) + if period == PERIOD_THIS_YEAR: + return date(today.year, 1, 1), date(today.year, 12, 31) + if period == PERIOD_HALF_YEAR_FIRST: + return date(today.year, 1, 1), date(today.year, 6, 30) + if period == PERIOD_HALF_YEAR_SECOND: + return date(today.year, 7, 1), date(today.year, 12, 31) + if period == PERIOD_CUSTOM: + if not from_date or not to_date: + raise serializers.ValidationError("Custom period requires from_date and to_date.") + if from_date > to_date: + raise serializers.ValidationError("from_date cannot be after to_date.") + if (to_date - from_date).days > 30: + raise serializers.ValidationError("Custom period cannot exceed 31 days.") + return from_date, to_date + raise serializers.ValidationError("Unsupported report period.") + + +def load_report_filters(actor, raw_data) -> ReportFilters: + normalized = { + "workspace": raw_data.get("workspace"), + "period": raw_data.get("period", PERIOD_THIS_MONTH), + "from_date": raw_data.get("from_date") or raw_data.get("from"), + "to_date": raw_data.get("to_date") or raw_data.get("to"), + "user": raw_data.get("user"), + "client": raw_data.get("client"), + "project": raw_data.get("project"), + "tags": raw_data.get("tags") or raw_data.getlist("tags") if hasattr(raw_data, "getlist") else raw_data.get("tags"), + "language": raw_data.get("language", "en"), + } + if normalized["tags"] and not isinstance(normalized["tags"], list): + normalized["tags"] = [normalized["tags"]] + + serializer = ReportFilterSerializer(data=normalized) + serializer.is_valid(raise_exception=True) + validated = serializer.validated_data + + workspace = Workspace.objects.filter(id=validated["workspace"]).first() + if not workspace: + raise serializers.ValidationError("Workspace not found.") + if not has_workspace_capability(actor, workspace, WORKSPACE_VIEW): + raise serializers.ValidationError("You do not have access to this workspace.") + + from_date, to_date = _resolve_period_bounds( + validated["period"], + validated.get("from_date"), + validated.get("to_date"), + language=validated.get("language", "en"), + ) + + role = get_workspace_role(actor, workspace) + is_workspace_scope = role in {"owner", "admin"} + requested_user_id = str(validated["user"]) if validated.get("user") else None + if requested_user_id and not is_workspace_scope and requested_user_id != str(actor.id): + raise serializers.ValidationError("You cannot view another user's report.") + + user_id = requested_user_id if is_workspace_scope else str(actor.id) + + client_id = str(validated["client"]) if validated.get("client") else None + project_id = str(validated["project"]) if validated.get("project") else None + tag_ids = [str(tag_id) for tag_id in validated.get("tags", [])] + + if client_id and not Client.objects.filter(id=client_id, workspace=workspace).exists(): + 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 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): + raise serializers.ValidationError("One or more tags do not belong to this workspace.") + + return ReportFilters( + workspace=workspace, + period=validated["period"], + from_date=from_date, + to_date=to_date, + user_id=user_id, + client_id=client_id, + project_id=project_id, + tag_ids=tag_ids, + actor=actor, + is_workspace_scope=is_workspace_scope, + language=validated.get("language", "en"), + ) + + +def _base_queryset(filters: ReportFilters) -> QuerySet[TimeEntry]: + start_dt = timezone.make_aware(datetime.combine(filters.from_date, time.min), timezone.get_current_timezone()) + end_dt = timezone.make_aware(datetime.combine(filters.to_date + timedelta(days=1), time.min), timezone.get_current_timezone()) + + queryset = ( + TimeEntry.objects.filter( + workspace=filters.workspace, + is_deleted=False, + start_time__gte=start_dt, + start_time__lt=end_dt, + ) + .select_related("project", "project__client", "workspace", "user") + .prefetch_related(Prefetch("tags", queryset=Tag.objects.filter(is_deleted=False))) + .order_by("-start_time", "-created_at") + .distinct() + ) + + if filters.user_id: + queryset = queryset.filter(user_id=filters.user_id) + if filters.client_id: + queryset = queryset.filter(project__client_id=filters.client_id) + if filters.project_id: + queryset = queryset.filter(project_id=filters.project_id) + if filters.tag_ids: + queryset = queryset.filter(tags__id__in=filters.tag_ids) + + return queryset.distinct() + + +def _summary_from_entries(entries: list[TimeEntry]) -> dict: + total_seconds = 0 + billable_seconds = 0 + non_billable_seconds = 0 + income_by_currency = _money_map() + for entry in entries: + duration_seconds = get_entry_duration_seconds(entry) + total_seconds += duration_seconds + if entry.is_billable: + billable_seconds += duration_seconds + else: + non_billable_seconds += duration_seconds + _add_income(income_by_currency, entry) + return { + "total_seconds": total_seconds, + "billable_seconds": billable_seconds, + "non_billable_seconds": non_billable_seconds, + "total_duration": _format_duration_seconds(total_seconds), + "billable_duration": _format_duration_seconds(billable_seconds), + "non_billable_duration": _format_duration_seconds(non_billable_seconds), + "income_totals": _serialize_money_totals(income_by_currency), + } + + +def _entry_payload(entry: TimeEntry) -> dict: + local_start = _localize_datetime(entry.start_time) + local_end = _localize_datetime(entry.end_time) if entry.end_time else None + duration_seconds = get_entry_duration_seconds(entry) + income_by_currency = _money_map() + _add_income(income_by_currency, entry) + return { + "id": str(entry.id), + "description": entry.description, + "user": { + "id": str(entry.user_id), + "name": _user_display(entry.user), + "mobile": entry.user.mobile, + }, + "project": ( + { + "id": str(entry.project_id), + "name": entry.project.name, + "client": ( + {"id": str(entry.project.client_id), "name": entry.project.client.name} + if entry.project and entry.project.client + else None + ), + } + if entry.project + else None + ), + "tags": [{"id": str(tag.id), "name": tag.name, "color": tag.color} for tag in entry.tags.all()], + "start_time": local_start.isoformat(), + "end_time": local_end.isoformat() if local_end else None, + "duration_seconds": duration_seconds, + "duration": _format_duration_seconds(duration_seconds), + "is_billable": entry.is_billable, + "hourly_rate": str(entry.hourly_rate) if entry.hourly_rate is not None else None, + "currency": entry.currency, + "income_totals": _serialize_money_totals(income_by_currency), + } + + +def _bucket_label(filters: ReportFilters, bucket_date: date) -> str: + if filters.period in {PERIOD_THIS_WEEK, PERIOD_THIS_MONTH, PERIOD_CUSTOM}: + return bucket_date.isoformat() + return bucket_date.strftime("%Y-%m") + + +def _bucket_key(filters: ReportFilters, local_dt: datetime) -> tuple[str, date]: + if filters.period in {PERIOD_THIS_WEEK, PERIOD_THIS_MONTH, PERIOD_CUSTOM}: + bucket_date = local_dt.date() + return bucket_date.isoformat(), bucket_date + bucket_date = date(local_dt.year, local_dt.month, 1) + return bucket_date.strftime("%Y-%m"), bucket_date + + +def build_chart_report(actor, raw_filters) -> dict: + filters = load_report_filters(actor, raw_filters) + entries = list(_base_queryset(filters)) + summary = _summary_from_entries(entries) + buckets: dict[str, dict] = {} + + for entry in entries: + local_start = _localize_datetime(entry.start_time) + bucket_id, bucket_date = _bucket_key(filters, local_start) + bucket = buckets.setdefault( + bucket_id, + { + "bucket_key": bucket_id, + "bucket_label": _bucket_label(filters, bucket_date), + "total_seconds": 0, + "total_duration": "00:00:00", + }, + ) + bucket["total_seconds"] += get_entry_duration_seconds(entry) + + serialized_buckets = [] + for bucket in sorted(buckets.values(), key=lambda item: item["bucket_key"]): + bucket["total_duration"] = _format_duration_seconds(bucket["total_seconds"]) + serialized_buckets.append(bucket) + + return { + "scope": _scope_payload(filters), + "summary": summary, + "buckets": serialized_buckets, + } + + +def _scope_payload(filters: ReportFilters) -> dict: + user_payload = None + if filters.user_id: + target_user = User.objects.filter(id=filters.user_id).first() + if target_user: + user_payload = { + "id": str(target_user.id), + "name": _user_display(target_user), + "mobile": target_user.mobile, + } + + return { + "workspace": {"id": str(filters.workspace.id), "name": filters.workspace.name}, + "period": filters.period, + "from_date": filters.from_date.isoformat(), + "to_date": filters.to_date.isoformat(), + "user": user_payload, + "is_workspace_scope": filters.is_workspace_scope and not filters.user_id, + "filters": { + "client_id": filters.client_id, + "project_id": filters.project_id, + "tag_ids": filters.tag_ids, + }, + } + + +def _table_report_payload(filters: ReportFilters, entries: list[TimeEntry]) -> dict: + summary = _summary_from_entries(entries) + return { + "scope": _scope_payload(filters), + "summary": summary, + "days": _group_daily(entries), + "clients": _build_breakdown(entries, "clients"), + "projects": _build_breakdown(entries, "projects"), + "tags": _build_breakdown(entries, "tags"), + } + + +def _group_daily(entries: list[TimeEntry]) -> list[dict]: + by_day: dict[str, dict] = {} + for entry in entries: + local_start = _localize_datetime(entry.start_time) + day_key = local_start.date().isoformat() + day_bucket = by_day.setdefault( + day_key, + { + "date": day_key, + "billable_seconds": 0, + "non_billable_seconds": 0, + "total_seconds": 0, + "income": _money_map(), + }, + ) + duration_seconds = get_entry_duration_seconds(entry) + day_bucket["total_seconds"] += duration_seconds + if entry.is_billable: + day_bucket["billable_seconds"] += duration_seconds + else: + day_bucket["non_billable_seconds"] += duration_seconds + _add_income(day_bucket["income"], entry) + + rows = [] + for day_key in sorted(by_day.keys()): + bucket = by_day[day_key] + rows.append( + { + "date": bucket["date"], + "billable_seconds": bucket["billable_seconds"], + "non_billable_seconds": bucket["non_billable_seconds"], + "total_seconds": bucket["total_seconds"], + "billable_duration": _format_duration_seconds(bucket["billable_seconds"]), + "non_billable_duration": _format_duration_seconds(bucket["non_billable_seconds"]), + "total_duration": _format_duration_seconds(bucket["total_seconds"]), + "income_totals": _serialize_money_totals(bucket["income"]), + } + ) + return rows + + +def _build_breakdown(entries: list[TimeEntry], kind: str) -> list[dict]: + data: dict[str, dict] = {} + for entry in entries: + if kind == "clients": + if not entry.project or not entry.project.client: + continue + item_id = str(entry.project.client_id) + item_name = entry.project.client.name + elif kind == "projects": + if not entry.project: + continue + item_id = str(entry.project_id) + item_name = entry.project.name + else: + if not entry.tags.exists(): + continue + for tag in entry.tags.all(): + bucket = data.setdefault( + str(tag.id), + { + "id": str(tag.id), + "name": tag.name, + "billable_seconds": 0, + "non_billable_seconds": 0, + "total_seconds": 0, + "income": _money_map(), + }, + ) + duration_seconds = get_entry_duration_seconds(entry) + bucket["total_seconds"] += duration_seconds + if entry.is_billable: + bucket["billable_seconds"] += duration_seconds + else: + bucket["non_billable_seconds"] += duration_seconds + _add_income(bucket["income"], entry) + continue + + bucket = data.setdefault( + item_id, + { + "id": item_id, + "name": item_name, + "billable_seconds": 0, + "non_billable_seconds": 0, + "total_seconds": 0, + "income": _money_map(), + }, + ) + duration_seconds = get_entry_duration_seconds(entry) + bucket["total_seconds"] += duration_seconds + if entry.is_billable: + bucket["billable_seconds"] += duration_seconds + else: + bucket["non_billable_seconds"] += duration_seconds + _add_income(bucket["income"], entry) + + rows = [] + for item_id, bucket in sorted(data.items(), key=lambda item: item[1]["name"].lower()): + rows.append( + { + "id": bucket["id"], + "name": bucket["name"], + "billable_seconds": bucket["billable_seconds"], + "non_billable_seconds": bucket["non_billable_seconds"], + "total_seconds": bucket["total_seconds"], + "billable_duration": _format_duration_seconds(bucket["billable_seconds"]), + "non_billable_duration": _format_duration_seconds(bucket["non_billable_seconds"]), + "total_duration": _format_duration_seconds(bucket["total_seconds"]), + "income_totals": _serialize_money_totals(bucket["income"]), + } + ) + return rows + + +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) + + +def build_user_scoped_table_reports(actor, raw_filters) -> list[dict]: + filters = load_report_filters(actor, raw_filters) + if not (filters.is_workspace_scope and not filters.user_id): + return [] + + entries = list(_base_queryset(filters)) + grouped: dict[str, list[TimeEntry]] = {} + for entry in entries: + grouped.setdefault(str(entry.user_id), []).append(entry) + + sorted_groups = sorted( + grouped.items(), + key=lambda item: _user_display(item[1][0].user).lower(), + ) + + 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)) + return reports + + +def build_day_details_report(actor, raw_filters) -> dict: + filters = load_report_filters(actor, raw_filters) + day_value = raw_filters.get("day") or raw_filters.get("date") + target_day = parse_date(day_value) if day_value else None + if not target_day: + raise serializers.ValidationError("A valid day is required.") + if target_day < filters.from_date or target_day > filters.to_date: + raise serializers.ValidationError("Requested day is outside the filtered period.") + + entries = [ + entry + for entry in _base_queryset(filters) + if _localize_datetime(entry.start_time).date() == target_day + ] + summary = _summary_from_entries(entries) + return { + "scope": _scope_payload(filters), + "day": target_day.isoformat(), + "summary": summary, + "entries": [_entry_payload(entry) for entry in entries], + } diff --git a/apps/reports/services/export_i18n.py b/apps/reports/services/export_i18n.py new file mode 100644 index 0000000..f4eefea --- /dev/null +++ b/apps/reports/services/export_i18n.py @@ -0,0 +1,173 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import date +from pathlib import Path +from typing import Iterable + +import jdatetime +from arabic_reshaper import reshape +from bidi.algorithm import get_display + +PERSIAN_DIGITS = str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹") +ARABIC_RANGES = ( + (0x0600, 0x06FF), + (0x0750, 0x077F), + (0x08A0, 0x08FF), + (0xFB50, 0xFDFF), + (0xFE70, 0xFEFF), +) + + +TRANSLATIONS = { + "en": { + "report_title": "Workspace Report", + "overall_sheet": "Overall Report", + "workspace": "Workspace", + "period": "Period", + "from_date": "From date", + "to_date": "To date", + "user": "User", + "all_users": "All users", + "generated_at": "Generated at", + "summary": "Summary", + "total_hours": "Total hours", + "billable_hours": "Billable hours", + "non_billable_hours": "Non-billable hours", + "income": "Income", + "daily_summary": "Daily Summary", + "clients": "Clients", + "projects": "Projects", + "tags": "Tags", + "date": "Date", + "name": "Name", + "total": "Total", + "no_data": "No data", + }, + "fa": { + "report_title": "گزارش فضای کاری", + "overall_sheet": "گزارش کلی", + "workspace": "فضای کاری", + "period": "بازه", + "from_date": "از تاریخ", + "to_date": "تا تاریخ", + "user": "کاربر", + "all_users": "همه کاربران", + "generated_at": "تاریخ تولید", + "summary": "خلاصه", + "total_hours": "کل ساعات", + "billable_hours": "ساعات کاری", + "non_billable_hours": "ساعات غیر کاری", + "income": "درآمد", + "daily_summary": "خلاصه روزانه", + "clients": "مشتریان", + "projects": "پروژه‌ها", + "tags": "تگ‌ها", + "date": "تاریخ", + "name": "نام", + "total": "جمع", + "no_data": "بدون داده", + }, +} + +PERIOD_LABELS = { + "en": { + "this_week": "This week", + "this_month": "This month", + "this_year": "This year", + "half_year_first": "First half of year", + "half_year_second": "Second half of year", + "period": "Custom period", + }, + "fa": { + "this_week": "این هفته", + "this_month": "این ماه", + "this_year": "امسال", + "half_year_first": "نیمه اول سال", + "half_year_second": "نیمه دوم سال", + "period": "بازه دلخواه", + }, +} + + +@dataclass(frozen=True) +class ExportLocale: + language: str + is_rtl: bool + font_regular: str + font_bold: str + + def t(self, key: str) -> str: + return TRANSLATIONS[self.language][key] + + def period_label(self, period: str) -> str: + return PERIOD_LABELS[self.language].get(period, period) + + def format_number(self, value: object, *, ascii_digits: bool = False) -> str: + text = str(value) + if self.language == "fa" and not ascii_digits: + return text.translate(PERSIAN_DIGITS) + return text + + def format_date(self, value: date | str | None, *, ascii_digits: bool = False) -> str: + if value is None: + return "-" + if isinstance(value, str): + value = date.fromisoformat(value) + if self.language == "fa": + jalali = jdatetime.date.fromgregorian(date=value) + return self.format_number(jalali.strftime("%Y/%m/%d"), ascii_digits=ascii_digits) + return value.strftime("%Y/%m/%d") + + def format_duration(self, value: str, *, ascii_digits: bool = False) -> str: + return self.format_number(value, ascii_digits=ascii_digits) + + def format_money_label(self, income_totals: list[dict], *, ascii_digits: bool = False) -> str: + if not income_totals: + return "-" + parts = [] + for item in income_totals: + parts.append(f"{self.format_number(item['amount'], ascii_digits=ascii_digits)} {item['currency']}") + return " | ".join(parts) + + def shape(self, text: object) -> str: + raw = str(text) + if not any(start <= ord(char) <= end for char in raw for start, end in ARABIC_RANGES): + return raw + return get_display(reshape(raw)) + + +def build_export_locale(language: str | None) -> ExportLocale: + resolved = language if language in {"en", "fa"} else "en" + assets_dir = Path(__file__).resolve().parent.parent / "assets" / "fonts" + return ExportLocale( + language=resolved, + is_rtl=resolved == "fa", + font_regular=str(assets_dir / "Vazirmatn-Regular.ttf"), + font_bold=str(assets_dir / "Vazirmatn-Bold.ttf"), + ) + + +def user_label(user_payload: dict | None, locale: ExportLocale, *, ascii_digits: bool = False) -> str: + if not user_payload: + return locale.t("all_users") + mobile = locale.format_number(user_payload.get("mobile") or "", ascii_digits=ascii_digits) + if mobile: + return f"{user_payload['name']} - {mobile}" + return str(user_payload["name"]) + + +def safe_sheet_title(title: str, used: Iterable[str]) -> str: + invalid = set('[]:*?/\\') + sanitized = "".join("-" if char in invalid else char for char in title).strip() or "Sheet" + base = sanitized[:31] + used_set = set(used) + if base not in used_set: + return base + index = 2 + while True: + suffix = f"-{index}" + candidate = f"{sanitized[:31 - len(suffix)]}{suffix}" + if candidate not in used_set: + return candidate + index += 1 diff --git a/apps/reports/services/exporters.py b/apps/reports/services/exporters.py new file mode 100644 index 0000000..845598f --- /dev/null +++ b/apps/reports/services/exporters.py @@ -0,0 +1,410 @@ +from __future__ import annotations + +import io +from datetime import datetime + +from openpyxl import Workbook +from openpyxl.styles import Alignment, Border, Font, PatternFill, Side +from openpyxl.utils import get_column_letter +from reportlab.lib import colors +from reportlab.lib.pagesizes import A4, landscape +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.lib.units import mm +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.ttfonts import TTFont +from reportlab.platypus import Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle + +from apps.reports.services.export_i18n import ExportLocale, safe_sheet_title, user_label + +HEADER_FILL = PatternFill("solid", fgColor="E7F2FF") +SECTION_FILL = PatternFill("solid", fgColor="F8FAFC") +BORDER = Border( + left=Side(style="thin", color="D0D7DE"), + right=Side(style="thin", color="D0D7DE"), + top=Side(style="thin", color="D0D7DE"), + bottom=Side(style="thin", color="D0D7DE"), +) + + +def _register_pdf_fonts(locale: ExportLocale) -> None: + registered = set(pdfmetrics.getRegisteredFontNames()) + if "Vazirmatn" not in registered: + pdfmetrics.registerFont(TTFont("Vazirmatn", locale.font_regular)) + if "Vazirmatn-Bold" not in registered: + pdfmetrics.registerFont(TTFont("Vazirmatn-Bold", locale.font_bold)) + + +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") + if fill is not None: + cell.fill = fill + + +def _autosize_columns(worksheet) -> None: + widths: dict[int, int] = {} + for row in worksheet.iter_rows(): + for cell in row: + if cell.value is None: + continue + widths[cell.column] = max(widths.get(cell.column, 0), len(str(cell.value))) + for column_index, width in widths.items(): + worksheet.column_dimensions[get_column_letter(column_index)].width = min(max(width + 4, 12), 30) + + +def _money_label(locale: ExportLocale, income_totals: list[dict]) -> str: + return locale.format_money_label(income_totals) + + +def _money_label_excel(locale: ExportLocale, income_totals: list[dict]) -> str: + return locale.format_money_label(income_totals, ascii_digits=True) + + +def _section_headers(locale: ExportLocale) -> list[str]: + headers = [ + locale.t("name"), + locale.t("billable_hours"), + locale.t("non_billable_hours"), + locale.t("total_hours"), + locale.t("income"), + ] + return list(reversed(headers)) if locale.is_rtl else headers + + +def _rtl_row(locale: ExportLocale, row: list[str]) -> list[str]: + return list(reversed(row)) if locale.is_rtl else row + + +def _append_meta_block(worksheet, *, locale: ExportLocale, report_data: dict) -> None: + scope = report_data["scope"] + summary = report_data["summary"] + + worksheet.append([locale.t("report_title"), scope["workspace"]["name"]]) + worksheet.append([locale.t("workspace"), scope["workspace"]["name"]]) + worksheet.append([locale.t("period"), locale.period_label(scope["period"])]) + worksheet.append([locale.t("from_date"), locale.format_date(scope["from_date"], ascii_digits=True)]) + worksheet.append([locale.t("to_date"), locale.format_date(scope["to_date"], ascii_digits=True)]) + worksheet.append([locale.t("user"), user_label(scope.get("user"), locale, ascii_digits=True)]) + worksheet.append([locale.t("generated_at"), locale.format_date(datetime.now().date(), ascii_digits=True)]) + worksheet.append([]) + worksheet.append([locale.t("summary")]) + worksheet.append([locale.t("total_hours"), locale.format_duration(summary["total_duration"], ascii_digits=True)]) + worksheet.append([locale.t("billable_hours"), locale.format_duration(summary["billable_duration"], ascii_digits=True)]) + worksheet.append([locale.t("non_billable_hours"), locale.format_duration(summary["non_billable_duration"], ascii_digits=True)]) + worksheet.append([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) + second_cell = worksheet.cell(row=row_index, column=2) + if row_index in {1, 9}: + _apply_cell_style(first_cell, bold=True, fill=HEADER_FILL, rtl=locale.is_rtl) + if second_cell.value: + _apply_cell_style(second_cell, bold=row_index == 1, fill=HEADER_FILL if row_index == 1 else None, rtl=locale.is_rtl) + elif first_cell.value: + _apply_cell_style(first_cell, bold=False, fill=SECTION_FILL if row_index == 8 else None, rtl=locale.is_rtl) + if second_cell.value: + _apply_cell_style(second_cell, rtl=locale.is_rtl) + + +def _append_daily_table(worksheet, *, locale: ExportLocale, report_data: dict) -> None: + worksheet.append([]) + worksheet.append([locale.t("daily_summary")]) + header_row = worksheet.max_row + 1 + worksheet.append( + _rtl_row( + locale, + [ + locale.t("date"), + locale.t("billable_hours"), + locale.t("non_billable_hours"), + locale.t("total_hours"), + locale.t("income"), + ], + ) + ) + for cell in worksheet[header_row]: + _apply_cell_style(cell, bold=True, fill=HEADER_FILL, rtl=locale.is_rtl) + + if not report_data["days"]: + 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 report_data["days"]: + worksheet.append( + _rtl_row( + locale, + [ + locale.format_date(row["date"], ascii_digits=True), + 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"]), + ], + ) + ) + for cell in worksheet[worksheet.max_row]: + _apply_cell_style(cell, rtl=locale.is_rtl) + + worksheet.append( + _rtl_row( + locale, + [ + locale.t("total"), + locale.format_duration(report_data["summary"]["billable_duration"], ascii_digits=True), + locale.format_duration(report_data["summary"]["non_billable_duration"], ascii_digits=True), + locale.format_duration(report_data["summary"]["total_duration"], ascii_digits=True), + _money_label_excel(locale, report_data["summary"]["income_totals"]), + ], + ) + ) + for cell in worksheet[worksheet.max_row]: + _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: + worksheet.append([]) + worksheet.append([locale.t(title_key)]) + header_row = worksheet.max_row + 1 + worksheet.append(_section_headers(locale)) + 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( + _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"]), + ], + ) + ) + for cell in worksheet[worksheet.max_row]: + _apply_cell_style(cell, rtl=locale.is_rtl) + + +def _render_excel_sheet(worksheet, *, locale: ExportLocale, report_data: dict) -> None: + if locale.is_rtl: + worksheet.sheet_view.rightToLeft = True + _append_meta_block(worksheet, locale=locale, report_data=report_data) + _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"]) + _autosize_columns(worksheet) + + +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 = {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) + + buffer = io.BytesIO() + workbook.save(buffer) + return buffer.getvalue() + + +def _paragraph(text: str, style: ParagraphStyle, locale: ExportLocale) -> Paragraph: + return Paragraph(locale.shape(text), style) + + +def _styled_table(data: list[list[str]], *, locale: ExportLocale, column_widths: list[float]) -> Table: + shaped_data = [ + [locale.shape(cell) if cell is not None else "" for cell in row] + for row in data + ] + table = Table(shaped_data, colWidths=column_widths, repeatRows=1) + table.setStyle( + TableStyle( + [ + ("FONTNAME", (0, 0), (-1, 0), "Vazirmatn-Bold" if locale.language == "fa" else "Helvetica-Bold"), + ("FONTNAME", (0, 1), (-1, -1), "Vazirmatn" if locale.language == "fa" else "Helvetica"), + ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#E7F2FF")), + ("TEXTCOLOR", (0, 0), (-1, -1), colors.HexColor("#0F172A")), + ("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#CBD5E1")), + ("VALIGN", (0, 0), (-1, -1), "MIDDLE"), + ("ALIGN", (0, 0), (-1, -1), "RIGHT" if locale.is_rtl else "LEFT"), + ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#F8FAFC")]), + ("TOPPADDING", (0, 0), (-1, -1), 6), + ("BOTTOMPADDING", (0, 0), (-1, -1), 6), + ] + ) + ) + return table + + +def _report_table_rows(locale: ExportLocale, rows: list[dict]) -> list[list[str]]: + if not rows: + return [_rtl_row(locale, [locale.t("no_data"), "", "", "", ""])] + return [ + _rtl_row( + locale, + [ + locale.format_date(row.get("date")) if row.get("date") else 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"]), + ], + ) + for row in rows + ] + + +def build_pdf_report(*, report_data: dict, locale: ExportLocale) -> bytes: + _register_pdf_fonts(locale) + font_regular = "Vazirmatn" + font_bold = "Vazirmatn-Bold" + + styles = getSampleStyleSheet() + title_style = ParagraphStyle( + "ReportTitle", + parent=styles["Heading1"], + fontName=font_bold, + fontSize=18, + leading=24, + alignment=2 if locale.is_rtl else 0, + textColor=colors.HexColor("#0F172A"), + ) + section_style = ParagraphStyle( + "ReportSection", + parent=styles["Heading3"], + fontName=font_bold, + fontSize=12, + leading=16, + 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( + buffer, + pagesize=landscape(A4), + leftMargin=14 * mm, + rightMargin=14 * mm, + topMargin=14 * mm, + bottomMargin=14 * mm, + ) + + scope = report_data["scope"] + summary = report_data["summary"] + story = [ + _paragraph(f"{locale.t('report_title')} - {scope['workspace']['name']}", title_style, locale), + Spacer(1, 6 * mm), + ] + + meta_rows = [ + [locale.t("workspace"), scope["workspace"]["name"]], + [locale.t("period"), locale.period_label(scope["period"])], + [locale.t("from_date"), locale.format_date(scope["from_date"])], + [locale.t("to_date"), locale.format_date(scope["to_date"])], + [locale.t("user"), user_label(scope.get("user"), locale)], + [locale.t("generated_at"), locale.format_date(datetime.now().date())], + ] + if locale.is_rtl: + meta_rows = [_rtl_row(locale, row) for row in meta_rows] + meta_rows = [[locale.shape(cell) for cell in row] for row in meta_rows] + meta_table = Table(meta_rows, colWidths=[doc.width * 0.24, doc.width * 0.76]) + meta_table.setStyle( + TableStyle( + [ + ("FONTNAME", (0, 0), (0, -1), font_bold), + ("FONTNAME", (1, 0), (1, -1), font_regular), + ("BACKGROUND", (0, 0), (0, -1), colors.HexColor("#F8FAFC")), + ("BOX", (0, 0), (-1, -1), 0.5, colors.HexColor("#CBD5E1")), + ("INNERGRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#CBD5E1")), + ("ALIGN", (0, 0), (-1, -1), "RIGHT" if locale.is_rtl else "LEFT"), + ("TOPPADDING", (0, 0), (-1, -1), 6), + ("BOTTOMPADDING", (0, 0), (-1, -1), 6), + ] + ) + ) + story.extend([meta_table, Spacer(1, 5 * mm)]) + + summary_data = [ + [locale.t("total_hours"), locale.format_duration(summary["total_duration"])], + [locale.t("billable_hours"), locale.format_duration(summary["billable_duration"])], + [locale.t("non_billable_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] + summary_table = Table(summary_data, colWidths=[doc.width * 0.38, doc.width * 0.62]) + summary_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), font_bold), + ("FONTNAME", (1, 0), (1, -1), font_regular), + ("ALIGN", (0, 0), (-1, -1), "RIGHT" if locale.is_rtl else "LEFT"), + ("TOPPADDING", (0, 0), (-1, -1), 6), + ("BOTTOMPADDING", (0, 0), (-1, -1), 6), + ] + ) + ) + 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)) + story.append(Spacer(1, 2 * mm)) + 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("income"), + ], + ) + table = _styled_table( + [header, *_report_table_rows(locale, rows)], + locale=locale, + column_widths=[ + 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)]) + + doc.build(story) + return buffer.getvalue() diff --git a/apps/reports/tasks.py b/apps/reports/tasks.py new file mode 100644 index 0000000..d3ac32f --- /dev/null +++ b/apps/reports/tasks.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from celery import shared_task +from django.conf import settings +from django.core.files.base import ContentFile +from django.urls import reverse +from django.utils import timezone + +from apps.notifications.services import RedisNotificationStore +from apps.reports.models import ReportExportJob +from apps.reports.services import build_table_report, build_user_scoped_table_reports +from apps.reports.services.export_i18n import build_export_locale +from apps.reports.services.exporters import build_excel_report, build_pdf_report + + +def _build_export_action_url(job: ReportExportJob) -> str: + path = reverse("report-export-job-download", kwargs={"pk": job.id}) + if settings.BASE_URL: + return f"{settings.BASE_URL.rstrip('/')}{path}" + return path + + +@shared_task(name="reports.generate_export") +def generate_report_export_task(job_id: str): + job = ReportExportJob.objects.filter(id=job_id).first() + if not job: + return None + + job.mark_processing() + try: + locale = build_export_locale(job.filters.get("language")) + report_data = build_table_report(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) + suffix = "pdf" + mime_type = "application/pdf" + + file_name = f"workspace-report-{timezone.now().strftime('%Y%m%d-%H%M%S')}.{suffix}" + storage_name = f"reports/exports/{job.id}-{file_name}" + job.file.save(storage_name, ContentFile(content), save=False) + job.status = ReportExportJob.Status.COMPLETED + job.file_name = file_name + job.completed_at = timezone.now() + job.expires_at = job.completed_at + timezone.timedelta(days=settings.REPORT_EXPORT_RETENTION_DAYS) + job.error_message = "" + job.save( + update_fields=[ + "file", + "status", + "file_name", + "completed_at", + "expires_at", + "error_message", + "updated_at", + ] + ) + + action_url = _build_export_action_url(job) + + RedisNotificationStore.add( + str(job.requesting_user_id), + { + "type": "report_export_ready", + "title": "Report export is ready", + "message": f"Your {job.export_type.upper()} report for {job.workspace.name} is ready to download.", + "level": "success", + "action_url": action_url, + "entity_type": "report_export", + "entity_id": str(job.id), + "meta": { + "workspace_id": str(job.workspace_id), + "workspace_name": job.workspace.name, + "export_type": job.export_type, + "file_name": file_name, + "download_url": action_url, + "mime_type": mime_type, + }, + }, + ) + return str(job.id) + except Exception as exc: # noqa: BLE001 + job.mark_failed(str(exc)) + RedisNotificationStore.add( + str(job.requesting_user_id), + { + "type": "report_export_failed", + "title": "Report export failed", + "message": f"Your {job.export_type.upper()} report for {job.workspace.name} could not be generated.", + "level": "error", + "action_url": "/reports", + "entity_type": "report_export", + "entity_id": str(job.id), + "meta": { + "workspace_id": str(job.workspace_id), + "workspace_name": job.workspace.name, + "export_type": job.export_type, + }, + }, + ) + raise + + +@shared_task(name="reports.cleanup_expired_exports") +def cleanup_expired_report_exports_task(): + expired_jobs = ReportExportJob.objects.filter( + status=ReportExportJob.Status.COMPLETED, + expires_at__lte=timezone.now(), + ) + removed = 0 + for job in expired_jobs: + if job.file: + job.file.delete(save=False) + job.mark_expired() + removed += 1 + return removed diff --git a/apps/reports/tests/__init__.py b/apps/reports/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/reports/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/reports/tests/test_tasks.py b/apps/reports/tests/test_tasks.py new file mode 100644 index 0000000..497ae6d --- /dev/null +++ b/apps/reports/tests/test_tasks.py @@ -0,0 +1,217 @@ +from datetime import timedelta +from decimal import Decimal +from io import BytesIO + +import pytest +from django.core.files.base import ContentFile +from django.core.files.storage import default_storage +from django.utils import timezone +from openpyxl import load_workbook + +from apps.notifications.services import store as notification_store +from apps.reports.models import ReportExportJob +from apps.reports.tasks import cleanup_expired_report_exports_task, generate_report_export_task +from apps.time_entries.models import TimeEntry +from apps.users.models import User +from apps.workspaces.models import Workspace + + +class FakeRedis: + def pipeline(self): + return self + + def zadd(self, *args, **kwargs): + return self + + def hset(self, *args, **kwargs): + return self + + def sadd(self, *args, **kwargs): + return self + + def execute(self): + return [] + + def publish(self, *args, **kwargs): + return None + + def zrevrange(self, *args, **kwargs): + return [] + + def hget(self, *args, **kwargs): + return None + + def zrem(self, *args, **kwargs): + return 1 + + def hdel(self, *args, **kwargs): + return 1 + + def zcard(self, *args, **kwargs): + return 0 + + def smembers(self, *args, **kwargs): + return set() + + def srem(self, *args, **kwargs): + return 1 + + +@pytest.fixture() +def fake_redis(monkeypatch): + redis = FakeRedis() + monkeypatch.setattr(notification_store, "redis_client", redis) + return redis + + +@pytest.fixture() +def owner(db): + return User.objects.create_user(mobile="09129990001", password="secret123", first_name="Owner", last_name="User") + + +@pytest.fixture() +def teammate(db): + return User.objects.create_user(mobile="09129990002", password="secret123", first_name="Team", last_name="Mate") + + +@pytest.fixture() +def workspace(owner, teammate): + workspace = Workspace.objects.create(name="Exports", owner=owner) + workspace.memberships.create(user=teammate, role="member", is_active=True) + return workspace + + +@pytest.fixture() +def time_entry(workspace, owner): + return TimeEntry.objects.create( + workspace=workspace, + user=owner, + description="Export row", + start_time="2026-04-12T08:00:00+03:30", + end_time="2026-04-12T10:00:00+03:30", + duration=timedelta(hours=2), + is_billable=True, + hourly_rate=Decimal("15.00"), + currency="USD", + ) + + +@pytest.fixture() +def teammate_entry(workspace, teammate): + return TimeEntry.objects.create( + workspace=workspace, + user=teammate, + description="Team row", + start_time="2026-04-13T08:00:00+03:30", + end_time="2026-04-13T09:00:00+03:30", + duration=timedelta(hours=1), + is_billable=False, + currency="USD", + ) + + +def test_generate_export_creates_file_and_marks_job_complete(fake_redis, workspace, owner, time_entry): + job = ReportExportJob.objects.create( + requesting_user=owner, + workspace=workspace, + export_type=ReportExportJob.ExportType.EXCEL, + filters={ + "workspace": str(workspace.id), + "period": "this_month", + "from_date": "2026-04-01", + "to_date": "2026-04-30", + "user": str(owner.id), + "client": None, + "project": None, + "tags": [], + "language": "en", + }, + ) + + generate_report_export_task(str(job.id)) + job.refresh_from_db() + + assert job.status == ReportExportJob.Status.COMPLETED + assert bool(job.file) + assert default_storage.exists(job.file.name) + + +def test_generate_excel_export_adds_per_user_sheets_for_all_users_scope( + fake_redis, + workspace, + owner, + teammate, + time_entry, + teammate_entry, +): + job = ReportExportJob.objects.create( + requesting_user=owner, + workspace=workspace, + export_type=ReportExportJob.ExportType.EXCEL, + filters={ + "workspace": str(workspace.id), + "period": "this_month", + "from_date": "2026-04-01", + "to_date": "2026-04-30", + "user": None, + "client": None, + "project": None, + "tags": [], + "language": "en", + }, + ) + + generate_report_export_task(str(job.id)) + job.refresh_from_db() + + workbook = load_workbook(BytesIO(job.file.read())) + assert workbook.sheetnames[0] == "Overall Report" + assert any("Owner User" in sheet for sheet in workbook.sheetnames[1:]) + assert any("Team Mate" in sheet for sheet in workbook.sheetnames[1:]) + assert len(workbook.sheetnames) == 3 + + +def test_generate_pdf_export_supports_persian_locale(fake_redis, workspace, owner, time_entry): + job = ReportExportJob.objects.create( + requesting_user=owner, + workspace=workspace, + export_type=ReportExportJob.ExportType.PDF, + filters={ + "workspace": str(workspace.id), + "period": "this_month", + "from_date": "2026-04-01", + "to_date": "2026-04-30", + "user": str(owner.id), + "client": None, + "project": None, + "tags": [], + "language": "fa", + }, + ) + + generate_report_export_task(str(job.id)) + job.refresh_from_db() + + assert job.status == ReportExportJob.Status.COMPLETED + assert job.file.read(4) == b"%PDF" + + +def test_cleanup_expires_and_removes_files(fake_redis, workspace, owner): + job = ReportExportJob.objects.create( + requesting_user=owner, + workspace=workspace, + export_type=ReportExportJob.ExportType.EXCEL, + status=ReportExportJob.Status.COMPLETED, + filters={}, + expires_at=timezone.now() - timezone.timedelta(days=1), + ) + file_name = f"reports/exports/{job.id}-old.xlsx" + job.file.save(file_name, ContentFile(b"old-data"), save=False) + job.save(update_fields=["file", "updated_at"]) + + removed = cleanup_expired_report_exports_task() + job.refresh_from_db() + + assert removed == 1 + assert job.status == ReportExportJob.Status.EXPIRED + assert not default_storage.exists(file_name) diff --git a/apps/reports/tests/test_views.py b/apps/reports/tests/test_views.py new file mode 100644 index 0000000..f492141 --- /dev/null +++ b/apps/reports/tests/test_views.py @@ -0,0 +1,163 @@ +from datetime import date, timedelta +from decimal import Decimal + +import pytest +from rest_framework.test import APIClient + +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 + + +@pytest.fixture() +def api_client(): + return APIClient() + + +@pytest.fixture() +def owner(db): + return User.objects.create_user(mobile="09128880001", password="secret123", first_name="Owner") + + +@pytest.fixture() +def admin(db): + return User.objects.create_user(mobile="09128880002", password="secret123", first_name="Admin") + + +@pytest.fixture() +def member(db): + return User.objects.create_user(mobile="09128880003", password="secret123", first_name="Member") + + +@pytest.fixture() +def workspace(owner, admin, member): + workspace = Workspace.objects.create(name="Reports", owner=owner) + WorkspaceMembership.objects.create(workspace=workspace, user=admin, role=WorkspaceMembership.Role.ADMIN, is_active=True) + WorkspaceMembership.objects.create(workspace=workspace, user=member, role=WorkspaceMembership.Role.MEMBER, is_active=True) + return workspace + + +@pytest.fixture() +def client(workspace): + return Client.objects.create(workspace=workspace, name="Acme") + + +@pytest.fixture() +def project(workspace, client): + return Project.objects.create(workspace=workspace, name="Website", client=client) + + +@pytest.fixture() +def tag(workspace): + return Tag.objects.create(workspace=workspace, name="Design", color="#ffffff") + + +@pytest.fixture() +def time_entries(workspace, owner, member, project, tag): + entry_owner = TimeEntry.objects.create( + workspace=workspace, + user=owner, + project=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(tag) + entry_member = TimeEntry.objects.create( + workspace=workspace, + user=member, + project=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(tag) + return [entry_owner, entry_member] + + +def test_member_only_sees_own_chart_report(api_client, member, workspace, time_entries): + api_client.force_authenticate(user=member) + + response = api_client.get( + "/api/reports/chart/", + {"workspace": str(workspace.id), "period": "this_month"}, + ) + + assert response.status_code == 200 + assert response.data["summary"]["total_duration"] == "01:00:00" + + +def test_admin_can_request_combined_table_report(api_client, admin, workspace, time_entries): + api_client.force_authenticate(user=admin) + + response = api_client.get( + "/api/reports/table/", + {"workspace": str(workspace.id), "period": "this_month"}, + ) + + assert response.status_code == 200 + assert response.data["summary"]["total_duration"] == "03:00:00" + assert len(response.data["days"]) == 2 + + +def test_custom_period_longer_than_31_days_is_rejected(api_client, owner, workspace): + api_client.force_authenticate(user=owner) + + response = api_client.get( + "/api/reports/chart/", + { + "workspace": str(workspace.id), + "period": "period", + "from_date": "2026-01-01", + "to_date": "2026-02-15", + }, + ) + + assert response.status_code == 400 + + +def test_persian_this_month_uses_jalali_month_bounds(api_client, owner, workspace, project, monkeypatch): + api_client.force_authenticate(user=owner) + monkeypatch.setattr("apps.reports.services.aggregation.timezone.localdate", lambda: date(2026, 4, 27)) + + TimeEntry.objects.create( + workspace=workspace, + user=owner, + project=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=workspace, + user=owner, + project=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 = api_client.get( + "/api/reports/table/", + {"workspace": str(workspace.id), "period": "this_month", "language": "fa"}, + ) + + assert response.status_code == 200 + assert response.data["summary"]["total_duration"] == "02:00:00" + assert response.data["scope"]["from_date"] == "2026-04-21" diff --git a/config/settings/base.py b/config/settings/base.py index 3b5296a..943e5cc 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -46,6 +46,7 @@ LOCAL_APPS = [ "apps.tags", "apps.time_entries", "apps.notifications", + "apps.reports", ] INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS @@ -197,7 +198,7 @@ CACHES = { } } -CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", f"redis://:{REDIS_PASSWORD}@{REDIS_HOST}:{REDIS_PORT}/1") +CELERY_BROKER_URL = os.getenv("CELERY_BROKER_URL", "redis://127.0.0.1:6379/0") CELERY_RESULT_BACKEND = os.getenv("CELERY_RESULT_BACKEND", "redis://127.0.0.1:6379/1") CELERY_ACCEPT_CONTENT = ["json"] CELERY_TASK_SERIALIZER = "json" @@ -230,7 +231,9 @@ NOTIFICATION_TOAST_LEVELS = tuple( if level.strip() ) -CELERY_IMPORTS = ("apps.users.tasks", "apps.notifications.tasks") +REPORT_EXPORT_RETENTION_DAYS = int(os.getenv("REPORT_EXPORT_RETENTION_DAYS", "7")) + +CELERY_IMPORTS = ("apps.users.tasks", "apps.notifications.tasks", "apps.reports.tasks") STORAGES = { diff --git a/config/urls.py b/config/urls.py index be25ddd..b575a98 100644 --- a/config/urls.py +++ b/config/urls.py @@ -22,6 +22,7 @@ urlpatterns = [ path('api/', include('apps.tags.api.urls'), name="tags"), path('api/', include('apps.time_entries.api.urls'), name="time_entries"), path("api/notifications/", include("apps.notifications.api.urls"), name="notifications"), + path("api/reports/", include("apps.reports.api.urls"), name="reports"), ] if settings.DEBUG: diff --git a/requirements/base.txt b/requirements/base.txt index 01b3169..6e58c63 100644 --- a/requirements/base.txt +++ b/requirements/base.txt @@ -29,12 +29,17 @@ psycopg[binary]>=3.2 django-auditlog==3.4.1 python-json-logger==3.3.0 -# Utilities -python-dateutil>=2.9 -requests - -# Image/file handling -Pillow>=10.3 +# Utilities +python-dateutil>=2.9 +requests +openpyxl>=3.1 +reportlab>=4.0 +jdatetime>=5.2 +arabic-reshaper>=3.0 +python-bidi>=0.6 + +# Image/file handling +Pillow>=10.3 # background tasks celery==5.4.0