ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: additive read-layer двусторонней доски внешних контуров

This commit is contained in:
DCCONSTRUCTIONS 2026-04-20 20:31:02 +03:00
parent f12a3b7338
commit 0184ff9a32
7 changed files with 582 additions and 7 deletions

View File

@ -54,6 +54,7 @@ from .intake import (
IntakeIssueUpdateSerializer,
)
from .external_contours import (
ExternalContourBoardItemSerializer,
ExternalContourRequestCreateSerializer,
ExternalContourRequestDecisionSerializer,
ExternalContourRequestReplySerializer,

View File

@ -212,6 +212,10 @@ class ExternalContourRequestSerializer(BaseSerializer):
return obj.extra.get("source_project_id")
def get_has_unread_updates(self, obj):
annotated_value = getattr(obj, "has_unread_updates_annotated", None)
if annotated_value is not None:
return annotated_value
request = self.context.get("request")
user = getattr(request, "user", None)
user_id = getattr(user, "id", None)
@ -329,3 +333,96 @@ class ExternalContourRequestSerializer(BaseSerializer):
if issue and issue.state and issue.state.group in ["completed", "cancelled"]:
return "closed"
return "open"
class ExternalContourBoardItemSerializer(ExternalContourRequestSerializer):
direction = serializers.SerializerMethodField()
source_project = serializers.SerializerMethodField()
target_project = serializers.SerializerMethodField()
requested_by = serializers.SerializerMethodField()
capabilities = serializers.SerializerMethodField()
class Meta(ExternalContourRequestSerializer.Meta):
fields = ExternalContourRequestSerializer.Meta.fields + [
"direction",
"source_project",
"target_project",
"requested_by",
"capabilities",
]
read_only_fields = fields
def _resolve_direction(self, obj):
current_project_id = str(self.context.get("current_project_id") or "")
source_project_id = str(obj.extra.get("source_project_id") or "")
target_project_id = str(obj.extra.get("target_project_id") or obj.issue.project_id or "")
if current_project_id and current_project_id == source_project_id:
return "outgoing"
if current_project_id and current_project_id == target_project_id:
return "incoming"
return self.context.get("direction") or "outgoing"
def get_has_unread_updates(self, obj):
unread_request_ids = self.context.get("unread_request_ids")
if unread_request_ids is not None:
return str(obj.id) in unread_request_ids
return super().get_has_unread_updates(obj)
def _get_project_payload(self, project_id, fallback_name=None):
if not project_id:
return None
project = (self.context.get("project_map") or {}).get(str(project_id))
return {
"id": str(project_id),
"identifier": getattr(project, "identifier", None),
"name": getattr(project, "name", None) or fallback_name,
"logo_props": getattr(project, "logo_props", None),
}
def get_direction(self, obj):
return self._resolve_direction(obj)
def get_source_project(self, obj):
source_project_id = obj.extra.get("source_project_id")
fallback_name = obj.extra.get("source_project_name")
return self._get_project_payload(source_project_id, fallback_name=fallback_name)
def get_target_project(self, obj):
target_project_id = obj.extra.get("target_project_id") or (str(obj.issue.project_id) if obj.issue_id else None)
fallback_name = obj.extra.get("target_project_name") or (obj.issue.project.name if obj.issue and obj.issue.project else None)
return self._get_project_payload(target_project_id, fallback_name=fallback_name)
def get_requested_by(self, obj):
requested_by_id = obj.extra.get("requested_by_id") or (str(obj.created_by_id) if obj.created_by_id else None)
requested_by_name = obj.extra.get("requested_by_name") or self.get_requested_by_name(obj)
return {
"id": requested_by_id,
"display_name": requested_by_name,
}
def get_capabilities(self, obj):
request = self.context.get("request")
user_id = str(getattr(getattr(request, "user", None), "id", "") or "")
requested_by_id = str(obj.extra.get("requested_by_id") or obj.created_by_id or "")
direction = self._resolve_direction(obj)
issue_project_id = str(obj.issue.project_id) if obj.issue_id and obj.issue and obj.issue.project_id else ""
member_project_ids = self.context.get("member_project_ids") or set()
is_open_request = self.get_status(obj) == "open"
return {
"can_open_detail": True,
"can_open_target_issue": issue_project_id in member_project_ids,
"can_edit_request": direction == "outgoing" and user_id == requested_by_id and is_open_request,
"can_reply": direction == "outgoing" and bool(obj.issue_id),
"can_source_decide": direction == "outgoing" and not is_open_request,
}
def get_source_project_name(self, obj):
source_project = self.get_source_project(obj)
return source_project["name"] if source_project else None
def get_target_project_name(self, obj):
target_project = self.get_target_project(obj)
return target_project["name"] if target_project else None

View File

@ -6,6 +6,8 @@ from django.urls import path
from plane.app.views import (
ExternalContourAttachmentDownloadEndpoint,
ExternalContourBoardEndpoint,
ExternalContourBoardItemDetailEndpoint,
ExternalContourDetailEndpoint,
ExternalContourDecisionEndpoint,
ExternalContourListCreateEndpoint,
@ -16,6 +18,16 @@ from plane.app.views import (
urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/board/",
ExternalContourBoardEndpoint.as_view(http_method_names=["get"]),
name="external-contour-board",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/board-items/<uuid:request_id>/",
ExternalContourBoardItemDetailEndpoint.as_view(http_method_names=["get"]),
name="external-contour-board-item-detail",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/",
ExternalContourListCreateEndpoint.as_view(http_method_names=["get", "post"]),

View File

@ -6,6 +6,8 @@ from django.urls import path
from plane.app.views import (
ExternalContourAttachmentDownloadEndpoint,
ExternalContourBoardEndpoint,
ExternalContourBoardItemDetailEndpoint,
ExternalContourDetailEndpoint,
ExternalContourDecisionEndpoint,
ExternalContourListCreateEndpoint,
@ -16,6 +18,16 @@ from plane.app.views import (
urlpatterns = [
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/board/",
ExternalContourBoardEndpoint.as_view(http_method_names=["get"]),
name="external-contour-board",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/board-items/<uuid:request_id>/",
ExternalContourBoardItemDetailEndpoint.as_view(http_method_names=["get"]),
name="external-contour-board-item-detail",
),
path(
"workspaces/<str:slug>/projects/<uuid:project_id>/external-contours/",
ExternalContourListCreateEndpoint.as_view(http_method_names=["get", "post"]),

View File

@ -226,6 +226,8 @@ from .notification.base import (
from .exporter.base import ExportIssuesEndpoint
from .external_contours import (
ExternalContourAttachmentDownloadEndpoint,
ExternalContourBoardEndpoint,
ExternalContourBoardItemDetailEndpoint,
ExternalContourListCreateEndpoint,
ExternalContourDetailEndpoint,
ExternalContourDecisionEndpoint,

View File

@ -3,6 +3,7 @@
# 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
@ -11,6 +12,7 @@ from rest_framework.response import Response
from plane.utils.host import base_host
from plane.api.serializers import (
ExternalContourBoardItemSerializer,
ExternalContourRequestCreateSerializer,
ExternalContourRequestDecisionSerializer,
ExternalContourRequestReplySerializer,
@ -258,6 +260,302 @@ class ExternalContourTargetOptionsEndpoint(BaseAPIView):
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_ids",
"priority",
"assignee_ids",
"created_by_ids",
"requested_by_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()
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):
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_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["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["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)
incoming_queryset = self.apply_board_filters(self.get_incoming_queryset(), request)
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
@ -519,16 +817,13 @@ class ExternalContourReplyEndpoint(BaseAPIView):
return Response(response_serializer.data, status=status.HTTP_200_OK)
class ExternalContourAttachmentDownloadEndpoint(BaseAPIView):
class ExternalContourAttachmentDownloadEndpoint(ExternalContourReadMixin, 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")
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())

View File

@ -0,0 +1,156 @@
# Copyright (c) 2023-present Plane Software, Inc. and contributors
# SPDX-License-Identifier: AGPL-3.0-only
# See the LICENSE file for details.
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from plane.db.models import Intake, IntakeIssue, Issue, Project, ProjectMember, State, WorkspaceMember
from plane.db.models.intake import IntakeIssueStatus
from plane.db.models.project import ROLE
from plane.db.models.state import StateGroup
@pytest.mark.contract
class TestExternalContoursBoardAPI:
def get_board_url(self, workspace_slug: str, project_id) -> str:
return f"/api/workspaces/{workspace_slug}/projects/{project_id}/external-contours/board/"
def get_board_item_url(self, workspace_slug: str, project_id, request_id) -> str:
return f"/api/workspaces/{workspace_slug}/projects/{project_id}/external-contours/board-items/{request_id}/"
def create_project_with_member(self, workspace, user, name: str, identifier: str, intake_view: bool = False) -> Project:
project = Project.objects.create(
name=name,
identifier=identifier,
workspace=workspace,
intake_view=intake_view,
)
ProjectMember.objects.create(project=project, member=user, role=ROLE.ADMIN.value, is_active=True)
return project
def create_state(self, project: Project, name: str, group: str, color: str, default: bool = False) -> State:
return State.objects.create(
project=project,
workspace=project.workspace,
name=name,
group=group,
color=color,
sequence=1000,
default=default,
)
def create_external_contour_request(self, source_project: Project, target_project: Project, requested_by, state: State):
intake = Intake.objects.create(
name="External Contours Bridge",
project=target_project,
is_default=False,
)
issue = Issue.objects.create(
project=target_project,
state=state,
name="Cross-project request",
description_html="<p>Need help</p>",
priority="high",
created_by=requested_by,
)
return IntakeIssue.objects.create(
intake=intake,
project=target_project,
issue=issue,
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(requested_by.id),
"requested_by_name": requested_by.display_name,
"requested_at": issue.created_at.isoformat() if issue.created_at else None,
},
)
@pytest.mark.django_db
def test_source_project_member_gets_outgoing_board_items(self, workspace, create_user):
source_user = create_user
target_user = type(source_user).objects.create_user(email="target-board@example.com", username="target-board")
WorkspaceMember.objects.create(workspace=workspace, member=target_user, role=ROLE.ADMIN.value, is_active=True)
source_project = self.create_project_with_member(workspace, source_user, "Source Board", "SRCBRD")
target_project = self.create_project_with_member(workspace, target_user, "Target Board", "TGTBRD", intake_view=True)
open_state = self.create_state(target_project, "Todo", StateGroup.UNSTARTED.value, "#cccccc", default=True)
contour_request = self.create_external_contour_request(source_project, target_project, source_user, open_state)
client = APIClient()
client.force_authenticate(user=source_user)
response = client.get(self.get_board_url(workspace.slug, source_project.id), format="json")
assert response.status_code == status.HTTP_200_OK
columns = {column["key"]: column for column in response.data["columns"]}
assert columns["outgoing"]["total_count"] == 1
assert columns["incoming"]["total_count"] == 0
assert columns["outgoing"]["results"][0]["id"] == str(contour_request.id)
assert columns["outgoing"]["results"][0]["direction"] == "outgoing"
assert columns["outgoing"]["results"][0]["capabilities"]["can_open_target_issue"] is False
@pytest.mark.django_db
def test_target_project_member_gets_incoming_board_items(self, workspace, create_user):
source_user = create_user
target_user = type(source_user).objects.create_user(email="incoming-board@example.com", username="incoming-board")
WorkspaceMember.objects.create(workspace=workspace, member=target_user, role=ROLE.ADMIN.value, is_active=True)
source_project = self.create_project_with_member(workspace, source_user, "Source Incoming", "SRCINC")
target_project = self.create_project_with_member(workspace, target_user, "Target Incoming", "TGTINC", intake_view=True)
open_state = self.create_state(target_project, "Todo", StateGroup.UNSTARTED.value, "#cccccc", default=True)
contour_request = self.create_external_contour_request(source_project, target_project, source_user, open_state)
client = APIClient()
client.force_authenticate(user=target_user)
response = client.get(self.get_board_url(workspace.slug, target_project.id), format="json")
assert response.status_code == status.HTTP_200_OK
columns = {column["key"]: column for column in response.data["columns"]}
assert columns["outgoing"]["total_count"] == 0
assert columns["incoming"]["total_count"] == 1
assert columns["incoming"]["results"][0]["id"] == str(contour_request.id)
assert columns["incoming"]["results"][0]["direction"] == "incoming"
assert columns["incoming"]["results"][0]["capabilities"]["can_open_target_issue"] is True
@pytest.mark.django_db
def test_board_item_detail_resolves_perspective_and_capabilities(self, workspace, create_user):
source_user = create_user
target_user = type(source_user).objects.create_user(email="detail-board@example.com", username="detail-board")
WorkspaceMember.objects.create(workspace=workspace, member=target_user, role=ROLE.ADMIN.value, is_active=True)
source_project = self.create_project_with_member(workspace, source_user, "Source Detail", "SRCDET")
target_project = self.create_project_with_member(workspace, target_user, "Target Detail", "TGTDET", intake_view=True)
closed_state = self.create_state(target_project, "Done", StateGroup.COMPLETED.value, "#00ff00", default=True)
contour_request = self.create_external_contour_request(source_project, target_project, source_user, closed_state)
source_client = APIClient()
source_client.force_authenticate(user=source_user)
source_response = source_client.get(
self.get_board_item_url(workspace.slug, source_project.id, contour_request.id),
format="json",
)
assert source_response.status_code == status.HTTP_200_OK
assert source_response.data["direction"] == "outgoing"
assert source_response.data["capabilities"]["can_source_decide"] is True
assert source_response.data["capabilities"]["can_open_target_issue"] is False
target_client = APIClient()
target_client.force_authenticate(user=target_user)
target_response = target_client.get(
self.get_board_item_url(workspace.slug, target_project.id, contour_request.id),
format="json",
)
assert target_response.status_code == status.HTTP_200_OK
assert target_response.data["direction"] == "incoming"
assert target_response.data["capabilities"]["can_source_decide"] is False
assert target_response.data["capabilities"]["can_open_target_issue"] is True