From 6860bdba156a1e0347be989f3f3e4bad263b2769 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Tue, 3 Nov 2026 12:28:53 +0300 Subject: [PATCH] Track unread subscription notifications separately --- backend/notifications/api/serializers.py | 1 + backend/notifications/cache.py | 18 +++- .../notifications/test_subscription_unread.py | 60 +++++++++++ frontend/src/app/procurement/page.tsx | 99 ++++++++++--------- .../src/contexts/NotificationsContext.tsx | 13 +++ frontend/src/hooks/useProcurementPage.ts | 2 + 6 files changed, 144 insertions(+), 49 deletions(-) create mode 100644 backend/tests/notifications/test_subscription_unread.py diff --git a/backend/notifications/api/serializers.py b/backend/notifications/api/serializers.py index eba36e1e..64665c83 100644 --- a/backend/notifications/api/serializers.py +++ b/backend/notifications/api/serializers.py @@ -78,6 +78,7 @@ class UnreadSummaryResponseSerializer(serializers.Serializer): verbs = UnreadSummaryVerbSerializer(many=True) employee_requests = UnreadSummaryEmployeeRequestSerializer(many=True) procurement_requests = UnreadSummaryProcurementRequestSerializer(many=True) + procurement_subscription_unread = serializers.IntegerField() procurement_item_comments = UnreadSummaryProcurementItemCommentSerializer( many=True, ) diff --git a/backend/notifications/cache.py b/backend/notifications/cache.py index e5cad9c5..b29059c5 100644 --- a/backend/notifications/cache.py +++ b/backend/notifications/cache.py @@ -8,7 +8,7 @@ logger = logging.getLogger(__name__) def unread_summary_cache_key(user_id): - return f"notifications:unread_summary:v2:user:{user_id}" + return f"notifications:unread_summary:v3:user:{user_id}" def _coerce_positive_int(value): @@ -258,6 +258,8 @@ def _build_regulation_counts(user_id): def build_unread_summary(user_id): + from procurement.models import ProcurementRequest + from .models import Notification rows = ( @@ -272,11 +274,23 @@ def build_unread_summary(user_id): ) verbs = [{"verb": row["verb"], "unread": row["unread"]} for row in rows] regulation_counts = _build_regulation_counts(user_id) + procurement_counts = _build_procurement_request_counts(user_id) + subscription_ids = set( + ProcurementRequest.objects.filter( + id__in=[item["request_id"] for item in procurement_counts], + request_type=ProcurementRequest.RequestType.SUBSCRIPTION, + ).values_list("id", flat=True) + ) return { "total": sum(item["unread"] for item in verbs), "verbs": verbs, "employee_requests": _build_employee_request_counts(user_id), - "procurement_requests": _build_procurement_request_counts(user_id), + "procurement_requests": procurement_counts, + "procurement_subscription_unread": sum( + item["unread"] + for item in procurement_counts + if item["request_id"] in subscription_ids + ), "procurement_item_comments": _build_procurement_item_comment_counts(user_id), "task_boards": _build_task_board_counts(user_id), "regulation_departments": regulation_counts["departments"], diff --git a/backend/tests/notifications/test_subscription_unread.py b/backend/tests/notifications/test_subscription_unread.py new file mode 100644 index 00000000..4d60ff17 --- /dev/null +++ b/backend/tests/notifications/test_subscription_unread.py @@ -0,0 +1,60 @@ +import pytest + +from notifications.cache import build_unread_summary +from notifications.models import Notification +from procurement.models import ProcurementRequest +from procurement.notifications.read_service import ProcurementNotificationReadService + + +@pytest.mark.django_db +def test_subscription_unread_count_and_read_isolation(user_factory, department_factory): + user = user_factory() + other_user = user_factory() + department = department_factory(name="Тестовый отдел") + standard = ProcurementRequest.objects.create( + title="Закупка", department=department, requestor=user + ) + subscription = ProcurementRequest.objects.create( + title="Подписка", + department=department, + requestor=user, + request_type=ProcurementRequest.RequestType.SUBSCRIPTION, + ) + Notification.objects.bulk_create( + [ + Notification( + recipient=user, + verb="procurement_department_request", + data={"request_id": standard.id}, + ), + Notification( + recipient=user, + verb="procurement_pending_approval", + data={"request_id": subscription.id}, + ), + Notification( + recipient=user, + verb="procurement_department_request", + data={"request_id": str(subscription.id)}, + ), + Notification( + recipient=user, + verb="procurement_approved", + data={"request_id": subscription.id}, + unread=False, + ), + Notification( + recipient=other_user, + verb="procurement_approved", + data={"request_id": subscription.id}, + ), + ] + ) + assert build_unread_summary(user.id)["procurement_subscription_unread"] == 2 + service = ProcurementNotificationReadService() + service.mark_request_notifications_as_read(user=user, procurement_request=standard) + assert build_unread_summary(user.id)["procurement_subscription_unread"] == 2 + service.mark_request_notifications_as_read( + user=user, procurement_request=subscription + ) + assert build_unread_summary(user.id)["procurement_subscription_unread"] == 0 diff --git a/frontend/src/app/procurement/page.tsx b/frontend/src/app/procurement/page.tsx index cc7d43a4..bc80abc9 100644 --- a/frontend/src/app/procurement/page.tsx +++ b/frontend/src/app/procurement/page.tsx @@ -159,9 +159,9 @@ const canConfirmAllReceived = ( ); return Boolean( isAuthor && - itemsCount > 0 && - request.fulfillment_status !== "completed" && - ["waiting", "in_progress"].includes(status), + itemsCount > 0 && + request.fulfillment_status !== "completed" && + ["waiting", "in_progress"].includes(status), ); }; @@ -308,7 +308,7 @@ function ProcurementRequestActionButtons({ const isInProgress = status === "in_progress"; const canNotifyArrival = Boolean( request.can_current_user_process_items && - (status === "waiting" || status === "in_progress"), + (status === "waiting" || status === "in_progress"), ); const canConfirmRequestReceived = canConfirmAllReceived(request, isAuthor); const startWorkLabel = request.executor @@ -626,6 +626,7 @@ export default function ProcurementPage() { scope, scopeCounts, unreadProcurementRequestCounts, + unreadProcurementSubscriptionCount, unreadProcurementItemCommentCounts, handleAddComment, handleDeleteComment, @@ -662,9 +663,9 @@ export default function ProcurementPage() { return Boolean( request.can_current_user_submit_for_approval && - user?.id && - processingDepartmentId && - isProcessingDepartmentMember, + user?.id && + processingDepartmentId && + isProcessingDepartmentMember, ); }, [user?.id, userDepartmentIds, userDepartmentsKnown], @@ -972,7 +973,8 @@ export default function ProcurementPage() { syncRequestUrl(requestId); try { - await ensureRequestDetail(requestId); + const detail = await ensureRequestDetail(requestId); + if (detail.request_type === "subscription") setScope("subscriptions"); await markRequestNotificationsRead(requestId); } catch (detailError) { setDetailModalError( @@ -986,6 +988,7 @@ export default function ProcurementPage() { desktopWideLayout, ensureRequestDetail, markRequestNotificationsRead, + setScope, syncRequestUrl, ], ); @@ -1218,15 +1221,17 @@ export default function ProcurementPage() { className={`inline-flex h-8 items-center gap-1.5 rounded-full px-3 text-xs font-medium transition ${scope === "subscriptions" ? "app-pill-active" : "app-pill"}`} > Подписки - - {scopeCounts.subscriptions ?? 0} - + {unreadProcurementSubscriptionCount > 0 && ( + + {unreadProcurementSubscriptionCount} + + )} @@ -2070,15 +2075,15 @@ export default function ProcurementPage() { )} isAuthor={Boolean( resolveUserId(selectedRequest.requestor) && - user?.id && - resolveUserId(selectedRequest.requestor) === - user.id, + user?.id && + resolveUserId(selectedRequest.requestor) === + user.id, )} isExecutor={Boolean( resolveUserId(selectedRequest.executor) && - user?.id && - resolveUserId(selectedRequest.executor) === - user.id, + user?.id && + resolveUserId(selectedRequest.executor) === + user.id, )} isFinal={isFinal} showLabels @@ -2175,9 +2180,9 @@ export default function ProcurementPage() { ).map((comment) => { const canDeleteCommentThis = Boolean( comment.author?.id && - (user?.id === comment.author.id || - user?.auth?.is_staff || - user?.auth?.is_superuser), + (user?.id === comment.author.id || + user?.auth?.is_staff || + user?.auth?.is_superuser), ); return (
{ const canDeleteCommentThis = Boolean( comment.author?.id && - (user?.id === comment.author.id || - user?.auth?.is_staff || - user?.auth?.is_superuser), + (user?.id === comment.author.id || + user?.auth?.is_staff || + user?.auth?.is_superuser), ); return (
{ const canDeleteCommentThis = Boolean( comment.author?.id && - (user?.id === comment.author.id || - user?.auth?.is_staff || - user?.auth?.is_superuser), + (user?.id === comment.author.id || + user?.auth?.is_staff || + user?.auth?.is_superuser), ); return (
; unreadEmployeeRequestCounts: Record; unreadProcurementRequestCounts: Record; + unreadProcurementSubscriptionCount: number; unreadProcurementItemCommentCounts: Record; unreadTaskBoardCounts: Record; unreadRegulationDepartmentCounts: Record; @@ -94,6 +95,10 @@ export function NotificationsProvider({ useState>({}); const [unreadProcurementRequestCounts, setUnreadProcurementRequestCounts] = useState>({}); + const [ + unreadProcurementSubscriptionCount, + setUnreadProcurementSubscriptionCount, + ] = useState(0); const [ unreadProcurementItemCommentCounts, setUnreadProcurementItemCommentCounts, @@ -124,6 +129,7 @@ export function NotificationsProvider({ verbs?: Array<{ verb?: string; unread?: number }>; employee_requests?: Array<{ request_id?: number; unread?: number }>; procurement_requests?: Array<{ request_id?: number; unread?: number }>; + procurement_subscription_unread?: number; procurement_item_comments?: Array<{ item_id?: number; unread?: number }>; task_boards?: Array<{ board_id?: number; unread?: number }>; regulation_departments?: Array<{ @@ -237,6 +243,9 @@ export function NotificationsProvider({ setUnreadCategoryCounts(nextCategoryCounts); setUnreadEmployeeRequestCounts(nextEmployeeRequestCounts); setUnreadProcurementRequestCounts(nextProcurementRequestCounts); + setUnreadProcurementSubscriptionCount( + summary.procurement_subscription_unread ?? 0, + ); setUnreadProcurementItemCommentCounts(nextProcurementItemCommentCounts); setUnreadTaskBoardCounts(nextTaskBoardCounts); setUnreadRegulationDepartmentCounts(nextRegulationDepartmentCounts); @@ -349,6 +358,7 @@ export function NotificationsProvider({ setUnreadCategoryCounts({}); setUnreadEmployeeRequestCounts({}); setUnreadProcurementRequestCounts({}); + setUnreadProcurementSubscriptionCount(0); setUnreadProcurementItemCommentCounts({}); setUnreadTaskBoardCounts({}); setUnreadRegulationDepartmentCounts({}); @@ -618,6 +628,7 @@ export function NotificationsProvider({ setUnreadCategoryCounts({}); setUnreadEmployeeRequestCounts({}); setUnreadProcurementRequestCounts({}); + setUnreadProcurementSubscriptionCount(0); setUnreadProcurementItemCommentCounts({}); setUnreadTaskBoardCounts({}); setUnreadRegulationDepartmentCounts({}); @@ -651,6 +662,7 @@ export function NotificationsProvider({ } if (category === "Закупки") { setUnreadProcurementRequestCounts({}); + setUnreadProcurementSubscriptionCount(0); setUnreadProcurementItemCommentCounts({}); } if (category === "Доска") { @@ -711,6 +723,7 @@ export function NotificationsProvider({ unreadCategoryCounts, unreadEmployeeRequestCounts, unreadProcurementRequestCounts, + unreadProcurementSubscriptionCount, unreadProcurementItemCommentCounts, unreadTaskBoardCounts, unreadRegulationDepartmentCounts, diff --git a/frontend/src/hooks/useProcurementPage.ts b/frontend/src/hooks/useProcurementPage.ts index f1a28977..723c889d 100644 --- a/frontend/src/hooks/useProcurementPage.ts +++ b/frontend/src/hooks/useProcurementPage.ts @@ -170,6 +170,7 @@ export function useProcurementPage(user: User | null) { const { refreshUnreadSummary, unreadProcurementRequestCounts, + unreadProcurementSubscriptionCount, unreadProcurementItemCommentCounts, } = useNotifications(); const canManage = canManageRequests(user); @@ -1903,6 +1904,7 @@ export function useProcurementPage(user: User | null) { nextPage, scopeCounts, unreadProcurementRequestCounts, + unreadProcurementSubscriptionCount, unreadProcurementItemCommentCounts, scope, setScope,