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

View File

@ -10,7 +10,7 @@ from .project import ProjectLiteSerializer
from .state import StateLiteSerializer from .state import StateLiteSerializer
from .user import UserLiteSerializer from .user import UserLiteSerializer
from plane.app.serializers.issue import LabelSerializer 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): class ExternalContourIssuePayloadSerializer(serializers.Serializer):
@ -149,6 +149,7 @@ class ExternalContourMirroredActivitySerializer(BaseSerializer):
class ExternalContourRequestSerializer(BaseSerializer): class ExternalContourRequestSerializer(BaseSerializer):
has_unread_updates = serializers.SerializerMethodField()
issue = ExternalContourIssueSerializer(read_only=True) issue = ExternalContourIssueSerializer(read_only=True)
mirrored_activity = serializers.SerializerMethodField() mirrored_activity = serializers.SerializerMethodField()
mirrored_attachments = serializers.SerializerMethodField() mirrored_attachments = serializers.SerializerMethodField()
@ -172,6 +173,7 @@ class ExternalContourRequestSerializer(BaseSerializer):
"created_at", "created_at",
"updated_at", "updated_at",
"created_by", "created_by",
"has_unread_updates",
"issue", "issue",
"mirrored_activity", "mirrored_activity",
"mirrored_attachments", "mirrored_attachments",
@ -193,6 +195,20 @@ class ExternalContourRequestSerializer(BaseSerializer):
def get_source_project_id(self, obj): def get_source_project_id(self, obj):
return obj.extra.get("source_project_id") 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): def get_mirrored_activity(self, obj):
if not self.context.get("include_mirror_data") or not obj.issue_id: if not self.context.get("include_mirror_data") or not obj.issue_id:
return [] return []

View File

@ -21,7 +21,7 @@ from plane.api.serializers import (
from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer from plane.api.serializers.issue import IssueSerializer as IssueCreateSerializer
from plane.app.permissions import ProjectLitePermission from plane.app.permissions import ProjectLitePermission
from plane.app.views.base import BaseAPIView 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.db.models.intake import IntakeIssueStatus, SourceType
from plane.settings.storage import S3Storage from plane.settings.storage import S3Storage
from plane.utils.external_contours import create_external_contour_issue_comment 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): 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( return Response(
{ {
"results": serializer.data, "results": serializer.data,
@ -174,7 +178,8 @@ class ExternalContourListCreateEndpoint(BaseAPIView):
"issue__created_by", "issue__created_by",
) )
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label") .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) 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") .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): def get(self, request, slug, project_id, request_id):
contour_request = get_object_or_404(self.get_queryset()) contour_request = get_object_or_404(self.get_queryset())
self.mark_request_notifications_read(request.user, contour_request)
serializer = ExternalContourRequestSerializer( serializer = ExternalContourRequestSerializer(
contour_request, contour_request,
context={ context={
"include_mirror_data": True, "include_mirror_data": True,
"workspace_slug": slug, "workspace_slug": slug,
"source_project_id": str(project_id), "source_project_id": str(project_id),
"request": request,
}, },
) )
return Response(serializer.data, status=status.HTTP_200_OK) 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.save(update_fields=["extra", "updated_at"])
contour_request.refresh_from_db() 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( serializer = ExternalContourRequestSerializer(
IntakeIssue.objects.select_related( IntakeIssue.objects.select_related(
"issue", "issue",
@ -379,6 +404,7 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
"include_mirror_data": True, "include_mirror_data": True,
"workspace_slug": slug, "workspace_slug": slug,
"source_project_id": str(project_id), "source_project_id": str(project_id),
"request": request,
}, },
) )
return Response(serializer.data, status=status.HTTP_200_OK) 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) 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() 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( response_serializer = ExternalContourRequestSerializer(
contour_request, contour_request,
context={ context={
"include_mirror_data": True, "include_mirror_data": True,
"workspace_slug": slug, "workspace_slug": slug,
"source_project_id": str(project_id), "source_project_id": str(project_id),
"request": request,
}, },
) )
return Response(response_serializer.data, status=status.HTTP_200_OK) 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.api.serializers.issue import IssueSerializer as IssueCreateSerializer
from plane.app.permissions import ProjectLitePermission from plane.app.permissions import ProjectLitePermission
from plane.app.views.base import BaseAPIView 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.db.models.intake import IntakeIssueStatus, SourceType
from plane.settings.storage import S3Storage from plane.settings.storage import S3Storage
from plane.utils.external_contours import create_external_contour_issue_comment 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): 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( return Response(
{ {
"results": serializer.data, "results": serializer.data,
@ -174,7 +178,8 @@ class ExternalContourListCreateEndpoint(BaseAPIView):
"issue__created_by", "issue__created_by",
) )
.prefetch_related("issue__issue_assignee__assignee", "issue__label_issue__label") .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) 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") .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): def get(self, request, slug, project_id, request_id):
contour_request = get_object_or_404(self.get_queryset()) contour_request = get_object_or_404(self.get_queryset())
self.mark_request_notifications_read(request.user, contour_request)
serializer = ExternalContourRequestSerializer( serializer = ExternalContourRequestSerializer(
contour_request, contour_request,
context={ context={
"include_mirror_data": True, "include_mirror_data": True,
"workspace_slug": slug, "workspace_slug": slug,
"source_project_id": str(project_id), "source_project_id": str(project_id),
"request": request,
}, },
) )
return Response(serializer.data, status=status.HTTP_200_OK) 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.save(update_fields=["extra", "updated_at"])
contour_request.refresh_from_db() 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( serializer = ExternalContourRequestSerializer(
IntakeIssue.objects.select_related( IntakeIssue.objects.select_related(
"issue", "issue",
@ -379,6 +404,7 @@ class ExternalContourDecisionEndpoint(BaseAPIView):
"include_mirror_data": True, "include_mirror_data": True,
"workspace_slug": slug, "workspace_slug": slug,
"source_project_id": str(project_id), "source_project_id": str(project_id),
"request": request,
}, },
) )
return Response(serializer.data, status=status.HTTP_200_OK) 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) 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() 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( response_serializer = ExternalContourRequestSerializer(
contour_request, contour_request,
context={ context={
"include_mirror_data": True, "include_mirror_data": True,
"workspace_slug": slug, "workspace_slug": slug,
"source_project_id": str(project_id), "source_project_id": str(project_id),
"request": request,
}, },
) )
return Response(response_serializer.data, status=status.HTTP_200_OK) 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} {issue.project_detail?.identifier || "REQ"}-{issue.sequence_id}
</span> </span>
{issue.project_detail?.name && <span className="truncate text-placeholder">{issue.project_detail.name}</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> </div>
<ExternalContourStatePill request={request} /> <ExternalContourStatePill request={request} />
</div> </div>

View File

@ -299,6 +299,7 @@ export default {
list: { list: {
last_updated: "Last updated", last_updated: "Last updated",
unassigned: "Unassigned", unassigned: "Unassigned",
unread_updates: "New updates available",
}, },
empty_state: { empty_state: {
title: "External contours module is ready for the next stage", title: "External contours module is ready for the next stage",

View File

@ -456,6 +456,7 @@ export default {
list: { list: {
last_updated: "Последнее изменение", last_updated: "Последнее изменение",
unassigned: "Не назначено", unassigned: "Не назначено",
unread_updates: "Есть новые изменения",
}, },
empty_state: { empty_state: {
title: "Модуль внешних контуров подготовлен", title: "Модуль внешних контуров подготовлен",

View File

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