ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: причина отклонения, reply и in-app уведомления

This commit is contained in:
DCCONSTRUCTIONS
2026-04-19 09:22:15 +03:00
parent 61a8625a5c
commit 0a584abf26
25 changed files with 623 additions and 32 deletions
@@ -56,6 +56,7 @@ from .intake import (
from .external_contours import (
ExternalContourRequestCreateSerializer,
ExternalContourRequestDecisionSerializer,
ExternalContourRequestReplySerializer,
ExternalContourRequestSerializer,
ExternalContourTargetOptionsSerializer,
ExternalContourTargetProjectSerializer,
@@ -29,6 +29,16 @@ class ExternalContourRequestCreateSerializer(serializers.Serializer):
class ExternalContourRequestDecisionSerializer(serializers.Serializer):
action = serializers.ChoiceField(choices=["accept", "decline"])
comment = serializers.CharField(required=False, allow_blank=True)
def validate(self, data):
if data.get("action") == "decline" and not (data.get("comment") or "").strip():
raise serializers.ValidationError({"comment": "Decline reason is required"})
return data
class ExternalContourRequestReplySerializer(serializers.Serializer):
comment = serializers.CharField()
class ExternalContourTargetProjectSerializer(BaseSerializer):
@@ -4,44 +4,51 @@
from django.urls import path
from plane.api.views import (
ExternalContourAttachmentDownloadAPIEndpoint,
ExternalContourDetailAPIEndpoint,
ExternalContourDecisionAPIEndpoint,
ExternalContourListCreateAPIEndpoint,
ExternalContourTargetOptionsAPIEndpoint,
ExternalContourTargetProjectListAPIEndpoint,
from plane.app.views import (
ExternalContourAttachmentDownloadEndpoint,
ExternalContourDetailEndpoint,
ExternalContourDecisionEndpoint,
ExternalContourListCreateEndpoint,
ExternalContourReplyEndpoint,
ExternalContourTargetOptionsEndpoint,
ExternalContourTargetProjectListEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/",
ExternalContourListCreateAPIEndpoint.as_view(http_method_names=["get", "post"]),
ExternalContourListCreateEndpoint.as_view(http_method_names=["get", "post"]),
name="external-contours",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/targets/",
ExternalContourTargetProjectListAPIEndpoint.as_view(http_method_names=["get"]),
ExternalContourTargetProjectListEndpoint.as_view(http_method_names=["get"]),
name="external-contour-targets",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/targets/<uuid:target_project_id>/options/",
ExternalContourTargetOptionsAPIEndpoint.as_view(http_method_names=["get"]),
ExternalContourTargetOptionsEndpoint.as_view(http_method_names=["get"]),
name="external-contour-target-options",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/",
ExternalContourDetailAPIEndpoint.as_view(http_method_names=["get"]),
ExternalContourDetailEndpoint.as_view(http_method_names=["get"]),
name="external-contour-detail",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/decision/",
ExternalContourDecisionAPIEndpoint.as_view(http_method_names=["post"]),
ExternalContourDecisionEndpoint.as_view(http_method_names=["post"]),
name="external-contour-decision",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/reply/",
ExternalContourReplyEndpoint.as_view(http_method_names=["post"]),
name="external-contour-reply",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/<uuid:request_id>/attachments/<uuid:attachment_id>/",
ExternalContourAttachmentDownloadAPIEndpoint.as_view(http_method_names=["get"]),
ExternalContourAttachmentDownloadEndpoint.as_view(http_method_names=["get"]),
name="external-contour-attachment-download",
),
]
@@ -60,6 +60,7 @@ from .external_contours import (
ExternalContourListCreateEndpoint as ExternalContourListCreateAPIEndpoint,
ExternalContourDetailEndpoint as ExternalContourDetailAPIEndpoint,
ExternalContourDecisionEndpoint as ExternalContourDecisionAPIEndpoint,
ExternalContourReplyEndpoint as ExternalContourReplyAPIEndpoint,
ExternalContourTargetProjectListEndpoint as ExternalContourTargetProjectListAPIEndpoint,
ExternalContourTargetOptionsEndpoint as ExternalContourTargetOptionsAPIEndpoint,
)
@@ -5,22 +5,26 @@
from django.http import HttpResponseRedirect
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 (
ExternalContourRequestCreateSerializer,
ExternalContourRequestDecisionSerializer,
ExternalContourRequestReplySerializer,
ExternalContourRequestSerializer,
ExternalContourTargetOptionsSerializer,
ExternalContourTargetProjectSerializer,
)
from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer
from plane.app.permissions import ProjectLitePermission
from .base import BaseAPIView
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
from plane.utils.external_contours import create_external_contour_issue_comment
class ExternalContourListCreateEndpoint(BaseAPIView):
@@ -309,6 +313,7 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
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]:
@@ -337,6 +342,16 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
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"])
@@ -344,6 +359,7 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
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
@@ -368,6 +384,58 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
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()
response_serializer = ExternalContourRequestSerializer(
contour_request,
context={
"include_mirror_data": True,
"workspace_slug": slug,
"source_project_id": str(project_id),
},
)
return Response(response_serializer.data, status=status.HTTP_200_OK)
class ExternalContourAttachmentDownloadEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission]