diff --git a/apps/workspaces/api/views.py b/apps/workspaces/api/views.py index ab055c0..6ec367b 100644 --- a/apps/workspaces/api/views.py +++ b/apps/workspaces/api/views.py @@ -3,7 +3,8 @@ from django.shortcuts import get_object_or_404 from rest_framework import status from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response -from rest_framework.filters import OrderingFilter, SearchFilter +from rest_framework.filters import OrderingFilter, SearchFilter +from rest_framework.decorators import action from django_filters.rest_framework import DjangoFilterBackend from rest_framework.viewsets import ModelViewSet from rest_framework.permissions import IsAuthenticated @@ -15,6 +16,8 @@ from apps.notifications.services import ( notify_workspace_membership_removed, notify_workspace_membership_role_changed, ) +from apps.projects.models import ProjectUserRate +from apps.projects.services.access import filter_projects_for_user from apps.workspaces.api.permissions import ( CanWorkspaceManageMembers, IsWorkspaceAdmin, @@ -78,6 +81,8 @@ class WorkspaceViewSet(ModelViewSet): def get_permissions(self): if self.action in ["list", "retrieve"]: return [IsAuthenticated(), IsWorkspaceMember()] + if self.action == "my_rates": + return [IsAuthenticated()] if self.action in ["update", "partial_update"]: return [IsAuthenticated(), IsWorkspaceAdmin()] @@ -86,8 +91,86 @@ class WorkspaceViewSet(ModelViewSet): return [IsAuthenticated()] - def perform_create(self, serializer): - serializer.save(owner=self.request.user) + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + + @action(detail=True, methods=["get"], url_path="my-rates") + def my_rates(self, request, pk=None): + workspace = self.get_object() + if not has_workspace_capability(request.user, workspace, WORKSPACE_VIEW): + raise PermissionDenied("You do not have access to this workspace.") + + def serialize_rate(rate): + if not rate: + return None + unit = PriceUnit.objects.filter(code=rate.currency, is_deleted=False).first() + return { + "id": str(rate.id), + "hourly_rate": str(rate.hourly_rate), + "currency": rate.currency, + "price_unit": PriceUnitSerializer(unit).data if unit else None, + "effective_from": rate.effective_from.isoformat() if rate.effective_from else None, + } + + workspace_rate = ( + WorkspaceUserRate.objects.filter( + workspace=workspace, + user=request.user, + is_deleted=False, + ) + .order_by("-effective_from", "-updated_at") + .first() + ) + accessible_projects = list( + filter_projects_for_user( + request.user, + workspace.projects.filter(is_deleted=False).select_related("client"), + ).order_by("client__name", "name") + ) + accessible_project_ids = [project.id for project in accessible_projects] + project_rates_by_project_id = {} + for rate in ( + ProjectUserRate.objects.filter( + project_id__in=accessible_project_ids, + user=request.user, + is_active=True, + is_deleted=False, + ) + .select_related("project", "project__client") + .order_by("project_id", "-effective_from", "-updated_at") + ): + project_rates_by_project_id.setdefault(str(rate.project_id), rate) + + payload = { + "workspace": { + "id": str(workspace.id), + "name": workspace.name, + }, + "workspace_rate": serialize_rate(workspace_rate), + "accessible_project_count": len(accessible_projects), + "project_rates": [ + { + "project": { + "id": str(project.id), + "name": project.name, + "client": ( + {"id": str(project.client_id), "name": project.client.name} + if project.client_id and project.client + else None + ), + }, + "rate": serialize_rate(project_rates_by_project_id[str(project.id)]), + } + for project in accessible_projects + if str(project.id) in project_rates_by_project_id + ], + } + payload["project_override_count"] = len(payload["project_rates"]) + payload["workspace_fallback_project_count"] = max( + payload["accessible_project_count"] - payload["project_override_count"], + 0, + ) + return Response(payload) class WorkspaceMembershipViewSet(ModelViewSet): diff --git a/apps/workspaces/tests/test_api_permissions.py b/apps/workspaces/tests/test_api_permissions.py index 74367b9..abecc98 100644 --- a/apps/workspaces/tests/test_api_permissions.py +++ b/apps/workspaces/tests/test_api_permissions.py @@ -4,6 +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, ProjectAccess, ProjectUserRate from apps.users.models import User from apps.workspaces.api.permissions import ( CanWorkspaceManageMembers, @@ -11,7 +12,7 @@ from apps.workspaces.api.permissions import ( IsWorkspaceMember, IsWorkspaceOwner, ) -from apps.workspaces.models import Workspace, WorkspaceMembership +from apps.workspaces.models import PriceUnit, Workspace, WorkspaceMembership, WorkspaceUserRate class WorkspacePermissionTests(TestCase): @@ -189,3 +190,48 @@ class WorkspaceMembershipCacheTests(APITestCase): target = next(item for item in fresh_response.data["items"] if item["id"] == str(self.membership.id)) self.assertEqual(target["role"], WorkspaceMembership.Role.GUEST) self.assertFalse(target["is_active"]) + + +class WorkspaceMyRatesApiTests(APITestCase): + @classmethod + def setUpTestData(cls): + cls.owner = User.objects.create_user(mobile="09127770101", password="secret123") + cls.member = User.objects.create_user(mobile="09127770102", password="secret123") + cls.workspace = Workspace.objects.create(name="Rates View", owner=cls.owner) + WorkspaceMembership.objects.create( + workspace=cls.workspace, + user=cls.member, + role=WorkspaceMembership.Role.MEMBER, + is_active=True, + ) + PriceUnit.objects.create(code="USD", name="US Dollar", local_name="Dollar", symbol="$") + cls.project = Project.objects.create(workspace=cls.workspace, name="Mobile App") + ProjectAccess.objects.create(project=cls.project, user=cls.member) + WorkspaceUserRate.objects.create( + workspace=cls.workspace, + user=cls.member, + hourly_rate="10.00", + currency="USD", + effective_from=cls.workspace.created_at, + is_active=True, + ) + ProjectUserRate.objects.create( + project=cls.project, + user=cls.member, + hourly_rate="18.00", + currency="USD", + effective_from=cls.workspace.created_at, + is_active=True, + ) + + def test_member_can_view_own_workspace_and_project_rates(self): + self.client.force_authenticate(user=self.member) + + response = self.client.get(f"/api/workspaces/{self.workspace.id}/my-rates/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["workspace_rate"]["hourly_rate"], "10.00") + self.assertEqual(response.data["project_override_count"], 1) + self.assertEqual(response.data["workspace_fallback_project_count"], 0) + self.assertEqual(response.data["project_rates"][0]["project"]["name"], "Mobile App") + self.assertEqual(response.data["project_rates"][0]["rate"]["hourly_rate"], "18.00")