UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: индикатор новых изменений во внешних контурах

This commit is contained in:
DCCONSTRUCTIONS 2026-04-19 09:52:27 +03:00
parent 0a584abf26
commit 4dff521f7f
8 changed files with 107 additions and 16 deletions

View File

@ -99,10 +99,10 @@
- отображение целевого проекта
- отображение исполнителей целевого контура
- отображение фактической даты последнего изменения
- индикатор новых изменений в source-side списке на базе unread уведомлений
- открытие source-side detail экрана
Что еще остается на следующие этапы:
- индикатор новых изменений
- полноценная зеркальная activity/history
- уведомления
@ -195,6 +195,7 @@
- notification payload несет `external contour request id` и `target issue id`
- список уведомлений помечает такие записи как `is_external_contour = true`
- notification preview может открыть source-side карточку внешнего контура, а не обычный issue preview
- открытие source-side карточки помечает связанные unread уведомления как прочитанные и снимает индикатор новых изменений в списке
Что остается:
- in-app уведомления на явные source-side решения `Принять / Отклонить`

View File

@ -10,7 +10,7 @@ from .project import ProjectLiteSerializer
from .state import StateLiteSerializer
from .user import UserLiteSerializer
from plane.app.serializers.issue import LabelSerializer
from plane.db.models import FileAsset, IntakeIssue, Issue, IssueActivity, IssueComment, Label, Project
from plane.db.models import FileAsset, IntakeIssue, Issue, IssueActivity, IssueComment, Label, Notification, Project
class ExternalContourIssuePayloadSerializer(serializers.Serializer):
@ -149,6 +149,7 @@ class ExternalContourMirroredActivitySerializer(BaseSerializer):
class ExternalContourRequestSerializer(BaseSerializer):
has_unread_updates = serializers.SerializerMethodField()
issue = ExternalContourIssueSerializer(read_only=True)
mirrored_activity = serializers.SerializerMethodField()
mirrored_attachments = serializers.SerializerMethodField()
@ -172,6 +173,7 @@ class ExternalContourRequestSerializer(BaseSerializer):
"created_at",
"updated_at",
"created_by",
"has_unread_updates",
"issue",
"mirrored_activity",
"mirrored_attachments",
@ -193,6 +195,20 @@ class ExternalContourRequestSerializer(BaseSerializer):
def get_source_project_id(self, obj):
return obj.extra.get("source_project_id")
def get_has_unread_updates(self, obj):
request = self.context.get("request")
user = getattr(request, "user", None)
user_id = getattr(user, "id", None)
if not user_id:
return False
return Notification.objects.filter(
receiver_id=user_id,
sender__startswith="in_app:external_contours:",
read_at__isnull=True,
data__issue__id=str(obj.id),
).exists()
def get_mirrored_activity(self, obj):
if not self.context.get("include_mirror_data") or not obj.issue_id:
return []

View File

@ -21,7 +21,7 @@ from plane.api.serializers import (
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 FileAsset, Intake, IntakeIssue, Label, Project, ProjectMember, State, StateGroup
from plane.db.models import FileAsset, Intake, IntakeIssue, Label, Notification, Project, ProjectMember, State, StateGroup
from plane.db.models.intake import IntakeIssueStatus, SourceType
from plane.settings.storage import S3Storage
from plane.utils.external_contours import create_external_contour_issue_comment
@ -61,7 +61,11 @@ class ExternalContourListCreateEndpoint(BaseAPIView):
)
def get(self, request, slug, project_id):
serializer = ExternalContourRequestSerializer(self.get_queryset(), many=True)
serializer = ExternalContourRequestSerializer(
self.get_queryset(),
many=True,
context={"request": request},
)
return Response(
{
"results": serializer.data,
@ -174,7 +178,8 @@ class ExternalContourListCreateEndpoint(BaseAPIView):
"issue__created_by",
)
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
.get(pk=intake_issue.id)
.get(pk=intake_issue.id),
context={"request": request},
)
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
@ -273,14 +278,28 @@ class ExternalContourDetailEndpoint(BaseAPIView):
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
)
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())
def get(self, request, slug, project_id, request_id):
contour_request = get_object_or_404(self.get_queryset())
self.mark_request_notifications_read(request.user, contour_request)
serializer = ExternalContourRequestSerializer(
contour_request,
context={
"include_mirror_data": True,
"workspace_slug": slug,
"source_project_id": str(project_id),
"request": request,
},
)
return Response(serializer.data, status=status.HTTP_200_OK)
@ -366,6 +385,12 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
contour_request.save(update_fields=["extra", "updated_at"])
contour_request.refresh_from_db()
Notification.objects.filter(
receiver_id=request.user.id,
sender__startswith="in_app:external_contours:",
read_at__isnull=True,
data__issue__id=str(contour_request.id),
).update(read_at=timezone.now())
serializer = ExternalContourRequestSerializer(
IntakeIssue.objects.select_related(
"issue",
@ -379,6 +404,7 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
"include_mirror_data": True,
"workspace_slug": slug,
"source_project_id": str(project_id),
"request": request,
},
)
return Response(serializer.data, status=status.HTTP_200_OK)
@ -425,12 +451,19 @@ class ExternalContourReplyEndpoint(BaseAPIView):
return Response({"error": exc.message_dict if hasattr(exc, "message_dict") else str(exc)}, status=status.HTTP_400_BAD_REQUEST)
contour_request.refresh_from_db()
Notification.objects.filter(
receiver_id=request.user.id,
sender__startswith="in_app:external_contours:",
read_at__isnull=True,
data__issue__id=str(contour_request.id),
).update(read_at=timezone.now())
response_serializer = ExternalContourRequestSerializer(
contour_request,
context={
"include_mirror_data": True,
"workspace_slug": slug,
"source_project_id": str(project_id),
"request": request,
},
)
return Response(response_serializer.data, status=status.HTTP_200_OK)

View File

@ -21,7 +21,7 @@ from plane.api.serializers import (
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 FileAsset, Intake, IntakeIssue, Label, Project, ProjectMember, State, StateGroup
from plane.db.models import FileAsset, Intake, IntakeIssue, Label, Notification, Project, ProjectMember, State, StateGroup
from plane.db.models.intake import IntakeIssueStatus, SourceType
from plane.settings.storage import S3Storage
from plane.utils.external_contours import create_external_contour_issue_comment
@ -61,7 +61,11 @@ class ExternalContourListCreateEndpoint(BaseAPIView):
)
def get(self, request, slug, project_id):
serializer = ExternalContourRequestSerializer(self.get_queryset(), many=True)
serializer = ExternalContourRequestSerializer(
self.get_queryset(),
many=True,
context={"request": request},
)
return Response(
{
"results": serializer.data,
@ -174,7 +178,8 @@ class ExternalContourListCreateEndpoint(BaseAPIView):
"issue__created_by",
)
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
.get(pk=intake_issue.id)
.get(pk=intake_issue.id),
context={"request": request},
)
return Response(response_serializer.data, status=status.HTTP_201_CREATED)
@ -273,14 +278,28 @@ class ExternalContourDetailEndpoint(BaseAPIView):
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label")
)
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())
def get(self, request, slug, project_id, request_id):
contour_request = get_object_or_404(self.get_queryset())
self.mark_request_notifications_read(request.user, contour_request)
serializer = ExternalContourRequestSerializer(
contour_request,
context={
"include_mirror_data": True,
"workspace_slug": slug,
"source_project_id": str(project_id),
"request": request,
},
)
return Response(serializer.data, status=status.HTTP_200_OK)
@ -366,6 +385,12 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
contour_request.save(update_fields=["extra", "updated_at"])
contour_request.refresh_from_db()
Notification.objects.filter(
receiver_id=request.user.id,
sender__startswith="in_app:external_contours:",
read_at__isnull=True,
data__issue__id=str(contour_request.id),
).update(read_at=timezone.now())
serializer = ExternalContourRequestSerializer(
IntakeIssue.objects.select_related(
"issue",
@ -379,6 +404,7 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
"include_mirror_data": True,
"workspace_slug": slug,
"source_project_id": str(project_id),
"request": request,
},
)
return Response(serializer.data, status=status.HTTP_200_OK)
@ -425,12 +451,19 @@ class ExternalContourReplyEndpoint(BaseAPIView):
return Response({"error": exc.message_dict if hasattr(exc, "message_dict") else str(exc)}, status=status.HTTP_400_BAD_REQUEST)
contour_request.refresh_from_db()
Notification.objects.filter(
receiver_id=request.user.id,
sender__startswith="in_app:external_contours:",
read_at__isnull=True,
data__issue__id=str(contour_request.id),
).update(read_at=timezone.now())
response_serializer = ExternalContourRequestSerializer(
contour_request,
context={
"include_mirror_data": True,
"workspace_slug": slug,
"source_project_id": str(project_id),
"request": request,
},
)
return Response(response_serializer.data, status=status.HTTP_200_OK)

View File

@ -65,6 +65,11 @@ export const ExternalContoursListItem = observer(function ExternalContoursListIt
{issue.project_detail?.identifier || "REQ"}-{issue.sequence_id}
</span>
{issue.project_detail?.name && <span className="truncate text-placeholder">{issue.project_detail.name}</span>}
{request.has_unread_updates && (
<Tooltip tooltipHeading={t("external_contours_page.list.unread_updates")} isMobile={isMobile}>
<span className="size-2 rounded-full bg-accent-primary" />
</Tooltip>
)}
</div>
<ExternalContourStatePill request={request} />
</div>

View File

@ -296,10 +296,11 @@ export default {
open: "Open",
closed: "Closed",
},
list: {
last_updated: "Last updated",
unassigned: "Unassigned",
},
list: {
last_updated: "Last updated",
unassigned: "Unassigned",
unread_updates: "New updates available",
},
empty_state: {
title: "External contours module is ready for the next stage",
description:

View File

@ -453,10 +453,11 @@ export default {
open: "Открытые",
closed: "Закрытые",
},
list: {
last_updated: "Последнее изменение",
unassigned: "Не назначено",
},
list: {
last_updated: "Последнее изменение",
unassigned: "Не назначено",
unread_updates: "Есть новые изменения",
},
empty_state: {
title: "Модуль внешних контуров подготовлен",
description:

View File

@ -56,6 +56,7 @@ export type TExternalContourMirroredActivity = {
export type TExternalContourRequest = {
created_at: string;
created_by: string | null;
has_unread_updates?: boolean;
id: string;
issue: TExternalContourIssue;
mirrored_activity?: TExternalContourMirroredActivity[];