feat(reports): add localized workspace reports and exports
This commit is contained in:
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"
|
||||
Reference in New Issue
Block a user