Compare commits
4 Commits
d1c4889d22
...
bb06762377
| Author | SHA1 | Date | |
|---|---|---|---|
| bb06762377 | |||
| d4a52d6f3b | |||
| 77c07adec8 | |||
| f9c4c06531 |
82
.gitea/workflows/backend.yml
Normal file
82
.gitea/workflows/backend.yml
Normal 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"
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
@@ -150,12 +158,76 @@ class ProjectViewSet(ModelViewSet):
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@action(detail=True, methods=["post"])
|
||||
def archive(self, request, pk=None):
|
||||
"""
|
||||
Custom endpoint to toggle the archive status of a project.
|
||||
"""
|
||||
project = self.get_object()
|
||||
def archive(self, request, pk=None):
|
||||
"""
|
||||
Custom endpoint to toggle the archive status of a project.
|
||||
"""
|
||||
project = self.get_object()
|
||||
updated_project = toggle_project_archive(project)
|
||||
|
||||
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)
|
||||
|
||||
38
apps/projects/migrations/0004_projectaccess.py
Normal file
38
apps/projects/migrations/0004_projectaccess.py
Normal 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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
144
apps/projects/services/access.py
Normal file
144
apps/projects/services/access.py
Normal 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
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
|
||||
from django.utils import timezone
|
||||
from rest_framework.exceptions import ValidationError, PermissionDenied
|
||||
|
||||
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
|
||||
@@ -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:
|
||||
raise ValidationError({"end_time": "End time must be strictly after start time."})
|
||||
|
||||
if project and project.workspace_id != workspace_id:
|
||||
raise ValidationError({"project": "Project must belong to the same workspace."})
|
||||
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
|
||||
|
||||
@@ -76,9 +79,11 @@ def update_time_entry(entry, **kwargs):
|
||||
Updates an existing time entry, recalculating duration and rates if necessary.
|
||||
"""
|
||||
# Verify Project Workspace if changing
|
||||
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."})
|
||||
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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -92,6 +92,7 @@ WORKSPACE_ROLE_CAPABILITIES = {
|
||||
TAGS_VIEW,
|
||||
PROJECTS_VIEW,
|
||||
TIME_ENTRIES_VIEW_OWN,
|
||||
TIME_ENTRIES_MANAGE_OWN,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user