feat(reports): refine exports and restore project access
This commit is contained in:
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user