Ключевые изменения
* Перевели доступ к объектам отдела (посты) на **ролевую модель внутри отдела**:
* Используются `DepartmentRole.scoped_permissions` (M2M к `DepartmentPermission`).
* Проверка прав идёт по **коду** из `DeptPerm` (`CREATE_POST`/`MANAGE_FEED`), а не по `auth.Permission`.
* **Company-посты** теперь контролируются **модельными правами** (`add_post`, `change_post`, `delete_post`) или статусом staff/superuser.
* Убраны остатки старой логики `publish_company_post` / `publish_department_post` (как глобальные права).
Пермишены / утилиты
* `user_has_dept_perm(user, dept, code)`:
* true для staff/superuser и для руководителя отдела;
* иначе проверка активной связи `EmployeeDepartment(is_active=True)` и наличия `role__scoped_permissions__code=...`.
* ВАЖНО: используем поле **`code`**, не `codename`.
* `has_dept_perm(user, department_id, code)`:
* те же правила, оптимизирован поиск (select\_related для роли).
* `AdminOrDeptAllowed`:
* Корректно извлекает `department_id` из `kwargs`/`data`/`query_params` и из объекта (`obj.department_id/department.id`).
* Если код не задан — SAFE-методы разрешены (чтение), небезопасные запрещены.
* `AdminOrActionOrModelPerms`:
* staff/superuser — полный доступ;
* поддержка явного `required_perm_code`/`required_perms_by_action`;
* SAFE-методы требуют `view_<model>`, небезопасные — стандартная карта DjangoModelPermissions.
* `IsSelfOrStaff`/`IsSelfOrStaffOrModelPerms`:
* Владелец объекта (по `author_id`/`author` и др.) или staff/superuser может изменять/удалять;
* SAFE-методы — для авторизованных.
API: PostViewSet
* Обновлён `get_permissions()`:
* `list/retrieve/create/like/unlike` → `IsAuthenticated`;
* `update/partial_update/destroy` → `AdminOrDeptAllowed` с `required_code_map`:
* `update/partial_update/destroy` → `DeptPerm.MANAGE_FEED`.
* Бизнес-проверки:
* `perform_create`:
* `type=department` → разрешено staff/superuser, head отдела, либо роль с `DeptPerm.CREATE_POST`;
* `type=company` → нужны модельные права `add_post` (или staff).
* `author` проставляется на `request.user`.
* `perform_update/perform_destroy`:
* dept-посты: staff/superuser, head, либо роль с `DeptPerm.MANAGE_FEED`;
* company-посты: модельные права `change_post`/`delete_post` (или staff).
* Производительность:
* Аннотация `is_liked` (через `Exists`).
* Аннотация `last_comment_id` + батч-подтягивание последнего комментария в `list()`.
* Лайки:
* `like/unlike` — идемпотентные; счётчик корректируется атомарно.
API: CommentViewSet
* `permission_classes = [IsAuthenticated, (IsSelfOrStaff | AdminOrActionOrModelPerms)]`.
* `perform_create` всегда ставит `author=request.user`.
* Фильтры `?post=`/`?author=` и сортировка `created_at ASC`.
* Обновлён `IsSelfOrStaff` для чтения настроек владельца с вью (например, `owner_id_attrs=("author_id",)`).
Тесты
* Добавлен/переписан комплект для новой модели доступа (фид):
* **Аноним**: все эндпоинты → 401.
* **Обычный пользователь**:
* `GET posts/comments` → 200;
* `POST comment`, `like/unlike` → 201/200;
* `POST/PUT/PATCH/DELETE dept-post` → 403 (без роли/прав).
* **Роль отдела**:
* С `DeptPerm.CREATE_POST` — может создать пост **в своём отделе**; **в чужом** → 403.
* С `DeptPerm.MANAGE_FEED` — может редактировать/удалять **в своём отделе**.
* **Head отдела** — полный доступ к постам своего отдела.
* **Staff/superuser** — полный доступ; `pin/unpin` только staff/superuser.
* **Глобальные модельные права на company**:
* `add_post`/`change_post`/`delete_post` → CRUD **только** для company-постов; dept-посты по-прежнему 403.
* **Комментарии**:
* Создание — любой аутентифицированный;
* Изменение/удаление — автор или staff/superuser.
* **Лайки**:
* Идемпотентность `like/unlike`, корректная синхронизация `is_liked`.
* **Валидация**:
* `type=department` требует `department`;
* роль в одном отделе не даёт создавать в другом → 403.
* Тестовые хелперы:
* `_ensure_permission(model, codename, name)` — создаёт/возвращает `auth.Permission` для company-кейсов;
* `_ensure_dept_permission(code)` и `_give_dept_role_with_perms(...)` — для департаментных прав (через `DepartmentPermission`/`DepartmentRole.scoped_permissions`).
Fixed / багфиксы
* `FieldError: Unsupported lookup 'codename'…` — заменён на фильтр по `scoped_permissions__code`.
* `IntegrityError: NOT NULL constraint failed: feed_post.author_id` — в `perform_create` постов/комментариев author задаётся принудительно.
* Несоответствие тестам по правам чтения/изменения комментариев — исправлено через корректный пермишен владельца.
Файлы (основные)
* `api/v1/permissions.py`: `AdminOrDeptAllowed`, `AdminOrActionOrModelPerms`, `IsSelfOrStaff`(+вариант `IsSelfOrStaffOrModelPerms`), `user_has_dept_perm`, `has_dept_perm`.
* `api/v1/feed/views.py`: `PostViewSet`, `CommentViewSet` (правки прав, `perform_*`, аннотации, like/unlike).
* `tests/api/v1/feed/test_posts.py`, `tests/api/v1/feed/test_comments.py` (и/или `test_feed_api.py`): полный набор сценариев под новую политику.
* Хелперы тестов: функции создания отделов, прав и ролей, генераторы payload.
Итог
* Политика доступа фида приведена к спецификации: **dept-права — через роли внутри отдела**, **company — через модельные права**.
* Исправлены ошибки в проверках прав и авторстве, покрыто автотестами (CRUD, лайки, комменты, валидация, пермишены).
* Производительность чтения ленты улучшена (аннотации `is_liked`, `last_comment_id` без N+1).
468 lines
19 KiB
Python
468 lines
19 KiB
Python
# backend\api\v1\permissions.py
|
||
from __future__ import annotations
|
||
|
||
from typing import Any, Dict, 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)."""
|
||
print(
|
||
view.action,
|
||
view.kwargs,
|
||
self.get_required_code(request, view),
|
||
self._to_int_or_none(self._extract_dept_id_from_request(request, view)),
|
||
)
|
||
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):
|
||
"""Комбинированная политика доступа для документов.
|
||
|
||
Поведение:
|
||
- staff/superuser → полный доступ (всегда True).
|
||
- Если на вью задан `required_perm_code` или есть карта
|
||
`required_perms_by_action[action]`, проверяем её через `user.has_perm(...)`.
|
||
- Иначе:
|
||
* для SAFE-методов (`GET/HEAD/OPTIONS`) требуем `view_<model>`;
|
||
* для остальных методов используем стандартную карту `DjangoModelPermissions`
|
||
(`add/change/delete`).
|
||
|
||
Raises:
|
||
exceptions.MethodNotAllowed: если передан неизвестный HTTP-метод.
|
||
"""
|
||
|
||
def has_permission(self, request, view) -> bool:
|
||
"""Проверка доступа на уровне запроса.
|
||
|
||
Args:
|
||
request: DRF Request.
|
||
view: Текущий ViewSet.
|
||
|
||
Returns:
|
||
bool: Разрешён ли доступ.
|
||
"""
|
||
user = request.user
|
||
if not user or isinstance(user, AnonymousUser) or not user.is_authenticated:
|
||
return False
|
||
if user.is_superuser or user.is_staff:
|
||
return True
|
||
|
||
code = self._explicit_perm_code(view)
|
||
if code:
|
||
return user.has_perm(code)
|
||
|
||
# SAFE-методы → требуем view_<model>
|
||
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}"
|
||
)
|
||
|
||
# Остальные → стандартная логика DjangoModelPermissions
|
||
return super().has_permission(request, view)
|
||
|
||
def has_object_permission(self, request, view, obj) -> bool:
|
||
"""Проверка доступа к конкретному объекту.
|
||
|
||
Args:
|
||
request: DRF Request.
|
||
view: Текущий ViewSet.
|
||
obj: Объект модели.
|
||
|
||
Returns:
|
||
bool: Разрешён ли доступ.
|
||
"""
|
||
user = request.user
|
||
if user.is_superuser or user.is_staff:
|
||
return True
|
||
|
||
code = self._explicit_perm_code(view)
|
||
if code:
|
||
return user.has_perm(code)
|
||
|
||
if request.method in SAFE_METHODS:
|
||
meta = obj._meta
|
||
return user.has_perm(f"{meta.app_label}.view_{meta.model_name}")
|
||
|
||
# Для остальных методов полагаемся на проверку уровня запроса
|
||
return super().has_object_permission(request, view, obj)
|
||
|
||
@staticmethod
|
||
def _explicit_perm_code(view) -> Optional[str]:
|
||
"""Возвращает явный код права, заданный на уровне вью.
|
||
|
||
Args:
|
||
view: DRF ViewSet.
|
||
|
||
Returns:
|
||
Optional[str]: Код права или None.
|
||
"""
|
||
mapping = getattr(view, "required_perms_by_action", {}) or {}
|
||
action = getattr(view, "action", None)
|
||
return getattr(view, "required_perm_code", None) or mapping.get(action)
|
||
|
||
@staticmethod
|
||
def _model_from_view(view):
|
||
"""Извлекает модель из view.queryset.
|
||
|
||
Args:
|
||
view: DRF ViewSet.
|
||
|
||
Returns:
|
||
type: Класс модели.
|
||
|
||
Raises:
|
||
AssertionError: если у вью нет queryset/model (для ModelViewSet не ожидается).
|
||
"""
|
||
qs = getattr(view, "queryset", None)
|
||
assert qs is not None and hasattr(
|
||
qs, "model"
|
||
), "View must define `.queryset` with `.model`"
|
||
return qs.model
|
||
|
||
|
||
__all__ = [
|
||
"MANAGE_PERM",
|
||
"CHANGE_HEAD_PERM",
|
||
"ASSIGN_ROLE_PERM",
|
||
"IsSelfOrStaff",
|
||
"AdminOrActionOrModelPerms",
|
||
"user_is_dept_head",
|
||
"user_has_dept_perm",
|
||
]
|