import logging from django.db.models import Q from django.shortcuts import get_object_or_404 from django.utils.translation import gettext_lazy as _ from rest_framework import status from rest_framework.generics import GenericAPIView from rest_framework.parsers import MultiPartParser, FormParser, JSONParser from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiResponse from apps.projects.models.project import ( Project, KanbanColumn, KanbanTask, ProjectDocument, ) from apps.projects.permissions import IsProjectParticipantOrReadOnly, CanCreateProject from apps.projects.serializers import ( ProjectListSerializer, ProjectDetailSerializer, ProjectCreateUpdateSerializer, KanbanColumnSerializer, KanbanTaskSerializer, KanbanTaskCreateUpdateSerializer, ProjectDocumentSerializer, ) from utils.pagination import StandardResultsSetPagination logger = logging.getLogger(__name__) def get_project_by_id_or_slug(lookup_val): if str(lookup_val).isdigit(): return get_object_or_404(Project, id=int(lookup_val)) return get_object_or_404(Project, slug=lookup_val) class ProjectListCreateView(GenericAPIView): serializer_class = ProjectListSerializer pagination_class = StandardResultsSetPagination queryset = Project.objects.all().select_related('owner_institution').prefetch_related('collaborating_institutions', 'tasks') def get_permissions(self): if self.request.method == 'POST': return [IsAuthenticated(), CanCreateProject()] return [AllowAny()] def get_queryset(self): if getattr(self, 'swagger_fake_view', False): return Project.objects.none() qs = Project.objects.all().select_related('owner_institution').prefetch_related('collaborating_institutions', 'tasks') # Filters status_param = self.request.query_params.get('status') if status_param: qs = qs.filter(status=status_param) category = self.request.query_params.get('category') if category: qs = qs.filter(category__iexact=category) institution_id = self.request.query_params.get('institution_id') if institution_id: qs = qs.filter( Q(owner_institution_id=institution_id) | Q(collaborating_institutions__id=institution_id) ).distinct() owner_id = self.request.query_params.get('owner_institution_id') if owner_id: qs = qs.filter(owner_institution_id=owner_id) search = self.request.query_params.get('search') if search: qs = qs.filter( Q(title__icontains=search) | Q(description__icontains=search) | Q(category__icontains=search) ) return qs.order_by('-created_at') @extend_schema( summary="List collaborative projects", description="Returns a paginated list of collaborative projects with filtering by institution, category, and status.", parameters=[ OpenApiParameter('status', str, description='Filter by project status (active, completed, on_hold, planning)'), OpenApiParameter('category', str, description='Filter by category name'), OpenApiParameter('institution_id', int, description='Filter by owner or collaborating institution ID'), OpenApiParameter('owner_institution_id', int, description='Filter specifically by owner institution ID'), OpenApiParameter('search', str, description='Search query across title and description'), ], responses={200: ProjectListSerializer(many=True)}, tags=["Collaborative Projects & Kanban"], ) def get(self, request, *args, **kwargs): queryset = self.filter_queryset(self.get_queryset()) page = self.paginate_queryset(queryset) if page is not None: serializer = self.get_serializer(page, many=True, context={'request': request}) return self.get_paginated_response(serializer.data) serializer = self.get_serializer(queryset, many=True, context={'request': request}) return Response(serializer.data, status=status.HTTP_200_OK) @extend_schema( summary="Create collaborative project", description="Creates a new multi-institution collaborative project with default Kanban columns.", request=ProjectCreateUpdateSerializer, responses={ 201: ProjectDetailSerializer, 400: OpenApiResponse(description="Validation error"), 401: OpenApiResponse(description="Authentication required"), }, tags=["Collaborative Projects & Kanban"], ) def post(self, request, *args, **kwargs): serializer = ProjectCreateUpdateSerializer(data=request.data, context={'request': request}) serializer.is_valid(raise_exception=True) project = serializer.save() response_serializer = ProjectDetailSerializer(project, context={'request': request}) return Response(response_serializer.data, status=status.HTTP_201_CREATED) class ProjectDetailView(GenericAPIView): serializer_class = ProjectDetailSerializer queryset = Project.objects.all() def get_permissions(self): if self.request.method in ['PATCH', 'PUT', 'DELETE']: return [IsAuthenticated(), IsProjectParticipantOrReadOnly()] return [AllowAny()] def get_object(self): lookup = self.kwargs.get('pk_or_slug') project = get_project_by_id_or_slug(lookup) self.check_object_permissions(self.request, project) return project @extend_schema( summary="Get project details", description="Retrieves full project details, collaborating institutions, and Kanban boards.", responses={ 200: ProjectDetailSerializer, 404: OpenApiResponse(description="Project not found"), }, tags=["Collaborative Projects & Kanban"], ) def get(self, request, pk_or_slug, *args, **kwargs): project = self.get_object() serializer = ProjectDetailSerializer(project, context={'request': request}) return Response(serializer.data, status=status.HTTP_200_OK) @extend_schema( summary="Update project", description="Updates project status, progress percentage, or details. Permitted for owner and collaborating editors.", request=ProjectCreateUpdateSerializer, responses={ 200: ProjectDetailSerializer, 403: OpenApiResponse(description="Permission denied"), }, tags=["Collaborative Projects & Kanban"], ) def patch(self, request, pk_or_slug, *args, **kwargs): project = self.get_object() serializer = ProjectCreateUpdateSerializer( project, data=request.data, partial=True, context={'request': request} ) serializer.is_valid(raise_exception=True) serializer.save() response_serializer = ProjectDetailSerializer(project, context={'request': request}) return Response(response_serializer.data, status=status.HTTP_200_OK) @extend_schema( summary="Delete project", description="Removes a collaborative project and associated Kanban boards.", responses={ 204: OpenApiResponse(description="Project deleted successfully"), 403: OpenApiResponse(description="Permission denied"), }, tags=["Collaborative Projects & Kanban"], ) def delete(self, request, pk_or_slug, *args, **kwargs): project = self.get_object() project.delete() return Response(status=status.HTTP_204_NO_CONTENT) class ProjectKanbanBoardView(GenericAPIView): serializer_class = KanbanColumnSerializer queryset = KanbanColumn.objects.all() def get_permissions(self): return [AllowAny()] @extend_schema( summary="Get project Kanban board hierarchy", description="Returns hierarchical Kanban columns with nested task lists for drag-and-drop board interfaces.", responses={200: KanbanColumnSerializer(many=True)}, tags=["Collaborative Projects & Kanban"], ) def get(self, request, pk_or_slug, *args, **kwargs): project = get_project_by_id_or_slug(pk_or_slug) columns = project.columns.all().order_by('order', 'id') serializer = KanbanColumnSerializer(columns, many=True, context={'request': request}) return Response(serializer.data, status=status.HTTP_200_OK) class KanbanTaskCreateView(GenericAPIView): serializer_class = KanbanTaskCreateUpdateSerializer permission_classes = [IsAuthenticated, IsProjectParticipantOrReadOnly] queryset = KanbanTask.objects.all() @extend_schema( summary="Create Kanban task", description="Adds a new task under a specific column of the project.", request=KanbanTaskCreateUpdateSerializer, responses={ 201: KanbanTaskSerializer, 400: OpenApiResponse(description="Validation error"), 403: OpenApiResponse(description="Permission denied"), }, tags=["Collaborative Projects & Kanban"], ) def post(self, request, pk_or_slug, *args, **kwargs): project = get_project_by_id_or_slug(pk_or_slug) self.check_object_permissions(request, project) serializer = KanbanTaskCreateUpdateSerializer( data=request.data, context={'project': project, 'request': request} ) serializer.is_valid(raise_exception=True) task = serializer.save(project=project) response_serializer = KanbanTaskSerializer(task, context={'request': request}) return Response(response_serializer.data, status=status.HTTP_201_CREATED) class KanbanTaskDetailView(GenericAPIView): serializer_class = KanbanTaskCreateUpdateSerializer permission_classes = [IsAuthenticated, IsProjectParticipantOrReadOnly] queryset = KanbanTask.objects.all() @extend_schema( summary="Update or move Kanban task", description="Updates task attributes, priority, assignee, or moves task to another column / reorders within column.", request=KanbanTaskCreateUpdateSerializer, responses={ 200: KanbanTaskSerializer, 400: OpenApiResponse(description="Validation error"), 403: OpenApiResponse(description="Permission denied"), }, tags=["Collaborative Projects & Kanban"], ) def patch(self, request, pk_or_slug, task_id, *args, **kwargs): project = get_project_by_id_or_slug(pk_or_slug) self.check_object_permissions(request, project) task = get_object_or_404(KanbanTask, id=task_id, project=project) serializer = KanbanTaskCreateUpdateSerializer( task, data=request.data, partial=True, context={'project': project, 'request': request} ) serializer.is_valid(raise_exception=True) serializer.save() response_serializer = KanbanTaskSerializer(task, context={'request': request}) return Response(response_serializer.data, status=status.HTTP_200_OK) @extend_schema( summary="Delete Kanban task", description="Removes a task from the project Kanban board.", responses={ 204: OpenApiResponse(description="Task deleted successfully"), 403: OpenApiResponse(description="Permission denied"), }, tags=["Collaborative Projects & Kanban"], ) def delete(self, request, pk_or_slug, task_id, *args, **kwargs): project = get_project_by_id_or_slug(pk_or_slug) self.check_object_permissions(request, project) task = get_object_or_404(KanbanTask, id=task_id, project=project) task.delete() return Response(status=status.HTTP_204_NO_CONTENT) class ProjectDocumentListView(GenericAPIView): serializer_class = ProjectDocumentSerializer parser_classes = [MultiPartParser, FormParser, JSONParser] queryset = ProjectDocument.objects.all() def get_permissions(self): if self.request.method == 'POST': return [IsAuthenticated(), IsProjectParticipantOrReadOnly()] return [AllowAny()] @extend_schema( summary="List project documents & MOUs", description="Returns uploaded agreements, contracts, and progress reports for a project.", responses={200: ProjectDocumentSerializer(many=True)}, tags=["Collaborative Projects & Kanban"], ) def get(self, request, pk_or_slug, *args, **kwargs): project = get_project_by_id_or_slug(pk_or_slug) docs = project.documents.all().order_by('-uploaded_at') serializer = ProjectDocumentSerializer(docs, many=True, context={'request': request}) return Response(serializer.data, status=status.HTTP_200_OK) @extend_schema( summary="Upload project document", description="Uploads an agreement, contract, or milestone report.", request=ProjectDocumentSerializer, responses={ 201: ProjectDocumentSerializer, 400: OpenApiResponse(description="Validation error"), 403: OpenApiResponse(description="Permission denied"), }, tags=["Collaborative Projects & Kanban"], ) def post(self, request, pk_or_slug, *args, **kwargs): project = get_project_by_id_or_slug(pk_or_slug) self.check_object_permissions(request, project) serializer = ProjectDocumentSerializer(data=request.data, context={'request': request}) serializer.is_valid(raise_exception=True) doc = serializer.save(project=project) return Response(ProjectDocumentSerializer(doc, context={'request': request}).data, status=status.HTTP_201_CREATED) class ProjectDocumentDetailView(GenericAPIView): serializer_class = ProjectDocumentSerializer permission_classes = [IsAuthenticated, IsProjectParticipantOrReadOnly] queryset = ProjectDocument.objects.all() @extend_schema( summary="Delete project document", description="Removes a document attachment from the project.", responses={ 204: OpenApiResponse(description="Document removed successfully"), 403: OpenApiResponse(description="Permission denied"), }, tags=["Collaborative Projects & Kanban"], ) def delete(self, request, pk_or_slug, doc_id, *args, **kwargs): project = get_project_by_id_or_slug(pk_or_slug) self.check_object_permissions(request, project) doc = get_object_or_404(ProjectDocument, id=doc_id, project=project) doc.delete() return Response(status=status.HTTP_204_NO_CONTENT)