From 181a135df97a96025a2a093ea56725734f45feca Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Sat, 23 May 2026 18:29:00 +0330 Subject: [PATCH] feat(projects): add project-specific member rates --- apps/projects/api/serializers.py | 23 +++++++ apps/projects/api/views.py | 54 ++++++++++++++++ apps/projects/services/access.py | 81 +++++++++++++++++++----- apps/projects/services/rates.py | 63 ++++++++++++++++++ apps/projects/tests/test_views.py | 81 +++++++++++++++++++++++- apps/time_entries/services/rates.py | 7 ++ apps/time_entries/tests/test_services.py | 45 ++++++++++++- apps/workspaces/tests/test_rates.py | 49 +++++++++++++- 8 files changed, 381 insertions(+), 22 deletions(-) create mode 100644 apps/projects/services/rates.py diff --git a/apps/projects/api/serializers.py b/apps/projects/api/serializers.py index 5f84123..7eda12b 100644 --- a/apps/projects/api/serializers.py +++ b/apps/projects/api/serializers.py @@ -1,6 +1,9 @@ +from decimal import Decimal + from rest_framework import serializers from core.serializers.base import BaseModelSerializer from apps.projects.models import Project +from apps.workspaces.models import PriceUnit class ProjectSerializer(BaseModelSerializer): @@ -54,3 +57,23 @@ class ProjectAccessMutationSerializer(serializers.Serializer): child=serializers.UUIDField(), allow_empty=False, ) + + +class ProjectAccessRateMutationSerializer(serializers.Serializer): + workspace = serializers.UUIDField() + user = serializers.UUIDField() + project = serializers.UUIDField() + hourly_rate = serializers.DecimalField( + max_digits=10, + decimal_places=2, + min_value=Decimal("0.01"), + required=False, + allow_null=True, + ) + currency = serializers.CharField(max_length=3, required=False, default="USD") + + def validate_currency(self, value): + code = value.upper() + if not PriceUnit.objects.filter(code=code, is_deleted=False).exists(): + raise serializers.ValidationError("Selected price unit is invalid.") + return code diff --git a/apps/projects/api/views.py b/apps/projects/api/views.py index 0f09f74..b4e1416 100644 --- a/apps/projects/api/views.py +++ b/apps/projects/api/views.py @@ -16,9 +16,11 @@ from apps.projects.models import Project from apps.projects.api.serializers import ( ProjectSerializer, ProjectCreateSerializer, ProjectUpdateSerializer, ProjectAccessMutationSerializer, ProjectAccessQuerySerializer, + ProjectAccessRateMutationSerializer, ) from apps.projects.api.permissions import IsProjectMember, IsProjectManager from apps.projects.services.access import ( + build_project_access_item, build_project_access_items, ensure_workspace_project_access, filter_projects_for_user, @@ -26,6 +28,7 @@ from apps.projects.services.access import ( grant_project_accesses, revoke_project_accesses, ) +from apps.projects.services.rates import get_current_project_user_rate, remove_project_user_rate, upsert_project_user_rate from apps.projects.services.projects import ( create_project, update_project, @@ -231,3 +234,54 @@ class ProjectViewSet(ModelViewSet): 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/rate") + def set_access_rate(self, request): + serializer = ProjectAccessRateMutationSerializer(data=request.data) + 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"])) + project = get_object_or_404( + Project, + id=serializer.validated_data["project"], + workspace=workspace, + is_deleted=False, + ) + + has_access = membership.user.project_accesses.filter(project=project).exists() + if not has_access: + return Response( + {"detail": "Grant project access before setting a project-specific rate."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + removed = serializer.validated_data.get("hourly_rate") is None + if removed: + remove_project_user_rate(project=project, user=membership.user) + else: + upsert_project_user_rate( + project=project, + user=membership.user, + hourly_rate=serializer.validated_data["hourly_rate"], + currency=serializer.validated_data.get("currency", "USD"), + ) + + workspace_rate = ( + workspace.user_rates.filter(user=membership.user, is_deleted=False) + .order_by("-effective_from", "-updated_at") + .first() + ) + project_rate = get_current_project_user_rate(project=project, user=membership.user) + item = build_project_access_item( + project=project, + has_access=True, + workspace_rate=workspace_rate, + project_rate=project_rate, + ) + return Response({"removed": removed, "item": item}, status=status.HTTP_200_OK) diff --git a/apps/projects/services/access.py b/apps/projects/services/access.py index dc9de59..d82f47d 100644 --- a/apps/projects/services/access.py +++ b/apps/projects/services/access.py @@ -5,8 +5,8 @@ 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.projects.models import Project, ProjectAccess, ProjectUserRate +from apps.workspaces.models import Workspace, WorkspaceMembership, WorkspaceUserRate from apps.workspaces.services import PROJECTS_EDIT, get_workspace_role, has_workspace_capability User = get_user_model() @@ -80,29 +80,76 @@ def get_access_managed_membership(workspace: Workspace, user_id: str) -> Workspa return membership +def serialize_rate(rate) -> dict | None: + if not rate: + return None + return { + "id": str(rate.id), + "hourly_rate": str(rate.hourly_rate), + "currency": rate.currency, + "effective_from": rate.effective_from.isoformat() if rate.effective_from else None, + } + + +def build_project_access_item(*, project: Project, has_access: bool, workspace_rate, project_rate) -> dict: + 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": has_access, + "workspace_rate": serialize_rate(workspace_rate), + "project_rate": serialize_rate(project_rate), + } + + 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) + explicit_access_ids = { + str(project_id) + for project_id in ProjectAccess.objects.filter( + project__workspace=workspace, + user=target_user, + ).values_list("project_id", flat=True) + } + workspace_rate = ( + WorkspaceUserRate.objects.filter( + workspace=workspace, + user=target_user, + is_deleted=False, + ) + .order_by("-effective_from", "-updated_at") + .first() ) + project_rates: dict[str, ProjectUserRate] = {} + for rate in ( + ProjectUserRate.objects.filter( + project__workspace=workspace, + user=target_user, + is_active=True, + is_deleted=False, + ) + .select_related("project") + .order_by("project_id", "-effective_from", "-updated_at") + ): + project_rates.setdefault(str(rate.project_id), rate) 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}, - } + build_project_access_item( + project=project, + has_access=str(project.id) in explicit_access_ids, + workspace_rate=workspace_rate, + project_rate=project_rates.get(str(project.id)), + ) for project in projects ] diff --git a/apps/projects/services/rates.py b/apps/projects/services/rates.py new file mode 100644 index 0000000..ec72838 --- /dev/null +++ b/apps/projects/services/rates.py @@ -0,0 +1,63 @@ +from django.utils import timezone + +from apps.projects.models import ProjectUserRate + + +def get_current_project_user_rate(*, project, user): + return ( + ProjectUserRate.objects.filter( + project=project, + user=user, + is_active=True, + is_deleted=False, + ) + .order_by("-effective_from", "-updated_at") + .first() + ) + + +def upsert_project_user_rate(*, project, user, hourly_rate, currency="USD"): + currency = currency.upper() + rate = ( + ProjectUserRate.all_objects.filter( + project=project, + user=user, + ) + .order_by("-updated_at", "-created_at") + .first() + ) + + if rate: + update_fields = [] + if rate.is_deleted: + rate.restore() + if rate.hourly_rate != hourly_rate: + rate.hourly_rate = hourly_rate + update_fields.append("hourly_rate") + if rate.currency != currency: + rate.currency = currency + update_fields.append("currency") + if not rate.is_active: + rate.is_active = True + update_fields.append("is_active") + if update_fields: + update_fields.append("updated_at") + rate.save(update_fields=update_fields) + return rate + + return ProjectUserRate.objects.create( + project=project, + user=user, + hourly_rate=hourly_rate, + currency=currency, + effective_from=timezone.now(), + is_active=True, + ) + + +def remove_project_user_rate(*, project, user): + rate = get_current_project_user_rate(project=project, user=user) + if not rate: + return False + rate.delete() + return True diff --git a/apps/projects/tests/test_views.py b/apps/projects/tests/test_views.py index f878f33..49d8432 100644 --- a/apps/projects/tests/test_views.py +++ b/apps/projects/tests/test_views.py @@ -1,9 +1,11 @@ +from decimal import Decimal + from rest_framework.test import APITestCase from apps.clients.models import Client -from apps.projects.models import Project, ProjectAccess +from apps.projects.models import Project, ProjectAccess, ProjectUserRate from apps.users.models import User -from apps.workspaces.models import Workspace, WorkspaceMembership +from apps.workspaces.models import PriceUnit, Workspace, WorkspaceMembership, WorkspaceUserRate class ProjectViewTests(APITestCase): @@ -15,6 +17,7 @@ class ProjectViewTests(APITestCase): first_name="Owner", ) cls.workspace = Workspace.objects.create(name="Projects", owner=cls.owner) + PriceUnit.objects.create(code="USD", name="US Dollar", local_name="Dollar", symbol="$") cls.member = User.objects.create_user( mobile="09121110002", password="secret123", @@ -47,6 +50,14 @@ class ProjectViewTests(APITestCase): 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) + WorkspaceUserRate.objects.create( + workspace=cls.workspace, + user=cls.member, + hourly_rate=Decimal("25.00"), + currency="USD", + effective_from=cls.workspace.created_at, + is_active=True, + ) def test_project_list_supports_multi_client_filter(self): self.client.force_authenticate(user=self.member) @@ -84,6 +95,9 @@ class ProjectViewTests(APITestCase): 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"]) + alpha_item = next(item for item in items if item["id"] == str(self.first_project.id)) + self.assertEqual(alpha_item["workspace_rate"]["hourly_rate"], "25.00") + self.assertIsNone(alpha_item["project_rate"]) grant_response = self.client.post( "/api/projects/access/grant/", @@ -114,3 +128,66 @@ class ProjectViewTests(APITestCase): ) self.assertEqual(revoke_response.status_code, 200) self.assertFalse(ProjectAccess.objects.filter(project=self.first_project, user=self.member).exists()) + + def test_project_access_rate_endpoint_saves_override_and_keeps_it_dormant_after_revoke(self): + self.client.force_authenticate(user=self.owner) + + save_response = self.client.post( + "/api/projects/access/rate/", + { + "workspace": str(self.workspace.id), + "user": str(self.member.id), + "project": str(self.first_project.id), + "hourly_rate": "44.50", + "currency": "USD", + }, + format="json", + ) + + self.assertEqual(save_response.status_code, 200) + self.assertFalse(save_response.data["removed"]) + self.assertEqual(save_response.data["item"]["project_rate"]["hourly_rate"], "44.50") + self.assertTrue( + ProjectUserRate.objects.filter(project=self.first_project, user=self.member, is_deleted=False).exists() + ) + + 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.assertTrue( + ProjectUserRate.objects.filter(project=self.first_project, user=self.member, is_deleted=False).exists() + ) + + 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) + alpha_item = next(item for item in access_response.data["items"] if item["id"] == str(self.first_project.id)) + self.assertFalse(alpha_item["has_access"]) + self.assertEqual(alpha_item["project_rate"]["hourly_rate"], "44.50") + + def test_project_access_rate_endpoint_rejects_projects_without_access(self): + self.client.force_authenticate(user=self.owner) + + response = self.client.post( + "/api/projects/access/rate/", + { + "workspace": str(self.workspace.id), + "user": str(self.member.id), + "project": str(self.third_project.id), + "hourly_rate": "44.50", + "currency": "USD", + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("Grant project access", response.data["detail"]) diff --git a/apps/time_entries/services/rates.py b/apps/time_entries/services/rates.py index f8b2e58..8a74170 100644 --- a/apps/time_entries/services/rates.py +++ b/apps/time_entries/services/rates.py @@ -1,7 +1,14 @@ +from apps.projects.services.access import user_has_project_access +from apps.projects.services.rates import get_current_project_user_rate from apps.workspaces.models import WorkspaceUserRate def resolve_rate(user, project): + if user_has_project_access(user, project): + project_user_rate = get_current_project_user_rate(project=project, user=user) + if project_user_rate: + return project_user_rate.hourly_rate, project_user_rate.currency + workspace_user_rate = WorkspaceUserRate.objects.filter( user=user, workspace=project.workspace, diff --git a/apps/time_entries/tests/test_services.py b/apps/time_entries/tests/test_services.py index 05925c1..e853fca 100644 --- a/apps/time_entries/tests/test_services.py +++ b/apps/time_entries/tests/test_services.py @@ -1,10 +1,11 @@ from datetime import timedelta +from decimal import Decimal from django.test import TestCase from django.utils import timezone from rest_framework.exceptions import ValidationError -from apps.projects.models import Project +from apps.projects.models import Project, ProjectAccess, ProjectUserRate from apps.tags.models import Tag from apps.time_entries.services.time_entries import ( create_time_entry, @@ -12,14 +13,21 @@ from apps.time_entries.services.time_entries import ( update_time_entry, ) from apps.users.models import User -from apps.workspaces.models import Workspace +from apps.workspaces.models import Workspace, WorkspaceMembership, WorkspaceUserRate class TimeEntryServiceTests(TestCase): @classmethod def setUpTestData(cls): cls.user = User.objects.create_user(mobile="09121111111", password="secret123") + cls.member = User.objects.create_user(mobile="09121111112", password="secret123") cls.workspace = Workspace.objects.create(name="Core", owner=cls.user) + WorkspaceMembership.objects.create( + workspace=cls.workspace, + user=cls.member, + role=WorkspaceMembership.Role.MEMBER, + is_active=True, + ) def test_create_time_entry_allows_only_one_running_timer_per_workspace(self): create_time_entry( @@ -97,3 +105,36 @@ class TimeEntryServiceTests(TestCase): ), [tag.id], ) + + def test_create_billable_time_entry_uses_project_user_rate_override(self): + project = Project.objects.create(workspace=self.workspace, name="Override project") + ProjectAccess.objects.create(project=project, user=self.member) + WorkspaceUserRate.objects.create( + workspace=self.workspace, + user=self.member, + hourly_rate=Decimal("10.00"), + currency="USD", + effective_from=self.workspace.created_at, + is_active=True, + ) + ProjectUserRate.objects.create( + project=project, + user=self.member, + hourly_rate=Decimal("20.00"), + currency="EUR", + effective_from=self.workspace.created_at, + is_active=True, + ) + + entry = create_time_entry( + user=self.member, + workspace_id=self.workspace.id, + start_time=timezone.now() - timedelta(minutes=30), + end_time=timezone.now(), + project=project, + description="Billable work", + is_billable=True, + ) + + self.assertEqual(entry.hourly_rate, Decimal("20.00")) + self.assertEqual(entry.currency, "EUR") diff --git a/apps/workspaces/tests/test_rates.py b/apps/workspaces/tests/test_rates.py index 38f7021..a8fb8e1 100644 --- a/apps/workspaces/tests/test_rates.py +++ b/apps/workspaces/tests/test_rates.py @@ -4,7 +4,7 @@ from django.core.cache import cache from django.test import TestCase from rest_framework.test import APITestCase -from apps.projects.models import Project +from apps.projects.models import Project, ProjectAccess, ProjectUserRate from apps.time_entries.services.rates import resolve_rate from apps.users.models import User from apps.workspaces.models import ( @@ -78,6 +78,53 @@ class WorkspaceRateTests(APITestCase): self.assertIsNone(hourly_rate) self.assertEqual(currency, "") + def test_resolve_rate_prefers_project_user_rate_when_member_has_access(self): + WorkspaceUserRate.objects.create( + workspace=self.workspace, + user=self.member, + hourly_rate=Decimal("40.00"), + currency="EUR", + effective_from=self.project.created_at, + is_active=True, + ) + ProjectAccess.objects.create(project=self.project, user=self.member) + ProjectUserRate.objects.create( + project=self.project, + user=self.member, + hourly_rate=Decimal("55.00"), + currency="USD", + effective_from=self.project.created_at, + is_active=True, + ) + + hourly_rate, currency = resolve_rate(self.member, self.project) + + self.assertEqual(hourly_rate, Decimal("55.00")) + self.assertEqual(currency, "USD") + + def test_resolve_rate_ignores_project_user_rate_without_access(self): + WorkspaceUserRate.objects.create( + workspace=self.workspace, + user=self.member, + hourly_rate=Decimal("40.00"), + currency="EUR", + effective_from=self.project.created_at, + is_active=True, + ) + ProjectUserRate.objects.create( + project=self.project, + user=self.member, + hourly_rate=Decimal("55.00"), + currency="USD", + effective_from=self.project.created_at, + is_active=True, + ) + + hourly_rate, currency = resolve_rate(self.member, self.project) + + self.assertEqual(hourly_rate, Decimal("40.00")) + self.assertEqual(currency, "EUR") + def test_admin_can_manage_workspace_user_rates(self): self.client.force_authenticate(user=self.admin)