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 ══════════ */}
-