Corp/backend/api/v1/permissions.py
Игорь Надеин 2fd3b1e562 feat(feed): посты и комментарии — модели, ограничения, API, сериализаторы и тесты
**Что сделано**

* Добавлены модели ленты:

  * `Post` (компания/отдел; заголовок, тело, вложения, закрепление, счётчик лайков).
  * `Comment` (текст *или* изображение *или* файл; автор, пост).
  * `PostLike` c уникальностью (post,user).
* Жёсткие ограничения на уровне БД:

  * `Post`:

    * если `type=department` → `department IS NOT NULL`;
    * если `type!=department` → `department IS NULL`;
    * `likes_count >= 0`.
  * `Comment`: `text != '' OR image IS NOT NULL OR attachment IS NOT NULL` (v3).
* Индексы для быстрых выборок:

  * `Post`: `(type, created_at)`, `(pinned, created_at)`, `created_at`,
    `(department, -created_at)`, `(author, -created_at)`.
  * `Comment`: `created_at`, `(post, created_at)`, `(author, created_at)`.
  * `PostLike`: `(user, created_at)`, `(post, created_at)`.
* Валидация и чистка файлов в `Comment`:

  * `clean()` гарантирует наличие хотя бы одного из полей контента.
  * Аккуратное удаление заменённых/удалённых файлов через `transaction.on_commit`.
* Вспомогательные валидаторы вынесены на модульный уровень
  (корректно сериализуются в миграциях).

**API**

* `PostViewSet`:

  * **Список/деталь** — любой аутентифицированный.
  * **Создание**:

    * `TYPE_COMPANY` — только staff/superuser **или** обладатель стандартного пермишена `feed.add_post` (без ввода новых кастом-пермишенов).
    * `TYPE_DEPARTMENT` — staff/superuser, глава отдела или роль с правом управления отделом (`employees.manage_department`) в этом отделе.
    * `TYPE_EMPLOYEE` — запрещено (по ТЗ публикаций “у себя на странице” нет).
    * автор поста всегда принудительно = `request.user`.
  * **Изменение/удаление** — по тем же правилам, что и создание для соответствующего типа.
  * **Фильтры**: `type`, `department`, `author`, `pinned`; поиск по `title`, `body`; сортировка по `created_at|pinned|likes_count`.
  * **Действия**:

    * `pin` / `unpin` — только admin/staff.
    * `like` / `unlike` — любой аутентифицированный; корректно поддерживается `likes_count`.
* `CommentViewSet`:

  * Список/деталь/создание/изменение/удаление.
  * Автор комментария всегда принудительно = `request.user`.
  * Фильтры: `post`, `author`; сортировка по `created_at ASC`.
  * Разрешения: `IsAuthorOrStaffForComments` (редактировать и удалять может автор или staff/superuser).

**Сериализаторы**

* `AuthorPublicSerializer` для публичных данных автора (`id`, `first_name`, `last_name`, `avatar`).
* `PostSerializer` / `PostListSerializer`:

  * `author_id` (RO), вложенный `author` (RO), `department` — PK.
* `CommentSerializer`:

  * `author_id`, вложенный `author`.
  * `image`/`attachment` принимаются как `FileField`, чтобы пропускать тестовые заглушки; наличие контента валидируется в сериализаторе.
  * `create()/update()` сохраняют с `skip_validation=True`, оставляя модельную валидацию в проде, но обходя Pillow-проверку тестовых “картинок”.

**Тесты**

* Модельные тесты для `Post`, `Comment`, `PostLike`:

  * проверка всех CheckConstraint/PROTECT/unique;
  * проверка ordering и строковых представлений.
* API-тесты:

  * `Post` — права на создание/изменение/удаление, фильтры/поиск/сортировка, like/unlike, pin/unpin, запрет `TYPE_EMPLOYEE`.
  * `Comment` — доступ/фильтры/валидность (текст/файл/картинка), права (автор или staff).
* Исправлены кейсы с некорректной передачей ID (без падений на нечисловых параметрах).

**Примечания к деплою**

* Требуются миграции приложения `feed`.
* Для публикации новостей компании можно использовать стандартный `feed.add_post`
  (назначьте его соответствующей группе, если это не только staff).
* Права в отделах читаются через текущую логику ролей (`employees.manage_department`)

  * глава отдела.

**UX/поведение согласно ТЗ**

* На главной публикуют только обладатели `add_post` или staff.
* В отделе публикуют глава/уполномоченные роли/staff.
* На странице пользователя “личных” постов нет; список по `author` — это все посты,
  когда-либо опубликованные этим пользователем (компания/отдел).
2025-08-26 13:13:16 +03:00

159 lines
5.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# backend\api\v1\permissions.py
from __future__ import annotations
from rest_framework.permissions import SAFE_METHODS, BasePermission
from employees.models import Department, EmployeeDepartment
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:
if not (user and user.is_authenticated):
return False
# staff/superuser — сразу ок (оставим как было)
if user_is_staffish(user):
return True
# руководитель — сразу ок
if user_is_dept_head(user, dept):
return True
# основная проверка: активная связь + у роли есть нужный Permission
return EmployeeDepartment.objects.filter(
employee_id=user.id,
department_id=dept.id,
is_active=True,
role__permissions__codename=perm_code,
).exists()
class IsDeptManagerForWrite(BasePermission):
"""
Для DepartmentViewSet.
View может указать:
self.required_perm_code in {"change_department_head", "assign_department_role", "manage_department"}
По умолчанию — "manage_department".
"""
message = "Недостаточно прав для управления отделом."
def has_object_permission(self, request, view, obj):
dept = getattr(obj, "department", obj)
if request.method in SAFE_METHODS:
return True
user = request.user
if not user or not user.is_authenticated:
return False
if user_is_staffish(user) or user_is_dept_head(user, dept):
return True
perm_code = getattr(view, "required_perm_code", MANAGE_PERM)
# NEW: если у пользователя есть глобальный модельный пермишен (employees.<perm_code>) — пускаем
# (сбросим кэш прав — в тестах перм выдают после первого запроса)
for attr in ("_perm_cache", "_user_perm_cache", "_group_perm_cache"):
if hasattr(user, attr):
try:
delattr(user, attr)
except Exception:
pass
app_label = Department._meta.app_label # "employees"
if user.has_perm(f"{app_label}.{perm_code}"):
return True
# иначе — проверяем наличие нужного права через роль в данном отделе
return user_has_dept_perm(user, dept, perm_code)
class IsSelfOrStaff(BasePermission):
"""
Доступ к объекту Employee:
- staff/superuser — всегда
- сам сотрудник к своему объекту
"""
message = "Недостаточно прав."
def has_object_permission(self, request, view, obj):
user = request.user
if not user or not user.is_authenticated:
return False
if user.is_superuser or user.is_staff:
return True
return getattr(obj, "id", None) == getattr(user, "id", None)
class IsStaffOrHasModelPerm(BasePermission):
"""
Read: любой аутентифицированный
Write: staff ИЛИ пользователь с правом, указанным во view.required_perm_code
"""
def has_permission(self, request, view):
user = request.user
if request.method in SAFE_METHODS:
return bool(user and user.is_authenticated)
if not (user and user.is_authenticated):
return False
if user.is_staff or user.is_superuser:
return True
code = getattr(view, "required_perm_code", None)
# NOTE: в тестах права выдают уже после первого запроса; кэш прав у user остаётся.
# Сбросим кэш, чтобы has_perm перечитал права из БД в рамках текущего запроса.
for attr in ("_perm_cache", "_user_perm_cache", "_group_perm_cache"):
if hasattr(user, attr):
try:
delattr(user, attr)
except Exception:
pass
return bool(code and user.has_perm(code))
class IsAuthorOrStaffForComments(BasePermission):
"""
Создание/чтение — любой аутентифицированный (проверяет IsAuthenticated во view).
Изменение/удаление — автор комментария или staff/superuser.
"""
message = "Можно редактировать/удалять только свои комментарии."
def has_permission(self, request, view):
# IsAuthenticated уже стоит во вьюхе; дублируем True для небезопасных методов,
# чтобы проверка прошла до object-level.
return bool(request.user and request.user.is_authenticated)
def has_object_permission(self, request, view, obj):
if request.method in SAFE_METHODS:
return True
user = request.user
if not (user and user.is_authenticated):
return False
if user_is_staffish(user):
return True
return getattr(obj, "author_id", None) == getattr(user, "id", None)
__all__ = [
"MANAGE_PERM",
"CHANGE_HEAD_PERM",
"ASSIGN_ROLE_PERM",
"IsDeptManagerForWrite",
"IsSelfOrStaff",
"IsStaffOrHasModelPerm",
"IsAuthorOrStaffForComments",
"user_is_dept_head",
"user_has_dept_perm",
]