refactor(projects): remove project membership access model

This commit is contained in:
2026-04-28 19:35:24 +03:30
parent 71924ce6fb
commit 1cd948592c
20 changed files with 150 additions and 905 deletions

View File

@@ -4,7 +4,6 @@ SECTION_WORKSPACE = "workspace"
SECTION_WORKSPACE_MEMBERS = "workspace_members" SECTION_WORKSPACE_MEMBERS = "workspace_members"
SECTION_CLIENTS = "clients" SECTION_CLIENTS = "clients"
SECTION_PROJECTS = "projects" SECTION_PROJECTS = "projects"
SECTION_PROJECT_MEMBERS = "project_members"
SECTION_TAGS = "tags" SECTION_TAGS = "tags"
SECTION_TIME_ENTRIES = "time_entries" SECTION_TIME_ENTRIES = "time_entries"
SECTION_RATES = "rates" SECTION_RATES = "rates"
@@ -15,7 +14,6 @@ LOG_SECTIONS = (
SECTION_WORKSPACE_MEMBERS, SECTION_WORKSPACE_MEMBERS,
SECTION_CLIENTS, SECTION_CLIENTS,
SECTION_PROJECTS, SECTION_PROJECTS,
SECTION_PROJECT_MEMBERS,
SECTION_TAGS, SECTION_TAGS,
SECTION_TIME_ENTRIES, SECTION_TIME_ENTRIES,
SECTION_RATES, SECTION_RATES,
@@ -48,11 +46,9 @@ SECTION_BY_MODEL_LABEL = {
"workspaces.workspaceuserrate": SECTION_RATES, "workspaces.workspaceuserrate": SECTION_RATES,
"clients.client": SECTION_CLIENTS, "clients.client": SECTION_CLIENTS,
"projects.project": SECTION_PROJECTS, "projects.project": SECTION_PROJECTS,
"projects.projectmembership": SECTION_PROJECT_MEMBERS,
"tags.tag": SECTION_TAGS, "tags.tag": SECTION_TAGS,
"time_entries.timeentry": SECTION_TIME_ENTRIES, "time_entries.timeentry": SECTION_TIME_ENTRIES,
"reports.reportexportjob": SECTION_REPORT_EXPORTS, "reports.reportexportjob": SECTION_REPORT_EXPORTS,
} }
TRACKED_MODEL_LABELS = tuple(SECTION_BY_MODEL_LABEL.keys()) TRACKED_MODEL_LABELS = tuple(SECTION_BY_MODEL_LABEL.keys())

View File

@@ -1,8 +1,4 @@
from apps.notifications.services.membership_events import ( from apps.notifications.services.membership_events import (
notify_project_membership_added,
notify_project_membership_deactivated,
notify_project_membership_removed,
notify_project_membership_role_changed,
notify_workspace_membership_added, notify_workspace_membership_added,
notify_workspace_membership_deactivated, notify_workspace_membership_deactivated,
notify_workspace_membership_removed, notify_workspace_membership_removed,
@@ -16,8 +12,4 @@ __all__ = [
"notify_workspace_membership_role_changed", "notify_workspace_membership_role_changed",
"notify_workspace_membership_deactivated", "notify_workspace_membership_deactivated",
"notify_workspace_membership_removed", "notify_workspace_membership_removed",
"notify_project_membership_added",
"notify_project_membership_role_changed",
"notify_project_membership_deactivated",
"notify_project_membership_removed",
] ]

View File

@@ -31,10 +31,6 @@ def _workspace_action_url(workspace) -> str:
return f"/workspaces/{workspace.id}" return f"/workspaces/{workspace.id}"
def _project_action_url(project) -> str:
return "/projects"
def notify_workspace_membership_added(*, actor, recipient, workspace, role: str) -> None: def notify_workspace_membership_added(*, actor, recipient, workspace, role: str) -> None:
if _should_skip(actor, recipient): if _should_skip(actor, recipient):
return return
@@ -148,124 +144,3 @@ def notify_workspace_membership_removed(*, actor, recipient, workspace, role: st
}, },
}, },
) )
def notify_project_membership_added(*, actor, recipient, project, role: str) -> None:
if _should_skip(actor, recipient):
return
actor_display = _actor_name(actor)
role_label = _role_label(recipient.project_memberships.model.Role, role)
_notify_user(
recipient,
{
"type": "project_membership_added",
"title": "Added to project",
"message": f"{actor_display} added you to {project.name} as {role_label}.",
"level": "info",
"action_url": _project_action_url(project),
"entity_type": "project",
"entity_id": str(project.id),
"meta": {
"workspace_id": str(project.workspace_id),
"workspace_name": project.workspace.name,
"project_id": str(project.id),
"project_name": project.name,
"actor_id": str(actor.id),
"actor_name": actor_display,
"new_role": role,
},
},
)
def notify_project_membership_role_changed(
*, actor, recipient, project, previous_role: str, new_role: str
) -> None:
if _should_skip(actor, recipient) or previous_role == new_role:
return
actor_display = _actor_name(actor)
previous_role_label = _role_label(recipient.project_memberships.model.Role, previous_role)
new_role_label = _role_label(recipient.project_memberships.model.Role, new_role)
_notify_user(
recipient,
{
"type": "project_membership_role_changed",
"title": "Project role changed",
"message": (
f"{actor_display} changed your role in {project.name} "
f"from {previous_role_label} to {new_role_label}."
),
"level": "info",
"action_url": _project_action_url(project),
"entity_type": "project",
"entity_id": str(project.id),
"meta": {
"workspace_id": str(project.workspace_id),
"workspace_name": project.workspace.name,
"project_id": str(project.id),
"project_name": project.name,
"actor_id": str(actor.id),
"actor_name": actor_display,
"previous_role": previous_role,
"new_role": new_role,
},
},
)
def notify_project_membership_deactivated(*, actor, recipient, project, role: str) -> None:
if _should_skip(actor, recipient):
return
actor_display = _actor_name(actor)
_notify_user(
recipient,
{
"type": "project_membership_deactivated",
"title": "Project access deactivated",
"message": f"{actor_display} deactivated your access to {project.name}.",
"level": "warning",
"action_url": _project_action_url(project),
"entity_type": "project",
"entity_id": str(project.id),
"meta": {
"workspace_id": str(project.workspace_id),
"workspace_name": project.workspace.name,
"project_id": str(project.id),
"project_name": project.name,
"actor_id": str(actor.id),
"actor_name": actor_display,
"previous_role": role,
},
},
)
def notify_project_membership_removed(*, actor, recipient, project, role: str) -> None:
if _should_skip(actor, recipient):
return
actor_display = _actor_name(actor)
_notify_user(
recipient,
{
"type": "project_membership_removed",
"title": "Removed from project",
"message": f"{actor_display} removed you from {project.name}.",
"level": "warning",
"action_url": _project_action_url(project),
"entity_type": "project",
"entity_id": str(project.id),
"meta": {
"workspace_id": str(project.workspace_id),
"workspace_name": project.workspace.name,
"project_id": str(project.id),
"project_name": project.name,
"actor_id": str(actor.id),
"actor_name": actor_display,
"previous_role": role,
},
},
)

View File

@@ -4,7 +4,6 @@ from rest_framework.test import APIClient
from apps.notifications.services import store as services from apps.notifications.services import store as services
from apps.notifications.services import RedisNotificationStore from apps.notifications.services import RedisNotificationStore
from apps.notifications.tests.test_services import FakeRedis from apps.notifications.tests.test_services import FakeRedis
from apps.projects.models import Project, ProjectMembership
from apps.users.models import User from apps.users.models import User
from apps.workspaces.models import Workspace, WorkspaceMembership from apps.workspaces.models import Workspace, WorkspaceMembership
@@ -158,140 +157,3 @@ def test_workspace_membership_update_skips_self_notifications(
assert response.status_code == 403 assert response.status_code == 403
assert _notifications_for(owner) == [] assert _notifications_for(owner) == []
def test_project_create_notifies_initial_members_not_creator(
fake_redis, api_client, owner, member
):
workspace = Workspace.objects.create(name="Engineering", description="", owner=owner)
api_client.force_authenticate(user=owner)
response = api_client.post(
"/api/projects/",
{
"workspace": str(workspace.id),
"name": "API",
"description": "",
"client": None,
"members": [
{"user_id": str(member.id), "role": ProjectMembership.Role.MEMBER}
],
},
format="json",
)
assert response.status_code == 201
assert _notifications_for(owner) == []
notifications = _notifications_for(member)
assert len(notifications) == 1
assert notifications[0]["type"] == "project_membership_added"
assert notifications[0]["meta"]["project_name"] == "API"
def test_project_update_full_sync_notifies_only_real_deltas(
fake_redis, api_client, owner, member, another_member, third_member, fourth_member
):
workspace = Workspace.objects.create(name="Build", description="", owner=owner)
project = Project.objects.create(workspace=workspace, name="Platform", 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.MEMBER,
is_active=True,
)
ProjectMembership.objects.create(
project=project,
user=another_member,
role=ProjectMembership.Role.MEMBER,
is_active=True,
)
ProjectMembership.objects.create(
project=project,
user=third_member,
role=ProjectMembership.Role.MEMBER,
is_active=True,
)
api_client.force_authenticate(user=owner)
response = api_client.patch(
f"/api/projects/{project.id}/",
{
"client": None,
"members": [
{"user_id": str(member.id), "role": ProjectMembership.Role.MANAGER},
{"user_id": str(third_member.id), "role": ProjectMembership.Role.MEMBER},
{"user_id": str(fourth_member.id), "role": ProjectMembership.Role.MEMBER},
],
},
format="json",
)
assert response.status_code == 200
assert [item["type"] for item in _notifications_for(member)] == [
"project_membership_role_changed"
]
assert [item["type"] for item in _notifications_for(another_member)] == [
"project_membership_deactivated"
]
assert _notifications_for(third_member) == []
assert [item["type"] for item in _notifications_for(fourth_member)] == [
"project_membership_added"
]
assert _notifications_for(owner) == []
def test_project_membership_crud_emits_add_role_change_deactivate_and_remove(
fake_redis, api_client, owner, member
):
workspace = Workspace.objects.create(name="Data", description="", owner=owner)
project = Project.objects.create(workspace=workspace, name="Warehouse", description="")
ProjectMembership.objects.create(
project=project,
user=owner,
role=ProjectMembership.Role.MANAGER,
is_active=True,
)
api_client.force_authenticate(user=owner)
create_response = api_client.post(
"/api/memberships/",
{
"project_id": str(project.id),
"user_id": str(member.id),
"role": ProjectMembership.Role.MEMBER,
},
format="json",
)
assert create_response.status_code == 201
membership_id = create_response.data["id"]
role_response = api_client.patch(
f"/api/memberships/{membership_id}/",
{"role": ProjectMembership.Role.MANAGER},
format="json",
)
assert role_response.status_code == 200
deactivate_response = api_client.patch(
f"/api/memberships/{membership_id}/",
{"is_active": False},
format="json",
)
assert deactivate_response.status_code == 200
remove_response = api_client.delete(f"/api/memberships/{membership_id}/")
assert remove_response.status_code == 204
notifications = _notifications_for(member)
assert [item["type"] for item in notifications] == [
"project_membership_removed",
"project_membership_deactivated",
"project_membership_role_changed",
"project_membership_added",
]

View File

@@ -1,13 +1,7 @@
from django.contrib import admin from django.contrib import admin
from core.admins.base import BaseAdmin from core.admins.base import BaseAdmin
from apps.projects.models import Project, ProjectMembership from apps.projects.models import Project
class ProjectMembershipInline(admin.TabularInline):
model = ProjectMembership
extra = 0
autocomplete_fields = ("user",)
@admin.register(Project) @admin.register(Project)
@@ -33,36 +27,7 @@ class ProjectAdmin(BaseAdmin):
"client__name", "client__name",
) )
autocomplete_fields = ( autocomplete_fields = (
"workspace", "workspace",
"client", "client",
) )
inlines = (ProjectMembershipInline,)
@admin.register(ProjectMembership)
class ProjectMembershipAdmin(BaseAdmin):
list_display = (
"id",
"project",
"user",
"role",
"is_active",
)
list_filter = (
"role",
"is_active",
"is_deleted",
)
search_fields = (
"project__name",
"user__mobile",
)
autocomplete_fields = (
"project",
"user",
)

View File

@@ -1,11 +1,9 @@
from rest_framework import permissions from rest_framework import permissions
from apps.projects.models import ProjectMembership
from apps.workspaces.services import ( from apps.workspaces.services import (
PROJECTS_EDIT, PROJECTS_EDIT,
PROJECTS_VIEW, PROJECTS_VIEW,
PROJECT_MEMBERS_CHANGE_ROLE, has_workspace_capability,
has_project_capability,
) )
@@ -17,9 +15,9 @@ def get_project_from_obj(obj):
class IsProjectMember(permissions.BasePermission): class IsProjectMember(permissions.BasePermission):
""" """
Allows access only to users who have an active membership in the project. Allows access to users who can view projects in the parent workspace.
""" """
message = "شما عضو این پروژه نیستید." message = "شما عضو این پروژه نیستید."
def has_object_permission(self, request, view, obj): def has_object_permission(self, request, view, obj):
@@ -27,13 +25,13 @@ class IsProjectMember(permissions.BasePermission):
return False return False
project = get_project_from_obj(obj) project = get_project_from_obj(obj)
return has_project_capability(request.user, project, PROJECTS_VIEW) return has_workspace_capability(request.user, project.workspace, PROJECTS_VIEW)
class IsProjectManager(permissions.BasePermission): class IsProjectManager(permissions.BasePermission):
""" """
Allows access only to users who are active MANAGERs of the project. Allows access to users who can manage projects in the parent workspace.
""" """
message = "فقط مدیران پروژه مجاز به انجام این عملیات هستند." message = "فقط مدیران پروژه مجاز به انجام این عملیات هستند."
def has_object_permission(self, request, view, obj): def has_object_permission(self, request, view, obj):
@@ -41,19 +39,4 @@ class IsProjectManager(permissions.BasePermission):
return False return False
project = get_project_from_obj(obj) project = get_project_from_obj(obj)
return has_project_capability(request.user, project, PROJECTS_EDIT) return has_workspace_capability(request.user, project.workspace, PROJECTS_EDIT)
class CanManageProjectMembers(permissions.BasePermission):
message = "Only authorized users can manage project memberships."
def has_object_permission(self, request, view, obj):
if not request.user or not request.user.is_authenticated:
return False
project = get_project_from_obj(obj)
return has_project_capability(
request.user,
project,
PROJECT_MEMBERS_CHANGE_ROLE,
)

View File

@@ -1,97 +1,42 @@
from rest_framework import serializers from rest_framework import serializers
from core.serializers.base import BaseModelSerializer from core.serializers.base import BaseModelSerializer
from apps.projects.models import ( from apps.projects.models import Project
Project,
ProjectMembership,
) class ProjectSerializer(BaseModelSerializer):
from core.serializers.mini import UserMiniSerializer class Meta:
model = Project
fields = BaseModelSerializer.Meta.fields + (
class ProjectMemberInputSerializer(serializers.Serializer): "workspace",
user_id = serializers.UUIDField() "name",
role = serializers.ChoiceField(choices=ProjectMembership.Role.choices, default=ProjectMembership.Role.MEMBER) "client",
"description",
"is_archived",
class ProjectSerializer(BaseModelSerializer): "color",
my_role = serializers.SerializerMethodField() )
members = serializers.SerializerMethodField() read_only_fields = fields
class Meta: def to_representation(self, instance):
model = Project representation = super().to_representation(instance)
fields = BaseModelSerializer.Meta.fields + ( if instance.client:
"workspace", representation['client'] = {
"name",
"client",
"description",
"is_archived",
"color",
"my_role",
"members",
)
read_only_fields = fields
def get_my_role(self, obj):
request = self.context.get("request")
if not request or not request.user.is_authenticated:
return None
membership = obj.memberships.filter(user=request.user, is_active=True, is_deleted=False).first()
return getattr(membership, "role", None)
def get_members(self, obj):
"""
Returns active project members in the response
"""
active_memberships = obj.memberships.filter(is_active=True, is_deleted=False)
return ProjectMembershipSerializer(active_memberships, many=True).data
def to_representation(self, instance):
representation = super().to_representation(instance)
if instance.client:
representation['client'] = {
'id': instance.client.id, 'id': instance.client.id,
'name': instance.client.name 'name': instance.client.name
} }
return representation return representation
class ProjectCreateSerializer(serializers.Serializer): class ProjectCreateSerializer(serializers.Serializer):
workspace = serializers.UUIDField() workspace = serializers.UUIDField()
name = serializers.CharField(max_length=255) name = serializers.CharField(max_length=255)
client = serializers.UUIDField(required=False, allow_null=True) client = serializers.UUIDField(required=False, allow_null=True)
description = serializers.CharField(required=False, allow_blank=True, default="") description = serializers.CharField(required=False, allow_blank=True, default="")
color = serializers.CharField(max_length=7, required=False, allow_blank=True, default="") color = serializers.CharField(max_length=7, required=False, allow_blank=True, default="")
members = ProjectMemberInputSerializer(many=True, required=False)
class ProjectUpdateSerializer(serializers.Serializer):
class ProjectUpdateSerializer(serializers.Serializer): name = serializers.CharField(max_length=255, required=False)
name = serializers.CharField(max_length=255, required=False) client = serializers.UUIDField(required=False, allow_null=True)
client = serializers.UUIDField(required=False, allow_null=True) description = serializers.CharField(required=False, allow_blank=True)
description = serializers.CharField(required=False, allow_blank=True) color = serializers.CharField(max_length=7, required=False, allow_blank=True)
color = serializers.CharField(max_length=7, required=False, allow_blank=True) is_archived = serializers.BooleanField(required=False)
is_archived = serializers.BooleanField(required=False)
members = ProjectMemberInputSerializer(many=True, required=False)
class ProjectMembershipSerializer(BaseModelSerializer):
user_details = UserMiniSerializer(read_only=True)
class Meta:
model = ProjectMembership
fields = BaseModelSerializer.Meta.fields + (
"project",
"user",
"user_details",
"role",
"is_active",
)
read_only_fields = fields
class ProjectMembershipCreateSerializer(serializers.Serializer):
project_id = serializers.UUIDField()
user_id = serializers.UUIDField()
role = serializers.ChoiceField(choices=ProjectMembership.Role.choices)
class ProjectMembershipUpdateSerializer(serializers.Serializer):
role = serializers.ChoiceField(choices=ProjectMembership.Role.choices, required=False)
is_active = serializers.BooleanField(required=False)

View File

@@ -1,16 +1,12 @@
from django.urls import path, include from django.urls import path, include
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from apps.projects.api.views import ( from apps.projects.api.views import ProjectViewSet
ProjectViewSet,
ProjectMembershipViewSet,
)
app_name = "projects" app_name = "projects"
router = DefaultRouter() router = DefaultRouter()
router.register(r"projects", ProjectViewSet, basename="project") router.register(r"projects", ProjectViewSet, basename="project")
router.register(r"memberships", ProjectMembershipViewSet, basename="membership")
urlpatterns = [ urlpatterns = [
path("", include(router.urls)), path("", include(router.urls)),

View File

@@ -1,49 +1,33 @@
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from rest_framework import status from rest_framework import status
from rest_framework.viewsets import ModelViewSet from rest_framework.viewsets import ModelViewSet
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import IsAuthenticated
from rest_framework.permissions import IsAuthenticated from rest_framework.decorators import action
from rest_framework.decorators import action from rest_framework.filters import SearchFilter, OrderingFilter
from rest_framework.filters import SearchFilter, OrderingFilter
from django_filters.rest_framework import DjangoFilterBackend from django_filters.rest_framework import DjangoFilterBackend
from core.paginations.limit_offset import CustomLimitOffsetPagination from core.paginations.limit_offset import CustomLimitOffsetPagination
from apps.notifications.services import (
notify_project_membership_added,
notify_project_membership_deactivated,
notify_project_membership_removed,
notify_project_membership_role_changed,
)
from apps.workspaces.models import Workspace from apps.workspaces.models import Workspace
from apps.clients.models import Client from apps.clients.models import Client
from apps.projects.models import ( from apps.projects.models import Project
Project,
ProjectMembership,
)
from apps.projects.api.serializers import ( from apps.projects.api.serializers import (
ProjectSerializer, ProjectCreateSerializer, ProjectUpdateSerializer, ProjectSerializer, ProjectCreateSerializer, ProjectUpdateSerializer,
ProjectMembershipSerializer, ProjectMembershipCreateSerializer, ProjectMembershipUpdateSerializer,
) )
from apps.projects.api.permissions import IsProjectMember, IsProjectManager from apps.projects.api.permissions import IsProjectMember, IsProjectManager
from apps.projects.services.projects import ( from apps.projects.services.projects import (
create_project, create_project,
update_project, update_project,
toggle_project_archive toggle_project_archive
) )
from apps.projects.services.memberships import add_project_member, update_project_member
from apps.workspaces.services import ( from apps.workspaces.services import (
PROJECTS_ARCHIVE, PROJECTS_ARCHIVE,
PROJECTS_CREATE, PROJECTS_CREATE,
PROJECTS_DELETE, PROJECTS_DELETE,
PROJECTS_EDIT, PROJECTS_EDIT,
PROJECT_MEMBERS_ADD,
PROJECT_MEMBERS_CHANGE_ROLE,
PROJECT_MEMBERS_REMOVE,
can_delete_workspace_object, can_delete_workspace_object,
has_project_capability,
has_workspace_capability, has_workspace_capability,
) )
@@ -60,13 +44,13 @@ class ProjectViewSet(ModelViewSet):
ordering_fields = ["name", "created_at", "updated_at"] ordering_fields = ["name", "created_at", "updated_at"]
ordering = ["-updated_at", "-created_at"] ordering = ["-updated_at", "-created_at"]
def get_permissions(self): def get_permissions(self):
""" """
Instantiates and returns the list of permissions that this view requires. Instantiates and returns the list of permissions that this view requires.
- Managers can update, delete, or archive. - Workspace-authorized users can update, delete, or archive.
- Members can retrieve/view. - Workspace members can retrieve/view.
- Any authenticated user can list (filtered to their memberships) or attempt to create. - Any authenticated user can list their workspace projects or attempt to create.
""" """
if self.action in ["update", "partial_update", "destroy", "archive"]: if self.action in ["update", "partial_update", "destroy", "archive"]:
permission_classes = [IsAuthenticated, IsProjectManager] permission_classes = [IsAuthenticated, IsProjectManager]
elif self.action in ["retrieve"]: elif self.action in ["retrieve"]:
@@ -76,10 +60,10 @@ class ProjectViewSet(ModelViewSet):
return [permission() for permission in permission_classes] return [permission() for permission in permission_classes]
def get_queryset(self): def get_queryset(self):
""" """
Returns active projects where the current user is an active member. Returns active projects in workspaces where the current user is an active member.
""" """
if getattr(self, "swagger_fake_view", False) or not self.request.user.is_authenticated: if getattr(self, "swagger_fake_view", False) or not self.request.user.is_authenticated:
return Project.objects.none() return Project.objects.none()
@@ -100,14 +84,12 @@ class ProjectViewSet(ModelViewSet):
return ProjectSerializer return ProjectSerializer
def create(self, request, *args, **kwargs): def create(self, request, *args, **kwargs):
""" """
Creates a new project using the project service layer. Creates a new project using the project service layer.
""" """
serializer = self.get_serializer(data=request.data) serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
members_data = serializer.validated_data.pop("members", [])
workspace = get_object_or_404(Workspace, id=serializer.validated_data["workspace"], is_deleted=False) workspace = get_object_or_404(Workspace, id=serializer.validated_data["workspace"], is_deleted=False)
if not has_workspace_capability(request.user, workspace, PROJECTS_CREATE): if not has_workspace_capability(request.user, workspace, PROJECTS_CREATE):
return Response( return Response(
@@ -122,101 +104,30 @@ class ProjectViewSet(ModelViewSet):
workspace=workspace, workspace=workspace,
name=serializer.validated_data["name"], name=serializer.validated_data["name"],
client=client, client=client,
description=serializer.validated_data.get("description", ""), description=serializer.validated_data.get("description", ""),
color=serializer.validated_data.get("color", "") color=serializer.validated_data.get("color", "")
) )
for member in members_data: output_serializer = ProjectSerializer(project)
membership = add_project_member( return Response(output_serializer.data, status=status.HTTP_201_CREATED)
project=project,
user_id=member["user_id"],
role=member["role"]
)
notify_project_membership_added(
actor=request.user,
recipient=membership.user,
project=project,
role=membership.role,
)
output_serializer = ProjectSerializer(project)
return Response(output_serializer.data, status=status.HTTP_201_CREATED)
def update(self, request, *args, **kwargs): def update(self, request, *args, **kwargs):
""" """
Updates an existing project using the project service layer. Updates an existing project using the project service layer.
""" """
partial = kwargs.pop("partial", False) partial = kwargs.pop("partial", False)
project = self.get_object() project = self.get_object()
serializer = self.get_serializer(data=request.data, partial=partial) serializer = self.get_serializer(data=request.data, partial=partial)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
members_data = serializer.validated_data.pop("members", None) updated_project = update_project(
project=project,
updated_project = update_project( **serializer.validated_data
project=project, )
**serializer.validated_data
) output_serializer = ProjectSerializer(updated_project)
return Response(output_serializer.data, status=status.HTTP_200_OK)
# Full sync of project members if array is provided
if members_data is not None:
current_memberships = {str(m.user_id): m for m in updated_project.memberships.filter(is_deleted=False)}
incoming_users = {str(m['user_id']) for m in members_data}
# Add or Update roles
for member in members_data:
user_id_str = str(member['user_id'])
if user_id_str in current_memberships:
membership = current_memberships[user_id_str]
previous_role = membership.role
previous_is_active = membership.is_active
updated_membership = update_project_member(
membership,
role=member['role'],
is_active=True
)
if not previous_is_active and updated_membership.is_active:
notify_project_membership_added(
actor=request.user,
recipient=updated_membership.user,
project=updated_project,
role=updated_membership.role,
)
elif previous_role != updated_membership.role:
notify_project_membership_role_changed(
actor=request.user,
recipient=updated_membership.user,
project=updated_project,
previous_role=previous_role,
new_role=updated_membership.role,
)
else:
membership = add_project_member(
project=updated_project,
user_id=member['user_id'],
role=member['role']
)
notify_project_membership_added(
actor=request.user,
recipient=membership.user,
project=updated_project,
role=membership.role,
)
# Deactivate omitted members
for user_id_str, membership in current_memberships.items():
if user_id_str not in incoming_users and membership.is_active:
update_project_member(membership, is_active=False)
notify_project_membership_deactivated(
actor=request.user,
recipient=membership.user,
project=updated_project,
role=membership.role,
)
output_serializer = ProjectSerializer(updated_project)
return Response(output_serializer.data, status=status.HTTP_200_OK)
def destroy(self, request, *args, **kwargs): def destroy(self, request, *args, **kwargs):
""" """
@@ -238,127 +149,7 @@ class ProjectViewSet(ModelViewSet):
Custom endpoint to toggle the archive status of a project. Custom endpoint to toggle the archive status of a project.
""" """
project = self.get_object() project = self.get_object()
updated_project = toggle_project_archive(project) updated_project = toggle_project_archive(project)
output_serializer = ProjectSerializer(updated_project) output_serializer = ProjectSerializer(updated_project)
return Response(output_serializer.data, status=status.HTTP_200_OK) return Response(output_serializer.data, status=status.HTTP_200_OK)
class BaseProjectNestedViewSet(ModelViewSet):
"""
Base ViewSet for nested project models to share common permission and queryset logic.
"""
pagination_class = CustomLimitOffsetPagination
filter_backends = [DjangoFilterBackend, OrderingFilter]
ordering = ["-updated_at", "-created_at"]
def get_permissions(self):
if self.action in ["create", "update", "partial_update", "destroy"]:
permission_classes = [IsAuthenticated, IsProjectManager]
else:
permission_classes = [IsAuthenticated, IsProjectMember]
return [permission() for permission in permission_classes]
def verify_manager_access(self, project_id):
"""Helper to verify if the requesting user is a manager of the target project."""
project = get_object_or_404(Project, id=project_id, is_deleted=False)
if not has_project_capability(self.request.user, project, PROJECT_MEMBERS_ADD):
raise PermissionDenied("You must be a project manager to perform this action.")
class ProjectMembershipViewSet(BaseProjectNestedViewSet):
filterset_fields = ["project", "user", "role", "is_active"]
def get_queryset(self):
if getattr(self, "swagger_fake_view", False) or not self.request.user.is_authenticated:
return ProjectMembership.objects.none()
return ProjectMembership.objects.filter(
project__memberships__user=self.request.user,
project__memberships__is_active=True,
is_deleted=False
).distinct()
def get_serializer_class(self):
if self.action == "create": return ProjectMembershipCreateSerializer
if self.action in ["update", "partial_update"]: return ProjectMembershipUpdateSerializer
return ProjectMembershipSerializer
def create(self, request, *args, **kwargs):
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
project_id = serializer.validated_data["project_id"]
self.verify_manager_access(project_id)
project = get_object_or_404(Project, id=project_id, is_deleted=False)
membership = add_project_member(
project=project,
user_id=serializer.validated_data["user_id"],
role=serializer.validated_data["role"]
)
notify_project_membership_added(
actor=request.user,
recipient=membership.user,
project=project,
role=membership.role,
)
return Response(ProjectMembershipSerializer(membership).data, status=status.HTTP_201_CREATED)
def update(self, request, *args, **kwargs):
membership = self.get_object()
if not has_project_capability(
request.user,
membership.project,
PROJECT_MEMBERS_CHANGE_ROLE,
):
raise PermissionDenied("You do not have permission to update project members.")
serializer = self.get_serializer(data=request.data, partial=kwargs.pop("partial", False))
serializer.is_valid(raise_exception=True)
previous_role = membership.role
previous_is_active = membership.is_active
updated_membership = update_project_member(membership, **serializer.validated_data)
if not previous_is_active and updated_membership.is_active:
notify_project_membership_added(
actor=request.user,
recipient=updated_membership.user,
project=updated_membership.project,
role=updated_membership.role,
)
elif previous_is_active and not updated_membership.is_active:
notify_project_membership_deactivated(
actor=request.user,
recipient=updated_membership.user,
project=updated_membership.project,
role=previous_role,
)
elif previous_role != updated_membership.role:
notify_project_membership_role_changed(
actor=request.user,
recipient=updated_membership.user,
project=updated_membership.project,
previous_role=previous_role,
new_role=updated_membership.role,
)
return Response(ProjectMembershipSerializer(updated_membership).data, status=status.HTTP_200_OK)
def destroy(self, request, *args, **kwargs):
membership = self.get_object()
if not has_project_capability(
request.user,
membership.project,
PROJECT_MEMBERS_REMOVE,
):
raise PermissionDenied("You do not have permission to remove project members.")
recipient = membership.user
project = membership.project
role = membership.role
membership.is_deleted = True
membership.save(update_fields=["is_deleted", "updated_at"])
notify_project_membership_removed(
actor=request.user,
recipient=recipient,
project=project,
role=role,
)
return Response(status=status.HTTP_204_NO_CONTENT)

View File

@@ -0,0 +1,13 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("projects", "0001_initial"),
]
operations = [
migrations.DeleteModel(
name="ProjectMembership",
),
]

View File

@@ -2,7 +2,7 @@ from django.contrib.auth import get_user_model
from django.db import models from django.db import models
from apps.logs.services import build_workspace_log_metadata from apps.logs.services import build_workspace_log_metadata
from apps.logs.services.constants import SECTION_PROJECTS, SECTION_PROJECT_MEMBERS from apps.logs.services.constants import SECTION_PROJECTS
from core.models.base import BaseModel from core.models.base import BaseModel
from apps.workspaces.models import Workspace from apps.workspaces.models import Workspace
@@ -59,64 +59,6 @@ class Project(BaseModel):
) )
class ProjectMembership(BaseModel):
class Role(models.TextChoices):
MANAGER = "manager", "Manager"
MEMBER = "member", "Member"
project = models.ForeignKey(
Project,
on_delete=models.CASCADE,
related_name="memberships",
)
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="project_memberships",
)
role = models.CharField(
max_length=20,
choices=Role.choices,
default=Role.MEMBER,
)
is_active = models.BooleanField(default=True)
class Meta:
db_table = "project_membership"
ordering = ("-created_at",)
indexes = [
models.Index(fields=["project"], name="project_membership_project_idx"),
models.Index(fields=["user"], name="project_membership_user_idx"),
]
constraints = [
models.UniqueConstraint(
fields=["project", "user"],
name="unique_project_membership",
condition=models.Q(is_deleted=False),
)
]
def __str__(self):
return f"{self.user} @ {self.project}"
def get_additional_data(self):
return build_workspace_log_metadata(
section=SECTION_PROJECT_MEMBERS,
workspace_id=self.project.workspace_id,
target_id=self.id,
target_label=self.user.full_name or self.user.mobile,
extra={
"project_id": str(self.project_id),
"member_user_id": str(self.user_id),
"role": self.role,
},
)
class ProjectRate(BaseModel): class ProjectRate(BaseModel):
project = models.ForeignKey( project = models.ForeignKey(
Project, Project,

View File

@@ -1,32 +0,0 @@
from rest_framework.exceptions import ValidationError
from apps.projects.models import ProjectMembership
def add_project_member(project, user_id, role):
"""
Adds a user to a project. Ensures no duplicate active memberships exist.
"""
if ProjectMembership.objects.filter(project=project, user_id=user_id, is_deleted=False).exists():
raise ValidationError({"user_id": "This user is already a member of the project."})
return ProjectMembership.objects.create(
project=project,
user_id=user_id,
role=role,
is_active=True
)
def update_project_member(membership, **kwargs):
"""
Updates a project membership (e.g., changing role or active status).
"""
update_fields = []
for field, value in kwargs.items():
if hasattr(membership, field) and getattr(membership, field) != value:
setattr(membership, field, value)
update_fields.append(field)
if update_fields:
update_fields.append("updated_at")
membership.save(update_fields=update_fields)
return membership

View File

@@ -1,18 +1,17 @@
from django.db import transaction from django.db import transaction
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from rest_framework.exceptions import ValidationError, PermissionDenied from rest_framework.exceptions import ValidationError, PermissionDenied
from apps.clients.models import Client from apps.clients.models import Client
from apps.projects.models import Project, ProjectMembership from apps.projects.models import Project
from apps.workspaces.models import WorkspaceMembership from apps.workspaces.models import WorkspaceMembership
@transaction.atomic @transaction.atomic
def create_project(user, workspace, name, client=None, description="", color=""): def create_project(user, workspace, name, client=None, description="", color=""):
""" """
Creates a new project and automatically assigns the creator Creates a new workspace-shared project.
as an active MANAGER of that project. """
"""
workspace_member = WorkspaceMembership.objects.filter( workspace_member = WorkspaceMembership.objects.filter(
workspace=workspace, workspace=workspace,
user=user, user=user,
@@ -36,14 +35,7 @@ def create_project(user, workspace, name, client=None, description="", color="")
updated_by=user, updated_by=user,
) )
ProjectMembership.objects.create( return project
project=project,
user=user,
role=ProjectMembership.Role.MANAGER,
is_active=True
)
return project
def update_project(project, **kwargs): def update_project(project, **kwargs):

View File

@@ -132,15 +132,17 @@ class WorkspaceSerializer(BaseModelSerializer):
class WorkspaceMembershipSerializer(BaseModelSerializer): class WorkspaceMembershipSerializer(BaseModelSerializer):
user = serializers.SerializerMethodField() user = serializers.SerializerMethodField()
user_id = serializers.UUIDField(write_only=True, required=False)
class Meta: class Meta:
model = WorkspaceMembership model = WorkspaceMembership
fields = BaseModelSerializer.Meta.fields + ( fields = BaseModelSerializer.Meta.fields + (
"workspace", "workspace",
"user", "user",
"role", "user_id",
"is_active", "role",
) "is_active",
)
def get_user(self, instance): def get_user(self, instance):
request = self.context.get("request") request = self.context.get("request")

View File

@@ -164,7 +164,11 @@ class WorkspaceMembershipViewSet(ModelViewSet):
status=status.HTTP_403_FORBIDDEN, status=status.HTTP_403_FORBIDDEN,
) )
serializer = self.get_serializer(data=request.data) payload = request.data.copy()
if payload.get("user") and not payload.get("user_id"):
payload["user_id"] = payload.get("user")
serializer = self.get_serializer(data=payload)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
membership = serializer.save() membership = serializer.save()
notify_workspace_membership_added( notify_workspace_membership_added(

View File

@@ -8,10 +8,6 @@ from apps.workspaces.services.permissions import (
PROJECTS_DELETE, PROJECTS_DELETE,
PROJECTS_EDIT, PROJECTS_EDIT,
PROJECTS_VIEW, PROJECTS_VIEW,
PROJECT_MEMBERS_ADD,
PROJECT_MEMBERS_CHANGE_ROLE,
PROJECT_MEMBERS_REMOVE,
PROJECT_MEMBERS_VIEW,
TAGS_CREATE, TAGS_CREATE,
TAGS_DELETE, TAGS_DELETE,
TAGS_EDIT, TAGS_EDIT,
@@ -62,10 +58,6 @@ __all__ = [
"PROJECTS_EDIT", "PROJECTS_EDIT",
"PROJECTS_DELETE", "PROJECTS_DELETE",
"PROJECTS_ARCHIVE", "PROJECTS_ARCHIVE",
"PROJECT_MEMBERS_VIEW",
"PROJECT_MEMBERS_ADD",
"PROJECT_MEMBERS_REMOVE",
"PROJECT_MEMBERS_CHANGE_ROLE",
"TIME_ENTRIES_VIEW_OWN", "TIME_ENTRIES_VIEW_OWN",
"TIME_ENTRIES_MANAGE_OWN", "TIME_ENTRIES_MANAGE_OWN",
"get_workspace_membership", "get_workspace_membership",

View File

@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
from apps.projects.models import ProjectMembership
from apps.workspaces.models import Workspace, WorkspaceMembership from apps.workspaces.models import Workspace, WorkspaceMembership
@@ -25,22 +24,9 @@ PROJECTS_CREATE = "projects.create"
PROJECTS_EDIT = "projects.edit" PROJECTS_EDIT = "projects.edit"
PROJECTS_DELETE = "projects.delete" PROJECTS_DELETE = "projects.delete"
PROJECTS_ARCHIVE = "projects.archive" 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_VIEW_OWN = "time_entries.view_own"
TIME_ENTRIES_MANAGE_OWN = "time_entries.manage_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 = { WORKSPACE_ROLE_CAPABILITIES = {
WorkspaceMembership.Role.OWNER: { WorkspaceMembership.Role.OWNER: {
WORKSPACE_VIEW, WORKSPACE_VIEW,
@@ -64,10 +50,6 @@ WORKSPACE_ROLE_CAPABILITIES = {
PROJECTS_EDIT, PROJECTS_EDIT,
PROJECTS_DELETE, PROJECTS_DELETE,
PROJECTS_ARCHIVE, PROJECTS_ARCHIVE,
PROJECT_MEMBERS_VIEW,
PROJECT_MEMBERS_ADD,
PROJECT_MEMBERS_REMOVE,
PROJECT_MEMBERS_CHANGE_ROLE,
TIME_ENTRIES_VIEW_OWN, TIME_ENTRIES_VIEW_OWN,
TIME_ENTRIES_MANAGE_OWN, TIME_ENTRIES_MANAGE_OWN,
}, },
@@ -92,10 +74,6 @@ WORKSPACE_ROLE_CAPABILITIES = {
PROJECTS_EDIT, PROJECTS_EDIT,
PROJECTS_DELETE, PROJECTS_DELETE,
PROJECTS_ARCHIVE, PROJECTS_ARCHIVE,
PROJECT_MEMBERS_VIEW,
PROJECT_MEMBERS_ADD,
PROJECT_MEMBERS_REMOVE,
PROJECT_MEMBERS_CHANGE_ROLE,
TIME_ENTRIES_VIEW_OWN, TIME_ENTRIES_VIEW_OWN,
TIME_ENTRIES_MANAGE_OWN, TIME_ENTRIES_MANAGE_OWN,
}, },
@@ -149,24 +127,7 @@ def has_workspace_capability(user, workspace: Workspace, capability: str) -> boo
def has_project_capability(user, project, capability: str) -> bool: def has_project_capability(user, project, capability: str) -> bool:
if has_workspace_capability(user, project.workspace, capability): return 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_delete_workspace_object(user, obj, capability: str) -> bool: def can_delete_workspace_object(user, obj, capability: str) -> bool:

View File

@@ -5,7 +5,7 @@ from django.utils import timezone
from rest_framework.test import APIClient from rest_framework.test import APIClient
from apps.clients.models import Client from apps.clients.models import Client
from apps.projects.models import Project, ProjectMembership from apps.projects.models import Project
from apps.tags.models import Tag from apps.tags.models import Tag
from apps.users.models import User from apps.users.models import User
from apps.workspaces.models import Workspace, WorkspaceMembership from apps.workspaces.models import Workspace, WorkspaceMembership
@@ -75,20 +75,7 @@ def workspace(owner, admin, member, guest):
@pytest.fixture() @pytest.fixture()
def project(workspace, owner, member): def project(workspace, owner, member):
project = Project.objects.create(workspace=workspace, name="Alpha", description="") return 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): def test_member_is_read_only_for_clients_and_projects(api_client, member, workspace, project):
@@ -118,16 +105,6 @@ def test_member_is_read_only_for_clients_and_projects(api_client, member, worksp
) )
archive_project_response = api_client.post(f"/api/projects/{project.id}/archive/") archive_project_response = api_client.post(f"/api/projects/{project.id}/archive/")
delete_project_response = api_client.delete(f"/api/projects/{project.id}/") 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 client_response.status_code == 403
assert update_client_response.status_code == 403 assert update_client_response.status_code == 403
assert delete_client_response.status_code == 403 assert delete_client_response.status_code == 403
@@ -135,7 +112,6 @@ def test_member_is_read_only_for_clients_and_projects(api_client, member, worksp
assert update_project_response.status_code == 403 assert update_project_response.status_code == 403
assert archive_project_response.status_code == 403 assert archive_project_response.status_code == 403
assert delete_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): def test_member_can_create_tags_and_manage_own_time_entries(api_client, owner, member, workspace):
@@ -218,7 +194,7 @@ def test_guest_is_read_only_for_workspace_resources(api_client, owner, guest, wo
assert edit_project_response.status_code == 403 assert edit_project_response.status_code == 403
def test_member_project_manager_cannot_edit_project(api_client, member, project): def test_member_cannot_edit_project(api_client, member, project):
api_client.force_authenticate(user=member) api_client.force_authenticate(user=member)
response = api_client.patch( response = api_client.patch(

View File

@@ -3,7 +3,7 @@ from decimal import Decimal
import pytest import pytest
from rest_framework.test import APIClient from rest_framework.test import APIClient
from apps.projects.models import Project, ProjectMembership from apps.projects.models import Project
from apps.time_entries.services.rates import resolve_rate from apps.time_entries.services.rates import resolve_rate
from apps.users.models import User from apps.users.models import User
from apps.workspaces.models import PriceUnit, Workspace, WorkspaceMembership, WorkspaceUserRate from apps.workspaces.models import PriceUnit, Workspace, WorkspaceMembership, WorkspaceUserRate
@@ -39,11 +39,7 @@ def workspace(owner, admin, member):
@pytest.fixture() @pytest.fixture()
def project(workspace, owner, admin, member): def project(workspace, owner, admin, member):
project = Project.objects.create(workspace=workspace, name="Billing") return Project.objects.create(workspace=workspace, name="Billing")
ProjectMembership.objects.create(project=project, user=owner, role=ProjectMembership.Role.MANAGER, is_active=True)
ProjectMembership.objects.create(project=project, user=admin, role=ProjectMembership.Role.MANAGER, is_active=True)
ProjectMembership.objects.create(project=project, user=member, role=ProjectMembership.Role.MEMBER, is_active=True)
return project
@pytest.fixture() @pytest.fixture()

View File

@@ -40,12 +40,6 @@ AUDITLOG_INCLUDE_TRACKING_MODELS = [
"serialize_data": True, "serialize_data": True,
"serialize_auditlog_fields_only": True, "serialize_auditlog_fields_only": True,
}, },
{
"model": "projects.ProjectMembership",
"exclude_fields": COMMON_EXCLUDED_FIELDS,
"serialize_data": True,
"serialize_auditlog_fields_only": True,
},
{ {
"model": "tags.Tag", "model": "tags.Tag",
"exclude_fields": COMMON_EXCLUDED_FIELDS, "exclude_fields": COMMON_EXCLUDED_FIELDS,