Compare commits

...

4 Commits

Author SHA1 Message Date
bb06762377 ci(backend): add gitea actions pipeline
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled
2026-05-14 18:18:25 +03:30
d4a52d6f3b feat(reports): refine exports and restore project access 2026-05-14 17:06:35 +03:30
77c07adec8 feat(reports): support multi-user chart series 2026-05-13 09:59:23 +03:30
f9c4c06531 feat(users): return otp expiry metadata 2026-05-13 09:58:58 +03:30
20 changed files with 1764 additions and 157 deletions

View File

@@ -0,0 +1,82 @@
name: Backend CI/CD
on:
push:
branches:
- main
pull_request:
permissions:
contents: read
jobs:
test:
runs-on: qlockify-python
steps:
- name: Install system dependencies
run: |
apt-get update
apt-get install -y --no-install-recommends git
- name: Checkout repository
env:
REPO_URL: ${{ gitea.server_url }}/${{ gitea.repository }}.git
REPO_SHA: ${{ gitea.sha }}
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
WORKSPACE: ${{ gitea.workspace }}
run: |
mkdir -p "$WORKSPACE"
cd "$WORKSPACE"
git init
git remote add origin "$REPO_URL"
git -c http.extraHeader="Authorization: Bearer $GITEA_TOKEN" fetch --depth 1 origin "$REPO_SHA"
git checkout --detach FETCH_HEAD
- name: Install Python dependencies
working-directory: ${{ gitea.workspace }}
run: |
python -m pip install --upgrade pip
python -m pip install -r requirements/base.txt -r requirements/dev.txt
- name: Lint backend
working-directory: ${{ gitea.workspace }}
run: python -m ruff check .
- name: Run backend tests
working-directory: ${{ gitea.workspace }}
run: python manage.py test --settings=config.settings.test
deploy:
if: github.event_name == 'push' && github.ref_name == 'main'
needs:
- test
runs-on: qlockify-deploy
steps:
- name: Install SSH client
run: |
apt-get update
apt-get install -y --no-install-recommends bash openssh-client
- name: Configure SSH
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
SSH_KNOWN_HOSTS: ${{ secrets.SSH_KNOWN_HOSTS }}
run: |
install -m 700 -d ~/.ssh
printf '%s\n' "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "$SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
- name: Deploy backend services
env:
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
DEPLOY_PORT: ${{ vars.DEPLOY_PORT }}
DEPLOY_USER: ${{ vars.DEPLOY_USER }}
DEPLOY_PATH: ${{ vars.DEPLOY_PATH }}
DEPLOY_BRANCH: ${{ vars.DEPLOY_BRANCH }}
BACKEND_BRANCH: ${{ vars.BACKEND_BRANCH }}
FRONTEND_BRANCH: ${{ vars.FRONTEND_BRANCH }}
run: |
ssh -p "${DEPLOY_PORT:-22}" "${DEPLOY_USER}@${DEPLOY_HOST}" \
"DEPLOY_ROOT='${DEPLOY_PATH}' DEPLOY_BRANCH='${DEPLOY_BRANCH}' BACKEND_BRANCH='${BACKEND_BRANCH}' FRONTEND_BRANCH='${FRONTEND_BRANCH}' bash '${DEPLOY_PATH}/scripts/deploy.sh' backend"

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):
@@ -387,31 +591,57 @@ 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] = {}
grouped_entries: dict[str | None, list[TimeEntry]] = defaultdict(list)
if filters.is_workspace_scope and not filters.user_id:
for entry in entries:
grouped_entries[str(entry.user_id)].append(entry)
else:
grouped_entries[filters.user_id] = entries
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,
serialized_series = []
for _, series_entries in sorted(
grouped_entries.items(),
key=lambda item: _user_display(item[1][0].user).lower() if item[1] else "",
):
if not series_entries:
continue
buckets: dict[str, dict] = {}
for entry in series_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)
user = series_entries[0].user
serialized_series.append(
{
"bucket_key": bucket_id,
"bucket_label": _bucket_label(filters, bucket_date),
"total_seconds": 0,
"total_duration": "00:00:00",
},
"user": {
"id": str(user.id),
"name": _user_display(user),
"mobile": user.mobile,
},
"buckets": serialized_buckets,
}
)
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,
"series": serialized_series,
}
@@ -445,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),
@@ -456,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]:
@@ -595,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]:
@@ -616,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)
per_user_reports = build_user_scoped_table_reports(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)
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

@@ -100,6 +100,28 @@ class ReportViewTests(APITestCase):
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["summary"]["total_duration"], "01:00:00")
self.assertEqual(len(response.data["series"]), 1)
self.assertEqual(response.data["series"][0]["user"]["id"], str(self.member.id))
def test_admin_chart_without_user_filter_returns_series_for_all_users(self):
self.client.force_authenticate(user=self.admin)
with patch(
"apps.reports.services.aggregation.timezone.localdate",
return_value=date(2026, 4, 20),
):
response = self.client.get(
"/api/reports/chart/",
{"workspace": str(self.workspace.id), "period": "this_month"},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["summary"]["total_duration"], "03:00:00")
self.assertEqual(len(response.data["series"]), 2)
self.assertEqual(
{series["user"]["id"] for series in response.data["series"]},
{str(self.owner.id), str(self.member.id)},
)
def test_admin_can_request_combined_table_report(self):
self.client.force_authenticate(user=self.admin)
@@ -116,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

@@ -125,12 +125,12 @@ class SendOTPView(APIView):
serializer = SendOTPSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
generate_and_send_otp(
payload = generate_and_send_otp(
mobile=serializer.validated_data["mobile"],
mode=serializer.validated_data["mode"]
)
return Response({"detail": "OTP sent successfully"}, status=status.HTTP_200_OK)
return Response(payload, status=status.HTTP_200_OK)
class LoginView(APIView):

View File

@@ -1,5 +1,6 @@
import random
import string
from datetime import timedelta
from django.contrib.auth import get_user_model, password_validation
from django.core.exceptions import ValidationError as DjangoValidationError
@@ -18,6 +19,7 @@ User = get_user_model()
USER_ALREADY_EXISTS_MESSAGE = "User already exists."
PASSWORD_REUSE_MESSAGE = "\u0631\u0645\u0632 \u0639\u0628\u0648\u0631 \u062c\u062f\u06cc\u062f \u0646\u0628\u0627\u06cc\u062f \u0628\u0627 \u0631\u0645\u0632 \u0639\u0628\u0648\u0631 \u0642\u0628\u0644\u06cc \u06cc\u06a9\u0633\u0627\u0646 \u0628\u0627\u0634\u062f."
OTP_EXPIRY_SECONDS = 120
def _validate_new_password(password, *, user, field_name):
@@ -90,9 +92,15 @@ def generate_and_send_otp(mobile, mode):
verification_code = "".join(random.choices(string.digits, k=5))
redis_conn = get_redis_connection("default")
redis_conn.setex(f"verification_code:{mobile}", 120, verification_code)
redis_conn.setex(f"verification_code:{mobile}", OTP_EXPIRY_SECONDS, verification_code)
send_verification_sms.delay(mobile, verification_code)
expires_at = timezone.now() + timedelta(seconds=OTP_EXPIRY_SECONDS)
return {
"detail": "OTP sent successfully",
"expires_in_seconds": OTP_EXPIRY_SECONDS,
"expires_at": expires_at.isoformat(),
}
def login_with_password(mobile, password, request=None):

View File

@@ -53,6 +53,11 @@ class UserApiViewTests(APITestCase):
@patch("apps.users.api.views.generate_and_send_otp")
def test_send_otp_view_validates_and_dispatches(self, generate_and_send_otp):
generate_and_send_otp.return_value = {
"detail": "OTP sent successfully",
"expires_in_seconds": 120,
"expires_at": "2026-05-12T10:00:00+03:30",
}
response = self.client.post(
"/api/users/otp/send/",
{"mobile": "09123330009", "mode": "login"},
@@ -60,6 +65,7 @@ class UserApiViewTests(APITestCase):
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["expires_in_seconds"], 120)
generate_and_send_otp.assert_called_once_with(
mobile="09123330009",
mode="login",
@@ -67,6 +73,11 @@ class UserApiViewTests(APITestCase):
@patch("apps.users.api.views.generate_and_send_otp")
def test_send_otp_view_supports_forget_password_mode(self, generate_and_send_otp):
generate_and_send_otp.return_value = {
"detail": "OTP sent successfully",
"expires_in_seconds": 120,
"expires_at": "2026-05-12T10:00:00+03:30",
}
response = self.client.post(
"/api/users/otp/send/",
{"mobile": "09123330001", "mode": "forget_password"},

View File

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