Track unread subscription notifications separately
All checks were successful
Deploy production / deploy (push) Successful in 12m7s
All checks were successful
Deploy production / deploy (push) Successful in 12m7s
This commit is contained in:
parent
47bf0cbcb9
commit
6860bdba15
@ -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,
|
||||
)
|
||||
|
||||
@ -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"],
|
||||
|
||||
60
backend/tests/notifications/test_subscription_unread.py
Normal file
60
backend/tests/notifications/test_subscription_unread.py
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user