feat(reports): add localized workspace reports and exports

This commit is contained in:
2026-04-27 16:15:41 +03:30
parent fadf898486
commit e26263e93f
22 changed files with 2029 additions and 8 deletions

1
apps/reports/__init__.py Normal file
View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

@@ -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

20
apps/reports/api/urls.py Normal file
View File

@@ -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)),
]

110
apps/reports/api/views.py Normal file
View File

@@ -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

7
apps/reports/apps.py Normal file
View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class ReportsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.reports"

Binary file not shown.

Binary file not shown.

View File

@@ -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')],
},
),
]

View File

@@ -0,0 +1 @@

83
apps/reports/models.py Normal file
View File

@@ -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"])

View File

@@ -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",
]

View File

@@ -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],
}

View File

@@ -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

View File

@@ -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()

120
apps/reports/tasks.py Normal file
View File

@@ -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

View File

@@ -0,0 +1 @@

View File

@@ -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)

View File

@@ -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"

View File

@@ -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 = {

View File

@@ -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:

View File

@@ -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