# Copyright (c) 2023-present Plane Software, Inc. and contributors # SPDX-License-Identifier: AGPL-3.0-only # See the LICENSE file for details. from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404 from django.utils import timezone from rest_framework import status from rest_framework.response import Response from plane.api.serializers import ( ExternalContourRequestCreateSerializer, ExternalContourRequestDecisionSerializer, ExternalContourRequestSerializer, ExternalContourTargetOptionsSerializer, ExternalContourTargetProjectSerializer, ) from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer from plane.app.permissions import ProjectLitePermission from plane.app.views.base import BaseAPIView from plane.db.models import FileAsset, Intake, IntakeIssue, Label, Project, ProjectMember, State, StateGroup from plane.db.models.intake import IntakeIssueStatus, SourceType from plane.settings.storage import S3Storage class ExternalContourListCreateEndpoint(BaseAPIView): permission_classes = [ProjectLitePermission] serializer_class = ExternalContourRequestSerializer def get_source_project(self, slug, project_id): return get_object_or_404(Project, workspace__slug=slug, pk=project_id) def get_default_target_state(self, target_project): return ( State.objects.filter(project=target_project, default=True) .exclude(group=StateGroup.TRIAGE.value) .first() ) or State.objects.filter(project=target_project).exclude(group=StateGroup.TRIAGE.value).order_by( "sequence", "created_at" ).first() def get_queryset(self): return ( IntakeIssue.objects.filter( workspace__slug=self.kwargs.get("slug"), extra__bridge="external-contours", extra__source_project_id=str(self.kwargs.get("project_id")), ) .select_related( "issue", "issue__state", "issue__project", "issue__created_by", ) .prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label") .order_by("-updated_at") ) def get(self, request, slug, project_id): serializer = ExternalContourRequestSerializer(self.get_queryset(), many=True) return Response( { "results": serializer.data, "next_cursor": "", "prev_cursor": "", "next_page_results": False, "prev_page_results": False, "total_count": len(serializer.data), "count": len(serializer.data), "total_pages": 1, "extra_stats": None, "total_results": len(serializer.data), }, status=status.HTTP_200_OK, ) def post(self, request, slug, project_id): source_project = self.get_source_project(slug, project_id) serializer = ExternalContourRequestCreateSerializer(data=request.data) serializer.is_valid(raise_exception=True) target_project = get_object_or_404( Project, workspace_id=source_project.workspace_id, pk=serializer.validated_data["target_project_id"], archived_at__isnull=True, ) if str(target_project.id) == str(source_project.id): return Response({"error": "Target project must differ from source project"}, status=status.HTTP_400_BAD_REQUEST) if not target_project.intake_view: return Response( {"error": "Target project is not enabled for external contour routing"}, status=status.HTTP_400_BAD_REQUEST, ) triage_state = State.triage_objects.filter(project=target_project).first() if not triage_state: triage_state = State.objects.create( name="Triage", group=StateGroup.TRIAGE.value, project=target_project, color="#4E5355", sequence=65000, default=False, ) target_default_state = self.get_default_target_state(target_project) if not target_default_state: return Response({"error": "Target project has no available workflow state"}, status=status.HTTP_400_BAD_REQUEST) intake = Intake.objects.filter(project=target_project, name="External Contours Bridge").first() if not intake: intake = Intake.objects.create( name="External Contours Bridge", description="System bridge intake used for cross-project routing.", is_default=False, project=target_project, ) issue_payload = serializer.validated_data["issue"] issue_serializer = IssueCreateSerializer( data={ "name": issue_payload["name"], "description_html": issue_payload.get("description_html") or "
", "priority": issue_payload.get("priority", "none"), "assignees": issue_payload.get("assignee_ids", []), "labels": issue_payload.get("label_ids", []), "target_date": issue_payload.get("target_date"), "state_id": str(triage_state.id), }, context={ "project_id": str(target_project.id), "workspace_id": str(target_project.workspace_id), "default_assignee_id": target_project.default_assignee_id, }, ) issue_serializer.is_valid(raise_exception=True) issue = issue_serializer.save(state=triage_state) intake_issue = IntakeIssue.objects.create( intake=intake, project=target_project, issue=issue, source=SourceType.IN_APP, status=IntakeIssueStatus.ACCEPTED.value, extra={ "bridge": "external-contours", "source_project_id": str(source_project.id), "source_project_name": source_project.name, "target_project_id": str(target_project.id), "target_project_name": target_project.name, "requested_by_id": str(request.user.id), "requested_by_name": request.user.display_name, "requested_at": issue.created_at.isoformat() if issue.created_at else None, }, ) if issue.state_id != target_default_state.id: issue.state = target_default_state issue.save() response_serializer = ExternalContourRequestSerializer( IntakeIssue.objects.select_related( "issue", "issue__state", "issue__project", "issue__created_by", ) .prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label") .get(pk=intake_issue.id) ) return Response(response_serializer.data, status=status.HTTP_201_CREATED) class ExternalContourTargetProjectListEndpoint(BaseAPIView): permission_classes = [ProjectLitePermission] serializer_class = ExternalContourTargetProjectSerializer def get_source_project(self, slug, project_id): return get_object_or_404(Project, workspace__slug=slug, pk=project_id) def get_queryset(self): source_project = self.get_source_project(self.kwargs.get("slug"), self.kwargs.get("project_id")) return ( Project.objects.filter( workspace_id=source_project.workspace_id, archived_at__isnull=True, intake_view=True, ) .exclude(pk=source_project.id) .order_by("name") ) def get(self, request, slug, project_id): serializer = ExternalContourTargetProjectSerializer(self.get_queryset(), many=True) return Response(serializer.data, status=status.HTTP_200_OK) class ExternalContourTargetOptionsEndpoint(BaseAPIView): permission_classes = [ProjectLitePermission] serializer_class = ExternalContourTargetOptionsSerializer def get_source_project(self, slug, project_id): return get_object_or_404(Project, workspace__slug=slug, pk=project_id) def get_target_project(self, source_project, target_project_id): return get_object_or_404( Project, workspace_id=source_project.workspace_id, pk=target_project_id, archived_at__isnull=True, intake_view=True, ) def get(self, request, slug, project_id, target_project_id): source_project = self.get_source_project(slug, project_id) target_project = self.get_target_project(source_project, target_project_id) if str(target_project.id) == str(source_project.id): return Response({"error": "Target project must differ from source project"}, status=status.HTTP_400_BAD_REQUEST) member_ids = list( ProjectMember.objects.filter( project=target_project, workspace_id=target_project.workspace_id, is_active=True, member__is_bot=False, member__member_workspace__workspace_id=target_project.workspace_id, member__member_workspace__is_active=True, ) .order_by("member__display_name", "member__email") .values_list("member_id", flat=True) .distinct() ) labels = Label.objects.filter(project=target_project).order_by("sort_order", "name") serializer = ExternalContourTargetOptionsSerializer( { "project": target_project, "member_ids": member_ids, "labels": labels, } ) return Response(serializer.data, status=status.HTTP_200_OK) class ExternalContourDetailEndpoint(BaseAPIView): permission_classes = [ProjectLitePermission] serializer_class = ExternalContourRequestSerializer def get_queryset(self): return ( IntakeIssue.objects.filter( workspace__slug=self.kwargs.get("slug"), extra__bridge="external-contours", extra__source_project_id=str(self.kwargs.get("project_id")), pk=self.kwargs.get("request_id"), ) .select_related( "issue", "issue__state", "issue__project", "issue__created_by", ) .prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label") ) def get(self, request, slug, project_id, request_id): contour_request = get_object_or_404(self.get_queryset()) serializer = ExternalContourRequestSerializer( contour_request, context={ "include_mirror_data": True, "workspace_slug": slug, "source_project_id": str(project_id), }, ) return Response(serializer.data, status=status.HTTP_200_OK) class ExternalContourDecisionEndpoint(BaseAPIView): permission_classes = [ProjectLitePermission] serializer_class = ExternalContourRequestSerializer def get_queryset(self): return ( IntakeIssue.objects.filter( workspace__slug=self.kwargs.get("slug"), extra__bridge="external-contours", extra__source_project_id=str(self.kwargs.get("project_id")), pk=self.kwargs.get("request_id"), ) .select_related( "issue", "issue__state", "issue__project", "issue__created_by", ) .prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label") ) def post(self, request, slug, project_id, request_id): contour_request = get_object_or_404(self.get_queryset()) serializer = ExternalContourRequestDecisionSerializer(data=request.data) serializer.is_valid(raise_exception=True) action = serializer.validated_data["action"] issue = contour_request.issue if not issue or not issue.state or issue.state.group not in [StateGroup.COMPLETED.value, StateGroup.CANCELLED.value]: return Response( {"error": "Source decision is available only after the target contour finishes processing"}, status=status.HTTP_400_BAD_REQUEST, ) if action == "accept": contour_request.extra = { **contour_request.extra, "source_decision": "accepted", "source_decision_at": timezone.now().isoformat(), "source_decision_by_name": request.user.display_name, } contour_request.save(update_fields=["extra", "updated_at"]) else: target_default_state = ( State.objects.filter(project=issue.project, default=True) .exclude(group=StateGroup.TRIAGE.value) .first() ) or State.objects.filter(project=issue.project).exclude(group=StateGroup.TRIAGE.value).order_by( "sequence", "created_at" ).first() if not target_default_state: return Response({"error": "Target project has no available workflow state"}, status=status.HTTP_400_BAD_REQUEST) issue.state = target_default_state issue.save(update_fields=["state", "updated_at"]) extra = dict(contour_request.extra or {}) extra.pop("source_decision", None) extra.pop("source_decision_at", None) extra.pop("source_decision_by_name", None) extra["last_reopened_at"] = issue.updated_at.isoformat() if issue.updated_at else None extra["last_reopened_by_name"] = request.user.display_name contour_request.extra = extra contour_request.save(update_fields=["extra", "updated_at"]) contour_request.refresh_from_db() serializer = ExternalContourRequestSerializer( IntakeIssue.objects.select_related( "issue", "issue__state", "issue__project", "issue__created_by", ) .prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label") .get(pk=contour_request.id), context={ "include_mirror_data": True, "workspace_slug": slug, "source_project_id": str(project_id), }, ) return Response(serializer.data, status=status.HTTP_200_OK) class ExternalContourAttachmentDownloadEndpoint(BaseAPIView): permission_classes = [ProjectLitePermission] def get_queryset(self): return IntakeIssue.objects.filter( workspace__slug=self.kwargs.get("slug"), extra__bridge="external-contours", extra__source_project_id=str(self.kwargs.get("project_id")), pk=self.kwargs.get("request_id"), ).select_related("issue", "issue__project", "workspace") def get(self, request, slug, project_id, request_id, attachment_id): contour_request = get_object_or_404(self.get_queryset()) attachment = get_object_or_404( FileAsset, pk=attachment_id, issue_id=contour_request.issue_id, entity_type=FileAsset.EntityTypeContext.ISSUE_ATTACHMENT, is_uploaded=True, is_deleted=False, ) storage = S3Storage(request=request) presigned_url = storage.generate_presigned_url( object_name=attachment.asset.name, disposition="attachment", filename=attachment.attributes.get("name"), ) return HttpResponseRedirect(presigned_url)