From 1cd948592c3fa307e8ce14a743b47384dc49ceec Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Tue, 28 Apr 2026 19:35:24 +0330 Subject: [PATCH] refactor(projects): remove project membership access model --- apps/logs/services/constants.py | 4 - apps/notifications/services/__init__.py | 8 - .../services/membership_events.py | 125 ------- .../tests/test_membership_events.py | 138 -------- apps/projects/admin.py | 51 +-- apps/projects/api/permissions.py | 35 +- apps/projects/api/serializers.py | 123 ++----- apps/projects/api/urls.py | 12 +- apps/projects/api/views.py | 315 +++--------------- .../0002_remove_projectmembership.py | 13 + apps/projects/models.py | 60 +--- apps/projects/services/memberships.py | 32 -- apps/projects/services/projects.py | 32 +- apps/workspaces/api/serializers.py | 8 +- apps/workspaces/api/views.py | 6 +- apps/workspaces/services/__init__.py | 8 - apps/workspaces/services/permissions.py | 41 +-- apps/workspaces/tests/test_capabilities.py | 30 +- apps/workspaces/tests/test_rates.py | 8 +- config/services/auditlog.py | 6 - 20 files changed, 150 insertions(+), 905 deletions(-) create mode 100644 apps/projects/migrations/0002_remove_projectmembership.py delete mode 100644 apps/projects/services/memberships.py diff --git a/apps/logs/services/constants.py b/apps/logs/services/constants.py index a55725f..cb44942 100644 --- a/apps/logs/services/constants.py +++ b/apps/logs/services/constants.py @@ -4,7 +4,6 @@ SECTION_WORKSPACE = "workspace" SECTION_WORKSPACE_MEMBERS = "workspace_members" SECTION_CLIENTS = "clients" SECTION_PROJECTS = "projects" -SECTION_PROJECT_MEMBERS = "project_members" SECTION_TAGS = "tags" SECTION_TIME_ENTRIES = "time_entries" SECTION_RATES = "rates" @@ -15,7 +14,6 @@ LOG_SECTIONS = ( SECTION_WORKSPACE_MEMBERS, SECTION_CLIENTS, SECTION_PROJECTS, - SECTION_PROJECT_MEMBERS, SECTION_TAGS, SECTION_TIME_ENTRIES, SECTION_RATES, @@ -48,11 +46,9 @@ SECTION_BY_MODEL_LABEL = { "workspaces.workspaceuserrate": SECTION_RATES, "clients.client": SECTION_CLIENTS, "projects.project": SECTION_PROJECTS, - "projects.projectmembership": SECTION_PROJECT_MEMBERS, "tags.tag": SECTION_TAGS, "time_entries.timeentry": SECTION_TIME_ENTRIES, "reports.reportexportjob": SECTION_REPORT_EXPORTS, } TRACKED_MODEL_LABELS = tuple(SECTION_BY_MODEL_LABEL.keys()) - diff --git a/apps/notifications/services/__init__.py b/apps/notifications/services/__init__.py index 61f7750..7bb094f 100644 --- a/apps/notifications/services/__init__.py +++ b/apps/notifications/services/__init__.py @@ -1,8 +1,4 @@ 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_deactivated, notify_workspace_membership_removed, @@ -16,8 +12,4 @@ __all__ = [ "notify_workspace_membership_role_changed", "notify_workspace_membership_deactivated", "notify_workspace_membership_removed", - "notify_project_membership_added", - "notify_project_membership_role_changed", - "notify_project_membership_deactivated", - "notify_project_membership_removed", ] diff --git a/apps/notifications/services/membership_events.py b/apps/notifications/services/membership_events.py index a689762..c8a24d2 100644 --- a/apps/notifications/services/membership_events.py +++ b/apps/notifications/services/membership_events.py @@ -31,10 +31,6 @@ def _workspace_action_url(workspace) -> str: return f"/workspaces/{workspace.id}" -def _project_action_url(project) -> str: - return "/projects" - - def notify_workspace_membership_added(*, actor, recipient, workspace, role: str) -> None: if _should_skip(actor, recipient): 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, - }, - }, - ) diff --git a/apps/notifications/tests/test_membership_events.py b/apps/notifications/tests/test_membership_events.py index 05300bb..02c82de 100644 --- a/apps/notifications/tests/test_membership_events.py +++ b/apps/notifications/tests/test_membership_events.py @@ -4,7 +4,6 @@ from rest_framework.test import APIClient from apps.notifications.services import store as services from apps.notifications.services import RedisNotificationStore from apps.notifications.tests.test_services import FakeRedis -from apps.projects.models import Project, ProjectMembership from apps.users.models import User 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 _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", - ] diff --git a/apps/projects/admin.py b/apps/projects/admin.py index 0880459..dff8cff 100644 --- a/apps/projects/admin.py +++ b/apps/projects/admin.py @@ -1,13 +1,7 @@ -from django.contrib import admin - -from core.admins.base import BaseAdmin -from apps.projects.models import Project, ProjectMembership - - -class ProjectMembershipInline(admin.TabularInline): - model = ProjectMembership - extra = 0 - autocomplete_fields = ("user",) +from django.contrib import admin + +from core.admins.base import BaseAdmin +from apps.projects.models import Project @admin.register(Project) @@ -33,36 +27,7 @@ class ProjectAdmin(BaseAdmin): "client__name", ) - autocomplete_fields = ( - "workspace", - "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", - ) + autocomplete_fields = ( + "workspace", + "client", + ) diff --git a/apps/projects/api/permissions.py b/apps/projects/api/permissions.py index c99bb1f..f117067 100644 --- a/apps/projects/api/permissions.py +++ b/apps/projects/api/permissions.py @@ -1,11 +1,9 @@ from rest_framework import permissions -from apps.projects.models import ProjectMembership from apps.workspaces.services import ( PROJECTS_EDIT, PROJECTS_VIEW, - PROJECT_MEMBERS_CHANGE_ROLE, - has_project_capability, + has_workspace_capability, ) @@ -17,9 +15,9 @@ def get_project_from_obj(obj): 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 = "شما عضو این پروژه نیستید." def has_object_permission(self, request, view, obj): @@ -27,13 +25,13 @@ class IsProjectMember(permissions.BasePermission): return False 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): - """ - 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 = "فقط مدیران پروژه مجاز به انجام این عملیات هستند." def has_object_permission(self, request, view, obj): @@ -41,19 +39,4 @@ class IsProjectManager(permissions.BasePermission): return False project = get_project_from_obj(obj) - return has_project_capability(request.user, project, 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, - ) + return has_workspace_capability(request.user, project.workspace, PROJECTS_EDIT) diff --git a/apps/projects/api/serializers.py b/apps/projects/api/serializers.py index 3b4e6a7..0583bdb 100644 --- a/apps/projects/api/serializers.py +++ b/apps/projects/api/serializers.py @@ -1,97 +1,42 @@ from rest_framework import serializers from core.serializers.base import BaseModelSerializer -from apps.projects.models import ( - Project, - ProjectMembership, -) -from core.serializers.mini import UserMiniSerializer - - -class ProjectMemberInputSerializer(serializers.Serializer): - user_id = serializers.UUIDField() - role = serializers.ChoiceField(choices=ProjectMembership.Role.choices, default=ProjectMembership.Role.MEMBER) - - -class ProjectSerializer(BaseModelSerializer): - my_role = serializers.SerializerMethodField() - members = serializers.SerializerMethodField() - - class Meta: - model = Project - fields = BaseModelSerializer.Meta.fields + ( - "workspace", - "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'] = { +from apps.projects.models import Project + + +class ProjectSerializer(BaseModelSerializer): + class Meta: + model = Project + fields = BaseModelSerializer.Meta.fields + ( + "workspace", + "name", + "client", + "description", + "is_archived", + "color", + ) + read_only_fields = fields + + def to_representation(self, instance): + representation = super().to_representation(instance) + if instance.client: + representation['client'] = { 'id': instance.client.id, 'name': instance.client.name } return representation -class ProjectCreateSerializer(serializers.Serializer): - workspace = serializers.UUIDField() - name = serializers.CharField(max_length=255) - client = serializers.UUIDField(required=False, allow_null=True) - description = serializers.CharField(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): - name = serializers.CharField(max_length=255, required=False) - client = serializers.UUIDField(required=False, allow_null=True) - description = serializers.CharField(required=False, allow_blank=True) - color = serializers.CharField(max_length=7, required=False, allow_blank=True) - 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) +class ProjectCreateSerializer(serializers.Serializer): + workspace = serializers.UUIDField() + name = serializers.CharField(max_length=255) + client = serializers.UUIDField(required=False, allow_null=True) + description = serializers.CharField(required=False, allow_blank=True, default="") + color = serializers.CharField(max_length=7, required=False, allow_blank=True, default="") + + +class ProjectUpdateSerializer(serializers.Serializer): + name = serializers.CharField(max_length=255, required=False) + client = serializers.UUIDField(required=False, allow_null=True) + description = serializers.CharField(required=False, allow_blank=True) + color = serializers.CharField(max_length=7, required=False, allow_blank=True) + is_archived = serializers.BooleanField(required=False) diff --git a/apps/projects/api/urls.py b/apps/projects/api/urls.py index 05a8cc2..8fa3804 100644 --- a/apps/projects/api/urls.py +++ b/apps/projects/api/urls.py @@ -1,16 +1,12 @@ -from django.urls import path, include -from rest_framework.routers import DefaultRouter - -from apps.projects.api.views import ( - ProjectViewSet, - ProjectMembershipViewSet, -) +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +from apps.projects.api.views import ProjectViewSet app_name = "projects" router = DefaultRouter() router.register(r"projects", ProjectViewSet, basename="project") -router.register(r"memberships", ProjectMembershipViewSet, basename="membership") urlpatterns = [ path("", include(router.urls)), diff --git a/apps/projects/api/views.py b/apps/projects/api/views.py index 9ecd8e3..56a1189 100644 --- a/apps/projects/api/views.py +++ b/apps/projects/api/views.py @@ -1,49 +1,33 @@ -from django.shortcuts import get_object_or_404 - -from rest_framework import status -from rest_framework.viewsets import ModelViewSet -from rest_framework.response import Response -from rest_framework.exceptions import PermissionDenied -from rest_framework.permissions import IsAuthenticated -from rest_framework.decorators import action -from rest_framework.filters import SearchFilter, OrderingFilter +from django.shortcuts import get_object_or_404 + +from rest_framework import status +from rest_framework.viewsets import ModelViewSet +from rest_framework.response import Response +from rest_framework.permissions import IsAuthenticated +from rest_framework.decorators import action +from rest_framework.filters import SearchFilter, OrderingFilter from django_filters.rest_framework import DjangoFilterBackend 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.clients.models import Client -from apps.projects.models import ( - Project, - ProjectMembership, -) +from apps.projects.models import Project from apps.projects.api.serializers import ( ProjectSerializer, ProjectCreateSerializer, ProjectUpdateSerializer, - ProjectMembershipSerializer, ProjectMembershipCreateSerializer, ProjectMembershipUpdateSerializer, ) from apps.projects.api.permissions import IsProjectMember, IsProjectManager from apps.projects.services.projects import ( - create_project, - update_project, + create_project, + update_project, toggle_project_archive ) -from apps.projects.services.memberships import add_project_member, update_project_member from apps.workspaces.services import ( PROJECTS_ARCHIVE, PROJECTS_CREATE, PROJECTS_DELETE, PROJECTS_EDIT, - PROJECT_MEMBERS_ADD, - PROJECT_MEMBERS_CHANGE_ROLE, - PROJECT_MEMBERS_REMOVE, can_delete_workspace_object, - has_project_capability, has_workspace_capability, ) @@ -60,13 +44,13 @@ class ProjectViewSet(ModelViewSet): ordering_fields = ["name", "created_at", "updated_at"] ordering = ["-updated_at", "-created_at"] - def get_permissions(self): - """ - Instantiates and returns the list of permissions that this view requires. - - Managers can update, delete, or archive. - - Members can retrieve/view. - - Any authenticated user can list (filtered to their memberships) or attempt to create. - """ + def get_permissions(self): + """ + Instantiates and returns the list of permissions that this view requires. + - Workspace-authorized users can update, delete, or archive. + - Workspace members can retrieve/view. + - Any authenticated user can list their workspace projects or attempt to create. + """ if self.action in ["update", "partial_update", "destroy", "archive"]: permission_classes = [IsAuthenticated, IsProjectManager] elif self.action in ["retrieve"]: @@ -76,10 +60,10 @@ class ProjectViewSet(ModelViewSet): return [permission() for permission in permission_classes] - def get_queryset(self): - """ - Returns active projects where the current user is an active member. - """ + def get_queryset(self): + """ + 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: return Project.objects.none() @@ -100,14 +84,12 @@ class ProjectViewSet(ModelViewSet): return ProjectSerializer def create(self, request, *args, **kwargs): - """ - Creates a new project using the project service layer. - """ - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - - members_data = serializer.validated_data.pop("members", []) - + """ + Creates a new project using the project service layer. + """ + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + workspace = get_object_or_404(Workspace, id=serializer.validated_data["workspace"], is_deleted=False) if not has_workspace_capability(request.user, workspace, PROJECTS_CREATE): return Response( @@ -122,101 +104,30 @@ class ProjectViewSet(ModelViewSet): workspace=workspace, name=serializer.validated_data["name"], client=client, - description=serializer.validated_data.get("description", ""), - color=serializer.validated_data.get("color", "") - ) - - for member in members_data: - membership = add_project_member( - 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) + description=serializer.validated_data.get("description", ""), + color=serializer.validated_data.get("color", "") + ) + + output_serializer = ProjectSerializer(project) + return Response(output_serializer.data, status=status.HTTP_201_CREATED) def update(self, request, *args, **kwargs): - """ - Updates an existing project using the project service layer. - """ - partial = kwargs.pop("partial", False) - project = self.get_object() + """ + Updates an existing project using the project service layer. + """ + partial = kwargs.pop("partial", False) + project = self.get_object() - serializer = self.get_serializer(data=request.data, partial=partial) - serializer.is_valid(raise_exception=True) - - members_data = serializer.validated_data.pop("members", None) - - updated_project = update_project( - project=project, - **serializer.validated_data - ) - - # 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) + serializer = self.get_serializer(data=request.data, partial=partial) + serializer.is_valid(raise_exception=True) + + updated_project = update_project( + project=project, + **serializer.validated_data + ) + + output_serializer = ProjectSerializer(updated_project) + return Response(output_serializer.data, status=status.HTTP_200_OK) def destroy(self, request, *args, **kwargs): """ @@ -238,127 +149,7 @@ class ProjectViewSet(ModelViewSet): Custom endpoint to toggle the archive status of a project. """ project = self.get_object() - updated_project = toggle_project_archive(project) - - output_serializer = ProjectSerializer(updated_project) - 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) + updated_project = toggle_project_archive(project) + + output_serializer = ProjectSerializer(updated_project) + return Response(output_serializer.data, status=status.HTTP_200_OK) diff --git a/apps/projects/migrations/0002_remove_projectmembership.py b/apps/projects/migrations/0002_remove_projectmembership.py new file mode 100644 index 0000000..8d76fae --- /dev/null +++ b/apps/projects/migrations/0002_remove_projectmembership.py @@ -0,0 +1,13 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("projects", "0001_initial"), + ] + + operations = [ + migrations.DeleteModel( + name="ProjectMembership", + ), + ] diff --git a/apps/projects/models.py b/apps/projects/models.py index 231a0f4..c796310 100644 --- a/apps/projects/models.py +++ b/apps/projects/models.py @@ -2,7 +2,7 @@ from django.contrib.auth import get_user_model from django.db import models 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 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): project = models.ForeignKey( Project, diff --git a/apps/projects/services/memberships.py b/apps/projects/services/memberships.py deleted file mode 100644 index d60f647..0000000 --- a/apps/projects/services/memberships.py +++ /dev/null @@ -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 diff --git a/apps/projects/services/projects.py b/apps/projects/services/projects.py index 40cec49..eaaaedd 100644 --- a/apps/projects/services/projects.py +++ b/apps/projects/services/projects.py @@ -1,18 +1,17 @@ -from django.db import transaction -from django.shortcuts import get_object_or_404 -from rest_framework.exceptions import ValidationError, PermissionDenied - -from apps.clients.models import Client -from apps.projects.models import Project, ProjectMembership -from apps.workspaces.models import WorkspaceMembership +from django.db import transaction +from django.shortcuts import get_object_or_404 +from rest_framework.exceptions import ValidationError, PermissionDenied + +from apps.clients.models import Client +from apps.projects.models import Project +from apps.workspaces.models import WorkspaceMembership @transaction.atomic -def create_project(user, workspace, name, client=None, description="", color=""): - """ - Creates a new project and automatically assigns the creator - as an active MANAGER of that project. - """ +def create_project(user, workspace, name, client=None, description="", color=""): + """ + Creates a new workspace-shared project. + """ workspace_member = WorkspaceMembership.objects.filter( workspace=workspace, user=user, @@ -36,14 +35,7 @@ def create_project(user, workspace, name, client=None, description="", color="") updated_by=user, ) - ProjectMembership.objects.create( - project=project, - user=user, - role=ProjectMembership.Role.MANAGER, - is_active=True - ) - - return project + return project def update_project(project, **kwargs): diff --git a/apps/workspaces/api/serializers.py b/apps/workspaces/api/serializers.py index dc8c470..747b9c4 100644 --- a/apps/workspaces/api/serializers.py +++ b/apps/workspaces/api/serializers.py @@ -132,15 +132,17 @@ class WorkspaceSerializer(BaseModelSerializer): class WorkspaceMembershipSerializer(BaseModelSerializer): user = serializers.SerializerMethodField() + user_id = serializers.UUIDField(write_only=True, required=False) class Meta: model = WorkspaceMembership fields = BaseModelSerializer.Meta.fields + ( "workspace", "user", - "role", - "is_active", - ) + "user_id", + "role", + "is_active", + ) def get_user(self, instance): request = self.context.get("request") diff --git a/apps/workspaces/api/views.py b/apps/workspaces/api/views.py index fc9b79a..bad2194 100644 --- a/apps/workspaces/api/views.py +++ b/apps/workspaces/api/views.py @@ -164,7 +164,11 @@ class WorkspaceMembershipViewSet(ModelViewSet): 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) membership = serializer.save() notify_workspace_membership_added( diff --git a/apps/workspaces/services/__init__.py b/apps/workspaces/services/__init__.py index d6a0999..9dfcc8c 100644 --- a/apps/workspaces/services/__init__.py +++ b/apps/workspaces/services/__init__.py @@ -8,10 +8,6 @@ from apps.workspaces.services.permissions import ( 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, @@ -62,10 +58,6 @@ __all__ = [ "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", diff --git a/apps/workspaces/services/permissions.py b/apps/workspaces/services/permissions.py index 2a4ab33..1327ee6 100644 --- a/apps/workspaces/services/permissions.py +++ b/apps/workspaces/services/permissions.py @@ -1,6 +1,5 @@ from __future__ import annotations -from apps.projects.models import ProjectMembership from apps.workspaces.models import Workspace, WorkspaceMembership @@ -25,22 +24,9 @@ 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, @@ -64,10 +50,6 @@ WORKSPACE_ROLE_CAPABILITIES = { 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, }, @@ -92,10 +74,6 @@ WORKSPACE_ROLE_CAPABILITIES = { 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, }, @@ -149,24 +127,7 @@ def has_workspace_capability(user, workspace: Workspace, capability: str) -> boo 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 + return has_workspace_capability(user, project.workspace, capability) def can_delete_workspace_object(user, obj, capability: str) -> bool: diff --git a/apps/workspaces/tests/test_capabilities.py b/apps/workspaces/tests/test_capabilities.py index bb6db83..2876d49 100644 --- a/apps/workspaces/tests/test_capabilities.py +++ b/apps/workspaces/tests/test_capabilities.py @@ -5,7 +5,7 @@ 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.projects.models import Project from apps.tags.models import Tag from apps.users.models import User from apps.workspaces.models import Workspace, WorkspaceMembership @@ -75,20 +75,7 @@ def workspace(owner, admin, member, guest): @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 + return Project.objects.create(workspace=workspace, name="Alpha", description="") 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/") 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 @@ -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 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): @@ -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 -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) response = api_client.patch( diff --git a/apps/workspaces/tests/test_rates.py b/apps/workspaces/tests/test_rates.py index e63e7c0..59dbfd7 100644 --- a/apps/workspaces/tests/test_rates.py +++ b/apps/workspaces/tests/test_rates.py @@ -3,7 +3,7 @@ from decimal import Decimal import pytest 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.users.models import User from apps.workspaces.models import PriceUnit, Workspace, WorkspaceMembership, WorkspaceUserRate @@ -39,11 +39,7 @@ def workspace(owner, admin, member): @pytest.fixture() def project(workspace, owner, admin, member): - project = 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 + return Project.objects.create(workspace=workspace, name="Billing") @pytest.fixture() diff --git a/config/services/auditlog.py b/config/services/auditlog.py index daa60b6..1421e1f 100644 --- a/config/services/auditlog.py +++ b/config/services/auditlog.py @@ -40,12 +40,6 @@ AUDITLOG_INCLUDE_TRACKING_MODELS = [ "serialize_data": 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", "exclude_fields": COMMON_EXCLUDED_FIELDS,