diff --git a/plane-src/apps/api/plane/api/serializers/__init__.py b/plane-src/apps/api/plane/api/serializers/__init__.py index 5a06ae2..79e715b 100644 --- a/plane-src/apps/api/plane/api/serializers/__init__.py +++ b/plane-src/apps/api/plane/api/serializers/__init__.py @@ -54,6 +54,7 @@ from .intake import ( IntakeIssueUpdateSerializer, ) from .external_contours import ( + ExternalContourBoardItemSerializer, ExternalContourRequestCreateSerializer, ExternalContourRequestDecisionSerializer, ExternalContourRequestReplySerializer, diff --git a/plane-src/apps/api/plane/api/serializers/external_contours.py b/plane-src/apps/api/plane/api/serializers/external_contours.py index f9ce935..782a27c 100644 --- a/plane-src/apps/api/plane/api/serializers/external_contours.py +++ b/plane-src/apps/api/plane/api/serializers/external_contours.py @@ -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 diff --git a/plane-src/apps/api/plane/api/urls/external_contours.py b/plane-src/apps/api/plane/api/urls/external_contours.py index a1148ff..c5129a2 100644 --- a/plane-src/apps/api/plane/api/urls/external_contours.py +++ b/plane-src/apps/api/plane/api/urls/external_contours.py @@ -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//projects//external-contours/board/", + ExternalContourBoardEndpoint.as_view(http_method_names=["get"]), + name="external-contour-board", + ), + path( + "workspaces//projects//external-contours/board-items//", + ExternalContourBoardItemDetailEndpoint.as_view(http_method_names=["get"]), + name="external-contour-board-item-detail", + ), path( "workspaces//projects//external-contours/", ExternalContourListCreateEndpoint.as_view(http_method_names=["get", "post"]), diff --git a/plane-src/apps/api/plane/app/urls/external_contours.py b/plane-src/apps/api/plane/app/urls/external_contours.py index a1148ff..c5129a2 100644 --- a/plane-src/apps/api/plane/app/urls/external_contours.py +++ b/plane-src/apps/api/plane/app/urls/external_contours.py @@ -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//projects//external-contours/board/", + ExternalContourBoardEndpoint.as_view(http_method_names=["get"]), + name="external-contour-board", + ), + path( + "workspaces//projects//external-contours/board-items//", + ExternalContourBoardItemDetailEndpoint.as_view(http_method_names=["get"]), + name="external-contour-board-item-detail", + ), path( "workspaces//projects//external-contours/", ExternalContourListCreateEndpoint.as_view(http_method_names=["get", "post"]), diff --git a/plane-src/apps/api/plane/app/views/__init__.py b/plane-src/apps/api/plane/app/views/__init__.py index 35205bb..104f561 100644 --- a/plane-src/apps/api/plane/app/views/__init__.py +++ b/plane-src/apps/api/plane/app/views/__init__.py @@ -226,6 +226,8 @@ from .notification.base import ( from .exporter.base import ExportIssuesEndpoint from .external_contours import ( ExternalContourAttachmentDownloadEndpoint, + ExternalContourBoardEndpoint, + ExternalContourBoardItemDetailEndpoint, ExternalContourListCreateEndpoint, ExternalContourDetailEndpoint, ExternalContourDecisionEndpoint, diff --git a/plane-src/apps/api/plane/app/views/external_contours.py b/plane-src/apps/api/plane/app/views/external_contours.py index 9c5a9df..80c45d4 100644 --- a/plane-src/apps/api/plane/app/views/external_contours.py +++ b/plane-src/apps/api/plane/app/views/external_contours.py @@ -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()) diff --git a/plane-src/apps/api/plane/tests/contract/app/test_external_contours_board.py b/plane-src/apps/api/plane/tests/contract/app/test_external_contours_board.py new file mode 100644 index 0000000..95f3b11 --- /dev/null +++ b/plane-src/apps/api/plane/tests/contract/app/test_external_contours_board.py @@ -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="

Need help

", + 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