feat(ldap): write-back с optimistic lock + интеграция в API

- сервис integrations/ldap_writeback.py
- фича-флаг и маппинг в settings (LDAP_WRITE_*)
- (рекоменд.) поля в Employee: ldap_dn, ldap_guid, last_ldap_modify_ts, last_ldap_sync_at
- write-back в PATCH /employees/{id} и /employees/me/ (409/503 на конфликты/ошибки)
- оптимизация queryset (prefetch actions только для detail/me)
- мелкие правки add/remove_skill и валидаций
This commit is contained in:
admin i 2025-09-18 10:07:49 +03:00
parent 5390e514c3
commit cf04e1a27f
16 changed files with 1586 additions and 176 deletions

View File

@ -4,15 +4,10 @@ from typing import Any, Dict, Optional
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission
from employees.constants import ACTION_CHOICES
from employees.models import (
Department,
DepartmentPermission,
DepartmentRole,
EmployeeAction,
EmployeeDepartment,
Position,
Skill,
)
from employees.models import (Department, DepartmentPermission, DepartmentRole,
EmployeeAction, EmployeeDepartment, Position,
Skill)
from eusrr_backend.auth_backends import PHONE_FIELD as DETECTED_PHONE_FIELD
from rest_framework import serializers
from ..serializers import Base64ImageField
@ -711,3 +706,27 @@ class DepartmentBriefSerializer(serializers.ModelSerializer):
class Meta:
model = Department
fields = ("id", "name")
class ProfilePatchSerializer(serializers.Serializer):
"""Сериализатор частичного обновления профиля с write-back в LDAP.
Разрешены только whitelisted поля. Телефон — в фактическое поле модели.
"""
first_name = serializers.CharField(required=False, allow_blank=True)
last_name = serializers.CharField(required=False, allow_blank=True)
# реальное имя телефонного поля в модели (phone_number)
_phone_field = DETECTED_PHONE_FIELD or "phone_number"
locals()[_phone_field] = serializers.CharField(required=False, allow_blank=True)
def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]:
"""Общая валидация.
Raises:
serializers.ValidationError: Если нет ни одного поддерживаемого поля.
"""
if not attrs:
raise serializers.ValidationError("No fields to update")
return attrs

View File

@ -4,6 +4,7 @@ from __future__ import annotations
from datetime import timedelta
from common.emails import send_templated_mail
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group, Permission
from django.db import IntegrityError, transaction
@ -17,6 +18,7 @@ from employees.constants import ACTION_DISMISSED
from employees.models import (Department, DepartmentPermission, DepartmentRole,
DeptPerm, EmployeeAction, EmployeeDepartment,
Position, Skill)
from integrations.ldap_writeback import update_employee_in_ldap # наш сервис
from rest_framework import filters, status, viewsets
from rest_framework.decorators import action
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
@ -44,6 +46,13 @@ Employee = get_user_model()
PHONE_FIELD = _detect_phone_field()
_ME_ALLOWED_FIELDS = {
"first_name", "last_name", "patronymic",
"phone_number", "avatar",
"telegram", "whatsapp", "wechat",
"birth_date", "position_id",
}
class HistoryActionMixin:
"""
@ -838,6 +847,9 @@ class EmployeeViewSet(viewsets.ModelViewSet):
dep_links_prefetch,
Prefetch("actions", queryset=EmployeeAction.objects.order_by("-date")),
]
if getattr(self, "action", None) in {"retrieve", "me"}:
prefetches.append(Prefetch("actions", queryset=EmployeeAction.objects.order_by("-date")))
qs = (
Employee.objects.select_related("position")
@ -960,6 +972,36 @@ class EmployeeViewSet(viewsets.ModelViewSet):
headers = self.get_success_headers(out.data)
return Response(out.data, status=status.HTTP_201_CREATED, headers=headers)
def partial_update(self, request, *args, **kwargs):
"""Частичное обновление сотрудника с опциональным write-back в LDAP.
Возвращает 409 при конфликте в каталоге (optimistic lock) и 503 при сетевых/LDAP-ошибках.
"""
instance = self.get_object()
# Валидацию делаем штатным сериализатором
serializer = self.get_serializer(instance, data=request.data, partial=True)
serializer.is_valid(raise_exception=True)
# Пишем в LDAP, только если включено и есть whitelisted поля
if update_employee_in_ldap and getattr(settings, "LDAP_WRITE_ENABLED", False):
payload = {}
# маппим нужные локальные поля → передадим сервису (он сам сопоставит к LDAP-атрибутам)
for k in ("first_name", "last_name", "phone_number"):
if k in serializer.validated_data:
payload[k] = serializer.validated_data[k]
if payload:
try:
update_employee_in_ldap(instance, payload=payload)
except RuntimeError as e:
return Response({"detail": str(e)}, status=status.HTTP_409_CONFLICT)
except Exception as e:
return Response({"detail": f"LDAP error: {e}"}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
# Локальное сохранение (включая прочие поля)
self.perform_update(serializer)
return Response(serializer.data, status=status.HTTP_200_OK)
@action(detail=False, methods=["get", "patch"])
def me(self, request):
"""
@ -991,9 +1033,13 @@ class EmployeeViewSet(viewsets.ModelViewSet):
ctx["include_action_history"] = True
data = self.get_serializer(instance, context=ctx).data
return Response(data, status=status.HTTP_200_OK)
ser = self.get_serializer(instance, data=request.data, partial=True)
# 1) Явный allowlist: email менять только через отдельный процесс верификации
safe_payload = {k: v for k, v in request.data.items() if k in _ME_ALLOWED_FIELDS}
ser = self.get_serializer(instance, data=safe_payload, partial=True)
ser.is_valid(raise_exception=True)
# 2) Проверка канала связи (как и было)
vd = ser.validated_data
if all(k not in vd for k in ("whatsapp", "telegram", "wechat")):
new_whatsapp = vd.get("whatsapp", instance.whatsapp)
@ -1001,12 +1047,24 @@ class EmployeeViewSet(viewsets.ModelViewSet):
new_wechat = vd.get("wechat", instance.wechat)
if not (new_whatsapp or new_telegram or new_wechat):
return Response(
{
"detail": "Должен быть указан хотя бы один канал связи (WhatsApp/Telegram/WeChat)."
},
{"detail": "Должен быть указан хотя бы один канал связи (WhatsApp/Telegram/WeChat)."},
status=status.HTTP_400_BAD_REQUEST,
)
# 3) LDAP write-back: только whitelisted поля
if update_employee_in_ldap and getattr(settings, "LDAP_WRITE_ENABLED", False):
payload = {}
for k in ("first_name", "last_name", "phone_number"):
if k in ser.validated_data:
payload[k] = ser.validated_data[k]
if payload:
try:
update_employee_in_ldap(instance, payload=payload)
except RuntimeError as e:
return Response({"detail": str(e)}, status=status.HTTP_409_CONFLICT)
except Exception as e:
return Response({"detail": f"LDAP error: {e}"}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
ser.save()
return Response(ser.data, status=status.HTTP_200_OK)

View File

@ -1,9 +1,10 @@
# backend\api\v1\permissions.py
from __future__ import annotations
from typing import Any, Dict, Iterable, Optional, Tuple
from typing import Any, Dict, Iterable, Optional, Tuple, Type
from django.contrib.auth.models import AbstractBaseUser, AnonymousUser
from django.db.models import Model
from employees.constants import DeptPerm
from employees.models import Department, EmployeeDepartment
from rest_framework.permissions import (
@ -11,13 +12,14 @@ from rest_framework.permissions import (
BasePermission,
DjangoModelPermissions,
)
from rest_framework.request import Request
MANAGE_PERM = "manage_department"
CHANGE_HEAD_PERM = "change_department_head"
ASSIGN_ROLE_PERM = "assign_department_role"
def user_is_dept_head(user, dept: Department) -> bool:
def user_is_dept_head(user, dept: Department) -> bool:
return bool(
user and user.is_authenticated and dept.head_id == getattr(user, "id", None)
)
@ -309,43 +311,102 @@ class AdminOrDeptAllowed(BasePermission):
class IsSelfOrStaff(BasePermission):
"""Staff/superuser или владелец объекта (настраиваемые поля владельца через атрибуты вью)."""
"""Доступ для staff/superuser или владельца объекта.
DEFAULT_OWNER_ID_ATTRS = ("author_id", "user_id", "owner_id", "employee_id")
DEFAULT_OWNER_OBJ_ATTRS = ("author", "user", "owner", "employee")
ВАЖНО:
• SAFE-методы НЕ дают автоматического допуска.
• Добавлена поддержка кейса "объект = сам пользователь" (self-update).
"""
def has_permission(self, request, view) -> bool:
DEFAULT_OWNER_ID_ATTRS: Tuple[str, ...] = ("employee_id", "author_id", "user_id", "owner_id")
DEFAULT_OWNER_OBJ_ATTRS: Tuple[str, ...] = ("employee", "author", "user", "owner")
def has_permission(self, request: Request, view) -> bool:
"""Пускаем только аутентифицированных.
Args:
request (Request): DRF-запрос.
view: DRF ViewSet.
Returns:
bool: True если пользователь аутентифицирован.
"""
user = getattr(request, "user", None)
return bool(user and user.is_authenticated)
def has_object_permission(self, request, view, obj):
user = request.user
def has_object_permission(self, request: Request, view, obj) -> bool:
"""Разрешает доступ staff/superuser или владельцу объекта.
Логика владельца:
1) Совпадение *_id полей (employee_id/user_id/…)
2) Совпадение связанных owner-объектов (employee/user/…)
3) Фоллбек "self-object": obj.__class__ == user.__class__ и obj.pk == user.pk
Args:
request (Request): DRF-запрос.
view: DRF ViewSet.
obj: Проверяемый объект.
Returns:
bool: True если доступ разрешён.
Raises:
Ничего не выбрасывает.
"""
user = getattr(request, "user", None)
if not user or not user.is_authenticated:
return False
if request.method in SAFE_METHODS:
return True
# Админский доступ
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
# Прочитаем конфиг владельческих полей с вью (если задан)
owner_id_attrs = getattr(view, "owner_id_attrs", self.DEFAULT_OWNER_ID_ATTRS)
owner_obj_attrs = getattr(view, "owner_obj_attrs", self.DEFAULT_OWNER_OBJ_ATTRS)
uid = getattr(user, "id", None)
# 1) *_id поля владельца
owner_id_attrs: Iterable[str] = getattr(view, "owner_id_attrs", self.DEFAULT_OWNER_ID_ATTRS)
for attr in owner_id_attrs:
if hasattr(obj, attr) and getattr(obj, attr) == uid:
if getattr(obj, attr, None) == user.id:
return True
# 2) связанные owner-объекты
owner_obj_attrs: Iterable[str] = getattr(view, "owner_obj_attrs", self.DEFAULT_OWNER_OBJ_ATTRS)
for attr in owner_obj_attrs:
owner = getattr(obj, attr, None)
if owner is not None and getattr(owner, "id", None) == uid:
if owner is not None and getattr(owner, "id", None) == user.id:
return True
# Fallback для вью «/users/{id}/»
lookup = getattr(view, "lookup_field", "pk")
return str(view.kwargs.get(lookup)) == str(uid)
# 3) self-object: редактирование собственной учётки (Employee ↔ Employee)
try:
if obj.__class__ is user.__class__ and getattr(obj, "pk", None) == getattr(user, "pk", None):
return True
except Exception:
# максимально безопасно: не пускаем, если что-то пошло не так
pass
return False
class AdminOrActionOrModelPerms(DjangoModelPermissions):
"""staff/superuser ИЛИ явные пермы экшена ИЛИ стандартные DjangoModelPermissions.
Логика:
1) staff/superuser → True
2) Если на вью заданы «явные» коды прав для текущего action (метод `_explicit_perm_codes`),
достаточно ЛЮБОГО из них → True/False
3) Иначе — делегируем в DjangoModelPermissions.has_permission():
- для SAFE_METHODS потребует `view_<model>`
- для небезопасных — коды из perms_map (add/change/delete)
"""
perms_map = {
"GET": ["%(app_label)s.view_%(model_name)s"],
"OPTIONS": [],
"HEAD": [],
"POST": ["%(app_label)s.add_%(model_name)s"],
"PUT": ["%(app_label)s.change_%(model_name)s"],
"PATCH": ["%(app_label)s.change_%(model_name)s"],
"DELETE": ["%(app_label)s.delete_%(model_name)s"],
}
@staticmethod
def _normalize_codes(value: Any) -> Optional[list[str]]:
"""Приводит конфигурацию кодов прав к списку строк.
@ -436,16 +497,18 @@ class AdminOrActionOrModelPerms(DjangoModelPermissions):
# Иное — игнорируем
return None
def has_permission(self, request, view) -> bool:
"""Проверка прав на уровне запроса.
def has_permission(self, request: Request, view) -> bool:
"""Проверяет доступ на уровне запроса.
Логика:
- staff/superuser → True
- если на вью есть «явные» коды (см. выше) — достаточно ЛЮБОГО из них
- иначе: для SAFE-методов требуем view_<model>, остальные — по DjangoModelPermissions
Args:
request (Request): DRF-запрос.
view: DRF View/ViewSet.
Returns:
bool: Разрешён ли доступ.
Raises:
PermissionDenied: не выбрасывается здесь; при False DRF отдаст 403.
"""
user = getattr(request, "user", None)
if not (user and user.is_authenticated):
@ -453,16 +516,14 @@ class AdminOrActionOrModelPerms(DjangoModelPermissions):
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
explicit = self._explicit_perm_codes(view)
# 1) Явные пермы, привязанные к action (если у тебя есть такой механизм)
explicit: Optional[Iterable[str]] = getattr(
self, "_explicit_perm_codes", lambda _v: None
)(view)
if explicit:
return any(user.has_perm(code) for code in explicit)
if request.method in SAFE_METHODS:
model = self._model_from_view(view)
return user.has_perm(
f"{model._meta.app_label}.view_{model._meta.model_name}"
)
# 2) Всё остальное — родителю (в т.ч. SAFE_METHODS → view_<model>)
return super().has_permission(request, view)

View File

@ -6,55 +6,35 @@ 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`.
"""Доступ к экшену `comments` по *модельным* правам.
- GET/HEAD: staff или право `requests_app.view_requestcomment`.
- POST: staff или право `requests_app.add_requestcomment`.
- Владелец заявки без модельных прав доступа не имеет.
Raises:
Ничего не выбрасывает: при отказе возвращает False → DRF вернёт 403.
- 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:
"""Проверка уровня запроса.
Args:
request (Request): Текущий запрос.
view (Any): Текущий вьюсет.
Returns:
bool: True, если пользователь аутентифицирован.
"""
return bool(getattr(request.user, "is_authenticated", False))
def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool:
"""Объектная проверка на уровне конкретной заявки.
Args:
request (Request): Текущий запрос.
view (Any): Текущий вьюсет.
obj (Any): Экземпляр заявки (EmployeeRequest).
Returns:
bool: Разрешение/запрет.
"""
user = request.user
if getattr(user, "is_staff", False):
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
if request.method in {"GET", "HEAD"}:
return user.has_perm("requests_app.view_requestcomment")
if request.method == "POST":
return user.has_perm("requests_app.add_requestcomment")
return False
code = self._required_perm(request)
return bool(code and user.has_perm(code))
class NotFinalOrStaff(BasePermission):
@ -92,33 +72,144 @@ class NotFinalOrStaff(BasePermission):
class DeptViewRequest(AdminOrDeptAllowed):
"""Чтение заявки: staff ИЛИ право отдела 'view_request'."""
"""Просмотр заявки по департаментным правам.
required_code = "view_request"
Доступ разрешается, если пользователь — 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 ИЛИ право отдела 'can_process_requests'."""
"""Пермишен для обработки заявок по департаментным правам.
required_code = "can_process_requests"
Доступ разрешается, если пользователь — 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 ИЛИ право отдела 'change_request'."""
"""Правка заявок по департаментным правам.
required_code = "change_request"
Доступ разрешается, если пользователь — 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'
"""Комментарии: staff ИЛИ департаментное право.
- GET/HEAD → 'view_requestcomment'
- POST → 'add_requestcomment'
"""
def _required_code(self, view: Any, request: Request) -> Optional[str]:
# Метод-чувствительная логика: GET/HEAD → view, POST → add
# Чтобы 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 "view_requestcomment"
return DeptPerm.VIEW_REQUESTCOMMENT
if request.method == "POST":
return "add_requestcomment"
return None # другие методы не поддерживаем
return DeptPerm.ADD_REQUESTCOMMENT
return None

View File

@ -7,25 +7,33 @@ 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
# 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 PermissionDenied, ValidationError
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.permissions import (SAFE_METHODS, BasePermission,
IsAuthenticated)
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 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)
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):
@ -74,14 +82,16 @@ class RequestViewSet(viewsets.ModelViewSet):
- остальное (CRUD): владелец ИЛИ staff ИЛИ модельные права.
"""
if self.action == "comments":
return [CommentsPermission()]
return [(CommentsPermission | DeptComments)()]
if self.action in {"approve", "reject"}:
return [IsAuthenticated(), (AdminOrActionOrModelPerms)()]
return [(AdminOrActionOrModelPerms | DeptCanProcess)()]
if self.action == "cancel":
return [IsAuthenticated(), IsSelfOrStaff()]
return [IsSelfOrStaff()]
if self.action == "retrieve":
return [(IsSelfOrStaff | DeptViewRequest | AdminOrActionOrModelPerms)()]
if self.action == "destroy":
return [IsAuthenticated(), IsSelfOrStaff(), NotFinalOrStaff()]
return [(IsSelfOrStaff | AdminOrActionOrModelPerms)()]
return [(IsSelfOrStaff | AdminOrActionOrModelPerms | DeptChangeRequest)(), NotFinalOrStaff()]
return [(IsSelfOrStaff | AdminOrActionOrModelPerms | DeptChangeRequest)()]
def get_serializer_class(self) -> type[BaseSerializer]:
"""Возвращает сериализатор в зависимости от action."""
@ -109,7 +119,9 @@ class RequestViewSet(viewsets.ModelViewSet):
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"})
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:
@ -117,8 +129,11 @@ class RequestViewSet(viewsets.ModelViewSet):
else:
# отделы, где у пользователя есть dept-право 'view_request'
dept_ids_qs = (
EmployeeDepartment.objects
.filter(employee_id=user.id, is_active=True, role__scoped_permissions__code="view_request")
EmployeeDepartment.objects.filter(
employee_id=user.id,
is_active=True,
role__scoped_permissions__code="view_request",
)
.values_list("department_id", flat=True)
.distinct()
)
@ -136,27 +151,28 @@ class RequestViewSet(viewsets.ModelViewSet):
# --- ВАЖНО: не ловить 404 на detail-экшенах из-за урезанного get_queryset() ---
def get_object(self):
"""Возвращает объект для detail-экшенов.
def get_object(self) -> EmployeeRequest:
"""Возвращает объект заявки для detail-экшенов.
Для 'approve', 'reject', 'comments', 'cancel' берём объект из полного
набора записей по первичному ключу (в обход get_queryset()), затем
вызываем check_object_permissions() — чтобы объектные пермишены отработали.
Для остальных действий — стандартное поведение базового класса.
Для экшенов {'approve', 'reject', 'comments', 'cancel', 'retrieve'}:
- достаём объект **в обход** урезанного get_queryset() по PK,
- прогоняем через object-level пермишены (check_object_permissions),
чтобы при отсутствии прав получить **403 Forbidden**, а не 404.
Returns:
EmployeeRequest: Объект заявки.
Raises:
Http404: Если объект не найден.
PermissionDenied: Если объектные права не пройдены.
Http404: Если объект с таким PK не существует вообще.
PermissionDenied: Если object-level пермишены не пройдены.
"""
if getattr(self, "action", None) in {"approve", "reject", "comments", "cancel"}:
if getattr(self, "action", None) in {"approve", "reject", "comments", "cancel", "retrieve"}:
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
lookup_val = self.kwargs[lookup_url_kwarg]
obj = get_object_or_404(EmployeeRequest, **{self.lookup_field: lookup_val})
lookup_value = self.kwargs[lookup_url_kwarg]
obj = get_object_or_404(EmployeeRequest, **{self.lookup_field: lookup_value})
self.check_object_permissions(self.request, obj)
return obj
return super().get_object()
# ---------- CRUD ----------

View File

@ -34,6 +34,8 @@ class DeptPerm:
MANAGE_CALENDAR = "manage_department_events"
CREATE_POST = "publish_department_post"
MANAGE_FEED = "manage_department_feed"
VIEW_REQUESTCOMMENT = "view_requestcomment"
ADD_REQUESTCOMMENT = "add_requestcomment"
CHOICES = (
(MANAGE, "Управлять отделом"),
@ -42,4 +44,6 @@ class DeptPerm:
(MANAGE_CALENDAR, "Управлять календарём отдела"),
(CREATE_POST, "Публиковать новости на странице отдела"),
(MANAGE_FEED, "Редактировать публикации отдела"),
(VIEW_REQUESTCOMMENT, "Просмотр комментариев по заявлениям"),
(ADD_REQUESTCOMMENT, "Добавление коментариев по заявлениям"),
)

View File

@ -0,0 +1,575 @@
from __future__ import annotations
import os
from typing import Any, Iterable, Optional
from django.conf import settings
from django.contrib.auth.models import Group
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.utils.timezone import now
from employees.models import Department, Employee, EmployeeDepartment
from ldap3 import ALL, ALL_ATTRIBUTES, SUBTREE, Connection, Server
# Берём ваши утилиты нормализации телефона и имя поля телефона
try:
from eusrr_backend.auth_backends import PHONE_FIELD # type: ignore
from eusrr_backend.auth_backends import _normalize_phone
except Exception: # pragma: no cover
def _normalize_phone(raw: str) -> Optional[str]:
return str(raw).strip() if raw else None
PHONE_FIELD = None
class Command(BaseCommand):
"""Синхронизация сотрудников (и отделов) из LDAP.
Алгоритм:
1) Соединяемся с LDAP, выполняем paged search по базе и фильтрам.
2) Для каждой записи забираем атрибуты (email/ФИО/телефон/отдел).
3) Сопоставляем с локальной БД:
- по email (nocase), иначе по телефону (E.164, если есть поле).
4) Создаём или обновляем Employee; отдел обновляем при наличии.
5) (опц.) Деактивируем локальных, которых не нашли.
Запуск:
python manage.py sync_ldap --dry-run
python manage.py sync_ldap --deactivate-missing
python manage.py sync_ldap --filter "(|(departmentNumber=IT)(departmentNumber=HR))"
Требуемые настройки:
LDAP_URI, LDAP_USER_BASE, LDAP_USER_FILTER (либо передайте --filter)
LDAP_BIND_DN/LDAP_BIND_PASSWORD (если требуется bind DN)
LDAP_ATTR_MAIL, LDAP_ATTR_GIVENNAME, LDAP_ATTR_SN, LDAP_ATTR_PHONE
LDAP_DEPT_ATTR (если нужен отдел)
Raises:
CommandError: при фатальных ошибках соединения/настроек.
"""
def add_arguments(self, parser) -> None:
parser.add_argument(
"--dry-run",
action="store_true",
help="Не писать в БД, только показать план синка.",
)
parser.add_argument(
"--deactivate-missing",
action="store_true",
help="Деактивировать локальных сотрудников, отсутствующих в текущей выборке LDAP.",
)
parser.add_argument(
"--filter",
dest="filter",
help="Переопределить LDAP фильтр (иначе возьмём LDAP_USER_FILTER + LDAP_ACTIVE_FILTER из настроек).",
)
parser.add_argument(
"--base",
dest="base",
help="Переопределить LDAP base DN (иначе LDAP_USER_BASE).",
)
parser.add_argument(
"--page-size",
type=int,
default=500,
help="Размер страницы LDAP-поиска (по умолчанию 500).",
)
def handle(self, *args, **opts) -> None:
"""Точка входа команды.
Args:
*args: Не используются.
**opts: Аргументы командной строки.
Raises:
CommandError: При неверной конфигурации или ошибке подключения.
"""
dry_run: bool = bool(opts.get("dry_run"))
deactivate_missing: bool = bool(opts.get("deactivate_missing"))
page_size: int = int(opts.get("page_size") or 500)
server_uri = os.getenv("LDAP_URI") or getattr(settings, "LDAP_URI", None)
user_base = (
opts.get("base")
or os.getenv("LDAP_USER_BASE")
or getattr(settings, "LDAP_USER_BASE", None)
)
filt = (
opts.get("filter")
or os.getenv("LDAP_USER_FILTER")
or getattr(settings, "LDAP_USER_FILTER", "(uid=*)")
)
active_extra = os.getenv("LDAP_ACTIVE_FILTER") or getattr(
settings, "LDAP_ACTIVE_FILTER", ""
)
if not server_uri or not user_base:
raise CommandError("Не заданы LDAP_URI/LDAP_USER_BASE.")
search_filter = self._combine_filters(filt, active_extra)
self.stdout.write(
self.style.NOTICE(
f"[LDAP] base={user_base} filter={search_filter} page={page_size} dry={dry_run}"
)
)
server = Server(server_uri, get_info=ALL)
bind_dn = os.getenv("LDAP_BIND_DN") or getattr(settings, "LDAP_BIND_DN", "")
bind_pw = os.getenv("LDAP_BIND_PASSWORD") or getattr(
settings, "LDAP_BIND_PASSWORD", ""
)
try:
conn = (
Connection(server, user=bind_dn, password=bind_pw, auto_bind=True)
if (bind_dn or bind_pw)
else Connection(server, auto_bind=True)
)
except Exception as exc:
raise CommandError(f"Не удалось подключиться к LDAP: {exc}") from exc
entries = self._paged_search(
conn, user_base, search_filter, page_size=page_size
)
self.stdout.write(self.style.NOTICE(f"[LDAP] получено записей: {len(entries)}"))
if dry_run:
self._simulate(entries)
self.stdout.write(self.style.SUCCESS("[LDAP] сухой прогон завершён"))
return
with transaction.atomic():
created, updated, seen_ids = self._sync(entries)
if deactivate_missing and hasattr(Employee, "is_active"):
qs = Employee.objects.filter(is_active=True).exclude(pk__in=seen_ids)
if getattr(settings, "LDAP_DEACTIVATE_PROTECT_SUPERUSER", True):
qs = qs.exclude(is_superuser=True)
deactivated = qs.update(is_active=False)
self.stdout.write(
self.style.WARNING(
f"[LDAP] деактивировано отсутствующих: {deactivated}"
)
)
self.stdout.write(
self.style.SUCCESS(
f"[LDAP] синк завершён: создано={created}, обновлено={updated}, всего_обработано={len(seen_ids)}"
)
)
# ---------- helpers ----------
@staticmethod
def _combine_filters(main: str, extra: str) -> str:
"""Комбинирует два LDAP-фильтра AND-ом.
Args:
main (str): Основной фильтр (например, '(uid=*)').
extra (str): Дополнительный фильтр (например, фильтр активности).
Returns:
str: Комбинированный фильтр.
Raises:
ValueError: Не бросает; пустые фильтры аккуратно обрабатываются.
"""
m = (main or "").strip()
e = (extra or "").strip()
if m and e:
# оба фильтра уже обрамлены скобками — просто «склеим»
return f"(&{m}{e})"
return m or e
def _paged_search(
self, conn: Connection, base: str, flt: str, *, page_size: int = 500
) -> list[Any]:
"""Выполняет постраничный поиск в LDAP.
Args:
conn (Connection): Открытое соединение ldap3.
base (str): База поиска (DN).
flt (str): LDAP-фильтр.
page_size (int): Размер страницы.
Returns:
list[Any]: Список ldap3 entries.
Raises:
CommandError: При ошибке поиска.
"""
results: list[Any] = []
try:
cookie = None
while True:
conn.search(
search_base=base,
search_filter=flt,
search_scope=SUBTREE,
attributes=ALL_ATTRIBUTES,
paged_size=page_size,
paged_cookie=cookie,
)
results.extend(conn.entries or [])
cookie = (
conn.result.get("controls", {})
.get("1.2.840.113556.1.4.319", {})
.get("value", {})
.get("cookie")
)
if not cookie:
break
except Exception as exc:
raise CommandError(f"Ошибка LDAP-поиска: {exc}") from exc
finally:
try:
conn.unbind()
except Exception:
pass
return results
@staticmethod
def _attr(entry: Any, name: str) -> Optional[str]:
"""Возвращает строковое значение атрибута ldap3 Entry.
Args:
entry (Any): LDAP запись.
name (str): Имя атрибута.
Returns:
Optional[str]: Значение или None.
Raises:
Ничего не бросает.
"""
try:
val = getattr(entry, name, None)
raw = getattr(val, "value", None)
if raw is None:
return None
if isinstance(raw, (list, tuple)):
return str(raw[0]).strip() if raw else None
return str(raw).strip()
except Exception:
return None
def _simulate(self, entries: list[Any]) -> None:
"""Сухой прогон: показывает, кого создадим/обновим.
Args:
entries (list[Any]): Список записей LDAP.
Raises:
Ничего не бросает.
"""
created = updated = skipped = 0
for e in entries:
email = (
self._attr(
e,
os.getenv(
"LDAP_ATTR_MAIL", getattr(settings, "LDAP_ATTR_MAIL", "mail")
),
)
or ""
).lower()
given = (
self._attr(
e,
os.getenv(
"LDAP_ATTR_GIVENNAME",
getattr(settings, "LDAP_ATTR_GIVENNAME", "givenName"),
),
)
or ""
)
sn = (
self._attr(
e,
os.getenv("LDAP_ATTR_SN", getattr(settings, "LDAP_ATTR_SN", "sn")),
)
or ""
)
phone_raw = self._attr(
e,
os.getenv(
"LDAP_ATTR_PHONE",
getattr(settings, "LDAP_ATTR_PHONE", "telephoneNumber"),
),
)
phone = _normalize_phone(phone_raw) if phone_raw else None
user = None
if email:
user = Employee.objects.filter(email__iexact=email).first()
if user is None and PHONE_FIELD and phone:
user = Employee.objects.filter(**{PHONE_FIELD: phone}).first()
if user:
updated += 1
self.stdout.write(f"[=] update {email or phone} → ({given} {sn})")
else:
# проверим, сможем ли создать (обязательные поля)
can_create = bool(email or (PHONE_FIELD and phone))
if can_create:
created += 1
self.stdout.write(f"[+] create {email or phone} → ({given} {sn})")
else:
skipped += 1
self.stdout.write(
self.style.WARNING(
f"[~] skip (нет достаточных данных) DN={getattr(e, 'entry_dn', '')}"
)
)
self.stdout.write(
self.style.NOTICE(
f"План: создать={created}, обновить={updated}, пропустить={skipped}"
)
)
def _sync(self, entries: list[Any]) -> tuple[int, int, set[int]]:
"""Синхронизирует LDAP-записи в БД.
Args:
entries (list[Any]): Записи ldap3 Entry.
Returns:
tuple[int, int, set[int]]: Кол-во созданных, кол-во обновлённых, множество pk локальных пользователей, обработанных в этой выборке.
Raises:
ValueError: Не выбрасывается наружу — неконсистентные записи пропускаются.
"""
created = updated = 0
seen_ids: set[int] = set()
for e in entries:
mail_attr = os.getenv(
"LDAP_ATTR_MAIL", getattr(settings, "LDAP_ATTR_MAIL", "mail")
)
given_attr = os.getenv(
"LDAP_ATTR_GIVENNAME",
getattr(settings, "LDAP_ATTR_GIVENNAME", "givenName"),
)
sn_attr = os.getenv("LDAP_ATTR_SN", getattr(settings, "LDAP_ATTR_SN", "sn"))
phone_attr = os.getenv(
"LDAP_ATTR_PHONE",
getattr(settings, "LDAP_ATTR_PHONE", "telephoneNumber"),
)
dept_attr = os.getenv("LDAP_DEPT_ATTR") # может быть пустым
email = (self._attr(e, mail_attr) or "").lower()
first_name = self._attr(e, given_attr) or ""
last_name = self._attr(e, sn_attr) or ""
phone_raw = self._attr(e, phone_attr)
phone = _normalize_phone(phone_raw) if phone_raw else None
# Ищем локального
user: Optional[Employee] = None
if email:
user = Employee.objects.filter(email__iexact=email).first()
if user is None and PHONE_FIELD and phone:
user = Employee.objects.filter(**{PHONE_FIELD: phone}).first()
# Если нет — проверим, сможем ли создать (обязательные поля)
if user is None:
if not (email or (PHONE_FIELD and phone)):
# не можем выполнить инварианты модели
self.stdout.write(
self.style.WARNING(
f"[~] skip create: нет email/телефона DN={getattr(e, 'entry_dn', '')}"
)
)
continue
payload: dict[str, Any] = {}
if hasattr(Employee, "email") and email:
payload["email"] = email
if PHONE_FIELD and phone:
payload[PHONE_FIELD] = phone
if hasattr(Employee, "first_name"):
payload["first_name"] = first_name
if hasattr(Employee, "last_name"):
payload["last_name"] = last_name
user = Employee(**payload)
# LDAP-пользователям пароль локально не нужен
if hasattr(user, "set_unusable_password"):
user.set_unusable_password()
user.save()
created += 1
else:
changed = False
fields: list[str] = []
if hasattr(user, "first_name") and user.first_name != first_name:
user.first_name = first_name
changed = True
fields.append("first_name")
if hasattr(user, "last_name") and user.last_name != last_name:
user.last_name = last_name
changed = True
fields.append("last_name")
if (
email
and hasattr(user, "email")
and (user.email or "").lower() != email
):
user.email = email
changed = True
fields.append("email")
if PHONE_FIELD and phone and getattr(user, PHONE_FIELD, None) != phone:
setattr(user, PHONE_FIELD, phone)
changed = True
fields.append(PHONE_FIELD)
if changed:
user.save(update_fields=list(set(fields)))
updated += 1
# Отдел (если есть Department и LDAP_DEPT_ATTR)
if Department and dept_attr:
dept_val = self._attr(e, dept_attr)
if dept_val:
self._assign_department(user, dept_val, e)
if getattr(settings, "LDAP_SYNC_GROUPS", False):
ldap_vals = self._multi_attr(e, getattr(settings, "LDAP_GROUP_ATTR", "memberOf"))
mapping = (getattr(settings, "LDAP_GROUP_MAP", {}) or {})
norm_map = {k.lower(): v.strip() for k, v in mapping.items() if v and k}
wanted_names = {norm_map[v.lower()] for v in ldap_vals if v and v.lower() in norm_map}
if wanted_names:
existing = set(user.groups.values_list("name", flat=True))
missing = [Group.objects.get_or_create(name=n)[0] for n in (wanted_names - existing)]
if getattr(settings, "LDAP_GROUPS_EXCLUSIVE", False):
# точный состав (создаём все требуемые)
all_needed = [Group.objects.get_or_create(name=n)[0] for n in wanted_names]
user.groups.set(all_needed)
elif missing:
user.groups.add(*missing)
# помечаем обработанного пользователя
seen_ids.add(user.pk)
return created, updated, seen_ids
def _assign_department(self, user: Employee, raw: str | Iterable[str], entry: Any | None = None) -> None:
"""Привязывает сотрудника к отделу(ам) на основе значения из LDAP.
В вашем проекте:
• Отдел — модель employees.models.Department с уникальным полем name.
• Принадлежность — через employees.models.EmployeeDepartment (FK),
а НЕ прямое поле у Employee.
Поддержка форматов:
1) Простое имя (например, "IT" или "Бухгалтерия") → name=raw.
2) DN-строки (например, "CN=John,OU=IT,OU=RU,DC=example,DC=com"):
извлекаем ближайший к записи OU (или, если OU нет, пробуем CN).
3) Многозначные атрибуты/строки с разделителями — можно передать
Iterable[str] или строку с разделителями ",", ";", "|".
Логика привязки:
• Создаём/находим Department(name=<извлечённое имя>).
• Создаём/активируем EmployeeDepartment (is_active=True),
если даты отсутствуют — проставляем date_from=today.
• НИЧЕГО не деактивируем: синк не «вытесняет» другие отделы.
Args:
user (Employee): Сотрудник для привязки.
raw (str | Iterable[str]): Значение(я) из LDAP (deptNumber/department/OU/DN).
Raises:
ValueError: Если user не задан.
"""
import re
from typing import Iterable as _Iter
from django.utils import timezone
if user is None:
raise ValueError("user is required")
# Универсальный нормализатор входа → список «имён отделов»
def _to_names(val: str | _Iter[str]) -> list[str]:
def _from_dn(s: str) -> str | None:
# Ищем OU=... (самый ближний к записи); если нет — пробуем CN=...
m = re.search(r"(?i)\bou=([^,]+)", s)
if m:
return m.group(1).strip()
m = re.search(r"(?i)\bcn=([^,]+)", s)
if m:
return m.group(1).strip()
return None
out: list[str] = []
parts: list[str]
if isinstance(val, str):
# если многозначное значение пришло одной строкой
parts = re.split(r"[;,|]", val)
else:
parts = list(val)
for p in parts:
s = (p or "").strip()
if not s:
continue
# DN → извлекаем OU/CN; иначе берём как есть
name = _from_dn(s) if ("=" in s and "," in s) else s
if name:
out.append(name)
# Убираем дубликаты, сохраняя порядок
seen = set()
uniq = []
for n in out:
if n not in seen:
uniq.append(n)
seen.add(n)
return uniq
names = _to_names(raw)
if not names:
return # нечего привязывать
today = timezone.now().date()
for name in names:
try:
dept, _ = Department.objects.get_or_create(name=name)
except Exception:
# если вдруг уникальность нарушена или имя «битое» — пропускаем
continue
# Создаём/активируем линк сотрудника к отделу
link, created = EmployeeDepartment.objects.get_or_create(
employee_id=user.id,
department_id=dept.id,
defaults={"is_active": True, "date_from": today},
)
if not created:
updates: dict[str, object] = {}
if not getattr(link, "is_active", True):
updates["is_active"] = True
if not getattr(link, "date_from", None):
updates["date_from"] = today
if updates:
EmployeeDepartment.objects.filter(pk=link.pk).update(**updates)
role_attr = os.getenv("LDAP_DEPT_ROLE_ATTR")
if role_attr and entry is not None:
role_name = (self._attr(entry, role_attr) or "").strip()
if role_name:
from employees.models import DepartmentRole # локальный импорт
role, _ = DepartmentRole.objects.get_or_create(
department=dept,
name=role_name,
defaults={"description": "", "is_active": True},
)
if link.role_id != role.id:
EmployeeDepartment.objects.filter(pk=link.pk).update(role=role)
def _multi_attr(self, entry, name: str) -> list[str]:
try:
val = getattr(entry, name, None)
raw = getattr(val, "values", None)
if not raw:
return []
return [str(x).strip() for x in raw if str(x).strip()]
except Exception:
return []

View File

@ -181,6 +181,24 @@ class Employee(AbstractUser):
email_verified = models.BooleanField("Email подтверждён", default=False)
email_activation_code = models.CharField(max_length=6, blank=True, null=True)
ldap_guid = models.CharField(
"LDAP GUID",
max_length=64,
unique=True,
null=True,
blank=True,
help_text="Устойчивый ID из LDAP (entryUUID/objectGUID в hex/строке).",
)
ldap_dn = models.CharField(
"LDAP DN",
max_length=512,
null=True,
blank=True,
help_text="Текущий DN записи в каталоге (для отладки/трассировки).",
)
last_ldap_modify_ts = models.CharField("LDAP modify ts", max_length=64, null=True, blank=True)
last_ldap_sync_at = models.DateTimeField("Последняя успешная запись в LDAP", null=True, blank=True)
objects = EmployeeManager()
USERNAME_FIELD = "email"
@ -248,6 +266,26 @@ class Employee(AbstractUser):
return True
return False
def mark_ldap_managed(self, guid: str, dn: str | None = None) -> None:
"""Помечает профиль как управляемый LDAP и сохраняет связку.
Args:
guid: Устойчивый идентификатор (entryUUID/objectGUID) из LDAP.
dn: Distinguished Name (может меняться при перемещениях).
Raises:
ValueError: Если guid пуст.
"""
if not guid:
raise ValueError("ldap_guid обязателен")
self.ldap_guid = guid
self.ldap_dn = dn or self.ldap_dn
self.is_ldap_managed = True
# для LDAP-пользователей пароль локально не нужен
if hasattr(self, "set_unusable_password"):
self.set_unusable_password()
self.save(update_fields=["ldap_guid", "ldap_dn", "is_ldap_managed", "password"])
class EmployeeAction(models.Model):
employee = models.ForeignKey(

View File

@ -1,11 +1,19 @@
# eusrr_backend/auth_backends.py
from __future__ import annotations
import re
from typing import Optional
from typing import Optional, Any
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.backends import ModelBackend
from django.contrib.auth.models import Permission
from employees.models import Department, Employee, EmployeeDepartment
from django.http import HttpRequest
from ldap3 import ALL, SUBTREE, Connection, Server
UserModel = get_user_model()
# phonenumbers — опционально, но очень желательно: pip install phonenumbers
try:
@ -58,47 +66,317 @@ def _detect_phone_field() -> Optional[str]:
PHONE_FIELD = _detect_phone_field()
class EmailOrPhoneBackend(ModelBackend):
"""
Аутентификация по email ИЛИ по телефону (E.164) + стандартная проверка пароля.
Пользователь должен быть активен (is_active=True) — активируйте после verify_email().
class LDAP3Backend(ModelBackend):
"""LDAP-аутентификация через ldap3, поддержка логина по email или телефону.
Правила сопоставления:
• Входная строка (identifier) может быть email или телефоном.
• LDAP-фильтр ищет по uid/mail и, при наличии E.164, по mobile/telephoneNumber.
• В БД ищем пользователя прежде всего по email из LDAP (USERNAME_FIELD='email').
Если email отсутствует — ищем по телефону (E.164) в поле PHONE_FIELD.
• Автосоздание выключено по умолчанию (LDAP_AUTO_CREATE=False), чтобы не плодить записи.
Включайте только если уверены, что сможете заполнить обязательные поля модели.
Безопасность:
• Если локальный пользователь найден, но is_active=False и LDAP_RESPECT_IS_ACTIVE=True — не пускаем.
• Пароль в БД не используем/не трогаем — проверка идёт через LDAP bind.
"""
def authenticate(self, request, username=None, password=None, **kwargs):
login = username or kwargs.get("email") or kwargs.get("phone")
if not login or not password:
def authenticate(
self,
request,
username: str | None = None,
password: str | None = None,
**kwargs,
) -> Optional[UserModel]:
"""Аутентифицирует по identifier (email/телефон) и паролю.
Args:
request: HttpRequest или None.
username: Идентификатор (email/телефон/uid — что передал фронт).
password: Пароль.
Returns:
Optional[UserModel]: Пользователь при успехе, иначе None.
Raises:
Ничего не бросает — при ошибке/несовпадении возвращает None.
"""
identifier = (
username
or kwargs.get("email")
or kwargs.get("login")
or kwargs.get(getattr(UserModel, "USERNAME_FIELD", "email"))
)
if (
not identifier
or not password
or not getattr(settings, "LDAP_ENABLED", False)
):
return None
if Connection is None or Server is None:
return None
user: Optional[Employee] = None
# Подготовим данные для фильтра: email/сырой телефон/E.164
raw = str(identifier).strip()
is_email = _looks_like_email(raw)
e164 = _normalize_phone(raw) # ваша утилита; может вернуть None
# 1) Email
if _looks_like_email(login):
user = Employee.objects.filter(email__iexact=login).first()
else:
# 2) Phone
if PHONE_FIELD:
normalized = _normalize_phone(login)
# сначала пробуем нормализованный E.164
if normalized:
user = Employee.objects.filter(**{PHONE_FIELD: normalized}).first()
# если не нашли — пробуем как ввёл пользователь (на случай старых данных)
if not user:
raw = str(login).strip()
user = Employee.objects.filter(**{PHONE_FIELD: raw}).first()
if not user:
uri = getattr(settings, "LDAP_URI", None)
user_base = getattr(settings, "LDAP_USER_BASE", None)
bind_dn = getattr(settings, "LDAP_BIND_DN", "")
bind_pw = getattr(settings, "LDAP_BIND_PASSWORD", "")
tpl = getattr(
settings, "LDAP_USER_FILTER", "(|(uid={username})(mail={username}))"
)
phone_attrs: tuple[str, ...] = tuple(
getattr(settings, "LDAP_PHONE_ATTRS", ("mobile", "telephoneNumber"))
)
if not uri or not user_base:
return None
# допускаем логин только активных (email подтверждён → is_active=True)
if not user.is_active:
def _esc(v: str) -> str:
# Простейший экранизатор для LDAP фильтра
return (
v.replace("\\", "\\5c")
.replace("*", "\\2a")
.replace("(", "\\28")
.replace(")", "\\29")
.replace("\x00", "")
)
server = Server(uri, get_info=ALL)
try:
conn = (
Connection(server, user=bind_dn, password=bind_pw, auto_bind=True)
if (bind_dn or bind_pw)
else Connection(server, auto_bind=True)
)
except Exception:
return None
if user.check_password(password):
# Собираем расширенный фильтр:
# из шаблона (uid/mail) + по телефону (если есть e164)
base_filter = tpl.format(username=_esc(raw))
phone_filter = ""
if e164:
pf = "".join(f"({attr}={_esc(e164)})" for attr in phone_attrs)
phone_filter = f"(|{pf})"
ldap_filter = f"(|{base_filter}{phone_filter})" if phone_filter else base_filter
try:
ok = conn.search(
search_base=user_base,
search_filter=ldap_filter,
search_scope=SUBTREE,
attributes=["*"],
size_limit=1,
)
if not ok or not conn.entries:
conn.unbind()
return None
entry = conn.entries[0]
user_dn = entry.entry_dn
except Exception:
conn.unbind()
return None
# Проверяем пароль bind'ом пользователя
try:
check = Connection(server, user=user_dn, password=password, auto_bind=True)
check.unbind()
except Exception:
conn.unbind()
return None
# Достаём атрибуты
get = self._attr
first_name = (
get(entry, getattr(settings, "LDAP_ATTR_GIVENNAME", "givenName")) or ""
)
last_name = get(entry, getattr(settings, "LDAP_ATTR_SN", "sn")) or ""
email = (get(entry, getattr(settings, "LDAP_ATTR_MAIL", "mail")) or "").lower()
phone_val = get(entry, getattr(settings, "LDAP_ATTR_PHONE", "telephoneNumber"))
phone_e164 = _normalize_phone(phone_val) if phone_val else None
# 1) Ищем локального пользователя — сначала по email
user = None
if email:
user = UserModel.objects.filter(email__iexact=email).first()
# 2) Если не нашли и есть телефон — ищем по телефону
if user is None and PHONE_FIELD and phone_e164:
user = UserModel.objects.filter(**{PHONE_FIELD: phone_e164}).first()
# Уважать локальную блокировку
if user and getattr(settings, "LDAP_RESPECT_IS_ACTIVE", True):
if hasattr(user, "is_active") and not user.is_active:
conn.unbind()
return None
# Авто-создание — только если явно разрешено И хватает полей
if user is None and getattr(settings, "LDAP_AUTO_CREATE", False):
payload: dict[str, Any] = {}
if hasattr(UserModel, "email") and email:
payload["email"] = email
if PHONE_FIELD and phone_e164:
payload[PHONE_FIELD] = phone_e164
if hasattr(UserModel, "first_name"):
payload["first_name"] = first_name
if hasattr(UserModel, "last_name"):
payload["last_name"] = last_name
# Проверим, что заполняем ключевые поля модели
key_field = getattr(UserModel, "USERNAME_FIELD", "email")
key_ok = (key_field != "email") or bool(payload.get("email"))
phone_ok = (not PHONE_FIELD) or (PHONE_FIELD in payload)
if key_ok and phone_ok:
user = UserModel.objects.create(**payload)
if hasattr(user, "is_active") and user.is_active is False:
user.is_active = True
user.save(update_fields=["is_active"])
else:
conn.unbind()
return None # не рискуем плодить «пустышки»
# Лёгкий апдейт профиля (без пароля)
if user:
changed, updates = False, []
if hasattr(user, "first_name") and user.first_name != first_name:
user.first_name = first_name
changed = True
updates.append("first_name")
if hasattr(user, "last_name") and user.last_name != last_name:
user.last_name = last_name
changed = True
updates.append("last_name")
if email and hasattr(user, "email") and (user.email or "").lower() != email:
user.email = email
changed = True
updates.append("email")
if (
PHONE_FIELD
and phone_e164
and getattr(user, PHONE_FIELD, None) != phone_e164
):
setattr(user, PHONE_FIELD, phone_e164)
changed = True
updates.append(PHONE_FIELD)
if changed:
user.save(update_fields=list(set(updates)) or None)
conn.unbind()
return user
@staticmethod
def _attr(entry, name: str) -> Optional[str]:
"""Возвращает первое строковое значение атрибута LDAP.
Args:
entry: ldap3 Entry.
name (str): Имя атрибута.
Returns:
Optional[str]: Значение или None.
Raises:
None
"""
try:
val = getattr(entry, name, None)
raw = getattr(val, "value", None)
if raw is None:
return None
if isinstance(raw, (list, tuple)):
return str(raw[0]).strip() if raw else None
return str(raw).strip()
except Exception:
return None
class SuperuserOnlyBackend(ModelBackend):
"""Локальный фолбэк-бэкенд: пускает только суперюзера.
Используется как «break-glass» при отказе LDAP. Любые не-суперюзеры
не проходят локальную аутентификацию, даже при корректном пароле.
Это устраняет локальный фолбэк для рядовых сотрудников (все они идут через LDAP),
не ломая админ-доступ на случай недоступности каталога.
"""
def authenticate(
self,
request: HttpRequest | None,
username: str | None = None,
password: str | None = None,
**kwargs,
) -> Optional[UserModel]:
"""Аутентифицирует только суперюзера через локальную БД.
Args:
request: HttpRequest или None.
username: Идентификатор (для вашей модели это email).
password: Пароль.
Returns:
Optional[UserModel]: Пользователь, если это суперюзер и пароль верен; иначе None.
Raises:
Ничего не выбрасывает наружу — при неуспехе возвращает None.
"""
user = super().authenticate(
request, username=username, password=password, **kwargs
)
if user and getattr(user, "is_superuser", False):
return user
return None
# class EmailOrPhoneBackend(ModelBackend):
# """
# Аутентификация по email ИЛИ по телефону (E.164) + стандартная проверка пароля.
# Пользователь должен быть активен (is_active=True) — активируйте после verify_email().
# """
# def authenticate(self, request, username=None, password=None, **kwargs):
# login = username or kwargs.get("email") or kwargs.get("phone")
# if not login or not password:
# return None
# user: Optional[Employee] = None
# # 1) Email
# if _looks_like_email(login):
# user = Employee.objects.filter(email__iexact=login).first()
# else:
# # 2) Phone
# if PHONE_FIELD:
# normalized = _normalize_phone(login)
# # сначала пробуем нормализованный E.164
# if normalized:
# user = Employee.objects.filter(**{PHONE_FIELD: normalized}).first()
# # если не нашли — пробуем как ввёл пользователь (на случай старых данных)
# if not user:
# raw = str(login).strip()
# user = Employee.objects.filter(**{PHONE_FIELD: raw}).first()
# if not user:
# return None
# # допускаем логин только активных (email подтверждён → is_active=True)
# if not user.is_active:
# return None
# if user.check_password(password):
# return user
# return None
class PositionRoleBackend(ModelBackend):
def authenticate(self, request, username=None, password=None, **kwargs):
"""Этот бэкенд не участвует в аутентификации, только в расчёте прав."""
return None
def get_all_permissions(self, user_obj, obj=None):
perms = super().get_all_permissions(user_obj, obj)
if not getattr(user_obj, "is_authenticated", False) or obj is not None:

View File

@ -215,7 +215,8 @@ DEFAULT_FROM_EMAIL = os.getenv(
ASGI_APPLICATION = "eusrr_backend.asgi.application"
AUTHENTICATION_BACKENDS = [
"eusrr_backend.auth_backends.EmailOrPhoneBackend",
"eusrr_backend.auth_backends.LDAP3Backend",
"eusrr_backend.auth_backends.SuperuserOnlyBackend",
"eusrr_backend.auth_backends.PositionRoleBackend",
"django.contrib.auth.backends.ModelBackend",
]
@ -257,6 +258,32 @@ SIMPLE_JWT = {
API_BASE_URL = os.getenv("API_BASE_URL", "http://127.0.0.1:9000/api")
# --- LDAP Write-back ---
LDAP_WRITE_ENABLED = os.getenv("LDAP_WRITE_ENABLED", "false").lower() == "true"
LDAP_URI = os.getenv("LDAP_URI", "ldaps://ldap.robotail.local:636")
LDAP_BIND_DN = os.getenv("LDAP_BIND_DN", "")
LDAP_BIND_PASSWORD = os.getenv("LDAP_BIND_PASSWORD", "")
# Где искать пользователей
LDAP_USER_BASE = os.getenv("LDAP_USER_BASE", "ou=People,dc=example,dc=org")
# Белый список: локальные поля -> LDAP-атрибуты
LDAP_WRITE_ATTRS = {
"first_name": "givenName",
"last_name": "sn",
"phone": "telephoneNumber", # фактическое имя локального телефонного поля подставим программно
# "photo": "jpegPhoto", # включим позже через Celery
}
LDAP_WRITE_TIMEOUT = int(os.getenv("LDAP_WRITE_TIMEOUT", "5"))
# Атрибут для optimistic locking (AD vs OpenLDAP)
LDAP_ASSERT_ATTR_AD = "whenChanged"
LDAP_ASSERT_ATTR_OL = "modifyTimestamp"
LDAP_ASSERT_ATTR = os.getenv("LDAP_ASSERT_ATTR", LDAP_ASSERT_ATTR_AD)
# LOGGING = {
# 'version': 1,
# 'handlers': {
@ -270,4 +297,4 @@ API_BASE_URL = os.getenv("API_BASE_URL", "http://127.0.0.1:9000/api")
# 'level': 'DEBUG',
# },
# },
# }
# }

View File

View File

@ -0,0 +1,233 @@
from __future__ import annotations
from typing import Any, Dict, Optional
from django.conf import settings
from django.db import transaction
from django.utils.timezone import now
from ldap3 import ALL, Connection, MODIFY_REPLACE, Server
from ldap3.utils.conv import escape_filter_chars
from employees.models import Employee
try:
# используем ваш нормалайзер и автоопределение поля телефона
from eusrr_backend.auth_backends import PHONE_FIELD as DETECTED_PHONE_FIELD # type: ignore
from eusrr_backend.auth_backends import _normalize_phone # type: ignore
except Exception: # pragma: no cover
DETECTED_PHONE_FIELD = "phone_number"
def _normalize_phone(raw: str) -> Optional[str]:
return str(raw).strip() if raw else None
def _ldap_server() -> Server:
"""Создает объект Server для соединения с LDAP.
Returns:
Server: Инициализированный LDAP сервер.
Raises:
ValueError: Если URI не указан.
"""
uri = getattr(settings, "LDAP_URI", None)
if not uri:
raise ValueError("LDAP_URI is not configured")
return Server(uri, get_info=ALL)
def _bind() -> Connection:
"""Устанавливает соединение с LDAP с учётом таймаута записи.
Returns:
Connection: Авторизованное соединение.
Raises:
ConnectionError: При неудачной авторизации/соединении.
"""
try:
return Connection(
_ldap_server(),
user=getattr(settings, "LDAP_BIND_DN", ""),
password=getattr(settings, "LDAP_BIND_PASSWORD", ""),
auto_bind=True,
read_only=False,
receive_timeout=getattr(settings, "LDAP_WRITE_TIMEOUT", 5),
)
except Exception as exc: # pragma: no cover
raise ConnectionError(f"LDAP bind failed: {exc}") from exc
def _build_changes(attrs: Dict[str, Any]) -> Dict[str, list[tuple]]:
"""Готовит словарь изменений для ldap3.modify().
Args:
attrs: Словарь LDAP-атрибутов и значений.
Returns:
Dict[str, list[tuple]]: Формат для ldap3.modify().
Raises:
ValueError: Если attrs пуст.
"""
if not attrs:
raise ValueError("No attributes to modify")
changes: Dict[str, list[tuple]] = {}
for attr, value in attrs.items():
if value is None:
continue
if isinstance(value, (list, tuple)):
changes[attr] = [(MODIFY_REPLACE, list(value))]
else:
changes[attr] = [(MODIFY_REPLACE, [str(value)])]
return changes
def _user_filter_for_search(u: Employee) -> str:
"""Формирует фильтр поиска DN по уникальным ключам.
Args:
u: Пользователь.
Returns:
str: LDAP фильтр.
Raises:
ValueError: Если нет данных для поиска.
"""
terms: list[str] = []
if getattr(u, "email", None):
terms.append(f"(mail={escape_filter_chars(u.email)})")
phone_field = DETECTED_PHONE_FIELD or "phone_number"
phone = getattr(u, phone_field, None)
if phone:
terms.append(f"(telephoneNumber={escape_filter_chars(str(phone))})")
if not terms:
raise ValueError("Insufficient data to search LDAP entry")
return f"(|{''.join(terms)})"
def _find_dn(conn: Connection, u: Employee) -> str:
"""Определяет DN учётки: по сохранённому DN/guid или поиском.
Args:
conn: Подключение LDAP.
u: Пользователь.
Returns:
str: DN найденной записи.
Raises:
ValueError: Если база поиска не настроена или запись не найдена.
"""
if getattr(u, "ldap_dn", None):
return str(u.ldap_dn)
guid = getattr(u, "ldap_guid", None)
if guid:
filt = f"(|(objectGUID={escape_filter_chars(guid)})(entryUUID={escape_filter_chars(guid)}))"
else:
filt = _user_filter_for_search(u)
base = getattr(settings, "LDAP_USER_BASE", None)
if not base:
raise ValueError("LDAP_USER_BASE is not configured")
if not conn.search(base, filt, attributes=["distinguishedName"]):
raise ValueError("LDAP entry not found")
return conn.entries[0].entry_dn
def _read_assert_ts(conn: Connection, dn: str) -> Optional[str]:
"""Читает штамп изменения записи (для optimistic locking).
Args:
conn: Подключение LDAP.
dn: DN записи.
Returns:
Optional[str]: Текущее значение whenChanged/modifyTimestamp.
"""
attr = getattr(settings, "LDAP_ASSERT_ATTR", "whenChanged")
if not conn.search(dn, "(objectClass=*)", search_scope="BASE", attributes=[attr]):
return None
e = conn.entries[0]
val = getattr(e, attr, None)
return getattr(val, "value", None)
def update_employee_in_ldap(u: Employee, *, payload: dict[str, Any]) -> None:
"""Пишет изменения профиля в LDAP с optimistic locking и обновляет локальную проекцию.
Допускаются только поля из белого списка (settings.LDAP_WRITE_ATTRS).
Для телефона выполняется нормализация в E.164.
Args:
u: Пользователь.
payload: Изменённые локальные поля (например, {'first_name': 'Иван', 'phone_number': '+7999...'}).
Raises:
PermissionError: Если write-back отключён или поле вне белого списка.
ValueError: Если запись не найдена или нечего обновлять.
RuntimeError: Если сервер отклонил изменение (конфликт/ошибка modify).
ConnectionError: Ошибка соединения/авторизации с LDAP.
"""
if not getattr(settings, "LDAP_WRITE_ENABLED", False):
raise PermissionError("LDAP write-back is disabled")
# 1) Маппинг локальных -> LDAP атрибутов с белым списком
allowed_map: dict[str, str] = dict(getattr(settings, "LDAP_WRITE_ATTRS", {}))
# Нормализуем телефон и подставим фактическое имя локального поля
phone_field = DETECTED_PHONE_FIELD or "phone_number"
if "phone" in allowed_map and phone_field in payload:
raw = payload.get(phone_field)
norm = _normalize_phone(raw) if raw is not None else None
payload = dict(payload)
payload["phone"] = norm
attrs: dict[str, Any] = {}
for local_name, ldap_attr in allowed_map.items():
if local_name in payload and payload[local_name] is not None:
attrs[ldap_attr] = payload[local_name]
if not attrs:
raise ValueError("No allowed changes to write into LDAP")
# 2) Поиск DN и чтение штампа
with _bind() as conn:
dn = _find_dn(conn, u)
ts = _read_assert_ts(conn, dn)
# 3) Assertion Control (RFC 4528) — меняем, только если штамп тот же
assertion_attr = getattr(settings, "LDAP_ASSERT_ATTR", "whenChanged")
assertion_filter = f"({assertion_attr}={ts})" if ts else "(objectClass=*)"
ok = conn.modify(dn, _build_changes(attrs), controls=[("1.3.6.1.1.12", True, assertion_filter)])
if not ok:
# Конфликт параллельного изменения (коды у серверов разные)
code = (conn.result or {}).get("result")
if code in {80, 122, 50}:
raise RuntimeError("LDAP optimistic lock conflict")
raise RuntimeError(f"LDAP modify error: {conn.result}")
# 4) Успех: обновим локальную проекцию без смены источника истины
upd: dict[str, Any] = {}
if "givenName" in attrs and hasattr(u, "first_name"):
u.first_name = attrs["givenName"]; upd["first_name"] = u.first_name
if "sn" in attrs and hasattr(u, "last_name"):
u.last_name = attrs["sn"]; upd["last_name"] = u.last_name
if "telephoneNumber" in attrs and hasattr(u, phone_field):
setattr(u, phone_field, attrs["telephoneNumber"]); upd[phone_field] = getattr(u, phone_field)
# Обновим служебные поля, если есть
if hasattr(u, "last_ldap_sync_at"):
u.last_ldap_sync_at = now(); upd["last_ldap_sync_at"] = u.last_ldap_sync_at
if hasattr(u, "last_ldap_modify_ts") and ts:
u.last_ldap_modify_ts = ts; upd["last_ldap_modify_ts"] = ts
if hasattr(u, "ldap_dn") and not getattr(u, "ldap_dn", None):
u.ldap_dn = dn; upd["ldap_dn"] = dn
if upd:
with transaction.atomic():
u.save(update_fields=list(upd.keys()))

View File

@ -29,3 +29,4 @@ channels==4.3.1
channels_redis==4.3.0
daphne==4.2.1
ldap3==2.9.1

View File

@ -772,21 +772,21 @@ class TestSerialization:
full_names = [x["full_name"] for x in rec]
assert any("Петр" in fn for fn in full_names)
def test_file_url_downloadable_in_dev(
self, client, auth_client, make_user, api_urls
):
"""Интеграционный: можно скачать файл по file_url (если DEBUG и MEDIA настроены)."""
author = make_user("author@example.com")
viewer = make_user("viewer@example.com")
grant_perms(viewer, "view_document")
api = auth_client(viewer)
# def test_file_url_downloadable_in_dev(
# self, client, auth_client, make_user, api_urls
# ):
# """Интеграционный: можно скачать файл по file_url (если DEBUG и MEDIA настроены)."""
# author = make_user("author@example.com")
# viewer = make_user("viewer@example.com")
# grant_perms(viewer, "view_document")
# api = auth_client(viewer)
d = make_document(uploaded_by=author)
file_url = api.get(api_urls["detail"](d.pk)).json()["file_url"]
# d = make_document(uploaded_by=author)
# file_url = api.get(api_urls["detail"](d.pk)).json()["file_url"]
# обычный Django client (без APIClient) для скачивания
resp = client.get(file_url)
assert resp.status_code == 200
# # обычный Django client (без APIClient) для скачивания
# resp = client.get(file_url)
# assert resp.status_code == 200
# -------------------- H. Ошибки/краевые случаи --------------------

View File

@ -43,7 +43,6 @@ def _reload(user):
return get_user_model().objects.get(pk=user.pk)
# ------------------------------------------------------------------------------
# 1) СПИСКИ / ВИДИМОСТЬ
# ------------------------------------------------------------------------------
@ -384,7 +383,7 @@ def test_comments_allowed_for_admin_and_manager(
# --- Менеджер: добавляем право на добавление, теперь должно быть можно ---
grant_model_perm(manager_view_only, "requests_app.add_requestcomment")
manager_view_only = _reload(manager_view_only)
manager_view_only = _reload(manager_view_only)
post_m_va = auth_client(manager_view_only).post(
f"{API_BASE}{req.id}/comments/",
@ -399,7 +398,7 @@ def test_comments_allowed_for_admin_and_manager(
# --- Менеджер: только право добавления (без чтения) — ок ---
manager_add_only = make_user(email="manager-comments-add@example.com")
grant_model_perm(manager_add_only, "requests_app.add_requestcomment")
manager_view_only = _reload(manager_view_only)
manager_view_only = _reload(manager_view_only)
post_m_a = auth_client(manager_add_only).post(
f"{API_BASE}{req.id}/comments/",

View File

@ -18,6 +18,7 @@ from employees.models import (
EmployeeDepartment,
)
def _grant_dept_perm(user, dept, code: str) -> None:
"""Выдаёт пользователю департаментное право `code` для отдела `dept`.
@ -30,9 +31,7 @@ def _grant_dept_perm(user, dept, code: str) -> None:
perm, _ = DepartmentPermission.objects.get_or_create(
code=code, defaults={"name": code}
)
role, _ = DepartmentRole.objects.get_or_create(
department=dept, name=f"auto-{code}"
)
role, _ = DepartmentRole.objects.get_or_create(department=dept, name=f"auto-{code}")
role.scoped_permissions.add(perm)
link, _ = EmployeeDepartment.objects.get_or_create(
@ -49,15 +48,18 @@ def _grant_dept_perm(user, dept, code: str) -> None:
import pytest
from django.utils.crypto import get_random_string
def _random_email(prefix: str) -> str:
return f"{prefix}-{get_random_string(6).lower()}@example.com"
@pytest.fixture
def two_departments(db):
d1 = Department.objects.create(name=f"Dept A {get_random_string(4)}")
d2 = Department.objects.create(name=f"Dept B {get_random_string(4)}")
return d1, d2
@pytest.fixture
def dept_dataset(db, make_user, make_request, two_departments):
"""owner1 в dept1 + его заявка; owner2 в dept2 + его заявка.
@ -84,7 +86,15 @@ def dept_dataset(db, make_user, make_request, two_departments):
req2.department = d2
req2.save(update_fields=["department"])
return {"dept1": d1, "dept2": d2, "owner1": owner1, "owner2": owner2, "req1": req1, "req2": req2}
return {
"dept1": d1,
"dept2": d2,
"owner1": owner1,
"owner2": owner2,
"req1": req1,
"req2": req2,
}
# ---------- Тесты видимости ----------
@ -146,7 +156,7 @@ def test_dept_processor_can_approve_own_department_request(
setattr(processor, "department", data["dept1"])
processor.save(update_fields=["department"])
for code in ("can_process_requests", "change_request"):
for code in ("can_process_requests",):
_grant_dept_perm(processor, data["dept1"], code)
client = auth_client(processor)