ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: source-side принятие и возврат внешнего запроса

This commit is contained in:
DCCONSTRUCTIONS
2026-04-19 00:29:23 +03:00
parent 3fe3539614
commit 8195c3fc80
17 changed files with 366 additions and 23 deletions
@@ -55,6 +55,7 @@ from .intake import (
)
from .external_contours import (
ExternalContourRequestCreateSerializer,
ExternalContourRequestDecisionSerializer,
ExternalContourRequestSerializer,
ExternalContourTargetOptionsSerializer,
ExternalContourTargetProjectSerializer,
@@ -27,6 +27,10 @@ class ExternalContourRequestCreateSerializer(serializers.Serializer):
issue = ExternalContourIssuePayloadSerializer()
class ExternalContourRequestDecisionSerializer(serializers.Serializer):
action = serializers.ChoiceField(choices=["accept", "decline"])
class ExternalContourTargetProjectSerializer(BaseSerializer):
inbox_view = serializers.BooleanField(read_only=True, source="intake_view")
@@ -95,6 +99,9 @@ class ExternalContourRequestSerializer(BaseSerializer):
issue = ExternalContourIssueSerializer(read_only=True)
source_project_id = serializers.SerializerMethodField()
source_project_name = serializers.SerializerMethodField()
source_decision = serializers.SerializerMethodField()
source_decision_at = serializers.SerializerMethodField()
source_decision_by_name = serializers.SerializerMethodField()
target_project_id = serializers.SerializerMethodField()
target_project_name = serializers.SerializerMethodField()
requested_by_id = serializers.SerializerMethodField()
@@ -112,6 +119,9 @@ class ExternalContourRequestSerializer(BaseSerializer):
"issue",
"source_project_id",
"source_project_name",
"source_decision",
"source_decision_at",
"source_decision_by_name",
"target_project_id",
"target_project_name",
"requested_by_id",
@@ -127,6 +137,15 @@ class ExternalContourRequestSerializer(BaseSerializer):
def get_source_project_name(self, obj):
return obj.extra.get("source_project_name")
def get_source_decision(self, obj):
return obj.extra.get("source_decision")
def get_source_decision_at(self, obj):
return obj.extra.get("source_decision_at")
def get_source_decision_by_name(self, obj):
return obj.extra.get("source_decision_by_name")
def get_target_project_id(self, obj):
target_project_id = obj.extra.get("target_project_id")
if target_project_id:
@@ -6,6 +6,7 @@ from django.urls import path
from plane.api.views import (
ExternalContourDetailAPIEndpoint,
ExternalContourDecisionAPIEndpoint,
ExternalContourListCreateAPIEndpoint,
ExternalContourTargetOptionsAPIEndpoint,
ExternalContourTargetProjectListAPIEndpoint,
@@ -32,4 +33,9 @@ urlpatterns = [
ExternalContourDetailAPIEndpoint.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"]),
name="external-contour-decision",
),
]
@@ -56,10 +56,11 @@ from .intake import (
IntakeIssueDetailAPIEndpoint,
)
from .external_contours import (
ExternalContourListCreateAPIEndpoint,
ExternalContourDetailAPIEndpoint,
ExternalContourTargetProjectListAPIEndpoint,
ExternalContourTargetOptionsAPIEndpoint,
ExternalContourListCreateEndpoint as ExternalContourListCreateAPIEndpoint,
ExternalContourDetailEndpoint as ExternalContourDetailAPIEndpoint,
ExternalContourDecisionEndpoint as ExternalContourDecisionAPIEndpoint,
ExternalContourTargetProjectListEndpoint as ExternalContourTargetProjectListAPIEndpoint,
ExternalContourTargetOptionsEndpoint as ExternalContourTargetOptionsAPIEndpoint,
)
from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpoint
@@ -3,29 +3,40 @@
# See the LICENSE file for details.
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.app.permissions import ProjectLitePermission
from plane.db.models import Intake, IntakeIssue, Label, Project, ProjectMember, State, StateGroup
from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer
from plane.app.permissions import ProjectLitePermission
from .base import BaseAPIView
from plane.db.models import Intake, IntakeIssue, Label, Project, ProjectMember, State, StateGroup
from plane.db.models.intake import IntakeIssueStatus, SourceType
class ExternalContourListCreateAPIEndpoint(BaseAPIView):
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(
@@ -93,11 +104,7 @@ class ExternalContourListCreateAPIEndpoint(BaseAPIView):
default=False,
)
target_default_state = (
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()
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)
@@ -166,7 +173,7 @@ class ExternalContourListCreateAPIEndpoint(BaseAPIView):
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
class ExternalContourTargetProjectListAPIEndpoint(BaseAPIView):
class ExternalContourTargetProjectListEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission]
serializer_class = ExternalContourTargetProjectSerializer
@@ -190,7 +197,7 @@ class ExternalContourTargetProjectListAPIEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK)
class ExternalContourTargetOptionsAPIEndpoint(BaseAPIView):
class ExternalContourTargetOptionsEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission]
serializer_class = ExternalContourTargetOptionsSerializer
@@ -239,7 +246,7 @@ class ExternalContourTargetOptionsAPIEndpoint(BaseAPIView):
return Response(serializer.data, status=status.HTTP_200_OK)
class ExternalContourDetailAPIEndpoint(BaseAPIView):
class ExternalContourDetailEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission]
serializer_class = ExternalContourRequestSerializer
@@ -264,3 +271,84 @@ class ExternalContourDetailAPIEndpoint(BaseAPIView):
contour_request = get_object_or_404(self.get_queryset())
serializer = ExternalContourRequestSerializer(contour_request)
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)
)
return Response(serializer.data, status=status.HTTP_200_OK)
@@ -6,6 +6,7 @@ from django.urls import path
from plane.app.views import (
ExternalContourDetailEndpoint,
ExternalContourDecisionEndpoint,
ExternalContourListCreateEndpoint,
ExternalContourTargetOptionsEndpoint,
ExternalContourTargetProjectListEndpoint,
@@ -33,4 +34,9 @@ urlpatterns = [
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/",
ExternalContourDecisionEndpoint.as_view(http_method_names=["post"]),
name="external-contour-decision",
),
]
@@ -227,6 +227,7 @@ from .exporter.base import ExportIssuesEndpoint
from .external_contours import (
ExternalContourListCreateEndpoint,
ExternalContourDetailEndpoint,
ExternalContourDecisionEndpoint,
ExternalContourTargetProjectListEndpoint,
ExternalContourTargetOptionsEndpoint,
)
@@ -3,11 +3,13 @@
# See the LICENSE file for details.
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,
@@ -26,6 +28,15 @@ class ExternalContourListCreateEndpoint(BaseAPIView):
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(
@@ -93,11 +104,7 @@ class ExternalContourListCreateEndpoint(BaseAPIView):
default=False,
)
target_default_state = (
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()
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)
@@ -264,3 +271,84 @@ class ExternalContourDetailEndpoint(BaseAPIView):
contour_request = get_object_or_404(self.get_queryset())
serializer = ExternalContourRequestSerializer(contour_request)
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)
)
return Response(serializer.data, status=status.HTTP_200_OK)