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.workspaces.models import Workspace from apps.clients.models import Client from apps.projects.models import ( Project, ProjectMembership, ProjectRate, ProjectUserRate, ) from apps.projects.api.serializers import ( ProjectSerializer, ProjectCreateSerializer, ProjectUpdateSerializer, ProjectMembershipSerializer, ProjectMembershipCreateSerializer, ProjectMembershipUpdateSerializer, ProjectRateSerializer, ProjectRateCreateSerializer, ProjectRateUpdateSerializer, ProjectUserRateSerializer, ProjectUserRateCreateSerializer, ProjectUserRateUpdateSerializer ) 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.projects.services.rates import ( create_project_rate, update_project_rate, create_project_user_rate, update_project_user_rate ) 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( memberships__user=self.request.user, 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) workspace = get_object_or_404(Workspace, id=serializer.validated_data["workspace"], is_deleted=False) client = get_object_or_404(Client, id=serializer.validated_data.get("client"), is_deleted=False) 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", "") ) 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) 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): """ Soft deletes a project. """ project = self.get_object() 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.""" is_manager = ProjectMembership.objects.filter( project_id=project_id, user=self.request.user, role=ProjectMembership.Role.MANAGER, is_active=True, is_deleted=False ).exists() if not is_manager: 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"] ) return Response(ProjectMembershipSerializer(membership).data, status=status.HTTP_201_CREATED) def update(self, request, *args, **kwargs): membership = self.get_object() serializer = self.get_serializer(data=request.data, partial=kwargs.pop("partial", False)) serializer.is_valid(raise_exception=True) updated_membership = update_project_member(membership, **serializer.validated_data) return Response(ProjectMembershipSerializer(updated_membership).data, status=status.HTTP_200_OK) def destroy(self, request, *args, **kwargs): membership = self.get_object() membership.is_deleted = True membership.save(update_fields=["is_deleted", "updated_at"]) return Response(status=status.HTTP_204_NO_CONTENT) class ProjectRateViewSet(BaseProjectNestedViewSet): filterset_fields = ["project", "currency"] def get_queryset(self): if not self.request.user.is_authenticated: return ProjectRate.objects.none() return ProjectRate.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 ProjectRateCreateSerializer if self.action in ["update", "partial_update"]: return ProjectRateUpdateSerializer return ProjectRateSerializer 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) rate = create_project_rate( project=project, amount=serializer.validated_data["amount"], currency=serializer.validated_data.get("currency", "USD") ) return Response(ProjectRateSerializer(rate).data, status=status.HTTP_201_CREATED) def update(self, request, *args, **kwargs): rate = self.get_object() serializer = self.get_serializer(data=request.data, partial=kwargs.pop("partial", False)) serializer.is_valid(raise_exception=True) updated_rate = update_project_rate(rate, **serializer.validated_data) return Response(ProjectRateSerializer(updated_rate).data, status=status.HTTP_200_OK) def destroy(self, request, *args, **kwargs): rate = self.get_object() rate.is_deleted = True rate.save(update_fields=["is_deleted", "updated_at"]) return Response(status=status.HTTP_204_NO_CONTENT) class ProjectUserRateViewSet(BaseProjectNestedViewSet): filterset_fields = ["project", "user", "currency"] def get_queryset(self): if not self.request.user.is_authenticated: return ProjectUserRate.objects.none() return ProjectUserRate.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 ProjectUserRateCreateSerializer if self.action in ["update", "partial_update"]: return ProjectUserRateUpdateSerializer return ProjectUserRateSerializer 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) user_rate = create_project_user_rate( project=project, user_id=serializer.validated_data["user_id"], amount=serializer.validated_data["amount"], currency=serializer.validated_data.get("currency", "USD") ) return Response(ProjectUserRateSerializer(user_rate).data, status=status.HTTP_201_CREATED) def update(self, request, *args, **kwargs): user_rate = self.get_object() serializer = self.get_serializer(data=request.data, partial=kwargs.pop("partial", False)) serializer.is_valid(raise_exception=True) updated_user_rate = update_project_user_rate(user_rate, **serializer.validated_data) return Response(ProjectUserRateSerializer(updated_user_rate).data, status=status.HTTP_200_OK) def destroy(self, request, *args, **kwargs): user_rate = self.get_object() user_rate.is_deleted = True user_rate.save(update_fields=["is_deleted", "updated_at"]) return Response(status=status.HTTP_204_NO_CONTENT)