diff --git a/backend/api/v1/requests_app/views.py b/backend/api/v1/requests_app/views.py
index 5fa86252..65581df2 100644
--- a/backend/api/v1/requests_app/views.py
+++ b/backend/api/v1/requests_app/views.py
@@ -13,6 +13,7 @@ from django.utils import timezone
from requests_app.enums import DAY_OFF_REQUEST_TYPES, RequestStatus, RequestType
from requests_app.models import Request as EmployeeRequest
from requests_app.models import RequestSettings
+from requests_app.notifications.read_service import RequestNotificationReadService
from communications import comments_helpers
from drf_spectacular.utils import OpenApiParameter, extend_schema
@@ -286,7 +287,11 @@ class RequestViewSet(viewsets.ModelViewSet):
from rest_framework.permissions import IsAuthenticated
return [IsAuthenticated()]
- if self.action in {"retrieve", "employee_statistics"}:
+ if self.action in {
+ "retrieve",
+ "employee_statistics",
+ "mark_notifications_read",
+ }:
return [CanViewRequest()]
if self.action == "destroy":
return [IsRequestAuthor(), NotFinalOrStaff()]
@@ -339,7 +344,13 @@ class RequestViewSet(viewsets.ModelViewSet):
) & ~Q(status=RequestStatus.DRAFT)
addressed_to_me = params.get("addressed_to_me") == "true"
- if addressed_to_me:
+ decision_required = params.get("decision_required") == "true"
+ if decision_required:
+ scope = Q(
+ recipients=user,
+ status=RequestStatus.PENDING,
+ )
+ elif addressed_to_me:
scope = incoming_scope
elif want_mine:
scope = Q(employee_id=user.id)
@@ -415,6 +426,24 @@ class RequestViewSet(viewsets.ModelViewSet):
return qs
+ def _get_scope_counts(self, user) -> dict[str, int]:
+ """Полные счётчики быстрых областей списка заявлений."""
+ incoming_scope = (
+ Q(recipients=user) | Q(cc_users=user)
+ ) & ~Q(status=RequestStatus.DRAFT)
+ visible_scope = Q(employee_id=user.id) | incoming_scope
+ base = EmployeeRequest.objects.filter(visible_scope).distinct()
+
+ return {
+ "all": base.count(),
+ "mine": base.filter(employee_id=user.id).count(),
+ "addressed": base.filter(incoming_scope).count(),
+ "decision": base.filter(
+ recipients=user,
+ status=RequestStatus.PENDING,
+ ).count(),
+ }
+
def _attach_linked_task_payloads(
self,
requests: list[EmployeeRequest],
@@ -472,17 +501,20 @@ class RequestViewSet(viewsets.ModelViewSet):
def list(self, request, *args, **kwargs):
queryset = self.filter_queryset(self.get_queryset())
+ scope_counts = self._get_scope_counts(request.user)
page = self.paginate_queryset(queryset)
if page is not None:
self._attach_linked_task_payloads(list(page))
serializer = self.get_serializer(page, many=True)
- return self.get_paginated_response(serializer.data)
+ response = self.get_paginated_response(serializer.data)
+ response.data["scope_counts"] = scope_counts
+ return response
requests = list(queryset)
self._attach_linked_task_payloads(requests)
serializer = self.get_serializer(requests, many=True)
- return Response(serializer.data)
+ return Response({"results": serializer.data, "scope_counts": scope_counts})
# --- ВАЖНО: не ловить 404 на detail-экшенах
# из-за урезанного get_queryset() ---
@@ -509,6 +541,7 @@ class RequestViewSet(viewsets.ModelViewSet):
"cancel",
"retrieve",
"employee_statistics",
+ "mark_notifications_read",
}:
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
lookup_value = self.kwargs[lookup_url_kwarg]
@@ -521,6 +554,22 @@ class RequestViewSet(viewsets.ModelViewSet):
return super().get_object()
+ @action(detail=True, methods=["post"], url_path="notifications/read")
+ def mark_notifications_read(self, request, pk=None):
+ """Отметить уведомления текущего пользователя по заявлению."""
+ employee_request = self.get_object()
+ result = RequestNotificationReadService().mark_request_notifications_as_read(
+ user=request.user,
+ employee_request=employee_request,
+ )
+ return Response(
+ {
+ "status": "success",
+ "count": result.count,
+ "notification_ids": result.notification_ids,
+ }
+ )
+
# ---------- CRUD ----------
def perform_create(self, serializer: RequestWriteSerializer) -> None:
diff --git a/backend/notifications/api/serializers.py b/backend/notifications/api/serializers.py
index 0f9dcc9b..a7582be5 100644
--- a/backend/notifications/api/serializers.py
+++ b/backend/notifications/api/serializers.py
@@ -51,6 +51,11 @@ class UnreadSummaryProcurementRequestSerializer(serializers.Serializer):
unread = serializers.IntegerField()
+class UnreadSummaryEmployeeRequestSerializer(serializers.Serializer):
+ request_id = serializers.IntegerField()
+ unread = serializers.IntegerField()
+
+
class UnreadSummaryProcurementItemCommentSerializer(serializers.Serializer):
item_id = serializers.IntegerField()
unread = serializers.IntegerField()
@@ -69,6 +74,7 @@ class UnreadSummaryRegulationDepartmentSerializer(serializers.Serializer):
class UnreadSummaryResponseSerializer(serializers.Serializer):
total = serializers.IntegerField()
verbs = UnreadSummaryVerbSerializer(many=True)
+ employee_requests = UnreadSummaryEmployeeRequestSerializer(many=True)
procurement_requests = UnreadSummaryProcurementRequestSerializer(many=True)
procurement_item_comments = UnreadSummaryProcurementItemCommentSerializer(
many=True,
diff --git a/backend/notifications/cache.py b/backend/notifications/cache.py
index f8a3e836..4dc4c972 100644
--- a/backend/notifications/cache.py
+++ b/backend/notifications/cache.py
@@ -7,7 +7,7 @@ UNREAD_SUMMARY_TIMEOUT = 60
def unread_summary_cache_key(user_id):
- return f"notifications:unread_summary:user:{user_id}"
+ return f"notifications:unread_summary:v2:user:{user_id}"
def _coerce_positive_int(value):
@@ -72,6 +72,52 @@ def _build_procurement_request_counts(user_id):
]
+def _build_employee_request_counts(user_id):
+ from django.contrib.contenttypes.models import ContentType
+ from requests_app.models import Request as EmployeeRequest
+
+ from .models import Notification
+
+ request_content_type_id = ContentType.objects.get_for_model(EmployeeRequest).id
+ counts = {}
+ notifications = Notification.objects.filter(
+ recipient_id=user_id,
+ unread=True,
+ deleted=False,
+ verb__startswith="request_",
+ ).only(
+ "data",
+ "target_content_type_id",
+ "target_object_id",
+ "action_object_content_type_id",
+ "action_object_object_id",
+ )
+
+ for notification in notifications:
+ data = notification.data if isinstance(notification.data, dict) else {}
+ request_id = _coerce_positive_int(data.get("request_id"))
+
+ if request_id is None and (
+ notification.target_content_type_id == request_content_type_id
+ ):
+ request_id = _coerce_positive_int(notification.target_object_id)
+
+ if request_id is None and (
+ notification.action_object_content_type_id == request_content_type_id
+ ):
+ request_id = _coerce_positive_int(
+ notification.action_object_object_id,
+ )
+
+ if request_id is not None:
+ counts[request_id] = counts.get(request_id, 0) + 1
+
+ return [
+ {"request_id": request_id, "unread": unread}
+ for request_id, unread in sorted(counts.items())
+ ]
+
+
def _build_procurement_item_comment_counts(user_id):
from .models import Notification
@@ -231,6 +277,7 @@ def build_unread_summary(user_id):
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_item_comments": _build_procurement_item_comment_counts(user_id),
"task_boards": _build_task_board_counts(user_id),
diff --git a/backend/requests_app/notifications/read_service.py b/backend/requests_app/notifications/read_service.py
new file mode 100644
index 00000000..0b8ebbdc
--- /dev/null
+++ b/backend/requests_app/notifications/read_service.py
@@ -0,0 +1,73 @@
+from dataclasses import dataclass
+
+from django.contrib.contenttypes.models import ContentType
+from django.db import transaction
+from django.db.models import Q
+from django.utils import timezone
+
+from notifications.cache import invalidate_unread_summary
+from notifications.models import Notification
+from notifications.realtime import send_notifications_read_all_event
+from requests_app.models import Request as EmployeeRequest
+
+
+@dataclass(frozen=True)
+class RequestNotificationReadResult:
+ notification_ids: list[int]
+ count: int
+
+
+class RequestNotificationReadService:
+ """Отмечает прочитанными уведомления только выбранного заявления."""
+
+ def mark_request_notifications_as_read(
+ self,
+ *,
+ user,
+ employee_request: EmployeeRequest,
+ ) -> RequestNotificationReadResult:
+ request_content_type = ContentType.objects.get_for_model(EmployeeRequest)
+ request_id = employee_request.id
+ request_id_text = str(request_id)
+
+ queryset = (
+ Notification.objects.filter(
+ recipient_id=user.id,
+ unread=True,
+ deleted=False,
+ verb__startswith="request_",
+ )
+ .filter(
+ Q(data__request_id=request_id)
+ | Q(data__request_id=request_id_text)
+ | Q(
+ target_content_type=request_content_type,
+ target_object_id=request_id_text,
+ )
+ | Q(
+ action_object_content_type=request_content_type,
+ action_object_object_id=request_id_text,
+ )
+ )
+ .distinct()
+ )
+ notification_ids = list(queryset.values_list("id", flat=True))
+
+ if not notification_ids:
+ return RequestNotificationReadResult(notification_ids=[], count=0)
+
+ count = Notification.objects.filter(id__in=notification_ids).update(
+ unread=False,
+ timestamp_read=timezone.now(),
+ )
+ invalidate_unread_summary(user.id)
+
+ def notify_after_commit():
+ invalidate_unread_summary(user.id)
+ send_notifications_read_all_event(user.id, notification_ids)
+
+ transaction.on_commit(notify_after_commit)
+ return RequestNotificationReadResult(
+ notification_ids=notification_ids,
+ count=count,
+ )
diff --git a/backend/tests/api/v1/notifications/test_notifications_api.py b/backend/tests/api/v1/notifications/test_notifications_api.py
index 8a320bb6..70eaec19 100644
--- a/backend/tests/api/v1/notifications/test_notifications_api.py
+++ b/backend/tests/api/v1/notifications/test_notifications_api.py
@@ -45,6 +45,18 @@ class TestNotificationUnreadSummary:
verb="chat_new_message",
description="Сообщение",
)
+ Notification.objects.create(
+ recipient=user,
+ verb="request_new",
+ description="Новое заявление",
+ data={"request_id": 31},
+ )
+ Notification.objects.create(
+ recipient=user,
+ verb="request_comment",
+ description="Комментарий к заявлению",
+ data={"request_id": "31"},
+ )
Notification.objects.create(
recipient=user,
verb="task_assigned",
@@ -102,7 +114,7 @@ class TestNotificationUnreadSummary:
response = api_client.get("/api/v1/notifications/summary/")
assert response.status_code == 200
- assert response.data["total"] == 10
+ assert response.data["total"] == 12
assert {
item["verb"]: item["unread"]
for item in response.data["verbs"]
@@ -110,12 +122,17 @@ class TestNotificationUnreadSummary:
"procurement_department_request": 2,
"procurement_item_commented": 1,
"chat_new_message": 1,
+ "request_new": 1,
+ "request_comment": 1,
"task_assigned": 1,
"task_comment": 1,
"task_board_member_added": 1,
"regulation_ready": 2,
"regulation_signed_all": 1,
}
+ assert response.data["employee_requests"] == [
+ {"request_id": 31, "unread": 2},
+ ]
assert response.data["procurement_requests"] == [
{"request_id": 10, "unread": 2},
{"request_id": 12, "unread": 1},
diff --git a/backend/tests/api/v1/requests_app/test_requests_api.py b/backend/tests/api/v1/requests_app/test_requests_api.py
index 0763a79e..dc4eef31 100644
--- a/backend/tests/api/v1/requests_app/test_requests_api.py
+++ b/backend/tests/api/v1/requests_app/test_requests_api.py
@@ -47,6 +47,59 @@ def test_list_unauth_401(api_client: APIClient) -> None:
assert resp.status_code == 401
+def test_marks_only_opened_request_notifications_read(
+ auth_client,
+ regular_user: models.Model,
+ make_request,
+) -> None:
+ """Открытие заявления читает уведомления только этого заявления."""
+ employee_request = make_request(employee=regular_user)
+ other_request = make_request(employee=regular_user)
+ request_content_type = ContentType.objects.get_for_model(EmployeeRequest)
+
+ matching_by_data = Notification.objects.create(
+ recipient=regular_user,
+ verb="request_new",
+ data={"request_id": employee_request.id},
+ )
+ matching_by_action_object = Notification.objects.create(
+ recipient=regular_user,
+ verb="request_comment",
+ action_object_content_type=request_content_type,
+ action_object_object_id=str(employee_request.id),
+ data={},
+ )
+ other_request_notification = Notification.objects.create(
+ recipient=regular_user,
+ verb="request_status_changed",
+ data={"request_id": other_request.id},
+ )
+ unrelated_notification = Notification.objects.create(
+ recipient=regular_user,
+ verb="chat_new_message",
+ data={"request_id": employee_request.id},
+ )
+
+ response = auth_client(regular_user).post(
+ f"{API_BASE}{employee_request.id}/notifications/read/",
+ )
+
+ assert response.status_code == 200
+ assert response.data["count"] == 2
+ assert sorted(response.data["notification_ids"]) == sorted(
+ [matching_by_data.id, matching_by_action_object.id]
+ )
+
+ matching_by_data.refresh_from_db()
+ matching_by_action_object.refresh_from_db()
+ other_request_notification.refresh_from_db()
+ unrelated_notification.refresh_from_db()
+ assert matching_by_data.unread is False
+ assert matching_by_action_object.unread is False
+ assert other_request_notification.unread is True
+ assert unrelated_notification.unread is True
+
+
def test_statistics_requires_privileged_access(
auth_client, regular_user: models.Model, make_user
) -> None:
@@ -227,6 +280,47 @@ def test_list_addressed_to_me_shows_only_recipient_and_cc_requests(
assert cc_request.id in ids
+def test_list_returns_scope_counts_and_filters_decisions(
+ auth_client, regular_user: models.Model, make_user, make_request
+) -> None:
+ foreign_user = make_user(email="scope-counts-owner@example.com")
+ own_request = make_request(employee=regular_user)
+ decision_request = make_request(
+ employee=foreign_user,
+ status=RequestStatus.PENDING,
+ )
+ copied_request = make_request(
+ employee=foreign_user,
+ status=RequestStatus.APPROVED,
+ approver=foreign_user,
+ )
+ hidden_request = make_request(employee=foreign_user)
+
+ decision_request.recipients.add(regular_user)
+ copied_request.cc_users.add(regular_user)
+
+ client = auth_client(regular_user)
+ response = client.get(API_BASE)
+
+ assert response.status_code == 200
+ assert response.json()["scope_counts"] == {
+ "all": 3,
+ "mine": 1,
+ "addressed": 2,
+ "decision": 1,
+ }
+
+ decision_response = client.get(f"{API_BASE}?decision_required=true")
+ assert decision_response.status_code == 200
+ decision_ids = {
+ item["id"] for item in _results(decision_response.json())
+ }
+ assert decision_ids == {decision_request.id}
+ assert own_request.id not in decision_ids
+ assert copied_request.id not in decision_ids
+ assert hidden_request.id not in decision_ids
+
+
def test_list_period_filter_matches_overlap_and_excludes_missing_period(
auth_client, regular_user: models.Model, make_request
) -> None:
diff --git a/frontend/src/app/procurement/page.tsx b/frontend/src/app/procurement/page.tsx
index 17a53d82..406fa6fb 100644
--- a/frontend/src/app/procurement/page.tsx
+++ b/frontend/src/app/procurement/page.tsx
@@ -1217,12 +1217,12 @@ export default function ProcurementPage() {
{/* ══════════ Wide master-detail workspace ══════════ */}
-