From 32500e3037637976185fb75e246da8bf7270839a Mon Sep 17 00:00:00 2001 From: lizerk Date: Sat, 20 Jun 2026 10:47:00 +0300 Subject: [PATCH] chore: checkpoint current project changes --- backend/api/v1/employees/serializers/role.py | 25 +- backend/api/v1/employees/views/actions.py | 27 +- backend/api/v1/employees/views/roles.py | 72 ++-- backend/api/v1/procurement/permissions.py | 4 + backend/api/v1/procurement/serializers.py | 8 + backend/api/v1/procurement/views.py | 40 ++- backend/api/v1/requests_app/permissions.py | 157 +-------- .../api/v1/requests_app/permissions.py.orig | 219 ------------ backend/api/v1/requests_app/views.py.orig | 323 ------------------ .../services/chat_notifications.py | 10 +- backend/employees/admin.py | 71 ++-- backend/employees/constants.py | 9 +- backend/employees/ldap/admin.py | 5 + .../ldap/repositories/employee_repository.py | 6 + .../ldap/services/department_service.py | 20 +- ...053_cleanup_department_role_permissions.py | 59 ++++ backend/eusrr_backend/celery.py | 4 + backend/notifications/api/serializers.py | 16 + backend/notifications/api/urls.py | 1 + backend/notifications/api/views.py | 35 +- backend/notifications/cache.py | 129 +++++++ backend/notifications/models.py | 6 + backend/notifications/realtime.py | 8 +- backend/notifications/signals.py | 3 + backend/procurement/notifications/handlers.py | 42 ++- .../procurement/notifications/read_service.py | 101 ++++++ backend/procurement/services.py | 100 +++++- .../requests_app/notifications/handlers.py | 22 -- backend/requests_app/tasks.py | 39 +++ .../api/v1/employees/test_department_roles.py | 113 ++++++ .../api/v1/employees/test_role_assignment.py | 11 +- .../notifications/test_notifications_api.py | 86 +++++ .../procurement/test_procurement_requests.py | 235 +++++++++++++ .../requests_app/test_requests_departments.py | 21 +- .../test_department_permission_migration.py | 61 ++++ .../test_department_role_ldap_sync.py | 127 ++++++- .../requests_app/test_request_actions.py | 203 ++++++++++- frontend/src/app/departments/[id]/page.tsx | 291 ++++++++++++++-- frontend/src/app/globals.css | 16 +- frontend/src/app/procurement/page.tsx | 16 +- frontend/src/components/AppShell.tsx | 24 +- .../requests/RequestDetailModal.tsx | 2 +- .../components/requests/RequestListItem.tsx | 2 +- .../components/requests/SwipeApprovalMode.tsx | 2 +- .../src/contexts/NotificationsContext.tsx | 153 ++++++++- frontend/src/hooks/useDepartmentPage.ts | 49 ++- frontend/src/hooks/useProcurementPage.ts | 65 +++- frontend/src/lib/api/employees.ts | 8 +- frontend/src/lib/api/notifications.ts | 13 + frontend/src/lib/api/procurement.ts | 2 + frontend/src/types/api.ts | 2 + 51 files changed, 2137 insertions(+), 926 deletions(-) delete mode 100644 backend/api/v1/requests_app/permissions.py.orig delete mode 100644 backend/api/v1/requests_app/views.py.orig create mode 100644 backend/employees/migrations/0053_cleanup_department_role_permissions.py create mode 100644 backend/notifications/cache.py create mode 100644 backend/procurement/notifications/read_service.py diff --git a/backend/api/v1/employees/serializers/role.py b/backend/api/v1/employees/serializers/role.py index ed6e8f44..cc2777f8 100644 --- a/backend/api/v1/employees/serializers/role.py +++ b/backend/api/v1/employees/serializers/role.py @@ -30,6 +30,8 @@ class DepartmentRoleSerializer(serializers.ModelSerializer): permissions = serializers.SerializerMethodField(read_only=True) permissions_verbose = serializers.SerializerMethodField(read_only=True) + active_assignments_count = serializers.SerializerMethodField(read_only=True) + ldap_linked = serializers.SerializerMethodField(read_only=True) class Meta: model = DepartmentRole @@ -41,8 +43,10 @@ class DepartmentRoleSerializer(serializers.ModelSerializer): "scoped_permission_codes", "permissions", "permissions_verbose", + "active_assignments_count", + "ldap_linked", ) - read_only_fields = ("id",) + read_only_fields = ("id", "active_assignments_count", "ldap_linked") def _apply_codes_if_present( self, instance: DepartmentRole, validated_data: dict @@ -76,6 +80,7 @@ class DepartmentRoleSerializer(serializers.ModelSerializer): return role def update(self, instance, validated_data): + validated_data.pop("department", None) codes = validated_data.pop("scoped_permission_codes", None) perms = validated_data.pop("scoped_permissions", None) @@ -107,6 +112,24 @@ class DepartmentRoleSerializer(serializers.ModelSerializer): for p in obj.scoped_permissions.order_by("code").all() ] + @extend_schema_field(serializers.IntegerField()) + def get_active_assignments_count(self, obj: DepartmentRole): + employee_ids = set( + obj.assignments.filter(is_active=True).values_list( + "employee_id", flat=True + ) + ) + employee_ids.update( + obj.members.filter(is_active=True).values_list( + "employee_id", flat=True + ) + ) + return len(employee_ids) + + @extend_schema_field(serializers.BooleanField()) + def get_ldap_linked(self, obj: DepartmentRole): + return bool((obj.ldap_group_dn or "").strip()) + class GroupSerializer(serializers.ModelSerializer): permissions = serializers.PrimaryKeyRelatedField( diff --git a/backend/api/v1/employees/views/actions.py b/backend/api/v1/employees/views/actions.py index 3aa7f6da..0a9f63cc 100644 --- a/backend/api/v1/employees/views/actions.py +++ b/backend/api/v1/employees/views/actions.py @@ -11,7 +11,13 @@ from employees.constants import ( ACTIVATING_MARKER_ACTIONS, PERMANENT_ACTIONS, ) -from employees.models import Department, Employee, EmployeeAction, EmployeeDepartment +from employees.models import ( + Department, + Employee, + EmployeeAction, + EmployeeDepartment, + RoleAssignment, +) from employees.services.personnel_state import resolve_employee_personnel_state from rest_framework import filters, viewsets from rest_framework.permissions import IsAuthenticated @@ -102,6 +108,15 @@ class EmployeeActionViewSet(HistoryActionMixin, viewsets.ModelViewSet): if not self._action_affects_account(action_obj.action): return + if action_obj.date and action_obj.date.date() > timezone.localdate(): + logger.info( + "Skipping future EmployeeAction effects for action_id=%s " + "effective_at=%s", + action_obj.id, + action_obj.date.date(), + ) + return + self._sync_employee_account_state(action_obj.employee) @staticmethod @@ -116,6 +131,8 @@ class EmployeeActionViewSet(HistoryActionMixin, viewsets.ModelViewSet): emp.refresh_from_db(fields=["is_active"]) if not should_be_active: + dismissal_date = current_state.date_from or today + # Снимаем с должности руководителя (до деактивации!) headed_depts = list(Department.objects.filter(head=emp)) if headed_depts: @@ -143,8 +160,14 @@ class EmployeeActionViewSet(HistoryActionMixin, viewsets.ModelViewSet): employee=emp, is_active=True ): emp_dept.is_active = False - emp_dept.date_to = today + emp_dept.date_to = dismissal_date emp_dept.save(update_fields=["is_active", "date_to"]) + + for assignment in RoleAssignment.objects.filter( + employee=emp, is_active=True + ): + assignment.is_active = False + assignment.save(update_fields=["is_active"]) else: # Активность учетной записи определяется текущим постоянным # кадровым состоянием; временные события сами по себе не меняют diff --git a/backend/api/v1/employees/views/roles.py b/backend/api/v1/employees/views/roles.py index ef92b21c..fd26052d 100644 --- a/backend/api/v1/employees/views/roles.py +++ b/backend/api/v1/employees/views/roles.py @@ -2,9 +2,7 @@ from __future__ import annotations -from django.shortcuts import get_object_or_404 from employees.models import ( - Department, DepartmentPermission, DepartmentRole, DeptPerm, @@ -43,9 +41,35 @@ class DepartmentRoleViewSet(viewsets.ModelViewSet): required_code = DeptPerm.ASSIGN_ROLE + def has_permission(self, request, view) -> bool: + if getattr(view, "action", None) in { + "update", + "partial_update", + "destroy", + "set_perms", + "assign", + "revoke", + }: + user = getattr(request, "user", None) + return bool(user and user.is_authenticated) + return super().has_permission(request, view) + ordering_fields = ("name", "id") ordering = ("name", "id") + def _active_assignments_count(self, role: DepartmentRole) -> int: + employee_ids = set( + role.assignments.filter(is_active=True).values_list( + "employee_id", flat=True + ) + ) + employee_ids.update( + role.members.filter(is_active=True).values_list( + "employee_id", flat=True + ) + ) + return len(employee_ids) + def get_permissions(self): if self.action in { "create", @@ -78,27 +102,8 @@ class DepartmentRoleViewSet(viewsets.ModelViewSet): """Создание роли.""" ser = self.get_serializer(data=request.data) ser.is_valid(raise_exception=True) - - dept_id = ser.validated_data.get("department") - if isinstance(dept_id, Department): - dept = dept_id - else: - dept = get_object_or_404(Department, id=dept_id) - - name = ser.validated_data["name"] - codes = ser.validated_data.pop("scoped_permission_codes", None) - perms = ser.validated_data.pop("scoped_permissions", None) - - role = DepartmentRole.objects.create( - department=dept, - name=name, - ) - if codes is not None: - qs = DepartmentPermission.objects.filter(code__in=codes) - role.scoped_permissions.set(list(qs)) - elif perms is not None: - role.scoped_permissions.set(perms) - + self.perform_create(ser) + role = ser.instance return Response( self.get_serializer(role).data, status=status.HTTP_201_CREATED ) @@ -107,6 +112,12 @@ class DepartmentRoleViewSet(viewsets.ModelViewSet): """Обновление роли.""" partial = kwargs.pop("partial", False) instance = self.get_object() + raw_department = request.data.get("department") + if raw_department is not None and str(raw_department) != str(instance.department_id): + return Response( + {"department": ["Роль нельзя перенести в другой отдел."]}, + status=status.HTTP_400_BAD_REQUEST, + ) ser = self.get_serializer(instance, data=request.data, partial=partial) ser.is_valid(raise_exception=True) self.perform_update(ser) @@ -120,6 +131,21 @@ class DepartmentRoleViewSet(viewsets.ModelViewSet): def destroy(self, request, *args, **kwargs): """Удаление роли.""" instance = self.get_object() + active_assignments_count = self._active_assignments_count(instance) + force = str(request.query_params.get("force", "")).lower() in { + "1", + "true", + "yes", + } + if active_assignments_count and not force: + return Response( + { + "detail": "У роли есть активные назначения.", + "active_assignments_count": active_assignments_count, + "force_required": True, + }, + status=status.HTTP_409_CONFLICT, + ) instance.delete() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/backend/api/v1/procurement/permissions.py b/backend/api/v1/procurement/permissions.py index 26d2059c..d2a4e915 100644 --- a/backend/api/v1/procurement/permissions.py +++ b/backend/api/v1/procurement/permissions.py @@ -211,6 +211,10 @@ class CanManageProcurementRequest(permissions.BasePermission): if action == "comments": return True + # Умное прочтение только гасит уведомления текущего пользователя. + if action == "mark_notifications_read": + return True + # Удаление комментария дополнительно валидируется во view по автору/админу if action == "delete_comment": return True diff --git a/backend/api/v1/procurement/serializers.py b/backend/api/v1/procurement/serializers.py index b58e1cbe..a8ccd09b 100644 --- a/backend/api/v1/procurement/serializers.py +++ b/backend/api/v1/procurement/serializers.py @@ -1398,6 +1398,14 @@ class SupplierSerializer(serializers.ModelSerializer): read_only_fields = ["id", "created_at", "updated_at"] +class ProcurementNotificationReadResponseSerializer(serializers.Serializer): + status = serializers.CharField() + count = serializers.IntegerField() + notification_ids = serializers.ListField( + child=serializers.IntegerField(), + ) + + class ProcurementOverviewStatsSerializer(serializers.Serializer): total_requests = serializers.IntegerField() pending_requests = serializers.IntegerField() diff --git a/backend/api/v1/procurement/views.py b/backend/api/v1/procurement/views.py index 100cc9be..75c7b3f5 100644 --- a/backend/api/v1/procurement/views.py +++ b/backend/api/v1/procurement/views.py @@ -36,6 +36,9 @@ from procurement.notifications.handlers import ( notify_request_comment, ) from procurement.notifications.config import NotificationVerbs +from procurement.notifications.read_service import ( + ProcurementNotificationReadService, +) from communications import comments_helpers from communications.models import Message from procurement.services import ProcurementApprovalResolver @@ -55,6 +58,7 @@ from .serializers import ( ProcurementDepartmentStatsSerializer, ProcurementOverviewStatsSerializer, ProcurementItemSerializer, + ProcurementNotificationReadResponseSerializer, ProcurementRequestCreateSerializer, ProcurementRequestDetailSerializer, ProcurementRequestListSerializer, @@ -203,8 +207,8 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): # Только мои заявки (где я автор) queryset = queryset.filter(requestor=user) elif scope == "department": - # Заявки моего отдела - queryset = queryset.filter(department__in=user.departments.all()) + # Заявки отделов, где пользователь участник или role-only участник. + queryset = queryset.filter(department_id__in=participant_department_ids) elif scope == "processing_department": # Заявки, направленные в отделы, где пользователь участвует. queryset = queryset.filter( @@ -276,7 +280,7 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): # Показываем: свои заявки + заявки отдела + где я approver queryset = queryset.filter( Q(requestor=user) - | Q(department__in=user.departments.all()) + | Q(department_id__in=participant_department_ids) | Q( processing_department_id__in=participant_department_ids ) @@ -481,9 +485,11 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): return Response( { "error": ( - "У выбранного отдела не назначен руководитель. " + "У выбранного отдела не назначен руководитель " + "и нет активной роли с правом согласования. " "Заявку нельзя отправить на согласование, " - "пока не будет назначен начальник отдела." + "пока не будет назначен начальник отдела " + "или согласующая роль." ), "code": "department_head_missing", "missing_priorities": missing_priorities, @@ -542,6 +548,8 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): {"error": "У вас нет прав на согласование этой заявки"}, status=status.HTTP_403_FORBIDDEN, ) + if approval.approver_id != request.user.id: + approval.approver = request.user # Вышестоящий согласующий закрывает все предыдущие pending-этапы. procurement_request.approvals.filter( @@ -599,6 +607,8 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): {"error": "У вас нет прав на согласование этой заявки"}, status=status.HTTP_403_FORBIDDEN, ) + if approval.approver_id != request.user.id: + approval.approver = request.user # Вышестоящий согласующий закрывает предыдущие pending-этапы. procurement_request.approvals.filter( @@ -1005,6 +1015,26 @@ class ProcurementRequestViewSet(viewsets.ModelViewSet): serializer = self.get_serializer(procurement_request) return Response(serializer.data) + @extend_schema( + responses=ProcurementNotificationReadResponseSerializer, + summary="Отметить уведомления по заявке как прочитанные", + ) + @action(detail=True, methods=["post"], url_path="notifications/read") + def mark_notifications_read(self, request, pk=None): + """Отметить уведомления текущего пользователя по заявке как прочитанные.""" + procurement_request = self.get_object() + result = ProcurementNotificationReadService().mark_request_notifications_as_read( + user=request.user, + procurement_request=procurement_request, + ) + return Response( + { + "status": "success", + "count": result.count, + "notification_ids": result.notification_ids, + } + ) + @action(detail=True, methods=["post"]) def cancel(self, request, pk=None): """Отменить заявку.""" diff --git a/backend/api/v1/requests_app/permissions.py b/backend/api/v1/requests_app/permissions.py index ea39541c..ce2ff505 100644 --- a/backend/api/v1/requests_app/permissions.py +++ b/backend/api/v1/requests_app/permissions.py @@ -1,11 +1,8 @@ # api/v1/requests_app/permissions.py from __future__ import annotations -from typing import Any, Optional +from typing import Any -from api.v1.permissions import AdminOrDeptAllowed -from employees.constants import DeptPerm -from employees.models import Department from rest_framework.permissions import BasePermission from rest_framework.request import Request @@ -127,155 +124,3 @@ class NotFinalOrStaff(BasePermission): return True return not bool(getattr(obj, "is_final", False)) - -class DeptViewRequest(AdminOrDeptAllowed): - """Просмотр заявки по департаментным правам. - - Доступ разрешается, если пользователь — staff/superuser ИЛИ имеет - право отдела ``view_request`` именно для отдела данной заявки. - - Применяется только к detail-экшену `retrieve`. - - Attributes: - allow_safe_without_code (bool): Запрещает фоллбек пропуска SAFE-методов - без явного кода права (защита от «случайных 200»). - required_code_map (dict[str, str]): Ограничиваем право только - на `retrieve`. - required_code (Optional[str]): Явно отключаем общий fallback. - """ - - # SAFE-методы не должны проходить «на шару» - allow_safe_without_code: bool = False - - # Явно ограничиваем сферу действия права - required_code_map: dict[str, str] = { - "retrieve": "view_request", - } - - # Не даём базовому классу брать «любой экшен = view_request» - required_code: Optional[str] = None - - def get_required_code(self, request: Request, view: Any) -> Optional[str]: - """Возвращает код департаментного права - только для поддерживаемых action. - - Args: - request (Request): DRF-запрос. - view (Any): Вью/вьюсет; важен `view.action`. - - Returns: - Optional[str]: 'view_request' для `retrieve`, иначе None (запрет). - """ - return self.required_code_map.get(getattr(view, "action", None)) - - -class DeptCanProcess(AdminOrDeptAllowed): - """Пермишен для обработки заявок по департаментным правам. - - Доступ разрешается, если пользователь — staff/superuser ИЛИ имеет - право отдела ``can_process_requests`` для отдела заявки. - - Применяется на детальных экшенах workflow: - - approve - - reject - - Атрибуты: - allow_safe_without_code (bool): Запретить «фоллбек» на SAFE-методы - без явного кода права (по умолчанию в базовом классе мог быть True). - required_code_map (dict[str, str]): Маппинг action → код права. - required_code (str): Резервный код права, если action не сматчился. - - Raises: - Ничего напрямую не выбрасывает — при отсутствии прав вернёт False, - DRF ответит 403 Forbidden. - """ - - # SAFE-методы пусть тоже требуют явного кода (на всякий случай) - allow_safe_without_code: bool = False - - # Явно описываем нужные action-ы workflow - required_code_map: dict[str, str] = { - "approve": "can_process_requests", - "reject": "can_process_requests", - } - - # Резервный вариант — если вдруг action неизвестен, - # базовый класс возьмёт этот код. - required_code: str = "can_process_requests" - - -class DeptChangeRequest(AdminOrDeptAllowed): - """Правка заявок по департаментным правам. - - Доступ разрешается, если пользователь — staff/superuser ИЛИ имеет - право отдела ``change_request`` для отдела конкретной заявки. - - Применяется ТОЛЬКО к экшенам редактирования: - - update (PUT) - - partial_update (PATCH) - - destroy (DELETE) - - ВНИМАНИЕ: для workflow-экшенов (approve/reject) используйте DeptCanProcess. - - Attributes: - allow_safe_without_code (bool): запрещает фоллбек пропуска SAFE-методов - без явного кода права (защита от случайных «200»). - """ - - # Не пропускаем SAFE-методы без явного кода (страховка) - allow_safe_without_code: bool = False - - def get_required_code(self, request: Request, view: Any) -> Optional[str]: - """Возвращает код департаментного права для экшенов редактирования. - - Args: - request (Request): DRF-запрос. - view (Any): Вью/вьюсет (важен view.action). - - Returns: - Optional[str]: 'change_request' для - update/partial_update/destroy, иначе None. - - Notes: - Возврат None означает «кода нет» → базовый класс вернёт False (403), - так мы исключаем approve/reject и прочие экшены из этого пермишена. - """ - if getattr(view, "action", None) in { - "update", - "partial_update", - "destroy", - }: - return "change_request" - return None - - -class DeptComments(AdminOrDeptAllowed): - """Комментарии: staff ИЛИ департаментное право. - - - GET/HEAD → 'view_requestcomment' - - POST → 'add_requestcomment' - """ - - # Чтобы SAFE-методы не проходили «без кода» при ошибке конфигурации: - allow_safe_without_code = False - - def get_required_code(self, request: Request, view: Any) -> Optional[str]: - """Возвращает код департаментного права для экшена `comments`. - - Args: - request (Request): Текущий запрос DRF. - view (Any): Вью/вьюсет (не используется). - - Returns: - Optional[str]: Код права для текущего HTTP-метода - или None для неподдерживаемых методов. - - Raises: - None: Исключения не выбрасываются — при отсутствии - кода доступ будет запрещён базовым классом. - """ - if request.method in {"GET", "HEAD"}: - return DeptPerm.VIEW_REQUESTCOMMENT - if request.method == "POST": - return DeptPerm.ADD_REQUESTCOMMENT - return None diff --git a/backend/api/v1/requests_app/permissions.py.orig b/backend/api/v1/requests_app/permissions.py.orig deleted file mode 100644 index a4b64052..00000000 --- a/backend/api/v1/requests_app/permissions.py.orig +++ /dev/null @@ -1,219 +0,0 @@ -# api/v1/requests_app/permissions.py -from __future__ import annotations - -from typing import Any, Optional - -from api.v1.permissions import AdminOrDeptAllowed -from rest_framework.permissions import BasePermission -from rest_framework.request import Request -from employees.constants import DeptPerm - - -class CommentsPermission(BasePermission): - """Доступ к экшену `comments` по *модельным* правам. - - - GET/HEAD: `requests_app.view_requestcomment` ИЛИ staff. - - POST: `requests_app.add_requestcomment` ИЛИ staff. - - Владелец без прав — НЕТ. - """ - - message = "Недостаточно прав для доступа к комментариям." - - def _required_perm(self, request: Request) -> Optional[str]: - if request.method in {"GET", "HEAD"}: - return "requests_app.view_requestcomment" - if request.method == "POST": - return "requests_app.add_requestcomment" - return None - - def has_permission(self, request: Request, view: Any) -> bool: - return bool(getattr(request.user, "is_authenticated", False)) - - def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool: - user = request.user - if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False): - return True - code = self._required_perm(request) - return bool(code and user.has_perm(code)) - - -class NotFinalOrStaff(BasePermission): - """Запрещает удаление финальной заявки для не-админов. - - - Разрешает любые методы, кроме DELETE, без ограничений. - - Для DELETE: разрешено, если заявка не финальная, либо пользователь — staff. - - Атрибуты: - message (str): Текст ошибки для 403 Forbidden. - - Notes: - Пермишены не выбрасывают исключения — они возвращают False, - что приводит к 403 Forbidden. - """ - - message = "Финальная заявка не может быть удалена." - - def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool: - """Объектная проверка. - - Args: - request (Request): Текущий запрос DRF. - view (Any): Экземпляр вьюсета. - obj (Any): Экземпляр удаляемого объекта (заявки). - - Returns: - bool: True, если доступ разрешён; иначе False. - """ - if request.method != "DELETE": - return True - is_final = bool(getattr(obj, "is_final", False)) - is_staff = bool(getattr(request.user, "is_staff", False)) - return (not is_final) or is_staff - - -class DeptViewRequest(AdminOrDeptAllowed): - """Просмотр заявки по департаментным правам. - - Доступ разрешается, если пользователь — staff/superuser ИЛИ имеет - право отдела ``view_request`` именно для отдела данной заявки. - - Применяется только к detail-экшену `retrieve`. - - Attributes: - allow_safe_without_code (bool): Запрещает фоллбек пропуска SAFE-методов - без явного кода права (защита от «случайных 200»). - required_code_map (dict[str, str]): Ограничиваем право только на `retrieve`. - required_code (Optional[str]): Явно отключаем общий fallback. - """ - - # SAFE-методы не должны проходить «на шару» - allow_safe_without_code: bool = False - - # Явно ограничиваем сферу действия права - required_code_map: dict[str, str] = { - "retrieve": "view_request", - } - - # Не даём базовому классу брать «любой экшен = view_request» - required_code: Optional[str] = None - - def get_required_code(self, request: Request, view: Any) -> Optional[str]: - """Возвращает код департаментного права только для поддерживаемых action. - - Args: - request (Request): DRF-запрос. - view (Any): Вью/вьюсет; важен `view.action`. - - Returns: - Optional[str]: 'view_request' для `retrieve`, иначе None (запрет). - """ - return self.required_code_map.get(getattr(view, "action", None)) - - -class DeptCanProcess(AdminOrDeptAllowed): - """Пермишен для обработки заявок по департаментным правам. - - Доступ разрешается, если пользователь — staff/superuser ИЛИ имеет - право отдела ``can_process_requests`` для отдела заявки. - - Применяется на детальных экшенах workflow: - - approve - - reject - - Атрибуты: - allow_safe_without_code (bool): Запретить «фоллбек» на SAFE-методы - без явного кода права (по умолчанию в базовом классе мог быть True). - required_code_map (dict[str, str]): Маппинг action → код права. - required_code (str): Резервный код права, если action не сматчился. - - Raises: - Ничего напрямую не выбрасывает — при отсутствии прав вернёт False, - DRF ответит 403 Forbidden. - """ - - # SAFE-методы пусть тоже требуют явного кода (на всякий случай) - allow_safe_without_code: bool = False - - # Явно описываем нужные action-ы workflow - required_code_map: dict[str, str] = { - "approve": "can_process_requests", - "reject": "can_process_requests", - } - - # Резервный вариант — если вдруг action неизвестен, - # базовый класс возьмёт этот код. - required_code: str = "can_process_requests" - - -class DeptChangeRequest(AdminOrDeptAllowed): - """Правка заявок по департаментным правам. - - Доступ разрешается, если пользователь — staff/superuser ИЛИ имеет - право отдела ``change_request`` для отдела конкретной заявки. - - Применяется ТОЛЬКО к экшенам редактирования: - - update (PUT) - - partial_update (PATCH) - - destroy (DELETE) - - ВНИМАНИЕ: для workflow-экшенов (approve/reject) используйте DeptCanProcess. - - Attributes: - allow_safe_without_code (bool): запрещает фоллбек пропуска SAFE-методов - без явного кода права (защита от случайных «200»). - """ - - # Не пропускаем SAFE-методы без явного кода (страховка) - allow_safe_without_code: bool = False - - def get_required_code(self, request: Request, view: Any) -> Optional[str]: - """Возвращает код департаментного права для экшенов редактирования. - - Args: - request (Request): DRF-запрос. - view (Any): Вью/вьюсет (важен view.action). - - Returns: - Optional[str]: 'change_request' для update/partial_update/destroy, иначе None. - - Notes: - Возврат None означает «кода нет» → базовый класс вернёт False (403), - так мы исключаем approve/reject и прочие экшены из этого пермишена. - """ - if getattr(view, "action", None) in {"update", "partial_update", "destroy"}: - return "change_request" - return None - - -class DeptComments(AdminOrDeptAllowed): - """Комментарии: staff ИЛИ департаментное право. - - - GET/HEAD → 'view_requestcomment' - - POST → 'add_requestcomment' - """ - - # Чтобы SAFE-методы не проходили «без кода» при ошибке конфигурации: - allow_safe_without_code = False - - def get_required_code(self, request: Request, view: Any) -> Optional[str]: - """Возвращает код департаментного права для экшена `comments`. - - Args: - request (Request): Текущий запрос DRF. - view (Any): Вью/вьюсет (не используется). - - Returns: - Optional[str]: Код права для текущего HTTP-метода или None для неподдерживаемых методов. - - Raises: - None: Исключения не выбрасываются — при отсутствии кода доступ будет запрещён базовым классом. - """ - if request.method in {"GET", "HEAD"}: - return DeptPerm.VIEW_REQUESTCOMMENT - if request.method == "POST": - return DeptPerm.ADD_REQUESTCOMMENT -<<<<<<< HEAD - return None -======= - return None ->>>>>>> origin/feat/ldap-writeback diff --git a/backend/api/v1/requests_app/views.py.orig b/backend/api/v1/requests_app/views.py.orig deleted file mode 100644 index fbea5c8a..00000000 --- a/backend/api/v1/requests_app/views.py.orig +++ /dev/null @@ -1,323 +0,0 @@ -from __future__ import annotations - -from typing import Any, List, Optional # было: Any, Dict, List, Type - -from django.db.models import Q, QuerySet -from django.shortcuts import get_object_or_404 # раскомментируйте импорт -from employees.models import EmployeeDepartment -from requests_app.models import Request as EmployeeRequest -from requests_app.models import RequestComment -<<<<<<< HEAD -from requests_app.enums import RequestStatus -======= ->>>>>>> origin/feat/ldap-writeback - -# from django.shortcuts import get_object_or_404 # <- не используется -from rest_framework import status, viewsets -from rest_framework.decorators import action -from rest_framework.exceptions import ( - NotAuthenticated, - PermissionDenied, - ValidationError, -) -from rest_framework.parsers import FormParser, JSONParser, MultiPartParser -from rest_framework.permissions import SAFE_METHODS, BasePermission, IsAuthenticated -from rest_framework.request import Request as DRFRequest -from rest_framework.response import Response -from rest_framework.serializers import ( - BaseSerializer, -) # <- для типизации get_serializer_class - -from ..permissions import AdminOrActionOrModelPerms, AdminOrDeptAllowed, IsSelfOrStaff -from .permissions import ( - CommentsPermission, - DeptCanProcess, - DeptChangeRequest, - DeptComments, - DeptViewRequest, - NotFinalOrStaff, -) -from .serializers import ( - RequestCommentSerializer, - RequestReadSerializer, - RequestWriteSerializer, -) - - -class RequestViewSet(viewsets.ModelViewSet): - """ViewSet для заявок сотрудников. - - Админы могут всё. Пользователи с модельными правами действуют по правам. - Без прав аутентифицированные пользователи работают только со своими заявками. - - Поддерживаемые экшены: - - POST /requests/{id}/approve/ - - POST /requests/{id}/reject/ - - POST /requests/{id}/cancel/ - - GET/POST /requests/{id}/comments/ - - Raises: - PermissionDenied: При нарушении правил доступа. - """ - - queryset = EmployeeRequest.objects.select_related( - "employee", "approver", "department" - ).all() - parser_classes = (MultiPartParser, FormParser, JSONParser) - pagination_class = None - - required_perms_by_action = { - "comments": { - "GET": "requests_app.view_requestcomment", - "HEAD": "requests_app.view_requestcomment", - "POST": "requests_app.add_requestcomment", - }, - "reject": ["requests_app.can_process_requests", "requests_app.change_request"], - "approve": ["requests_app.can_process_requests", "requests_app.change_request"], - } - - def get_permissions(self) -> list[BasePermission]: - """Подбирает пермишены под текущий action. - - - comments: владелец ИЛИ staff ИЛИ явные модельные права из required_perms_by_action. - - approve/reject: аутентификация + явные права из required_perms_by_action. - - cancel: владелец ИЛИ staff. - - остальное (CRUD): владелец ИЛИ staff ИЛИ модельные права. - """ - if self.action == "comments": - return [(CommentsPermission | DeptComments)()] - if self.action in {"approve", "reject"}: - return [(AdminOrActionOrModelPerms | DeptCanProcess)()] - if self.action == "cancel": - return [IsSelfOrStaff()] - if self.action == "retrieve": - return [(IsSelfOrStaff | DeptViewRequest | AdminOrActionOrModelPerms)()] - if self.action == "destroy": -<<<<<<< HEAD - return [ - (IsSelfOrStaff | AdminOrActionOrModelPerms | DeptChangeRequest)(), - NotFinalOrStaff(), - ] -======= - return [(IsSelfOrStaff | AdminOrActionOrModelPerms | DeptChangeRequest)(), NotFinalOrStaff()] ->>>>>>> origin/feat/ldap-writeback - return [(IsSelfOrStaff | AdminOrActionOrModelPerms | DeptChangeRequest)()] - - def get_serializer_class(self) -> type[BaseSerializer]: - """Возвращает сериализатор в зависимости от action.""" - if self.action in {"list", "retrieve"}: - return RequestReadSerializer - return RequestWriteSerializer - - # ---------- Queryset с учётом прав ---------- - - def _can_view_all(self, user) -> bool: - """Проверяет, может ли пользователь видеть все заявки.""" - return bool( - user.is_staff - or user.has_perm("requests_app.can_view_all_requests") - or user.has_perm("requests_app.view_request") - ) - - def get_queryset(self) -> QuerySet[EmployeeRequest]: - """Список заявок с учётом прав: - - staff/глобальные видят всё (или только свои при ?view=mine), - - остальные: свои + по отделам, где есть право 'view_request'. - """ - qs = super().get_queryset() - user = self.request.user - params = self.request.query_params - - mine_raw = (params.get("mine") or "").lower() - want_mine = (params.get("view") == "mine") or ( - mine_raw in {"1", "true", "yes", "on"} - ) - - if user.is_staff or self._can_view_all(user): - if want_mine: - qs = qs.filter(employee_id=user.id) - else: - dept_ids_qs = ( - EmployeeDepartment.objects.filter( - employee_id=user.id, - is_active=True, - role__scoped_permissions__code="view_request", - ) - .values_list("department_id", flat=True) - .distinct() - ) - scope = Q(employee_id=user.id) | Q(department_id__in=dept_ids_qs) - qs = qs.filter(scope) - - t = (params.get("type") or "").strip() - s = (params.get("status") or "").strip() - if t: - qs = qs.filter(type=t) - if s: - qs = qs.filter(status=s) - - return qs - - # --- ВАЖНО: не ловить 404 на detail-экшенах из-за урезанного get_queryset() --- - - def get_object(self) -> EmployeeRequest: - """Возвращает объект заявки для detail-экшенов. - - Для экшенов {'approve', 'reject', 'comments', 'cancel', 'retrieve'}: - - достаём объект **в обход** урезанного get_queryset() по PK, - - прогоняем через object-level пермишены (check_object_permissions), - чтобы при отсутствии прав получить **403 Forbidden**, а не 404. - - Returns: - EmployeeRequest: Объект заявки. - - Raises: - Http404: Если объект с таким PK не существует вообще. - PermissionDenied: Если object-level пермишены не пройдены. - """ -<<<<<<< HEAD - if getattr(self, "action", None) in { - "approve", - "reject", - "comments", - "cancel", - "retrieve", - }: - lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field - lookup_value = self.kwargs[lookup_url_kwarg] - base = EmployeeRequest.objects.select_related("employee", "approver", "department") - obj = get_object_or_404(base, **{self.lookup_field: lookup_value}) -======= - if getattr(self, "action", None) in {"approve", "reject", "comments", "cancel", "retrieve"}: - lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field - lookup_value = self.kwargs[lookup_url_kwarg] - obj = get_object_or_404(EmployeeRequest, **{self.lookup_field: lookup_value}) ->>>>>>> origin/feat/ldap-writeback - self.check_object_permissions(self.request, obj) - return obj - - return super().get_object() - - # ---------- CRUD ---------- - - def perform_create(self, serializer: RequestWriteSerializer) -> None: - """Создание: для не-админа принудительно ставим себя как автора.""" - user = self.request.user - if not user or not user.is_authenticated: - raise NotAuthenticated("Authentication required") - is_power = user.is_staff or user.has_perm("requests_app.add_request") - extra = {"employee": user} if not is_power else {} - save_as = (self.request.query_params.get("save_as") or "").strip().lower() - if save_as == "draft": - extra["status"] = RequestStatus.DRAFT # ✅ обычному пользователю поле в payload всё равно бы «очистили» - - serializer.save(**extra) - - def perform_update(self, serializer: RequestWriteSerializer) -> None: - """Обновление: владельцу разрешаем до финального статуса. - - Raises: - PermissionDenied: Если заявка финальная и нет прав, либо правка чужой заявки без прав. - """ - obj = self.get_object() - user = self.request.user - if obj.is_final and not ( - user.is_staff or user.has_perm("requests_app.change_request") - ): - raise PermissionDenied("Финальная заявка недоступна для правок.") - if (obj.employee_id != user.id) and not ( - user.is_staff or user.has_perm("requests_app.change_request") - ): - raise PermissionDenied("Нельзя редактировать чужую заявку.") - extra: dict[str, Any] = {} - save_as = (self.request.query_params.get("save_as") or "").strip().lower() - - if save_as == "draft": - extra["status"] = RequestStatus.DRAFT - elif save_as == "submit" and obj.status == RequestStatus.DRAFT: - # ⬅️ явная подача «на рассмотрение» из черновика - extra["status"] = RequestStatus.PENDING - - serializer.save(**extra) - - # ---------- Комментарии ---------- - - @action(detail=True, methods=["get", "post"]) - def comments(self, request: DRFRequest, pk: int | str | None = None) -> Response: - """Список/создание комментариев для заявки. - - GET: список комментариев (staff, владелец или право view_requestcomment). - POST: создание {"text": "..."} (staff, владелец или право add_requestcomment). - """ - req_obj = self.get_object() - - if request.method in {"GET", "HEAD"}: - qs = RequestComment.objects.filter(request=req_obj).select_related("author") - ser = RequestCommentSerializer( - qs, many=True, context=self.get_serializer_context() - ) - return Response(ser.data) - - ser = RequestCommentSerializer( - data=request.data, context=self.get_serializer_context() - ) - ser.is_valid(raise_exception=True) - ser.save(request=req_obj, author=request.user) - return Response(ser.data, status=status.HTTP_201_CREATED) - - # ---------- Экшены статусов (только бизнес-валидация) ---------- - - @action(detail=True, methods=["post"]) - def approve(self, request: DRFRequest, pk: int | str | None = None) -> Response: - """Одобряет заявку. - - Raises: - ValidationError: Если заявка уже финальная. - """ - obj = self.get_object() # права проверит AdminOrActionOrModelPerms - if getattr(obj, "is_final", False): - raise ValidationError("Заявка уже в финальном статусе.") - obj.approve(by_user=request.user) - return Response( - RequestReadSerializer(obj, context=self.get_serializer_context()).data - ) - - @action(detail=True, methods=["post"]) - def reject(self, request: DRFRequest, pk: int | str | None = None) -> Response: - """Отклоняет заявку. - - Raises: - ValidationError: Если заявка уже финальная. - """ - obj = self.get_object() - if getattr(obj, "is_final", False): - raise ValidationError("Заявка уже в финальном статусе.") - obj.reject(by_user=request.user) - return Response( - RequestReadSerializer(obj, context=self.get_serializer_context()).data - ) - - @action(detail=True, methods=["post"]) - def cancel(self, request: DRFRequest, pk: int | str | None = None) -> Response: - """Отменяет заявку (владелец или админ). - - Raises: - PermissionDenied: Если заявка финальная (для не admin). - """ - obj = self.get_object() - if getattr(obj, "is_final", False) and not request.user.is_staff: - raise PermissionDenied("Финальная заявка уже не может быть отменена.") - obj.cancel() - return Response( - RequestReadSerializer(obj, context=self.get_serializer_context()).data - ) - - def create(self, request, *args, **kwargs): - """Создание заявки: валидируем write-сериализатором, отвечаем read-сериализатором.""" - serializer = self.get_serializer(data=request.data) - serializer.is_valid(raise_exception=True) - self.perform_create(serializer) - instance = serializer.instance - read = RequestReadSerializer(instance, context=self.get_serializer_context()) - headers = self.get_success_headers(read.data) - return Response(read.data, status=status.HTTP_201_CREATED, headers=headers) diff --git a/backend/communications/services/chat_notifications.py b/backend/communications/services/chat_notifications.py index 2d5c5d38..09055fda 100644 --- a/backend/communications/services/chat_notifications.py +++ b/backend/communications/services/chat_notifications.py @@ -3,6 +3,7 @@ from dataclasses import dataclass from django.db import transaction 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 @@ -41,10 +42,13 @@ class ChatNotificationReadService: read_at = timezone.now() count = queryset.update(unread=False, timestamp_read=read_at) + invalidate_unread_summary(user.id) - transaction.on_commit( - lambda: send_notifications_read_all_event(user.id, notification_ids) - ) + 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 ChatNotificationReadResult( notification_ids=notification_ids, diff --git a/backend/employees/admin.py b/backend/employees/admin.py index eb9836ed..3cf60181 100644 --- a/backend/employees/admin.py +++ b/backend/employees/admin.py @@ -737,6 +737,25 @@ class EmployeeAdmin(DjangoUserAdmin): ): changes["phone_number"] = employee.phone_number + desired_is_active = ( + employee.is_active and employee.is_actually_active + ) + if employee.is_active != desired_is_active: + changes["is_active"] = desired_is_active + else: + try: + from employees.ldap.utils.ldap_utils import ( + _uac_is_active, + ) + + ldap_is_active = _uac_is_active( + ldap_user.user_account_control + ) + except Exception: + ldap_is_active = None + if ldap_is_active != desired_is_active: + changes["is_active"] = desired_is_active + if changes: service.update_user(employee, changes) updated_count += 1 @@ -745,7 +764,7 @@ class EmployeeAdmin(DjangoUserAdmin): from django.conf import settings as _settings expected_base = None - if not employee.is_actually_active: + if not desired_is_active: expected_base = getattr( _settings, "LDAP_DISMISSED_BASE", None ) @@ -795,23 +814,26 @@ class EmployeeAdmin(DjangoUserAdmin): level=messages.INFO, ) - # Добавляем в группу DEP_* нового отдела - try: - from employees.ldap.orm_models import ( - LdapOrganizationalUnit, - ) - - ou = ( - LdapOrganizationalUnit.objects.get( - dn=expected_base + # Добавляем в группу DEP_* только активного + # сотрудника. Уволенные остаются в + # LDAP_DISMISSED_BASE без DEP membership. + if desired_is_active: + try: + from employees.ldap.orm_models import ( + LdapOrganizationalUnit, ) - ) - dept_group = ( - ou.ensure_department_group() - ) - dept_group.add_member(ldap_user.dn) - except Exception: - pass # best effort + + ou = ( + LdapOrganizationalUnit.objects.get( + dn=expected_base + ) + ) + dept_group = ( + ou.ensure_department_group() + ) + dept_group.add_member(ldap_user.dn) + except Exception: + pass # best effort except Exception as move_err: logger.warning( @@ -889,7 +911,10 @@ class EmployeeAdmin(DjangoUserAdmin): # Определяем department_dn на основе отдела и статуса department_dn = None - if not employee.is_actually_active: + desired_is_active = ( + employee.is_active and employee.is_actually_active + ) + if not desired_is_active: # Уволенный → OU=Dismissed department_dn = getattr( _settings, "LDAP_DISMISSED_BASE", None @@ -936,9 +961,9 @@ class EmployeeAdmin(DjangoUserAdmin): department_dn=department_dn, group_cns=[], initial_password=temp_password, + is_active=desired_is_active, ) - # Создаем через сервис logger.info( f"[sync_from_django_to_ldap] Employee { employee.pk @@ -981,11 +1006,12 @@ class EmployeeAdmin(DjangoUserAdmin): f"Ошибка создания {employee.email} в LDAP: {e}", level=messages.ERROR, ) + except Exception as e: logger.exception( f"[sync_from_django_to_ldap] Employee { employee.pk - }: необработанное исключение на верхнем уровне" + }: общая ошибка" ) error_count += 1 self.message_user( @@ -1705,6 +1731,11 @@ class DepartmentAdmin(admin.ModelAdmin): member_dns = [] for link in active_links: + if not ( + link.employee.is_active + and link.employee.is_actually_active + ): + continue emp_sync = LdapSyncState.objects.filter( model="employee", object_pk=str(link.employee_id), diff --git a/backend/employees/constants.py b/backend/employees/constants.py index eb6991ae..0befe222 100644 --- a/backend/employees/constants.py +++ b/backend/employees/constants.py @@ -91,10 +91,8 @@ class DeptPerm: CHANGE_HEAD = "change_department_head" ASSIGN_ROLE = "assign_department_role" MANAGE_FEED = "manage_department_feed" - VIEW_REQUESTCOMMENT = "view_requestcomment" - ADD_REQUESTCOMMENT = "add_requestcomment" - VIEW_REQUEST = "view_request" CAN_PROCESS_REQUESTS = "can_process_requests" + APPROVE_PROCUREMENT = CAN_PROCESS_REQUESTS MANAGE_EQUIPMENT = "manage_department_equipment" CHOICES = ( @@ -102,9 +100,6 @@ class DeptPerm: (CHANGE_HEAD, "Назначать руководителя"), (ASSIGN_ROLE, "Назначать роли участникам"), (MANAGE_FEED, "Редактировать публикации отдела"), - (VIEW_REQUESTCOMMENT, "Просмотр комментариев по заявлениям"), - (ADD_REQUESTCOMMENT, "Добавление коментариев по заявлениям"), - (VIEW_REQUEST, "Просмотр заявлений отдела"), - (CAN_PROCESS_REQUESTS, "Рассмотрение заявлений отдела"), + (APPROVE_PROCUREMENT, "Согласование закупок отдела"), (MANAGE_EQUIPMENT, "Управлять оборудованием отдела"), ) diff --git a/backend/employees/ldap/admin.py b/backend/employees/ldap/admin.py index 01591cee..f47284c3 100644 --- a/backend/employees/ldap/admin.py +++ b/backend/employees/ldap/admin.py @@ -588,6 +588,7 @@ class LdapUserAdmin(admin.ModelAdmin): "last_name": emp.last_name, "email": emp.email, "phone_number": emp.phone_number, + "is_active": emp.is_active and emp.is_actually_active, } service.update_user(emp, changes) @@ -1783,6 +1784,10 @@ class LdapOrganizationalUnitGroupAdmin(admin.ModelAdmin): member_dns = [] for link in active_links: + if not ( + link.employee.is_active and link.employee.is_actually_active + ): + continue emp_sync = LdapSyncState.objects.filter( model="employee", object_pk=str(link.employee_id), diff --git a/backend/employees/ldap/repositories/employee_repository.py b/backend/employees/ldap/repositories/employee_repository.py index 0d5bb143..9c17213d 100644 --- a/backend/employees/ldap/repositories/employee_repository.py +++ b/backend/employees/ldap/repositories/employee_repository.py @@ -98,6 +98,12 @@ def bind_user_department(user: Employee, dn: str) -> None: user: Пользователь. dn: Distinguished Name пользователя в LDAP. """ + if not (user.is_active and user.is_actually_active): + EmployeeDepartment.objects.filter(employee=user, is_active=True).update( + is_active=False + ) + return + dept_name = extract_department_from_dn(dn) if dept_name: dept_obj, _ = Department.objects.get_or_create(name=dept_name) diff --git a/backend/employees/ldap/services/department_service.py b/backend/employees/ldap/services/department_service.py index c02cf808..99d803ae 100644 --- a/backend/employees/ldap/services/department_service.py +++ b/backend/employees/ldap/services/department_service.py @@ -1075,6 +1075,9 @@ class DepartmentService(BaseService): user_dn = self._user_service._get_employee_dn(employee) if add: + if not (employee.is_active and employee.is_actually_active): + self._remove_ou_group_member(role.ldap_group_dn, user_dn) + return self._add_ou_group_member(role.ldap_group_dn, user_dn) else: self._remove_ou_group_member(role.ldap_group_dn, user_dn) @@ -1086,6 +1089,9 @@ class DepartmentService(BaseService): role_hint: Any = None, ) -> List[DepartmentRole]: """Собирает целевой набор ролей для LDAP sync в рамках одного отдела.""" + if not (employee.is_active and employee.is_actually_active): + return [] + roles_by_id: Dict[int, DepartmentRole] = {} def add_role(role_obj: Optional[DepartmentRole]) -> None: @@ -1555,11 +1561,15 @@ class DepartmentService(BaseService): group_dn = self._ensure_department_group(dept, dept_dn) group = LdapOrganizationalUnitGroup.objects.get(dn=group_dn) - active_emp_ids = list( - EmployeeDepartment.objects.filter( - department_id=dept.id, is_active=True - ).values_list("employee_id", flat=True) - ) + active_links = EmployeeDepartment.objects.filter( + department_id=dept.id, + is_active=True, + ).select_related("employee") + active_emp_ids = [ + link.employee_id + for link in active_links + if link.employee.is_active and link.employee.is_actually_active + ] role_group_dns = [ item for item in DepartmentRole.objects.filter( diff --git a/backend/employees/migrations/0053_cleanup_department_role_permissions.py b/backend/employees/migrations/0053_cleanup_department_role_permissions.py new file mode 100644 index 00000000..d251560f --- /dev/null +++ b/backend/employees/migrations/0053_cleanup_department_role_permissions.py @@ -0,0 +1,59 @@ +# Generated by Codex on 2026-06-20 + +from django.db import migrations, models + + +OBSOLETE_REQUEST_PERMISSION_CODES = [ + "view_request", + "view_requestcomment", + "add_requestcomment", +] + + +def cleanup_department_role_permissions(apps, schema_editor): + DepartmentPermission = apps.get_model("employees", "DepartmentPermission") + DepartmentPermission.objects.filter( + code__in=OBSOLETE_REQUEST_PERMISSION_CODES + ).delete() + DepartmentPermission.objects.filter(code="can_process_requests").update( + name="Согласование закупок отдела" + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("employees", "0052_employee_attendance_aliases"), + ] + + operations = [ + migrations.AlterField( + model_name="departmentpermission", + name="code", + field=models.CharField( + choices=[ + ("manage_department", "Управлять отделом"), + ("change_department_head", "Назначать руководителя"), + ("assign_department_role", "Назначать роли участникам"), + ( + "manage_department_feed", + "Редактировать публикации отдела", + ), + ( + "can_process_requests", + "Согласование закупок отдела", + ), + ( + "manage_department_equipment", + "Управлять оборудованием отдела", + ), + ], + max_length=64, + unique=True, + ), + ), + migrations.RunPython( + cleanup_department_role_permissions, + migrations.RunPython.noop, + ), + ] diff --git a/backend/eusrr_backend/celery.py b/backend/eusrr_backend/celery.py index d70d5902..487b32ee 100644 --- a/backend/eusrr_backend/celery.py +++ b/backend/eusrr_backend/celery.py @@ -24,6 +24,10 @@ app.conf.beat_schedule = { 'task': 'requests_app.tasks.cleanup_missed_returns', 'schedule': crontab(hour=0, minute=5), # Каждый день в 00:05 }, + 'process-due-personnel-actions': { + 'task': 'requests_app.tasks.process_due_personnel_actions', + 'schedule': crontab(hour=0, minute=10), # Каждый день в 00:10 + }, 'attendance-auto-sync-dispatcher': { 'task': 'attendance.tasks.dispatch_attendance_auto_sync', 'schedule': 60.0, diff --git a/backend/notifications/api/serializers.py b/backend/notifications/api/serializers.py index e72e7859..c14a8e81 100644 --- a/backend/notifications/api/serializers.py +++ b/backend/notifications/api/serializers.py @@ -41,6 +41,22 @@ class CountResponseSerializer(serializers.Serializer): count = serializers.IntegerField() +class UnreadSummaryVerbSerializer(serializers.Serializer): + verb = serializers.CharField() + unread = serializers.IntegerField() + + +class UnreadSummaryProcurementRequestSerializer(serializers.Serializer): + request_id = serializers.IntegerField() + unread = serializers.IntegerField() + + +class UnreadSummaryResponseSerializer(serializers.Serializer): + total = serializers.IntegerField() + verbs = UnreadSummaryVerbSerializer(many=True) + procurement_requests = UnreadSummaryProcurementRequestSerializer(many=True) + + class StatusResponseSerializer(serializers.Serializer): status = serializers.CharField() message = serializers.CharField(required=False) diff --git a/backend/notifications/api/urls.py b/backend/notifications/api/urls.py index 18a0211f..db7aeaab 100644 --- a/backend/notifications/api/urls.py +++ b/backend/notifications/api/urls.py @@ -13,6 +13,7 @@ urlpatterns = [ # Список и счетчики path("", views.get_notifications, name="list"), path("count/", views.get_unread_count, name="count"), + path("summary/", views.get_unread_summary_view, name="summary"), path("verb-types/", views.get_verb_types, name="verb_types"), # Операции с уведомлениями path("/read/", views.mark_as_read, name="mark_read"), diff --git a/backend/notifications/api/views.py b/backend/notifications/api/views.py index 304cc666..4990779d 100644 --- a/backend/notifications/api/views.py +++ b/backend/notifications/api/views.py @@ -17,6 +17,11 @@ from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from ..models import Notification, UserChannelPreferences +from ..cache import ( + get_unread_count as get_cached_unread_count, + get_unread_summary, + invalidate_unread_summary, +) from ..realtime import ( send_notification_read_event, send_notifications_read_all_event, @@ -31,6 +36,7 @@ from .serializers import ( SubscribePushRequestSerializer, UnsubscribePushRequestSerializer, UpdateChannelPreferencesSerializer, + UnreadSummaryResponseSerializer, VapidPublicKeyResponseSerializer, VerbTypesResponseSerializer, ) @@ -164,15 +170,23 @@ def get_notifications(request): @permission_classes([IsAuthenticated]) def get_unread_count(request): """Получить количество непрочитанных уведомлений""" - count = Notification.objects.filter( - recipient=request.user, - unread=True, - deleted=False, - ).count() + count = get_cached_unread_count(request.user.id) return Response({'count': count}) +@extend_schema( + tags=["Notifications"], + summary="Получить сводку непрочитанных уведомлений по типам", + responses=UnreadSummaryResponseSerializer, +) +@api_view(['GET']) +@permission_classes([IsAuthenticated]) +def get_unread_summary_view(request): + """Получить cached-сводку непрочитанных уведомлений по verb.""" + return Response(get_unread_summary(request.user.id)) + + @extend_schema( tags=["Notifications"], summary="Отметить уведомление как прочитанное", @@ -192,6 +206,7 @@ def mark_as_read(request, notification_id): notification.mark_as_read() if was_unread: + invalidate_unread_summary(request.user.id) send_notification_read_event(request.user.id, notification_id) return Response({ @@ -221,7 +236,10 @@ def mark_as_unread(request, notification_id): id=notification_id, recipient=request.user ) + was_read = not notification.unread notification.mark_as_unread() + if was_read: + invalidate_unread_summary(request.user.id) return Response({ 'status': 'success', @@ -258,6 +276,8 @@ def mark_all_as_read(request): notification_ids = list(queryset.values_list('id', flat=True)) count = queryset.mark_all_as_read() + if count: + invalidate_unread_summary(request.user.id) if notification_ids: send_notifications_read_all_event( @@ -313,6 +333,8 @@ def mark_category_as_read(request): notification_ids = list(queryset.values_list('id', flat=True)) count = queryset.mark_all_as_read() + if count: + invalidate_unread_summary(request.user.id) if notification_ids: send_notifications_read_all_event( @@ -342,8 +364,11 @@ def delete_notification(request, notification_id): id=notification_id, recipient=request.user ) + was_unread = notification.unread notification.deleted = True notification.save(update_fields=['deleted']) + if was_unread: + invalidate_unread_summary(request.user.id) return Response({'status': 'success'}) diff --git a/backend/notifications/cache.py b/backend/notifications/cache.py new file mode 100644 index 00000000..e77d4b85 --- /dev/null +++ b/backend/notifications/cache.py @@ -0,0 +1,129 @@ +from django.core.cache import cache +from django.db.models import Count +from django.db.models import Q + + +UNREAD_SUMMARY_TIMEOUT = 60 + + +def unread_summary_cache_key(user_id): + return f"notifications:unread_summary:user:{user_id}" + + +def _coerce_positive_int(value): + try: + integer_value = int(value) + except (TypeError, ValueError): + return None + return integer_value if integer_value > 0 else None + + +def _build_procurement_request_counts(user_id): + from django.contrib.contenttypes.models import ContentType + from procurement.models import ProcurementRequest + from .models import Notification + + request_content_type = ContentType.objects.get_for_model( + ProcurementRequest, + ) + request_content_type_id = request_content_type.id + counts = {} + notifications = ( + Notification.objects.filter( + recipient_id=user_id, + unread=True, + deleted=False, + ) + .filter( + Q(verb__startswith="procurement_") + | Q(verb__startswith="equipment_") + ) + .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_unread_summary(user_id): + from .models import Notification + + rows = ( + Notification.objects.filter( + recipient_id=user_id, + unread=True, + deleted=False, + ) + .values("verb") + .annotate(unread=Count("id")) + .order_by() + ) + verbs = [ + {"verb": row["verb"], "unread": row["unread"]} + for row in rows + ] + return { + "total": sum(item["unread"] for item in verbs), + "verbs": verbs, + "procurement_requests": _build_procurement_request_counts(user_id), + } + + +def get_unread_summary(user_id): + key = unread_summary_cache_key(user_id) + + try: + cached = cache.get(key) + except Exception: + cached = None + + if cached is not None: + return cached + + summary = build_unread_summary(user_id) + try: + cache.set(key, summary, UNREAD_SUMMARY_TIMEOUT) + except Exception: + pass + return summary + + +def get_unread_count(user_id): + return get_unread_summary(user_id)["total"] + + +def invalidate_unread_summary(user_id): + if not user_id: + return + + try: + cache.delete(unread_summary_cache_key(user_id)) + except Exception: + pass diff --git a/backend/notifications/models.py b/backend/notifications/models.py index 087658ce..99ab9ed1 100644 --- a/backend/notifications/models.py +++ b/backend/notifications/models.py @@ -239,6 +239,9 @@ class Notification(models.Model): self.unread = False self.timestamp_read = timezone.now() self.save(update_fields=["unread", "timestamp_read"]) + from .cache import invalidate_unread_summary + + invalidate_unread_summary(self.recipient_id) def mark_as_unread(self): """Отметить уведомление как непрочитанное""" @@ -246,6 +249,9 @@ class Notification(models.Model): self.unread = True self.timestamp_read = None self.save(update_fields=["unread", "timestamp_read"]) + from .cache import invalidate_unread_summary + + invalidate_unread_summary(self.recipient_id) @property def timesince(self, now=None): diff --git a/backend/notifications/realtime.py b/backend/notifications/realtime.py index fbfc7e7c..9605cc28 100644 --- a/backend/notifications/realtime.py +++ b/backend/notifications/realtime.py @@ -1,15 +1,11 @@ from asgiref.sync import async_to_sync from channels.layers import get_channel_layer -from .models import Notification +from .cache import get_unread_count def get_unread_notifications_count(user_id): - return Notification.objects.filter( - recipient_id=user_id, - unread=True, - deleted=False, - ).count() + return get_unread_count(user_id) def _send_user_event(user_id, event): diff --git a/backend/notifications/signals.py b/backend/notifications/signals.py index 8b39096e..1d7870f6 100644 --- a/backend/notifications/signals.py +++ b/backend/notifications/signals.py @@ -122,6 +122,9 @@ def create_notification_handler(sender, **kwargs): new_notification.timestamp = timestamp new_notification.save() + from .cache import invalidate_unread_summary + + invalidate_unread_summary(recip.id) notifications.append(new_notification) # Возвращаем одно уведомление или список diff --git a/backend/procurement/notifications/handlers.py b/backend/procurement/notifications/handlers.py index d9a73412..874c7c08 100644 --- a/backend/procurement/notifications/handlers.py +++ b/backend/procurement/notifications/handlers.py @@ -239,17 +239,18 @@ def notify_approver(approval): approval.request.title, approval.request.total_cost ) - _send_procurement_notification( - recipient=approval.approver, - verb=NotificationVerbs.PENDING_APPROVAL, - procurement_request=approval.request, - title=notification_title, - description=description, - extra_data={ - "approval_id": approval.id, - "total_cost": float(approval.request.total_cost), - }, - ) + for recipient in _approval_recipients(approval): + _send_procurement_notification( + recipient=recipient, + verb=NotificationVerbs.PENDING_APPROVAL, + procurement_request=approval.request, + title=notification_title, + description=description, + extra_data={ + "approval_id": approval.id, + "total_cost": float(approval.request.total_cost), + }, + ) logger.info( f"[Procurement] Отправлено уведомление о согласовании " @@ -257,6 +258,21 @@ def notify_approver(approval): ) +def _approval_recipients(approval): + from ..models import ApprovalRoute + from ..services import ProcurementApprovalResolver + + route = ApprovalRoute.objects.filter( + priority=approval.priority, + resolver_type=ApprovalRoute.ResolverType.DEPARTMENT_HEAD, + ).first() + if route: + return ProcurementApprovalResolver.get_department_approval_users( + approval.request, + ) + return [approval.approver] + + def notify_requestor( request, verb, @@ -472,7 +488,9 @@ def notify_request_cancelled(request): ) actor = _request_actor(request) - recipients = [approval.approver for approval in request.approvals.all()] + recipients = [] + for approval in request.approvals.all(): + recipients.extend(_approval_recipients(approval)) _notify_many( recipients, verb=NotificationVerbs.CANCELLED, diff --git a/backend/procurement/notifications/read_service.py b/backend/procurement/notifications/read_service.py new file mode 100644 index 00000000..3278c7bc --- /dev/null +++ b/backend/procurement/notifications/read_service.py @@ -0,0 +1,101 @@ +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 procurement.models import ProcurementRequest + + +@dataclass(frozen=True) +class ProcurementNotificationReadResult: + notification_ids: list[int] + count: int + + +class ProcurementNotificationReadService: + """Use case for reading notifications related to a procurement request.""" + + procurement_verb_filter = ( + Q(verb__startswith="procurement_") + | Q(verb__startswith="equipment_") + ) + + def mark_request_notifications_as_read( + self, + *, + user, + procurement_request: ProcurementRequest, + ) -> ProcurementNotificationReadResult: + queryset = self._get_unread_queryset( + user_id=user.id, + procurement_request=procurement_request, + ) + notification_ids = list(queryset.values_list("id", flat=True)) + + if not notification_ids: + return ProcurementNotificationReadResult( + notification_ids=[], + count=0, + ) + + read_at = timezone.now() + count = Notification.objects.filter( + id__in=notification_ids, + ).update( + unread=False, + timestamp_read=read_at, + ) + + 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 ProcurementNotificationReadResult( + notification_ids=notification_ids, + count=count, + ) + + def _get_unread_queryset( + self, + *, + user_id: int, + procurement_request: ProcurementRequest, + ): + request_content_type = ContentType.objects.get_for_model( + ProcurementRequest, + ) + request_id = procurement_request.id + request_id_text = str(request_id) + + 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, + ) + ) + + return ( + Notification.objects.filter( + recipient_id=user_id, + unread=True, + deleted=False, + ) + .filter(self.procurement_verb_filter) + .filter(request_filter) + .distinct() + ) diff --git a/backend/procurement/services.py b/backend/procurement/services.py index 338f9f24..8e677c4e 100644 --- a/backend/procurement/services.py +++ b/backend/procurement/services.py @@ -166,6 +166,63 @@ class ProcurementApprovalResolver: procurement_request.processing_department_id, ) + @classmethod + def get_department_approval_users(cls, procurement_request): + """Return users who can approve the department-head approval step.""" + if not procurement_request.department_id: + return [] + + from employees.constants import DeptPerm + from employees.models import Employee, EmployeeDepartment, RoleAssignment + + department = procurement_request.department + approver_ids = [] + seen = set() + + def add_user_id(user_id): + if user_id and user_id not in seen: + seen.add(user_id) + approver_ids.append(user_id) + + if department.head_id and department.head.is_active: + add_user_id(department.head_id) + + role_assignment_user_ids = RoleAssignment.objects.filter( + role__department_id=department.id, + role__scoped_permissions__code=DeptPerm.APPROVE_PROCUREMENT, + employee__is_active=True, + is_active=True, + ).values_list("employee_id", flat=True) + for user_id in role_assignment_user_ids: + add_user_id(user_id) + + legacy_role_user_ids = EmployeeDepartment.objects.filter( + department_id=department.id, + role__scoped_permissions__code=DeptPerm.APPROVE_PROCUREMENT, + employee__is_active=True, + is_active=True, + ).values_list("employee_id", flat=True) + for user_id in legacy_role_user_ids: + add_user_id(user_id) + + users_by_id = Employee.objects.in_bulk(approver_ids) + return [ + users_by_id[user_id] + for user_id in approver_ids + if user_id in users_by_id + ] + + @classmethod + def user_can_approve_department_step(cls, user, procurement_request) -> bool: + if not user or not user.is_authenticated: + return False + return any( + approver.id == user.id + for approver in cls.get_department_approval_users( + procurement_request, + ) + ) + @classmethod def user_can_submit_for_approval(cls, user, procurement_request) -> bool: if not user or not user.is_authenticated: @@ -308,9 +365,9 @@ class ProcurementApprovalResolver: ) if route.resolver_type == ApprovalRoute.ResolverType.DEPARTMENT_HEAD: - head = procurement_request.department.head - if head and head.is_active: - return route.priority, head, step_name + approvers = cls.get_department_approval_users(procurement_request) + if approvers: + return route.priority, approvers[0], step_name return None if route.employee and route.employee.is_active: @@ -387,10 +444,29 @@ class ProcurementApprovalResolver: if first_pending is None: return None - return pending.filter( + direct_approval = pending.filter( approver=user, priority__gte=first_pending.priority, ).first() + if direct_approval is not None: + return direct_approval + + for approval in pending.filter(priority__gte=first_pending.priority): + if cls.approval_can_be_taken_by_user(approval, user): + return approval + return None + + @classmethod + def approval_can_be_taken_by_user(cls, approval, user) -> bool: + from .models import ApprovalRoute + + route = ApprovalRoute.objects.filter( + priority=approval.priority, + resolver_type=ApprovalRoute.ResolverType.DEPARTMENT_HEAD, + ).first() + if not route: + return False + return cls.user_can_approve_department_step(user, approval.request) @classmethod def get_available_higher_route(cls, user, procurement_request): @@ -426,6 +502,22 @@ class ProcurementApprovalResolver: step = cls.resolve_approval_step(procurement_request, route) if step and step[1].id == user.id: return step + if ( + route.resolver_type == ApprovalRoute.ResolverType.DEPARTMENT_HEAD + and cls.user_can_approve_department_step( + user, + procurement_request, + ) + ): + step_name = ( + step[2] + if step + else route.name or get_default_approval_step_name( + route.priority, + resolver_type=route.resolver_type, + ) + ) + return route.priority, user, step_name return None @classmethod diff --git a/backend/requests_app/notifications/handlers.py b/backend/requests_app/notifications/handlers.py index d4b27953..a7774a6a 100644 --- a/backend/requests_app/notifications/handlers.py +++ b/backend/requests_app/notifications/handlers.py @@ -22,8 +22,6 @@ def notify_new_request(request_obj): - Всем в копии (cc_users) - с пометкой "в копии" - Согласующему (approver) - Руководителям отделов - - Пользователям с правом can_process_requests - При sent_to_all_department=True отправляет всем сотрудникам отделов Args: @@ -105,26 +103,6 @@ def notify_new_request(request_obj): if request_obj.department.head.id != request_obj.employee.id: recipients_set.add(request_obj.department.head) - # 6. Пользователи с правом обрабатывать заявки в этих отделах - dept_ids = list(request_obj.departments.values_list("id", flat=True)) - if request_obj.department_id and request_obj.department_id not in dept_ids: - dept_ids.append(request_obj.department_id) - - if dept_ids: - dept_processors = ( - Employee.objects.filter( - departments_links__department_id__in=dept_ids, - departments_links__is_active=True, - departments_links__role__scoped_permissions__code=( - "can_process_requests" - ), - is_active=True, - ) - .exclude(id=request_obj.employee.id) - .distinct() - ) - recipients_set.update(dept_processors) - # Итоговое логирование logger.info( f"[notify_new_request] 📊 ИТОГО получателей: {len(recipients_set)}" diff --git a/backend/requests_app/tasks.py b/backend/requests_app/tasks.py index 11f6fa69..88e0e290 100644 --- a/backend/requests_app/tasks.py +++ b/backend/requests_app/tasks.py @@ -3,8 +3,10 @@ Celery tasks для автоматизации кадровых событий """ import logging +from datetime import datetime, time from celery import shared_task +from django.db.models import Q from django.utils import timezone logger = logging.getLogger(__name__) @@ -132,3 +134,40 @@ def cleanup_missed_returns(): """ logger.info("cleanup_missed_returns skipped: auto-return is disabled") return 0 + + +@shared_task +def process_due_personnel_actions(): + """Apply account effects for dismissal actions that are due today. + + Future dismissal actions are created from approved requests immediately, but + account and department deactivation must happen only when their effective + date arrives. This task also repairs missed runs by processing all due + dismissals. + """ + from api.v1.employees.views.actions import EmployeeActionViewSet + from employees.constants import ACTION_DISMISSED + from employees.models import EmployeeAction + + today = timezone.localdate() + end_of_today = timezone.make_aware(datetime.combine(today, time.max)) + + dismissal_employee_ids = EmployeeAction.objects.filter( + action=ACTION_DISMISSED, + date__lte=end_of_today, + ).filter( + Q(employee__is_active=True) + | Q(employee__departments_links__is_active=True) + | Q(employee__role_assignments__is_active=True) + | Q(employee__headed_departments__isnull=False) + ).values_list("employee_id", flat=True) + + employee_ids = sorted(set(dismissal_employee_ids)) + viewset = EmployeeActionViewSet() + processed = 0 + for employee_id in employee_ids: + viewset._sync_employee_account_state_by_id(employee_id) + processed += 1 + + logger.info("Processed due dismissal actions for %s employees", processed) + return processed diff --git a/backend/tests/api/v1/employees/test_department_roles.py b/backend/tests/api/v1/employees/test_department_roles.py index afb61c15..82d35adc 100644 --- a/backend/tests/api/v1/employees/test_department_roles.py +++ b/backend/tests/api/v1/employees/test_department_roles.py @@ -12,6 +12,7 @@ from employees.models import ( DepartmentRole, DepartmentPermission, EmployeeDepartment, # <-- если у тебя EmployeeDepartmentLink, замени импорт и упоминания ниже + RoleAssignment, ) from tests.conftest import _unique_phone @@ -175,6 +176,15 @@ def test_perm_choices_and_perms_and_set_perms(api_client: APIClient): assert {"manage_department", "change_department_head", "assign_department_role"}.issubset(codes) assert "manage_department_events" not in codes assert "publish_department_post" not in codes + assert "view_request" not in codes + assert "view_requestcomment" not in codes + assert "add_requestcomment" not in codes + process_choice = next( + item + for item in resp.json().get("results", []) + if item["code"] == "can_process_requests" + ) + assert process_choice["name"] == "Согласование закупок отдела" # set_perms (codes) url_set = reverse("api:v1:department-roles-set-perms", args=[role.id]) @@ -239,6 +249,109 @@ def test_back_compat_permissions_field_contains_ids(api_client: APIClient): assert resp.status_code == 200 data = resp.json() assert set(data["permissions"]) == {dp_manage.id, dp_assign.id} + assert data["active_assignments_count"] == 0 + assert data["ldap_linked"] is False + + +@pytest.mark.django_db +def test_update_role_name_and_permissions_and_reject_department_move(api_client: APIClient): + d1 = Department.objects.create(name="Dept1") + d2 = Department.objects.create(name="Dept2") + user = make_user("role-editor@example.com") + api_client.force_authenticate(user=user) + grant_assign_in_dept(user, d1) + + ensure_dept_perm("manage_department") + ensure_dept_perm("assign_department_role") + role = make_role(d1, "Reviewer", ["manage_department"]) + url_detail = reverse("api:v1:department-roles-detail", args=[role.id]) + + resp = api_client.patch( + url_detail, + { + "name": "Lead Reviewer", + "scoped_permission_codes": ["assign_department_role"], + }, + format="json", + ) + assert resp.status_code == 200 + data = resp.json() + assert data["name"] == "Lead Reviewer" + assert {p["code"] for p in data["permissions_verbose"]} == { + "assign_department_role" + } + + role.refresh_from_db() + assert role.department_id == d1.id + assert set(role.scoped_permissions.values_list("code", flat=True)) == { + "assign_department_role" + } + + resp = api_client.patch( + url_detail, + {"department": d2.id}, + format="json", + ) + assert resp.status_code == 400 + role.refresh_from_db() + assert role.department_id == d1.id + + +@pytest.mark.django_db +def test_delete_assigned_role_requires_force(api_client: APIClient): + dept = Department.objects.create(name="Dept") + manager = make_user("role-manager@example.com") + employee = make_user("role-holder@example.com") + legacy_employee = make_user("legacy-role-holder@example.com") + api_client.force_authenticate(user=manager) + grant_assign_in_dept(manager, dept) + + role = make_role(dept, "Operator", []) + RoleAssignment.objects.create(employee=employee, role=role, assigned_by=manager) + EmployeeDepartment.objects.create( + employee=legacy_employee, + department=dept, + is_active=True, + role=role, + ) + + url_detail = reverse("api:v1:department-roles-detail", args=[role.id]) + resp = api_client.delete(url_detail) + assert resp.status_code == status.HTTP_409_CONFLICT + assert resp.json()["active_assignments_count"] == 2 + assert DepartmentRole.objects.filter(id=role.id).exists() + + resp = api_client.delete(f"{url_detail}?force=true") + assert resp.status_code == status.HTTP_204_NO_CONTENT + assert not DepartmentRole.objects.filter(id=role.id).exists() + assert not RoleAssignment.objects.filter(role_id=role.id).exists() + assert EmployeeDepartment.objects.get( + employee=legacy_employee, department=dept + ).role_id is None + + +@pytest.mark.django_db +@override_settings(LDAP_ENABLED=True) +def test_delete_role_triggers_ldap_signal_not_view_service(api_client: APIClient): + with override_settings(LDAP_ENABLED=False): + dept = Department.objects.create(name="Dept LDAP Delete") + staff = make_user("staff-delete-ldap@example.com", staff=True) + role = DepartmentRole.objects.create( + department=dept, + name="LDAP Linked", + ldap_group_dn="CN=ROLE_LDAP_Linked,OU=Dept,DC=example,DC=local", + ) + + api_client.force_authenticate(user=staff) + url_detail = reverse("api:v1:department-roles-detail", args=[role.id]) + + with patch( + "employees.signals.ldap.role.DepartmentService.sync_role_delete" + ) as mock_sync: + resp = api_client.delete(url_detail) + + assert resp.status_code == status.HTTP_204_NO_CONTENT + mock_sync.assert_called_once() @pytest.mark.django_db def test_create_with_scoped_permission_codes_and_update_name(api_client: APIClient): diff --git a/backend/tests/api/v1/employees/test_role_assignment.py b/backend/tests/api/v1/employees/test_role_assignment.py index 428c2ec1..56b63681 100644 --- a/backend/tests/api/v1/employees/test_role_assignment.py +++ b/backend/tests/api/v1/employees/test_role_assignment.py @@ -8,6 +8,7 @@ from rest_framework.test import APIClient from unittest.mock import patch from django.contrib.auth import get_user_model +from employees.constants import DeptPerm from employees.models import ( Department, DepartmentPermission, DepartmentRole, EmployeeDepartment, RoleAssignment, @@ -61,7 +62,7 @@ class TestRoleAssignmentEndpoints: # Создаём admin, отдел и роль admin = make_user("admin@test.com", is_staff=True) dept = make_dept("IT") - role = make_role(dept, "Developer", ["view_request"]) + role = make_role(dept, "Developer", [DeptPerm.APPROVE_PROCUREMENT]) # Создаём сотрудника НЕ в отделе other_user = make_user("other@test.com") @@ -231,7 +232,11 @@ class TestRoleAssignmentPermissions: from api.v1.permissions import has_dept_perm dept = make_dept("IT") - role = make_role(dept, "Manager", ["manage_department", "view_request"]) + role = make_role( + dept, + "Manager", + ["manage_department", DeptPerm.APPROVE_PROCUREMENT], + ) user = make_user("manager@test.com") @@ -245,7 +250,7 @@ class TestRoleAssignmentPermissions: # Проверяем права assert has_dept_perm(user, dept.id, "manage_department") - assert has_dept_perm(user, dept.id, "view_request") + assert has_dept_perm(user, dept.id, DeptPerm.APPROVE_PROCUREMENT) assert not has_dept_perm(user, dept.id, "change_department_head") def test_inactive_assignment_does_not_grant_permission(self, api_client: APIClient): diff --git a/backend/tests/api/v1/notifications/test_notifications_api.py b/backend/tests/api/v1/notifications/test_notifications_api.py index f34e0db3..f8926a91 100644 --- a/backend/tests/api/v1/notifications/test_notifications_api.py +++ b/backend/tests/api/v1/notifications/test_notifications_api.py @@ -1,8 +1,10 @@ from unittest.mock import patch import pytest +from django.core.cache import cache from push_notifications.models import WebPushDevice +from notifications.cache import unread_summary_cache_key from notifications.models import Notification from notifications.models import UserChannelPreferences @@ -10,6 +12,90 @@ from notifications.models import UserChannelPreferences pytestmark = pytest.mark.django_db +class TestNotificationUnreadSummary: + def test_unread_summary_groups_unread_notifications_by_verb( + self, api_client, user_factory + ): + user = user_factory() + other_user = user_factory() + Notification.objects.create( + recipient=user, + verb="procurement_department_request", + description="Закупка", + data={"request_id": 10}, + ) + Notification.objects.create( + recipient=user, + verb="procurement_department_request", + description="Еще закупка", + data={"request_id": "12"}, + ) + Notification.objects.create( + recipient=user, + verb="chat_new_message", + description="Сообщение", + ) + Notification.objects.create( + recipient=user, + verb="document_uploaded", + description="Прочитанный документ", + unread=False, + ) + Notification.objects.create( + recipient=other_user, + verb="chat_new_message", + description="Чужое сообщение", + ) + + api_client.force_authenticate(user=user) + cache.delete(unread_summary_cache_key(user.id)) + + response = api_client.get("/api/v1/notifications/summary/") + + assert response.status_code == 200 + assert response.data["total"] == 3 + assert { + item["verb"]: item["unread"] + for item in response.data["verbs"] + } == { + "procurement_department_request": 2, + "chat_new_message": 1, + } + assert response.data["procurement_requests"] == [ + {"request_id": 10, "unread": 1}, + {"request_id": 12, "unread": 1}, + ] + + def test_unread_summary_cache_does_not_stay_stale_after_mark_as_read( + self, api_client, user_factory + ): + user = user_factory() + notification = Notification.objects.create( + recipient=user, + verb="request_updated", + description="Заявка обновлена", + ) + + api_client.force_authenticate(user=user) + cache.delete(unread_summary_cache_key(user.id)) + + response = api_client.get("/api/v1/notifications/summary/") + assert response.status_code == 200 + assert response.data["total"] == 1 + assert cache.get(unread_summary_cache_key(user.id)) is not None + + response = api_client.post( + f"/api/v1/notifications/{notification.id}/read/" + ) + assert response.status_code == 200 + cached = cache.get(unread_summary_cache_key(user.id)) + assert cached is None or cached["total"] == 0 + + response = api_client.get("/api/v1/notifications/summary/") + assert response.status_code == 200 + assert response.data["total"] == 0 + + class TestNotificationRealtimeEvents: def test_mark_as_read_sends_realtime_event(self, api_client, user_factory): user = user_factory() diff --git a/backend/tests/api/v1/procurement/test_procurement_requests.py b/backend/tests/api/v1/procurement/test_procurement_requests.py index 35fbcaa2..2e45dec0 100644 --- a/backend/tests/api/v1/procurement/test_procurement_requests.py +++ b/backend/tests/api/v1/procurement/test_procurement_requests.py @@ -7,6 +7,7 @@ from unittest.mock import patch import pytest from django.contrib.auth.models import Permission +from django.contrib.contenttypes.models import ContentType from django.urls import reverse from django.utils.dateparse import parse_datetime from django.utils import timezone @@ -15,8 +16,10 @@ from rest_framework import status from rest_framework.test import APIClient from communications.comments_helpers import get_comments +from employees.constants import DeptPerm from employees.models import ( Department, + DepartmentPermission, DepartmentRole, Employee, EmployeeDepartment, @@ -3325,6 +3328,39 @@ class TestProcurementRequestWorkflow: employee=director, ) + def _create_department_approval_role_user( + self, + department, + *, + email="department-approval-role@example.com", + phone_number="+79995555556", + ): + permission, _ = DepartmentPermission.objects.get_or_create( + code=DeptPerm.APPROVE_PROCUREMENT, + defaults={"name": "Согласование закупок отдела"}, + ) + role = DepartmentRole.objects.create( + department=department, + name=f"Согласующие закупки {email}", + ) + role.scoped_permissions.add(permission) + employee = Employee.objects.create_user( + email=email, + password='testpass123', + phone_number=phone_number, + first_name='Ролевой', + last_name='Согласующий', + is_active=True, + email_verified=True, + send_activation_email=False, + ) + RoleAssignment.objects.create( + employee=employee, + role=role, + is_active=True, + ) + return employee + def test_submit_notifies_only_current_stage_approver( self, api_client, user, procurement_request, procurement_item, budget, monkeypatch ): @@ -3355,6 +3391,124 @@ class TestProcurementRequestWorkflow: ] assert pending_recipients == ["head@example.com"] + def test_submit_notifies_department_head_and_role_approvers( + self, api_client, user, department, procurement_request, + procurement_item, monkeypatch + ): + role_approver = self._create_department_approval_role_user( + department, + ) + sent = [] + + def fake_notify_send(**kwargs): + sent.append(kwargs) + + monkeypatch.setattr( + "procurement.notifications.handlers.notify.send", + fake_notify_send, + ) + + api_client.force_authenticate(user=user) + url = reverse( + 'api:v1:procurement:procurementrequest-submit', + kwargs={'pk': procurement_request.id} + ) + + response = api_client.post(url) + + assert response.status_code == status.HTTP_200_OK + pending_recipients = sorted( + item["recipient"].email + for item in sent + if item["verb"] == "procurement_pending_approval" + ) + assert pending_recipients == sorted([ + "head@example.com", + role_approver.email, + ]) + + def test_department_role_user_can_approve_head_stage( + self, api_client, user, department, department_head, + procurement_request, procurement_item + ): + role_approver = self._create_department_approval_role_user( + department, + email="department-stage-approver@example.com", + phone_number="+79995555557", + ) + procurement_request.status = ProcurementStatus.PENDING + procurement_request.save() + approval = Approval.objects.create( + request=procurement_request, + approver=department_head, + priority=HEAD_PRIORITY, + status=ApprovalStatus.PENDING, + step_name="Руководитель отдела", + ) + + api_client.force_authenticate(user=role_approver) + pending_url = reverse( + 'api:v1:procurement:procurementrequest-pending-approvals' + ) + pending_response = api_client.get(pending_url) + assert pending_response.status_code == status.HTTP_200_OK + assert [ + item['id'] for item in pending_response.data['results'] + ] == [procurement_request.id] + assert pending_response.data['results'][0]['can_current_user_approve'] is True + + url = reverse( + 'api:v1:procurement:procurementrequest-approve', + kwargs={'pk': procurement_request.id} + ) + + response = api_client.post(url, {'comment': 'Согласовано ролью'}) + + assert response.status_code == status.HTTP_200_OK + approval.refresh_from_db() + procurement_request.refresh_from_db() + assert approval.approver_id == role_approver.id + assert approval.status == ApprovalStatus.APPROVED + assert procurement_request.status == ProcurementStatus.APPROVED + + def test_department_role_approver_allows_submit_without_head( + self, api_client, user, department + ): + department.head = None + department.save(update_fields=["head"]) + role_approver = self._create_department_approval_role_user( + department, + email="department-no-head-approver@example.com", + phone_number="+79995555558", + ) + procurement_request = ProcurementRequest.objects.create( + title="Заявка без начальника, но с ролью", + description="Проверка согласующей роли", + department=department, + requestor=user, + status=ProcurementStatus.DRAFT, + urgency=UrgencyLevel.MEDIUM, + ) + ProcurementItem.objects.create( + request=procurement_request, + name="Тестовая позиция", + quantity=1, + unit="шт", + estimated_unit_price=Decimal("12.00"), + ) + + api_client.force_authenticate(user=user) + url = reverse( + 'api:v1:procurement:procurementrequest-submit', + kwargs={'pk': procurement_request.id} + ) + + response = api_client.post(url) + + assert response.status_code == status.HTTP_200_OK + approval = procurement_request.approvals.get(priority=HEAD_PRIORITY) + assert approval.approver_id == role_approver.id + def test_approve_notifies_only_next_stage_approver( self, api_client, department_head, procurement_request, procurement_item, monkeypatch ): @@ -4427,6 +4581,87 @@ class TestProcurementRequestWorkflow: assert procurement_request.status == ProcurementStatus.CANCELLED +# ============================================================================== +# ТЕСТЫ УМНОГО ПРОЧТЕНИЯ УВЕДОМЛЕНИЙ +# ============================================================================== + + +class TestProcurementRequestNotificationRead: + """Тесты прочтения уведомлений, связанных с конкретной закупкой.""" + + def test_marks_only_current_request_procurement_notifications_read( + self, api_client, user, department, procurement_request + ): + request_content_type = ContentType.objects.get_for_model( + ProcurementRequest, + ) + other_request = ProcurementRequest.objects.create( + title="Другая закупка", + description="Другая заявка", + department=department, + requestor=user, + status=ProcurementStatus.DRAFT, + ) + + matching_by_data = Notification.objects.create( + recipient=user, + verb="procurement_department_request", + data={"request_id": procurement_request.id}, + ) + matching_by_target = Notification.objects.create( + recipient=user, + verb="procurement_request_commented", + target_content_type=request_content_type, + target_object_id=str(procurement_request.id), + data={}, + ) + other_request_notification = Notification.objects.create( + recipient=user, + verb="procurement_department_request", + data={"request_id": other_request.id}, + ) + non_procurement_notification = Notification.objects.create( + recipient=user, + verb="new_message", + data={"request_id": procurement_request.id}, + ) + already_read_notification = Notification.objects.create( + recipient=user, + verb="procurement_approved", + unread=False, + timestamp_read=timezone.now(), + data={"request_id": procurement_request.id}, + ) + + api_client.force_authenticate(user=user) + url = reverse( + 'api:v1:procurement:procurementrequest-mark-notifications-read', + kwargs={'pk': procurement_request.id}, + ) + + response = api_client.post(url) + + assert response.status_code == status.HTTP_200_OK + assert response.data["status"] == "success" + assert response.data["count"] == 2 + assert sorted(response.data["notification_ids"]) == sorted([ + matching_by_data.id, + matching_by_target.id, + ]) + + matching_by_data.refresh_from_db() + matching_by_target.refresh_from_db() + other_request_notification.refresh_from_db() + non_procurement_notification.refresh_from_db() + already_read_notification.refresh_from_db() + + assert matching_by_data.unread is False + assert matching_by_target.unread is False + assert other_request_notification.unread is True + assert non_procurement_notification.unread is True + assert already_read_notification.unread is False + + # ============================================================================== # ТЕСТЫ УДАЛЕНИЯ # ============================================================================== diff --git a/backend/tests/api/v1/requests_app/test_requests_departments.py b/backend/tests/api/v1/requests_app/test_requests_departments.py index 55c85d19..f9430917 100644 --- a/backend/tests/api/v1/requests_app/test_requests_departments.py +++ b/backend/tests/api/v1/requests_app/test_requests_departments.py @@ -12,6 +12,7 @@ from employees.models import ( DepartmentRole, EmployeeDepartment, ) +from employees.constants import DeptPerm from tests.test_config import API_REQUESTS_URL @@ -83,13 +84,13 @@ def dept_dataset(db, make_user, make_request, two_departments): @pytest.mark.django_db -def test_department_view_permission_does_not_grant_list_visibility( +def test_department_role_permission_does_not_grant_list_visibility( auth_client, make_user, dept_dataset ): """Права отдела не раскрывают заявки вне participant-only модели.""" data = dept_dataset manager = make_user(email=f"manager-{get_random_string(6).lower()}@example.com") - _grant_dept_perm(manager, data["dept1"], "view_request") + _grant_dept_perm(manager, data["dept1"], DeptPerm.MANAGE) resp = auth_client(manager).get(API_BASE) @@ -98,13 +99,13 @@ def test_department_view_permission_does_not_grant_list_visibility( @pytest.mark.django_db -def test_department_view_permission_does_not_grant_detail_visibility( +def test_department_role_permission_does_not_grant_detail_visibility( auth_client, make_user, dept_dataset ): """Даже детальный просмотр требует участия в заявке.""" data = dept_dataset manager = make_user(email=f"manager2-{get_random_string(6).lower()}@example.com") - _grant_dept_perm(manager, data["dept1"], "view_request") + _grant_dept_perm(manager, data["dept1"], DeptPerm.MANAGE) resp = auth_client(manager).get(f"{API_BASE}{data['req1'].id}/") @@ -112,16 +113,16 @@ def test_department_view_permission_does_not_grant_detail_visibility( @pytest.mark.django_db -def test_department_comment_permissions_do_not_grant_comment_access( +def test_department_role_permissions_do_not_grant_comment_access( auth_client, make_user, dept_dataset ): - """Права отдела на комментарии больше не открывают комментарии без участия.""" + """Права отдела не открывают комментарии без участия.""" data = dept_dataset viewer = make_user(email=f"viewer-{get_random_string(6).lower()}@example.com") adder = make_user(email=f"adder-{get_random_string(6).lower()}@example.com") - _grant_dept_perm(viewer, data["dept1"], "view_requestcomment") - _grant_dept_perm(adder, data["dept1"], "add_requestcomment") + _grant_dept_perm(viewer, data["dept1"], DeptPerm.MANAGE) + _grant_dept_perm(adder, data["dept1"], DeptPerm.MANAGE_FEED) get_resp = auth_client(viewer).get(f"{API_BASE}{data['req1'].id}/comments/") post_resp = auth_client(adder).post( @@ -143,7 +144,7 @@ def test_department_processor_can_approve_only_as_direct_recipient( processor = make_user( email=f"processor-{get_random_string(6).lower()}@example.com" ) - _grant_dept_perm(processor, data["dept1"], "can_process_requests") + _grant_dept_perm(processor, data["dept1"], DeptPerm.APPROVE_PROCUREMENT) data["req1"].recipients.add(processor) resp = auth_client(processor).post(f"{API_BASE}{data['req1'].id}/approve/") @@ -160,7 +161,7 @@ def test_department_processor_without_recipient_cannot_approve( processor = make_user( email=f"processor2-{get_random_string(6).lower()}@example.com" ) - _grant_dept_perm(processor, data["dept1"], "can_process_requests") + _grant_dept_perm(processor, data["dept1"], DeptPerm.APPROVE_PROCUREMENT) resp = auth_client(processor).post(f"{API_BASE}{data['req1'].id}/approve/") diff --git a/backend/tests/employees/test_department_permission_migration.py b/backend/tests/employees/test_department_permission_migration.py index fd0c766e..5caa5314 100644 --- a/backend/tests/employees/test_department_permission_migration.py +++ b/backend/tests/employees/test_department_permission_migration.py @@ -57,3 +57,64 @@ def test_migration_0047_removes_obsolete_department_permissions(): assert "manage_department" in codes finally: _migrate_to_latest() + + +def test_migration_0053_removes_obsolete_request_department_permissions(): + try: + old_apps = _migrate_to([("employees", "0052_employee_attendance_aliases")]) + Department = old_apps.get_model("employees", "Department") + DepartmentRole = old_apps.get_model("employees", "DepartmentRole") + DepartmentPermission = old_apps.get_model( + "employees", "DepartmentPermission" + ) + + dept = Department.objects.create(name="Dept") + role = DepartmentRole.objects.create(department=dept, name="Legacy") + obsolete_perms = [ + DepartmentPermission.objects.create( + code="view_request", + name="Просмотр заявлений отдела", + ), + DepartmentPermission.objects.create( + code="view_requestcomment", + name="Просмотр комментариев по заявлениям", + ), + DepartmentPermission.objects.create( + code="add_requestcomment", + name="Добавление коментариев по заявлениям", + ), + ] + procurement_perm = DepartmentPermission.objects.create( + code="can_process_requests", + name="Рассмотрение заявлений отдела", + ) + role.scoped_permissions.add(*obsolete_perms, procurement_perm) + + _migrate_to([("employees", "0053_cleanup_department_role_permissions")]) + + from employees.models import ( + DepartmentPermission as CurrentDepartmentPermission, + DepartmentRole as CurrentDepartmentRole, + ) + + codes = set( + CurrentDepartmentPermission.objects.values_list("code", flat=True) + ) + assert "view_request" not in codes + assert "view_requestcomment" not in codes + assert "add_requestcomment" not in codes + assert "can_process_requests" in codes + assert ( + CurrentDepartmentPermission.objects.get( + code="can_process_requests" + ).name + == "Согласование закупок отдела" + ) + + role_codes = set( + CurrentDepartmentRole.objects.get(pk=role.pk) + .scoped_permissions.values_list("code", flat=True) + ) + assert role_codes == {"can_process_requests"} + finally: + _migrate_to_latest() diff --git a/backend/tests/employees/test_department_role_ldap_sync.py b/backend/tests/employees/test_department_role_ldap_sync.py index cc297aad..ca5ac5d8 100644 --- a/backend/tests/employees/test_department_role_ldap_sync.py +++ b/backend/tests/employees/test_department_role_ldap_sync.py @@ -4,8 +4,15 @@ from unittest.mock import MagicMock, patch import pytest from django.contrib.auth import get_user_model +from employees.ldap.repositories.employee_repository import bind_user_department from employees.ldap.services.department_service import DepartmentService -from employees.models import Department, DepartmentRole, EmployeeDepartment, LdapSyncState +from employees.models import ( + Department, + DepartmentRole, + EmployeeDepartment, + LdapSyncState, + RoleAssignment, +) User = get_user_model() @@ -100,3 +107,121 @@ def test_reconcile_department_group_keeps_members_and_multiple_role_groups(): ) assert set(fake_group.member[1:]) == {role1.ldap_group_dn, role2.ldap_group_dn} fake_group.save.assert_called_once() + + +@pytest.mark.django_db +def test_reconcile_department_group_skips_inactive_employee_with_active_link(): + dept = Department.objects.create(name="LDAP Dept Inactive Guard") + active_employee = User.objects.create( + email="active-aggregate@example.com", + phone_number="+79990001001", + first_name="Active", + last_name="Member", + is_active=True, + email_verified=True, + ) + inactive_employee = User.objects.create( + email="inactive-aggregate@example.com", + phone_number="+79990001002", + first_name="Inactive", + last_name="Member", + is_active=False, + email_verified=True, + ) + EmployeeDepartment.objects.create( + employee=active_employee, + department=dept, + is_active=True, + ) + EmployeeDepartment.objects.create( + employee=inactive_employee, + department=dept, + is_active=True, + ) + active_dn = "CN=Active,OU=LDAP Dept Inactive Guard,OU=Departments,DC=example,DC=local" + inactive_dn = "CN=Inactive,OU=LDAP Dept Inactive Guard,OU=Departments,DC=example,DC=local" + LdapSyncState.objects.create( + model="employee", + object_pk=str(active_employee.pk), + ldap_dn=active_dn, + ) + LdapSyncState.objects.create( + model="employee", + object_pk=str(inactive_employee.pk), + ldap_dn=inactive_dn, + ) + fake_group = SimpleNamespace(member=[], save=MagicMock()) + + with patch.object( + DepartmentService, + "_ensure_department_group", + return_value=( + "CN=DEP_LDAP Dept Inactive Guard," + "OU=LDAP Dept Inactive Guard,OU=Departments,DC=example,DC=local" + ), + ), patch( + "employees.ldap.services.department_service.LdapOrganizationalUnitGroup.objects.get", + return_value=fake_group, + ): + DepartmentService()._reconcile_department_group( + dept, + "OU=LDAP Dept Inactive Guard,OU=Departments,DC=example,DC=local", + ) + + assert active_dn in fake_group.member + assert inactive_dn not in fake_group.member + fake_group.save.assert_called_once() + + +@pytest.mark.django_db +def test_resolve_member_roles_returns_no_roles_for_inactive_employee(): + dept = Department.objects.create(name="LDAP Role Inactive Guard") + role = DepartmentRole.objects.create(department=dept, name="Reviewer") + employee = User.objects.create( + email="inactive-role-member@example.com", + phone_number="+79990001003", + first_name="Inactive", + last_name="Role", + is_active=False, + email_verified=True, + ) + EmployeeDepartment.objects.create( + employee=employee, + department=dept, + role=role, + is_active=True, + ) + RoleAssignment.objects.create( + employee=employee, + role=role, + is_active=True, + ) + + assert DepartmentService()._resolve_member_roles(employee, dept) == [] + + +@pytest.mark.django_db +def test_bind_user_department_deactivates_links_for_inactive_employee(): + old_dept = Department.objects.create(name="Old LDAP Dept") + inactive_employee = User.objects.create( + email="inactive-import@example.com", + phone_number="+79990001004", + first_name="Inactive", + last_name="Import", + is_active=False, + email_verified=True, + ) + old_link = EmployeeDepartment.objects.create( + employee=inactive_employee, + department=old_dept, + is_active=True, + ) + + bind_user_department( + inactive_employee, + "CN=Inactive Import,OU=New LDAP Dept,OU=Departments,DC=example,DC=local", + ) + + old_link.refresh_from_db() + assert old_link.is_active is False + assert not Department.objects.filter(name="New LDAP Dept").exists() diff --git a/backend/tests/requests_app/test_request_actions.py b/backend/tests/requests_app/test_request_actions.py index 1775b81d..2bacf1d7 100644 --- a/backend/tests/requests_app/test_request_actions.py +++ b/backend/tests/requests_app/test_request_actions.py @@ -1,17 +1,29 @@ from __future__ import annotations from datetime import date, timedelta +from unittest.mock import patch import pytest from django.db import connection from django.db.migrations.executor import MigrationExecutor from django.utils import timezone -from employees.models import EmployeeAction +from employees.constants import ACTION_DISMISSED, ACTION_HIRED +from employees.models import ( + Department, + DepartmentRole, + EmployeeAction, + EmployeeDepartment, + RoleAssignment, +) from employees.services.request_actions import create_request_action from requests_app.enums import RequestStatus from requests_app.models import Request -from requests_app.tasks import create_scheduled_action, schedule_auto_return +from requests_app.tasks import ( + create_scheduled_action, + process_due_personnel_actions, + schedule_auto_return, +) pytestmark = pytest.mark.django_db @@ -132,6 +144,193 @@ def test_immediate_approval_is_idempotent(user_factory): assert actions.get().extra["request_id"] == request.id +@patch("api.v1.employees.views.actions.EmployeeActionViewSet._ensure_ldap_dn_location") +@patch("employees.signals.ldap.employee._is_ldap_enabled", return_value=True) +@patch("employees.signals.ldap.employee.UserService.update_user") +def test_same_day_dismissal_request_deactivates_profile_and_ldap( + mock_update, + mock_is_enabled, + mock_ensure_dn, + user_factory, +): + from employees.models import LdapSyncState + + employee = user_factory(is_ldap_managed=True) + approver = user_factory() + today = timezone.localdate() + department = Department.objects.create(name="Today Dismissal Dept") + link = EmployeeDepartment.objects.create( + employee=employee, + department=department, + is_active=True, + date_from=today - timedelta(days=30), + ) + LdapSyncState.objects.create( + model="employee", + object_pk=str(employee.pk), + ldap_dn=f"CN={employee.email},OU=Users,DC=example,DC=com", + last_sync_dir="ldap", + ) + request = Request.objects.create( + employee=employee, + type="dismissal", + status=RequestStatus.PENDING, + date_from=today, + ) + + request.status = RequestStatus.APPROVED + request.approver = approver + request.save() + + action = EmployeeAction.objects.get( + source_request=request, + action=ACTION_DISMISSED, + ) + employee.refresh_from_db() + link.refresh_from_db() + assert action.date.date() == today + assert employee.is_active is False + assert link.is_active is False + assert link.date_to == today + mock_update.assert_called_once() + assert mock_update.call_args.kwargs["emp"].id == employee.id + assert mock_update.call_args.kwargs["changes"] == {"is_active": False} + mock_ensure_dn.assert_called_once() + assert mock_ensure_dn.call_args.args[0].id == employee.id + + +@patch("api.v1.employees.views.actions.EmployeeActionViewSet._ensure_ldap_dn_location") +@patch("employees.signals.ldap.employee._is_ldap_enabled", return_value=True) +@patch("employees.signals.ldap.employee.UserService.update_user") +def test_past_dismissal_request_deactivates_immediately_with_request_date( + mock_update, + mock_is_enabled, + mock_ensure_dn, + user_factory, +): + from employees.models import LdapSyncState + + employee = user_factory(is_ldap_managed=True) + approver = user_factory() + dismissal_date = timezone.localdate() - timedelta(days=3) + EmployeeAction.objects.filter( + employee=employee, + action=ACTION_HIRED, + ).update(date=timezone.now() - timedelta(days=30)) + department = Department.objects.create(name="Past Dismissal Dept") + link = EmployeeDepartment.objects.create( + employee=employee, + department=department, + is_active=True, + date_from=dismissal_date - timedelta(days=30), + ) + LdapSyncState.objects.create( + model="employee", + object_pk=str(employee.pk), + ldap_dn=f"CN={employee.email},OU=Users,DC=example,DC=com", + last_sync_dir="ldap", + ) + request = Request.objects.create( + employee=employee, + type="dismissal", + status=RequestStatus.PENDING, + date_from=dismissal_date, + ) + + request.status = RequestStatus.APPROVED + request.approver = approver + request.save() + + action = EmployeeAction.objects.get( + source_request=request, + action=ACTION_DISMISSED, + ) + employee.refresh_from_db() + link.refresh_from_db() + assert action.date.date() == dismissal_date + assert employee.is_active is False + assert link.is_active is False + assert link.date_to == dismissal_date + mock_update.assert_called_once() + assert mock_update.call_args.kwargs["emp"].id == employee.id + assert mock_update.call_args.kwargs["changes"] == {"is_active": False} + mock_ensure_dn.assert_called_once() + assert mock_ensure_dn.call_args.args[0].id == employee.id + + +def test_future_dismissal_request_deactivates_on_effective_date( + user_factory, + monkeypatch, +): + employee = user_factory() + approver = user_factory() + today = timezone.localdate() + dismissal_date = today + timedelta(days=1) + department = Department.objects.create(name="Dismissal Dept", head=employee) + link = EmployeeDepartment.objects.create( + employee=employee, + department=department, + is_active=True, + date_from=today - timedelta(days=30), + ) + role = DepartmentRole.objects.create( + department=department, + name="Dismissal Role", + ) + assignment = RoleAssignment.objects.create( + employee=employee, + role=role, + is_active=True, + ) + request = Request.objects.create( + employee=employee, + type="dismissal", + status=RequestStatus.PENDING, + date_from=dismissal_date, + ) + + request.status = RequestStatus.APPROVED + request.approver = approver + request.save() + + action = EmployeeAction.objects.get( + source_request=request, + action=ACTION_DISMISSED, + ) + assert action.date.date() == dismissal_date + employee.refresh_from_db() + link.refresh_from_db() + assignment.refresh_from_db() + department.refresh_from_db() + assert employee.is_active is True + assert link.is_active is True + assert assignment.is_active is True + assert department.head_id == employee.id + + monkeypatch.setattr( + "requests_app.tasks.timezone.localdate", + lambda: dismissal_date, + ) + monkeypatch.setattr( + "api.v1.employees.views.actions.timezone.localdate", + lambda: dismissal_date, + ) + + result = process_due_personnel_actions.apply() + + assert result.result == 1 + employee.refresh_from_db() + link.refresh_from_db() + assignment.refresh_from_db() + department.refresh_from_db() + assert employee.is_active is False + assert link.is_active is False + assert link.date_to == dismissal_date + assert assignment.is_active is False + assert department.head_id is None + assert process_due_personnel_actions.apply().result == 0 + + def test_helper_returns_existing_legacy_action(user_factory): employee = user_factory() approver = user_factory() diff --git a/frontend/src/app/departments/[id]/page.tsx b/frontend/src/app/departments/[id]/page.tsx index a2809625..da4a8fe0 100644 --- a/frontend/src/app/departments/[id]/page.tsx +++ b/frontend/src/app/departments/[id]/page.tsx @@ -43,10 +43,15 @@ import { Modal } from "@/components/ui/Modal"; import { useDepartmentPage } from "@/hooks/useDepartmentPage"; import { apiClient } from "@/lib/api"; import { displayUserName, userProfileLink } from "@/lib/shared"; -import type { DepartmentMemberLink, User } from "@/types/api"; +import type { DepartmentMemberLink, DepartmentPermissionChoice, DepartmentRole, User } from "@/types/api"; import { toast } from "sonner"; type DepartmentMemberModalMode = "add" | "assignRole"; +type DepartmentRoleDraft = { + id: number | null; + name: string; + permissionIds: number[]; +}; function MetaChip({ children, @@ -681,6 +686,8 @@ function AddMemberModal({ function RoleEditorModal({ isOpen, loading, + permissionChoices, + permissionsLoading, onClose, onDraftChange, onSave, @@ -688,23 +695,38 @@ function RoleEditorModal({ }: { isOpen: boolean; loading: boolean; + permissionChoices: DepartmentPermissionChoice[]; + permissionsLoading: boolean; onClose: () => void; onDraftChange: ( next: - | { id: number | null; name: string } - | ((current: { id: number | null; name: string }) => { - id: number | null; - name: string; - }), + | DepartmentRoleDraft + | ((current: DepartmentRoleDraft) => DepartmentRoleDraft), ) => void; onSave: () => Promise; - roleDraft: { id: number | null; name: string }; + roleDraft: DepartmentRoleDraft; }) { + const selectedIds = new Set(roleDraft.permissionIds); + const togglePermission = (permissionId: number) => { + onDraftChange((current) => { + const nextIds = new Set(current.permissionIds); + if (nextIds.has(permissionId)) { + nextIds.delete(permissionId); + } else { + nextIds.add(permissionId); + } + return { + ...current, + permissionIds: Array.from(nextIds), + }; + }); + }; + return ( @@ -726,25 +748,206 @@ function RoleEditorModal({ } > -
- - - onDraftChange((current) => ({ - ...current, - name: event.target.value, - })) - } - className="app-input w-full rounded-lg px-3 py-2 text-sm" - placeholder="Например, Координатор отдела" - /> -

- Роль нужна для аккуратного описания состава отдела и назначения участникам. -

-
+
+
+ + + onDraftChange((current) => ({ + ...current, + name: event.target.value, + })) + } + className="app-input w-full rounded-lg px-3 py-2 text-sm" + placeholder="Например, Координатор отдела" + /> +
+ +
+
+

+ Права роли +

+ + Выбрано: {roleDraft.permissionIds.length} + +
+ + {permissionsLoading ? ( +

Загружаем права...

+ ) : permissionChoices.length ? ( +
+ {permissionChoices.map((permission) => { + const checked = selectedIds.has(permission.id); + return ( + + ); + })} +
+ ) : ( +

Права ролей не найдены

+ )} +
+
+
+ ); +} + +function RoleManagementModal({ + isOpen, + onClose, + onCreateRole, + onDeleteRole, + onEditRole, + pendingKey, + roles, + roleUsage, +}: { + isOpen: boolean; + onClose: () => void; + onCreateRole: () => void; + onDeleteRole: (role: DepartmentRole) => Promise; + onEditRole: (role: DepartmentRole) => void; + pendingKey: string | null; + roles: DepartmentRole[]; + roleUsage: Record; +}) { + return ( + + + + + } + > + {roles.length ? ( +
+ {roles.map((role) => { + const activeAssignmentsCount = + role.active_assignments_count ?? roleUsage[role.id] ?? 0; + const isDeleting = pendingKey === `role-delete-${role.id}`; + const permissions = role.permissions_verbose || []; + + return ( +
+
+
+
+

+ {role.name} +

+ {role.ldap_linked ? ( + + + LDAP + + ) : null} + + {activeAssignmentsCount} назначений + +
+ +
+ {permissions.length ? ( + permissions.map((permission) => ( + + {permission.name} + + )) + ) : ( + + Права не назначены + + )} +
+
+ +
+ + +
+
+
+ ); + })} +
+ ) : ( +
+

+ Ролей пока нет +

+

+ Создайте первую роль и назначьте ей права отдела. +

+
+ )}
); } @@ -760,6 +963,7 @@ export default function DepartmentDetailPage() { const roleMenuRef = useRef(null); const [departmentMenuOpen, setDepartmentMenuOpen] = useState(false); const departmentMenuRef = useRef(null); + const [roleManagementOpen, setRoleManagementOpen] = useState(false); const [openingDepartmentChat, setOpeningDepartmentChat] = useState(false); const [openingDepartmentCalendar, setOpeningDepartmentCalendar] = useState(false); @@ -971,6 +1175,20 @@ export default function DepartmentDetailPage() { ) : null} + {h.userPerms.can_assign_roles ? ( + + ) : null} + {h.userPerms.can_manage ? ( diff --git a/frontend/src/components/requests/SwipeApprovalMode.tsx b/frontend/src/components/requests/SwipeApprovalMode.tsx index 71886d56..f0762fe2 100644 --- a/frontend/src/components/requests/SwipeApprovalMode.tsx +++ b/frontend/src/components/requests/SwipeApprovalMode.tsx @@ -359,7 +359,7 @@ export default function SwipeApprovalMode({ requests, onApprove, onReject, onClo type="button" onClick={() => { setFlyOut("left"); setTimeout(() => performAction("rejected"), 250); }} disabled={busy} - className="app-feedback-danger flex h-14 w-14 items-center justify-center rounded-full transition hover:shadow-md active:scale-95 disabled:opacity-50" + className="app-action-reject flex h-14 w-14 items-center justify-center rounded-full transition hover:shadow-md active:scale-95 disabled:opacity-50" title="Отклонить (←)" > diff --git a/frontend/src/contexts/NotificationsContext.tsx b/frontend/src/contexts/NotificationsContext.tsx index 7e9735c8..56e38bea 100644 --- a/frontend/src/contexts/NotificationsContext.tsx +++ b/frontend/src/contexts/NotificationsContext.tsx @@ -3,6 +3,10 @@ import React, { createContext, useContext, useEffect, useState, useCallback, useRef } from 'react'; import apiClient from '@/lib/api'; import wsManager from '@/lib/websocketManager'; +import { getVerbCategory } from '@/lib/verbTranslations'; + +const NOTIFICATIONS_SYNC_INTERVAL_MS = 30000; +const UNREAD_NOTIFICATIONS_PAGE_SIZE = 50; export type NotificationItem = { id: number; @@ -33,8 +37,11 @@ type NotificationSocketEvent = { interface NotificationsContextType { notifications: NotificationItem[]; unreadCount: number; + unreadCategoryCounts: Record; + unreadProcurementRequestCounts: Record; loading: boolean; error: Error | null; + refreshUnreadSummary: () => Promise; markAsRead: (id: number) => Promise; markAllAsRead: () => Promise; markCategoryAsRead: (category: string) => Promise; @@ -49,10 +56,46 @@ export function NotificationsProvider({ children }: { children: React.ReactNode const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [unreadCount, setUnreadCount] = useState(0); + const [unreadCategoryCounts, setUnreadCategoryCounts] = useState>({}); + const [unreadProcurementRequestCounts, setUnreadProcurementRequestCounts] = useState>({}); const [isWsConnected, setIsWsConnected] = useState( typeof window !== 'undefined' && wsManager.getState() === WebSocket.OPEN ); const fetchInFlightRef = useRef | null>(null); + const summaryInFlightRef = useRef | null>(null); + + const applyUnreadSummary = useCallback((summary: { + total?: number; + verbs?: Array<{ verb?: string; unread?: number }>; + procurement_requests?: Array<{ request_id?: number; unread?: number }>; + }) => { + const nextCategoryCounts: Record = {}; + const nextProcurementRequestCounts: Record = {}; + const verbs = Array.isArray(summary.verbs) ? summary.verbs : []; + const procurementRequests = Array.isArray(summary.procurement_requests) + ? summary.procurement_requests + : []; + + verbs.forEach((item) => { + if (!item.verb || typeof item.unread !== 'number' || item.unread <= 0) { + return; + } + const category = getVerbCategory(item.verb); + nextCategoryCounts[category] = (nextCategoryCounts[category] || 0) + item.unread; + }); + + procurementRequests.forEach((item) => { + const requestId = Number(item.request_id); + if (!Number.isFinite(requestId) || requestId <= 0 || typeof item.unread !== 'number' || item.unread <= 0) { + return; + } + nextProcurementRequestCounts[requestId] = item.unread; + }); + + setUnreadCategoryCounts(nextCategoryCounts); + setUnreadProcurementRequestCounts(nextProcurementRequestCounts); + setUnreadCount(typeof summary.total === 'number' ? summary.total : verbs.reduce((sum, item) => sum + (item.unread || 0), 0)); + }, []); const syncUnreadNotifications = useCallback(async (showLoader = false) => { if (fetchInFlightRef.current) { @@ -66,7 +109,7 @@ export function NotificationsProvider({ children }: { children: React.ReactNode try { const data = await apiClient.getNotifications({ - page_size: 50, + page_size: UNREAD_NOTIFICATIONS_PAGE_SIZE, unread_only: true, }); const notifs = data.notifications || data.results || data; @@ -91,10 +134,32 @@ export function NotificationsProvider({ children }: { children: React.ReactNode return request; }, []); + const syncUnreadSummary = useCallback(async () => { + if (summaryInFlightRef.current) { + return summaryInFlightRef.current; + } + + const request = (async () => { + try { + const summary = await apiClient.getUnreadNotificationsSummary(); + applyUnreadSummary(summary); + setError(null); + } catch (err) { + setError(err as Error); + } finally { + summaryInFlightRef.current = null; + } + })(); + + summaryInFlightRef.current = request; + return request; + }, [applyUnreadSummary]); + // Загрузка начальных данных useEffect(() => { void syncUnreadNotifications(true); - }, [syncUnreadNotifications]); + void syncUnreadSummary(); + }, [syncUnreadNotifications, syncUnreadSummary]); // ЕДИНСТВЕННЫЙ WebSocket через singleton manager useEffect(() => { @@ -123,6 +188,22 @@ export function NotificationsProvider({ children }: { children: React.ReactNode const isRead = incomingNotification.is_read ?? !incomingNotification.unread; if (!isRead) { setUnreadCount(prev => prev + 1); + if (incomingNotification.verb) { + const category = getVerbCategory(incomingNotification.verb); + setUnreadCategoryCounts(prev => ({ + ...prev, + [category]: (prev[category] || 0) + 1, + })); + if (category === 'Закупки') { + const requestId = Number(incomingNotification.data?.request_id); + if (Number.isFinite(requestId) && requestId > 0) { + setUnreadProcurementRequestCounts(prev => ({ + ...prev, + [requestId]: (prev[requestId] || 0) + 1, + })); + } + } + } } return; } @@ -134,6 +215,7 @@ export function NotificationsProvider({ children }: { children: React.ReactNode if (typeof data.unread_count === 'number') { setUnreadCount(data.unread_count); } + void syncUnreadSummary(); return; } @@ -150,11 +232,13 @@ export function NotificationsProvider({ children }: { children: React.ReactNode if (typeof data.unread_count === 'number') { setUnreadCount(data.unread_count); } + void syncUnreadSummary(); return; } - if (data.type === 'unread_count' && typeof data.count === 'number') { + if (['unread_count', 'notification_count_update'].includes(data.type) && typeof data.count === 'number') { setUnreadCount(data.count); + void syncUnreadSummary(); } }); @@ -166,21 +250,21 @@ export function NotificationsProvider({ children }: { children: React.ReactNode unsubscribeMessages(); unsubscribeStatus(); }; - }, []); + }, [syncUnreadSummary]); - // API fallback на время разрыва WebSocket и при возврате фокуса + // Регулярная сверка с API нужна даже при живом WebSocket: часть событий может не дойти по сокету. useEffect(() => { - if (typeof window === 'undefined' || isWsConnected || !localStorage.getItem('access_token')) { + if (typeof window === 'undefined' || !localStorage.getItem('access_token')) { return; } const sync = () => { - void syncUnreadNotifications(false); + void syncUnreadSummary(); }; sync(); - const intervalId = window.setInterval(sync, 15000); + const intervalId = window.setInterval(sync, NOTIFICATIONS_SYNC_INTERVAL_MS); const handleFocus = () => sync(); const handleVisibilityChange = () => { if (document.visibilityState === 'visible') { @@ -196,46 +280,78 @@ export function NotificationsProvider({ children }: { children: React.ReactNode window.removeEventListener('focus', handleFocus); document.removeEventListener('visibilitychange', handleVisibilityChange); }; - }, [isWsConnected, syncUnreadNotifications]); + }, [syncUnreadSummary]); useEffect(() => { if (!isWsConnected) { return; } - void syncUnreadNotifications(false); - }, [isWsConnected, syncUnreadNotifications]); + void syncUnreadSummary(); + }, [isWsConnected, syncUnreadSummary]); const markAsRead = useCallback(async (id: number) => { await apiClient.markNotificationAsRead(id); + let readVerb: string | undefined; setNotifications(prev => - prev.map(n => (n.id === id ? { ...n, is_read: true, unread: false } : n)) + prev.map(n => { + if (n.id !== id) { + return n; + } + if (!n.is_read && n.unread !== false) { + readVerb = n.verb; + } + return { ...n, is_read: true, unread: false }; + }) ); setUnreadCount(prev => Math.max(0, prev - 1)); - }, []); + if (readVerb) { + const category = getVerbCategory(readVerb); + setUnreadCategoryCounts(prev => ({ + ...prev, + [category]: Math.max(0, (prev[category] || 0) - 1), + })); + } + void syncUnreadSummary(); + }, [syncUnreadSummary]); const markAllAsRead = useCallback(async () => { await apiClient.markAllNotificationsAsRead(); setNotifications(prev => prev.map(n => ({ ...n, is_read: true, unread: false }))); setUnreadCount(0); - }, []); + setUnreadCategoryCounts({}); + setUnreadProcurementRequestCounts({}); + void syncUnreadSummary(); + }, [syncUnreadSummary]); const markCategoryAsRead = useCallback(async (category: string) => { await apiClient.markCategoryAsRead(category); setNotifications(prev => { const updatedNotifications = prev.map(n => - n.category === category ? { ...n, is_read: true, unread: false } : n + n.verb && getVerbCategory(n.verb) === category + ? { ...n, is_read: true, unread: false } + : n ); const newUnreadCount = updatedNotifications.filter(n => !n.is_read && n.unread !== false).length; setUnreadCount(newUnreadCount); return updatedNotifications; }); - }, []); + setUnreadCategoryCounts(prev => { + const next = { ...prev }; + delete next[category]; + return next; + }); + if (category === 'Закупки') { + setUnreadProcurementRequestCounts({}); + } + void syncUnreadSummary(); + }, [syncUnreadSummary]); const deleteNotification = useCallback(async (id: number) => { await apiClient.deleteNotification(id); setNotifications(prev => prev.filter(n => n.id !== id)); - }, []); + void syncUnreadSummary(); + }, [syncUnreadSummary]); const deleteAllRead = useCallback(async (): Promise => { const result = await apiClient.deleteAllReadNotifications(); @@ -246,8 +362,11 @@ export function NotificationsProvider({ children }: { children: React.ReactNode const value = { notifications, unreadCount, + unreadCategoryCounts, + unreadProcurementRequestCounts, loading, error, + refreshUnreadSummary: syncUnreadSummary, markAsRead, markAllAsRead, markCategoryAsRead, diff --git a/frontend/src/hooks/useDepartmentPage.ts b/frontend/src/hooks/useDepartmentPage.ts index bbf2f02d..9dc1f901 100644 --- a/frontend/src/hooks/useDepartmentPage.ts +++ b/frontend/src/hooks/useDepartmentPage.ts @@ -11,6 +11,7 @@ import { loadAllPages } from "@/lib/shared"; import type { Department, DepartmentMemberLink, + DepartmentPermissionChoice, DepartmentRole, DepartmentUserPermissions, PaginatedResponse, @@ -20,6 +21,7 @@ import type { type DepartmentRoleDraft = { id: number | null; name: string; + permissionIds: number[]; }; type DepartmentMemberModalMode = "add" | "assignRole"; @@ -55,6 +57,8 @@ export type DepartmentPageController = { pendingKey: SavingKey | null; roleDraft: DepartmentRoleDraft; roleEditorOpen: boolean; + rolePermissionChoices: DepartmentPermissionChoice[]; + rolePermissionChoicesLoading: boolean; roleUsage: Record; roles: DepartmentRole[]; selectableEmployees: User[]; @@ -139,7 +143,10 @@ export function useDepartmentPage(departmentId: number): DepartmentPageControlle const [roleDraft, setRoleDraft] = useState({ id: null, name: "", + permissionIds: [], }); + const [rolePermissionChoices, setRolePermissionChoices] = useState([]); + const [rolePermissionChoicesLoading, setRolePermissionChoicesLoading] = useState(false); const hydrateDepartmentDraft = useCallback((nextDepartment: Department | null) => { setDepartmentDraft({ @@ -285,6 +292,21 @@ export function useDepartmentPage(departmentId: number): DepartmentPageControlle await loadEmployeesDirectory(); }, [loadEmployeesDirectory]); + const loadRolePermissionChoices = useCallback(async () => { + if (rolePermissionChoices.length > 0 || rolePermissionChoicesLoading) return; + + try { + setRolePermissionChoicesLoading(true); + const response = await apiClient.getDepartmentRolePermChoices() as PaginatedResponse | { results?: DepartmentPermissionChoice[] } | DepartmentPermissionChoice[]; + const results = Array.isArray(response) ? response : (response.results || []); + setRolePermissionChoices([...results].sort((a, b) => a.name.localeCompare(b.name, "ru"))); + } catch (loadError) { + toast.error(getErrorMessage(loadError, "Не удалось загрузить права ролей")); + } finally { + setRolePermissionChoicesLoading(false); + } + }, [rolePermissionChoices.length, rolePermissionChoicesLoading]); + const updateDepartmentDraft = useCallback((patch: Partial<{ name: string; description: string }>) => { setDepartmentDraft((current) => ({ ...current, ...patch })); }, []); @@ -547,21 +569,24 @@ export function useDepartmentPage(departmentId: number): DepartmentPageControlle }, [department, loadCore]); const openCreateRole = useCallback(() => { - setRoleDraft({ id: null, name: "" }); + setRoleDraft({ id: null, name: "", permissionIds: [] }); setRoleEditorOpen(true); - }, []); + void loadRolePermissionChoices(); + }, [loadRolePermissionChoices]); const openEditRole = useCallback((role: DepartmentRole) => { setRoleDraft({ id: role.id, name: role.name, + permissionIds: [...(role.permissions || [])], }); setRoleEditorOpen(true); - }, []); + void loadRolePermissionChoices(); + }, [loadRolePermissionChoices]); const closeRoleEditor = useCallback(() => { setRoleEditorOpen(false); - setRoleDraft({ id: null, name: "" }); + setRoleDraft({ id: null, name: "", permissionIds: [] }); }, []); const saveRole = useCallback(async () => { @@ -578,12 +603,14 @@ export function useDepartmentPage(departmentId: number): DepartmentPageControlle if (roleDraft.id) { await apiClient.updateDepartmentRole(roleDraft.id, { name, + scoped_permissions: roleDraft.permissionIds, }); toast.success("Роль обновлена"); } else { await apiClient.createDepartmentRole({ department: department.id, name, + scoped_permissions: roleDraft.permissionIds, }); toast.success("Роль создана"); } @@ -594,14 +621,18 @@ export function useDepartmentPage(departmentId: number): DepartmentPageControlle } finally { setPendingKey(null); } - }, [closeRoleEditor, department, loadCore, roleDraft.id, roleDraft.name]); + }, [closeRoleEditor, department, loadCore, roleDraft.id, roleDraft.name, roleDraft.permissionIds]); const submitRoleDelete = useCallback(async (role: DepartmentRole) => { - if (!window.confirm(`Удалить роль «${role.name}»?`)) return; + const activeAssignmentsCount = role.active_assignments_count ?? roleUsage[role.id] ?? 0; + const message = activeAssignmentsCount > 0 + ? `Удалить роль «${role.name}»? Она выдана сотрудникам: ${activeAssignmentsCount}. Назначения будут сняты.` + : `Удалить роль «${role.name}»?`; + if (!window.confirm(message)) return; try { setPendingKey(`role-delete-${role.id}`); - await apiClient.deleteDepartmentRole(role.id); + await apiClient.deleteDepartmentRole(role.id, { force: activeAssignmentsCount > 0 }); await loadCore(); toast.success("Роль удалена"); } catch (submitError) { @@ -609,7 +640,7 @@ export function useDepartmentPage(departmentId: number): DepartmentPageControlle } finally { setPendingKey(null); } - }, [loadCore]); + }, [loadCore, roleUsage]); return { addMemberOpen, @@ -632,6 +663,8 @@ export function useDepartmentPage(departmentId: number): DepartmentPageControlle pendingKey, roleDraft, roleEditorOpen, + rolePermissionChoices, + rolePermissionChoicesLoading, roleUsage, roles, selectableEmployees, diff --git a/frontend/src/hooks/useProcurementPage.ts b/frontend/src/hooks/useProcurementPage.ts index 41c907a8..3dc09d33 100644 --- a/frontend/src/hooks/useProcurementPage.ts +++ b/frontend/src/hooks/useProcurementPage.ts @@ -1,6 +1,7 @@ "use client"; import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; +import { useNotifications } from "@/contexts/NotificationsContext"; import { apiClient } from "@/lib/api"; import { canManageRequests, canManageSupplier } from "@/lib/permissions"; import { cleanLinkRows, toLinkRows, validateLinkRows } from "@/lib/procurementLinks"; @@ -134,6 +135,10 @@ const getReadableError = (error: unknown, fallback: string): string => { }; export function useProcurementPage(user: User | null) { + const { + refreshUnreadSummary, + unreadProcurementRequestCounts, + } = useNotifications(); const canManage = canManageRequests(user); const canSupplierManage = canManageSupplier(user); @@ -555,6 +560,15 @@ export function useProcurementPage(user: User | null) { return detail; }, []); + const markRequestNotificationsRead = useCallback(async (id: number) => { + try { + await apiClient.markProcurementRequestNotificationsRead(id); + void refreshUnreadSummary(); + } catch (readError) { + console.error("Mark procurement notifications read error:", readError); + } + }, [refreshUnreadSummary]); + const saveRequest = useCallback(async () => { try { setBusyKey("save"); @@ -628,12 +642,15 @@ export function useProcurementPage(user: User | null) { resetForm(); await refreshCurrentView(); + if (editingId) { + await markRequestNotificationsRead(editingId); + } } catch (saveError) { setActionError(getReadableError(saveError, "Ошибка сохранения")); } finally { setBusyKey(null); } - }, [editingId, form, modalMode, refreshCurrentView, resetForm]); + }, [editingId, form, markRequestNotificationsRead, modalMode, refreshCurrentView, resetForm]); const handleSave = useCallback(() => saveRequest(), [saveRequest]); @@ -642,6 +659,7 @@ export function useProcurementPage(user: User | null) { setBusyKey(key); setActionError(null); await action(); + await markRequestNotificationsRead(id); toast.success(successMessage); await refreshAfterMutation(id); return true; @@ -651,7 +669,7 @@ export function useProcurementPage(user: User | null) { } finally { setBusyKey(null); } - }, [refreshAfterMutation]); + }, [markRequestNotificationsRead, refreshAfterMutation]); const handleLoadMore = useCallback(async () => { if (!nextPage || loadingMore) return; @@ -676,6 +694,8 @@ export function useProcurementPage(user: User | null) { }, [buildParams, loadingMore, nextPage, scope]); const toggleExpand = useCallback(async (id: number) => { + const willOpen = !expandedIds.has(id); + setExpandedIds((previous) => { const next = new Set(previous); if (next.has(id)) { @@ -686,14 +706,17 @@ export function useProcurementPage(user: User | null) { return next; }); - if (!detailsCacheRef.current[id]) { + if (willOpen && !detailsCacheRef.current[id]) { try { await ensureRequestDetail(id); } catch { // ignore detail loading failures for collapsed rows } } - }, [ensureRequestDetail]); + if (willOpen) { + await markRequestNotificationsRead(id); + } + }, [ensureRequestDetail, expandedIds, markRequestNotificationsRead]); const ensureCommentsLoaded = useCallback(async (id: number) => { if (commentsMap[id]) { @@ -800,12 +823,13 @@ export function useProcurementPage(user: User | null) { [id]: { ...detail, comments_count: (detail.comments_count || 0) + 1 }, }; }); + await markRequestNotificationsRead(id); } catch { setActionError("Не удалось добавить комментарий"); } finally { setBusyKey(null); } - }, [commentDrafts]); + }, [commentDrafts, markRequestNotificationsRead]); const handleAddItemComment = useCallback(async (requestId: number, itemId: number) => { const text = (itemCommentDrafts[itemId] || "").trim(); @@ -823,12 +847,13 @@ export function useProcurementPage(user: User | null) { ...item, comments_count: (item.comments_count || 0) + 1, })); + await markRequestNotificationsRead(requestId); } catch { setActionError("Не удалось добавить комментарий к позиции"); } finally { setBusyKey(null); } - }, [itemCommentDrafts, updateCachedItem]); + }, [itemCommentDrafts, markRequestNotificationsRead, updateCachedItem]); const handleDeleteComment = useCallback(async (requestId: number, commentId: number) => { try { @@ -851,12 +876,13 @@ export function useProcurementPage(user: User | null) { [requestId]: { ...detail, comments_count: Math.max(0, (detail.comments_count || 0) - 1) }, }; }); + await markRequestNotificationsRead(requestId); } catch { setActionError("Не удалось удалить комментарий"); } finally { setBusyKey(null); } - }, []); + }, [markRequestNotificationsRead]); const handleDeleteItemComment = useCallback(async (requestId: number, itemId: number, commentId: number) => { try { @@ -870,12 +896,13 @@ export function useProcurementPage(user: User | null) { ...item, comments_count: Math.max(0, (item.comments_count || 0) - 1), })); + await markRequestNotificationsRead(requestId); } catch { setActionError("Не удалось удалить комментарий позиции"); } finally { setBusyKey(null); } - }, [updateCachedItem]); + }, [markRequestNotificationsRead, updateCachedItem]); const handleSubmit = useCallback((id: number) => doAction( `submit-${id}`, @@ -926,6 +953,9 @@ export function useProcurementPage(user: User | null) { setActionError(null); const updated = await apiClient.setProcurementRequestViewed(id, isViewed) as ProcurementRequest; applyRequestStateUpdate(updated); + if (isViewed) { + await markRequestNotificationsRead(id); + } if (isViewed) { setExpandedIds((previous) => { if (!previous.has(id)) { @@ -949,7 +979,7 @@ export function useProcurementPage(user: User | null) { } finally { setBusyKey(null); } - }, [applyRequestStateUpdate]); + }, [applyRequestStateUpdate, markRequestNotificationsRead]); const handleUpdateItem = useCallback(async (requestId: number, itemId: number, patch: Record) => { try { @@ -963,6 +993,7 @@ export function useProcurementPage(user: User | null) { } } await apiClient.updateProcurementItem(itemId, patch); + await markRequestNotificationsRead(requestId); toast.success("Позиция обновлена."); await refreshAfterMutation(requestId); return true; @@ -972,13 +1003,14 @@ export function useProcurementPage(user: User | null) { } finally { setBusyKey(null); } - }, [refreshAfterMutation]); + }, [markRequestNotificationsRead, refreshAfterMutation]); const handleReportItemIssue = useCallback(async (requestId: number, itemId: number, text = "") => { try { setBusyKey(`item-issue-${itemId}`); setActionError(null); await apiClient.reportProcurementItemIssue(itemId, text); + await markRequestNotificationsRead(requestId); toast.success("Позиция отмечена как проблемная."); await refreshAfterMutation(requestId); return true; @@ -988,13 +1020,14 @@ export function useProcurementPage(user: User | null) { } finally { setBusyKey(null); } - }, [refreshAfterMutation]); + }, [markRequestNotificationsRead, refreshAfterMutation]); const handleCancelItemIssue = useCallback(async (requestId: number, itemId: number) => { try { setBusyKey(`item-cancel-issue-${itemId}`); setActionError(null); await apiClient.cancelProcurementItemIssue(itemId); + await markRequestNotificationsRead(requestId); toast.success("Отметка брака снята."); await refreshAfterMutation(requestId); return true; @@ -1004,13 +1037,14 @@ export function useProcurementPage(user: User | null) { } finally { setBusyKey(null); } - }, [refreshAfterMutation]); + }, [markRequestNotificationsRead, refreshAfterMutation]); const handleConfirmItemReceived = useCallback(async (requestId: number, itemId: number, receivedQuantity?: number) => { try { setBusyKey(`item-confirm-received-${itemId}`); setActionError(null); await apiClient.confirmProcurementItemReceived(itemId, receivedQuantity); + await markRequestNotificationsRead(requestId); toast.success("Получение позиции подтверждено."); await refreshAfterMutation(requestId); return true; @@ -1020,13 +1054,14 @@ export function useProcurementPage(user: User | null) { } finally { setBusyKey(null); } - }, [refreshAfterMutation]); + }, [markRequestNotificationsRead, refreshAfterMutation]); const handleCancelItemReceived = useCallback(async (requestId: number, itemId: number, cancelQuantity?: number) => { try { setBusyKey(`item-cancel-received-${itemId}`); setActionError(null); await apiClient.cancelProcurementItemReceived(itemId, cancelQuantity); + await markRequestNotificationsRead(requestId); toast.success("Получение позиции отменено."); await refreshAfterMutation(requestId); return true; @@ -1036,7 +1071,7 @@ export function useProcurementPage(user: User | null) { } finally { setBusyKey(null); } - }, [refreshAfterMutation]); + }, [markRequestNotificationsRead, refreshAfterMutation]); const handleCancel = useCallback((id: number, reason = "") => { return doAction(`cancel-${id}`, () => apiClient.cancelProcurementRequest(id, reason), id, "Заявка отменена."); @@ -1101,6 +1136,7 @@ export function useProcurementPage(user: User | null) { busyKey, nextPage, scopeCounts, + unreadProcurementRequestCounts, scope, setScope, searchQuery, @@ -1146,6 +1182,7 @@ export function useProcurementPage(user: User | null) { toggleItemComments, ensureCommentsLoaded, ensureItemCommentsLoaded, + markRequestNotificationsRead, handleSubmit, handleApprove, handleReject, diff --git a/frontend/src/lib/api/employees.ts b/frontend/src/lib/api/employees.ts index 3d17f08f..51a528c6 100644 --- a/frontend/src/lib/api/employees.ts +++ b/frontend/src/lib/api/employees.ts @@ -108,18 +108,18 @@ export function createEmployeesApi(request: RequestFn) { const qs = qp.toString(); return request(`/api/v1/department-roles/${qs ? '?' + qs : ''}`); }, - createDepartmentRole: (data: { department: number; name: string }) => + createDepartmentRole: (data: { department: number; name: string; scoped_permissions?: number[]; scoped_permission_codes?: string[] }) => request('/api/v1/department-roles/', { method: 'POST', body: JSON.stringify(data), }), - updateDepartmentRole: (id: number | string, data: { name?: string }) => + updateDepartmentRole: (id: number | string, data: { name?: string; scoped_permissions?: number[]; scoped_permission_codes?: string[] }) => request(`/api/v1/department-roles/${id}/`, { method: 'PATCH', body: JSON.stringify(data), }), - deleteDepartmentRole: (id: number | string) => - request(`/api/v1/department-roles/${id}/`, { method: 'DELETE' }), + deleteDepartmentRole: (id: number | string, params?: { force?: boolean }) => + request(`/api/v1/department-roles/${id}/${params?.force ? '?force=true' : ''}`, { method: 'DELETE' }), getDepartmentRolePermChoices: () => request('/api/v1/department-roles/perm_choices/'), getDepartmentRoleAssignments: (id: number | string, params?: { active?: boolean }) => { diff --git a/frontend/src/lib/api/notifications.ts b/frontend/src/lib/api/notifications.ts index c4db064e..4b3116f1 100644 --- a/frontend/src/lib/api/notifications.ts +++ b/frontend/src/lib/api/notifications.ts @@ -1,6 +1,18 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { RequestFn } from './utils'; +export type UnreadNotificationsSummary = { + total: number; + verbs: Array<{ + verb: string; + unread: number; + }>; + procurement_requests?: Array<{ + request_id: number; + unread: number; + }>; +}; + export function createNotificationsApi(request: RequestFn) { return { getNotifications: (params?: { page?: number; page_size?: number; unread_only?: boolean }) => { @@ -12,6 +24,7 @@ export function createNotificationsApi(request: RequestFn) { return request(qs ? `/api/v1/notifications/?${qs}` : '/api/v1/notifications/'); }, getUnreadNotificationsCount: (): Promise<{ count: number }> => request('/api/v1/notifications/count/'), + getUnreadNotificationsSummary: (): Promise => request('/api/v1/notifications/summary/'), markNotificationAsRead: (id: number): Promise => request(`/api/v1/notifications/${id}/read/`, { method: 'POST' }), markAllNotificationsAsRead: (): Promise => request('/api/v1/notifications/read-all/', { method: 'POST' }), markCategoryAsRead: async (category: string): Promise<{ status: string; count: number }> => { diff --git a/frontend/src/lib/api/procurement.ts b/frontend/src/lib/api/procurement.ts index 78a051fd..61181998 100644 --- a/frontend/src/lib/api/procurement.ts +++ b/frontend/src/lib/api/procurement.ts @@ -19,6 +19,8 @@ export function createProcurementApi(request: RequestFn) { notifyProcurementRequestArrival: (id: number) => request(`/api/v1/procurement/requests/${id}/notify_arrival/`, { method: 'POST' }), setProcurementRequestViewed: (id: number, isViewed: boolean) => request(`/api/v1/procurement/requests/${id}/set_viewed/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ is_viewed: isViewed }) }), + markProcurementRequestNotificationsRead: (id: number): Promise<{ status: string; count: number; notification_ids: number[] }> => + request(`/api/v1/procurement/requests/${id}/notifications/read/`, { method: 'POST' }), cancelProcurementRequest: (id: number, reason?: string) => request(`/api/v1/procurement/requests/${id}/cancel/`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reason: reason || '' }) }), getProcurementComments: (requestId: number) => request(`/api/v1/procurement/requests/${requestId}/comments/`), diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index dc7fbc15..e5aeac75 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -212,6 +212,8 @@ export interface DepartmentRole { name: string; permissions: number[]; permissions_verbose: DepartmentPermissionChoice[]; + active_assignments_count?: number; + ldap_linked?: boolean; } export interface DepartmentRoleAssignment {