ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: web-доступ к внешним контурам и исправление модалки запроса

This commit is contained in:
DCCONSTRUCTIONS
2026-04-19 00:07:10 +03:00
parent 6d67571b27
commit 3fe3539614
21 changed files with 707 additions and 42 deletions
@@ -56,6 +56,8 @@ from .intake import (
from .external_contours import (
ExternalContourRequestCreateSerializer,
ExternalContourRequestSerializer,
ExternalContourTargetOptionsSerializer,
ExternalContourTargetProjectSerializer,
)
from .estimate import EstimateSerializer, EstimatePointSerializer
from .asset import (
@@ -9,7 +9,8 @@ from .issue import IssueSerializer
from .project import ProjectLiteSerializer
from .state import StateLiteSerializer
from .user import UserLiteSerializer
from plane.db.models import IntakeIssue, Issue
from plane.app.serializers.issue import LabelSerializer
from plane.db.models import IntakeIssue, Issue, Label, Project
class ExternalContourIssuePayloadSerializer(serializers.Serializer):
@@ -26,6 +27,21 @@ class ExternalContourRequestCreateSerializer(serializers.Serializer):
issue = ExternalContourIssuePayloadSerializer()
class ExternalContourTargetProjectSerializer(BaseSerializer):
inbox_view = serializers.BooleanField(read_only=True, source="intake_view")
class Meta:
model = Project
fields = ["id", "identifier", "name", "logo_props", "inbox_view"]
read_only_fields = fields
class ExternalContourTargetOptionsSerializer(serializers.Serializer):
project = ExternalContourTargetProjectSerializer(read_only=True)
member_ids = serializers.ListField(child=serializers.UUIDField(), read_only=True)
labels = LabelSerializer(many=True, read_only=True)
class ExternalContourIssueSerializer(BaseSerializer):
assignee_ids = serializers.SerializerMethodField()
assignee_details = serializers.SerializerMethodField()
@@ -4,7 +4,12 @@
from django.urls import path
from plane.api.views import ExternalContourDetailAPIEndpoint, ExternalContourListCreateAPIEndpoint
from plane.api.views import (
ExternalContourDetailAPIEndpoint,
ExternalContourListCreateAPIEndpoint,
ExternalContourTargetOptionsAPIEndpoint,
ExternalContourTargetProjectListAPIEndpoint,
)
urlpatterns = [
path(
@@ -12,6 +17,16 @@ urlpatterns = [
ExternalContourListCreateAPIEndpoint.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"]),
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"]),
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"]),
@@ -58,6 +58,8 @@ from .intake import (
from .external_contours import (
ExternalContourListCreateAPIEndpoint,
ExternalContourDetailAPIEndpoint,
ExternalContourTargetProjectListAPIEndpoint,
ExternalContourTargetOptionsAPIEndpoint,
)
from .asset import UserAssetEndpoint, UserServerAssetEndpoint, GenericAssetEndpoint
@@ -9,9 +9,11 @@ from rest_framework.response import Response
from plane.api.serializers import (
ExternalContourRequestCreateSerializer,
ExternalContourRequestSerializer,
ExternalContourTargetOptionsSerializer,
ExternalContourTargetProjectSerializer,
)
from plane.app.permissions import ProjectLitePermission
from plane.db.models import Intake, IntakeIssue, Project, State, StateGroup
from plane.db.models import Intake, IntakeIssue, Label, Project, ProjectMember, State, StateGroup
from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer
from .base import BaseAPIView
from plane.db.models.intake import IntakeIssueStatus, SourceType
@@ -74,6 +76,12 @@ class ExternalContourListCreateAPIEndpoint(BaseAPIView):
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(
@@ -158,6 +166,79 @@ class ExternalContourListCreateAPIEndpoint(BaseAPIView):
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
class ExternalContourTargetProjectListAPIEndpoint(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 ExternalContourTargetOptionsAPIEndpoint(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 ExternalContourDetailAPIEndpoint(BaseAPIView):
permission_classes = [ProjectLitePermission]
serializer_class = ExternalContourRequestSerializer
@@ -8,6 +8,7 @@ from .asset import urlpatterns as asset_urls
from .cycle import urlpatterns as cycle_urls
from .estimate import urlpatterns as estimate_urls
from .external import urlpatterns as external_urls
from .external_contours import urlpatterns as external_contour_urls
from .intake import urlpatterns as intake_urls
from .issue import urlpatterns as issue_urls
from .module import urlpatterns as module_urls
@@ -29,6 +30,7 @@ urlpatterns = [
*cycle_urls,
*estimate_urls,
*external_urls,
*external_contour_urls,
*intake_urls,
*issue_urls,
*module_urls,
@@ -0,0 +1,36 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from django.urls import path
from plane.app.views import (
ExternalContourDetailEndpoint,
ExternalContourListCreateEndpoint,
ExternalContourTargetOptionsEndpoint,
ExternalContourTargetProjectListEndpoint,
)
urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/",
ExternalContourListCreateEndpoint.as_view(http_method_names=["get", "post"]),
name="external-contours",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/targets/",
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/",
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>/",
ExternalContourDetailEndpoint.as_view(http_method_names=["get"]),
name="external-contour-detail",
),
]
@@ -224,6 +224,12 @@ from .notification.base import (
)
from .exporter.base import ExportIssuesEndpoint
from .external_contours import (
ExternalContourListCreateEndpoint,
ExternalContourDetailEndpoint,
ExternalContourTargetProjectListEndpoint,
ExternalContourTargetOptionsEndpoint,
)
from .webhook.base import (
@@ -0,0 +1,266 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
from django.shortcuts import get_object_or_404
from rest_framework import status
from rest_framework.response import Response
from plane.api.serializers import (
ExternalContourRequestCreateSerializer,
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 Intake, IntakeIssue, Label, Project, ProjectMember, State, StateGroup
from plane.db.models.intake import IntakeIssueStatus, SourceType
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_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 = (
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()
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 "<p></p>",
"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)
return Response(serializer.data, status=status.HTTP_200_OK)