diff --git a/backend/api/v1/.permissions.py.swn b/backend/api/v1/.permissions.py.swn new file mode 100644 index 00000000..61002b47 Binary files /dev/null and b/backend/api/v1/.permissions.py.swn differ diff --git a/backend/api/v1/.permissions.py.swo b/backend/api/v1/.permissions.py.swo new file mode 100644 index 00000000..4814fbf7 Binary files /dev/null and b/backend/api/v1/.permissions.py.swo differ diff --git a/backend/api/v1/.permissions_BASE_1250.py.swp b/backend/api/v1/.permissions_BASE_1250.py.swp new file mode 100644 index 00000000..b3605aa0 Binary files /dev/null and b/backend/api/v1/.permissions_BASE_1250.py.swp differ diff --git a/backend/api/v1/.permissions_BASE_1541.py.swp b/backend/api/v1/.permissions_BASE_1541.py.swp new file mode 100644 index 00000000..ce946ba5 Binary files /dev/null and b/backend/api/v1/.permissions_BASE_1541.py.swp differ diff --git a/backend/api/v1/.permissions_BASE_959.py.swp b/backend/api/v1/.permissions_BASE_959.py.swp new file mode 100644 index 00000000..2be712df Binary files /dev/null and b/backend/api/v1/.permissions_BASE_959.py.swp differ diff --git a/backend/api/v1/.permissions_LOCAL_1250.py.swp b/backend/api/v1/.permissions_LOCAL_1250.py.swp new file mode 100644 index 00000000..655d254b Binary files /dev/null and b/backend/api/v1/.permissions_LOCAL_1250.py.swp differ diff --git a/backend/api/v1/.permissions_LOCAL_1541.py.swp b/backend/api/v1/.permissions_LOCAL_1541.py.swp new file mode 100644 index 00000000..9851f604 Binary files /dev/null and b/backend/api/v1/.permissions_LOCAL_1541.py.swp differ diff --git a/backend/api/v1/.permissions_LOCAL_959.py.swp b/backend/api/v1/.permissions_LOCAL_959.py.swp new file mode 100644 index 00000000..3cb53922 Binary files /dev/null and b/backend/api/v1/.permissions_LOCAL_959.py.swp differ diff --git a/backend/api/v1/.permissions_REMOTE_1250.py.swp b/backend/api/v1/.permissions_REMOTE_1250.py.swp new file mode 100644 index 00000000..ea567b6a Binary files /dev/null and b/backend/api/v1/.permissions_REMOTE_1250.py.swp differ diff --git a/backend/api/v1/.permissions_REMOTE_1541.py.swp b/backend/api/v1/.permissions_REMOTE_1541.py.swp new file mode 100644 index 00000000..eccb4667 Binary files /dev/null and b/backend/api/v1/.permissions_REMOTE_1541.py.swp differ diff --git a/backend/api/v1/.permissions_REMOTE_959.py.swp b/backend/api/v1/.permissions_REMOTE_959.py.swp new file mode 100644 index 00000000..5e1f51ee Binary files /dev/null and b/backend/api/v1/.permissions_REMOTE_959.py.swp differ diff --git a/backend/api/v1/employees/serializers.py b/backend/api/v1/employees/serializers.py index 94339b78..69b7bd5f 100644 --- a/backend/api/v1/employees/serializers.py +++ b/backend/api/v1/employees/serializers.py @@ -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 diff --git a/backend/api/v1/employees/views.py b/backend/api/v1/employees/views.py index d3f4ded6..5fc7e315 100644 --- a/backend/api/v1/employees/views.py +++ b/backend/api/v1/employees/views.py @@ -1613,4 +1613,4 @@ class GroupViewSet(viewsets.ModelViewSet): "permission_ids": list(grp.permissions.values_list("id", flat=True)), }, status=200, - ) + ) \ No newline at end of file diff --git a/backend/api/v1/permissions.py b/backend/api/v1/permissions.py index 2dba5eca..296f0e0b 100644 --- a/backend/api/v1/permissions.py +++ b/backend/api/v1/permissions.py @@ -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" diff --git a/backend/api/v1/permissions.py.orig b/backend/api/v1/permissions.py.orig new file mode 100644 index 00000000..54418594 --- /dev/null +++ b/backend/api/v1/permissions.py.orig @@ -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_` + - для небезопасных — коды из 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_) + 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", +] diff --git a/backend/api/v1/permissions_BACKUP_1250.py b/backend/api/v1/permissions_BACKUP_1250.py new file mode 100644 index 00000000..54418594 --- /dev/null +++ b/backend/api/v1/permissions_BACKUP_1250.py @@ -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_` + - для небезопасных — коды из 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_) + 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", +] diff --git a/backend/api/v1/permissions_BACKUP_959.py b/backend/api/v1/permissions_BACKUP_959.py new file mode 100644 index 00000000..54418594 --- /dev/null +++ b/backend/api/v1/permissions_BACKUP_959.py @@ -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_` + - для небезопасных — коды из 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_) + 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", +] diff --git a/backend/api/v1/permissions_BASE_1250.py b/backend/api/v1/permissions_BASE_1250.py new file mode 100644 index 00000000..2988b746 --- /dev/null +++ b/backend/api/v1/permissions_BASE_1250.py @@ -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_, остальные — по 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", +] diff --git a/backend/api/v1/permissions_BASE_1541.py b/backend/api/v1/permissions_BASE_1541.py new file mode 100644 index 00000000..2988b746 --- /dev/null +++ b/backend/api/v1/permissions_BASE_1541.py @@ -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_, остальные — по 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", +] diff --git a/backend/api/v1/permissions_BASE_959.py b/backend/api/v1/permissions_BASE_959.py new file mode 100644 index 00000000..2988b746 --- /dev/null +++ b/backend/api/v1/permissions_BASE_959.py @@ -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_, остальные — по 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", +] diff --git a/backend/api/v1/permissions_LOCAL_1250.py b/backend/api/v1/permissions_LOCAL_1250.py new file mode 100644 index 00000000..2dba5eca --- /dev/null +++ b/backend/api/v1/permissions_LOCAL_1250.py @@ -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_` + - для небезопасных — коды из 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_) + 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", +] diff --git a/backend/api/v1/permissions_LOCAL_1541.py b/backend/api/v1/permissions_LOCAL_1541.py new file mode 100644 index 00000000..2dba5eca --- /dev/null +++ b/backend/api/v1/permissions_LOCAL_1541.py @@ -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_` + - для небезопасных — коды из 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_) + 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", +] diff --git a/backend/api/v1/permissions_LOCAL_959.py b/backend/api/v1/permissions_LOCAL_959.py new file mode 100644 index 00000000..2dba5eca --- /dev/null +++ b/backend/api/v1/permissions_LOCAL_959.py @@ -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_` + - для небезопасных — коды из 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_) + 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", +] diff --git a/backend/api/v1/permissions_REMOTE_1250.py b/backend/api/v1/permissions_REMOTE_1250.py new file mode 100644 index 00000000..10c9acc6 --- /dev/null +++ b/backend/api/v1/permissions_REMOTE_1250.py @@ -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_` + - для небезопасных — коды из 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_) + 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", +] diff --git a/backend/api/v1/permissions_REMOTE_1541.py b/backend/api/v1/permissions_REMOTE_1541.py new file mode 100644 index 00000000..10c9acc6 --- /dev/null +++ b/backend/api/v1/permissions_REMOTE_1541.py @@ -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_` + - для небезопасных — коды из 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_) + 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", +] diff --git a/backend/api/v1/permissions_REMOTE_959.py b/backend/api/v1/permissions_REMOTE_959.py new file mode 100644 index 00000000..10c9acc6 --- /dev/null +++ b/backend/api/v1/permissions_REMOTE_959.py @@ -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_` + - для небезопасных — коды из 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_) + 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", +] diff --git a/backend/api/v1/requests_app/permissions.py b/backend/api/v1/requests_app/permissions.py index adae1cac..b32b8a72 100644 --- a/backend/api/v1/requests_app/permissions.py +++ b/backend/api/v1/requests_app/permissions.py @@ -212,4 +212,4 @@ class DeptComments(AdminOrDeptAllowed): return DeptPerm.VIEW_REQUESTCOMMENT if request.method == "POST": return DeptPerm.ADD_REQUESTCOMMENT - return None \ No newline at end of file + return None diff --git a/backend/api/v1/requests_app/permissions.py.orig b/backend/api/v1/requests_app/permissions.py.orig new file mode 100644 index 00000000..a4b64052 --- /dev/null +++ b/backend/api/v1/requests_app/permissions.py.orig @@ -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 diff --git a/backend/api/v1/requests_app/views.py.orig b/backend/api/v1/requests_app/views.py.orig new file mode 100644 index 00000000..fbea5c8a --- /dev/null +++ b/backend/api/v1/requests_app/views.py.orig @@ -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) diff --git a/backend/employees/constants.py b/backend/employees/constants.py index 3dc0bfb7..54f32b9e 100644 --- a/backend/employees/constants.py +++ b/backend/employees/constants.py @@ -46,4 +46,4 @@ class DeptPerm: (MANAGE_FEED, "Редактировать публикации отдела"), (VIEW_REQUESTCOMMENT, "Просмотр комментариев по заявлениям"), (ADD_REQUESTCOMMENT, "Добавление коментариев по заявлениям"), - ) \ No newline at end of file + ) diff --git a/backend/employees/constants.py.orig b/backend/employees/constants.py.orig new file mode 100644 index 00000000..73f01094 --- /dev/null +++ b/backend/employees/constants.py.orig @@ -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 diff --git a/backend/employees/management/commands/sync_ldap.py b/backend/employees/management/commands/sync_ldap.py new file mode 100644 index 00000000..f2f3800b --- /dev/null +++ b/backend/employees/management/commands/sync_ldap.py @@ -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 [] diff --git a/backend/employees/migrations/0027_employee_last_ldap_modify_ts_and_more.py b/backend/employees/migrations/0027_employee_last_ldap_modify_ts_and_more.py new file mode 100644 index 00000000..e0320725 --- /dev/null +++ b/backend/employees/migrations/0027_employee_last_ldap_modify_ts_and_more.py @@ -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'), + ), + ] diff --git a/backend/employees/models.py b/backend/employees/models.py index d1226127..56a718ed 100644 --- a/backend/employees/models.py +++ b/backend/employees/models.py @@ -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( diff --git a/backend/eusrr_backend/auth_backends.py b/backend/eusrr_backend/auth_backends.py index f1e4ce73..43d11453 100644 --- a/backend/eusrr_backend/auth_backends.py +++ b/backend/eusrr_backend/auth_backends.py @@ -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: diff --git a/backend/eusrr_backend/settings.py b/backend/eusrr_backend/settings.py index 71fc9f2d..67ffa3db 100644 --- a/backend/eusrr_backend/settings.py +++ b/backend/eusrr_backend/settings.py @@ -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', # }, # }, -# } \ No newline at end of file +# } diff --git a/backend/integrations/__init__.py b/backend/integrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/integrations/ldap_writeback.py b/backend/integrations/ldap_writeback.py new file mode 100644 index 00000000..2f0bfe19 --- /dev/null +++ b/backend/integrations/ldap_writeback.py @@ -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())) diff --git a/backend/requirements.txt b/backend/requirements.txt index afdb65f5..a6d8fcec 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -29,3 +29,4 @@ channels==4.3.1 channels_redis==4.3.0 daphne==4.2.1 +ldap3==2.9.1