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_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.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, 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, ) class ProjectViewSet(ModelViewSet): """ API endpoints for managing projects. """ pagination_class = CustomLimitOffsetPagination filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] filterset_fields = ["workspace", "client", "is_archived"] search_fields = ["name", "description"] 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. """ if self.action in ["update", "partial_update", "destroy", "archive"]: permission_classes = [IsAuthenticated, IsProjectManager] elif self.action in ["retrieve"]: permission_classes = [IsAuthenticated, IsProjectMember] else: permission_classes = [IsAuthenticated] return [permission() for permission in permission_classes] def get_queryset(self): """ Returns active projects 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() return Project.objects.filter( workspace__memberships__user=self.request.user, workspace__memberships__is_active=True, is_deleted=False ).distinct() def get_serializer_class(self): """ Selects the appropriate serializer based on the request action. """ if self.action == "create": return ProjectCreateSerializer elif self.action in ["update", "partial_update"]: return ProjectUpdateSerializer 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", []) 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( {"detail": "You do not have permission to create projects in this workspace."}, status=status.HTTP_403_FORBIDDEN, ) client_id = serializer.validated_data.get("client") client = get_object_or_404(Client, id=client_id, is_deleted=False) if client_id else None project = create_project( user=request.user, 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) def update(self, request, *args, **kwargs): """ 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) def destroy(self, request, *args, **kwargs): """ Soft deletes a project. """ project = self.get_object() if not can_delete_workspace_object(request.user, project, PROJECTS_DELETE): return Response( {"detail": "You do not have permission to delete this project."}, status=status.HTTP_403_FORBIDDEN, ) project.is_deleted = True project.save(update_fields=["is_deleted", "updated_at"]) return Response(status=status.HTTP_204_NO_CONTENT) @action(detail=True, methods=["post"]) def archive(self, request, pk=None): """ 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)