Track unread subscription notifications separately
All checks were successful
Deploy production / deploy (push) Successful in 12m7s

This commit is contained in:
admin i 2026-11-03 12:28:53 +03:00
parent 47bf0cbcb9
commit 6860bdba15
6 changed files with 144 additions and 49 deletions

View File

@ -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,
)

View File

@ -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"],

View File

@ -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

View File

@ -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"}`}
>
Подписки
<span
className={`app-badge px-1.5 py-0.5 text-[10px] font-bold ${
scope === "subscriptions"
? "app-pill-count-active"
: "app-pill-count"
}`}
>
{scopeCounts.subscriptions ?? 0}
</span>
{unreadProcurementSubscriptionCount > 0 && (
<span
className={`app-badge px-1.5 py-0.5 text-[10px] font-bold ${
scope === "subscriptions"
? "app-pill-count-active"
: "app-pill-count"
}`}
>
{unreadProcurementSubscriptionCount}
</span>
)}
</button>
</HorizontalChipScroller>
@ -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 (
<div
@ -2253,13 +2258,13 @@ export default function ProcurementPage() {
const urg = urgencyMeta[req.urgency] ?? urgencyMeta.medium;
const isAuthor = Boolean(
resolveUserId(req.requestor) &&
user?.id &&
resolveUserId(req.requestor) === user.id,
user?.id &&
resolveUserId(req.requestor) === user.id,
);
const isExecutor = Boolean(
resolveUserId(req.executor) &&
user?.id &&
resolveUserId(req.executor) === user.id,
user?.id &&
resolveUserId(req.executor) === user.id,
);
const isEditableStatus =
(st === "draft" || st === "waiting") && !req.executor;
@ -2267,7 +2272,7 @@ export default function ProcurementPage() {
const resolvedDetail = detail || req;
const canApproveThis = Boolean(
resolvedDetail.can_current_user_approve ??
req.can_current_user_approve,
req.can_current_user_approve,
);
const canSubmitThis =
canCurrentUserSubmitRequest(resolvedDetail);
@ -2284,11 +2289,11 @@ export default function ProcurementPage() {
);
const hasInlineRequestActions = Boolean(
canSubmitThis ||
canApproveThis ||
(resolvedDetail.can_current_user_start_work ??
req.can_current_user_start_work) ||
(st === "in_progress" && isExecutor) ||
canConfirmAllReceivedThis,
canApproveThis ||
(resolvedDetail.can_current_user_start_work ??
req.can_current_user_start_work) ||
(st === "in_progress" && isExecutor) ||
canConfirmAllReceivedThis,
);
const expanded = expandedIds.has(req.id);
const comments = commentsMap[req.id] || [];
@ -2894,9 +2899,9 @@ export default function ProcurementPage() {
(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 (
<div
@ -2948,7 +2953,7 @@ export default function ProcurementPage() {
busyKey={busyKey}
canDeleteAnyComment={Boolean(
user?.auth?.is_staff ||
user?.auth?.is_superuser,
user?.auth?.is_superuser,
)}
onUpdateItem={handleUpdateItem}
onReportItemIssue={handleReportItemIssue}
@ -3053,13 +3058,13 @@ 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
@ -3259,9 +3264,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 (
<div

View File

@ -56,6 +56,7 @@ interface NotificationsContextType {
unreadCategoryCounts: Record<string, number>;
unreadEmployeeRequestCounts: Record<number, number>;
unreadProcurementRequestCounts: Record<number, number>;
unreadProcurementSubscriptionCount: number;
unreadProcurementItemCommentCounts: Record<number, number>;
unreadTaskBoardCounts: Record<number, number>;
unreadRegulationDepartmentCounts: Record<number, number>;
@ -94,6 +95,10 @@ export function NotificationsProvider({
useState<Record<number, number>>({});
const [unreadProcurementRequestCounts, setUnreadProcurementRequestCounts] =
useState<Record<number, number>>({});
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,

View File

@ -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,