Merge feat/ldap-writeback into master (create merge/master-and-ldap-writeback)

This commit is contained in:
admin i 2025-09-22 14:34:49 +03:00
commit 5256347829
39 changed files with 8253 additions and 16 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

@ -1613,4 +1613,4 @@ class GroupViewSet(viewsets.ModelViewSet):
"permission_ids": list(grp.permissions.values_list("id", flat=True)),
},
status=200,
)
)

View File

@ -7,8 +7,11 @@ 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 (SAFE_METHODS, BasePermission,
DjangoModelPermissions)
from rest_framework.permissions import (
SAFE_METHODS,
BasePermission,
DjangoModelPermissions,
)
from rest_framework.request import Request
MANAGE_PERM = "manage_department"

View File

@ -0,0 +1,570 @@
# backend\api\v1\permissions.py
from __future__ import annotations
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
<<<<<<< HEAD
from rest_framework.permissions import (SAFE_METHODS, BasePermission,
DjangoModelPermissions)
=======
from rest_framework.permissions import (
SAFE_METHODS,
BasePermission,
DjangoModelPermissions,
)
>>>>>>> origin/feat/ldap-writeback
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:
return bool(
user and user.is_authenticated and dept.head_id == getattr(user, "id", None)
)
def user_is_staffish(user) -> bool:
return bool(user and user.is_authenticated and (user.is_superuser or user.is_staff))
def user_has_dept_perm(user, dept: Department, perm_code: str) -> bool:
"""Проверяет, есть ли у пользователя право `perm_code` в указанном отделе.
Правила:
- Неаутентифицированным → False.
- staff/superuser → True.
- Руководитель отдела → True.
- Иначе: существует активная связь EmployeeDepartment, и у роли есть
DepartmentPermission с нужным code.
Args:
user (User): Текущий пользователь.
dept (Department): Отдел.
perm_code (str): Код департаментного права (см. DeptPerm.CHOICES).
Returns:
bool: True, если доступ разрешён.
Raises:
ValueError: Если `dept` не задан или не имеет id.
"""
if not (user and user.is_authenticated):
return False
# staff/superuser — сразу ок
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
if not isinstance(dept, Department) or getattr(dept, "id", None) is None:
raise ValueError("Argument `dept` must be Department instance with a valid id.")
# руководитель — сразу ок
if getattr(dept, "head_id", None) == user.id:
return True
# основная проверка: активная связь + у роли есть нужный DepartmentPermission.code
return EmployeeDepartment.objects.filter(
employee_id=user.id,
department_id=dept.id,
is_active=True,
role__scoped_permissions__code=perm_code, # <-- ВАЖНО: `code`, не `codename`
).exists()
def has_dept_perm(user: AbstractBaseUser, department_id: int, code: str) -> bool:
"""
Возвращает True, если пользователь имеет право `code` в рамках отдела `department_id`.
Логика:
1) staff/superuser → всегда True
2) пользователь является текущим руководителем отдела → True для любых «управленческих» кодов
3) пользователь состоит в отделе и его роль содержит `code` в scoped_permissions → True
4) иначе False
"""
if not getattr(user, "is_authenticated", False):
return False
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
# 2) Руководитель отдела имеет все управленческие права в своём отделе
if Department.objects.filter(id=department_id, head_id=user.id).exists():
return True
# 3) Проверка роли в отделе
link = (
EmployeeDepartment.objects.select_related("role")
.filter(employee_id=user.id, department_id=department_id, is_active=True)
.first()
)
if not link or not link.role_id:
return False
# роль хранит «скоуп-права» только для этого отдела
return link.role.scoped_permissions.filter(code=code).exists()
class AdminOrDeptAllowed(BasePermission):
"""Комбинированный пермишен: админский доступ ИЛИ доступ по праву на конкретный отдел.
Поведение:
1. Если пользователь superuser/staff — доступ всегда разрешён.
2. Если для текущего экшена требуется код права (например, DeptPerm.MANAGE_CALENDAR),
то доступ разрешается только при наличии у пользователя этого права на указанный отдел.
3. Идентификатор отдела извлекается из:
- URL kwargs: 'department_pk' → 'department_id';
- request.data: 'department' → 'department_id';
- query params: 'department' → 'department_id'.
На уровне has_object_permission отдел берётся из самого объекта:
- Department.id;
- obj.department_id / obj.dept_id;
- obj.department.id.
4. Если код права не задан (required_code/required_code_map пусты), то
для SAFE-методов (GET/HEAD/OPTIONS) доступ разрешён, если allow_safe_without_code=True (по умолчанию).
Для небезопасных методов в этом случае доступ запрещён.
Атрибуты класса:
required_code (str|None): Единый код права для всех экшенов. Если None, используется required_code_map.
required_code_map (dict[str,str]): Коды прав по имени action (create/update/partial_update/destroy/...).
allow_safe_without_code (bool): Разрешать ли SAFE-методы, когда код не задан. По умолчанию True.
Примеры:
class ManageCalendarPerm(AdminOrDeptAllowed):
required_code = DeptPerm.MANAGE_CALENDAR
class DeptEventsPerm(AdminOrDeptAllowed):
required_code_map = {
"create": DeptPerm.MANAGE_CALENDAR,
"update": DeptPerm.MANAGE_CALENDAR,
"partial_update": DeptPerm.MANAGE_CALENDAR,
"destroy": DeptPerm.MANAGE_CALENDAR,
}
"""
required_code: Optional[str] = None
required_code_map: Dict[str, str] = {}
allow_safe_without_code: bool = True
# -------- helpers --------
def get_required_code(self, request, view) -> Optional[str]:
"""Определяет требуемый код права для текущего экшена.
Сначала берёт из required_code_map по имени action (DRF: view.action),
затем fallback на required_code.
Args:
request: DRF Request (не используется в текущей логике).
view: DRF View/ViewSet; важно наличие атрибута 'action' для ViewSet.
Returns:
Optional[str]: Код права или None, если право для экшена не требуется.
"""
return (
self.required_code_map.get(getattr(view, "action", None))
or self.required_code
)
def _extract_dept_id_from_request(self, request, view) -> Optional[int]:
"""Пытается извлечь department_id из kwargs, body или query-параметров.
Порядок:
1) view.kwargs: department_pk, department_id
2) request.data: department, department_id
3) request.query_params: department, department_id
Args:
request: DRF Request
view: DRF View
Returns:
Optional[int]: Значение dept_id, если найдено и приведено к int, иначе None.
"""
for k in ("department_pk", "department_id"):
v = view.kwargs.get(k)
if v is not None:
return v
for k in ("department", "department_id"):
v = request.data.get(k)
if v is not None:
return v
for k in ("department", "department_id"):
v = request.query_params.get(k)
if v is not None:
return v
return None
def _extract_dept_id_from_obj(self, obj: Any) -> Optional[int]:
"""Извлекает department_id из объекта.
Порядок:
- Если obj — модель Department с полем id → вернуть id.
- Если у obj есть атрибуты department_id или dept_id → вернуть его.
- Если у obj есть атрибут department (FK) → взять department.id.
Args:
obj: Модель/объект detail-view.
Returns:
Optional[int]: department_id или None.
"""
if obj is None:
return None
if obj.__class__.__name__ == "Department" and hasattr(obj, "id"):
return obj.id
for attr in ("department_id", "dept_id"):
if hasattr(obj, attr):
return getattr(obj, attr)
dep = getattr(obj, "department", None)
if dep is not None:
return getattr(dep, "id", None)
return None
def _to_int_or_none(self, v) -> Optional[int]:
"""Аккуратное приведение к int.
Args:
v: Любое значение с возможностью строкового преобразования.
Returns:
Optional[int]: int или None при ошибке.
"""
try:
return int(str(v))
except Exception:
return None
# -------- main checks --------
def has_permission(self, request, view) -> bool:
"""См. докстринг класса. Если dept_id не найден, но это detail-запрос,
откладываем проверку до object-level (вернём True)."""
user = getattr(request, "user", None)
if not user or not user.is_authenticated:
return False
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
code = self.get_required_code(request, view)
if not code:
return (
(request.method in SAFE_METHODS)
if self.allow_safe_without_code
else False
)
dept_id = self._to_int_or_none(
self._extract_dept_id_from_request(request, view)
)
if dept_id is None:
# robust: считаем detail, если есть lookup в kwargs
lookup_kw = getattr(view, "lookup_url_kwarg", None) or getattr(
view, "lookup_field", "pk"
)
is_detail = getattr(view, "detail", None)
if is_detail is None:
is_detail = lookup_kw in getattr(view, "kwargs", {})
if is_detail:
# отложим проверку до has_object_permission
return True
return False
return has_dept_perm(user, dept_id, code)
def has_object_permission(self, request, view, obj) -> bool:
"""Проверка доступа к конкретному объекту (detail).
Правила:
- superuser/staff → True.
- Если код не требуется → SAFE-методы допускаются (если allow_safe_without_code=True).
- Иначе:
- Пытаемся извлечь department_id из объекта;
- если не удалось — пробуем извлечь из запроса (kwargs/data/query);
- если не нашли вообще → False;
- иначе → has_dept_perm(user, dept_id, code).
Returns:
bool: Разрешить/запретить доступ к объекту.
"""
user = getattr(request, "user", None)
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
code = self.get_required_code(request, view)
if not code:
return (
request.method in SAFE_METHODS
if self.allow_safe_without_code
else False
)
dept_id = self._to_int_or_none(self._extract_dept_id_from_obj(obj))
if dept_id is None:
dept_id = self._to_int_or_none(
self._extract_dept_id_from_request(request, view)
)
return has_dept_perm(request.user, dept_id, code)
class IsSelfOrStaff(BasePermission):
"""Доступ для staff/superuser или владельца объекта.
ВАЖНО:
• SAFE-методы НЕ дают автоматического допуска.
• Добавлена поддержка кейса "объект = сам пользователь" (self-update).
"""
<<<<<<< HEAD
DEFAULT_OWNER_ID_ATTRS: Tuple[str, ...] = (
"employee_id",
"author_id",
"user_id",
"owner_id",
)
=======
DEFAULT_OWNER_ID_ATTRS: Tuple[str, ...] = ("employee_id", "author_id", "user_id", "owner_id")
>>>>>>> origin/feat/ldap-writeback
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: 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 getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
# 1) *_id поля владельца
<<<<<<< HEAD
owner_id_attrs: Iterable[str] = getattr(
view, "owner_id_attrs", self.DEFAULT_OWNER_ID_ATTRS
)
=======
owner_id_attrs: Iterable[str] = getattr(view, "owner_id_attrs", self.DEFAULT_OWNER_ID_ATTRS)
>>>>>>> origin/feat/ldap-writeback
for attr in owner_id_attrs:
if getattr(obj, attr, None) == user.id:
return True
# 2) связанные owner-объекты
<<<<<<< HEAD
owner_obj_attrs: Iterable[str] = getattr(
view, "owner_obj_attrs", self.DEFAULT_OWNER_OBJ_ATTRS
)
=======
owner_obj_attrs: Iterable[str] = getattr(view, "owner_obj_attrs", self.DEFAULT_OWNER_OBJ_ATTRS)
>>>>>>> origin/feat/ldap-writeback
for attr in owner_obj_attrs:
owner = getattr(obj, attr, None)
if owner is not None and getattr(owner, "id", None) == user.id:
return True
# 3) self-object: редактирование собственной учётки (Employee ↔ Employee)
try:
<<<<<<< HEAD
if obj.__class__ is user.__class__ and getattr(obj, "pk", None) == getattr(
user, "pk", None
):
=======
if obj.__class__ is user.__class__ and getattr(obj, "pk", None) == getattr(user, "pk", None):
>>>>>>> origin/feat/ldap-writeback
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]]:
"""Приводит конфигурацию кодов прав к списку строк.
Args:
value (Any): Значение из конфигурации прав:
- str — один код,
- Iterable[str] — несколько кодов,
- None — отсутствует.
Returns:
Optional[list[str]]: Список кодов или None.
Raises:
TypeError: Если найден нестроковый элемент в Iterable.
"""
if value is None:
return None
if isinstance(value, str):
v = value.strip()
return [v] if v else None
if isinstance(value, Iterable):
out: list[str] = []
for v in value:
if not isinstance(v, str):
raise TypeError("Коды прав должны быть строками.")
v = v.strip()
if v:
out.append(v)
return out or None
return None
@staticmethod
def _explicit_perm_codes(view) -> Optional[list[str]]:
"""Достаёт из вью «явные» коды прав (ИЛИ между кодами).
Поддерживаемые формы на ViewSet:
- required_perm_code: str | Iterable[str]
- required_perms_by_action[action]: str | Iterable[str]
- required_perms_by_action[action]: {method: str|Iterable[str]}
где method сравнивается без учёта регистра, возможны '*', 'ANY',
а HEAD трактуется как GET.
Args:
view: DRF ViewSet с полями `action`, `request` (опционально).
Returns:
Optional[list[str]]: Список кодов прав или None.
"""
# 1) Единый код(ы) на всю вью
codes = AdminOrActionOrModelPerms._normalize_codes(
getattr(view, "required_perm_code", None)
)
if codes:
return codes
# 2) Карта по экшенам (+ по методам)
mapping = getattr(view, "required_perms_by_action", None) or {}
if not isinstance(mapping, dict):
return None
action = getattr(view, "action", None)
if not action or action not in mapping:
return None
value = mapping[action]
# 2.1) Прямо строка/итерируемое — сразу нормализуем
codes = AdminOrActionOrModelPerms._normalize_codes(value)
if codes is not None:
return codes
# 2.2) Вложенная карта по методам
if isinstance(value, dict):
method = getattr(getattr(view, "request", None), "method", None)
if not method:
return None
m = method.upper()
keys = (m, m.lower())
if m == "HEAD":
keys += ("GET", "get")
keys += ("*", "ANY")
for k in keys:
if k in value:
return AdminOrActionOrModelPerms._normalize_codes(value[k])
return None
# Иное — игнорируем
return None
def has_permission(self, request: Request, view) -> bool:
"""Проверяет доступ на уровне запроса.
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):
return False
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
# 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)
# 2) Всё остальное — родителю (в т.ч. SAFE_METHODS → view_<model>)
return super().has_permission(request, view)
__all__ = [
"MANAGE_PERM",
"CHANGE_HEAD_PERM",
"ASSIGN_ROLE_PERM",
"IsSelfOrStaff",
"AdminOrActionOrModelPerms",
"user_is_dept_head",
"user_has_dept_perm",
]

View File

@ -0,0 +1,570 @@
# backend\api\v1\permissions.py
from __future__ import annotations
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
<<<<<<< HEAD
from rest_framework.permissions import (SAFE_METHODS, BasePermission,
DjangoModelPermissions)
=======
from rest_framework.permissions import (
SAFE_METHODS,
BasePermission,
DjangoModelPermissions,
)
>>>>>>> origin/feat/ldap-writeback
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:
return bool(
user and user.is_authenticated and dept.head_id == getattr(user, "id", None)
)
def user_is_staffish(user) -> bool:
return bool(user and user.is_authenticated and (user.is_superuser or user.is_staff))
def user_has_dept_perm(user, dept: Department, perm_code: str) -> bool:
"""Проверяет, есть ли у пользователя право `perm_code` в указанном отделе.
Правила:
- Неаутентифицированным → False.
- staff/superuser → True.
- Руководитель отдела → True.
- Иначе: существует активная связь EmployeeDepartment, и у роли есть
DepartmentPermission с нужным code.
Args:
user (User): Текущий пользователь.
dept (Department): Отдел.
perm_code (str): Код департаментного права (см. DeptPerm.CHOICES).
Returns:
bool: True, если доступ разрешён.
Raises:
ValueError: Если `dept` не задан или не имеет id.
"""
if not (user and user.is_authenticated):
return False
# staff/superuser — сразу ок
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
if not isinstance(dept, Department) or getattr(dept, "id", None) is None:
raise ValueError("Argument `dept` must be Department instance with a valid id.")
# руководитель — сразу ок
if getattr(dept, "head_id", None) == user.id:
return True
# основная проверка: активная связь + у роли есть нужный DepartmentPermission.code
return EmployeeDepartment.objects.filter(
employee_id=user.id,
department_id=dept.id,
is_active=True,
role__scoped_permissions__code=perm_code, # <-- ВАЖНО: `code`, не `codename`
).exists()
def has_dept_perm(user: AbstractBaseUser, department_id: int, code: str) -> bool:
"""
Возвращает True, если пользователь имеет право `code` в рамках отдела `department_id`.
Логика:
1) staff/superuser → всегда True
2) пользователь является текущим руководителем отдела → True для любых «управленческих» кодов
3) пользователь состоит в отделе и его роль содержит `code` в scoped_permissions → True
4) иначе False
"""
if not getattr(user, "is_authenticated", False):
return False
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
# 2) Руководитель отдела имеет все управленческие права в своём отделе
if Department.objects.filter(id=department_id, head_id=user.id).exists():
return True
# 3) Проверка роли в отделе
link = (
EmployeeDepartment.objects.select_related("role")
.filter(employee_id=user.id, department_id=department_id, is_active=True)
.first()
)
if not link or not link.role_id:
return False
# роль хранит «скоуп-права» только для этого отдела
return link.role.scoped_permissions.filter(code=code).exists()
class AdminOrDeptAllowed(BasePermission):
"""Комбинированный пермишен: админский доступ ИЛИ доступ по праву на конкретный отдел.
Поведение:
1. Если пользователь superuser/staff — доступ всегда разрешён.
2. Если для текущего экшена требуется код права (например, DeptPerm.MANAGE_CALENDAR),
то доступ разрешается только при наличии у пользователя этого права на указанный отдел.
3. Идентификатор отдела извлекается из:
- URL kwargs: 'department_pk' → 'department_id';
- request.data: 'department' → 'department_id';
- query params: 'department' → 'department_id'.
На уровне has_object_permission отдел берётся из самого объекта:
- Department.id;
- obj.department_id / obj.dept_id;
- obj.department.id.
4. Если код права не задан (required_code/required_code_map пусты), то
для SAFE-методов (GET/HEAD/OPTIONS) доступ разрешён, если allow_safe_without_code=True (по умолчанию).
Для небезопасных методов в этом случае доступ запрещён.
Атрибуты класса:
required_code (str|None): Единый код права для всех экшенов. Если None, используется required_code_map.
required_code_map (dict[str,str]): Коды прав по имени action (create/update/partial_update/destroy/...).
allow_safe_without_code (bool): Разрешать ли SAFE-методы, когда код не задан. По умолчанию True.
Примеры:
class ManageCalendarPerm(AdminOrDeptAllowed):
required_code = DeptPerm.MANAGE_CALENDAR
class DeptEventsPerm(AdminOrDeptAllowed):
required_code_map = {
"create": DeptPerm.MANAGE_CALENDAR,
"update": DeptPerm.MANAGE_CALENDAR,
"partial_update": DeptPerm.MANAGE_CALENDAR,
"destroy": DeptPerm.MANAGE_CALENDAR,
}
"""
required_code: Optional[str] = None
required_code_map: Dict[str, str] = {}
allow_safe_without_code: bool = True
# -------- helpers --------
def get_required_code(self, request, view) -> Optional[str]:
"""Определяет требуемый код права для текущего экшена.
Сначала берёт из required_code_map по имени action (DRF: view.action),
затем fallback на required_code.
Args:
request: DRF Request (не используется в текущей логике).
view: DRF View/ViewSet; важно наличие атрибута 'action' для ViewSet.
Returns:
Optional[str]: Код права или None, если право для экшена не требуется.
"""
return (
self.required_code_map.get(getattr(view, "action", None))
or self.required_code
)
def _extract_dept_id_from_request(self, request, view) -> Optional[int]:
"""Пытается извлечь department_id из kwargs, body или query-параметров.
Порядок:
1) view.kwargs: department_pk, department_id
2) request.data: department, department_id
3) request.query_params: department, department_id
Args:
request: DRF Request
view: DRF View
Returns:
Optional[int]: Значение dept_id, если найдено и приведено к int, иначе None.
"""
for k in ("department_pk", "department_id"):
v = view.kwargs.get(k)
if v is not None:
return v
for k in ("department", "department_id"):
v = request.data.get(k)
if v is not None:
return v
for k in ("department", "department_id"):
v = request.query_params.get(k)
if v is not None:
return v
return None
def _extract_dept_id_from_obj(self, obj: Any) -> Optional[int]:
"""Извлекает department_id из объекта.
Порядок:
- Если obj — модель Department с полем id → вернуть id.
- Если у obj есть атрибуты department_id или dept_id → вернуть его.
- Если у obj есть атрибут department (FK) → взять department.id.
Args:
obj: Модель/объект detail-view.
Returns:
Optional[int]: department_id или None.
"""
if obj is None:
return None
if obj.__class__.__name__ == "Department" and hasattr(obj, "id"):
return obj.id
for attr in ("department_id", "dept_id"):
if hasattr(obj, attr):
return getattr(obj, attr)
dep = getattr(obj, "department", None)
if dep is not None:
return getattr(dep, "id", None)
return None
def _to_int_or_none(self, v) -> Optional[int]:
"""Аккуратное приведение к int.
Args:
v: Любое значение с возможностью строкового преобразования.
Returns:
Optional[int]: int или None при ошибке.
"""
try:
return int(str(v))
except Exception:
return None
# -------- main checks --------
def has_permission(self, request, view) -> bool:
"""См. докстринг класса. Если dept_id не найден, но это detail-запрос,
откладываем проверку до object-level (вернём True)."""
user = getattr(request, "user", None)
if not user or not user.is_authenticated:
return False
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
code = self.get_required_code(request, view)
if not code:
return (
(request.method in SAFE_METHODS)
if self.allow_safe_without_code
else False
)
dept_id = self._to_int_or_none(
self._extract_dept_id_from_request(request, view)
)
if dept_id is None:
# robust: считаем detail, если есть lookup в kwargs
lookup_kw = getattr(view, "lookup_url_kwarg", None) or getattr(
view, "lookup_field", "pk"
)
is_detail = getattr(view, "detail", None)
if is_detail is None:
is_detail = lookup_kw in getattr(view, "kwargs", {})
if is_detail:
# отложим проверку до has_object_permission
return True
return False
return has_dept_perm(user, dept_id, code)
def has_object_permission(self, request, view, obj) -> bool:
"""Проверка доступа к конкретному объекту (detail).
Правила:
- superuser/staff → True.
- Если код не требуется → SAFE-методы допускаются (если allow_safe_without_code=True).
- Иначе:
- Пытаемся извлечь department_id из объекта;
- если не удалось — пробуем извлечь из запроса (kwargs/data/query);
- если не нашли вообще → False;
- иначе → has_dept_perm(user, dept_id, code).
Returns:
bool: Разрешить/запретить доступ к объекту.
"""
user = getattr(request, "user", None)
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
code = self.get_required_code(request, view)
if not code:
return (
request.method in SAFE_METHODS
if self.allow_safe_without_code
else False
)
dept_id = self._to_int_or_none(self._extract_dept_id_from_obj(obj))
if dept_id is None:
dept_id = self._to_int_or_none(
self._extract_dept_id_from_request(request, view)
)
return has_dept_perm(request.user, dept_id, code)
class IsSelfOrStaff(BasePermission):
"""Доступ для staff/superuser или владельца объекта.
ВАЖНО:
• SAFE-методы НЕ дают автоматического допуска.
• Добавлена поддержка кейса "объект = сам пользователь" (self-update).
"""
<<<<<<< HEAD
DEFAULT_OWNER_ID_ATTRS: Tuple[str, ...] = (
"employee_id",
"author_id",
"user_id",
"owner_id",
)
=======
DEFAULT_OWNER_ID_ATTRS: Tuple[str, ...] = ("employee_id", "author_id", "user_id", "owner_id")
>>>>>>> origin/feat/ldap-writeback
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: 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 getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
# 1) *_id поля владельца
<<<<<<< HEAD
owner_id_attrs: Iterable[str] = getattr(
view, "owner_id_attrs", self.DEFAULT_OWNER_ID_ATTRS
)
=======
owner_id_attrs: Iterable[str] = getattr(view, "owner_id_attrs", self.DEFAULT_OWNER_ID_ATTRS)
>>>>>>> origin/feat/ldap-writeback
for attr in owner_id_attrs:
if getattr(obj, attr, None) == user.id:
return True
# 2) связанные owner-объекты
<<<<<<< HEAD
owner_obj_attrs: Iterable[str] = getattr(
view, "owner_obj_attrs", self.DEFAULT_OWNER_OBJ_ATTRS
)
=======
owner_obj_attrs: Iterable[str] = getattr(view, "owner_obj_attrs", self.DEFAULT_OWNER_OBJ_ATTRS)
>>>>>>> origin/feat/ldap-writeback
for attr in owner_obj_attrs:
owner = getattr(obj, attr, None)
if owner is not None and getattr(owner, "id", None) == user.id:
return True
# 3) self-object: редактирование собственной учётки (Employee ↔ Employee)
try:
<<<<<<< HEAD
if obj.__class__ is user.__class__ and getattr(obj, "pk", None) == getattr(
user, "pk", None
):
=======
if obj.__class__ is user.__class__ and getattr(obj, "pk", None) == getattr(user, "pk", None):
>>>>>>> origin/feat/ldap-writeback
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]]:
"""Приводит конфигурацию кодов прав к списку строк.
Args:
value (Any): Значение из конфигурации прав:
- str — один код,
- Iterable[str] — несколько кодов,
- None — отсутствует.
Returns:
Optional[list[str]]: Список кодов или None.
Raises:
TypeError: Если найден нестроковый элемент в Iterable.
"""
if value is None:
return None
if isinstance(value, str):
v = value.strip()
return [v] if v else None
if isinstance(value, Iterable):
out: list[str] = []
for v in value:
if not isinstance(v, str):
raise TypeError("Коды прав должны быть строками.")
v = v.strip()
if v:
out.append(v)
return out or None
return None
@staticmethod
def _explicit_perm_codes(view) -> Optional[list[str]]:
"""Достаёт из вью «явные» коды прав (ИЛИ между кодами).
Поддерживаемые формы на ViewSet:
- required_perm_code: str | Iterable[str]
- required_perms_by_action[action]: str | Iterable[str]
- required_perms_by_action[action]: {method: str|Iterable[str]}
где method сравнивается без учёта регистра, возможны '*', 'ANY',
а HEAD трактуется как GET.
Args:
view: DRF ViewSet с полями `action`, `request` (опционально).
Returns:
Optional[list[str]]: Список кодов прав или None.
"""
# 1) Единый код(ы) на всю вью
codes = AdminOrActionOrModelPerms._normalize_codes(
getattr(view, "required_perm_code", None)
)
if codes:
return codes
# 2) Карта по экшенам (+ по методам)
mapping = getattr(view, "required_perms_by_action", None) or {}
if not isinstance(mapping, dict):
return None
action = getattr(view, "action", None)
if not action or action not in mapping:
return None
value = mapping[action]
# 2.1) Прямо строка/итерируемое — сразу нормализуем
codes = AdminOrActionOrModelPerms._normalize_codes(value)
if codes is not None:
return codes
# 2.2) Вложенная карта по методам
if isinstance(value, dict):
method = getattr(getattr(view, "request", None), "method", None)
if not method:
return None
m = method.upper()
keys = (m, m.lower())
if m == "HEAD":
keys += ("GET", "get")
keys += ("*", "ANY")
for k in keys:
if k in value:
return AdminOrActionOrModelPerms._normalize_codes(value[k])
return None
# Иное — игнорируем
return None
def has_permission(self, request: Request, view) -> bool:
"""Проверяет доступ на уровне запроса.
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):
return False
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
# 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)
# 2) Всё остальное — родителю (в т.ч. SAFE_METHODS → view_<model>)
return super().has_permission(request, view)
__all__ = [
"MANAGE_PERM",
"CHANGE_HEAD_PERM",
"ASSIGN_ROLE_PERM",
"IsSelfOrStaff",
"AdminOrActionOrModelPerms",
"user_is_dept_head",
"user_has_dept_perm",
]

View File

@ -0,0 +1,570 @@
# backend\api\v1\permissions.py
from __future__ import annotations
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
<<<<<<< HEAD
from rest_framework.permissions import (SAFE_METHODS, BasePermission,
DjangoModelPermissions)
=======
from rest_framework.permissions import (
SAFE_METHODS,
BasePermission,
DjangoModelPermissions,
)
>>>>>>> origin/feat/ldap-writeback
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:
return bool(
user and user.is_authenticated and dept.head_id == getattr(user, "id", None)
)
def user_is_staffish(user) -> bool:
return bool(user and user.is_authenticated and (user.is_superuser or user.is_staff))
def user_has_dept_perm(user, dept: Department, perm_code: str) -> bool:
"""Проверяет, есть ли у пользователя право `perm_code` в указанном отделе.
Правила:
- Неаутентифицированным → False.
- staff/superuser → True.
- Руководитель отдела → True.
- Иначе: существует активная связь EmployeeDepartment, и у роли есть
DepartmentPermission с нужным code.
Args:
user (User): Текущий пользователь.
dept (Department): Отдел.
perm_code (str): Код департаментного права (см. DeptPerm.CHOICES).
Returns:
bool: True, если доступ разрешён.
Raises:
ValueError: Если `dept` не задан или не имеет id.
"""
if not (user and user.is_authenticated):
return False
# staff/superuser — сразу ок
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
if not isinstance(dept, Department) or getattr(dept, "id", None) is None:
raise ValueError("Argument `dept` must be Department instance with a valid id.")
# руководитель — сразу ок
if getattr(dept, "head_id", None) == user.id:
return True
# основная проверка: активная связь + у роли есть нужный DepartmentPermission.code
return EmployeeDepartment.objects.filter(
employee_id=user.id,
department_id=dept.id,
is_active=True,
role__scoped_permissions__code=perm_code, # <-- ВАЖНО: `code`, не `codename`
).exists()
def has_dept_perm(user: AbstractBaseUser, department_id: int, code: str) -> bool:
"""
Возвращает True, если пользователь имеет право `code` в рамках отдела `department_id`.
Логика:
1) staff/superuser → всегда True
2) пользователь является текущим руководителем отдела → True для любых «управленческих» кодов
3) пользователь состоит в отделе и его роль содержит `code` в scoped_permissions → True
4) иначе False
"""
if not getattr(user, "is_authenticated", False):
return False
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
# 2) Руководитель отдела имеет все управленческие права в своём отделе
if Department.objects.filter(id=department_id, head_id=user.id).exists():
return True
# 3) Проверка роли в отделе
link = (
EmployeeDepartment.objects.select_related("role")
.filter(employee_id=user.id, department_id=department_id, is_active=True)
.first()
)
if not link or not link.role_id:
return False
# роль хранит «скоуп-права» только для этого отдела
return link.role.scoped_permissions.filter(code=code).exists()
class AdminOrDeptAllowed(BasePermission):
"""Комбинированный пермишен: админский доступ ИЛИ доступ по праву на конкретный отдел.
Поведение:
1. Если пользователь superuser/staff — доступ всегда разрешён.
2. Если для текущего экшена требуется код права (например, DeptPerm.MANAGE_CALENDAR),
то доступ разрешается только при наличии у пользователя этого права на указанный отдел.
3. Идентификатор отдела извлекается из:
- URL kwargs: 'department_pk' → 'department_id';
- request.data: 'department' → 'department_id';
- query params: 'department' → 'department_id'.
На уровне has_object_permission отдел берётся из самого объекта:
- Department.id;
- obj.department_id / obj.dept_id;
- obj.department.id.
4. Если код права не задан (required_code/required_code_map пусты), то
для SAFE-методов (GET/HEAD/OPTIONS) доступ разрешён, если allow_safe_without_code=True (по умолчанию).
Для небезопасных методов в этом случае доступ запрещён.
Атрибуты класса:
required_code (str|None): Единый код права для всех экшенов. Если None, используется required_code_map.
required_code_map (dict[str,str]): Коды прав по имени action (create/update/partial_update/destroy/...).
allow_safe_without_code (bool): Разрешать ли SAFE-методы, когда код не задан. По умолчанию True.
Примеры:
class ManageCalendarPerm(AdminOrDeptAllowed):
required_code = DeptPerm.MANAGE_CALENDAR
class DeptEventsPerm(AdminOrDeptAllowed):
required_code_map = {
"create": DeptPerm.MANAGE_CALENDAR,
"update": DeptPerm.MANAGE_CALENDAR,
"partial_update": DeptPerm.MANAGE_CALENDAR,
"destroy": DeptPerm.MANAGE_CALENDAR,
}
"""
required_code: Optional[str] = None
required_code_map: Dict[str, str] = {}
allow_safe_without_code: bool = True
# -------- helpers --------
def get_required_code(self, request, view) -> Optional[str]:
"""Определяет требуемый код права для текущего экшена.
Сначала берёт из required_code_map по имени action (DRF: view.action),
затем fallback на required_code.
Args:
request: DRF Request (не используется в текущей логике).
view: DRF View/ViewSet; важно наличие атрибута 'action' для ViewSet.
Returns:
Optional[str]: Код права или None, если право для экшена не требуется.
"""
return (
self.required_code_map.get(getattr(view, "action", None))
or self.required_code
)
def _extract_dept_id_from_request(self, request, view) -> Optional[int]:
"""Пытается извлечь department_id из kwargs, body или query-параметров.
Порядок:
1) view.kwargs: department_pk, department_id
2) request.data: department, department_id
3) request.query_params: department, department_id
Args:
request: DRF Request
view: DRF View
Returns:
Optional[int]: Значение dept_id, если найдено и приведено к int, иначе None.
"""
for k in ("department_pk", "department_id"):
v = view.kwargs.get(k)
if v is not None:
return v
for k in ("department", "department_id"):
v = request.data.get(k)
if v is not None:
return v
for k in ("department", "department_id"):
v = request.query_params.get(k)
if v is not None:
return v
return None
def _extract_dept_id_from_obj(self, obj: Any) -> Optional[int]:
"""Извлекает department_id из объекта.
Порядок:
- Если obj — модель Department с полем id → вернуть id.
- Если у obj есть атрибуты department_id или dept_id → вернуть его.
- Если у obj есть атрибут department (FK) → взять department.id.
Args:
obj: Модель/объект detail-view.
Returns:
Optional[int]: department_id или None.
"""
if obj is None:
return None
if obj.__class__.__name__ == "Department" and hasattr(obj, "id"):
return obj.id
for attr in ("department_id", "dept_id"):
if hasattr(obj, attr):
return getattr(obj, attr)
dep = getattr(obj, "department", None)
if dep is not None:
return getattr(dep, "id", None)
return None
def _to_int_or_none(self, v) -> Optional[int]:
"""Аккуратное приведение к int.
Args:
v: Любое значение с возможностью строкового преобразования.
Returns:
Optional[int]: int или None при ошибке.
"""
try:
return int(str(v))
except Exception:
return None
# -------- main checks --------
def has_permission(self, request, view) -> bool:
"""См. докстринг класса. Если dept_id не найден, но это detail-запрос,
откладываем проверку до object-level (вернём True)."""
user = getattr(request, "user", None)
if not user or not user.is_authenticated:
return False
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
code = self.get_required_code(request, view)
if not code:
return (
(request.method in SAFE_METHODS)
if self.allow_safe_without_code
else False
)
dept_id = self._to_int_or_none(
self._extract_dept_id_from_request(request, view)
)
if dept_id is None:
# robust: считаем detail, если есть lookup в kwargs
lookup_kw = getattr(view, "lookup_url_kwarg", None) or getattr(
view, "lookup_field", "pk"
)
is_detail = getattr(view, "detail", None)
if is_detail is None:
is_detail = lookup_kw in getattr(view, "kwargs", {})
if is_detail:
# отложим проверку до has_object_permission
return True
return False
return has_dept_perm(user, dept_id, code)
def has_object_permission(self, request, view, obj) -> bool:
"""Проверка доступа к конкретному объекту (detail).
Правила:
- superuser/staff → True.
- Если код не требуется → SAFE-методы допускаются (если allow_safe_without_code=True).
- Иначе:
- Пытаемся извлечь department_id из объекта;
- если не удалось — пробуем извлечь из запроса (kwargs/data/query);
- если не нашли вообще → False;
- иначе → has_dept_perm(user, dept_id, code).
Returns:
bool: Разрешить/запретить доступ к объекту.
"""
user = getattr(request, "user", None)
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
code = self.get_required_code(request, view)
if not code:
return (
request.method in SAFE_METHODS
if self.allow_safe_without_code
else False
)
dept_id = self._to_int_or_none(self._extract_dept_id_from_obj(obj))
if dept_id is None:
dept_id = self._to_int_or_none(
self._extract_dept_id_from_request(request, view)
)
return has_dept_perm(request.user, dept_id, code)
class IsSelfOrStaff(BasePermission):
"""Доступ для staff/superuser или владельца объекта.
ВАЖНО:
• SAFE-методы НЕ дают автоматического допуска.
• Добавлена поддержка кейса "объект = сам пользователь" (self-update).
"""
<<<<<<< HEAD
DEFAULT_OWNER_ID_ATTRS: Tuple[str, ...] = (
"employee_id",
"author_id",
"user_id",
"owner_id",
)
=======
DEFAULT_OWNER_ID_ATTRS: Tuple[str, ...] = ("employee_id", "author_id", "user_id", "owner_id")
>>>>>>> origin/feat/ldap-writeback
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: 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 getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
# 1) *_id поля владельца
<<<<<<< HEAD
owner_id_attrs: Iterable[str] = getattr(
view, "owner_id_attrs", self.DEFAULT_OWNER_ID_ATTRS
)
=======
owner_id_attrs: Iterable[str] = getattr(view, "owner_id_attrs", self.DEFAULT_OWNER_ID_ATTRS)
>>>>>>> origin/feat/ldap-writeback
for attr in owner_id_attrs:
if getattr(obj, attr, None) == user.id:
return True
# 2) связанные owner-объекты
<<<<<<< HEAD
owner_obj_attrs: Iterable[str] = getattr(
view, "owner_obj_attrs", self.DEFAULT_OWNER_OBJ_ATTRS
)
=======
owner_obj_attrs: Iterable[str] = getattr(view, "owner_obj_attrs", self.DEFAULT_OWNER_OBJ_ATTRS)
>>>>>>> origin/feat/ldap-writeback
for attr in owner_obj_attrs:
owner = getattr(obj, attr, None)
if owner is not None and getattr(owner, "id", None) == user.id:
return True
# 3) self-object: редактирование собственной учётки (Employee ↔ Employee)
try:
<<<<<<< HEAD
if obj.__class__ is user.__class__ and getattr(obj, "pk", None) == getattr(
user, "pk", None
):
=======
if obj.__class__ is user.__class__ and getattr(obj, "pk", None) == getattr(user, "pk", None):
>>>>>>> origin/feat/ldap-writeback
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]]:
"""Приводит конфигурацию кодов прав к списку строк.
Args:
value (Any): Значение из конфигурации прав:
- str — один код,
- Iterable[str] — несколько кодов,
- None — отсутствует.
Returns:
Optional[list[str]]: Список кодов или None.
Raises:
TypeError: Если найден нестроковый элемент в Iterable.
"""
if value is None:
return None
if isinstance(value, str):
v = value.strip()
return [v] if v else None
if isinstance(value, Iterable):
out: list[str] = []
for v in value:
if not isinstance(v, str):
raise TypeError("Коды прав должны быть строками.")
v = v.strip()
if v:
out.append(v)
return out or None
return None
@staticmethod
def _explicit_perm_codes(view) -> Optional[list[str]]:
"""Достаёт из вью «явные» коды прав (ИЛИ между кодами).
Поддерживаемые формы на ViewSet:
- required_perm_code: str | Iterable[str]
- required_perms_by_action[action]: str | Iterable[str]
- required_perms_by_action[action]: {method: str|Iterable[str]}
где method сравнивается без учёта регистра, возможны '*', 'ANY',
а HEAD трактуется как GET.
Args:
view: DRF ViewSet с полями `action`, `request` (опционально).
Returns:
Optional[list[str]]: Список кодов прав или None.
"""
# 1) Единый код(ы) на всю вью
codes = AdminOrActionOrModelPerms._normalize_codes(
getattr(view, "required_perm_code", None)
)
if codes:
return codes
# 2) Карта по экшенам (+ по методам)
mapping = getattr(view, "required_perms_by_action", None) or {}
if not isinstance(mapping, dict):
return None
action = getattr(view, "action", None)
if not action or action not in mapping:
return None
value = mapping[action]
# 2.1) Прямо строка/итерируемое — сразу нормализуем
codes = AdminOrActionOrModelPerms._normalize_codes(value)
if codes is not None:
return codes
# 2.2) Вложенная карта по методам
if isinstance(value, dict):
method = getattr(getattr(view, "request", None), "method", None)
if not method:
return None
m = method.upper()
keys = (m, m.lower())
if m == "HEAD":
keys += ("GET", "get")
keys += ("*", "ANY")
for k in keys:
if k in value:
return AdminOrActionOrModelPerms._normalize_codes(value[k])
return None
# Иное — игнорируем
return None
def has_permission(self, request: Request, view) -> bool:
"""Проверяет доступ на уровне запроса.
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):
return False
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
# 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)
# 2) Всё остальное — родителю (в т.ч. SAFE_METHODS → view_<model>)
return super().has_permission(request, view)
__all__ = [
"MANAGE_PERM",
"CHANGE_HEAD_PERM",
"ASSIGN_ROLE_PERM",
"IsSelfOrStaff",
"AdminOrActionOrModelPerms",
"user_is_dept_head",
"user_has_dept_perm",
]

View File

@ -0,0 +1,477 @@
# backend\api\v1\permissions.py
from __future__ import annotations
from typing import Any, Dict, Iterable, Optional, Tuple
from django.contrib.auth.models import AbstractBaseUser, AnonymousUser
from employees.constants import DeptPerm
from employees.models import Department, EmployeeDepartment
from rest_framework.permissions import (
SAFE_METHODS,
BasePermission,
DjangoModelPermissions,
)
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:
return bool(
user and user.is_authenticated and dept.head_id == getattr(user, "id", None)
)
def user_is_staffish(user) -> bool:
return bool(user and user.is_authenticated and (user.is_superuser or user.is_staff))
def user_has_dept_perm(user, dept: Department, perm_code: str) -> bool:
"""Проверяет, есть ли у пользователя право `perm_code` в указанном отделе.
Правила:
- Неаутентифицированным → False.
- staff/superuser → True.
- Руководитель отдела → True.
- Иначе: существует активная связь EmployeeDepartment, и у роли есть
DepartmentPermission с нужным code.
Args:
user (User): Текущий пользователь.
dept (Department): Отдел.
perm_code (str): Код департаментного права (см. DeptPerm.CHOICES).
Returns:
bool: True, если доступ разрешён.
Raises:
ValueError: Если `dept` не задан или не имеет id.
"""
if not (user and user.is_authenticated):
return False
# staff/superuser — сразу ок
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
if not isinstance(dept, Department) or getattr(dept, "id", None) is None:
raise ValueError("Argument `dept` must be Department instance with a valid id.")
# руководитель — сразу ок
if getattr(dept, "head_id", None) == user.id:
return True
# основная проверка: активная связь + у роли есть нужный DepartmentPermission.code
return EmployeeDepartment.objects.filter(
employee_id=user.id,
department_id=dept.id,
is_active=True,
role__scoped_permissions__code=perm_code, # <-- ВАЖНО: `code`, не `codename`
).exists()
def has_dept_perm(user: AbstractBaseUser, department_id: int, code: str) -> bool:
"""
Возвращает True, если пользователь имеет право `code` в рамках отдела `department_id`.
Логика:
1) staff/superuser → всегда True
2) пользователь является текущим руководителем отдела → True для любых «управленческих» кодов
3) пользователь состоит в отделе и его роль содержит `code` в scoped_permissions → True
4) иначе False
"""
if not getattr(user, "is_authenticated", False):
return False
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
# 2) Руководитель отдела имеет все управленческие права в своём отделе
if Department.objects.filter(id=department_id, head_id=user.id).exists():
return True
# 3) Проверка роли в отделе
link = (
EmployeeDepartment.objects.select_related("role")
.filter(employee_id=user.id, department_id=department_id, is_active=True)
.first()
)
if not link or not link.role_id:
return False
# роль хранит «скоуп-права» только для этого отдела
return link.role.scoped_permissions.filter(code=code).exists()
class AdminOrDeptAllowed(BasePermission):
"""Комбинированный пермишен: админский доступ ИЛИ доступ по праву на конкретный отдел.
Поведение:
1. Если пользователь superuser/staff — доступ всегда разрешён.
2. Если для текущего экшена требуется код права (например, DeptPerm.MANAGE_CALENDAR),
то доступ разрешается только при наличии у пользователя этого права на указанный отдел.
3. Идентификатор отдела извлекается из:
- URL kwargs: 'department_pk' → 'department_id';
- request.data: 'department' → 'department_id';
- query params: 'department' → 'department_id'.
На уровне has_object_permission отдел берётся из самого объекта:
- Department.id;
- obj.department_id / obj.dept_id;
- obj.department.id.
4. Если код права не задан (required_code/required_code_map пусты), то
для SAFE-методов (GET/HEAD/OPTIONS) доступ разрешён, если allow_safe_without_code=True (по умолчанию).
Для небезопасных методов в этом случае доступ запрещён.
Атрибуты класса:
required_code (str|None): Единый код права для всех экшенов. Если None, используется required_code_map.
required_code_map (dict[str,str]): Коды прав по имени action (create/update/partial_update/destroy/...).
allow_safe_without_code (bool): Разрешать ли SAFE-методы, когда код не задан. По умолчанию True.
Примеры:
class ManageCalendarPerm(AdminOrDeptAllowed):
required_code = DeptPerm.MANAGE_CALENDAR
class DeptEventsPerm(AdminOrDeptAllowed):
required_code_map = {
"create": DeptPerm.MANAGE_CALENDAR,
"update": DeptPerm.MANAGE_CALENDAR,
"partial_update": DeptPerm.MANAGE_CALENDAR,
"destroy": DeptPerm.MANAGE_CALENDAR,
}
"""
required_code: Optional[str] = None
required_code_map: Dict[str, str] = {}
allow_safe_without_code: bool = True
# -------- helpers --------
def get_required_code(self, request, view) -> Optional[str]:
"""Определяет требуемый код права для текущего экшена.
Сначала берёт из required_code_map по имени action (DRF: view.action),
затем fallback на required_code.
Args:
request: DRF Request (не используется в текущей логике).
view: DRF View/ViewSet; важно наличие атрибута 'action' для ViewSet.
Returns:
Optional[str]: Код права или None, если право для экшена не требуется.
"""
return (
self.required_code_map.get(getattr(view, "action", None))
or self.required_code
)
def _extract_dept_id_from_request(self, request, view) -> Optional[int]:
"""Пытается извлечь department_id из kwargs, body или query-параметров.
Порядок:
1) view.kwargs: department_pk, department_id
2) request.data: department, department_id
3) request.query_params: department, department_id
Args:
request: DRF Request
view: DRF View
Returns:
Optional[int]: Значение dept_id, если найдено и приведено к int, иначе None.
"""
for k in ("department_pk", "department_id"):
v = view.kwargs.get(k)
if v is not None:
return v
for k in ("department", "department_id"):
v = request.data.get(k)
if v is not None:
return v
for k in ("department", "department_id"):
v = request.query_params.get(k)
if v is not None:
return v
return None
def _extract_dept_id_from_obj(self, obj: Any) -> Optional[int]:
"""Извлекает department_id из объекта.
Порядок:
- Если obj — модель Department с полем id → вернуть id.
- Если у obj есть атрибуты department_id или dept_id → вернуть его.
- Если у obj есть атрибут department (FK) → взять department.id.
Args:
obj: Модель/объект detail-view.
Returns:
Optional[int]: department_id или None.
"""
if obj is None:
return None
if obj.__class__.__name__ == "Department" and hasattr(obj, "id"):
return obj.id
for attr in ("department_id", "dept_id"):
if hasattr(obj, attr):
return getattr(obj, attr)
dep = getattr(obj, "department", None)
if dep is not None:
return getattr(dep, "id", None)
return None
def _to_int_or_none(self, v) -> Optional[int]:
"""Аккуратное приведение к int.
Args:
v: Любое значение с возможностью строкового преобразования.
Returns:
Optional[int]: int или None при ошибке.
"""
try:
return int(str(v))
except Exception:
return None
# -------- main checks --------
def has_permission(self, request, view) -> bool:
"""См. докстринг класса. Если dept_id не найден, но это detail-запрос,
откладываем проверку до object-level (вернём True)."""
user = getattr(request, "user", None)
if not user or not user.is_authenticated:
return False
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
code = self.get_required_code(request, view)
if not code:
return (
(request.method in SAFE_METHODS)
if self.allow_safe_without_code
else False
)
dept_id = self._to_int_or_none(
self._extract_dept_id_from_request(request, view)
)
if dept_id is None:
# robust: считаем detail, если есть lookup в kwargs
lookup_kw = getattr(view, "lookup_url_kwarg", None) or getattr(
view, "lookup_field", "pk"
)
is_detail = getattr(view, "detail", None)
if is_detail is None:
is_detail = lookup_kw in getattr(view, "kwargs", {})
if is_detail:
# отложим проверку до has_object_permission
return True
return False
return has_dept_perm(user, dept_id, code)
def has_object_permission(self, request, view, obj) -> bool:
"""Проверка доступа к конкретному объекту (detail).
Правила:
- superuser/staff → True.
- Если код не требуется → SAFE-методы допускаются (если allow_safe_without_code=True).
- Иначе:
- Пытаемся извлечь department_id из объекта;
- если не удалось — пробуем извлечь из запроса (kwargs/data/query);
- если не нашли вообще → False;
- иначе → has_dept_perm(user, dept_id, code).
Returns:
bool: Разрешить/запретить доступ к объекту.
"""
user = getattr(request, "user", None)
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
code = self.get_required_code(request, view)
if not code:
return (
request.method in SAFE_METHODS
if self.allow_safe_without_code
else False
)
dept_id = self._to_int_or_none(self._extract_dept_id_from_obj(obj))
if dept_id is None:
dept_id = self._to_int_or_none(
self._extract_dept_id_from_request(request, view)
)
return has_dept_perm(request.user, dept_id, code)
class IsSelfOrStaff(BasePermission):
"""Staff/superuser или владелец объекта (настраиваемые поля владельца через атрибуты вью)."""
DEFAULT_OWNER_ID_ATTRS = ("author_id", "user_id", "owner_id", "employee_id")
DEFAULT_OWNER_OBJ_ATTRS = ("author", "user", "owner", "employee")
def has_permission(self, request, view) -> bool:
user = getattr(request, "user", None)
return bool(user and user.is_authenticated)
def has_object_permission(self, request, view, obj):
user = request.user
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)
for attr in owner_id_attrs:
if hasattr(obj, attr) and getattr(obj, attr) == uid:
return True
for attr in owner_obj_attrs:
owner = getattr(obj, attr, None)
if owner is not None and getattr(owner, "id", None) == uid:
return True
# Fallback для вью «/users/{id}/»
lookup = getattr(view, "lookup_field", "pk")
return str(view.kwargs.get(lookup)) == str(uid)
class AdminOrActionOrModelPerms(DjangoModelPermissions):
@staticmethod
def _normalize_codes(value: Any) -> Optional[list[str]]:
"""Приводит конфигурацию кодов прав к списку строк.
Args:
value (Any): Значение из конфигурации прав:
- str — один код,
- Iterable[str] — несколько кодов,
- None — отсутствует.
Returns:
Optional[list[str]]: Список кодов или None.
Raises:
TypeError: Если найден нестроковый элемент в Iterable.
"""
if value is None:
return None
if isinstance(value, str):
v = value.strip()
return [v] if v else None
if isinstance(value, Iterable):
out: list[str] = []
for v in value:
if not isinstance(v, str):
raise TypeError("Коды прав должны быть строками.")
v = v.strip()
if v:
out.append(v)
return out or None
return None
@staticmethod
def _explicit_perm_codes(view) -> Optional[list[str]]:
"""Достаёт из вью «явные» коды прав (ИЛИ между кодами).
Поддерживаемые формы на ViewSet:
- required_perm_code: str | Iterable[str]
- required_perms_by_action[action]: str | Iterable[str]
- required_perms_by_action[action]: {method: str|Iterable[str]}
где method сравнивается без учёта регистра, возможны '*', 'ANY',
а HEAD трактуется как GET.
Args:
view: DRF ViewSet с полями `action`, `request` (опционально).
Returns:
Optional[list[str]]: Список кодов прав или None.
"""
# 1) Единый код(ы) на всю вью
codes = AdminOrActionOrModelPerms._normalize_codes(
getattr(view, "required_perm_code", None)
)
if codes:
return codes
# 2) Карта по экшенам (+ по методам)
mapping = getattr(view, "required_perms_by_action", None) or {}
if not isinstance(mapping, dict):
return None
action = getattr(view, "action", None)
if not action or action not in mapping:
return None
value = mapping[action]
# 2.1) Прямо строка/итерируемое — сразу нормализуем
codes = AdminOrActionOrModelPerms._normalize_codes(value)
if codes is not None:
return codes
# 2.2) Вложенная карта по методам
if isinstance(value, dict):
method = getattr(getattr(view, "request", None), "method", None)
if not method:
return None
m = method.upper()
keys = (m, m.lower())
if m == "HEAD":
keys += ("GET", "get")
keys += ("*", "ANY")
for k in keys:
if k in value:
return AdminOrActionOrModelPerms._normalize_codes(value[k])
return None
# Иное — игнорируем
return None
def has_permission(self, request, view) -> bool:
"""Проверка прав на уровне запроса.
Логика:
- staff/superuser → True
- если на вью есть «явные» коды (см. выше) — достаточно ЛЮБОГО из них
- иначе: для SAFE-методов требуем view_<model>, остальные — по DjangoModelPermissions
Returns:
bool: Разрешён ли доступ.
"""
user = getattr(request, "user", None)
if not (user and user.is_authenticated):
return False
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
explicit = self._explicit_perm_codes(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}"
)
return super().has_permission(request, view)
__all__ = [
"MANAGE_PERM",
"CHANGE_HEAD_PERM",
"ASSIGN_ROLE_PERM",
"IsSelfOrStaff",
"AdminOrActionOrModelPerms",
"user_is_dept_head",
"user_has_dept_perm",
]

View File

@ -0,0 +1,477 @@
# backend\api\v1\permissions.py
from __future__ import annotations
from typing import Any, Dict, Iterable, Optional, Tuple
from django.contrib.auth.models import AbstractBaseUser, AnonymousUser
from employees.constants import DeptPerm
from employees.models import Department, EmployeeDepartment
from rest_framework.permissions import (
SAFE_METHODS,
BasePermission,
DjangoModelPermissions,
)
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:
return bool(
user and user.is_authenticated and dept.head_id == getattr(user, "id", None)
)
def user_is_staffish(user) -> bool:
return bool(user and user.is_authenticated and (user.is_superuser or user.is_staff))
def user_has_dept_perm(user, dept: Department, perm_code: str) -> bool:
"""Проверяет, есть ли у пользователя право `perm_code` в указанном отделе.
Правила:
- Неаутентифицированным → False.
- staff/superuser → True.
- Руководитель отдела → True.
- Иначе: существует активная связь EmployeeDepartment, и у роли есть
DepartmentPermission с нужным code.
Args:
user (User): Текущий пользователь.
dept (Department): Отдел.
perm_code (str): Код департаментного права (см. DeptPerm.CHOICES).
Returns:
bool: True, если доступ разрешён.
Raises:
ValueError: Если `dept` не задан или не имеет id.
"""
if not (user and user.is_authenticated):
return False
# staff/superuser — сразу ок
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
if not isinstance(dept, Department) or getattr(dept, "id", None) is None:
raise ValueError("Argument `dept` must be Department instance with a valid id.")
# руководитель — сразу ок
if getattr(dept, "head_id", None) == user.id:
return True
# основная проверка: активная связь + у роли есть нужный DepartmentPermission.code
return EmployeeDepartment.objects.filter(
employee_id=user.id,
department_id=dept.id,
is_active=True,
role__scoped_permissions__code=perm_code, # <-- ВАЖНО: `code`, не `codename`
).exists()
def has_dept_perm(user: AbstractBaseUser, department_id: int, code: str) -> bool:
"""
Возвращает True, если пользователь имеет право `code` в рамках отдела `department_id`.
Логика:
1) staff/superuser → всегда True
2) пользователь является текущим руководителем отдела → True для любых «управленческих» кодов
3) пользователь состоит в отделе и его роль содержит `code` в scoped_permissions → True
4) иначе False
"""
if not getattr(user, "is_authenticated", False):
return False
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
# 2) Руководитель отдела имеет все управленческие права в своём отделе
if Department.objects.filter(id=department_id, head_id=user.id).exists():
return True
# 3) Проверка роли в отделе
link = (
EmployeeDepartment.objects.select_related("role")
.filter(employee_id=user.id, department_id=department_id, is_active=True)
.first()
)
if not link or not link.role_id:
return False
# роль хранит «скоуп-права» только для этого отдела
return link.role.scoped_permissions.filter(code=code).exists()
class AdminOrDeptAllowed(BasePermission):
"""Комбинированный пермишен: админский доступ ИЛИ доступ по праву на конкретный отдел.
Поведение:
1. Если пользователь superuser/staff — доступ всегда разрешён.
2. Если для текущего экшена требуется код права (например, DeptPerm.MANAGE_CALENDAR),
то доступ разрешается только при наличии у пользователя этого права на указанный отдел.
3. Идентификатор отдела извлекается из:
- URL kwargs: 'department_pk' → 'department_id';
- request.data: 'department' → 'department_id';
- query params: 'department' → 'department_id'.
На уровне has_object_permission отдел берётся из самого объекта:
- Department.id;
- obj.department_id / obj.dept_id;
- obj.department.id.
4. Если код права не задан (required_code/required_code_map пусты), то
для SAFE-методов (GET/HEAD/OPTIONS) доступ разрешён, если allow_safe_without_code=True (по умолчанию).
Для небезопасных методов в этом случае доступ запрещён.
Атрибуты класса:
required_code (str|None): Единый код права для всех экшенов. Если None, используется required_code_map.
required_code_map (dict[str,str]): Коды прав по имени action (create/update/partial_update/destroy/...).
allow_safe_without_code (bool): Разрешать ли SAFE-методы, когда код не задан. По умолчанию True.
Примеры:
class ManageCalendarPerm(AdminOrDeptAllowed):
required_code = DeptPerm.MANAGE_CALENDAR
class DeptEventsPerm(AdminOrDeptAllowed):
required_code_map = {
"create": DeptPerm.MANAGE_CALENDAR,
"update": DeptPerm.MANAGE_CALENDAR,
"partial_update": DeptPerm.MANAGE_CALENDAR,
"destroy": DeptPerm.MANAGE_CALENDAR,
}
"""
required_code: Optional[str] = None
required_code_map: Dict[str, str] = {}
allow_safe_without_code: bool = True
# -------- helpers --------
def get_required_code(self, request, view) -> Optional[str]:
"""Определяет требуемый код права для текущего экшена.
Сначала берёт из required_code_map по имени action (DRF: view.action),
затем fallback на required_code.
Args:
request: DRF Request (не используется в текущей логике).
view: DRF View/ViewSet; важно наличие атрибута 'action' для ViewSet.
Returns:
Optional[str]: Код права или None, если право для экшена не требуется.
"""
return (
self.required_code_map.get(getattr(view, "action", None))
or self.required_code
)
def _extract_dept_id_from_request(self, request, view) -> Optional[int]:
"""Пытается извлечь department_id из kwargs, body или query-параметров.
Порядок:
1) view.kwargs: department_pk, department_id
2) request.data: department, department_id
3) request.query_params: department, department_id
Args:
request: DRF Request
view: DRF View
Returns:
Optional[int]: Значение dept_id, если найдено и приведено к int, иначе None.
"""
for k in ("department_pk", "department_id"):
v = view.kwargs.get(k)
if v is not None:
return v
for k in ("department", "department_id"):
v = request.data.get(k)
if v is not None:
return v
for k in ("department", "department_id"):
v = request.query_params.get(k)
if v is not None:
return v
return None
def _extract_dept_id_from_obj(self, obj: Any) -> Optional[int]:
"""Извлекает department_id из объекта.
Порядок:
- Если obj — модель Department с полем id → вернуть id.
- Если у obj есть атрибуты department_id или dept_id → вернуть его.
- Если у obj есть атрибут department (FK) → взять department.id.
Args:
obj: Модель/объект detail-view.
Returns:
Optional[int]: department_id или None.
"""
if obj is None:
return None
if obj.__class__.__name__ == "Department" and hasattr(obj, "id"):
return obj.id
for attr in ("department_id", "dept_id"):
if hasattr(obj, attr):
return getattr(obj, attr)
dep = getattr(obj, "department", None)
if dep is not None:
return getattr(dep, "id", None)
return None
def _to_int_or_none(self, v) -> Optional[int]:
"""Аккуратное приведение к int.
Args:
v: Любое значение с возможностью строкового преобразования.
Returns:
Optional[int]: int или None при ошибке.
"""
try:
return int(str(v))
except Exception:
return None
# -------- main checks --------
def has_permission(self, request, view) -> bool:
"""См. докстринг класса. Если dept_id не найден, но это detail-запрос,
откладываем проверку до object-level (вернём True)."""
user = getattr(request, "user", None)
if not user or not user.is_authenticated:
return False
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
code = self.get_required_code(request, view)
if not code:
return (
(request.method in SAFE_METHODS)
if self.allow_safe_without_code
else False
)
dept_id = self._to_int_or_none(
self._extract_dept_id_from_request(request, view)
)
if dept_id is None:
# robust: считаем detail, если есть lookup в kwargs
lookup_kw = getattr(view, "lookup_url_kwarg", None) or getattr(
view, "lookup_field", "pk"
)
is_detail = getattr(view, "detail", None)
if is_detail is None:
is_detail = lookup_kw in getattr(view, "kwargs", {})
if is_detail:
# отложим проверку до has_object_permission
return True
return False
return has_dept_perm(user, dept_id, code)
def has_object_permission(self, request, view, obj) -> bool:
"""Проверка доступа к конкретному объекту (detail).
Правила:
- superuser/staff → True.
- Если код не требуется → SAFE-методы допускаются (если allow_safe_without_code=True).
- Иначе:
- Пытаемся извлечь department_id из объекта;
- если не удалось — пробуем извлечь из запроса (kwargs/data/query);
- если не нашли вообще → False;
- иначе → has_dept_perm(user, dept_id, code).
Returns:
bool: Разрешить/запретить доступ к объекту.
"""
user = getattr(request, "user", None)
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
code = self.get_required_code(request, view)
if not code:
return (
request.method in SAFE_METHODS
if self.allow_safe_without_code
else False
)
dept_id = self._to_int_or_none(self._extract_dept_id_from_obj(obj))
if dept_id is None:
dept_id = self._to_int_or_none(
self._extract_dept_id_from_request(request, view)
)
return has_dept_perm(request.user, dept_id, code)
class IsSelfOrStaff(BasePermission):
"""Staff/superuser или владелец объекта (настраиваемые поля владельца через атрибуты вью)."""
DEFAULT_OWNER_ID_ATTRS = ("author_id", "user_id", "owner_id", "employee_id")
DEFAULT_OWNER_OBJ_ATTRS = ("author", "user", "owner", "employee")
def has_permission(self, request, view) -> bool:
user = getattr(request, "user", None)
return bool(user and user.is_authenticated)
def has_object_permission(self, request, view, obj):
user = request.user
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)
for attr in owner_id_attrs:
if hasattr(obj, attr) and getattr(obj, attr) == uid:
return True
for attr in owner_obj_attrs:
owner = getattr(obj, attr, None)
if owner is not None and getattr(owner, "id", None) == uid:
return True
# Fallback для вью «/users/{id}/»
lookup = getattr(view, "lookup_field", "pk")
return str(view.kwargs.get(lookup)) == str(uid)
class AdminOrActionOrModelPerms(DjangoModelPermissions):
@staticmethod
def _normalize_codes(value: Any) -> Optional[list[str]]:
"""Приводит конфигурацию кодов прав к списку строк.
Args:
value (Any): Значение из конфигурации прав:
- str — один код,
- Iterable[str] — несколько кодов,
- None — отсутствует.
Returns:
Optional[list[str]]: Список кодов или None.
Raises:
TypeError: Если найден нестроковый элемент в Iterable.
"""
if value is None:
return None
if isinstance(value, str):
v = value.strip()
return [v] if v else None
if isinstance(value, Iterable):
out: list[str] = []
for v in value:
if not isinstance(v, str):
raise TypeError("Коды прав должны быть строками.")
v = v.strip()
if v:
out.append(v)
return out or None
return None
@staticmethod
def _explicit_perm_codes(view) -> Optional[list[str]]:
"""Достаёт из вью «явные» коды прав (ИЛИ между кодами).
Поддерживаемые формы на ViewSet:
- required_perm_code: str | Iterable[str]
- required_perms_by_action[action]: str | Iterable[str]
- required_perms_by_action[action]: {method: str|Iterable[str]}
где method сравнивается без учёта регистра, возможны '*', 'ANY',
а HEAD трактуется как GET.
Args:
view: DRF ViewSet с полями `action`, `request` (опционально).
Returns:
Optional[list[str]]: Список кодов прав или None.
"""
# 1) Единый код(ы) на всю вью
codes = AdminOrActionOrModelPerms._normalize_codes(
getattr(view, "required_perm_code", None)
)
if codes:
return codes
# 2) Карта по экшенам (+ по методам)
mapping = getattr(view, "required_perms_by_action", None) or {}
if not isinstance(mapping, dict):
return None
action = getattr(view, "action", None)
if not action or action not in mapping:
return None
value = mapping[action]
# 2.1) Прямо строка/итерируемое — сразу нормализуем
codes = AdminOrActionOrModelPerms._normalize_codes(value)
if codes is not None:
return codes
# 2.2) Вложенная карта по методам
if isinstance(value, dict):
method = getattr(getattr(view, "request", None), "method", None)
if not method:
return None
m = method.upper()
keys = (m, m.lower())
if m == "HEAD":
keys += ("GET", "get")
keys += ("*", "ANY")
for k in keys:
if k in value:
return AdminOrActionOrModelPerms._normalize_codes(value[k])
return None
# Иное — игнорируем
return None
def has_permission(self, request, view) -> bool:
"""Проверка прав на уровне запроса.
Логика:
- staff/superuser → True
- если на вью есть «явные» коды (см. выше) — достаточно ЛЮБОГО из них
- иначе: для SAFE-методов требуем view_<model>, остальные — по DjangoModelPermissions
Returns:
bool: Разрешён ли доступ.
"""
user = getattr(request, "user", None)
if not (user and user.is_authenticated):
return False
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
explicit = self._explicit_perm_codes(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}"
)
return super().has_permission(request, view)
__all__ = [
"MANAGE_PERM",
"CHANGE_HEAD_PERM",
"ASSIGN_ROLE_PERM",
"IsSelfOrStaff",
"AdminOrActionOrModelPerms",
"user_is_dept_head",
"user_has_dept_perm",
]

View File

@ -0,0 +1,477 @@
# backend\api\v1\permissions.py
from __future__ import annotations
from typing import Any, Dict, Iterable, Optional, Tuple
from django.contrib.auth.models import AbstractBaseUser, AnonymousUser
from employees.constants import DeptPerm
from employees.models import Department, EmployeeDepartment
from rest_framework.permissions import (
SAFE_METHODS,
BasePermission,
DjangoModelPermissions,
)
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:
return bool(
user and user.is_authenticated and dept.head_id == getattr(user, "id", None)
)
def user_is_staffish(user) -> bool:
return bool(user and user.is_authenticated and (user.is_superuser or user.is_staff))
def user_has_dept_perm(user, dept: Department, perm_code: str) -> bool:
"""Проверяет, есть ли у пользователя право `perm_code` в указанном отделе.
Правила:
- Неаутентифицированным → False.
- staff/superuser → True.
- Руководитель отдела → True.
- Иначе: существует активная связь EmployeeDepartment, и у роли есть
DepartmentPermission с нужным code.
Args:
user (User): Текущий пользователь.
dept (Department): Отдел.
perm_code (str): Код департаментного права (см. DeptPerm.CHOICES).
Returns:
bool: True, если доступ разрешён.
Raises:
ValueError: Если `dept` не задан или не имеет id.
"""
if not (user and user.is_authenticated):
return False
# staff/superuser — сразу ок
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
if not isinstance(dept, Department) or getattr(dept, "id", None) is None:
raise ValueError("Argument `dept` must be Department instance with a valid id.")
# руководитель — сразу ок
if getattr(dept, "head_id", None) == user.id:
return True
# основная проверка: активная связь + у роли есть нужный DepartmentPermission.code
return EmployeeDepartment.objects.filter(
employee_id=user.id,
department_id=dept.id,
is_active=True,
role__scoped_permissions__code=perm_code, # <-- ВАЖНО: `code`, не `codename`
).exists()
def has_dept_perm(user: AbstractBaseUser, department_id: int, code: str) -> bool:
"""
Возвращает True, если пользователь имеет право `code` в рамках отдела `department_id`.
Логика:
1) staff/superuser → всегда True
2) пользователь является текущим руководителем отдела → True для любых «управленческих» кодов
3) пользователь состоит в отделе и его роль содержит `code` в scoped_permissions → True
4) иначе False
"""
if not getattr(user, "is_authenticated", False):
return False
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
# 2) Руководитель отдела имеет все управленческие права в своём отделе
if Department.objects.filter(id=department_id, head_id=user.id).exists():
return True
# 3) Проверка роли в отделе
link = (
EmployeeDepartment.objects.select_related("role")
.filter(employee_id=user.id, department_id=department_id, is_active=True)
.first()
)
if not link or not link.role_id:
return False
# роль хранит «скоуп-права» только для этого отдела
return link.role.scoped_permissions.filter(code=code).exists()
class AdminOrDeptAllowed(BasePermission):
"""Комбинированный пермишен: админский доступ ИЛИ доступ по праву на конкретный отдел.
Поведение:
1. Если пользователь superuser/staff — доступ всегда разрешён.
2. Если для текущего экшена требуется код права (например, DeptPerm.MANAGE_CALENDAR),
то доступ разрешается только при наличии у пользователя этого права на указанный отдел.
3. Идентификатор отдела извлекается из:
- URL kwargs: 'department_pk' → 'department_id';
- request.data: 'department' → 'department_id';
- query params: 'department' → 'department_id'.
На уровне has_object_permission отдел берётся из самого объекта:
- Department.id;
- obj.department_id / obj.dept_id;
- obj.department.id.
4. Если код права не задан (required_code/required_code_map пусты), то
для SAFE-методов (GET/HEAD/OPTIONS) доступ разрешён, если allow_safe_without_code=True (по умолчанию).
Для небезопасных методов в этом случае доступ запрещён.
Атрибуты класса:
required_code (str|None): Единый код права для всех экшенов. Если None, используется required_code_map.
required_code_map (dict[str,str]): Коды прав по имени action (create/update/partial_update/destroy/...).
allow_safe_without_code (bool): Разрешать ли SAFE-методы, когда код не задан. По умолчанию True.
Примеры:
class ManageCalendarPerm(AdminOrDeptAllowed):
required_code = DeptPerm.MANAGE_CALENDAR
class DeptEventsPerm(AdminOrDeptAllowed):
required_code_map = {
"create": DeptPerm.MANAGE_CALENDAR,
"update": DeptPerm.MANAGE_CALENDAR,
"partial_update": DeptPerm.MANAGE_CALENDAR,
"destroy": DeptPerm.MANAGE_CALENDAR,
}
"""
required_code: Optional[str] = None
required_code_map: Dict[str, str] = {}
allow_safe_without_code: bool = True
# -------- helpers --------
def get_required_code(self, request, view) -> Optional[str]:
"""Определяет требуемый код права для текущего экшена.
Сначала берёт из required_code_map по имени action (DRF: view.action),
затем fallback на required_code.
Args:
request: DRF Request (не используется в текущей логике).
view: DRF View/ViewSet; важно наличие атрибута 'action' для ViewSet.
Returns:
Optional[str]: Код права или None, если право для экшена не требуется.
"""
return (
self.required_code_map.get(getattr(view, "action", None))
or self.required_code
)
def _extract_dept_id_from_request(self, request, view) -> Optional[int]:
"""Пытается извлечь department_id из kwargs, body или query-параметров.
Порядок:
1) view.kwargs: department_pk, department_id
2) request.data: department, department_id
3) request.query_params: department, department_id
Args:
request: DRF Request
view: DRF View
Returns:
Optional[int]: Значение dept_id, если найдено и приведено к int, иначе None.
"""
for k in ("department_pk", "department_id"):
v = view.kwargs.get(k)
if v is not None:
return v
for k in ("department", "department_id"):
v = request.data.get(k)
if v is not None:
return v
for k in ("department", "department_id"):
v = request.query_params.get(k)
if v is not None:
return v
return None
def _extract_dept_id_from_obj(self, obj: Any) -> Optional[int]:
"""Извлекает department_id из объекта.
Порядок:
- Если obj — модель Department с полем id → вернуть id.
- Если у obj есть атрибуты department_id или dept_id → вернуть его.
- Если у obj есть атрибут department (FK) → взять department.id.
Args:
obj: Модель/объект detail-view.
Returns:
Optional[int]: department_id или None.
"""
if obj is None:
return None
if obj.__class__.__name__ == "Department" and hasattr(obj, "id"):
return obj.id
for attr in ("department_id", "dept_id"):
if hasattr(obj, attr):
return getattr(obj, attr)
dep = getattr(obj, "department", None)
if dep is not None:
return getattr(dep, "id", None)
return None
def _to_int_or_none(self, v) -> Optional[int]:
"""Аккуратное приведение к int.
Args:
v: Любое значение с возможностью строкового преобразования.
Returns:
Optional[int]: int или None при ошибке.
"""
try:
return int(str(v))
except Exception:
return None
# -------- main checks --------
def has_permission(self, request, view) -> bool:
"""См. докстринг класса. Если dept_id не найден, но это detail-запрос,
откладываем проверку до object-level (вернём True)."""
user = getattr(request, "user", None)
if not user or not user.is_authenticated:
return False
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
code = self.get_required_code(request, view)
if not code:
return (
(request.method in SAFE_METHODS)
if self.allow_safe_without_code
else False
)
dept_id = self._to_int_or_none(
self._extract_dept_id_from_request(request, view)
)
if dept_id is None:
# robust: считаем detail, если есть lookup в kwargs
lookup_kw = getattr(view, "lookup_url_kwarg", None) or getattr(
view, "lookup_field", "pk"
)
is_detail = getattr(view, "detail", None)
if is_detail is None:
is_detail = lookup_kw in getattr(view, "kwargs", {})
if is_detail:
# отложим проверку до has_object_permission
return True
return False
return has_dept_perm(user, dept_id, code)
def has_object_permission(self, request, view, obj) -> bool:
"""Проверка доступа к конкретному объекту (detail).
Правила:
- superuser/staff → True.
- Если код не требуется → SAFE-методы допускаются (если allow_safe_without_code=True).
- Иначе:
- Пытаемся извлечь department_id из объекта;
- если не удалось — пробуем извлечь из запроса (kwargs/data/query);
- если не нашли вообще → False;
- иначе → has_dept_perm(user, dept_id, code).
Returns:
bool: Разрешить/запретить доступ к объекту.
"""
user = getattr(request, "user", None)
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
code = self.get_required_code(request, view)
if not code:
return (
request.method in SAFE_METHODS
if self.allow_safe_without_code
else False
)
dept_id = self._to_int_or_none(self._extract_dept_id_from_obj(obj))
if dept_id is None:
dept_id = self._to_int_or_none(
self._extract_dept_id_from_request(request, view)
)
return has_dept_perm(request.user, dept_id, code)
class IsSelfOrStaff(BasePermission):
"""Staff/superuser или владелец объекта (настраиваемые поля владельца через атрибуты вью)."""
DEFAULT_OWNER_ID_ATTRS = ("author_id", "user_id", "owner_id", "employee_id")
DEFAULT_OWNER_OBJ_ATTRS = ("author", "user", "owner", "employee")
def has_permission(self, request, view) -> bool:
user = getattr(request, "user", None)
return bool(user and user.is_authenticated)
def has_object_permission(self, request, view, obj):
user = request.user
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)
for attr in owner_id_attrs:
if hasattr(obj, attr) and getattr(obj, attr) == uid:
return True
for attr in owner_obj_attrs:
owner = getattr(obj, attr, None)
if owner is not None and getattr(owner, "id", None) == uid:
return True
# Fallback для вью «/users/{id}/»
lookup = getattr(view, "lookup_field", "pk")
return str(view.kwargs.get(lookup)) == str(uid)
class AdminOrActionOrModelPerms(DjangoModelPermissions):
@staticmethod
def _normalize_codes(value: Any) -> Optional[list[str]]:
"""Приводит конфигурацию кодов прав к списку строк.
Args:
value (Any): Значение из конфигурации прав:
- str — один код,
- Iterable[str] — несколько кодов,
- None — отсутствует.
Returns:
Optional[list[str]]: Список кодов или None.
Raises:
TypeError: Если найден нестроковый элемент в Iterable.
"""
if value is None:
return None
if isinstance(value, str):
v = value.strip()
return [v] if v else None
if isinstance(value, Iterable):
out: list[str] = []
for v in value:
if not isinstance(v, str):
raise TypeError("Коды прав должны быть строками.")
v = v.strip()
if v:
out.append(v)
return out or None
return None
@staticmethod
def _explicit_perm_codes(view) -> Optional[list[str]]:
"""Достаёт из вью «явные» коды прав (ИЛИ между кодами).
Поддерживаемые формы на ViewSet:
- required_perm_code: str | Iterable[str]
- required_perms_by_action[action]: str | Iterable[str]
- required_perms_by_action[action]: {method: str|Iterable[str]}
где method сравнивается без учёта регистра, возможны '*', 'ANY',
а HEAD трактуется как GET.
Args:
view: DRF ViewSet с полями `action`, `request` (опционально).
Returns:
Optional[list[str]]: Список кодов прав или None.
"""
# 1) Единый код(ы) на всю вью
codes = AdminOrActionOrModelPerms._normalize_codes(
getattr(view, "required_perm_code", None)
)
if codes:
return codes
# 2) Карта по экшенам (+ по методам)
mapping = getattr(view, "required_perms_by_action", None) or {}
if not isinstance(mapping, dict):
return None
action = getattr(view, "action", None)
if not action or action not in mapping:
return None
value = mapping[action]
# 2.1) Прямо строка/итерируемое — сразу нормализуем
codes = AdminOrActionOrModelPerms._normalize_codes(value)
if codes is not None:
return codes
# 2.2) Вложенная карта по методам
if isinstance(value, dict):
method = getattr(getattr(view, "request", None), "method", None)
if not method:
return None
m = method.upper()
keys = (m, m.lower())
if m == "HEAD":
keys += ("GET", "get")
keys += ("*", "ANY")
for k in keys:
if k in value:
return AdminOrActionOrModelPerms._normalize_codes(value[k])
return None
# Иное — игнорируем
return None
def has_permission(self, request, view) -> bool:
"""Проверка прав на уровне запроса.
Логика:
- staff/superuser → True
- если на вью есть «явные» коды (см. выше) — достаточно ЛЮБОГО из них
- иначе: для SAFE-методов требуем view_<model>, остальные — по DjangoModelPermissions
Returns:
bool: Разрешён ли доступ.
"""
user = getattr(request, "user", None)
if not (user and user.is_authenticated):
return False
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
explicit = self._explicit_perm_codes(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}"
)
return super().has_permission(request, view)
__all__ = [
"MANAGE_PERM",
"CHANGE_HEAD_PERM",
"ASSIGN_ROLE_PERM",
"IsSelfOrStaff",
"AdminOrActionOrModelPerms",
"user_is_dept_head",
"user_has_dept_perm",
]

View File

@ -0,0 +1,546 @@
# backend\api\v1\permissions.py
from __future__ import annotations
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 (SAFE_METHODS, 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:
return bool(
user and user.is_authenticated and dept.head_id == getattr(user, "id", None)
)
def user_is_staffish(user) -> bool:
return bool(user and user.is_authenticated and (user.is_superuser or user.is_staff))
def user_has_dept_perm(user, dept: Department, perm_code: str) -> bool:
"""Проверяет, есть ли у пользователя право `perm_code` в указанном отделе.
Правила:
- Неаутентифицированным → False.
- staff/superuser → True.
- Руководитель отдела → True.
- Иначе: существует активная связь EmployeeDepartment, и у роли есть
DepartmentPermission с нужным code.
Args:
user (User): Текущий пользователь.
dept (Department): Отдел.
perm_code (str): Код департаментного права (см. DeptPerm.CHOICES).
Returns:
bool: True, если доступ разрешён.
Raises:
ValueError: Если `dept` не задан или не имеет id.
"""
if not (user and user.is_authenticated):
return False
# staff/superuser — сразу ок
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
if not isinstance(dept, Department) or getattr(dept, "id", None) is None:
raise ValueError("Argument `dept` must be Department instance with a valid id.")
# руководитель — сразу ок
if getattr(dept, "head_id", None) == user.id:
return True
# основная проверка: активная связь + у роли есть нужный DepartmentPermission.code
return EmployeeDepartment.objects.filter(
employee_id=user.id,
department_id=dept.id,
is_active=True,
role__scoped_permissions__code=perm_code, # <-- ВАЖНО: `code`, не `codename`
).exists()
def has_dept_perm(user: AbstractBaseUser, department_id: int, code: str) -> bool:
"""
Возвращает True, если пользователь имеет право `code` в рамках отдела `department_id`.
Логика:
1) staff/superuser → всегда True
2) пользователь является текущим руководителем отдела → True для любых «управленческих» кодов
3) пользователь состоит в отделе и его роль содержит `code` в scoped_permissions → True
4) иначе False
"""
if not getattr(user, "is_authenticated", False):
return False
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
# 2) Руководитель отдела имеет все управленческие права в своём отделе
if Department.objects.filter(id=department_id, head_id=user.id).exists():
return True
# 3) Проверка роли в отделе
link = (
EmployeeDepartment.objects.select_related("role")
.filter(employee_id=user.id, department_id=department_id, is_active=True)
.first()
)
if not link or not link.role_id:
return False
# роль хранит «скоуп-права» только для этого отдела
return link.role.scoped_permissions.filter(code=code).exists()
class AdminOrDeptAllowed(BasePermission):
"""Комбинированный пермишен: админский доступ ИЛИ доступ по праву на конкретный отдел.
Поведение:
1. Если пользователь superuser/staff — доступ всегда разрешён.
2. Если для текущего экшена требуется код права (например, DeptPerm.MANAGE_CALENDAR),
то доступ разрешается только при наличии у пользователя этого права на указанный отдел.
3. Идентификатор отдела извлекается из:
- URL kwargs: 'department_pk' → 'department_id';
- request.data: 'department' → 'department_id';
- query params: 'department' → 'department_id'.
На уровне has_object_permission отдел берётся из самого объекта:
- Department.id;
- obj.department_id / obj.dept_id;
- obj.department.id.
4. Если код права не задан (required_code/required_code_map пусты), то
для SAFE-методов (GET/HEAD/OPTIONS) доступ разрешён, если allow_safe_without_code=True (по умолчанию).
Для небезопасных методов в этом случае доступ запрещён.
Атрибуты класса:
required_code (str|None): Единый код права для всех экшенов. Если None, используется required_code_map.
required_code_map (dict[str,str]): Коды прав по имени action (create/update/partial_update/destroy/...).
allow_safe_without_code (bool): Разрешать ли SAFE-методы, когда код не задан. По умолчанию True.
Примеры:
class ManageCalendarPerm(AdminOrDeptAllowed):
required_code = DeptPerm.MANAGE_CALENDAR
class DeptEventsPerm(AdminOrDeptAllowed):
required_code_map = {
"create": DeptPerm.MANAGE_CALENDAR,
"update": DeptPerm.MANAGE_CALENDAR,
"partial_update": DeptPerm.MANAGE_CALENDAR,
"destroy": DeptPerm.MANAGE_CALENDAR,
}
"""
required_code: Optional[str] = None
required_code_map: Dict[str, str] = {}
allow_safe_without_code: bool = True
# -------- helpers --------
def get_required_code(self, request, view) -> Optional[str]:
"""Определяет требуемый код права для текущего экшена.
Сначала берёт из required_code_map по имени action (DRF: view.action),
затем fallback на required_code.
Args:
request: DRF Request (не используется в текущей логике).
view: DRF View/ViewSet; важно наличие атрибута 'action' для ViewSet.
Returns:
Optional[str]: Код права или None, если право для экшена не требуется.
"""
return (
self.required_code_map.get(getattr(view, "action", None))
or self.required_code
)
def _extract_dept_id_from_request(self, request, view) -> Optional[int]:
"""Пытается извлечь department_id из kwargs, body или query-параметров.
Порядок:
1) view.kwargs: department_pk, department_id
2) request.data: department, department_id
3) request.query_params: department, department_id
Args:
request: DRF Request
view: DRF View
Returns:
Optional[int]: Значение dept_id, если найдено и приведено к int, иначе None.
"""
for k in ("department_pk", "department_id"):
v = view.kwargs.get(k)
if v is not None:
return v
for k in ("department", "department_id"):
v = request.data.get(k)
if v is not None:
return v
for k in ("department", "department_id"):
v = request.query_params.get(k)
if v is not None:
return v
return None
def _extract_dept_id_from_obj(self, obj: Any) -> Optional[int]:
"""Извлекает department_id из объекта.
Порядок:
- Если obj — модель Department с полем id → вернуть id.
- Если у obj есть атрибуты department_id или dept_id → вернуть его.
- Если у obj есть атрибут department (FK) → взять department.id.
Args:
obj: Модель/объект detail-view.
Returns:
Optional[int]: department_id или None.
"""
if obj is None:
return None
if obj.__class__.__name__ == "Department" and hasattr(obj, "id"):
return obj.id
for attr in ("department_id", "dept_id"):
if hasattr(obj, attr):
return getattr(obj, attr)
dep = getattr(obj, "department", None)
if dep is not None:
return getattr(dep, "id", None)
return None
def _to_int_or_none(self, v) -> Optional[int]:
"""Аккуратное приведение к int.
Args:
v: Любое значение с возможностью строкового преобразования.
Returns:
Optional[int]: int или None при ошибке.
"""
try:
return int(str(v))
except Exception:
return None
# -------- main checks --------
def has_permission(self, request, view) -> bool:
"""См. докстринг класса. Если dept_id не найден, но это detail-запрос,
откладываем проверку до object-level (вернём True)."""
user = getattr(request, "user", None)
if not user or not user.is_authenticated:
return False
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
code = self.get_required_code(request, view)
if not code:
return (
(request.method in SAFE_METHODS)
if self.allow_safe_without_code
else False
)
dept_id = self._to_int_or_none(
self._extract_dept_id_from_request(request, view)
)
if dept_id is None:
# robust: считаем detail, если есть lookup в kwargs
lookup_kw = getattr(view, "lookup_url_kwarg", None) or getattr(
view, "lookup_field", "pk"
)
is_detail = getattr(view, "detail", None)
if is_detail is None:
is_detail = lookup_kw in getattr(view, "kwargs", {})
if is_detail:
# отложим проверку до has_object_permission
return True
return False
return has_dept_perm(user, dept_id, code)
def has_object_permission(self, request, view, obj) -> bool:
"""Проверка доступа к конкретному объекту (detail).
Правила:
- superuser/staff → True.
- Если код не требуется → SAFE-методы допускаются (если allow_safe_without_code=True).
- Иначе:
- Пытаемся извлечь department_id из объекта;
- если не удалось — пробуем извлечь из запроса (kwargs/data/query);
- если не нашли вообще → False;
- иначе → has_dept_perm(user, dept_id, code).
Returns:
bool: Разрешить/запретить доступ к объекту.
"""
user = getattr(request, "user", None)
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
code = self.get_required_code(request, view)
if not code:
return (
request.method in SAFE_METHODS
if self.allow_safe_without_code
else False
)
dept_id = self._to_int_or_none(self._extract_dept_id_from_obj(obj))
if dept_id is None:
dept_id = self._to_int_or_none(
self._extract_dept_id_from_request(request, view)
)
return has_dept_perm(request.user, dept_id, code)
class IsSelfOrStaff(BasePermission):
"""Доступ для staff/superuser или владельца объекта.
ВАЖНО:
• SAFE-методы НЕ дают автоматического допуска.
• Добавлена поддержка кейса "объект = сам пользователь" (self-update).
"""
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: 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 getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
# 1) *_id поля владельца
owner_id_attrs: Iterable[str] = getattr(
view, "owner_id_attrs", self.DEFAULT_OWNER_ID_ATTRS
)
for attr in owner_id_attrs:
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) == user.id:
return True
# 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]]:
"""Приводит конфигурацию кодов прав к списку строк.
Args:
value (Any): Значение из конфигурации прав:
- str — один код,
- Iterable[str] — несколько кодов,
- None — отсутствует.
Returns:
Optional[list[str]]: Список кодов или None.
Raises:
TypeError: Если найден нестроковый элемент в Iterable.
"""
if value is None:
return None
if isinstance(value, str):
v = value.strip()
return [v] if v else None
if isinstance(value, Iterable):
out: list[str] = []
for v in value:
if not isinstance(v, str):
raise TypeError("Коды прав должны быть строками.")
v = v.strip()
if v:
out.append(v)
return out or None
return None
@staticmethod
def _explicit_perm_codes(view) -> Optional[list[str]]:
"""Достаёт из вью «явные» коды прав (ИЛИ между кодами).
Поддерживаемые формы на ViewSet:
- required_perm_code: str | Iterable[str]
- required_perms_by_action[action]: str | Iterable[str]
- required_perms_by_action[action]: {method: str|Iterable[str]}
где method сравнивается без учёта регистра, возможны '*', 'ANY',
а HEAD трактуется как GET.
Args:
view: DRF ViewSet с полями `action`, `request` (опционально).
Returns:
Optional[list[str]]: Список кодов прав или None.
"""
# 1) Единый код(ы) на всю вью
codes = AdminOrActionOrModelPerms._normalize_codes(
getattr(view, "required_perm_code", None)
)
if codes:
return codes
# 2) Карта по экшенам (+ по методам)
mapping = getattr(view, "required_perms_by_action", None) or {}
if not isinstance(mapping, dict):
return None
action = getattr(view, "action", None)
if not action or action not in mapping:
return None
value = mapping[action]
# 2.1) Прямо строка/итерируемое — сразу нормализуем
codes = AdminOrActionOrModelPerms._normalize_codes(value)
if codes is not None:
return codes
# 2.2) Вложенная карта по методам
if isinstance(value, dict):
method = getattr(getattr(view, "request", None), "method", None)
if not method:
return None
m = method.upper()
keys = (m, m.lower())
if m == "HEAD":
keys += ("GET", "get")
keys += ("*", "ANY")
for k in keys:
if k in value:
return AdminOrActionOrModelPerms._normalize_codes(value[k])
return None
# Иное — игнорируем
return None
def has_permission(self, request: Request, view) -> bool:
"""Проверяет доступ на уровне запроса.
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):
return False
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
# 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)
# 2) Всё остальное — родителю (в т.ч. SAFE_METHODS → view_<model>)
return super().has_permission(request, view)
__all__ = [
"MANAGE_PERM",
"CHANGE_HEAD_PERM",
"ASSIGN_ROLE_PERM",
"IsSelfOrStaff",
"AdminOrActionOrModelPerms",
"user_is_dept_head",
"user_has_dept_perm",
]

View File

@ -0,0 +1,546 @@
# backend\api\v1\permissions.py
from __future__ import annotations
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 (SAFE_METHODS, 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:
return bool(
user and user.is_authenticated and dept.head_id == getattr(user, "id", None)
)
def user_is_staffish(user) -> bool:
return bool(user and user.is_authenticated and (user.is_superuser or user.is_staff))
def user_has_dept_perm(user, dept: Department, perm_code: str) -> bool:
"""Проверяет, есть ли у пользователя право `perm_code` в указанном отделе.
Правила:
- Неаутентифицированным → False.
- staff/superuser → True.
- Руководитель отдела → True.
- Иначе: существует активная связь EmployeeDepartment, и у роли есть
DepartmentPermission с нужным code.
Args:
user (User): Текущий пользователь.
dept (Department): Отдел.
perm_code (str): Код департаментного права (см. DeptPerm.CHOICES).
Returns:
bool: True, если доступ разрешён.
Raises:
ValueError: Если `dept` не задан или не имеет id.
"""
if not (user and user.is_authenticated):
return False
# staff/superuser — сразу ок
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
if not isinstance(dept, Department) or getattr(dept, "id", None) is None:
raise ValueError("Argument `dept` must be Department instance with a valid id.")
# руководитель — сразу ок
if getattr(dept, "head_id", None) == user.id:
return True
# основная проверка: активная связь + у роли есть нужный DepartmentPermission.code
return EmployeeDepartment.objects.filter(
employee_id=user.id,
department_id=dept.id,
is_active=True,
role__scoped_permissions__code=perm_code, # <-- ВАЖНО: `code`, не `codename`
).exists()
def has_dept_perm(user: AbstractBaseUser, department_id: int, code: str) -> bool:
"""
Возвращает True, если пользователь имеет право `code` в рамках отдела `department_id`.
Логика:
1) staff/superuser → всегда True
2) пользователь является текущим руководителем отдела → True для любых «управленческих» кодов
3) пользователь состоит в отделе и его роль содержит `code` в scoped_permissions → True
4) иначе False
"""
if not getattr(user, "is_authenticated", False):
return False
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
# 2) Руководитель отдела имеет все управленческие права в своём отделе
if Department.objects.filter(id=department_id, head_id=user.id).exists():
return True
# 3) Проверка роли в отделе
link = (
EmployeeDepartment.objects.select_related("role")
.filter(employee_id=user.id, department_id=department_id, is_active=True)
.first()
)
if not link or not link.role_id:
return False
# роль хранит «скоуп-права» только для этого отдела
return link.role.scoped_permissions.filter(code=code).exists()
class AdminOrDeptAllowed(BasePermission):
"""Комбинированный пермишен: админский доступ ИЛИ доступ по праву на конкретный отдел.
Поведение:
1. Если пользователь superuser/staff — доступ всегда разрешён.
2. Если для текущего экшена требуется код права (например, DeptPerm.MANAGE_CALENDAR),
то доступ разрешается только при наличии у пользователя этого права на указанный отдел.
3. Идентификатор отдела извлекается из:
- URL kwargs: 'department_pk' → 'department_id';
- request.data: 'department' → 'department_id';
- query params: 'department' → 'department_id'.
На уровне has_object_permission отдел берётся из самого объекта:
- Department.id;
- obj.department_id / obj.dept_id;
- obj.department.id.
4. Если код права не задан (required_code/required_code_map пусты), то
для SAFE-методов (GET/HEAD/OPTIONS) доступ разрешён, если allow_safe_without_code=True (по умолчанию).
Для небезопасных методов в этом случае доступ запрещён.
Атрибуты класса:
required_code (str|None): Единый код права для всех экшенов. Если None, используется required_code_map.
required_code_map (dict[str,str]): Коды прав по имени action (create/update/partial_update/destroy/...).
allow_safe_without_code (bool): Разрешать ли SAFE-методы, когда код не задан. По умолчанию True.
Примеры:
class ManageCalendarPerm(AdminOrDeptAllowed):
required_code = DeptPerm.MANAGE_CALENDAR
class DeptEventsPerm(AdminOrDeptAllowed):
required_code_map = {
"create": DeptPerm.MANAGE_CALENDAR,
"update": DeptPerm.MANAGE_CALENDAR,
"partial_update": DeptPerm.MANAGE_CALENDAR,
"destroy": DeptPerm.MANAGE_CALENDAR,
}
"""
required_code: Optional[str] = None
required_code_map: Dict[str, str] = {}
allow_safe_without_code: bool = True
# -------- helpers --------
def get_required_code(self, request, view) -> Optional[str]:
"""Определяет требуемый код права для текущего экшена.
Сначала берёт из required_code_map по имени action (DRF: view.action),
затем fallback на required_code.
Args:
request: DRF Request (не используется в текущей логике).
view: DRF View/ViewSet; важно наличие атрибута 'action' для ViewSet.
Returns:
Optional[str]: Код права или None, если право для экшена не требуется.
"""
return (
self.required_code_map.get(getattr(view, "action", None))
or self.required_code
)
def _extract_dept_id_from_request(self, request, view) -> Optional[int]:
"""Пытается извлечь department_id из kwargs, body или query-параметров.
Порядок:
1) view.kwargs: department_pk, department_id
2) request.data: department, department_id
3) request.query_params: department, department_id
Args:
request: DRF Request
view: DRF View
Returns:
Optional[int]: Значение dept_id, если найдено и приведено к int, иначе None.
"""
for k in ("department_pk", "department_id"):
v = view.kwargs.get(k)
if v is not None:
return v
for k in ("department", "department_id"):
v = request.data.get(k)
if v is not None:
return v
for k in ("department", "department_id"):
v = request.query_params.get(k)
if v is not None:
return v
return None
def _extract_dept_id_from_obj(self, obj: Any) -> Optional[int]:
"""Извлекает department_id из объекта.
Порядок:
- Если obj — модель Department с полем id → вернуть id.
- Если у obj есть атрибуты department_id или dept_id → вернуть его.
- Если у obj есть атрибут department (FK) → взять department.id.
Args:
obj: Модель/объект detail-view.
Returns:
Optional[int]: department_id или None.
"""
if obj is None:
return None
if obj.__class__.__name__ == "Department" and hasattr(obj, "id"):
return obj.id
for attr in ("department_id", "dept_id"):
if hasattr(obj, attr):
return getattr(obj, attr)
dep = getattr(obj, "department", None)
if dep is not None:
return getattr(dep, "id", None)
return None
def _to_int_or_none(self, v) -> Optional[int]:
"""Аккуратное приведение к int.
Args:
v: Любое значение с возможностью строкового преобразования.
Returns:
Optional[int]: int или None при ошибке.
"""
try:
return int(str(v))
except Exception:
return None
# -------- main checks --------
def has_permission(self, request, view) -> bool:
"""См. докстринг класса. Если dept_id не найден, но это detail-запрос,
откладываем проверку до object-level (вернём True)."""
user = getattr(request, "user", None)
if not user or not user.is_authenticated:
return False
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
code = self.get_required_code(request, view)
if not code:
return (
(request.method in SAFE_METHODS)
if self.allow_safe_without_code
else False
)
dept_id = self._to_int_or_none(
self._extract_dept_id_from_request(request, view)
)
if dept_id is None:
# robust: считаем detail, если есть lookup в kwargs
lookup_kw = getattr(view, "lookup_url_kwarg", None) or getattr(
view, "lookup_field", "pk"
)
is_detail = getattr(view, "detail", None)
if is_detail is None:
is_detail = lookup_kw in getattr(view, "kwargs", {})
if is_detail:
# отложим проверку до has_object_permission
return True
return False
return has_dept_perm(user, dept_id, code)
def has_object_permission(self, request, view, obj) -> bool:
"""Проверка доступа к конкретному объекту (detail).
Правила:
- superuser/staff → True.
- Если код не требуется → SAFE-методы допускаются (если allow_safe_without_code=True).
- Иначе:
- Пытаемся извлечь department_id из объекта;
- если не удалось — пробуем извлечь из запроса (kwargs/data/query);
- если не нашли вообще → False;
- иначе → has_dept_perm(user, dept_id, code).
Returns:
bool: Разрешить/запретить доступ к объекту.
"""
user = getattr(request, "user", None)
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
code = self.get_required_code(request, view)
if not code:
return (
request.method in SAFE_METHODS
if self.allow_safe_without_code
else False
)
dept_id = self._to_int_or_none(self._extract_dept_id_from_obj(obj))
if dept_id is None:
dept_id = self._to_int_or_none(
self._extract_dept_id_from_request(request, view)
)
return has_dept_perm(request.user, dept_id, code)
class IsSelfOrStaff(BasePermission):
"""Доступ для staff/superuser или владельца объекта.
ВАЖНО:
• SAFE-методы НЕ дают автоматического допуска.
• Добавлена поддержка кейса "объект = сам пользователь" (self-update).
"""
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: 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 getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
# 1) *_id поля владельца
owner_id_attrs: Iterable[str] = getattr(
view, "owner_id_attrs", self.DEFAULT_OWNER_ID_ATTRS
)
for attr in owner_id_attrs:
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) == user.id:
return True
# 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]]:
"""Приводит конфигурацию кодов прав к списку строк.
Args:
value (Any): Значение из конфигурации прав:
- str — один код,
- Iterable[str] — несколько кодов,
- None — отсутствует.
Returns:
Optional[list[str]]: Список кодов или None.
Raises:
TypeError: Если найден нестроковый элемент в Iterable.
"""
if value is None:
return None
if isinstance(value, str):
v = value.strip()
return [v] if v else None
if isinstance(value, Iterable):
out: list[str] = []
for v in value:
if not isinstance(v, str):
raise TypeError("Коды прав должны быть строками.")
v = v.strip()
if v:
out.append(v)
return out or None
return None
@staticmethod
def _explicit_perm_codes(view) -> Optional[list[str]]:
"""Достаёт из вью «явные» коды прав (ИЛИ между кодами).
Поддерживаемые формы на ViewSet:
- required_perm_code: str | Iterable[str]
- required_perms_by_action[action]: str | Iterable[str]
- required_perms_by_action[action]: {method: str|Iterable[str]}
где method сравнивается без учёта регистра, возможны '*', 'ANY',
а HEAD трактуется как GET.
Args:
view: DRF ViewSet с полями `action`, `request` (опционально).
Returns:
Optional[list[str]]: Список кодов прав или None.
"""
# 1) Единый код(ы) на всю вью
codes = AdminOrActionOrModelPerms._normalize_codes(
getattr(view, "required_perm_code", None)
)
if codes:
return codes
# 2) Карта по экшенам (+ по методам)
mapping = getattr(view, "required_perms_by_action", None) or {}
if not isinstance(mapping, dict):
return None
action = getattr(view, "action", None)
if not action or action not in mapping:
return None
value = mapping[action]
# 2.1) Прямо строка/итерируемое — сразу нормализуем
codes = AdminOrActionOrModelPerms._normalize_codes(value)
if codes is not None:
return codes
# 2.2) Вложенная карта по методам
if isinstance(value, dict):
method = getattr(getattr(view, "request", None), "method", None)
if not method:
return None
m = method.upper()
keys = (m, m.lower())
if m == "HEAD":
keys += ("GET", "get")
keys += ("*", "ANY")
for k in keys:
if k in value:
return AdminOrActionOrModelPerms._normalize_codes(value[k])
return None
# Иное — игнорируем
return None
def has_permission(self, request: Request, view) -> bool:
"""Проверяет доступ на уровне запроса.
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):
return False
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
# 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)
# 2) Всё остальное — родителю (в т.ч. SAFE_METHODS → view_<model>)
return super().has_permission(request, view)
__all__ = [
"MANAGE_PERM",
"CHANGE_HEAD_PERM",
"ASSIGN_ROLE_PERM",
"IsSelfOrStaff",
"AdminOrActionOrModelPerms",
"user_is_dept_head",
"user_has_dept_perm",
]

View File

@ -0,0 +1,546 @@
# backend\api\v1\permissions.py
from __future__ import annotations
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 (SAFE_METHODS, 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:
return bool(
user and user.is_authenticated and dept.head_id == getattr(user, "id", None)
)
def user_is_staffish(user) -> bool:
return bool(user and user.is_authenticated and (user.is_superuser or user.is_staff))
def user_has_dept_perm(user, dept: Department, perm_code: str) -> bool:
"""Проверяет, есть ли у пользователя право `perm_code` в указанном отделе.
Правила:
- Неаутентифицированным → False.
- staff/superuser → True.
- Руководитель отдела → True.
- Иначе: существует активная связь EmployeeDepartment, и у роли есть
DepartmentPermission с нужным code.
Args:
user (User): Текущий пользователь.
dept (Department): Отдел.
perm_code (str): Код департаментного права (см. DeptPerm.CHOICES).
Returns:
bool: True, если доступ разрешён.
Raises:
ValueError: Если `dept` не задан или не имеет id.
"""
if not (user and user.is_authenticated):
return False
# staff/superuser — сразу ок
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
if not isinstance(dept, Department) or getattr(dept, "id", None) is None:
raise ValueError("Argument `dept` must be Department instance with a valid id.")
# руководитель — сразу ок
if getattr(dept, "head_id", None) == user.id:
return True
# основная проверка: активная связь + у роли есть нужный DepartmentPermission.code
return EmployeeDepartment.objects.filter(
employee_id=user.id,
department_id=dept.id,
is_active=True,
role__scoped_permissions__code=perm_code, # <-- ВАЖНО: `code`, не `codename`
).exists()
def has_dept_perm(user: AbstractBaseUser, department_id: int, code: str) -> bool:
"""
Возвращает True, если пользователь имеет право `code` в рамках отдела `department_id`.
Логика:
1) staff/superuser → всегда True
2) пользователь является текущим руководителем отдела → True для любых «управленческих» кодов
3) пользователь состоит в отделе и его роль содержит `code` в scoped_permissions → True
4) иначе False
"""
if not getattr(user, "is_authenticated", False):
return False
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
# 2) Руководитель отдела имеет все управленческие права в своём отделе
if Department.objects.filter(id=department_id, head_id=user.id).exists():
return True
# 3) Проверка роли в отделе
link = (
EmployeeDepartment.objects.select_related("role")
.filter(employee_id=user.id, department_id=department_id, is_active=True)
.first()
)
if not link or not link.role_id:
return False
# роль хранит «скоуп-права» только для этого отдела
return link.role.scoped_permissions.filter(code=code).exists()
class AdminOrDeptAllowed(BasePermission):
"""Комбинированный пермишен: админский доступ ИЛИ доступ по праву на конкретный отдел.
Поведение:
1. Если пользователь superuser/staff — доступ всегда разрешён.
2. Если для текущего экшена требуется код права (например, DeptPerm.MANAGE_CALENDAR),
то доступ разрешается только при наличии у пользователя этого права на указанный отдел.
3. Идентификатор отдела извлекается из:
- URL kwargs: 'department_pk' → 'department_id';
- request.data: 'department' → 'department_id';
- query params: 'department' → 'department_id'.
На уровне has_object_permission отдел берётся из самого объекта:
- Department.id;
- obj.department_id / obj.dept_id;
- obj.department.id.
4. Если код права не задан (required_code/required_code_map пусты), то
для SAFE-методов (GET/HEAD/OPTIONS) доступ разрешён, если allow_safe_without_code=True (по умолчанию).
Для небезопасных методов в этом случае доступ запрещён.
Атрибуты класса:
required_code (str|None): Единый код права для всех экшенов. Если None, используется required_code_map.
required_code_map (dict[str,str]): Коды прав по имени action (create/update/partial_update/destroy/...).
allow_safe_without_code (bool): Разрешать ли SAFE-методы, когда код не задан. По умолчанию True.
Примеры:
class ManageCalendarPerm(AdminOrDeptAllowed):
required_code = DeptPerm.MANAGE_CALENDAR
class DeptEventsPerm(AdminOrDeptAllowed):
required_code_map = {
"create": DeptPerm.MANAGE_CALENDAR,
"update": DeptPerm.MANAGE_CALENDAR,
"partial_update": DeptPerm.MANAGE_CALENDAR,
"destroy": DeptPerm.MANAGE_CALENDAR,
}
"""
required_code: Optional[str] = None
required_code_map: Dict[str, str] = {}
allow_safe_without_code: bool = True
# -------- helpers --------
def get_required_code(self, request, view) -> Optional[str]:
"""Определяет требуемый код права для текущего экшена.
Сначала берёт из required_code_map по имени action (DRF: view.action),
затем fallback на required_code.
Args:
request: DRF Request (не используется в текущей логике).
view: DRF View/ViewSet; важно наличие атрибута 'action' для ViewSet.
Returns:
Optional[str]: Код права или None, если право для экшена не требуется.
"""
return (
self.required_code_map.get(getattr(view, "action", None))
or self.required_code
)
def _extract_dept_id_from_request(self, request, view) -> Optional[int]:
"""Пытается извлечь department_id из kwargs, body или query-параметров.
Порядок:
1) view.kwargs: department_pk, department_id
2) request.data: department, department_id
3) request.query_params: department, department_id
Args:
request: DRF Request
view: DRF View
Returns:
Optional[int]: Значение dept_id, если найдено и приведено к int, иначе None.
"""
for k in ("department_pk", "department_id"):
v = view.kwargs.get(k)
if v is not None:
return v
for k in ("department", "department_id"):
v = request.data.get(k)
if v is not None:
return v
for k in ("department", "department_id"):
v = request.query_params.get(k)
if v is not None:
return v
return None
def _extract_dept_id_from_obj(self, obj: Any) -> Optional[int]:
"""Извлекает department_id из объекта.
Порядок:
- Если obj — модель Department с полем id → вернуть id.
- Если у obj есть атрибуты department_id или dept_id → вернуть его.
- Если у obj есть атрибут department (FK) → взять department.id.
Args:
obj: Модель/объект detail-view.
Returns:
Optional[int]: department_id или None.
"""
if obj is None:
return None
if obj.__class__.__name__ == "Department" and hasattr(obj, "id"):
return obj.id
for attr in ("department_id", "dept_id"):
if hasattr(obj, attr):
return getattr(obj, attr)
dep = getattr(obj, "department", None)
if dep is not None:
return getattr(dep, "id", None)
return None
def _to_int_or_none(self, v) -> Optional[int]:
"""Аккуратное приведение к int.
Args:
v: Любое значение с возможностью строкового преобразования.
Returns:
Optional[int]: int или None при ошибке.
"""
try:
return int(str(v))
except Exception:
return None
# -------- main checks --------
def has_permission(self, request, view) -> bool:
"""См. докстринг класса. Если dept_id не найден, но это detail-запрос,
откладываем проверку до object-level (вернём True)."""
user = getattr(request, "user", None)
if not user or not user.is_authenticated:
return False
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
code = self.get_required_code(request, view)
if not code:
return (
(request.method in SAFE_METHODS)
if self.allow_safe_without_code
else False
)
dept_id = self._to_int_or_none(
self._extract_dept_id_from_request(request, view)
)
if dept_id is None:
# robust: считаем detail, если есть lookup в kwargs
lookup_kw = getattr(view, "lookup_url_kwarg", None) or getattr(
view, "lookup_field", "pk"
)
is_detail = getattr(view, "detail", None)
if is_detail is None:
is_detail = lookup_kw in getattr(view, "kwargs", {})
if is_detail:
# отложим проверку до has_object_permission
return True
return False
return has_dept_perm(user, dept_id, code)
def has_object_permission(self, request, view, obj) -> bool:
"""Проверка доступа к конкретному объекту (detail).
Правила:
- superuser/staff → True.
- Если код не требуется → SAFE-методы допускаются (если allow_safe_without_code=True).
- Иначе:
- Пытаемся извлечь department_id из объекта;
- если не удалось — пробуем извлечь из запроса (kwargs/data/query);
- если не нашли вообще → False;
- иначе → has_dept_perm(user, dept_id, code).
Returns:
bool: Разрешить/запретить доступ к объекту.
"""
user = getattr(request, "user", None)
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
code = self.get_required_code(request, view)
if not code:
return (
request.method in SAFE_METHODS
if self.allow_safe_without_code
else False
)
dept_id = self._to_int_or_none(self._extract_dept_id_from_obj(obj))
if dept_id is None:
dept_id = self._to_int_or_none(
self._extract_dept_id_from_request(request, view)
)
return has_dept_perm(request.user, dept_id, code)
class IsSelfOrStaff(BasePermission):
"""Доступ для staff/superuser или владельца объекта.
ВАЖНО:
• SAFE-методы НЕ дают автоматического допуска.
• Добавлена поддержка кейса "объект = сам пользователь" (self-update).
"""
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: 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 getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
# 1) *_id поля владельца
owner_id_attrs: Iterable[str] = getattr(
view, "owner_id_attrs", self.DEFAULT_OWNER_ID_ATTRS
)
for attr in owner_id_attrs:
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) == user.id:
return True
# 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]]:
"""Приводит конфигурацию кодов прав к списку строк.
Args:
value (Any): Значение из конфигурации прав:
- str — один код,
- Iterable[str] — несколько кодов,
- None — отсутствует.
Returns:
Optional[list[str]]: Список кодов или None.
Raises:
TypeError: Если найден нестроковый элемент в Iterable.
"""
if value is None:
return None
if isinstance(value, str):
v = value.strip()
return [v] if v else None
if isinstance(value, Iterable):
out: list[str] = []
for v in value:
if not isinstance(v, str):
raise TypeError("Коды прав должны быть строками.")
v = v.strip()
if v:
out.append(v)
return out or None
return None
@staticmethod
def _explicit_perm_codes(view) -> Optional[list[str]]:
"""Достаёт из вью «явные» коды прав (ИЛИ между кодами).
Поддерживаемые формы на ViewSet:
- required_perm_code: str | Iterable[str]
- required_perms_by_action[action]: str | Iterable[str]
- required_perms_by_action[action]: {method: str|Iterable[str]}
где method сравнивается без учёта регистра, возможны '*', 'ANY',
а HEAD трактуется как GET.
Args:
view: DRF ViewSet с полями `action`, `request` (опционально).
Returns:
Optional[list[str]]: Список кодов прав или None.
"""
# 1) Единый код(ы) на всю вью
codes = AdminOrActionOrModelPerms._normalize_codes(
getattr(view, "required_perm_code", None)
)
if codes:
return codes
# 2) Карта по экшенам (+ по методам)
mapping = getattr(view, "required_perms_by_action", None) or {}
if not isinstance(mapping, dict):
return None
action = getattr(view, "action", None)
if not action or action not in mapping:
return None
value = mapping[action]
# 2.1) Прямо строка/итерируемое — сразу нормализуем
codes = AdminOrActionOrModelPerms._normalize_codes(value)
if codes is not None:
return codes
# 2.2) Вложенная карта по методам
if isinstance(value, dict):
method = getattr(getattr(view, "request", None), "method", None)
if not method:
return None
m = method.upper()
keys = (m, m.lower())
if m == "HEAD":
keys += ("GET", "get")
keys += ("*", "ANY")
for k in keys:
if k in value:
return AdminOrActionOrModelPerms._normalize_codes(value[k])
return None
# Иное — игнорируем
return None
def has_permission(self, request: Request, view) -> bool:
"""Проверяет доступ на уровне запроса.
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):
return False
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
# 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)
# 2) Всё остальное — родителю (в т.ч. SAFE_METHODS → view_<model>)
return super().has_permission(request, view)
__all__ = [
"MANAGE_PERM",
"CHANGE_HEAD_PERM",
"ASSIGN_ROLE_PERM",
"IsSelfOrStaff",
"AdminOrActionOrModelPerms",
"user_is_dept_head",
"user_has_dept_perm",
]

View File

@ -0,0 +1,538 @@
# backend\api\v1\permissions.py
from __future__ import annotations
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 (
SAFE_METHODS,
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:
return bool(
user and user.is_authenticated and dept.head_id == getattr(user, "id", None)
)
def user_is_staffish(user) -> bool:
return bool(user and user.is_authenticated and (user.is_superuser or user.is_staff))
def user_has_dept_perm(user, dept: Department, perm_code: str) -> bool:
"""Проверяет, есть ли у пользователя право `perm_code` в указанном отделе.
Правила:
- Неаутентифицированным → False.
- staff/superuser → True.
- Руководитель отдела → True.
- Иначе: существует активная связь EmployeeDepartment, и у роли есть
DepartmentPermission с нужным code.
Args:
user (User): Текущий пользователь.
dept (Department): Отдел.
perm_code (str): Код департаментного права (см. DeptPerm.CHOICES).
Returns:
bool: True, если доступ разрешён.
Raises:
ValueError: Если `dept` не задан или не имеет id.
"""
if not (user and user.is_authenticated):
return False
# staff/superuser — сразу ок
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
if not isinstance(dept, Department) or getattr(dept, "id", None) is None:
raise ValueError("Argument `dept` must be Department instance with a valid id.")
# руководитель — сразу ок
if getattr(dept, "head_id", None) == user.id:
return True
# основная проверка: активная связь + у роли есть нужный DepartmentPermission.code
return EmployeeDepartment.objects.filter(
employee_id=user.id,
department_id=dept.id,
is_active=True,
role__scoped_permissions__code=perm_code, # <-- ВАЖНО: `code`, не `codename`
).exists()
def has_dept_perm(user: AbstractBaseUser, department_id: int, code: str) -> bool:
"""
Возвращает True, если пользователь имеет право `code` в рамках отдела `department_id`.
Логика:
1) staff/superuser → всегда True
2) пользователь является текущим руководителем отдела → True для любых «управленческих» кодов
3) пользователь состоит в отделе и его роль содержит `code` в scoped_permissions → True
4) иначе False
"""
if not getattr(user, "is_authenticated", False):
return False
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
# 2) Руководитель отдела имеет все управленческие права в своём отделе
if Department.objects.filter(id=department_id, head_id=user.id).exists():
return True
# 3) Проверка роли в отделе
link = (
EmployeeDepartment.objects.select_related("role")
.filter(employee_id=user.id, department_id=department_id, is_active=True)
.first()
)
if not link or not link.role_id:
return False
# роль хранит «скоуп-права» только для этого отдела
return link.role.scoped_permissions.filter(code=code).exists()
class AdminOrDeptAllowed(BasePermission):
"""Комбинированный пермишен: админский доступ ИЛИ доступ по праву на конкретный отдел.
Поведение:
1. Если пользователь superuser/staff — доступ всегда разрешён.
2. Если для текущего экшена требуется код права (например, DeptPerm.MANAGE_CALENDAR),
то доступ разрешается только при наличии у пользователя этого права на указанный отдел.
3. Идентификатор отдела извлекается из:
- URL kwargs: 'department_pk' → 'department_id';
- request.data: 'department' → 'department_id';
- query params: 'department' → 'department_id'.
На уровне has_object_permission отдел берётся из самого объекта:
- Department.id;
- obj.department_id / obj.dept_id;
- obj.department.id.
4. Если код права не задан (required_code/required_code_map пусты), то
для SAFE-методов (GET/HEAD/OPTIONS) доступ разрешён, если allow_safe_without_code=True (по умолчанию).
Для небезопасных методов в этом случае доступ запрещён.
Атрибуты класса:
required_code (str|None): Единый код права для всех экшенов. Если None, используется required_code_map.
required_code_map (dict[str,str]): Коды прав по имени action (create/update/partial_update/destroy/...).
allow_safe_without_code (bool): Разрешать ли SAFE-методы, когда код не задан. По умолчанию True.
Примеры:
class ManageCalendarPerm(AdminOrDeptAllowed):
required_code = DeptPerm.MANAGE_CALENDAR
class DeptEventsPerm(AdminOrDeptAllowed):
required_code_map = {
"create": DeptPerm.MANAGE_CALENDAR,
"update": DeptPerm.MANAGE_CALENDAR,
"partial_update": DeptPerm.MANAGE_CALENDAR,
"destroy": DeptPerm.MANAGE_CALENDAR,
}
"""
required_code: Optional[str] = None
required_code_map: Dict[str, str] = {}
allow_safe_without_code: bool = True
# -------- helpers --------
def get_required_code(self, request, view) -> Optional[str]:
"""Определяет требуемый код права для текущего экшена.
Сначала берёт из required_code_map по имени action (DRF: view.action),
затем fallback на required_code.
Args:
request: DRF Request (не используется в текущей логике).
view: DRF View/ViewSet; важно наличие атрибута 'action' для ViewSet.
Returns:
Optional[str]: Код права или None, если право для экшена не требуется.
"""
return (
self.required_code_map.get(getattr(view, "action", None))
or self.required_code
)
def _extract_dept_id_from_request(self, request, view) -> Optional[int]:
"""Пытается извлечь department_id из kwargs, body или query-параметров.
Порядок:
1) view.kwargs: department_pk, department_id
2) request.data: department, department_id
3) request.query_params: department, department_id
Args:
request: DRF Request
view: DRF View
Returns:
Optional[int]: Значение dept_id, если найдено и приведено к int, иначе None.
"""
for k in ("department_pk", "department_id"):
v = view.kwargs.get(k)
if v is not None:
return v
for k in ("department", "department_id"):
v = request.data.get(k)
if v is not None:
return v
for k in ("department", "department_id"):
v = request.query_params.get(k)
if v is not None:
return v
return None
def _extract_dept_id_from_obj(self, obj: Any) -> Optional[int]:
"""Извлекает department_id из объекта.
Порядок:
- Если obj — модель Department с полем id → вернуть id.
- Если у obj есть атрибуты department_id или dept_id → вернуть его.
- Если у obj есть атрибут department (FK) → взять department.id.
Args:
obj: Модель/объект detail-view.
Returns:
Optional[int]: department_id или None.
"""
if obj is None:
return None
if obj.__class__.__name__ == "Department" and hasattr(obj, "id"):
return obj.id
for attr in ("department_id", "dept_id"):
if hasattr(obj, attr):
return getattr(obj, attr)
dep = getattr(obj, "department", None)
if dep is not None:
return getattr(dep, "id", None)
return None
def _to_int_or_none(self, v) -> Optional[int]:
"""Аккуратное приведение к int.
Args:
v: Любое значение с возможностью строкового преобразования.
Returns:
Optional[int]: int или None при ошибке.
"""
try:
return int(str(v))
except Exception:
return None
# -------- main checks --------
def has_permission(self, request, view) -> bool:
"""См. докстринг класса. Если dept_id не найден, но это detail-запрос,
откладываем проверку до object-level (вернём True)."""
user = getattr(request, "user", None)
if not user or not user.is_authenticated:
return False
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
code = self.get_required_code(request, view)
if not code:
return (
(request.method in SAFE_METHODS)
if self.allow_safe_without_code
else False
)
dept_id = self._to_int_or_none(
self._extract_dept_id_from_request(request, view)
)
if dept_id is None:
# robust: считаем detail, если есть lookup в kwargs
lookup_kw = getattr(view, "lookup_url_kwarg", None) or getattr(
view, "lookup_field", "pk"
)
is_detail = getattr(view, "detail", None)
if is_detail is None:
is_detail = lookup_kw in getattr(view, "kwargs", {})
if is_detail:
# отложим проверку до has_object_permission
return True
return False
return has_dept_perm(user, dept_id, code)
def has_object_permission(self, request, view, obj) -> bool:
"""Проверка доступа к конкретному объекту (detail).
Правила:
- superuser/staff → True.
- Если код не требуется → SAFE-методы допускаются (если allow_safe_without_code=True).
- Иначе:
- Пытаемся извлечь department_id из объекта;
- если не удалось — пробуем извлечь из запроса (kwargs/data/query);
- если не нашли вообще → False;
- иначе → has_dept_perm(user, dept_id, code).
Returns:
bool: Разрешить/запретить доступ к объекту.
"""
user = getattr(request, "user", None)
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
code = self.get_required_code(request, view)
if not code:
return (
request.method in SAFE_METHODS
if self.allow_safe_without_code
else False
)
dept_id = self._to_int_or_none(self._extract_dept_id_from_obj(obj))
if dept_id is None:
dept_id = self._to_int_or_none(
self._extract_dept_id_from_request(request, view)
)
return has_dept_perm(request.user, dept_id, code)
class IsSelfOrStaff(BasePermission):
"""Доступ для staff/superuser или владельца объекта.
ВАЖНО:
• SAFE-методы НЕ дают автоматического допуска.
• Добавлена поддержка кейса "объект = сам пользователь" (self-update).
"""
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: 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 getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
# 1) *_id поля владельца
owner_id_attrs: Iterable[str] = getattr(view, "owner_id_attrs", self.DEFAULT_OWNER_ID_ATTRS)
for attr in owner_id_attrs:
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) == user.id:
return True
# 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]]:
"""Приводит конфигурацию кодов прав к списку строк.
Args:
value (Any): Значение из конфигурации прав:
- str — один код,
- Iterable[str] — несколько кодов,
- None — отсутствует.
Returns:
Optional[list[str]]: Список кодов или None.
Raises:
TypeError: Если найден нестроковый элемент в Iterable.
"""
if value is None:
return None
if isinstance(value, str):
v = value.strip()
return [v] if v else None
if isinstance(value, Iterable):
out: list[str] = []
for v in value:
if not isinstance(v, str):
raise TypeError("Коды прав должны быть строками.")
v = v.strip()
if v:
out.append(v)
return out or None
return None
@staticmethod
def _explicit_perm_codes(view) -> Optional[list[str]]:
"""Достаёт из вью «явные» коды прав (ИЛИ между кодами).
Поддерживаемые формы на ViewSet:
- required_perm_code: str | Iterable[str]
- required_perms_by_action[action]: str | Iterable[str]
- required_perms_by_action[action]: {method: str|Iterable[str]}
где method сравнивается без учёта регистра, возможны '*', 'ANY',
а HEAD трактуется как GET.
Args:
view: DRF ViewSet с полями `action`, `request` (опционально).
Returns:
Optional[list[str]]: Список кодов прав или None.
"""
# 1) Единый код(ы) на всю вью
codes = AdminOrActionOrModelPerms._normalize_codes(
getattr(view, "required_perm_code", None)
)
if codes:
return codes
# 2) Карта по экшенам (+ по методам)
mapping = getattr(view, "required_perms_by_action", None) or {}
if not isinstance(mapping, dict):
return None
action = getattr(view, "action", None)
if not action or action not in mapping:
return None
value = mapping[action]
# 2.1) Прямо строка/итерируемое — сразу нормализуем
codes = AdminOrActionOrModelPerms._normalize_codes(value)
if codes is not None:
return codes
# 2.2) Вложенная карта по методам
if isinstance(value, dict):
method = getattr(getattr(view, "request", None), "method", None)
if not method:
return None
m = method.upper()
keys = (m, m.lower())
if m == "HEAD":
keys += ("GET", "get")
keys += ("*", "ANY")
for k in keys:
if k in value:
return AdminOrActionOrModelPerms._normalize_codes(value[k])
return None
# Иное — игнорируем
return None
def has_permission(self, request: Request, view) -> bool:
"""Проверяет доступ на уровне запроса.
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):
return False
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
# 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)
# 2) Всё остальное — родителю (в т.ч. SAFE_METHODS → view_<model>)
return super().has_permission(request, view)
__all__ = [
"MANAGE_PERM",
"CHANGE_HEAD_PERM",
"ASSIGN_ROLE_PERM",
"IsSelfOrStaff",
"AdminOrActionOrModelPerms",
"user_is_dept_head",
"user_has_dept_perm",
]

View File

@ -0,0 +1,538 @@
# backend\api\v1\permissions.py
from __future__ import annotations
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 (
SAFE_METHODS,
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:
return bool(
user and user.is_authenticated and dept.head_id == getattr(user, "id", None)
)
def user_is_staffish(user) -> bool:
return bool(user and user.is_authenticated and (user.is_superuser or user.is_staff))
def user_has_dept_perm(user, dept: Department, perm_code: str) -> bool:
"""Проверяет, есть ли у пользователя право `perm_code` в указанном отделе.
Правила:
- Неаутентифицированным → False.
- staff/superuser → True.
- Руководитель отдела → True.
- Иначе: существует активная связь EmployeeDepartment, и у роли есть
DepartmentPermission с нужным code.
Args:
user (User): Текущий пользователь.
dept (Department): Отдел.
perm_code (str): Код департаментного права (см. DeptPerm.CHOICES).
Returns:
bool: True, если доступ разрешён.
Raises:
ValueError: Если `dept` не задан или не имеет id.
"""
if not (user and user.is_authenticated):
return False
# staff/superuser — сразу ок
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
if not isinstance(dept, Department) or getattr(dept, "id", None) is None:
raise ValueError("Argument `dept` must be Department instance with a valid id.")
# руководитель — сразу ок
if getattr(dept, "head_id", None) == user.id:
return True
# основная проверка: активная связь + у роли есть нужный DepartmentPermission.code
return EmployeeDepartment.objects.filter(
employee_id=user.id,
department_id=dept.id,
is_active=True,
role__scoped_permissions__code=perm_code, # <-- ВАЖНО: `code`, не `codename`
).exists()
def has_dept_perm(user: AbstractBaseUser, department_id: int, code: str) -> bool:
"""
Возвращает True, если пользователь имеет право `code` в рамках отдела `department_id`.
Логика:
1) staff/superuser → всегда True
2) пользователь является текущим руководителем отдела → True для любых «управленческих» кодов
3) пользователь состоит в отделе и его роль содержит `code` в scoped_permissions → True
4) иначе False
"""
if not getattr(user, "is_authenticated", False):
return False
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
# 2) Руководитель отдела имеет все управленческие права в своём отделе
if Department.objects.filter(id=department_id, head_id=user.id).exists():
return True
# 3) Проверка роли в отделе
link = (
EmployeeDepartment.objects.select_related("role")
.filter(employee_id=user.id, department_id=department_id, is_active=True)
.first()
)
if not link or not link.role_id:
return False
# роль хранит «скоуп-права» только для этого отдела
return link.role.scoped_permissions.filter(code=code).exists()
class AdminOrDeptAllowed(BasePermission):
"""Комбинированный пермишен: админский доступ ИЛИ доступ по праву на конкретный отдел.
Поведение:
1. Если пользователь superuser/staff — доступ всегда разрешён.
2. Если для текущего экшена требуется код права (например, DeptPerm.MANAGE_CALENDAR),
то доступ разрешается только при наличии у пользователя этого права на указанный отдел.
3. Идентификатор отдела извлекается из:
- URL kwargs: 'department_pk' → 'department_id';
- request.data: 'department' → 'department_id';
- query params: 'department' → 'department_id'.
На уровне has_object_permission отдел берётся из самого объекта:
- Department.id;
- obj.department_id / obj.dept_id;
- obj.department.id.
4. Если код права не задан (required_code/required_code_map пусты), то
для SAFE-методов (GET/HEAD/OPTIONS) доступ разрешён, если allow_safe_without_code=True (по умолчанию).
Для небезопасных методов в этом случае доступ запрещён.
Атрибуты класса:
required_code (str|None): Единый код права для всех экшенов. Если None, используется required_code_map.
required_code_map (dict[str,str]): Коды прав по имени action (create/update/partial_update/destroy/...).
allow_safe_without_code (bool): Разрешать ли SAFE-методы, когда код не задан. По умолчанию True.
Примеры:
class ManageCalendarPerm(AdminOrDeptAllowed):
required_code = DeptPerm.MANAGE_CALENDAR
class DeptEventsPerm(AdminOrDeptAllowed):
required_code_map = {
"create": DeptPerm.MANAGE_CALENDAR,
"update": DeptPerm.MANAGE_CALENDAR,
"partial_update": DeptPerm.MANAGE_CALENDAR,
"destroy": DeptPerm.MANAGE_CALENDAR,
}
"""
required_code: Optional[str] = None
required_code_map: Dict[str, str] = {}
allow_safe_without_code: bool = True
# -------- helpers --------
def get_required_code(self, request, view) -> Optional[str]:
"""Определяет требуемый код права для текущего экшена.
Сначала берёт из required_code_map по имени action (DRF: view.action),
затем fallback на required_code.
Args:
request: DRF Request (не используется в текущей логике).
view: DRF View/ViewSet; важно наличие атрибута 'action' для ViewSet.
Returns:
Optional[str]: Код права или None, если право для экшена не требуется.
"""
return (
self.required_code_map.get(getattr(view, "action", None))
or self.required_code
)
def _extract_dept_id_from_request(self, request, view) -> Optional[int]:
"""Пытается извлечь department_id из kwargs, body или query-параметров.
Порядок:
1) view.kwargs: department_pk, department_id
2) request.data: department, department_id
3) request.query_params: department, department_id
Args:
request: DRF Request
view: DRF View
Returns:
Optional[int]: Значение dept_id, если найдено и приведено к int, иначе None.
"""
for k in ("department_pk", "department_id"):
v = view.kwargs.get(k)
if v is not None:
return v
for k in ("department", "department_id"):
v = request.data.get(k)
if v is not None:
return v
for k in ("department", "department_id"):
v = request.query_params.get(k)
if v is not None:
return v
return None
def _extract_dept_id_from_obj(self, obj: Any) -> Optional[int]:
"""Извлекает department_id из объекта.
Порядок:
- Если obj — модель Department с полем id → вернуть id.
- Если у obj есть атрибуты department_id или dept_id → вернуть его.
- Если у obj есть атрибут department (FK) → взять department.id.
Args:
obj: Модель/объект detail-view.
Returns:
Optional[int]: department_id или None.
"""
if obj is None:
return None
if obj.__class__.__name__ == "Department" and hasattr(obj, "id"):
return obj.id
for attr in ("department_id", "dept_id"):
if hasattr(obj, attr):
return getattr(obj, attr)
dep = getattr(obj, "department", None)
if dep is not None:
return getattr(dep, "id", None)
return None
def _to_int_or_none(self, v) -> Optional[int]:
"""Аккуратное приведение к int.
Args:
v: Любое значение с возможностью строкового преобразования.
Returns:
Optional[int]: int или None при ошибке.
"""
try:
return int(str(v))
except Exception:
return None
# -------- main checks --------
def has_permission(self, request, view) -> bool:
"""См. докстринг класса. Если dept_id не найден, но это detail-запрос,
откладываем проверку до object-level (вернём True)."""
user = getattr(request, "user", None)
if not user or not user.is_authenticated:
return False
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
code = self.get_required_code(request, view)
if not code:
return (
(request.method in SAFE_METHODS)
if self.allow_safe_without_code
else False
)
dept_id = self._to_int_or_none(
self._extract_dept_id_from_request(request, view)
)
if dept_id is None:
# robust: считаем detail, если есть lookup в kwargs
lookup_kw = getattr(view, "lookup_url_kwarg", None) or getattr(
view, "lookup_field", "pk"
)
is_detail = getattr(view, "detail", None)
if is_detail is None:
is_detail = lookup_kw in getattr(view, "kwargs", {})
if is_detail:
# отложим проверку до has_object_permission
return True
return False
return has_dept_perm(user, dept_id, code)
def has_object_permission(self, request, view, obj) -> bool:
"""Проверка доступа к конкретному объекту (detail).
Правила:
- superuser/staff → True.
- Если код не требуется → SAFE-методы допускаются (если allow_safe_without_code=True).
- Иначе:
- Пытаемся извлечь department_id из объекта;
- если не удалось — пробуем извлечь из запроса (kwargs/data/query);
- если не нашли вообще → False;
- иначе → has_dept_perm(user, dept_id, code).
Returns:
bool: Разрешить/запретить доступ к объекту.
"""
user = getattr(request, "user", None)
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
code = self.get_required_code(request, view)
if not code:
return (
request.method in SAFE_METHODS
if self.allow_safe_without_code
else False
)
dept_id = self._to_int_or_none(self._extract_dept_id_from_obj(obj))
if dept_id is None:
dept_id = self._to_int_or_none(
self._extract_dept_id_from_request(request, view)
)
return has_dept_perm(request.user, dept_id, code)
class IsSelfOrStaff(BasePermission):
"""Доступ для staff/superuser или владельца объекта.
ВАЖНО:
• SAFE-методы НЕ дают автоматического допуска.
• Добавлена поддержка кейса "объект = сам пользователь" (self-update).
"""
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: 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 getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
# 1) *_id поля владельца
owner_id_attrs: Iterable[str] = getattr(view, "owner_id_attrs", self.DEFAULT_OWNER_ID_ATTRS)
for attr in owner_id_attrs:
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) == user.id:
return True
# 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]]:
"""Приводит конфигурацию кодов прав к списку строк.
Args:
value (Any): Значение из конфигурации прав:
- str — один код,
- Iterable[str] — несколько кодов,
- None — отсутствует.
Returns:
Optional[list[str]]: Список кодов или None.
Raises:
TypeError: Если найден нестроковый элемент в Iterable.
"""
if value is None:
return None
if isinstance(value, str):
v = value.strip()
return [v] if v else None
if isinstance(value, Iterable):
out: list[str] = []
for v in value:
if not isinstance(v, str):
raise TypeError("Коды прав должны быть строками.")
v = v.strip()
if v:
out.append(v)
return out or None
return None
@staticmethod
def _explicit_perm_codes(view) -> Optional[list[str]]:
"""Достаёт из вью «явные» коды прав (ИЛИ между кодами).
Поддерживаемые формы на ViewSet:
- required_perm_code: str | Iterable[str]
- required_perms_by_action[action]: str | Iterable[str]
- required_perms_by_action[action]: {method: str|Iterable[str]}
где method сравнивается без учёта регистра, возможны '*', 'ANY',
а HEAD трактуется как GET.
Args:
view: DRF ViewSet с полями `action`, `request` (опционально).
Returns:
Optional[list[str]]: Список кодов прав или None.
"""
# 1) Единый код(ы) на всю вью
codes = AdminOrActionOrModelPerms._normalize_codes(
getattr(view, "required_perm_code", None)
)
if codes:
return codes
# 2) Карта по экшенам (+ по методам)
mapping = getattr(view, "required_perms_by_action", None) or {}
if not isinstance(mapping, dict):
return None
action = getattr(view, "action", None)
if not action or action not in mapping:
return None
value = mapping[action]
# 2.1) Прямо строка/итерируемое — сразу нормализуем
codes = AdminOrActionOrModelPerms._normalize_codes(value)
if codes is not None:
return codes
# 2.2) Вложенная карта по методам
if isinstance(value, dict):
method = getattr(getattr(view, "request", None), "method", None)
if not method:
return None
m = method.upper()
keys = (m, m.lower())
if m == "HEAD":
keys += ("GET", "get")
keys += ("*", "ANY")
for k in keys:
if k in value:
return AdminOrActionOrModelPerms._normalize_codes(value[k])
return None
# Иное — игнорируем
return None
def has_permission(self, request: Request, view) -> bool:
"""Проверяет доступ на уровне запроса.
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):
return False
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
# 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)
# 2) Всё остальное — родителю (в т.ч. SAFE_METHODS → view_<model>)
return super().has_permission(request, view)
__all__ = [
"MANAGE_PERM",
"CHANGE_HEAD_PERM",
"ASSIGN_ROLE_PERM",
"IsSelfOrStaff",
"AdminOrActionOrModelPerms",
"user_is_dept_head",
"user_has_dept_perm",
]

View File

@ -0,0 +1,538 @@
# backend\api\v1\permissions.py
from __future__ import annotations
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 (
SAFE_METHODS,
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:
return bool(
user and user.is_authenticated and dept.head_id == getattr(user, "id", None)
)
def user_is_staffish(user) -> bool:
return bool(user and user.is_authenticated and (user.is_superuser or user.is_staff))
def user_has_dept_perm(user, dept: Department, perm_code: str) -> bool:
"""Проверяет, есть ли у пользователя право `perm_code` в указанном отделе.
Правила:
- Неаутентифицированным → False.
- staff/superuser → True.
- Руководитель отдела → True.
- Иначе: существует активная связь EmployeeDepartment, и у роли есть
DepartmentPermission с нужным code.
Args:
user (User): Текущий пользователь.
dept (Department): Отдел.
perm_code (str): Код департаментного права (см. DeptPerm.CHOICES).
Returns:
bool: True, если доступ разрешён.
Raises:
ValueError: Если `dept` не задан или не имеет id.
"""
if not (user and user.is_authenticated):
return False
# staff/superuser — сразу ок
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
if not isinstance(dept, Department) or getattr(dept, "id", None) is None:
raise ValueError("Argument `dept` must be Department instance with a valid id.")
# руководитель — сразу ок
if getattr(dept, "head_id", None) == user.id:
return True
# основная проверка: активная связь + у роли есть нужный DepartmentPermission.code
return EmployeeDepartment.objects.filter(
employee_id=user.id,
department_id=dept.id,
is_active=True,
role__scoped_permissions__code=perm_code, # <-- ВАЖНО: `code`, не `codename`
).exists()
def has_dept_perm(user: AbstractBaseUser, department_id: int, code: str) -> bool:
"""
Возвращает True, если пользователь имеет право `code` в рамках отдела `department_id`.
Логика:
1) staff/superuser → всегда True
2) пользователь является текущим руководителем отдела → True для любых «управленческих» кодов
3) пользователь состоит в отделе и его роль содержит `code` в scoped_permissions → True
4) иначе False
"""
if not getattr(user, "is_authenticated", False):
return False
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
# 2) Руководитель отдела имеет все управленческие права в своём отделе
if Department.objects.filter(id=department_id, head_id=user.id).exists():
return True
# 3) Проверка роли в отделе
link = (
EmployeeDepartment.objects.select_related("role")
.filter(employee_id=user.id, department_id=department_id, is_active=True)
.first()
)
if not link or not link.role_id:
return False
# роль хранит «скоуп-права» только для этого отдела
return link.role.scoped_permissions.filter(code=code).exists()
class AdminOrDeptAllowed(BasePermission):
"""Комбинированный пермишен: админский доступ ИЛИ доступ по праву на конкретный отдел.
Поведение:
1. Если пользователь superuser/staff — доступ всегда разрешён.
2. Если для текущего экшена требуется код права (например, DeptPerm.MANAGE_CALENDAR),
то доступ разрешается только при наличии у пользователя этого права на указанный отдел.
3. Идентификатор отдела извлекается из:
- URL kwargs: 'department_pk' → 'department_id';
- request.data: 'department' → 'department_id';
- query params: 'department' → 'department_id'.
На уровне has_object_permission отдел берётся из самого объекта:
- Department.id;
- obj.department_id / obj.dept_id;
- obj.department.id.
4. Если код права не задан (required_code/required_code_map пусты), то
для SAFE-методов (GET/HEAD/OPTIONS) доступ разрешён, если allow_safe_without_code=True (по умолчанию).
Для небезопасных методов в этом случае доступ запрещён.
Атрибуты класса:
required_code (str|None): Единый код права для всех экшенов. Если None, используется required_code_map.
required_code_map (dict[str,str]): Коды прав по имени action (create/update/partial_update/destroy/...).
allow_safe_without_code (bool): Разрешать ли SAFE-методы, когда код не задан. По умолчанию True.
Примеры:
class ManageCalendarPerm(AdminOrDeptAllowed):
required_code = DeptPerm.MANAGE_CALENDAR
class DeptEventsPerm(AdminOrDeptAllowed):
required_code_map = {
"create": DeptPerm.MANAGE_CALENDAR,
"update": DeptPerm.MANAGE_CALENDAR,
"partial_update": DeptPerm.MANAGE_CALENDAR,
"destroy": DeptPerm.MANAGE_CALENDAR,
}
"""
required_code: Optional[str] = None
required_code_map: Dict[str, str] = {}
allow_safe_without_code: bool = True
# -------- helpers --------
def get_required_code(self, request, view) -> Optional[str]:
"""Определяет требуемый код права для текущего экшена.
Сначала берёт из required_code_map по имени action (DRF: view.action),
затем fallback на required_code.
Args:
request: DRF Request (не используется в текущей логике).
view: DRF View/ViewSet; важно наличие атрибута 'action' для ViewSet.
Returns:
Optional[str]: Код права или None, если право для экшена не требуется.
"""
return (
self.required_code_map.get(getattr(view, "action", None))
or self.required_code
)
def _extract_dept_id_from_request(self, request, view) -> Optional[int]:
"""Пытается извлечь department_id из kwargs, body или query-параметров.
Порядок:
1) view.kwargs: department_pk, department_id
2) request.data: department, department_id
3) request.query_params: department, department_id
Args:
request: DRF Request
view: DRF View
Returns:
Optional[int]: Значение dept_id, если найдено и приведено к int, иначе None.
"""
for k in ("department_pk", "department_id"):
v = view.kwargs.get(k)
if v is not None:
return v
for k in ("department", "department_id"):
v = request.data.get(k)
if v is not None:
return v
for k in ("department", "department_id"):
v = request.query_params.get(k)
if v is not None:
return v
return None
def _extract_dept_id_from_obj(self, obj: Any) -> Optional[int]:
"""Извлекает department_id из объекта.
Порядок:
- Если obj — модель Department с полем id → вернуть id.
- Если у obj есть атрибуты department_id или dept_id → вернуть его.
- Если у obj есть атрибут department (FK) → взять department.id.
Args:
obj: Модель/объект detail-view.
Returns:
Optional[int]: department_id или None.
"""
if obj is None:
return None
if obj.__class__.__name__ == "Department" and hasattr(obj, "id"):
return obj.id
for attr in ("department_id", "dept_id"):
if hasattr(obj, attr):
return getattr(obj, attr)
dep = getattr(obj, "department", None)
if dep is not None:
return getattr(dep, "id", None)
return None
def _to_int_or_none(self, v) -> Optional[int]:
"""Аккуратное приведение к int.
Args:
v: Любое значение с возможностью строкового преобразования.
Returns:
Optional[int]: int или None при ошибке.
"""
try:
return int(str(v))
except Exception:
return None
# -------- main checks --------
def has_permission(self, request, view) -> bool:
"""См. докстринг класса. Если dept_id не найден, но это detail-запрос,
откладываем проверку до object-level (вернём True)."""
user = getattr(request, "user", None)
if not user or not user.is_authenticated:
return False
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
code = self.get_required_code(request, view)
if not code:
return (
(request.method in SAFE_METHODS)
if self.allow_safe_without_code
else False
)
dept_id = self._to_int_or_none(
self._extract_dept_id_from_request(request, view)
)
if dept_id is None:
# robust: считаем detail, если есть lookup в kwargs
lookup_kw = getattr(view, "lookup_url_kwarg", None) or getattr(
view, "lookup_field", "pk"
)
is_detail = getattr(view, "detail", None)
if is_detail is None:
is_detail = lookup_kw in getattr(view, "kwargs", {})
if is_detail:
# отложим проверку до has_object_permission
return True
return False
return has_dept_perm(user, dept_id, code)
def has_object_permission(self, request, view, obj) -> bool:
"""Проверка доступа к конкретному объекту (detail).
Правила:
- superuser/staff → True.
- Если код не требуется → SAFE-методы допускаются (если allow_safe_without_code=True).
- Иначе:
- Пытаемся извлечь department_id из объекта;
- если не удалось — пробуем извлечь из запроса (kwargs/data/query);
- если не нашли вообще → False;
- иначе → has_dept_perm(user, dept_id, code).
Returns:
bool: Разрешить/запретить доступ к объекту.
"""
user = getattr(request, "user", None)
if getattr(user, "is_superuser", False) or getattr(user, "is_staff", False):
return True
code = self.get_required_code(request, view)
if not code:
return (
request.method in SAFE_METHODS
if self.allow_safe_without_code
else False
)
dept_id = self._to_int_or_none(self._extract_dept_id_from_obj(obj))
if dept_id is None:
dept_id = self._to_int_or_none(
self._extract_dept_id_from_request(request, view)
)
return has_dept_perm(request.user, dept_id, code)
class IsSelfOrStaff(BasePermission):
"""Доступ для staff/superuser или владельца объекта.
ВАЖНО:
• SAFE-методы НЕ дают автоматического допуска.
• Добавлена поддержка кейса "объект = сам пользователь" (self-update).
"""
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: 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 getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
# 1) *_id поля владельца
owner_id_attrs: Iterable[str] = getattr(view, "owner_id_attrs", self.DEFAULT_OWNER_ID_ATTRS)
for attr in owner_id_attrs:
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) == user.id:
return True
# 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]]:
"""Приводит конфигурацию кодов прав к списку строк.
Args:
value (Any): Значение из конфигурации прав:
- str — один код,
- Iterable[str] — несколько кодов,
- None — отсутствует.
Returns:
Optional[list[str]]: Список кодов или None.
Raises:
TypeError: Если найден нестроковый элемент в Iterable.
"""
if value is None:
return None
if isinstance(value, str):
v = value.strip()
return [v] if v else None
if isinstance(value, Iterable):
out: list[str] = []
for v in value:
if not isinstance(v, str):
raise TypeError("Коды прав должны быть строками.")
v = v.strip()
if v:
out.append(v)
return out or None
return None
@staticmethod
def _explicit_perm_codes(view) -> Optional[list[str]]:
"""Достаёт из вью «явные» коды прав (ИЛИ между кодами).
Поддерживаемые формы на ViewSet:
- required_perm_code: str | Iterable[str]
- required_perms_by_action[action]: str | Iterable[str]
- required_perms_by_action[action]: {method: str|Iterable[str]}
где method сравнивается без учёта регистра, возможны '*', 'ANY',
а HEAD трактуется как GET.
Args:
view: DRF ViewSet с полями `action`, `request` (опционально).
Returns:
Optional[list[str]]: Список кодов прав или None.
"""
# 1) Единый код(ы) на всю вью
codes = AdminOrActionOrModelPerms._normalize_codes(
getattr(view, "required_perm_code", None)
)
if codes:
return codes
# 2) Карта по экшенам (+ по методам)
mapping = getattr(view, "required_perms_by_action", None) or {}
if not isinstance(mapping, dict):
return None
action = getattr(view, "action", None)
if not action or action not in mapping:
return None
value = mapping[action]
# 2.1) Прямо строка/итерируемое — сразу нормализуем
codes = AdminOrActionOrModelPerms._normalize_codes(value)
if codes is not None:
return codes
# 2.2) Вложенная карта по методам
if isinstance(value, dict):
method = getattr(getattr(view, "request", None), "method", None)
if not method:
return None
m = method.upper()
keys = (m, m.lower())
if m == "HEAD":
keys += ("GET", "get")
keys += ("*", "ANY")
for k in keys:
if k in value:
return AdminOrActionOrModelPerms._normalize_codes(value[k])
return None
# Иное — игнорируем
return None
def has_permission(self, request: Request, view) -> bool:
"""Проверяет доступ на уровне запроса.
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):
return False
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
# 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)
# 2) Всё остальное — родителю (в т.ч. SAFE_METHODS → view_<model>)
return super().has_permission(request, view)
__all__ = [
"MANAGE_PERM",
"CHANGE_HEAD_PERM",
"ASSIGN_ROLE_PERM",
"IsSelfOrStaff",
"AdminOrActionOrModelPerms",
"user_is_dept_head",
"user_has_dept_perm",
]

View File

@ -212,4 +212,4 @@ class DeptComments(AdminOrDeptAllowed):
return DeptPerm.VIEW_REQUESTCOMMENT
if request.method == "POST":
return DeptPerm.ADD_REQUESTCOMMENT
return None
return None

View File

@ -0,0 +1,219 @@
# api/v1/requests_app/permissions.py
from __future__ import annotations
from typing import Any, Optional
from api.v1.permissions import AdminOrDeptAllowed
from rest_framework.permissions import BasePermission
from rest_framework.request import Request
from employees.constants import DeptPerm
class CommentsPermission(BasePermission):
"""Доступ к экшену `comments` по *модельным* правам.
- GET/HEAD: `requests_app.view_requestcomment` ИЛИ staff.
- POST: `requests_app.add_requestcomment` ИЛИ staff.
- Владелец без прав — НЕТ.
"""
message = "Недостаточно прав для доступа к комментариям."
def _required_perm(self, request: Request) -> Optional[str]:
if request.method in {"GET", "HEAD"}:
return "requests_app.view_requestcomment"
if request.method == "POST":
return "requests_app.add_requestcomment"
return None
def has_permission(self, request: Request, view: Any) -> bool:
return bool(getattr(request.user, "is_authenticated", False))
def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool:
user = request.user
if getattr(user, "is_staff", False) or getattr(user, "is_superuser", False):
return True
code = self._required_perm(request)
return bool(code and user.has_perm(code))
class NotFinalOrStaff(BasePermission):
"""Запрещает удаление финальной заявки для не-админов.
- Разрешает любые методы, кроме DELETE, без ограничений.
- Для DELETE: разрешено, если заявка не финальная, либо пользователь — staff.
Атрибуты:
message (str): Текст ошибки для 403 Forbidden.
Notes:
Пермишены не выбрасывают исключения — они возвращают False,
что приводит к 403 Forbidden.
"""
message = "Финальная заявка не может быть удалена."
def has_object_permission(self, request: Request, view: Any, obj: Any) -> bool:
"""Объектная проверка.
Args:
request (Request): Текущий запрос DRF.
view (Any): Экземпляр вьюсета.
obj (Any): Экземпляр удаляемого объекта (заявки).
Returns:
bool: True, если доступ разрешён; иначе False.
"""
if request.method != "DELETE":
return True
is_final = bool(getattr(obj, "is_final", False))
is_staff = bool(getattr(request.user, "is_staff", False))
return (not is_final) or is_staff
class DeptViewRequest(AdminOrDeptAllowed):
"""Просмотр заявки по департаментным правам.
Доступ разрешается, если пользователь — staff/superuser ИЛИ имеет
право отдела ``view_request`` именно для отдела данной заявки.
Применяется только к detail-экшену `retrieve`.
Attributes:
allow_safe_without_code (bool): Запрещает фоллбек пропуска SAFE-методов
без явного кода права (защита от «случайных 200»).
required_code_map (dict[str, str]): Ограничиваем право только на `retrieve`.
required_code (Optional[str]): Явно отключаем общий fallback.
"""
# SAFE-методы не должны проходить «на шару»
allow_safe_without_code: bool = False
# Явно ограничиваем сферу действия права
required_code_map: dict[str, str] = {
"retrieve": "view_request",
}
# Не даём базовому классу брать «любой экшен = view_request»
required_code: Optional[str] = None
def get_required_code(self, request: Request, view: Any) -> Optional[str]:
"""Возвращает код департаментного права только для поддерживаемых action.
Args:
request (Request): DRF-запрос.
view (Any): Вью/вьюсет; важен `view.action`.
Returns:
Optional[str]: 'view_request' для `retrieve`, иначе None (запрет).
"""
return self.required_code_map.get(getattr(view, "action", None))
class DeptCanProcess(AdminOrDeptAllowed):
"""Пермишен для обработки заявок по департаментным правам.
Доступ разрешается, если пользователь — staff/superuser ИЛИ имеет
право отдела ``can_process_requests`` для отдела заявки.
Применяется на детальных экшенах workflow:
- approve
- reject
Атрибуты:
allow_safe_without_code (bool): Запретить «фоллбек» на SAFE-методы
без явного кода права (по умолчанию в базовом классе мог быть True).
required_code_map (dict[str, str]): Маппинг action → код права.
required_code (str): Резервный код права, если action не сматчился.
Raises:
Ничего напрямую не выбрасывает — при отсутствии прав вернёт False,
DRF ответит 403 Forbidden.
"""
# SAFE-методы пусть тоже требуют явного кода (на всякий случай)
allow_safe_without_code: bool = False
# Явно описываем нужные action-ы workflow
required_code_map: dict[str, str] = {
"approve": "can_process_requests",
"reject": "can_process_requests",
}
# Резервный вариант — если вдруг action неизвестен,
# базовый класс возьмёт этот код.
required_code: str = "can_process_requests"
class DeptChangeRequest(AdminOrDeptAllowed):
"""Правка заявок по департаментным правам.
Доступ разрешается, если пользователь — staff/superuser ИЛИ имеет
право отдела ``change_request`` для отдела конкретной заявки.
Применяется ТОЛЬКО к экшенам редактирования:
- update (PUT)
- partial_update (PATCH)
- destroy (DELETE)
ВНИМАНИЕ: для workflow-экшенов (approve/reject) используйте DeptCanProcess.
Attributes:
allow_safe_without_code (bool): запрещает фоллбек пропуска SAFE-методов
без явного кода права (защита от случайных «200»).
"""
# Не пропускаем SAFE-методы без явного кода (страховка)
allow_safe_without_code: bool = False
def get_required_code(self, request: Request, view: Any) -> Optional[str]:
"""Возвращает код департаментного права для экшенов редактирования.
Args:
request (Request): DRF-запрос.
view (Any): Вью/вьюсет (важен view.action).
Returns:
Optional[str]: 'change_request' для update/partial_update/destroy, иначе None.
Notes:
Возврат None означает «кода нет» → базовый класс вернёт False (403),
так мы исключаем approve/reject и прочие экшены из этого пермишена.
"""
if getattr(view, "action", None) in {"update", "partial_update", "destroy"}:
return "change_request"
return None
class DeptComments(AdminOrDeptAllowed):
"""Комментарии: staff ИЛИ департаментное право.
- GET/HEAD → 'view_requestcomment'
- POST → 'add_requestcomment'
"""
# Чтобы SAFE-методы не проходили «без кода» при ошибке конфигурации:
allow_safe_without_code = False
def get_required_code(self, request: Request, view: Any) -> Optional[str]:
"""Возвращает код департаментного права для экшена `comments`.
Args:
request (Request): Текущий запрос DRF.
view (Any): Вью/вьюсет (не используется).
Returns:
Optional[str]: Код права для текущего HTTP-метода или None для неподдерживаемых методов.
Raises:
None: Исключения не выбрасываются — при отсутствии кода доступ будет запрещён базовым классом.
"""
if request.method in {"GET", "HEAD"}:
return DeptPerm.VIEW_REQUESTCOMMENT
if request.method == "POST":
return DeptPerm.ADD_REQUESTCOMMENT
<<<<<<< HEAD
return None
=======
return None
>>>>>>> origin/feat/ldap-writeback

View File

@ -0,0 +1,323 @@
from __future__ import annotations
from typing import Any, List, Optional # было: Any, Dict, List, Type
from django.db.models import Q, QuerySet
from django.shortcuts import get_object_or_404 # раскомментируйте импорт
from employees.models import EmployeeDepartment
from requests_app.models import Request as EmployeeRequest
from requests_app.models import RequestComment
<<<<<<< HEAD
from requests_app.enums import RequestStatus
=======
>>>>>>> origin/feat/ldap-writeback
# from django.shortcuts import get_object_or_404 # <- не используется
from rest_framework import status, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import (
NotAuthenticated,
PermissionDenied,
ValidationError,
)
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.permissions import SAFE_METHODS, BasePermission, IsAuthenticated
from rest_framework.request import Request as DRFRequest
from rest_framework.response import Response
from rest_framework.serializers import (
BaseSerializer,
) # <- для типизации get_serializer_class
from ..permissions import AdminOrActionOrModelPerms, AdminOrDeptAllowed, IsSelfOrStaff
from .permissions import (
CommentsPermission,
DeptCanProcess,
DeptChangeRequest,
DeptComments,
DeptViewRequest,
NotFinalOrStaff,
)
from .serializers import (
RequestCommentSerializer,
RequestReadSerializer,
RequestWriteSerializer,
)
class RequestViewSet(viewsets.ModelViewSet):
"""ViewSet для заявок сотрудников.
Админы могут всё. Пользователи с модельными правами действуют по правам.
Без прав аутентифицированные пользователи работают только со своими заявками.
Поддерживаемые экшены:
- POST /requests/{id}/approve/
- POST /requests/{id}/reject/
- POST /requests/{id}/cancel/
- GET/POST /requests/{id}/comments/
Raises:
PermissionDenied: При нарушении правил доступа.
"""
queryset = EmployeeRequest.objects.select_related(
"employee", "approver", "department"
).all()
parser_classes = (MultiPartParser, FormParser, JSONParser)
pagination_class = None
required_perms_by_action = {
"comments": {
"GET": "requests_app.view_requestcomment",
"HEAD": "requests_app.view_requestcomment",
"POST": "requests_app.add_requestcomment",
},
"reject": ["requests_app.can_process_requests", "requests_app.change_request"],
"approve": ["requests_app.can_process_requests", "requests_app.change_request"],
}
def get_permissions(self) -> list[BasePermission]:
"""Подбирает пермишены под текущий action.
- comments: владелец ИЛИ staff ИЛИ явные модельные права из required_perms_by_action.
- approve/reject: аутентификация + явные права из required_perms_by_action.
- cancel: владелец ИЛИ staff.
- остальное (CRUD): владелец ИЛИ staff ИЛИ модельные права.
"""
if self.action == "comments":
return [(CommentsPermission | DeptComments)()]
if self.action in {"approve", "reject"}:
return [(AdminOrActionOrModelPerms | DeptCanProcess)()]
if self.action == "cancel":
return [IsSelfOrStaff()]
if self.action == "retrieve":
return [(IsSelfOrStaff | DeptViewRequest | AdminOrActionOrModelPerms)()]
if self.action == "destroy":
<<<<<<< HEAD
return [
(IsSelfOrStaff | AdminOrActionOrModelPerms | DeptChangeRequest)(),
NotFinalOrStaff(),
]
=======
return [(IsSelfOrStaff | AdminOrActionOrModelPerms | DeptChangeRequest)(), NotFinalOrStaff()]
>>>>>>> origin/feat/ldap-writeback
return [(IsSelfOrStaff | AdminOrActionOrModelPerms | DeptChangeRequest)()]
def get_serializer_class(self) -> type[BaseSerializer]:
"""Возвращает сериализатор в зависимости от action."""
if self.action in {"list", "retrieve"}:
return RequestReadSerializer
return RequestWriteSerializer
# ---------- Queryset с учётом прав ----------
def _can_view_all(self, user) -> bool:
"""Проверяет, может ли пользователь видеть все заявки."""
return bool(
user.is_staff
or user.has_perm("requests_app.can_view_all_requests")
or user.has_perm("requests_app.view_request")
)
def get_queryset(self) -> QuerySet[EmployeeRequest]:
"""Список заявок с учётом прав:
- staff/глобальные видят всё (или только свои при ?view=mine),
- остальные: свои + по отделам, где есть право 'view_request'.
"""
qs = super().get_queryset()
user = self.request.user
params = self.request.query_params
mine_raw = (params.get("mine") or "").lower()
want_mine = (params.get("view") == "mine") or (
mine_raw in {"1", "true", "yes", "on"}
)
if user.is_staff or self._can_view_all(user):
if want_mine:
qs = qs.filter(employee_id=user.id)
else:
dept_ids_qs = (
EmployeeDepartment.objects.filter(
employee_id=user.id,
is_active=True,
role__scoped_permissions__code="view_request",
)
.values_list("department_id", flat=True)
.distinct()
)
scope = Q(employee_id=user.id) | Q(department_id__in=dept_ids_qs)
qs = qs.filter(scope)
t = (params.get("type") or "").strip()
s = (params.get("status") or "").strip()
if t:
qs = qs.filter(type=t)
if s:
qs = qs.filter(status=s)
return qs
# --- ВАЖНО: не ловить 404 на detail-экшенах из-за урезанного get_queryset() ---
def get_object(self) -> EmployeeRequest:
"""Возвращает объект заявки для detail-экшенов.
Для экшенов {'approve', 'reject', 'comments', 'cancel', 'retrieve'}:
- достаём объект **в обход** урезанного get_queryset() по PK,
- прогоняем через object-level пермишены (check_object_permissions),
чтобы при отсутствии прав получить **403 Forbidden**, а не 404.
Returns:
EmployeeRequest: Объект заявки.
Raises:
Http404: Если объект с таким PK не существует вообще.
PermissionDenied: Если object-level пермишены не пройдены.
"""
<<<<<<< HEAD
if getattr(self, "action", None) in {
"approve",
"reject",
"comments",
"cancel",
"retrieve",
}:
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
lookup_value = self.kwargs[lookup_url_kwarg]
base = EmployeeRequest.objects.select_related("employee", "approver", "department")
obj = get_object_or_404(base, **{self.lookup_field: lookup_value})
=======
if getattr(self, "action", None) in {"approve", "reject", "comments", "cancel", "retrieve"}:
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
lookup_value = self.kwargs[lookup_url_kwarg]
obj = get_object_or_404(EmployeeRequest, **{self.lookup_field: lookup_value})
>>>>>>> origin/feat/ldap-writeback
self.check_object_permissions(self.request, obj)
return obj
return super().get_object()
# ---------- CRUD ----------
def perform_create(self, serializer: RequestWriteSerializer) -> None:
"""Создание: для не-админа принудительно ставим себя как автора."""
user = self.request.user
if not user or not user.is_authenticated:
raise NotAuthenticated("Authentication required")
is_power = user.is_staff or user.has_perm("requests_app.add_request")
extra = {"employee": user} if not is_power else {}
save_as = (self.request.query_params.get("save_as") or "").strip().lower()
if save_as == "draft":
extra["status"] = RequestStatus.DRAFT # ✅ обычному пользователю поле в payload всё равно бы «очистили»
serializer.save(**extra)
def perform_update(self, serializer: RequestWriteSerializer) -> None:
"""Обновление: владельцу разрешаем до финального статуса.
Raises:
PermissionDenied: Если заявка финальная и нет прав, либо правка чужой заявки без прав.
"""
obj = self.get_object()
user = self.request.user
if obj.is_final and not (
user.is_staff or user.has_perm("requests_app.change_request")
):
raise PermissionDenied("Финальная заявка недоступна для правок.")
if (obj.employee_id != user.id) and not (
user.is_staff or user.has_perm("requests_app.change_request")
):
raise PermissionDenied("Нельзя редактировать чужую заявку.")
extra: dict[str, Any] = {}
save_as = (self.request.query_params.get("save_as") or "").strip().lower()
if save_as == "draft":
extra["status"] = RequestStatus.DRAFT
elif save_as == "submit" and obj.status == RequestStatus.DRAFT:
# ⬅️ явная подача «на рассмотрение» из черновика
extra["status"] = RequestStatus.PENDING
serializer.save(**extra)
# ---------- Комментарии ----------
@action(detail=True, methods=["get", "post"])
def comments(self, request: DRFRequest, pk: int | str | None = None) -> Response:
"""Список/создание комментариев для заявки.
GET: список комментариев (staff, владелец или право view_requestcomment).
POST: создание {"text": "..."} (staff, владелец или право add_requestcomment).
"""
req_obj = self.get_object()
if request.method in {"GET", "HEAD"}:
qs = RequestComment.objects.filter(request=req_obj).select_related("author")
ser = RequestCommentSerializer(
qs, many=True, context=self.get_serializer_context()
)
return Response(ser.data)
ser = RequestCommentSerializer(
data=request.data, context=self.get_serializer_context()
)
ser.is_valid(raise_exception=True)
ser.save(request=req_obj, author=request.user)
return Response(ser.data, status=status.HTTP_201_CREATED)
# ---------- Экшены статусов (только бизнес-валидация) ----------
@action(detail=True, methods=["post"])
def approve(self, request: DRFRequest, pk: int | str | None = None) -> Response:
"""Одобряет заявку.
Raises:
ValidationError: Если заявка уже финальная.
"""
obj = self.get_object() # права проверит AdminOrActionOrModelPerms
if getattr(obj, "is_final", False):
raise ValidationError("Заявка уже в финальном статусе.")
obj.approve(by_user=request.user)
return Response(
RequestReadSerializer(obj, context=self.get_serializer_context()).data
)
@action(detail=True, methods=["post"])
def reject(self, request: DRFRequest, pk: int | str | None = None) -> Response:
"""Отклоняет заявку.
Raises:
ValidationError: Если заявка уже финальная.
"""
obj = self.get_object()
if getattr(obj, "is_final", False):
raise ValidationError("Заявка уже в финальном статусе.")
obj.reject(by_user=request.user)
return Response(
RequestReadSerializer(obj, context=self.get_serializer_context()).data
)
@action(detail=True, methods=["post"])
def cancel(self, request: DRFRequest, pk: int | str | None = None) -> Response:
"""Отменяет заявку (владелец или админ).
Raises:
PermissionDenied: Если заявка финальная (для не admin).
"""
obj = self.get_object()
if getattr(obj, "is_final", False) and not request.user.is_staff:
raise PermissionDenied("Финальная заявка уже не может быть отменена.")
obj.cancel()
return Response(
RequestReadSerializer(obj, context=self.get_serializer_context()).data
)
def create(self, request, *args, **kwargs):
"""Создание заявки: валидируем write-сериализатором, отвечаем read-сериализатором."""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
self.perform_create(serializer)
instance = serializer.instance
read = RequestReadSerializer(instance, context=self.get_serializer_context())
headers = self.get_success_headers(read.data)
return Response(read.data, status=status.HTTP_201_CREATED, headers=headers)

View File

@ -46,4 +46,4 @@ class DeptPerm:
(MANAGE_FEED, "Редактировать публикации отдела"),
(VIEW_REQUESTCOMMENT, "Просмотр комментариев по заявлениям"),
(ADD_REQUESTCOMMENT, "Добавление коментариев по заявлениям"),
)
)

View File

@ -0,0 +1,53 @@
# backend\employees\constants.py
GENDER_CHOICES = (
(1, "Мужской"),
(2, "Женский"),
(0, "Не указан"),
)
ACTION_HIRED = "hired"
ACTION_DISMISSED = "dismissed"
ACTION_ON_LEAVE = "on_leave"
ACTION_RETURNED_FROM_LEAVE = "returned_from_leave"
ACTION_ON_MATERNITY = "on_maternity"
ACTION_RETURNED_FROM_MATERNITY = "returned_from_maternity"
ACTION_TRANSFERRED = "transferred"
ACTION_REHIRED = "rehired"
ACTION_CHOICES = [
(ACTION_HIRED, "Принят"),
(ACTION_DISMISSED, "Уволен"),
(ACTION_ON_LEAVE, "В отпуске"),
(ACTION_RETURNED_FROM_LEAVE, "Вернулся из отпуска"),
(ACTION_ON_MATERNITY, "В декрете"),
(ACTION_RETURNED_FROM_MATERNITY, "Вернулся из декрета"),
(ACTION_TRANSFERRED, "Переведен"),
(ACTION_REHIRED, "Восстановлен"),
]
class DeptPerm:
MANAGE = "manage_department"
CHANGE_HEAD = "change_department_head"
ASSIGN_ROLE = "assign_department_role"
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, "Управлять отделом"),
(CHANGE_HEAD, "Назначать руководителя"),
(ASSIGN_ROLE, "Назначать роли участникам"),
(MANAGE_CALENDAR, "Управлять календарём отдела"),
(CREATE_POST, "Публиковать новости на странице отдела"),
(MANAGE_FEED, "Редактировать публикации отдела"),
(VIEW_REQUESTCOMMENT, "Просмотр комментариев по заявлениям"),
(ADD_REQUESTCOMMENT, "Добавление коментариев по заявлениям"),
<<<<<<< HEAD
)
=======
)
>>>>>>> origin/feat/ldap-writeback

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

@ -0,0 +1,33 @@
# Generated by Django 5.2.4 on 2025-09-22 11:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('employees', '0026_alter_departmentpermission_code'),
]
operations = [
migrations.AddField(
model_name='employee',
name='last_ldap_modify_ts',
field=models.CharField(blank=True, max_length=64, null=True, verbose_name='LDAP modify ts'),
),
migrations.AddField(
model_name='employee',
name='last_ldap_sync_at',
field=models.DateTimeField(blank=True, null=True, verbose_name='Последняя успешная запись в LDAP'),
),
migrations.AddField(
model_name='employee',
name='ldap_dn',
field=models.CharField(blank=True, help_text='Текущий DN записи в каталоге (для отладки/трассировки).', max_length=512, null=True, verbose_name='LDAP DN'),
),
migrations.AddField(
model_name='employee',
name='ldap_guid',
field=models.CharField(blank=True, help_text='Устойчивый ID из LDAP (entryUUID/objectGUID в hex/строке).', max_length=64, null=True, unique=True, verbose_name='LDAP GUID'),
),
]

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:
@ -98,7 +106,318 @@ class EmailOrPhoneBackend(ModelBackend):
return None
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: 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
# Подготовим данные для фильтра: email/сырой телефон/E.164
raw = str(identifier).strip()
is_email = _looks_like_email(raw)
e164 = _normalize_phone(raw) # ваша утилита; может вернуть None
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
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
# Собираем расширенный фильтр:
# из шаблона (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,9 @@ DEFAULT_FROM_EMAIL = os.getenv(
ASGI_APPLICATION = "eusrr_backend.asgi.application"
AUTHENTICATION_BACKENDS = [
# "eusrr_backend.auth_backends.LDAP3Backend",
"eusrr_backend.auth_backends.EmailOrPhoneBackend",
"eusrr_backend.auth_backends.SuperuserOnlyBackend",
"eusrr_backend.auth_backends.PositionRoleBackend",
"django.contrib.auth.backends.ModelBackend",
]
@ -257,6 +259,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 +298,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