Merge feat/ldap-writeback into master (create merge/master-and-ldap-writeback)
This commit is contained in:
commit
5256347829
BIN
backend/api/v1/.permissions.py.swn
Normal file
BIN
backend/api/v1/.permissions.py.swn
Normal file
Binary file not shown.
BIN
backend/api/v1/.permissions.py.swo
Normal file
BIN
backend/api/v1/.permissions.py.swo
Normal file
Binary file not shown.
BIN
backend/api/v1/.permissions_BASE_1250.py.swp
Normal file
BIN
backend/api/v1/.permissions_BASE_1250.py.swp
Normal file
Binary file not shown.
BIN
backend/api/v1/.permissions_BASE_1541.py.swp
Normal file
BIN
backend/api/v1/.permissions_BASE_1541.py.swp
Normal file
Binary file not shown.
BIN
backend/api/v1/.permissions_BASE_959.py.swp
Normal file
BIN
backend/api/v1/.permissions_BASE_959.py.swp
Normal file
Binary file not shown.
BIN
backend/api/v1/.permissions_LOCAL_1250.py.swp
Normal file
BIN
backend/api/v1/.permissions_LOCAL_1250.py.swp
Normal file
Binary file not shown.
BIN
backend/api/v1/.permissions_LOCAL_1541.py.swp
Normal file
BIN
backend/api/v1/.permissions_LOCAL_1541.py.swp
Normal file
Binary file not shown.
BIN
backend/api/v1/.permissions_LOCAL_959.py.swp
Normal file
BIN
backend/api/v1/.permissions_LOCAL_959.py.swp
Normal file
Binary file not shown.
BIN
backend/api/v1/.permissions_REMOTE_1250.py.swp
Normal file
BIN
backend/api/v1/.permissions_REMOTE_1250.py.swp
Normal file
Binary file not shown.
BIN
backend/api/v1/.permissions_REMOTE_1541.py.swp
Normal file
BIN
backend/api/v1/.permissions_REMOTE_1541.py.swp
Normal file
Binary file not shown.
BIN
backend/api/v1/.permissions_REMOTE_959.py.swp
Normal file
BIN
backend/api/v1/.permissions_REMOTE_959.py.swp
Normal file
Binary file not shown.
@ -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
|
||||
|
||||
@ -1613,4 +1613,4 @@ class GroupViewSet(viewsets.ModelViewSet):
|
||||
"permission_ids": list(grp.permissions.values_list("id", flat=True)),
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
)
|
||||
@ -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"
|
||||
|
||||
570
backend/api/v1/permissions.py.orig
Normal file
570
backend/api/v1/permissions.py.orig
Normal 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",
|
||||
]
|
||||
570
backend/api/v1/permissions_BACKUP_1250.py
Normal file
570
backend/api/v1/permissions_BACKUP_1250.py
Normal 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",
|
||||
]
|
||||
570
backend/api/v1/permissions_BACKUP_959.py
Normal file
570
backend/api/v1/permissions_BACKUP_959.py
Normal 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",
|
||||
]
|
||||
477
backend/api/v1/permissions_BASE_1250.py
Normal file
477
backend/api/v1/permissions_BASE_1250.py
Normal 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",
|
||||
]
|
||||
477
backend/api/v1/permissions_BASE_1541.py
Normal file
477
backend/api/v1/permissions_BASE_1541.py
Normal 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",
|
||||
]
|
||||
477
backend/api/v1/permissions_BASE_959.py
Normal file
477
backend/api/v1/permissions_BASE_959.py
Normal 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",
|
||||
]
|
||||
546
backend/api/v1/permissions_LOCAL_1250.py
Normal file
546
backend/api/v1/permissions_LOCAL_1250.py
Normal 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",
|
||||
]
|
||||
546
backend/api/v1/permissions_LOCAL_1541.py
Normal file
546
backend/api/v1/permissions_LOCAL_1541.py
Normal 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",
|
||||
]
|
||||
546
backend/api/v1/permissions_LOCAL_959.py
Normal file
546
backend/api/v1/permissions_LOCAL_959.py
Normal 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",
|
||||
]
|
||||
538
backend/api/v1/permissions_REMOTE_1250.py
Normal file
538
backend/api/v1/permissions_REMOTE_1250.py
Normal 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",
|
||||
]
|
||||
538
backend/api/v1/permissions_REMOTE_1541.py
Normal file
538
backend/api/v1/permissions_REMOTE_1541.py
Normal 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",
|
||||
]
|
||||
538
backend/api/v1/permissions_REMOTE_959.py
Normal file
538
backend/api/v1/permissions_REMOTE_959.py
Normal 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",
|
||||
]
|
||||
@ -212,4 +212,4 @@ class DeptComments(AdminOrDeptAllowed):
|
||||
return DeptPerm.VIEW_REQUESTCOMMENT
|
||||
if request.method == "POST":
|
||||
return DeptPerm.ADD_REQUESTCOMMENT
|
||||
return None
|
||||
return None
|
||||
|
||||
219
backend/api/v1/requests_app/permissions.py.orig
Normal file
219
backend/api/v1/requests_app/permissions.py.orig
Normal 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
|
||||
323
backend/api/v1/requests_app/views.py.orig
Normal file
323
backend/api/v1/requests_app/views.py.orig
Normal 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)
|
||||
@ -46,4 +46,4 @@ class DeptPerm:
|
||||
(MANAGE_FEED, "Редактировать публикации отдела"),
|
||||
(VIEW_REQUESTCOMMENT, "Просмотр комментариев по заявлениям"),
|
||||
(ADD_REQUESTCOMMENT, "Добавление коментариев по заявлениям"),
|
||||
)
|
||||
)
|
||||
|
||||
53
backend/employees/constants.py.orig
Normal file
53
backend/employees/constants.py.orig
Normal 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
|
||||
575
backend/employees/management/commands/sync_ldap.py
Normal file
575
backend/employees/management/commands/sync_ldap.py
Normal 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 []
|
||||
@ -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'),
|
||||
),
|
||||
]
|
||||
@ -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(
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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',
|
||||
# },
|
||||
# },
|
||||
# }
|
||||
# }
|
||||
|
||||
0
backend/integrations/__init__.py
Normal file
0
backend/integrations/__init__.py
Normal file
233
backend/integrations/ldap_writeback.py
Normal file
233
backend/integrations/ldap_writeback.py
Normal 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()))
|
||||
@ -29,3 +29,4 @@ channels==4.3.1
|
||||
channels_redis==4.3.0
|
||||
daphne==4.2.1
|
||||
|
||||
ldap3==2.9.1
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user