diff --git a/apps/projects/api/views.py b/apps/projects/api/views.py index b4e1416..1c7f56a 100644 --- a/apps/projects/api/views.py +++ b/apps/projects/api/views.py @@ -24,9 +24,10 @@ from apps.projects.services.access import ( build_project_access_items, ensure_workspace_project_access, filter_projects_for_user, - get_access_managed_membership, + get_project_access_target_membership, grant_project_accesses, revoke_project_accesses, + user_has_project_access, ) 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 ( @@ -182,7 +183,7 @@ class ProjectViewSet(ModelViewSet): is_deleted=False, ) ensure_workspace_project_access(request.user, workspace) - membership = get_access_managed_membership(workspace, str(serializer.validated_data["user"])) + membership = get_project_access_target_membership(workspace, str(serializer.validated_data["user"])) return Response( { @@ -207,7 +208,7 @@ class ProjectViewSet(ModelViewSet): id=serializer.validated_data["workspace"], is_deleted=False, ) - membership = get_access_managed_membership(workspace, str(serializer.validated_data["user"])) + membership = get_project_access_target_membership(workspace, str(serializer.validated_data["user"])) changed = grant_project_accesses( actor=request.user, workspace=workspace, @@ -226,7 +227,7 @@ class ProjectViewSet(ModelViewSet): id=serializer.validated_data["workspace"], is_deleted=False, ) - membership = get_access_managed_membership(workspace, str(serializer.validated_data["user"])) + membership = get_project_access_target_membership(workspace, str(serializer.validated_data["user"])) changed = revoke_project_accesses( actor=request.user, workspace=workspace, @@ -246,7 +247,7 @@ class ProjectViewSet(ModelViewSet): is_deleted=False, ) ensure_workspace_project_access(request.user, workspace) - membership = get_access_managed_membership(workspace, str(serializer.validated_data["user"])) + membership = get_project_access_target_membership(workspace, str(serializer.validated_data["user"])) project = get_object_or_404( Project, id=serializer.validated_data["project"], @@ -254,7 +255,7 @@ class ProjectViewSet(ModelViewSet): is_deleted=False, ) - has_access = membership.user.project_accesses.filter(project=project).exists() + has_access = user_has_project_access(membership.user, project) if not has_access: return Response( {"detail": "Grant project access before setting a project-specific rate."}, diff --git a/apps/projects/services/access.py b/apps/projects/services/access.py index d82f47d..30e4a42 100644 --- a/apps/projects/services/access.py +++ b/apps/projects/services/access.py @@ -66,7 +66,7 @@ def ensure_workspace_project_access(user, workspace: Workspace) -> None: 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: +def get_project_access_target_membership(workspace: Workspace, user_id: str) -> WorkspaceMembership: membership = WorkspaceMembership.objects.filter( workspace=workspace, user_id=user_id, @@ -75,8 +75,6 @@ def get_access_managed_membership(workspace: Workspace, user_id: str) -> Workspa ).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 @@ -146,7 +144,7 @@ def build_project_access_items(*, workspace: Workspace, target_user) -> list[dic return [ build_project_access_item( project=project, - has_access=str(project.id) in explicit_access_ids, + has_access=user_has_project_access(target_user, project) if user_has_implicit_project_access(target_user, workspace) else str(project.id) in explicit_access_ids, workspace_rate=workspace_rate, project_rate=project_rates.get(str(project.id)), ) @@ -156,7 +154,9 @@ def build_project_access_items(*, workspace: Workspace, target_user) -> list[dic 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)) + membership = get_project_access_target_membership(workspace, str(target_user.id)) + if membership.role not in PROJECT_ACCESS_MANAGED_ROLES: + raise ValidationError({"user": "Owners and admins already have access to all projects."}) projects = list(Project.objects.filter(workspace=workspace, id__in=project_ids, is_deleted=False)) if len(projects) != len(set(project_ids)): @@ -175,7 +175,9 @@ def grant_project_accesses(*, actor, workspace: Workspace, target_user, project_ 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)) + membership = get_project_access_target_membership(workspace, str(target_user.id)) + if membership.role not in PROJECT_ACCESS_MANAGED_ROLES: + raise ValidationError({"user": "Owners and admins always keep project access."}) accesses = list( ProjectAccess.objects.filter( diff --git a/apps/projects/tests/test_views.py b/apps/projects/tests/test_views.py index 49d8432..5a67650 100644 --- a/apps/projects/tests/test_views.py +++ b/apps/projects/tests/test_views.py @@ -191,3 +191,29 @@ class ProjectViewTests(APITestCase): self.assertEqual(response.status_code, 400) self.assertIn("Grant project access", response.data["detail"]) + + def test_owner_access_state_marks_all_projects_as_accessible_and_allows_project_rate_override(self): + self.client.force_authenticate(user=self.owner) + + access_response = self.client.get( + "/api/projects/access/", + {"workspace": str(self.workspace.id), "user": str(self.owner.id)}, + ) + self.assertEqual(access_response.status_code, 200) + self.assertTrue(all(item["has_access"] for item in access_response.data["items"])) + + save_response = self.client.post( + "/api/projects/access/rate/", + { + "workspace": str(self.workspace.id), + "user": str(self.owner.id), + "project": str(self.first_project.id), + "hourly_rate": "60.00", + "currency": "USD", + }, + format="json", + ) + + self.assertEqual(save_response.status_code, 200) + self.assertEqual(save_response.data["item"]["project_rate"]["hourly_rate"], "60.00") + self.assertTrue(ProjectUserRate.objects.filter(project=self.first_project, user=self.owner, is_deleted=False).exists())