feat(projects): support implicit-access roles in rates modal

This commit is contained in:
2026-05-24 10:18:31 +03:30
parent 22e08a099c
commit 2a0fa22be6
3 changed files with 41 additions and 12 deletions

View File

@@ -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."},

View File

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

View File

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