chore: checkpoint current project changes

This commit is contained in:
lizerk 2026-06-20 10:47:00 +03:00
parent 8799b23443
commit 32500e3037
51 changed files with 2137 additions and 926 deletions

View File

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

View File

@ -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:
# Активность учетной записи определяется текущим постоянным
# кадровым состоянием; временные события сами по себе не меняют

View File

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

View File

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

View File

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

View File

@ -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):
"""Отменить заявку."""

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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, "Управлять оборудованием отдела"),
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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("<int:notification_id>/read/", views.mark_as_read, name="mark_read"),

View File

@ -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'})

View File

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

View File

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

View File

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

View File

@ -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)
# Возвращаем одно уведомление или список

View File

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

View File

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

View File

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

View File

@ -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)}"

View File

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

View File

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

View File

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

View File

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

View File

@ -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
# ==============================================================================
# ТЕСТЫ УДАЛЕНИЯ
# ==============================================================================

View File

@ -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/")

View File

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

View File

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

View File

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

View File

@ -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<void>;
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 (
<Modal
isOpen={isOpen}
onClose={onClose}
title={roleDraft.id ? "Переименование роли" : "Новая роль отдела"}
title={roleDraft.id ? "Редактирование роли" : "Новая роль отдела"}
size="md"
footer={
<div className="flex flex-wrap justify-end gap-2">
@ -726,25 +748,206 @@ function RoleEditorModal({
</div>
}
>
<section className="app-surface-muted rounded-xl p-4">
<label className="mb-2 block text-sm font-medium text-[var(--foreground)]">
Название роли *
</label>
<input
value={roleDraft.name}
onChange={(event) =>
onDraftChange((current) => ({
...current,
name: event.target.value,
}))
}
className="app-input w-full rounded-lg px-3 py-2 text-sm"
placeholder="Например, Координатор отдела"
/>
<p className="app-text-muted mt-3 text-sm">
Роль нужна для аккуратного описания состава отдела и назначения участникам.
</p>
</section>
<div className="space-y-4">
<section className="app-surface-muted rounded-xl p-4">
<label className="mb-2 block text-sm font-medium text-[var(--foreground)]">
Название роли *
</label>
<input
value={roleDraft.name}
onChange={(event) =>
onDraftChange((current) => ({
...current,
name: event.target.value,
}))
}
className="app-input w-full rounded-lg px-3 py-2 text-sm"
placeholder="Например, Координатор отдела"
/>
</section>
<section className="app-surface-muted rounded-xl p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h4 className="text-sm font-medium text-[var(--foreground)]">
Права роли
</h4>
<span className="app-text-muted text-xs">
Выбрано: {roleDraft.permissionIds.length}
</span>
</div>
{permissionsLoading ? (
<p className="app-text-muted text-sm">Загружаем права...</p>
) : permissionChoices.length ? (
<div className="grid gap-2 sm:grid-cols-2">
{permissionChoices.map((permission) => {
const checked = selectedIds.has(permission.id);
return (
<label
key={permission.id}
className={`flex min-h-14 cursor-pointer items-start gap-3 rounded-lg border px-3 py-2.5 transition ${
checked
? "border-[color:var(--accent-primary)] bg-[color:var(--accent-soft)]"
: "border-[color:var(--border-subtle)] bg-[var(--surface-primary)] hover:border-[color:var(--border-strong)]"
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => togglePermission(permission.id)}
className="mt-0.5 h-4 w-4 shrink-0 accent-[var(--accent-primary)]"
/>
<span className="min-w-0">
<span className="block text-sm font-medium text-[var(--foreground)]">
{permission.name}
</span>
<span className="app-text-muted block break-all text-xs">
{permission.code}
</span>
</span>
</label>
);
})}
</div>
) : (
<p className="app-text-muted text-sm">Права ролей не найдены</p>
)}
</section>
</div>
</Modal>
);
}
function RoleManagementModal({
isOpen,
onClose,
onCreateRole,
onDeleteRole,
onEditRole,
pendingKey,
roles,
roleUsage,
}: {
isOpen: boolean;
onClose: () => void;
onCreateRole: () => void;
onDeleteRole: (role: DepartmentRole) => Promise<void>;
onEditRole: (role: DepartmentRole) => void;
pendingKey: string | null;
roles: DepartmentRole[];
roleUsage: Record<number, number>;
}) {
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Управление ролями"
size="lg"
footer={
<div className="flex flex-wrap justify-end gap-2">
<button
type="button"
onClick={onClose}
className="app-action-secondary rounded-lg px-4 py-2 text-sm"
>
Закрыть
</button>
<button
type="button"
onClick={onCreateRole}
className="app-action-primary inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm"
>
<BadgePlus size={15} />
Создать роль
</button>
</div>
}
>
{roles.length ? (
<div className="space-y-3">
{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 (
<article
key={role.id}
className="app-surface-muted rounded-xl border border-[color:var(--border-subtle)] p-4"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 flex-wrap items-center gap-2">
<h4 className="min-w-0 truncate text-sm font-semibold text-[var(--foreground)]">
{role.name}
</h4>
{role.ldap_linked ? (
<MetaChip title="Связана с LDAP">
<Link2 size={12} />
LDAP
</MetaChip>
) : null}
<MetaChip>
{activeAssignmentsCount} назначений
</MetaChip>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{permissions.length ? (
permissions.map((permission) => (
<span
key={permission.id}
className="app-badge inline-flex max-w-full items-center rounded-full px-2.5 py-1 text-xs"
title={permission.code}
>
<span className="truncate">{permission.name}</span>
</span>
))
) : (
<span className="app-text-muted text-sm">
Права не назначены
</span>
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={() => onEditRole(role)}
className="app-action-secondary inline-flex h-9 w-9 items-center justify-center rounded-lg"
aria-label="Редактировать роль"
title="Редактировать роль"
>
<PencilLine size={16} />
</button>
<button
type="button"
onClick={() => void onDeleteRole(role)}
disabled={isDeleting}
className="app-action-danger inline-flex h-9 w-9 items-center justify-center rounded-lg disabled:opacity-50"
aria-label="Удалить роль"
title="Удалить роль"
>
<Trash2 size={16} />
</button>
</div>
</div>
</article>
);
})}
</div>
) : (
<div className="app-surface-muted rounded-xl px-4 py-8 text-center">
<p className="text-sm font-medium text-[var(--foreground)]">
Ролей пока нет
</p>
<p className="app-text-muted mt-2 text-sm">
Создайте первую роль и назначьте ей права отдела.
</p>
</div>
)}
</Modal>
);
}
@ -760,6 +963,7 @@ export default function DepartmentDetailPage() {
const roleMenuRef = useRef<HTMLDivElement | null>(null);
const [departmentMenuOpen, setDepartmentMenuOpen] = useState(false);
const departmentMenuRef = useRef<HTMLDivElement | null>(null);
const [roleManagementOpen, setRoleManagementOpen] = useState(false);
const [openingDepartmentChat, setOpeningDepartmentChat] = useState(false);
const [openingDepartmentCalendar, setOpeningDepartmentCalendar] = useState(false);
@ -971,6 +1175,20 @@ export default function DepartmentDetailPage() {
</button>
) : null}
{h.userPerms.can_assign_roles ? (
<button
type="button"
onClick={() => {
setDepartmentMenuOpen(false);
setRoleManagementOpen(true);
}}
className="flex w-full items-center gap-2 px-3 py-2 text-left text-sm text-[var(--foreground)] transition hover:bg-[var(--surface-secondary)]"
>
<UserRoundCog size={14} className="app-text-muted" />
Управление ролями
</button>
) : null}
{h.userPerms.can_manage ? (
<button
type="button"
@ -1168,9 +1386,28 @@ export default function DepartmentDetailPage() {
selectedRoleId={h.selectedRoleId}
/>
<RoleManagementModal
isOpen={roleManagementOpen}
onClose={() => setRoleManagementOpen(false)}
onCreateRole={() => {
setRoleManagementOpen(false);
h.openCreateRole();
}}
onDeleteRole={h.submitRoleDelete}
onEditRole={(role) => {
setRoleManagementOpen(false);
h.openEditRole(role);
}}
pendingKey={h.pendingKey}
roles={h.roles}
roleUsage={h.roleUsage}
/>
<RoleEditorModal
isOpen={h.roleEditorOpen}
loading={h.pendingKey === "role"}
permissionChoices={h.rolePermissionChoices}
permissionsLoading={h.rolePermissionChoicesLoading}
onClose={h.closeRoleEditor}
onDraftChange={h.setRoleDraft}
onSave={h.saveRole}

View File

@ -17,6 +17,7 @@
--accent-soft-strong: rgba(14, 165, 233, 0.24);
--success-soft: rgba(16, 185, 129, 0.15);
--danger-soft: rgba(239, 68, 68, 0.12);
--danger-foreground: #dc2626;
--warning-soft: rgba(245, 158, 11, 0.15);
--app-shell-gradient: radial-gradient(circle at top, rgba(125, 211, 252, 0.12), transparent 32%), linear-gradient(180deg, #f5f9ff 0%, #eef3fb 46%, #f4f7fb 100%);
--header-surface: rgba(255, 255, 255, 0.84);
@ -48,6 +49,7 @@ html[data-bs-theme="dark"] {
--accent-soft-strong: rgba(30, 167, 255, 0.28);
--success-soft: rgba(16, 185, 129, 0.17);
--danger-soft: rgba(239, 68, 68, 0.16);
--danger-foreground: #f87171;
--warning-soft: rgba(245, 158, 11, 0.18);
--app-shell-gradient: radial-gradient(circle at top, rgba(30, 167, 255, 0.08), transparent 24%), linear-gradient(180deg, #10161d 0%, #111922 38%, #0b1016 100%);
--header-surface: rgba(14, 20, 28, 0.88);
@ -291,6 +293,7 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator {
.app-menu {
background: var(--surface-elevated);
border: 1px solid var(--border-subtle);
color: var(--foreground);
box-shadow: var(--shadow-elevated);
}
@ -492,7 +495,7 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator {
}
.app-action-approve {
border: 1px solid color-mix(in srgb, var(--danger-foreground) 18%, var(--border-strong));
border: 1px solid color-mix(in srgb, #10b981 18%, var(--border-strong));
background: var(--surface-elevated);
color: #10b981;
}
@ -502,6 +505,17 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator {
border-color: color-mix(in srgb, #10b981 24%, var(--border-strong));
}
.app-action-reject {
border: 1px solid var(--border-strong);
background: var(--surface-elevated);
color: var(--danger-foreground);
}
.app-action-reject:hover {
background: var(--danger-soft);
border-color: color-mix(in srgb, var(--danger-foreground) 24%, var(--border-strong));
}
.app-selected {
border: 1px solid color-mix(in srgb, var(--accent-primary) 22%, var(--border-subtle));
background: color-mix(in srgb, var(--accent-primary) 12%, var(--surface-primary));

View File

@ -262,7 +262,7 @@ function ProcurementRequestActionButtons({
onClick={() => onReject(request.id)}
disabled={busyKey === `reject-${request.id}`}
title="Отклонить"
className={buttonClass("app-action-danger")}
className={buttonClass("app-action-reject")}
>
<ThumbsDown size={approvalIconSize} />
{label("Отклонить")}
@ -407,10 +407,12 @@ export default function ProcurementPage() {
userLink,
scope,
scopeCounts,
unreadProcurementRequestCounts,
handleAddComment,
handleDeleteComment,
handleAddItemComment,
handleDeleteItemComment,
markRequestNotificationsRead,
toggleItemComments,
} = useProcurementPage(user);
@ -523,6 +525,7 @@ export default function ProcurementPage() {
try {
await ensureRequestDetail(requestId);
await markRequestNotificationsRead(requestId);
setDetailModalId(requestId);
syncRequestUrl(requestId);
} catch (detailError) {
@ -530,7 +533,7 @@ export default function ProcurementPage() {
} finally {
setDetailModalLoading(false);
}
}, [ensureRequestDetail, syncRequestUrl]);
}, [ensureRequestDetail, markRequestNotificationsRead, syncRequestUrl]);
useEffect(() => {
if (typeof window === "undefined") return;
@ -835,6 +838,7 @@ export default function ProcurementPage() {
const commentsOpen = Boolean(expandedComments[req.id]);
const commentsTotal = resolvedDetail.comments_count ?? req.comments_count ?? comments.length;
const isViewed = Boolean(resolvedDetail.is_viewed ?? req.is_viewed);
const hasUnreadNotifications = (unreadProcurementRequestCounts[req.id] || 0) > 0;
const rowExpanded = !isViewed && expanded;
const rowCommentsOpen = !isViewed && commentsOpen;
const showInlineRequestActions = !isViewed && hasInlineRequestActions;
@ -911,7 +915,13 @@ export default function ProcurementPage() {
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className={`h-2 w-2 shrink-0 rounded-full ${st === "completed" ? "bg-teal-500" : st === "approved" ? "bg-emerald-500" : st === "pending" || st === "waiting" ? "bg-amber-500" : st === "in_progress" ? "bg-sky-500" : st === "rejected" ? "bg-rose-500" : "bg-slate-400"}`} />
{hasUnreadNotifications ? (
<span
className="app-dot-accent h-2 w-2 shrink-0 rounded-full"
title="Есть новые уведомления"
aria-label="Есть новые уведомления"
/>
) : null}
<button
type="button"
onClick={() => void openDetailModal(req.id)}

View File

@ -1,13 +1,12 @@
"use client";
import { Building2, CalendarCheck, CalendarDays, Download, FileSignature, FileText, Home as HomeIcon, Menu, MessageSquare, Monitor, Search, ShoppingCart, Users } from "lucide-react";
import { Building2, CalendarCheck, CalendarDays, Download, FileSignature, FileText, Home as HomeIcon, Menu, MessageSquare, Search, ShoppingCart, Users } from "lucide-react";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { ReactNode, startTransition, useEffect, useRef, useState, useMemo } from "react";
import { useMobileNavPlacement } from "@/contexts/MobileNavPlacementContext";
import { useUser } from "@/contexts/UserContext";
import { useNotifications } from "@/hooks/useApi";
import { getVerbCategory } from "@/lib/verbTranslations";
import { NotificationCenter, NotificationPanel } from "@/components/NotificationCenter";
import { CalendarSidebar } from "@/components/calendar/CalendarSidebar";
import { PushOnboardingPrompt } from "@/components/PushOnboardingPrompt";
@ -318,31 +317,16 @@ function Header({ mobileNavPlacement, suppressMobileChrome = false, onOpenLeftNa
function LeftNavContent({ onNavigate }: LeftNavContentProps) {
const pathname = usePathname();
const { user } = useUser();
const { notifications: notificationsData, markCategoryAsRead } = useNotifications();
const notifications = useMemo(() => Array.isArray(notificationsData) ? notificationsData : [], [notificationsData]);
const { unreadCategoryCounts, markCategoryAsRead } = useNotifications();
const canManageAttendance = Boolean(user?.auth?.is_staff || user?.auth?.is_superuser);
const visibleNavItems = useMemo(
() => navItems.filter((item) => item.href !== "/attendance" || canManageAttendance),
[canManageAttendance],
);
// Подсчет уведомлений по категориям
const categoryCounts = useMemo(() => {
const counts: Record<string, number> = {};
notifications.forEach((n: { verb?: string; is_read?: boolean }) => {
// Пропускаем прочитанные
if (!n.verb || n.is_read) return;
const category = getVerbCategory(n.verb);
counts[category] = (counts[category] || 0) + 1;
});
return counts;
}, [notifications]);
const handleNavClick = async (category?: string, autoReadOnNavigate = true) => {
// Помечаем уведомления категории как прочитанные
if (autoReadOnNavigate && category && category !== "Сообщения" && categoryCounts[category] > 0) {
if (autoReadOnNavigate && category && category !== "Сообщения" && (unreadCategoryCounts[category] || 0) > 0) {
await markCategoryAsRead(category);
}
@ -362,7 +346,7 @@ function LeftNavContent({ onNavigate }: LeftNavContentProps) {
<div className="app-surface rounded-2xl p-5">
<div className="space-y-2 text-sm">
{visibleNavItems.map(({ href, label, icon: Icon, category, autoReadOnNavigate }) => {
const count = category ? categoryCounts[category] || 0 : 0;
const count = category ? unreadCategoryCounts[category] || 0 : 0;
return (
<Link

View File

@ -102,7 +102,7 @@ export function RequestDetailModal({
type="button"
onClick={() => void onReject(request.id)}
disabled={busyKey === `reject-${request.id}`}
className="app-action-danger inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-60"
className="app-action-reject inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-sm font-medium disabled:opacity-60"
>
<ThumbsDown size={16} />
Отклонить

View File

@ -278,7 +278,7 @@ export function RequestListItem({
title="Отклонить"
onClick={() => void onReject(request.id)}
disabled={busyKey === `reject-${request.id}`}
className="app-action-danger inline-flex items-center justify-center rounded-lg p-2 disabled:opacity-60"
className="app-action-reject inline-flex items-center justify-center rounded-lg p-2 disabled:opacity-60"
>
<ThumbsDown size={18} />
</button>

View File

@ -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="Отклонить (←)"
>
<ThumbsDown size={22} />

View File

@ -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<string, number>;
unreadProcurementRequestCounts: Record<number, number>;
loading: boolean;
error: Error | null;
refreshUnreadSummary: () => Promise<void>;
markAsRead: (id: number) => Promise<void>;
markAllAsRead: () => Promise<void>;
markCategoryAsRead: (category: string) => Promise<void>;
@ -49,10 +56,46 @@ export function NotificationsProvider({ children }: { children: React.ReactNode
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const [unreadCount, setUnreadCount] = useState(0);
const [unreadCategoryCounts, setUnreadCategoryCounts] = useState<Record<string, number>>({});
const [unreadProcurementRequestCounts, setUnreadProcurementRequestCounts] = useState<Record<number, number>>({});
const [isWsConnected, setIsWsConnected] = useState(
typeof window !== 'undefined' && wsManager.getState() === WebSocket.OPEN
);
const fetchInFlightRef = useRef<Promise<void> | null>(null);
const summaryInFlightRef = useRef<Promise<void> | 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<string, number> = {};
const nextProcurementRequestCounts: Record<number, number> = {};
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<number> => {
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,

View File

@ -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<number, number>;
roles: DepartmentRole[];
selectableEmployees: User[];
@ -139,7 +143,10 @@ export function useDepartmentPage(departmentId: number): DepartmentPageControlle
const [roleDraft, setRoleDraft] = useState<DepartmentRoleDraft>({
id: null,
name: "",
permissionIds: [],
});
const [rolePermissionChoices, setRolePermissionChoices] = useState<DepartmentPermissionChoice[]>([]);
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<DepartmentPermissionChoice> | { 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,

View File

@ -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<string, unknown>) => {
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,

View File

@ -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 }) => {

View File

@ -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<UnreadNotificationsSummary> => request('/api/v1/notifications/summary/'),
markNotificationAsRead: (id: number): Promise<void> => request(`/api/v1/notifications/${id}/read/`, { method: 'POST' }),
markAllNotificationsAsRead: (): Promise<void> => request('/api/v1/notifications/read-all/', { method: 'POST' }),
markCategoryAsRead: async (category: string): Promise<{ status: string; count: number }> => {

View File

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

View File

@ -212,6 +212,8 @@ export interface DepartmentRole {
name: string;
permissions: number[];
permissions_verbose: DepartmentPermissionChoice[];
active_assignments_count?: number;
ldap_linked?: boolean;
}
export interface DepartmentRoleAssignment {