Ключевые изменения
* Перевели доступ к объектам отдела (посты) на **ролевую модель внутри отдела**:
* Используются `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).
287 lines
11 KiB
Python
287 lines
11 KiB
Python
from __future__ import annotations
|
||
|
||
from typing import Any, List
|
||
|
||
from django.contrib.auth import get_user_model
|
||
from django.db.models import BooleanField, Count, Exists, F, OuterRef, Subquery, Value
|
||
from employees.constants import DeptPerm
|
||
from feed.constants import TYPE_COMPANY, TYPE_DEPARTMENT, TYPE_EMPLOYEE
|
||
from feed.models import Comment, Post, PostLike
|
||
from rest_framework import filters, status, viewsets
|
||
from rest_framework.decorators import action
|
||
from rest_framework.exceptions import PermissionDenied
|
||
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
|
||
from rest_framework.permissions import IsAdminUser, IsAuthenticated
|
||
from rest_framework.response import Response
|
||
|
||
from ..permissions import (
|
||
AdminOrActionOrModelPerms,
|
||
AdminOrDeptAllowed,
|
||
IsSelfOrStaff,
|
||
user_has_dept_perm,
|
||
user_is_dept_head,
|
||
user_is_staffish,
|
||
)
|
||
from .serializers import CommentSerializer, PostListSerializer, PostSerializer
|
||
|
||
Employee = get_user_model()
|
||
|
||
|
||
def _to_id(val):
|
||
if val is None:
|
||
return None
|
||
if isinstance(val, int):
|
||
return val
|
||
if hasattr(val, "id"):
|
||
return getattr(val, "id")
|
||
try:
|
||
return int(str(val))
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
class PostViewSet(viewsets.ModelViewSet):
|
||
"""CRUD ленты + like/unlike, pin/unpin.
|
||
|
||
Доступ (коротко):
|
||
- list/retrieve/like/unlike — IsAuthenticated
|
||
- create:
|
||
* type=company → AdminOrActionOrModelPerms + required_perm_code="feed.publish_company_post"
|
||
* type=department → AdminOrDeptAllowed с кодом DeptPerm.CREATE_POST|publish_department_post
|
||
- update/partial_update/destroy:
|
||
* company → как для company-create
|
||
* department → как для department-create
|
||
- pin/unpin — только staff/superuser
|
||
"""
|
||
|
||
queryset = (
|
||
Post.objects.select_related("author", "department")
|
||
.annotate(comments_count=Count("comments", distinct=True))
|
||
.order_by("-pinned", "-created_at")
|
||
)
|
||
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||
filter_backends = [filters.SearchFilter, filters.OrderingFilter]
|
||
search_fields = ["title", "body"]
|
||
ordering_fields = ["created_at", "pinned", "likes_count"]
|
||
ordering = ["-pinned", "-created_at"]
|
||
|
||
class PostDeptPerm(AdminOrDeptAllowed):
|
||
"""Департаментные права для операций над постами отдела.
|
||
|
||
SAFE-методы разрешены (IsAuthenticated стоит на вьюхе).
|
||
Коды прав подставляются динамически из get_permissions().
|
||
"""
|
||
|
||
allow_safe_without_code = True
|
||
|
||
def has_permission(self, request, view) -> bool:
|
||
"""На create, если type=department, но department не указан,
|
||
не режем запрос — даём сериализатору вернуть 400 вместо 403.
|
||
"""
|
||
if getattr(view, "action", None) == "create":
|
||
t = (request.data.get("type") or "").strip()
|
||
if t == TYPE_DEPARTMENT:
|
||
dept_id = self._to_int_or_none(
|
||
self._extract_dept_id_from_request(request, view)
|
||
)
|
||
if dept_id is None:
|
||
return True # пропускаем к валидации сериализатора
|
||
return super().has_permission(request, view)
|
||
|
||
def get_permissions(self) -> List[Any]:
|
||
"""Возвращает DRF-пермишены под текущий action (без вызова get_object())."""
|
||
if self.action in {"list", "retrieve", "like", "unlike"}:
|
||
return [IsAuthenticated()]
|
||
|
||
if self.action in {"pin", "unpin"}:
|
||
return [IsAdminUser()]
|
||
|
||
# --- CREATE: смотрим на тип из тела запроса ---
|
||
if self.action == "create":
|
||
t = (self.request.data.get("type") or "").strip()
|
||
if t == TYPE_COMPANY:
|
||
return [IsAuthenticated(), AdminOrActionOrModelPerms()]
|
||
# department → департаментное право на создание
|
||
dept_code = getattr(DeptPerm, "CREATE_POST", "publish_department_post")
|
||
perm = self.PostDeptPerm()
|
||
perm.required_code = DeptPerm.CREATE_POST
|
||
return [IsAuthenticated(), perm]
|
||
|
||
# --- UPDATE/PATCH/DELETE: определяем тип поста из БД, но без get_object() ---
|
||
if self.action in {"update", "partial_update", "destroy"}:
|
||
lookup = getattr(self, "lookup_url_kwarg", None) or getattr(
|
||
self, "lookup_field", "pk"
|
||
)
|
||
pk = self.kwargs.get(lookup)
|
||
info = (
|
||
Post.objects.filter(pk=pk).values("type", "department_id").first() or {}
|
||
)
|
||
if info.get("type") == TYPE_COMPANY:
|
||
return [IsAuthenticated(), AdminOrActionOrModelPerms()]
|
||
|
||
# department
|
||
dept_code = DeptPerm.MANAGE_FEED
|
||
perm = self.PostDeptPerm()
|
||
perm.required_code_map = {
|
||
"update": dept_code,
|
||
"partial_update": dept_code,
|
||
"destroy": dept_code,
|
||
}
|
||
return [IsAuthenticated(), perm]
|
||
|
||
# дефолт: просто аутентификация
|
||
return [IsAuthenticated()]
|
||
|
||
def get_serializer_class(self):
|
||
return PostListSerializer if self.action == "list" else PostSerializer
|
||
|
||
def get_queryset(self):
|
||
qs = super().get_queryset()
|
||
user = self.request.user
|
||
|
||
if user.is_authenticated:
|
||
qs = qs.annotate(
|
||
is_liked=Exists(PostLike.objects.filter(post=OuterRef("pk"), user=user))
|
||
)
|
||
else:
|
||
qs = qs.annotate(is_liked=Value(False, output_field=BooleanField()))
|
||
|
||
# последний комментарий (id) — без N+1
|
||
lc = Comment.objects.filter(post_id=OuterRef("pk")).order_by("-created_at")
|
||
qs = qs.annotate(last_comment_id=Subquery(lc.values("id")[:1]))
|
||
|
||
# фильтры
|
||
qp = self.request.query_params
|
||
t = qp.get("type")
|
||
if t:
|
||
qs = qs.filter(type=t)
|
||
dept_id = _to_id(qp.get("department"))
|
||
if dept_id is not None:
|
||
qs = qs.filter(department_id=dept_id)
|
||
author_id = _to_id(qp.get("author"))
|
||
if author_id is not None:
|
||
qs = qs.filter(author_id=author_id)
|
||
pinned = qp.get("pinned")
|
||
if pinned in ("true", "1"):
|
||
qs = qs.filter(pinned=True)
|
||
elif pinned in ("false", "0"):
|
||
qs = qs.filter(pinned=False)
|
||
|
||
return qs
|
||
|
||
def list(self, request, *args, **kwargs):
|
||
"""Возвращает список постов с картой последних комментариев без N+1."""
|
||
queryset = self.filter_queryset(self.get_queryset())
|
||
page = self.paginate_queryset(queryset)
|
||
objects = page if page is not None else queryset
|
||
|
||
ids = [getattr(o, "last_comment_id", None) for o in objects]
|
||
ids = [i for i in ids if i]
|
||
last_comments_map = {}
|
||
if ids:
|
||
last_comments_map = Comment.objects.select_related("author").in_bulk(ids)
|
||
|
||
serializer = self.get_serializer(
|
||
objects,
|
||
many=True,
|
||
context={
|
||
**self.get_serializer_context(),
|
||
"last_comments_map": last_comments_map,
|
||
},
|
||
)
|
||
if page is not None:
|
||
return self.get_paginated_response(serializer.data)
|
||
return Response(serializer.data)
|
||
|
||
# --- ВАЖНО: возвращаем perform_create, чтобы заполнить author ---
|
||
def perform_create(self, serializer: PostSerializer) -> None:
|
||
"""Сохраняет пост, проставляя автора текущим пользователем.
|
||
|
||
Raises:
|
||
ValidationError: если сериализатор невалиден (обрабатывается DRF).
|
||
"""
|
||
serializer.save(author=self.request.user)
|
||
|
||
# --- actions (pin/unpin и лайки) ---
|
||
|
||
@action(detail=True, methods=["post"], permission_classes=[IsAdminUser])
|
||
def pin(self, request, pk=None):
|
||
"""Пинует пост (только staff/superuser)."""
|
||
post = self.get_object()
|
||
if not post.pinned:
|
||
Post.objects.filter(pk=post.pk).update(pinned=True)
|
||
return Response({"pinned": True})
|
||
|
||
@action(detail=True, methods=["post"], permission_classes=[IsAdminUser])
|
||
def unpin(self, request, pk=None):
|
||
"""Снимает пин (только staff/superuser)."""
|
||
post = self.get_object()
|
||
if post.pinned:
|
||
Post.objects.filter(pk=post.pk).update(pinned=False)
|
||
return Response({"pinned": False})
|
||
|
||
@action(detail=True, methods=["post"], permission_classes=[IsAuthenticated])
|
||
def like(self, request, pk=None):
|
||
"""Ставит лайк текущего пользователя (идемпотентно)."""
|
||
post = self.get_object()
|
||
_, created = PostLike.objects.get_or_create(post=post, user=request.user)
|
||
if created:
|
||
Post.objects.filter(pk=post.pk).update(likes_count=F("likes_count") + 1)
|
||
post.refresh_from_db(fields=["likes_count"])
|
||
return Response(
|
||
{"liked": True, "likes_count": post.likes_count}, status=status.HTTP_200_OK
|
||
)
|
||
|
||
@action(detail=True, methods=["post"], permission_classes=[IsAuthenticated])
|
||
def unlike(self, request, pk=None):
|
||
"""Снимает лайк текущего пользователя (идемпотентно)."""
|
||
post = self.get_object()
|
||
deleted, _ = PostLike.objects.filter(post=post, user=request.user).delete()
|
||
if deleted:
|
||
Post.objects.filter(pk=post.pk, likes_count__gt=0).update(
|
||
likes_count=F("likes_count") - 1
|
||
)
|
||
post.refresh_from_db(fields=["likes_count"])
|
||
return Response(
|
||
{"liked": False, "likes_count": post.likes_count},
|
||
status=status.HTTP_200_OK,
|
||
)
|
||
|
||
|
||
class CommentViewSet(viewsets.ModelViewSet):
|
||
"""
|
||
/api/v1/comments/
|
||
- GET: аутентифицированные
|
||
- POST: аутентифицированные (author = request.user)
|
||
- PATCH/PUT/DELETE: автор или staff/superuser
|
||
Фильтры: ?post=<id>, ?author=<id>
|
||
Сортировка: created_at ASC
|
||
"""
|
||
|
||
queryset = Comment.objects.select_related("post", "author").all()
|
||
serializer_class = CommentSerializer
|
||
owner_id_attrs = ("author_id",)
|
||
owner_obj_attrs = ("author",)
|
||
permission_classes = [IsAuthenticated, (IsSelfOrStaff | AdminOrActionOrModelPerms)]
|
||
parser_classes = [MultiPartParser, FormParser, JSONParser]
|
||
filter_backends = [filters.OrderingFilter]
|
||
ordering_fields = ["created_at", "id"]
|
||
ordering = ["created_at"]
|
||
|
||
def perform_create(self, serializer):
|
||
serializer.save(author=self.request.user)
|
||
|
||
def get_queryset(self):
|
||
qs = super().get_queryset()
|
||
qp = self.request.query_params
|
||
|
||
pid = _to_id(qp.get("post"))
|
||
if pid is not None:
|
||
qs = qs.filter(post_id=pid)
|
||
|
||
aid = _to_id(qp.get("author"))
|
||
if aid is not None:
|
||
qs = qs.filter(author_id=aid)
|
||
|
||
return qs
|