ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: additive read-layer двусторонней доски внешних контуров
This commit is contained in:
parent
f12a3b7338
commit
0184ff9a32
|
|
@ -54,6 +54,7 @@ from .intake import (
|
|||
IntakeIssueUpdateSerializer,
|
||||
)
|
||||
from .external_contours import (
|
||||
ExternalContourBoardItemSerializer,
|
||||
ExternalContourRequestCreateSerializer,
|
||||
ExternalContourRequestDecisionSerializer,
|
||||
ExternalContourRequestReplySerializer,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
|
|
|
|||
|
|
@ -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"]),
|
||||
|
|
|
|||
|
|
@ -226,6 +226,8 @@ from .notification.base import (
|
|||
from .exporter.base import ExportIssuesEndpoint
|
||||
from .external_contours import (
|
||||
ExternalContourAttachmentDownloadEndpoint,
|
||||
ExternalContourBoardEndpoint,
|
||||
ExternalContourBoardItemDetailEndpoint,
|
||||
ExternalContourListCreateEndpoint,
|
||||
ExternalContourDetailEndpoint,
|
||||
ExternalContourDecisionEndpoint,
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Reference in New Issue