Compare commits
2 Commits
fadf898486
...
208e81139b
| Author | SHA1 | Date | |
|---|---|---|---|
| 208e81139b | |||
| e26263e93f |
1
apps/reports/__init__.py
Normal file
1
apps/reports/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
apps/reports/api/__init__.py
Normal file
1
apps/reports/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
39
apps/reports/api/serializers.py
Normal file
39
apps/reports/api/serializers.py
Normal 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
20
apps/reports/api/urls.py
Normal 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
110
apps/reports/api/views.py
Normal 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
7
apps/reports/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ReportsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.reports"
|
||||
|
||||
BIN
apps/reports/assets/fonts/Vazirmatn-Bold.ttf
Normal file
BIN
apps/reports/assets/fonts/Vazirmatn-Bold.ttf
Normal file
Binary file not shown.
BIN
apps/reports/assets/fonts/Vazirmatn-Regular.ttf
Normal file
BIN
apps/reports/assets/fonts/Vazirmatn-Regular.ttf
Normal file
Binary file not shown.
47
apps/reports/migrations/0001_initial.py
Normal file
47
apps/reports/migrations/0001_initial.py
Normal 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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
1
apps/reports/migrations/__init__.py
Normal file
1
apps/reports/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
83
apps/reports/models.py
Normal file
83
apps/reports/models.py
Normal 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"])
|
||||
|
||||
15
apps/reports/services/__init__.py
Normal file
15
apps/reports/services/__init__.py
Normal 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",
|
||||
]
|
||||
610
apps/reports/services/aggregation.py
Normal file
610
apps/reports/services/aggregation.py
Normal file
@@ -0,0 +1,610 @@
|
||||
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()
|
||||
if filters.language == "fa":
|
||||
persian_date = jdatetime.date.fromgregorian(date=bucket_date)
|
||||
return f"{persian_date.year:04d}-{persian_date.month:02d}"
|
||||
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
|
||||
if filters.language == "fa":
|
||||
persian_date = jdatetime.date.fromgregorian(date=local_dt.date())
|
||||
return f"{persian_date.year:04d}-{persian_date.month:02d}", local_dt.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],
|
||||
}
|
||||
173
apps/reports/services/export_i18n.py
Normal file
173
apps/reports/services/export_i18n.py
Normal 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
|
||||
410
apps/reports/services/exporters.py
Normal file
410
apps/reports/services/exporters.py
Normal 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
120
apps/reports/tasks.py
Normal 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
|
||||
1
apps/reports/tests/__init__.py
Normal file
1
apps/reports/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
217
apps/reports/tests/test_tasks.py
Normal file
217
apps/reports/tests/test_tasks.py
Normal 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)
|
||||
163
apps/reports/tests/test_views.py
Normal file
163
apps/reports/tests/test_views.py
Normal 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"
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user