# 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.db.models import Q from django.shortcuts import get_object_or_404 from django.utils import timezone from rest_framework.exceptions import ValidationError from rest_framework import status from rest_framework.response import Response from plane.utils.host import base_host from plane.api.serializers import ( ExternalContourBoardItemSerializer, ExternalContourRequestCreateSerializer, ExternalContourRequestDecisionSerializer, ExternalContourRequestReplySerializer, ExternalContourRequestUpdateSerializer, ExternalContourRequestSerializer, ExternalContourTargetOptionsSerializer, ExternalContourTargetProjectSerializer, ) from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer from plane.app.permissions import ProjectLitePermission from plane.app.realtime.issue_events import publish_external_contour_event_on_commit, publish_issue_event_on_commit from plane.app.views.base import BaseAPIView from plane.db.models import FileAsset, Intake, IntakeIssue, Label, Notification, Project, ProjectMember, State, StateGroup from plane.db.models.intake import IntakeIssueStatus, SourceType from plane.settings.storage import S3Storage from plane.utils.external_contours import create_external_contour_issue_comment 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, context={"request": request}, ) 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() publish_issue_event_on_commit( "issue.created", issue, actor_id=request.user.id, changed_fields=[ "name", "description_html", "priority", "assignees", "labels", "target_date", "state", "external_contour", ], publish_external_bridge=False, ) publish_external_contour_event_on_commit( "external_contour.created", intake_issue, actor_id=request.user.id, changed_fields=["issue", "state", "external_contour"], ) 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), context={"request": request}, ) 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") states = State.objects.filter(project=target_project).exclude(group=StateGroup.TRIAGE.value).order_by( "sequence", "created_at" ) serializer = ExternalContourTargetOptionsSerializer( { "project": target_project, "member_ids": member_ids, "states": states, "labels": labels, } ) return Response(serializer.data, status=status.HTTP_200_OK) class ExternalContourReadMixin: CLOSED_STATE_GROUPS = (StateGroup.COMPLETED.value, StateGroup.CANCELLED.value) BOARD_ORDERING_MAP = { "requested_at": "created_at", "updated_at": "updated_at", "issue__sequence_id": "issue__sequence_id", "target_date": "issue__target_date", } def get_base_queryset(self): return ( IntakeIssue.objects.filter( workspace__slug=self.kwargs.get("slug"), extra__bridge="external-contours", ) .select_related( "issue", "issue__state", "issue__project", "issue__created_by", ) .prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label") ) def get_outgoing_queryset(self): return self.get_base_queryset().filter(extra__source_project_id=str(self.kwargs.get("project_id"))) def get_incoming_queryset(self): return self.get_base_queryset().filter(project_id=self.kwargs.get("project_id")) def get_board_item_queryset(self): return self.get_base_queryset().filter( Q(extra__source_project_id=str(self.kwargs.get("project_id"))) | Q(project_id=self.kwargs.get("project_id")) ) def parse_csv_param(self, request, key): value = (request.query_params.get(key) or "").strip() if not value: return [] return [item.strip() for item in value.split(",") if item.strip()] def parse_bool_param(self, request, key): value = (request.query_params.get(key) or "").strip().lower() if value in ["true", "1", "yes"]: return True if value in ["false", "0", "no"]: return False return None def get_requested_directions(self, request): directions = set(self.parse_csv_param(request, "direction")) valid_directions = {"outgoing", "incoming"} return directions.intersection(valid_directions) or valid_directions def get_applied_filters(self, request): filters = {} for key in [ "direction", "status", "state_groups", "state_ids", "priority", "assignee_ids", "created_by_ids", "requested_by_ids", "counterparty_project_ids", "source_project_ids", "target_project_ids", "label_ids", ]: filters[key] = self.parse_csv_param(request, key) filters["has_unread_updates"] = self.parse_bool_param(request, "has_unread_updates") filters["search"] = (request.query_params.get("search") or "").strip() filters["target_date_exact"] = (request.query_params.get("target_date_exact") or "").strip() filters["target_date_from"] = (request.query_params.get("target_date_from") or "").strip() filters["target_date_to"] = (request.query_params.get("target_date_to") or "").strip() return filters def get_sorting(self, request): order_by = (request.query_params.get("order_by") or "updated_at").strip() sort_by = (request.query_params.get("sort_by") or "desc").strip().lower() if order_by not in self.BOARD_ORDERING_MAP: order_by = "updated_at" if sort_by not in ["asc", "desc"]: sort_by = "desc" return { "order_by": order_by, "sort_by": sort_by, } def get_unread_request_ids(self, user, request_ids=None): user_id = getattr(user, "id", None) if not user_id: return set() queryset = Notification.objects.filter( receiver_id=user_id, sender__startswith="in_app:external_contours:", read_at__isnull=True, ) if request_ids: queryset = queryset.filter(data__issue__id__in=[str(request_id) for request_id in request_ids]) return { str(request_id) for request_id in queryset.values_list("data__issue__id", flat=True) if request_id } def apply_status_filter(self, queryset, statuses): normalized_statuses = {status_value for status_value in statuses if status_value in ["open", "closed"]} if not normalized_statuses or normalized_statuses == {"open", "closed"}: return queryset if normalized_statuses == {"open"}: return queryset.exclude(issue__state__group__in=self.CLOSED_STATE_GROUPS) if normalized_statuses == {"closed"}: return queryset.filter(issue__state__group__in=self.CLOSED_STATE_GROUPS) return queryset def apply_board_filters(self, queryset, request, direction=None): filters = self.get_applied_filters(request) if filters["search"]: queryset = queryset.filter( Q(issue__name__icontains=filters["search"]) | Q(issue__description_html__icontains=filters["search"]) ) queryset = self.apply_status_filter(queryset, filters["status"]) if filters["state_groups"]: queryset = queryset.filter(issue__state__group__in=filters["state_groups"]) if filters["state_ids"]: queryset = queryset.filter(issue__state_id__in=filters["state_ids"]) if filters["priority"]: queryset = queryset.filter(issue__priority__in=filters["priority"]) if filters["assignee_ids"]: queryset = queryset.filter(issue__issue_assignee__assignee_id__in=filters["assignee_ids"]) if filters["created_by_ids"]: queryset = queryset.filter(issue__created_by_id__in=filters["created_by_ids"]) if filters["requested_by_ids"]: queryset = queryset.filter(extra__requested_by_id__in=filters["requested_by_ids"]) if filters["counterparty_project_ids"]: if direction == "outgoing": queryset = queryset.filter( Q(extra__target_project_id__in=filters["counterparty_project_ids"]) | Q(issue__project_id__in=filters["counterparty_project_ids"]) ) elif direction == "incoming": queryset = queryset.filter(extra__source_project_id__in=filters["counterparty_project_ids"]) if filters["source_project_ids"]: queryset = queryset.filter(extra__source_project_id__in=filters["source_project_ids"]) if filters["target_project_ids"]: queryset = queryset.filter( Q(extra__target_project_id__in=filters["target_project_ids"]) | Q(issue__project_id__in=filters["target_project_ids"]) ) if filters["label_ids"]: queryset = queryset.filter(issue__label_issue__label_id__in=filters["label_ids"]) if filters["target_date_exact"]: queryset = queryset.filter(issue__target_date=filters["target_date_exact"]) if filters["target_date_from"]: queryset = queryset.filter(issue__target_date__gte=filters["target_date_from"]) if filters["target_date_to"]: queryset = queryset.filter(issue__target_date__lte=filters["target_date_to"]) if filters["has_unread_updates"] is not None: unread_request_ids = self.get_unread_request_ids(request.user) if filters["has_unread_updates"]: queryset = queryset.filter(pk__in=unread_request_ids or ["00000000-0000-0000-0000-000000000000"]) else: queryset = queryset.exclude(pk__in=unread_request_ids) sorting = self.get_sorting(request) ordering = self.BOARD_ORDERING_MAP[sorting["order_by"]] if sorting["sort_by"] == "desc": ordering = f"-{ordering}" return queryset.order_by(ordering).distinct() def build_project_map(self, contour_requests): project_ids = { str(project_id) for contour_request in contour_requests for project_id in [ contour_request.extra.get("source_project_id"), contour_request.extra.get("target_project_id") or (str(contour_request.issue.project_id) if contour_request.issue_id else None), ] if project_id } if not project_ids: return {} return { str(project.id): project for project in Project.objects.filter(pk__in=project_ids) } def build_member_project_ids(self, request, contour_requests): issue_project_ids = { str(contour_request.issue.project_id) for contour_request in contour_requests if contour_request.issue_id and contour_request.issue and contour_request.issue.project_id } if not issue_project_ids: return set() return { str(project_id) for project_id in ProjectMember.objects.filter( workspace__slug=self.kwargs.get("slug"), member=request.user, project_id__in=issue_project_ids, is_active=True, ).values_list("project_id", flat=True) } def build_serializer_context(self, request, contour_requests, current_project_id, include_mirror_data=False): contour_requests = list(contour_requests) unread_request_ids = self.get_unread_request_ids(request.user, request_ids=[contour_request.id for contour_request in contour_requests]) return { "include_mirror_data": include_mirror_data, "workspace_slug": self.kwargs.get("slug"), "source_project_id": str(current_project_id), "current_project_id": str(current_project_id), "project_map": self.build_project_map(contour_requests), "member_project_ids": self.build_member_project_ids(request, contour_requests), "unread_request_ids": unread_request_ids, "request": request, } def mark_request_notifications_read(self, user, contour_request): user_id = getattr(user, "id", None) if not user_id: return Notification.objects.filter( receiver_id=user_id, sender__startswith="in_app:external_contours:", read_at__isnull=True, data__issue__id=str(contour_request.id), ).update(read_at=timezone.now()) class ExternalContourBoardEndpoint(ExternalContourReadMixin, BaseAPIView): permission_classes = [ProjectLitePermission] serializer_class = ExternalContourBoardItemSerializer def get(self, request, slug, project_id): requested_directions = self.get_requested_directions(request) current_project_id = str(project_id) outgoing_queryset = self.apply_board_filters(self.get_outgoing_queryset(), request, direction="outgoing") incoming_queryset = self.apply_board_filters(self.get_incoming_queryset(), request, direction="incoming") outgoing_requests = list(outgoing_queryset) if "outgoing" in requested_directions else [] incoming_requests = list(incoming_queryset) if "incoming" in requested_directions else [] outgoing_serializer = ExternalContourBoardItemSerializer( outgoing_requests, many=True, context=self.build_serializer_context(request, outgoing_requests, current_project_id=current_project_id), ) incoming_serializer = ExternalContourBoardItemSerializer( incoming_requests, many=True, context=self.build_serializer_context(request, incoming_requests, current_project_id=current_project_id), ) return Response( { "filters": self.get_applied_filters(request), "sorting": self.get_sorting(request), "columns": [ { "key": "outgoing", "title": "Исходящие", "total_count": len(outgoing_requests), "next_cursor": "", "results": outgoing_serializer.data, }, { "key": "incoming", "title": "Входящие", "total_count": len(incoming_requests), "next_cursor": "", "results": incoming_serializer.data, }, ], }, status=status.HTTP_200_OK, ) class ExternalContourBoardItemDetailEndpoint(ExternalContourReadMixin, BaseAPIView): permission_classes = [ProjectLitePermission] serializer_class = ExternalContourBoardItemSerializer def get(self, request, slug, project_id, request_id): contour_request = get_object_or_404(self.get_board_item_queryset(), pk=request_id) self.mark_request_notifications_read(request.user, contour_request) serializer = ExternalContourBoardItemSerializer( contour_request, context=self.build_serializer_context( request, [contour_request], current_project_id=str(project_id), include_mirror_data=True, ), ) 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 mark_request_notifications_read(self, user, contour_request): user_id = getattr(user, "id", None) if not user_id: return Notification.objects.filter( receiver_id=user_id, sender__startswith="in_app:external_contours:", read_at__isnull=True, data__issue__id=str(contour_request.id), ).update(read_at=timezone.now()) def get(self, request, slug, project_id, request_id): contour_request = get_object_or_404(self.get_queryset()) self.mark_request_notifications_read(request.user, contour_request) serializer = ExternalContourRequestSerializer( contour_request, context={ "include_mirror_data": True, "workspace_slug": slug, "source_project_id": str(project_id), "request": request, }, ) return Response(serializer.data, status=status.HTTP_200_OK) def patch(self, request, slug, project_id, request_id): contour_request = get_object_or_404(self.get_queryset()) issue = contour_request.issue if not issue: return Response({"error": "Target issue was not found"}, status=status.HTTP_404_NOT_FOUND) requested_by_id = contour_request.extra.get("requested_by_id") or ( str(issue.created_by_id) if issue.created_by_id else None ) if str(request.user.id) != str(requested_by_id): return Response({"error": "Only the sender can edit this request"}, status=status.HTTP_403_FORBIDDEN) if issue.state and issue.state.group in [StateGroup.COMPLETED.value, StateGroup.CANCELLED.value]: return Response( {"error": "Only open external contour requests can be edited"}, status=status.HTTP_400_BAD_REQUEST, ) serializer = ExternalContourRequestUpdateSerializer(data=request.data) serializer.is_valid(raise_exception=True) issue_update_data = serializer.validated_data.copy() changed_fields = list(issue_update_data.keys()) assignee_ids = issue_update_data.pop("assignee_ids", None) label_ids = issue_update_data.pop("label_ids", None) if assignee_ids is not None: issue_update_data["assignees"] = assignee_ids if label_ids is not None: issue_update_data["labels"] = label_ids issue_serializer = IssueCreateSerializer( issue, data=issue_update_data, partial=True, context={ "project_id": str(issue.project_id), "workspace_id": str(issue.workspace_id), }, ) issue_serializer.is_valid(raise_exception=True) updated_issue = issue_serializer.save() contour_request.updated_at = timezone.now() contour_request.save(update_fields=["updated_at"]) contour_request.refresh_from_db() publish_issue_event_on_commit( "issue.updated", updated_issue, actor_id=request.user.id, changed_fields=changed_fields, publish_external_bridge=False, ) publish_external_contour_event_on_commit( "external_contour.updated", contour_request, actor_id=request.user.id, changed_fields=changed_fields, ) response_serializer = ExternalContourRequestSerializer( contour_request, context={ "include_mirror_data": True, "workspace_slug": slug, "source_project_id": str(project_id), "request": request, }, ) return Response(response_serializer.data, status=status.HTTP_200_OK) def delete(self, request, slug, project_id, request_id): contour_request = get_object_or_404(self.get_queryset()) issue = contour_request.issue if not issue: contour_request.delete() return Response(status=status.HTTP_204_NO_CONTENT) requested_by_id = contour_request.extra.get("requested_by_id") or ( str(issue.created_by_id) if issue.created_by_id else None ) if str(request.user.id) != str(requested_by_id): return Response({"error": "Only the sender can delete this request"}, status=status.HTTP_403_FORBIDDEN) publish_external_contour_event_on_commit( "external_contour.deleted", contour_request, actor_id=request.user.id, changed_fields=["deleted_at"], ) publish_issue_event_on_commit( "issue.deleted", issue, actor_id=request.user.id, changed_fields=["deleted_at", "external_contour"], publish_external_bridge=False, ) contour_request.delete() issue.delete() return Response(status=status.HTTP_204_NO_CONTENT) 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"] comment = (serializer.validated_data.get("comment") or "").strip() 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) try: create_external_contour_issue_comment( issue=issue, actor=request.user, comment=comment, origin=base_host(request=request, is_app=True), ) except ValidationError as exc: return Response({"error": exc.message_dict if hasattr(exc, "message_dict") else str(exc)}, status=status.HTTP_400_BAD_REQUEST) issue.state = target_default_state issue.save(update_fields=["state", "updated_at"]) publish_issue_event_on_commit( "issue.updated", issue, actor_id=request.user.id, changed_fields=["state", "external_contour_reopen"], publish_external_bridge=False, ) 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_decline_comment"] = comment 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"]) publish_external_contour_event_on_commit( "external_contour.updated", contour_request, actor_id=request.user.id, changed_fields=["source_decision", "state"], ) contour_request.refresh_from_db() Notification.objects.filter( receiver_id=request.user.id, sender__startswith="in_app:external_contours:", read_at__isnull=True, data__issue__id=str(contour_request.id), ).update(read_at=timezone.now()) 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), "request": request, }, ) return Response(serializer.data, status=status.HTTP_200_OK) class ExternalContourReplyEndpoint(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 = ExternalContourRequestReplySerializer(data=request.data) serializer.is_valid(raise_exception=True) issue = contour_request.issue if not issue: return Response({"error": "Target issue was not found"}, status=status.HTTP_404_NOT_FOUND) try: create_external_contour_issue_comment( issue=issue, actor=request.user, comment=serializer.validated_data["comment"], origin=base_host(request=request, is_app=True), ) except ValidationError as exc: return Response({"error": exc.message_dict if hasattr(exc, "message_dict") else str(exc)}, status=status.HTTP_400_BAD_REQUEST) contour_request.refresh_from_db() publish_external_contour_event_on_commit( "external_contour.updated", contour_request, actor_id=request.user.id, changed_fields=["comments"], ) Notification.objects.filter( receiver_id=request.user.id, sender__startswith="in_app:external_contours:", read_at__isnull=True, data__issue__id=str(contour_request.id), ).update(read_at=timezone.now()) response_serializer = ExternalContourRequestSerializer( contour_request, context={ "include_mirror_data": True, "workspace_slug": slug, "source_project_id": str(project_id), "request": request, }, ) return Response(response_serializer.data, status=status.HTTP_200_OK) class ExternalContourAttachmentDownloadEndpoint(ExternalContourReadMixin, BaseAPIView): permission_classes = [ProjectLitePermission] def get_queryset(self): return self.get_board_item_queryset().filter(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)