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) description = serializers.CharField(required=False, allow_blank=True)
color = serializers.CharField(max_length=7, required=False, allow_blank=True) color = serializers.CharField(max_length=7, required=False, allow_blank=True)
is_archived = serializers.BooleanField(required=False) 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.models import Project
from apps.projects.api.serializers import ( from apps.projects.api.serializers import (
ProjectSerializer, ProjectCreateSerializer, ProjectUpdateSerializer, ProjectSerializer, ProjectCreateSerializer, ProjectUpdateSerializer,
ProjectAccessMutationSerializer, ProjectAccessQuerySerializer,
) )
from apps.projects.api.permissions import IsProjectMember, IsProjectManager 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 ( from apps.projects.services.projects import (
create_project, create_project,
update_project, update_project,
@@ -67,11 +76,10 @@ class ProjectViewSet(ModelViewSet):
if getattr(self, "swagger_fake_view", False) or not self.request.user.is_authenticated: if getattr(self, "swagger_fake_view", False) or not self.request.user.is_authenticated:
return Project.objects.none() return Project.objects.none()
queryset = Project.objects.filter( queryset = filter_projects_for_user(
workspace__memberships__user=self.request.user, self.request.user,
workspace__memberships__is_active=True, Project.objects.filter(is_deleted=False),
is_deleted=False )
).distinct()
client_ids = [client_id for client_id in self.request.query_params.getlist("clients") if client_id] client_ids = [client_id for client_id in self.request.query_params.getlist("clients") if client_id]
if client_ids: if client_ids:
@@ -150,12 +158,76 @@ class ProjectViewSet(ModelViewSet):
return Response(status=status.HTTP_204_NO_CONTENT) return Response(status=status.HTTP_204_NO_CONTENT)
@action(detail=True, methods=["post"]) @action(detail=True, methods=["post"])
def archive(self, request, pk=None): def archive(self, request, pk=None):
""" """
Custom endpoint to toggle the archive status of a project. Custom endpoint to toggle the archive status of a project.
""" """
project = self.get_object() project = self.get_object()
updated_project = toggle_project_archive(project) updated_project = toggle_project_archive(project)
output_serializer = ProjectSerializer(updated_project) output_serializer = ProjectSerializer(updated_project)
return Response(output_serializer.data, status=status.HTTP_200_OK) 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=["project"], name="pur_project_idx"),
models.Index(fields=["user"], name="pur_user_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 rest_framework.test import APITestCase
from apps.clients.models import Client 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.users.models import User
from apps.workspaces.models import Workspace, WorkspaceMembership from apps.workspaces.models import Workspace, WorkspaceMembership
@@ -34,16 +34,19 @@ class ProjectViewTests(APITestCase):
client=cls.first_client, client=cls.first_client,
name="Alpha", name="Alpha",
) )
Project.objects.create( cls.second_project = Project.objects.create(
workspace=cls.workspace, workspace=cls.workspace,
client=cls.second_client, client=cls.second_client,
name="Beta", name="Beta",
) )
Project.objects.create( cls.third_project = Project.objects.create(
workspace=cls.workspace, workspace=cls.workspace,
client=cls.third_client, client=cls.third_client,
name="Gamma", 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): def test_project_list_supports_multi_client_filter(self):
self.client.force_authenticate(user=self.member) self.client.force_authenticate(user=self.member)
@@ -68,3 +71,46 @@ class ProjectViewTests(APITestCase):
result_ids, result_ids,
{str(self.first_client.id), str(self.second_client.id)}, {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 collections import defaultdict
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from datetime import date, datetime, time, timedelta from datetime import date, datetime, time, timedelta
from decimal import Decimal from decimal import Decimal, ROUND_HALF_UP
from typing import Iterable from typing import Iterable
import jdatetime import jdatetime
@@ -15,6 +15,7 @@ from rest_framework import serializers
from apps.clients.models import Client from apps.clients.models import Client
from apps.projects.models import Project from apps.projects.models import Project
from apps.projects.services.access import user_has_project_access
from apps.tags.models import Tag from apps.tags.models import Tag
from apps.time_entries.models import TimeEntry from apps.time_entries.models import TimeEntry
from apps.workspaces.models import Workspace 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): def _add_income(bucket: defaultdict[str, Decimal], entry: TimeEntry):
if not entry.is_billable or not entry.hourly_rate: if not entry.is_billable or not entry.hourly_rate:
return return
@@ -251,6 +451,10 @@ def load_report_filters(actor, raw_data) -> ReportFilters:
raise serializers.ValidationError("Client does not belong to this workspace.") raise serializers.ValidationError("Client does not belong to this workspace.")
if project_id and not Project.objects.filter(id=project_id, workspace=workspace).exists(): if project_id and not Project.objects.filter(id=project_id, workspace=workspace).exists():
raise serializers.ValidationError("Project does not belong to this workspace.") 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: if tag_ids:
existing_tag_ids = set(Tag.objects.filter(id__in=tag_ids, workspace=workspace).values_list("id", flat=True)) 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): 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) filters = load_report_filters(actor, raw_filters)
entries = list(_base_queryset(filters)) entries = list(_base_queryset(filters))
summary = _summary_from_entries(entries) 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: serialized_series = []
local_start = _localize_datetime(entry.start_time) for _, series_entries in sorted(
bucket_id, bucket_date = _bucket_key(filters, local_start) grouped_entries.items(),
bucket = buckets.setdefault( key=lambda item: _user_display(item[1][0].user).lower() if item[1] else "",
bucket_id, ):
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, "user": {
"bucket_label": _bucket_label(filters, bucket_date), "id": str(user.id),
"total_seconds": 0, "name": _user_display(user),
"total_duration": "00:00:00", "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 { return {
"scope": _scope_payload(filters), "scope": _scope_payload(filters),
"summary": summary, "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) summary = _summary_from_entries(entries)
include_latest_rate = not (filters.is_workspace_scope and not filters.user_id) include_latest_rate = not (filters.is_workspace_scope and not filters.user_id)
return { payload = {
"scope": _scope_payload(filters), "scope": _scope_payload(filters),
"summary": summary, "summary": summary,
"days": _group_daily(entries, include_latest_rate=include_latest_rate), "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"), "projects": _build_breakdown(entries, "projects"),
"tags": _build_breakdown(entries, "tags"), "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]: 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: def build_table_report(actor, raw_filters) -> dict:
filters = load_report_filters(actor, raw_filters) filters = load_report_filters(actor, raw_filters)
entries = list(_base_queryset(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]: 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] = [] reports: list[dict] = []
for user_id, user_entries in sorted_groups: for user_id, user_entries in sorted_groups:
user_filters = replace(filters, user_id=user_id) 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 return reports

View File

@@ -24,6 +24,7 @@ TRANSLATIONS = {
"en": { "en": {
"report_title": "Workspace Report", "report_title": "Workspace Report",
"overall_sheet": "Overall Report", "overall_sheet": "Overall Report",
"users_summary_sheet": "Users Summary",
"workspace": "Workspace", "workspace": "Workspace",
"period": "Period", "period": "Period",
"from_date": "From date", "from_date": "From date",
@@ -38,6 +39,18 @@ TRANSLATIONS = {
"non_billable_hours": "Non-billable hours", "non_billable_hours": "Non-billable hours",
"hourly_rate": "Hourly rate", "hourly_rate": "Hourly rate",
"income": "Income", "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", "daily_summary": "Daily Summary",
"clients": "Clients", "clients": "Clients",
"projects": "Projects", "projects": "Projects",
@@ -50,6 +63,7 @@ TRANSLATIONS = {
"fa": { "fa": {
"report_title": "گزارش فضای کاری", "report_title": "گزارش فضای کاری",
"overall_sheet": "گزارش کلی", "overall_sheet": "گزارش کلی",
"users_summary_sheet": "خلاصه کاربران",
"workspace": "فضای کاری", "workspace": "فضای کاری",
"period": "بازه", "period": "بازه",
"from_date": "از تاریخ", "from_date": "از تاریخ",
@@ -63,7 +77,19 @@ TRANSLATIONS = {
"billable_hours": "ساعات کاری", "billable_hours": "ساعات کاری",
"non_billable_hours": "ساعات غیر کاری", "non_billable_hours": "ساعات غیر کاری",
"hourly_rate": "نرخ ساعتی", "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": "خلاصه روزانه", "daily_summary": "خلاصه روزانه",
"clients": "مشتریان", "clients": "مشتریان",
"projects": "پروژه‌ها", "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: try:
locale = build_export_locale(job.filters.get("language")) locale = build_export_locale(job.filters.get("language"))
report_data = build_table_report(job.requesting_user, job.filters) 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: 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) content = build_excel_report(report_data=report_data, locale=locale, per_user_reports=per_user_reports)
suffix = "xlsx" suffix = "xlsx"
mime_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" mime_type = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
else: 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" suffix = "pdf"
mime_type = "application/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): class ReportExporterTests(TestCase):
def test_excel_export_adds_per_user_sheets_and_daily_rate_column(self): def test_excel_export_adds_per_user_sheets_and_daily_rate_column(self):
locale = build_export_locale("en") locale = build_export_locale("en")
report_data = make_report_data( report_data = make_report_data(
hourly_rate={"amount": "15.00", "currency": "USD"}, 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 = [ 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( workbook = load_workbook(
@@ -71,13 +114,38 @@ class ReportExporterTests(TestCase):
self.assertIn("Owner User", workbook.sheetnames[1]) self.assertIn("Owner User", workbook.sheetnames[1])
self.assertIn("Team Mate", workbook.sheetnames[2]) self.assertIn("Team Mate", workbook.sheetnames[2])
worksheet = workbook.active summary_sheet = workbook[workbook.sheetnames[0]]
values = list(worksheet.iter_rows(values_only=True)) 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.assertEqual(summary_sheet["A1"].value, "Workspace Report")
self.assertTrue(any(row[:2] == ("Mobile", "09129990001") for row in values if row)) 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( self.assertEqual(
daily_header, 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") self.assertEqual(daily_row[4], "15 USD")
def test_pdf_export_supports_persian_locale(self): def test_pdf_export_supports_persian_locale(self):
@@ -98,7 +166,11 @@ class ReportExporterTests(TestCase):
report_data = make_report_data( report_data = make_report_data(
hourly_rate={"amount": "15.00", "currency": "USD"}, 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") self.assertEqual(content[:4], b"%PDF")

View File

@@ -100,6 +100,28 @@ class ReportViewTests(APITestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["summary"]["total_duration"], "01:00:00") 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): def test_admin_can_request_combined_table_report(self):
self.client.force_authenticate(user=self.admin) self.client.force_authenticate(user=self.admin)
@@ -116,8 +138,18 @@ class ReportViewTests(APITestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["summary"]["total_duration"], "03:00:00") self.assertEqual(response.data["summary"]["total_duration"], "03:00:00")
self.assertEqual(len(response.data["days"]), 2) 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"][0]["latest_hourly_rate"])
self.assertIsNone(response.data["days"][1]["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): def test_daily_rate_uses_latest_billable_entry_snapshot(self):
self.client.force_authenticate(user=self.owner) 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 core.serializers.base import BaseModelSerializer
from apps.time_entries.models import TimeEntry from apps.time_entries.models import TimeEntry
from apps.projects.models import Project from apps.projects.models import Project
from apps.projects.services.access import ensure_project_access
from apps.tags.models import Tag from apps.tags.models import Tag
@@ -99,6 +100,8 @@ class TimeEntryCreateSerializer(serializers.Serializer):
is_billable = serializers.BooleanField(default=False) is_billable = serializers.BooleanField(default=False)
def validate(self, attrs): 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) project_id = attrs.pop("project_id", serializers.empty)
if project_id is not serializers.empty: if project_id is not serializers.empty:
if project_id is None: if project_id is None:
@@ -107,6 +110,10 @@ class TimeEntryCreateSerializer(serializers.Serializer):
project = Project.objects.filter(id=project_id).first() project = Project.objects.filter(id=project_id).first()
if not project: if not project:
raise serializers.ValidationError({"project_id": "Selected project is unavailable."}) 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 attrs["project"] = project
tag_ids = attrs.pop("tags", serializers.empty) tag_ids = attrs.pop("tags", serializers.empty)
@@ -134,6 +141,7 @@ class TimeEntryUpdateSerializer(serializers.Serializer):
def validate(self, attrs): def validate(self, attrs):
entry = self.instance entry = self.instance
user = self.context.get("request").user if self.context.get("request") else None
project_id = attrs.pop("project_id", serializers.empty) project_id = attrs.pop("project_id", serializers.empty)
if project_id is not 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() project = Project.objects.filter(id=project_id).first()
if not project: if not project:
raise serializers.ValidationError({"project_id": "Selected project is unavailable."}) 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 attrs["project"] = project
tag_ids = attrs.pop("tags", serializers.empty) tag_ids = attrs.pop("tags", serializers.empty)

View File

@@ -1,7 +1,8 @@
from django.utils import timezone from django.utils import timezone
from rest_framework.exceptions import ValidationError, PermissionDenied 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.models import TimeEntry
from apps.time_entries.services.rates import resolve_rate from apps.time_entries.services.rates import resolve_rate
from apps.workspaces.models import Workspace from apps.workspaces.models import Workspace
@@ -40,8 +41,10 @@ def create_time_entry(user, workspace_id, start_time, end_time=None, project=Non
if start_time and end_time and start_time >= end_time: if start_time and end_time and start_time >= end_time:
raise ValidationError({"end_time": "End time must be strictly after start time."}) raise ValidationError({"end_time": "End time must be strictly after start time."})
if project and project.workspace_id != workspace_id: if project and project.workspace_id != workspace_id:
raise ValidationError({"project": "Project must belong to the same workspace."}) 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 duration = (end_time - start_time) if end_time else None
@@ -76,9 +79,11 @@ def update_time_entry(entry, **kwargs):
Updates an existing time entry, recalculating duration and rates if necessary. Updates an existing time entry, recalculating duration and rates if necessary.
""" """
# Verify Project Workspace if changing # Verify Project Workspace if changing
project = kwargs.get("project", entry.project) project = kwargs.get("project", entry.project)
if project and project.workspace_id != entry.workspace_id: if project and project.workspace_id != entry.workspace_id:
raise ValidationError({"project": "Project must belong to the same workspace."}) 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) start_time = kwargs.get("start_time", entry.start_time)
end_time = kwargs.get("end_time", entry.end_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 django.utils import timezone
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from apps.projects.models import Project, ProjectAccess
from apps.tags.models import Tag from apps.tags.models import Tag
from apps.time_entries.models import TimeEntry from apps.time_entries.models import TimeEntry
from apps.users.models import User 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): 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.status_code, 200)
self.assertEqual(response.data["tags"], []) 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 = SendOTPSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
generate_and_send_otp( payload = generate_and_send_otp(
mobile=serializer.validated_data["mobile"], mobile=serializer.validated_data["mobile"],
mode=serializer.validated_data["mode"] 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): class LoginView(APIView):

View File

@@ -1,5 +1,6 @@
import random import random
import string import string
from datetime import timedelta
from django.contrib.auth import get_user_model, password_validation from django.contrib.auth import get_user_model, password_validation
from django.core.exceptions import ValidationError as DjangoValidationError from django.core.exceptions import ValidationError as DjangoValidationError
@@ -18,6 +19,7 @@ User = get_user_model()
USER_ALREADY_EXISTS_MESSAGE = "User already exists." 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." 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): 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)) verification_code = "".join(random.choices(string.digits, k=5))
redis_conn = get_redis_connection("default") 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) 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): 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") @patch("apps.users.api.views.generate_and_send_otp")
def test_send_otp_view_validates_and_dispatches(self, 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( response = self.client.post(
"/api/users/otp/send/", "/api/users/otp/send/",
{"mobile": "09123330009", "mode": "login"}, {"mobile": "09123330009", "mode": "login"},
@@ -60,6 +65,7 @@ class UserApiViewTests(APITestCase):
) )
self.assertEqual(response.status_code, status.HTTP_200_OK) 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( generate_and_send_otp.assert_called_once_with(
mobile="09123330009", mobile="09123330009",
mode="login", mode="login",
@@ -67,6 +73,11 @@ class UserApiViewTests(APITestCase):
@patch("apps.users.api.views.generate_and_send_otp") @patch("apps.users.api.views.generate_and_send_otp")
def test_send_otp_view_supports_forget_password_mode(self, 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( response = self.client.post(
"/api/users/otp/send/", "/api/users/otp/send/",
{"mobile": "09123330001", "mode": "forget_password"}, {"mobile": "09123330001", "mode": "forget_password"},

View File

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