feat(reports): refine exports and restore project access

This commit is contained in:
2026-05-14 17:06:35 +03:30
parent 77c07adec8
commit d4a52d6f3b
16 changed files with 1594 additions and 136 deletions

View File

@@ -40,3 +40,17 @@ class ProjectUpdateSerializer(serializers.Serializer):
description = serializers.CharField(required=False, allow_blank=True)
color = serializers.CharField(max_length=7, required=False, allow_blank=True)
is_archived = serializers.BooleanField(required=False)
class ProjectAccessQuerySerializer(serializers.Serializer):
workspace = serializers.UUIDField()
user = serializers.UUIDField()
class ProjectAccessMutationSerializer(serializers.Serializer):
workspace = serializers.UUIDField()
user = serializers.UUIDField()
project_ids = serializers.ListField(
child=serializers.UUIDField(),
allow_empty=False,
)

View File

@@ -15,8 +15,17 @@ from apps.clients.models import Client
from apps.projects.models import Project
from apps.projects.api.serializers import (
ProjectSerializer, ProjectCreateSerializer, ProjectUpdateSerializer,
ProjectAccessMutationSerializer, ProjectAccessQuerySerializer,
)
from apps.projects.api.permissions import IsProjectMember, IsProjectManager
from apps.projects.services.access import (
build_project_access_items,
ensure_workspace_project_access,
filter_projects_for_user,
get_access_managed_membership,
grant_project_accesses,
revoke_project_accesses,
)
from apps.projects.services.projects import (
create_project,
update_project,
@@ -67,11 +76,10 @@ class ProjectViewSet(ModelViewSet):
if getattr(self, "swagger_fake_view", False) or not self.request.user.is_authenticated:
return Project.objects.none()
queryset = Project.objects.filter(
workspace__memberships__user=self.request.user,
workspace__memberships__is_active=True,
is_deleted=False
).distinct()
queryset = filter_projects_for_user(
self.request.user,
Project.objects.filter(is_deleted=False),
)
client_ids = [client_id for client_id in self.request.query_params.getlist("clients") if client_id]
if client_ids:
@@ -159,3 +167,67 @@ class ProjectViewSet(ModelViewSet):
output_serializer = ProjectSerializer(updated_project)
return Response(output_serializer.data, status=status.HTTP_200_OK)
@action(detail=False, methods=["get"], url_path="access")
def access(self, request):
serializer = ProjectAccessQuerySerializer(data=request.query_params)
serializer.is_valid(raise_exception=True)
workspace = get_object_or_404(
Workspace,
id=serializer.validated_data["workspace"],
is_deleted=False,
)
ensure_workspace_project_access(request.user, workspace)
membership = get_access_managed_membership(workspace, str(serializer.validated_data["user"]))
return Response(
{
"workspace": {"id": str(workspace.id), "name": workspace.name},
"user": {
"id": str(membership.user_id),
"name": membership.user.full_name or membership.user.mobile,
"mobile": membership.user.mobile,
"role": membership.role,
},
"items": build_project_access_items(workspace=workspace, target_user=membership.user),
}
)
@action(detail=False, methods=["post"], url_path="access/grant")
def grant_access(self, request):
serializer = ProjectAccessMutationSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
workspace = get_object_or_404(
Workspace,
id=serializer.validated_data["workspace"],
is_deleted=False,
)
membership = get_access_managed_membership(workspace, str(serializer.validated_data["user"]))
changed = grant_project_accesses(
actor=request.user,
workspace=workspace,
target_user=membership.user,
project_ids=[str(project_id) for project_id in serializer.validated_data["project_ids"]],
)
return Response({"changed": changed}, status=status.HTTP_200_OK)
@action(detail=False, methods=["post"], url_path="access/revoke")
def revoke_access(self, request):
serializer = ProjectAccessMutationSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
workspace = get_object_or_404(
Workspace,
id=serializer.validated_data["workspace"],
is_deleted=False,
)
membership = get_access_managed_membership(workspace, str(serializer.validated_data["user"]))
changed = revoke_project_accesses(
actor=request.user,
workspace=workspace,
target_user=membership.user,
project_ids=[str(project_id) for project_id in serializer.validated_data["project_ids"]],
)
return Response({"changed": changed}, status=status.HTTP_200_OK)

View File

@@ -0,0 +1,38 @@
# Generated by Django 5.2.12 on 2026-05-13 12:30
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('projects', '0003_project_project_ws_arch_upd_idx'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='ProjectAccess',
fields=[
('id', models.UUIDField(default=uuid.uuid7, primary_key=True, serialize=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('is_deleted', models.BooleanField(default=False)),
('is_active', models.BooleanField(default=False)),
('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='created_%(app_label)s_%(class)s_set', to=settings.AUTH_USER_MODEL)),
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='access_memberships', to='projects.project')),
('updated_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='updated_%(app_label)s_%(class)s_set', to=settings.AUTH_USER_MODEL)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_accesses', to=settings.AUTH_USER_MODEL)),
],
options={
'db_table': 'project_access',
'ordering': ('-created_at',),
'indexes': [models.Index(fields=['project'], name='project_access_project_idx'), models.Index(fields=['user'], name='project_access_user_idx')],
'constraints': [models.UniqueConstraint(condition=models.Q(('is_deleted', False)), fields=('project', 'user'), name='unique_project_access')],
},
),
]

View File

@@ -130,3 +130,31 @@ class ProjectUserRate(BaseModel):
models.Index(fields=["project"], name="pur_project_idx"),
models.Index(fields=["user"], name="pur_user_idx"),
]
class ProjectAccess(BaseModel):
project = models.ForeignKey(
Project,
on_delete=models.CASCADE,
related_name="access_memberships",
)
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="project_accesses",
)
class Meta:
db_table = "project_access"
ordering = ("-created_at",)
constraints = [
models.UniqueConstraint(
fields=["project", "user"],
name="unique_project_access",
condition=models.Q(is_deleted=False),
)
]
indexes = [
models.Index(fields=["project"], name="project_access_project_idx"),
models.Index(fields=["user"], name="project_access_user_idx"),
]

View File

@@ -0,0 +1,144 @@
from __future__ import annotations
from django.contrib.auth import get_user_model
from django.db.models import Q, QuerySet
from django.utils import timezone
from rest_framework.exceptions import PermissionDenied, ValidationError
from apps.projects.models import Project, ProjectAccess
from apps.workspaces.models import Workspace, WorkspaceMembership
from apps.workspaces.services import PROJECTS_EDIT, get_workspace_role, has_workspace_capability
User = get_user_model()
PROJECT_ACCESS_MANAGED_ROLES = {
WorkspaceMembership.Role.MEMBER,
WorkspaceMembership.Role.GUEST,
}
PROJECT_ACCESS_IMPLICIT_ROLES = {
WorkspaceMembership.Role.OWNER,
WorkspaceMembership.Role.ADMIN,
}
def user_has_implicit_project_access(user, workspace: Workspace) -> bool:
return get_workspace_role(user, workspace) in PROJECT_ACCESS_IMPLICIT_ROLES
def user_has_project_access(user, project: Project) -> bool:
if not user or not getattr(user, "is_authenticated", False):
return False
if user_has_implicit_project_access(user, project.workspace):
return True
return ProjectAccess.objects.filter(project=project, user=user).exists()
def filter_projects_for_user(user, queryset: QuerySet[Project] | None = None) -> QuerySet[Project]:
if queryset is None:
queryset = Project.objects.all()
if not user or not getattr(user, "is_authenticated", False):
return queryset.none()
return queryset.filter(
Q(workspace__owner=user)
| Q(
workspace__memberships__user=user,
workspace__memberships__is_active=True,
workspace__memberships__role__in=PROJECT_ACCESS_IMPLICIT_ROLES,
)
| Q(
workspace__memberships__user=user,
workspace__memberships__is_active=True,
workspace__memberships__role__in=PROJECT_ACCESS_MANAGED_ROLES,
access_memberships__user=user,
)
).distinct()
def ensure_project_access(user, project: Project, *, message: str = "Selected project is unavailable.") -> None:
if not user_has_project_access(user, project):
raise ValidationError({"project_id": message})
def ensure_workspace_project_access(user, workspace: Workspace) -> None:
if not has_workspace_capability(user, workspace, PROJECTS_EDIT):
raise PermissionDenied("You do not have permission to manage project access in this workspace.")
def get_access_managed_membership(workspace: Workspace, user_id: str) -> WorkspaceMembership:
membership = WorkspaceMembership.objects.filter(
workspace=workspace,
user_id=user_id,
is_active=True,
is_deleted=False,
).select_related("user").first()
if not membership:
raise ValidationError({"user": "Selected user is not an active member of this workspace."})
if membership.role not in PROJECT_ACCESS_MANAGED_ROLES:
raise ValidationError({"user": "Owners and admins have implicit access to all projects."})
return membership
def build_project_access_items(*, workspace: Workspace, target_user) -> list[dict]:
explicit_access_ids = set(
ProjectAccess.objects.filter(project__workspace=workspace, user=target_user).values_list("project_id", flat=True)
)
projects = (
Project.objects.filter(workspace=workspace, is_deleted=False)
.select_related("client")
.order_by("client__name", "name")
)
return [
{
"id": str(project.id),
"name": project.name,
"description": project.description,
"color": project.color,
"is_archived": project.is_archived,
"client": (
{"id": str(project.client_id), "name": project.client.name}
if project.client_id and project.client
else None
),
"has_access": str(project.id) in {str(project_id) for project_id in explicit_access_ids},
}
for project in projects
]
def grant_project_accesses(*, actor, workspace: Workspace, target_user, project_ids: list[str]) -> int:
ensure_workspace_project_access(actor, workspace)
get_access_managed_membership(workspace, str(target_user.id))
projects = list(Project.objects.filter(workspace=workspace, id__in=project_ids, is_deleted=False))
if len(projects) != len(set(project_ids)):
raise ValidationError({"project_ids": "One or more selected projects do not belong to this workspace."})
changed = 0
for project in projects:
access, created, restored = ProjectAccess.get_or_restore(project=project, user=target_user)
if created or restored:
access.is_active = True
access.updated_at = timezone.now()
access.save(update_fields=["is_active", "updated_at"])
changed += 1
return changed
def revoke_project_accesses(*, actor, workspace: Workspace, target_user, project_ids: list[str]) -> int:
ensure_workspace_project_access(actor, workspace)
get_access_managed_membership(workspace, str(target_user.id))
accesses = list(
ProjectAccess.objects.filter(
project__workspace=workspace,
user=target_user,
project_id__in=project_ids,
).select_related("project")
)
changed = 0
for access in accesses:
access.delete()
changed += 1
return changed

View File

@@ -1,7 +1,7 @@
from rest_framework.test import APITestCase
from apps.clients.models import Client
from apps.projects.models import Project
from apps.projects.models import Project, ProjectAccess
from apps.users.models import User
from apps.workspaces.models import Workspace, WorkspaceMembership
@@ -34,16 +34,19 @@ class ProjectViewTests(APITestCase):
client=cls.first_client,
name="Alpha",
)
Project.objects.create(
cls.second_project = Project.objects.create(
workspace=cls.workspace,
client=cls.second_client,
name="Beta",
)
Project.objects.create(
cls.third_project = Project.objects.create(
workspace=cls.workspace,
client=cls.third_client,
name="Gamma",
)
cls.first_project = Project.objects.get(name="Alpha")
ProjectAccess.objects.create(project=cls.first_project, user=cls.member)
ProjectAccess.objects.create(project=cls.second_project, user=cls.member)
def test_project_list_supports_multi_client_filter(self):
self.client.force_authenticate(user=self.member)
@@ -68,3 +71,46 @@ class ProjectViewTests(APITestCase):
result_ids,
{str(self.first_client.id), str(self.second_client.id)},
)
def test_project_access_list_and_mutations_require_explicit_member_access(self):
self.client.force_authenticate(user=self.owner)
access_response = self.client.get(
"/api/projects/access/",
{"workspace": str(self.workspace.id), "user": str(self.member.id)},
)
self.assertEqual(access_response.status_code, 200)
items = access_response.data["items"]
gamma_item = next(item for item in items if item["id"] == str(self.third_project.id))
self.assertFalse(gamma_item["has_access"])
grant_response = self.client.post(
"/api/projects/access/grant/",
{
"workspace": str(self.workspace.id),
"user": str(self.member.id),
"project_ids": [str(self.third_project.id)],
},
format="json",
)
self.assertEqual(grant_response.status_code, 200)
access_response = self.client.get(
"/api/projects/access/",
{"workspace": str(self.workspace.id), "user": str(self.member.id)},
)
gamma_item = next(item for item in access_response.data["items"] if item["id"] == str(self.third_project.id))
self.assertTrue(gamma_item["has_access"])
revoke_response = self.client.post(
"/api/projects/access/revoke/",
{
"workspace": str(self.workspace.id),
"user": str(self.member.id),
"project_ids": [str(self.first_project.id)],
},
format="json",
)
self.assertEqual(revoke_response.status_code, 200)
self.assertFalse(ProjectAccess.objects.filter(project=self.first_project, user=self.member).exists())

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass, replace
from datetime import date, datetime, time, timedelta
from decimal import Decimal
from decimal import Decimal, ROUND_HALF_UP
from typing import Iterable
import jdatetime
@@ -15,6 +15,7 @@ from rest_framework import serializers
from apps.clients.models import Client
from apps.projects.models import Project
from apps.projects.services.access import user_has_project_access
from apps.tags.models import Tag
from apps.time_entries.models import TimeEntry
from apps.workspaces.models import Workspace
@@ -90,6 +91,205 @@ def _serialize_rate(amount: Decimal | None, currency: str | None) -> dict | None
}
def _serialize_distinct_rates(entries: list[TimeEntry]) -> list[dict]:
unique_rates: set[tuple[str, str]] = set()
for entry in entries:
if not entry.hourly_rate:
continue
unique_rates.add((f"{Decimal(entry.hourly_rate).quantize(Decimal('0.01'))}", entry.currency or "USD"))
return [
{"amount": amount, "currency": currency}
for amount, currency in sorted(unique_rates, key=lambda item: (item[1], Decimal(item[0])))
]
def _serialize_rate_periods(entries: list[TimeEntry]) -> list[dict]:
sorted_entries = sorted(entries, key=lambda entry: entry.start_time)
periods: list[dict] = []
current: dict | None = None
for entry in sorted_entries:
if not entry.hourly_rate:
continue
amount = f"{Decimal(entry.hourly_rate).quantize(Decimal('0.01'))}"
currency = entry.currency or "USD"
start_date = _localize_datetime(entry.start_time).date()
end_source = entry.end_time or entry.start_time
end_date = _localize_datetime(end_source).date()
if (
current
and current["amount"] == amount
and current["currency"] == currency
):
if end_date > current["to_date"]:
current["to_date"] = end_date
continue
if current:
periods.append(
{
"amount": current["amount"],
"currency": current["currency"],
"from_date": current["from_date"].isoformat(),
"to_date": current["to_date"].isoformat(),
}
)
current = {
"amount": amount,
"currency": currency,
"from_date": start_date,
"to_date": end_date,
}
if current:
periods.append(
{
"amount": current["amount"],
"currency": current["currency"],
"from_date": current["from_date"].isoformat(),
"to_date": current["to_date"].isoformat(),
}
)
return periods
def _serialize_percentage_rows(shares: dict[str, dict], total_seconds: int) -> list[dict]:
if total_seconds <= 0:
return []
rows = []
for bucket in shares.values():
percentage = (
Decimal(bucket["seconds"]) * Decimal("100") / Decimal(total_seconds)
).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
rows.append(
{
"id": bucket["id"],
"name": bucket["name"],
"percentage": f"{percentage}",
}
)
rows.sort(key=lambda item: (-Decimal(item["percentage"]), item["name"].lower()))
return rows
def _build_user_summary(user, entries: list[TimeEntry]) -> dict:
summary = _summary_from_entries(entries)
project_shares: dict[str, dict] = {}
client_shares: dict[str, dict] = {}
tag_shares: dict[str, dict] = {}
total_seconds = summary["billable_seconds"]
for entry in entries:
if not entry.is_billable:
continue
duration_seconds = get_entry_duration_seconds(entry)
if duration_seconds <= 0:
continue
if entry.project_id:
project_bucket = project_shares.setdefault(
str(entry.project_id),
{"id": str(entry.project_id), "name": entry.project.name, "seconds": 0},
)
project_bucket["seconds"] += duration_seconds
if entry.project and entry.project.client_id:
client_bucket = client_shares.setdefault(
str(entry.project.client_id),
{"id": str(entry.project.client_id), "name": entry.project.client.name, "seconds": 0},
)
client_bucket["seconds"] += duration_seconds
tags = list(entry.tags.all())
if tags:
allocated_seconds = Decimal(duration_seconds) / Decimal(len(tags))
for tag in tags:
tag_bucket = tag_shares.setdefault(
str(tag.id),
{"id": str(tag.id), "name": tag.name, "seconds": Decimal("0")},
)
tag_bucket["seconds"] += allocated_seconds
return {
"user": {
"id": str(user.id),
"name": _user_display(user),
"mobile": user.mobile,
},
"hourly_rates": _serialize_distinct_rates(entries),
"rate_periods": _serialize_rate_periods(entries),
"total_seconds": total_seconds,
"total_duration": summary["total_duration"],
"billable_seconds": summary["billable_seconds"],
"billable_duration": summary["billable_duration"],
"non_billable_seconds": summary["non_billable_seconds"],
"non_billable_duration": summary["non_billable_duration"],
"income_totals": summary["income_totals"],
"project_percentages": _serialize_percentage_rows(project_shares, total_seconds),
"client_percentages": _serialize_percentage_rows(client_shares, total_seconds),
"tag_percentages": _serialize_percentage_rows(tag_shares, total_seconds),
}
def _build_user_summaries(entries: list[TimeEntry]) -> list[dict]:
grouped: dict[str, list[TimeEntry]] = defaultdict(list)
for entry in entries:
grouped[str(entry.user_id)].append(entry)
summaries = [_build_user_summary(grouped_entries[0].user, grouped_entries) for grouped_entries in grouped.values() if grouped_entries]
summaries.sort(key=lambda item: item["user"]["name"].lower())
return summaries
def _build_overall_percentage_payload(entries: list[TimeEntry]) -> dict:
project_shares: dict[str, dict] = {}
client_shares: dict[str, dict] = {}
tag_shares: dict[str, dict] = {}
total_seconds = 0
for entry in entries:
if not entry.is_billable:
continue
duration_seconds = get_entry_duration_seconds(entry)
if duration_seconds <= 0:
continue
total_seconds += duration_seconds
if entry.project_id:
project_bucket = project_shares.setdefault(
str(entry.project_id),
{"id": str(entry.project_id), "name": entry.project.name, "seconds": 0},
)
project_bucket["seconds"] += duration_seconds
if entry.project and entry.project.client_id:
client_bucket = client_shares.setdefault(
str(entry.project.client_id),
{"id": str(entry.project.client_id), "name": entry.project.client.name, "seconds": 0},
)
client_bucket["seconds"] += duration_seconds
tags = list(entry.tags.all())
if tags:
allocated_seconds = Decimal(duration_seconds) / Decimal(len(tags))
for tag in tags:
tag_bucket = tag_shares.setdefault(
str(tag.id),
{"id": str(tag.id), "name": tag.name, "seconds": Decimal("0")},
)
tag_bucket["seconds"] += allocated_seconds
return {
"project_percentages": _serialize_percentage_rows(project_shares, total_seconds),
"client_percentages": _serialize_percentage_rows(client_shares, total_seconds),
"tag_percentages": _serialize_percentage_rows(tag_shares, total_seconds),
}
def _add_income(bucket: defaultdict[str, Decimal], entry: TimeEntry):
if not entry.is_billable or not entry.hourly_rate:
return
@@ -251,6 +451,10 @@ def load_report_filters(actor, raw_data) -> ReportFilters:
raise serializers.ValidationError("Client does not belong to this workspace.")
if project_id and not Project.objects.filter(id=project_id, workspace=workspace).exists():
raise serializers.ValidationError("Project does not belong to this workspace.")
if project_id and not is_workspace_scope:
project = Project.objects.filter(id=project_id, workspace=workspace).first()
if project and not user_has_project_access(actor, project):
raise serializers.ValidationError("Project does not belong to this workspace.")
if tag_ids:
existing_tag_ids = set(Tag.objects.filter(id__in=tag_ids, workspace=workspace).values_list("id", flat=True))
if len(existing_tag_ids) != len(tag_ids):
@@ -471,10 +675,16 @@ def _scope_payload(filters: ReportFilters) -> dict:
}
def _table_report_payload(filters: ReportFilters, entries: list[TimeEntry]) -> dict:
def _table_report_payload(
filters: ReportFilters,
entries: list[TimeEntry],
*,
user_summary: dict | None = None,
user_summaries: list[dict] | None = None,
) -> dict:
summary = _summary_from_entries(entries)
include_latest_rate = not (filters.is_workspace_scope and not filters.user_id)
return {
payload = {
"scope": _scope_payload(filters),
"summary": summary,
"days": _group_daily(entries, include_latest_rate=include_latest_rate),
@@ -482,6 +692,13 @@ def _table_report_payload(filters: ReportFilters, entries: list[TimeEntry]) -> d
"projects": _build_breakdown(entries, "projects"),
"tags": _build_breakdown(entries, "tags"),
}
if filters.is_workspace_scope and not filters.user_id:
payload.update(_build_overall_percentage_payload(entries))
if user_summary is not None:
payload["user_summary"] = user_summary
if user_summaries is not None:
payload["user_summaries"] = user_summaries
return payload
def _group_daily(entries: list[TimeEntry], *, include_latest_rate: bool) -> list[dict]:
@@ -621,7 +838,10 @@ def _build_breakdown(entries: list[TimeEntry], kind: str) -> list[dict]:
def build_table_report(actor, raw_filters) -> dict:
filters = load_report_filters(actor, raw_filters)
entries = list(_base_queryset(filters))
return _table_report_payload(filters, entries)
if filters.is_workspace_scope and not filters.user_id:
return _table_report_payload(filters, entries, user_summaries=_build_user_summaries(entries))
user_summary = _build_user_summary(entries[0].user, entries) if entries and filters.user_id else None
return _table_report_payload(filters, entries, user_summary=user_summary)
def build_user_scoped_table_reports(actor, raw_filters) -> list[dict]:
@@ -642,7 +862,13 @@ def build_user_scoped_table_reports(actor, raw_filters) -> list[dict]:
reports: list[dict] = []
for user_id, user_entries in sorted_groups:
user_filters = replace(filters, user_id=user_id)
reports.append(_table_report_payload(user_filters, user_entries))
reports.append(
_table_report_payload(
user_filters,
user_entries,
user_summary=_build_user_summary(user_entries[0].user, user_entries),
)
)
return reports

View File

@@ -24,6 +24,7 @@ TRANSLATIONS = {
"en": {
"report_title": "Workspace Report",
"overall_sheet": "Overall Report",
"users_summary_sheet": "Users Summary",
"workspace": "Workspace",
"period": "Period",
"from_date": "From date",
@@ -38,6 +39,18 @@ TRANSLATIONS = {
"non_billable_hours": "Non-billable hours",
"hourly_rate": "Hourly rate",
"income": "Income",
"working_hours": "Working hours",
"non_working_hours": "Non-working hours",
"hourly_rates": "Hourly rates",
"project_percentages": "Project percentages",
"client_percentages": "Client percentages",
"tag_percentages": "Tag percentages",
"summary_by_user": "Summary by user",
"rate_history": "Hourly rate history",
"from": "From",
"to": "To",
"percentage": "Percentage",
"none": "None",
"daily_summary": "Daily Summary",
"clients": "Clients",
"projects": "Projects",
@@ -50,6 +63,7 @@ TRANSLATIONS = {
"fa": {
"report_title": "گزارش فضای کاری",
"overall_sheet": "گزارش کلی",
"users_summary_sheet": "خلاصه کاربران",
"workspace": "فضای کاری",
"period": "بازه",
"from_date": "از تاریخ",
@@ -63,7 +77,19 @@ TRANSLATIONS = {
"billable_hours": "ساعات کاری",
"non_billable_hours": "ساعات غیر کاری",
"hourly_rate": "نرخ ساعتی",
"income": "درآمد",
"income": "کارکرد",
"working_hours": "ساعات کاری",
"non_working_hours": "ساعات غیرکاری",
"hourly_rates": "نرخ‌های ساعتی",
"project_percentages": "درصد پروژه‌ها",
"client_percentages": "درصد مشتری‌ها",
"tag_percentages": "درصد تگ‌ها",
"summary_by_user": "خلاصه کاربران",
"rate_history": "تاریخچه نرخ ساعتی",
"from": "از",
"to": "تا",
"percentage": "درصد",
"none": "بدون مورد",
"daily_summary": "خلاصه روزانه",
"clients": "مشتریان",
"projects": "پروژه‌ها",

File diff suppressed because it is too large Load Diff

View File

@@ -30,13 +30,13 @@ def generate_report_export_task(job_id: str):
try:
locale = build_export_locale(job.filters.get("language"))
report_data = build_table_report(job.requesting_user, job.filters)
if job.export_type == ReportExportJob.ExportType.EXCEL:
per_user_reports = build_user_scoped_table_reports(job.requesting_user, job.filters)
if job.export_type == ReportExportJob.ExportType.EXCEL:
content = build_excel_report(report_data=report_data, locale=locale, per_user_reports=per_user_reports)
suffix = "xlsx"
mime_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
else:
content = build_pdf_report(report_data=report_data, locale=locale)
content = build_pdf_report(report_data=report_data, locale=locale, per_user_reports=per_user_reports)
suffix = "pdf"
mime_type = "application/pdf"

View File

@@ -46,15 +46,58 @@ def make_report_data(*, user_name="Owner User", mobile="09129990001", hourly_rat
}
def make_user_summary(*, name: str, mobile: str):
return {
"user": {"id": mobile, "name": name, "mobile": mobile},
"hourly_rates": [{"amount": "15.00", "currency": "USD"}],
"rate_periods": [
{
"amount": "15.00",
"currency": "USD",
"from_date": "2026-04-01",
"to_date": "2026-04-30",
}
],
"total_seconds": 7200,
"total_duration": "02:00:00",
"billable_seconds": 7200,
"billable_duration": "02:00:00",
"non_billable_seconds": 0,
"non_billable_duration": "00:00:00",
"income_totals": [{"amount": "30.00", "currency": "USD"}],
"project_percentages": [{"id": "1", "name": "Website", "percentage": "100"}],
"client_percentages": [{"id": "1", "name": "Acme", "percentage": "100"}],
"tag_percentages": [{"id": "1", "name": "Design", "percentage": "100"}],
}
class ReportExporterTests(TestCase):
def test_excel_export_adds_per_user_sheets_and_daily_rate_column(self):
locale = build_export_locale("en")
report_data = make_report_data(
hourly_rate={"amount": "15.00", "currency": "USD"},
)
report_data["user_summaries"] = [
make_user_summary(name="Owner User", mobile="09129990001"),
make_user_summary(name="Team Mate", mobile="09129990002"),
]
per_user_reports = [
make_report_data(user_name="Owner User", mobile="09129990001"),
make_report_data(user_name="Team Mate", mobile="09129990002"),
{
**make_report_data(
user_name="Owner User",
mobile="09129990001",
hourly_rate={"amount": "15.00", "currency": "USD"},
),
"user_summary": make_user_summary(name="Owner User", mobile="09129990001"),
},
{
**make_report_data(
user_name="Team Mate",
mobile="09129990002",
hourly_rate={"amount": "15.00", "currency": "USD"},
),
"user_summary": make_user_summary(name="Team Mate", mobile="09129990002"),
},
]
workbook = load_workbook(
@@ -71,13 +114,38 @@ class ReportExporterTests(TestCase):
self.assertIn("Owner User", workbook.sheetnames[1])
self.assertIn("Team Mate", workbook.sheetnames[2])
worksheet = workbook.active
values = list(worksheet.iter_rows(values_only=True))
summary_sheet = workbook[workbook.sheetnames[0]]
summary_values = list(summary_sheet.iter_rows(values_only=True))
self.assertTrue(any(row[:2] == ("User", "Owner User") for row in values if row))
self.assertTrue(any(row[:2] == ("Mobile", "09129990001") for row in values if row))
self.assertEqual(summary_sheet["A1"].value, "Workspace Report")
self.assertEqual(summary_sheet["B1"].value, "Exports")
self.assertEqual(summary_sheet["A15"].value, "Users Summary")
self.assertIn("A15:M15", {str(item) for item in summary_sheet.merged_cells.ranges})
self.assertEqual(
tuple(summary_sheet.iter_rows(min_row=16, max_row=16, values_only=True))[0][:13],
(
"Name",
"Mobile",
"Working hours",
"Non-working hours",
"Income",
"Hourly rate",
"Period",
"Clients",
"Percentage",
"Projects",
"Percentage",
"Tags",
"Percentage",
),
)
self.assertTrue(any(row and "Owner User" in row for row in summary_values))
self.assertTrue(any(row and "09129990001" in row for row in summary_values))
daily_header = next(row[:6] for row in values if row and row[0] == "Date")
user_sheet = workbook[workbook.sheetnames[1]]
user_values = list(user_sheet.iter_rows(values_only=True))
daily_header = next(row[:6] for row in user_values if row and "Date" in row)
self.assertEqual(
daily_header,
(
@@ -90,7 +158,7 @@ class ReportExporterTests(TestCase):
),
)
daily_row = next(row[:6] for row in values if row and row[0] == "2026/04/12")
daily_row = next(row[:6] for row in user_values if row and "2026/04/12" in row)
self.assertEqual(daily_row[4], "15 USD")
def test_pdf_export_supports_persian_locale(self):
@@ -98,7 +166,11 @@ class ReportExporterTests(TestCase):
report_data = make_report_data(
hourly_rate={"amount": "15.00", "currency": "USD"},
)
report_data["user_summaries"] = [make_user_summary(name="Owner User", mobile="09129990001")]
per_user_reports = [
{**make_report_data(user_name="Owner User", mobile="09129990001"), "user_summary": make_user_summary(name="Owner User", mobile="09129990001")}
]
content = build_pdf_report(report_data=report_data, locale=locale)
content = build_pdf_report(report_data=report_data, locale=locale, per_user_reports=per_user_reports)
self.assertEqual(content[:4], b"%PDF")

View File

@@ -138,8 +138,18 @@ class ReportViewTests(APITestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["summary"]["total_duration"], "03:00:00")
self.assertEqual(len(response.data["days"]), 2)
self.assertEqual(len(response.data["user_summaries"]), 2)
self.assertIsNone(response.data["days"][0]["latest_hourly_rate"])
self.assertIsNone(response.data["days"][1]["latest_hourly_rate"])
summaries = {item["user"]["id"]: item for item in response.data["user_summaries"]}
owner_summary = summaries[str(self.owner.id)]
member_summary = summaries[str(self.member.id)]
self.assertEqual(owner_summary["project_percentages"][0]["percentage"], "100")
self.assertEqual(owner_summary["client_percentages"][0]["percentage"], "100")
self.assertEqual(owner_summary["tag_percentages"][0]["percentage"], "100")
self.assertEqual(member_summary["project_percentages"], [])
self.assertEqual(member_summary["client_percentages"], [])
self.assertEqual(member_summary["tag_percentages"], [])
def test_daily_rate_uses_latest_billable_entry_snapshot(self):
self.client.force_authenticate(user=self.owner)

View File

@@ -3,6 +3,7 @@ from rest_framework import serializers
from core.serializers.base import BaseModelSerializer
from apps.time_entries.models import TimeEntry
from apps.projects.models import Project
from apps.projects.services.access import ensure_project_access
from apps.tags.models import Tag
@@ -99,6 +100,8 @@ class TimeEntryCreateSerializer(serializers.Serializer):
is_billable = serializers.BooleanField(default=False)
def validate(self, attrs):
user = self.context.get("request").user if self.context.get("request") else None
workspace_id = attrs.get("workspace_id")
project_id = attrs.pop("project_id", serializers.empty)
if project_id is not serializers.empty:
if project_id is None:
@@ -107,6 +110,10 @@ class TimeEntryCreateSerializer(serializers.Serializer):
project = Project.objects.filter(id=project_id).first()
if not project:
raise serializers.ValidationError({"project_id": "Selected project is unavailable."})
if workspace_id and str(project.workspace_id) != str(workspace_id):
raise serializers.ValidationError({"project_id": "Selected project is unavailable."})
if user:
ensure_project_access(user, project)
attrs["project"] = project
tag_ids = attrs.pop("tags", serializers.empty)
@@ -134,6 +141,7 @@ class TimeEntryUpdateSerializer(serializers.Serializer):
def validate(self, attrs):
entry = self.instance
user = self.context.get("request").user if self.context.get("request") else None
project_id = attrs.pop("project_id", serializers.empty)
if project_id is not serializers.empty:
@@ -146,6 +154,10 @@ class TimeEntryUpdateSerializer(serializers.Serializer):
project = Project.objects.filter(id=project_id).first()
if not project:
raise serializers.ValidationError({"project_id": "Selected project is unavailable."})
if entry and str(project.workspace_id) != str(entry.workspace_id):
raise serializers.ValidationError({"project_id": "Selected project is unavailable."})
if user:
ensure_project_access(user, project)
attrs["project"] = project
tag_ids = attrs.pop("tags", serializers.empty)

View File

@@ -2,6 +2,7 @@
from django.utils import timezone
from rest_framework.exceptions import ValidationError, PermissionDenied
from apps.projects.services.access import user_has_project_access
from apps.time_entries.models import TimeEntry
from apps.time_entries.services.rates import resolve_rate
from apps.workspaces.models import Workspace
@@ -42,6 +43,8 @@ def create_time_entry(user, workspace_id, start_time, end_time=None, project=Non
if project and project.workspace_id != workspace_id:
raise ValidationError({"project": "Project must belong to the same workspace."})
if project and not user_has_project_access(user, project):
raise ValidationError({"project_id": "Selected project is unavailable."})
duration = (end_time - start_time) if end_time else None
@@ -79,6 +82,8 @@ def update_time_entry(entry, **kwargs):
project = kwargs.get("project", entry.project)
if project and project.workspace_id != entry.workspace_id:
raise ValidationError({"project": "Project must belong to the same workspace."})
if project and not user_has_project_access(entry.user, project):
raise ValidationError({"project_id": "Selected project is unavailable."})
start_time = kwargs.get("start_time", entry.start_time)
end_time = kwargs.get("end_time", entry.end_time)

View File

@@ -3,10 +3,11 @@ from datetime import datetime
from django.utils import timezone
from rest_framework.test import APITestCase
from apps.projects.models import Project, ProjectAccess
from apps.tags.models import Tag
from apps.time_entries.models import TimeEntry
from apps.users.models import User
from apps.workspaces.models import Workspace
from apps.workspaces.models import Workspace, WorkspaceMembership
def make_aware(year, month, day, hour=9, minute=0, second=0):
@@ -139,3 +140,62 @@ class TimeEntryViewTests(APITestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["tags"], [])
def test_member_cannot_create_time_entry_for_inaccessible_project(self):
owner = User.objects.create_user(mobile="09120000001", password="secret123")
member = User.objects.create_user(mobile="09120000002", password="secret123")
workspace = Workspace.objects.create(name="Core", owner=owner)
WorkspaceMembership.objects.create(
workspace=workspace,
user=member,
role=WorkspaceMembership.Role.MEMBER,
is_active=True,
)
project = Project.objects.create(workspace=workspace, name="Restricted")
self.client.force_authenticate(user=member)
response = self.client.post(
"/api/time-entries/",
{
"workspace_id": str(workspace.id),
"project_id": str(project.id),
"description": "Blocked",
"start_time": make_aware(2026, 4, 24, 9, 0, 0).isoformat(),
"end_time": make_aware(2026, 4, 24, 10, 0, 0).isoformat(),
},
format="json",
)
self.assertEqual(response.status_code, 400)
self.assertTrue(
any("Selected project is unavailable." in item["message"] for item in response.data["messages"])
)
def test_member_can_create_time_entry_after_project_access_is_granted(self):
owner = User.objects.create_user(mobile="09120000011", password="secret123")
member = User.objects.create_user(mobile="09120000012", password="secret123")
workspace = Workspace.objects.create(name="Core", owner=owner)
WorkspaceMembership.objects.create(
workspace=workspace,
user=member,
role=WorkspaceMembership.Role.MEMBER,
is_active=True,
)
project = Project.objects.create(workspace=workspace, name="Accessible")
ProjectAccess.objects.create(project=project, user=member)
self.client.force_authenticate(user=member)
response = self.client.post(
"/api/time-entries/",
{
"workspace_id": str(workspace.id),
"project_id": str(project.id),
"description": "Allowed",
"start_time": make_aware(2026, 4, 24, 9, 0, 0).isoformat(),
"end_time": make_aware(2026, 4, 24, 10, 0, 0).isoformat(),
},
format="json",
)
self.assertEqual(response.status_code, 201)
self.assertEqual(response.data["project"], str(project.id))

View File

@@ -92,6 +92,7 @@ WORKSPACE_ROLE_CAPABILITIES = {
TAGS_VIEW,
PROJECTS_VIEW,
TIME_ENTRIES_VIEW_OWN,
TIME_ENTRIES_MANAGE_OWN,
},
}