chore: checkpoint current project changes
This commit is contained in:
parent
8799b23443
commit
32500e3037
@ -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(
|
||||
|
||||
@ -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:
|
||||
# Активность учетной записи определяется текущим постоянным
|
||||
# кадровым состоянием; временные события сами по себе не меняют
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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):
|
||||
"""Отменить заявку."""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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, "Управлять оборудованием отдела"),
|
||||
)
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
),
|
||||
]
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"),
|
||||
|
||||
@ -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'})
|
||||
|
||||
|
||||
129
backend/notifications/cache.py
Normal file
129
backend/notifications/cache.py
Normal 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
|
||||
@ -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):
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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)
|
||||
|
||||
# Возвращаем одно уведомление или список
|
||||
|
||||
@ -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,
|
||||
|
||||
101
backend/procurement/notifications/read_service.py
Normal file
101
backend/procurement/notifications/read_service.py
Normal 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()
|
||||
)
|
||||
@ -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
|
||||
|
||||
@ -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)}"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
# ==============================================================================
|
||||
# ТЕСТЫ УДАЛЕНИЯ
|
||||
# ==============================================================================
|
||||
|
||||
@ -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/")
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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} />
|
||||
Отклонить
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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 }> => {
|
||||
|
||||
@ -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/`),
|
||||
|
||||
@ -212,6 +212,8 @@ export interface DepartmentRole {
|
||||
name: string;
|
||||
permissions: number[];
|
||||
permissions_verbose: DepartmentPermissionChoice[];
|
||||
active_assignments_count?: number;
|
||||
ldap_linked?: boolean;
|
||||
}
|
||||
|
||||
export interface DepartmentRoleAssignment {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user