feat(reports): refine exports and restore project access

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

View File

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

View File

@@ -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)

View File

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