feat(permissions): centralize workspace role capability checks

This commit is contained in:
2026-04-25 18:48:50 +03:30
parent 5f9d413a57
commit f960ca8221
14 changed files with 925 additions and 222 deletions

View File

@@ -1,9 +1,15 @@
from rest_framework import permissions
from apps.workspaces.models import Workspace, WorkspaceMembership
from rest_framework import permissions
from apps.workspaces.models import Workspace, WorkspaceMembership
from apps.workspaces.services import (
WORKSPACE_EDIT,
WORKSPACE_MEMBERS_CHANGE_ROLE,
WORKSPACE_VIEW,
has_workspace_capability,
)
class IsWorkspaceOwner(permissions.BasePermission):
class IsWorkspaceOwner(permissions.BasePermission):
"""
Permission check:
- User must be the explicit 'owner' on the Workspace model.
@@ -11,98 +17,86 @@ class IsWorkspaceOwner(permissions.BasePermission):
"""
message = "Access denied. You must be the Workspace Owner to perform this action."
def has_object_permission(self, request, view, obj):
if not request.user or not request.user.is_authenticated:
return False
if isinstance(obj, Workspace):
workspace = obj
elif isinstance(obj, WorkspaceMembership):
workspace = obj.workspace
elif hasattr(obj, 'workspace'):
workspace = obj.workspace
else:
return False
if workspace.owner == request.user:
return True
return WorkspaceMembership.objects.filter(
workspace=workspace,
user=request.user,
role=WorkspaceMembership.Role.OWNER,
is_active=True
).exists()
class IsWorkspaceAdmin(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
if not request.user or not request.user.is_authenticated:
return False
if isinstance(obj, Workspace):
workspace = obj
elif isinstance(obj, WorkspaceMembership):
workspace = obj.workspace
elif hasattr(obj, "workspace"):
workspace = obj.workspace
else:
return False
return workspace.owner_id == request.user.id
class IsWorkspaceAdmin(permissions.BasePermission):
"""
Permission check:
- User's role in the workspace is either 'ADMIN' or 'OWNER'.
"""
message = "Access denied. You must be a Workspace Admin or Owner to perform this action."
def has_object_permission(self, request, view, obj):
if not request.user or not request.user.is_authenticated:
return False
if isinstance(obj, Workspace):
workspace = obj
elif isinstance(obj, WorkspaceMembership):
workspace = obj.workspace
elif hasattr(obj, 'workspace'):
workspace = obj.workspace
else:
return False
if workspace.owner == request.user:
return True
allowed_roles = [
WorkspaceMembership.Role.OWNER,
WorkspaceMembership.Role.ADMIN,
]
return WorkspaceMembership.objects.filter(
workspace=workspace,
user=request.user,
role__in=allowed_roles,
is_active=True
).exists()
class IsWorkspaceMember(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
if not request.user or not request.user.is_authenticated:
return False
if isinstance(obj, Workspace):
workspace = obj
elif isinstance(obj, WorkspaceMembership):
workspace = obj.workspace
elif hasattr(obj, "workspace"):
workspace = obj.workspace
else:
return False
return has_workspace_capability(request.user, workspace, WORKSPACE_EDIT)
class IsWorkspaceMember(permissions.BasePermission):
"""
Permission check:
- User's role in the workspace is 'OWNER', 'ADMIN', or 'MEMBER'.
"""
message = "Access denied. You must be an active member of this workspace."
def has_object_permission(self, request, view, obj):
if not request.user or not request.user.is_authenticated:
return False
if isinstance(obj, Workspace):
workspace = obj
elif isinstance(obj, WorkspaceMembership):
workspace = obj.workspace
elif hasattr(obj, 'workspace'):
workspace = obj.workspace
else:
return False
if workspace.owner == request.user:
return True
allowed_roles = [
WorkspaceMembership.Role.OWNER,
WorkspaceMembership.Role.ADMIN,
WorkspaceMembership.Role.MEMBER,
]
return WorkspaceMembership.objects.filter(
workspace=workspace,
user=request.user,
role__in=allowed_roles,
is_active=True
).exists()
def has_object_permission(self, request, view, obj):
if not request.user or not request.user.is_authenticated:
return False
if isinstance(obj, Workspace):
workspace = obj
elif isinstance(obj, WorkspaceMembership):
workspace = obj.workspace
elif hasattr(obj, "workspace"):
workspace = obj.workspace
else:
return False
return has_workspace_capability(request.user, workspace, WORKSPACE_VIEW)
class CanWorkspaceManageMembers(permissions.BasePermission):
message = "Access denied. You do not have permission to manage workspace members."
def has_object_permission(self, request, view, obj):
if not request.user or not request.user.is_authenticated:
return False
if isinstance(obj, Workspace):
workspace = obj
elif isinstance(obj, WorkspaceMembership):
workspace = obj.workspace
elif hasattr(obj, "workspace"):
workspace = obj.workspace
else:
return False
return has_workspace_capability(
request.user,
workspace,
WORKSPACE_MEMBERS_CHANGE_ROLE,
)

View File

@@ -13,11 +13,22 @@ from apps.notifications.services import (
notify_workspace_membership_removed,
notify_workspace_membership_role_changed,
)
from apps.workspaces.api.permissions import IsWorkspaceOwner, IsWorkspaceAdmin
from apps.workspaces.api.permissions import (
CanWorkspaceManageMembers,
IsWorkspaceAdmin,
IsWorkspaceMember,
IsWorkspaceOwner,
)
from apps.workspaces.api.serializers import WorkspaceMembershipSerializer, WorkspaceSerializer
from apps.workspaces.api.filters import WorkspaceFilter, WorkspaceMembershipFilter
from apps.workspaces.models import Workspace, WorkspaceMembership
from core.paginations.limit_offset import CustomLimitOffsetPagination
from apps.workspaces.models import Workspace, WorkspaceMembership
from apps.workspaces.services import (
WORKSPACE_MEMBERS_VIEW,
can_assign_workspace_role,
can_change_workspace_membership,
has_workspace_capability,
)
from core.paginations.limit_offset import CustomLimitOffsetPagination
class WorkspaceViewSet(ModelViewSet):
@@ -39,10 +50,12 @@ class WorkspaceViewSet(ModelViewSet):
Q(memberships__user=user, memberships__is_active=True)
).distinct()
def get_permissions(self):
if self.action in ["update", "partial_update"]:
return [IsAuthenticated(), IsWorkspaceAdmin()]
def get_permissions(self):
if self.action in ["list", "retrieve"]:
return [IsAuthenticated(), IsWorkspaceMember()]
if self.action in ["update", "partial_update"]:
return [IsAuthenticated(), IsWorkspaceAdmin()]
elif self.action == "destroy":
return [IsAuthenticated(), IsWorkspaceOwner()]
@@ -77,14 +90,31 @@ class WorkspaceMembershipViewSet(ModelViewSet):
Q(workspace__memberships__user=user, workspace__memberships__is_active=True)
).distinct()
def get_permissions(self):
if self.action in ["update", "partial_update"]:
return [IsAuthenticated(), IsWorkspaceAdmin()]
if self.action in ["destroy"]:
return [IsAuthenticated(), IsWorkspaceOwner()]
return [IsAuthenticated()]
def get_permissions(self):
if self.action in ["list", "retrieve", "create", "update", "partial_update"]:
return [IsAuthenticated(), CanWorkspaceManageMembers()]
if self.action in ["destroy"]:
return [IsAuthenticated(), CanWorkspaceManageMembers()]
return [IsAuthenticated()]
def list(self, request, *args, **kwargs):
workspace_id = request.query_params.get("workspace")
if not workspace_id:
return Response(
{"detail": "workspace query parameter is required."},
status=status.HTTP_400_BAD_REQUEST,
)
workspace = get_object_or_404(Workspace, id=workspace_id, is_deleted=False)
if not has_workspace_capability(request.user, workspace, WORKSPACE_MEMBERS_VIEW):
return Response(
{"detail": "You do not have permission to view workspace members."},
status=status.HTTP_403_FORBIDDEN,
)
return super().list(request, *args, **kwargs)
def create(self, request, *args, **kwargs):
"""
Overridden to check permissions manually.
@@ -100,13 +130,24 @@ class WorkspaceMembershipViewSet(ModelViewSet):
workspace = get_object_or_404(Workspace, id=workspace_id)
permission = IsWorkspaceAdmin()
if not permission.has_object_permission(request, self, workspace):
return Response(
{"detail": "You must be a Workspace Admin or Owner to add members."},
status=status.HTTP_403_FORBIDDEN
)
permission = IsWorkspaceAdmin()
if not permission.has_object_permission(request, self, workspace):
return Response(
{"detail": "You must be a Workspace Admin or Owner to add members."},
status=status.HTTP_403_FORBIDDEN
)
requested_role = request.data.get("role")
if requested_role and not can_assign_workspace_role(
request.user,
workspace,
requested_role,
):
return Response(
{"detail": "You do not have permission to assign this workspace role."},
status=status.HTTP_403_FORBIDDEN,
)
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
membership = serializer.save()
@@ -127,6 +168,17 @@ class WorkspaceMembershipViewSet(ModelViewSet):
previous_role = membership.role
previous_is_active = membership.is_active
requested_role = request.data.get("role")
if not can_change_workspace_membership(
request.user,
membership,
new_role=requested_role,
):
return Response(
{"detail": "You do not have permission to change this workspace membership."},
status=status.HTTP_403_FORBIDDEN,
)
serializer = self.get_serializer(membership, data=request.data, partial=partial)
serializer.is_valid(raise_exception=True)
updated_membership = serializer.save()
@@ -168,6 +220,11 @@ class WorkspaceMembershipViewSet(ModelViewSet):
def destroy(self, request, *args, **kwargs):
membership = self.get_object()
if not can_change_workspace_membership(request.user, membership):
return Response(
{"detail": "You do not have permission to remove this workspace membership."},
status=status.HTTP_403_FORBIDDEN,
)
recipient = membership.user
workspace = membership.workspace
role = membership.role

View File

@@ -0,0 +1,71 @@
from apps.workspaces.services.permissions import (
CLIENTS_CREATE,
CLIENTS_DELETE,
CLIENTS_EDIT,
CLIENTS_VIEW,
PROJECTS_ARCHIVE,
PROJECTS_CREATE,
PROJECTS_DELETE,
PROJECTS_EDIT,
PROJECTS_VIEW,
PROJECT_MEMBERS_ADD,
PROJECT_MEMBERS_CHANGE_ROLE,
PROJECT_MEMBERS_REMOVE,
PROJECT_MEMBERS_VIEW,
TAGS_CREATE,
TAGS_DELETE,
TAGS_EDIT,
TAGS_VIEW,
TIME_ENTRIES_MANAGE_OWN,
TIME_ENTRIES_VIEW_OWN,
WORKSPACE_DELETE,
WORKSPACE_EDIT,
WORKSPACE_MEMBERS_ADD,
WORKSPACE_MEMBERS_CHANGE_ROLE,
WORKSPACE_MEMBERS_REMOVE,
WORKSPACE_MEMBERS_VIEW,
WORKSPACE_VIEW,
can_assign_workspace_role,
can_change_workspace_membership,
can_manage_workspace_members,
get_workspace_membership,
get_workspace_role,
has_project_capability,
has_workspace_capability,
)
__all__ = [
"WORKSPACE_VIEW",
"WORKSPACE_EDIT",
"WORKSPACE_DELETE",
"WORKSPACE_MEMBERS_VIEW",
"WORKSPACE_MEMBERS_ADD",
"WORKSPACE_MEMBERS_REMOVE",
"WORKSPACE_MEMBERS_CHANGE_ROLE",
"CLIENTS_VIEW",
"CLIENTS_CREATE",
"CLIENTS_EDIT",
"CLIENTS_DELETE",
"TAGS_VIEW",
"TAGS_CREATE",
"TAGS_EDIT",
"TAGS_DELETE",
"PROJECTS_VIEW",
"PROJECTS_CREATE",
"PROJECTS_EDIT",
"PROJECTS_DELETE",
"PROJECTS_ARCHIVE",
"PROJECT_MEMBERS_VIEW",
"PROJECT_MEMBERS_ADD",
"PROJECT_MEMBERS_REMOVE",
"PROJECT_MEMBERS_CHANGE_ROLE",
"TIME_ENTRIES_VIEW_OWN",
"TIME_ENTRIES_MANAGE_OWN",
"get_workspace_membership",
"get_workspace_role",
"has_workspace_capability",
"has_project_capability",
"can_manage_workspace_members",
"can_assign_workspace_role",
"can_change_workspace_membership",
]

View File

@@ -0,0 +1,210 @@
from __future__ import annotations
from apps.projects.models import ProjectMembership
from apps.workspaces.models import Workspace, WorkspaceMembership
WORKSPACE_VIEW = "workspace.view"
WORKSPACE_EDIT = "workspace.edit"
WORKSPACE_DELETE = "workspace.delete"
WORKSPACE_MEMBERS_VIEW = "workspace.members.view"
WORKSPACE_MEMBERS_ADD = "workspace.members.add"
WORKSPACE_MEMBERS_REMOVE = "workspace.members.remove"
WORKSPACE_MEMBERS_CHANGE_ROLE = "workspace.members.change_role"
CLIENTS_VIEW = "clients.view"
CLIENTS_CREATE = "clients.create"
CLIENTS_EDIT = "clients.edit"
CLIENTS_DELETE = "clients.delete"
TAGS_VIEW = "tags.view"
TAGS_CREATE = "tags.create"
TAGS_EDIT = "tags.edit"
TAGS_DELETE = "tags.delete"
PROJECTS_VIEW = "projects.view"
PROJECTS_CREATE = "projects.create"
PROJECTS_EDIT = "projects.edit"
PROJECTS_DELETE = "projects.delete"
PROJECTS_ARCHIVE = "projects.archive"
PROJECT_MEMBERS_VIEW = "project_members.view"
PROJECT_MEMBERS_ADD = "project_members.add"
PROJECT_MEMBERS_REMOVE = "project_members.remove"
PROJECT_MEMBERS_CHANGE_ROLE = "project_members.change_role"
TIME_ENTRIES_VIEW_OWN = "time_entries.view_own"
TIME_ENTRIES_MANAGE_OWN = "time_entries.manage_own"
PROJECT_MANAGER_CAPABILITIES = {
PROJECTS_EDIT,
PROJECTS_ARCHIVE,
PROJECT_MEMBERS_VIEW,
PROJECT_MEMBERS_ADD,
PROJECT_MEMBERS_REMOVE,
PROJECT_MEMBERS_CHANGE_ROLE,
}
WORKSPACE_ROLE_CAPABILITIES = {
WorkspaceMembership.Role.OWNER: {
WORKSPACE_VIEW,
WORKSPACE_EDIT,
WORKSPACE_DELETE,
WORKSPACE_MEMBERS_VIEW,
WORKSPACE_MEMBERS_ADD,
WORKSPACE_MEMBERS_REMOVE,
WORKSPACE_MEMBERS_CHANGE_ROLE,
CLIENTS_VIEW,
CLIENTS_CREATE,
CLIENTS_EDIT,
CLIENTS_DELETE,
TAGS_VIEW,
TAGS_CREATE,
TAGS_EDIT,
TAGS_DELETE,
PROJECTS_VIEW,
PROJECTS_CREATE,
PROJECTS_EDIT,
PROJECTS_DELETE,
PROJECTS_ARCHIVE,
PROJECT_MEMBERS_VIEW,
PROJECT_MEMBERS_ADD,
PROJECT_MEMBERS_REMOVE,
PROJECT_MEMBERS_CHANGE_ROLE,
TIME_ENTRIES_VIEW_OWN,
TIME_ENTRIES_MANAGE_OWN,
},
WorkspaceMembership.Role.ADMIN: {
WORKSPACE_VIEW,
WORKSPACE_EDIT,
WORKSPACE_MEMBERS_VIEW,
WORKSPACE_MEMBERS_ADD,
WORKSPACE_MEMBERS_REMOVE,
WORKSPACE_MEMBERS_CHANGE_ROLE,
CLIENTS_VIEW,
CLIENTS_CREATE,
CLIENTS_EDIT,
CLIENTS_DELETE,
TAGS_VIEW,
TAGS_CREATE,
TAGS_EDIT,
TAGS_DELETE,
PROJECTS_VIEW,
PROJECTS_CREATE,
PROJECTS_EDIT,
PROJECTS_DELETE,
PROJECTS_ARCHIVE,
PROJECT_MEMBERS_VIEW,
PROJECT_MEMBERS_ADD,
PROJECT_MEMBERS_REMOVE,
PROJECT_MEMBERS_CHANGE_ROLE,
TIME_ENTRIES_VIEW_OWN,
TIME_ENTRIES_MANAGE_OWN,
},
WorkspaceMembership.Role.MEMBER: {
WORKSPACE_VIEW,
CLIENTS_VIEW,
TAGS_VIEW,
TAGS_CREATE,
PROJECTS_VIEW,
TIME_ENTRIES_VIEW_OWN,
TIME_ENTRIES_MANAGE_OWN,
},
WorkspaceMembership.Role.GUEST: {
WORKSPACE_VIEW,
CLIENTS_VIEW,
TAGS_VIEW,
PROJECTS_VIEW,
TIME_ENTRIES_VIEW_OWN,
},
}
def get_workspace_membership(user, workspace: Workspace) -> WorkspaceMembership | None:
if not user or not user.is_authenticated:
return None
return WorkspaceMembership.objects.filter(
workspace=workspace,
user=user,
is_active=True,
is_deleted=False,
).first()
def get_workspace_role(user, workspace: Workspace) -> str | None:
if not user or not user.is_authenticated:
return None
if workspace.owner_id == user.id:
return WorkspaceMembership.Role.OWNER
membership = get_workspace_membership(user, workspace)
return getattr(membership, "role", None)
def has_workspace_capability(user, workspace: Workspace, capability: str) -> bool:
role = get_workspace_role(user, workspace)
if not role:
return False
return capability in WORKSPACE_ROLE_CAPABILITIES.get(role, set())
def has_project_capability(user, project, capability: str) -> bool:
if has_workspace_capability(user, project.workspace, capability):
return True
workspace_role = get_workspace_role(user, project.workspace)
if workspace_role not in {
WorkspaceMembership.Role.OWNER,
WorkspaceMembership.Role.ADMIN,
}:
return False
is_project_manager = ProjectMembership.objects.filter(
project=project,
user=user,
role=ProjectMembership.Role.MANAGER,
is_active=True,
is_deleted=False,
).exists()
return is_project_manager and capability in PROJECT_MANAGER_CAPABILITIES
def can_manage_workspace_members(user, workspace: Workspace) -> bool:
return has_workspace_capability(user, workspace, WORKSPACE_MEMBERS_CHANGE_ROLE)
def can_assign_workspace_role(user, workspace: Workspace, role: str) -> bool:
actor_role = get_workspace_role(user, workspace)
if actor_role == WorkspaceMembership.Role.OWNER:
return True
if actor_role == WorkspaceMembership.Role.ADMIN:
return role != WorkspaceMembership.Role.OWNER
return False
def can_change_workspace_membership(user, membership: WorkspaceMembership, *, new_role: str | None = None) -> bool:
workspace = membership.workspace
actor_role = get_workspace_role(user, workspace)
if actor_role not in {
WorkspaceMembership.Role.OWNER,
WorkspaceMembership.Role.ADMIN,
}:
return False
if membership.user_id == user.id:
return False
target_is_canonical_owner = workspace.owner_id == membership.user_id
target_is_owner_role = membership.role == WorkspaceMembership.Role.OWNER
if actor_role == WorkspaceMembership.Role.ADMIN:
if target_is_owner_role or target_is_canonical_owner:
return False
if new_role == WorkspaceMembership.Role.OWNER:
return False
return True
if target_is_canonical_owner:
return False
if new_role == WorkspaceMembership.Role.OWNER and workspace.owner_id != user.id:
return False
return True

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1,258 @@
from datetime import timedelta
import pytest
from django.utils import timezone
from rest_framework.test import APIClient
from apps.clients.models import Client
from apps.projects.models import Project, ProjectMembership
from apps.tags.models import Tag
from apps.users.models import User
from apps.workspaces.models import Workspace, WorkspaceMembership
@pytest.fixture()
def api_client():
return APIClient()
def _user(index: int) -> User:
return User.objects.create_user(
mobile=f"091255500{index:02d}",
password="secret123",
first_name=f"User{index}",
)
@pytest.fixture()
def owner(db):
return _user(1)
@pytest.fixture()
def admin(db):
return _user(2)
@pytest.fixture()
def member(db):
return _user(3)
@pytest.fixture()
def guest(db):
return _user(4)
@pytest.fixture()
def extra_owner(db):
return _user(5)
@pytest.fixture()
def workspace(owner, admin, member, guest):
workspace = Workspace.objects.create(name="Ops", description="", owner=owner)
WorkspaceMembership.objects.create(
workspace=workspace,
user=admin,
role=WorkspaceMembership.Role.ADMIN,
is_active=True,
)
WorkspaceMembership.objects.create(
workspace=workspace,
user=member,
role=WorkspaceMembership.Role.MEMBER,
is_active=True,
)
WorkspaceMembership.objects.create(
workspace=workspace,
user=guest,
role=WorkspaceMembership.Role.GUEST,
is_active=True,
)
return workspace
@pytest.fixture()
def project(workspace, owner, member):
project = Project.objects.create(workspace=workspace, name="Alpha", description="")
ProjectMembership.objects.create(
project=project,
user=owner,
role=ProjectMembership.Role.MANAGER,
is_active=True,
)
ProjectMembership.objects.create(
project=project,
user=member,
role=ProjectMembership.Role.MANAGER,
is_active=True,
)
return project
def test_member_is_read_only_for_clients_and_projects(api_client, member, workspace, project):
client = Client.objects.create(workspace=workspace, name="Existing Client", notes="")
api_client.force_authenticate(user=member)
client_response = api_client.post(
"/api/clients/",
{"workspace_id": str(workspace.id), "name": "Acme", "notes": ""},
format="json",
)
update_client_response = api_client.patch(
f"/api/clients/{client.id}/",
{"name": "Updated"},
format="json",
)
delete_client_response = api_client.delete(f"/api/clients/{client.id}/")
project_response = api_client.post(
"/api/projects/",
{"workspace": str(workspace.id), "name": "Beta", "description": "", "client": None},
format="json",
)
update_project_response = api_client.patch(
f"/api/projects/{project.id}/",
{"description": "Blocked edit"},
format="json",
)
archive_project_response = api_client.post(f"/api/projects/{project.id}/archive/")
delete_project_response = api_client.delete(f"/api/projects/{project.id}/")
membership_response = api_client.post(
"/api/memberships/",
{
"project_id": str(project.id),
"user_id": str(workspace.owner_id),
"role": ProjectMembership.Role.MEMBER,
},
format="json",
)
assert client_response.status_code == 403
assert update_client_response.status_code == 403
assert delete_client_response.status_code == 403
assert project_response.status_code == 403
assert update_project_response.status_code == 403
assert archive_project_response.status_code == 403
assert delete_project_response.status_code == 403
assert membership_response.status_code == 403
def test_member_can_create_tags_and_manage_own_time_entries(api_client, owner, member, workspace):
tag = Tag.objects.create(workspace=workspace, name="Existing", color="#000000")
api_client.force_authenticate(user=member)
create_tag_response = api_client.post(
"/api/tags/",
{"workspace_id": str(workspace.id), "name": "New Tag", "color": "#ffffff"},
format="json",
)
update_tag_response = api_client.patch(
f"/api/tags/{tag.id}/",
{"name": "Changed"},
format="json",
)
delete_tag_response = api_client.delete(f"/api/tags/{tag.id}/")
now = timezone.now()
create_entry_response = api_client.post(
"/api/time-entries/",
{
"workspace_id": str(workspace.id),
"start_time": now.isoformat(),
"end_time": (now + timedelta(hours=1)).isoformat(),
"description": "Focus block",
},
format="json",
)
assert create_tag_response.status_code == 201
assert update_tag_response.status_code == 403
assert delete_tag_response.status_code == 403
assert create_entry_response.status_code == 201
entry_id = create_entry_response.data["id"]
update_entry_response = api_client.patch(
f"/api/time-entries/{entry_id}/",
{"description": "Updated focus block"},
format="json",
)
delete_entry_response = api_client.delete(f"/api/time-entries/{entry_id}/")
assert update_entry_response.status_code == 200
assert delete_entry_response.status_code == 204
def test_guest_is_read_only_for_workspace_resources(api_client, owner, guest, workspace, project):
Client.objects.create(workspace=workspace, name="Visible Client", notes="")
Tag.objects.create(workspace=workspace, name="Visible Tag", color="#123456")
api_client.force_authenticate(user=guest)
list_clients_response = api_client.get(f"/api/clients/?workspace={workspace.id}")
list_projects_response = api_client.get(f"/api/projects/?workspace={workspace.id}")
create_tag_response = api_client.post(
"/api/tags/",
{"workspace_id": str(workspace.id), "name": "Blocked", "color": "#ffffff"},
format="json",
)
create_entry_response = api_client.post(
"/api/time-entries/",
{
"workspace_id": str(workspace.id),
"start_time": timezone.now().isoformat(),
"description": "Blocked guest entry",
},
format="json",
)
edit_project_response = api_client.patch(
f"/api/projects/{project.id}/",
{"description": "Blocked"},
format="json",
)
assert list_clients_response.status_code == 200
assert list_projects_response.status_code == 200
assert create_tag_response.status_code == 403
assert create_entry_response.status_code == 403
assert edit_project_response.status_code == 404
def test_member_project_manager_cannot_edit_project(api_client, member, project):
api_client.force_authenticate(user=member)
response = api_client.patch(
f"/api/projects/{project.id}/",
{"description": "Still blocked"},
format="json",
)
assert response.status_code == 403
def test_admin_cannot_change_owner_membership_but_canonical_owner_can(
api_client, owner, admin, extra_owner, workspace
):
extra_owner_membership = WorkspaceMembership.objects.create(
workspace=workspace,
user=extra_owner,
role=WorkspaceMembership.Role.OWNER,
is_active=True,
)
api_client.force_authenticate(user=admin)
admin_response = api_client.patch(
f"/api/workspace-memberships/{extra_owner_membership.id}/",
{"role": WorkspaceMembership.Role.ADMIN},
format="json",
)
api_client.force_authenticate(user=owner)
owner_response = api_client.patch(
f"/api/workspace-memberships/{extra_owner_membership.id}/",
{"role": WorkspaceMembership.Role.ADMIN},
format="json",
)
assert admin_response.status_code == 403
assert owner_response.status_code == 200