Игорь Надеин 4631f076a4 **feat(feed, perms, tests): новая политика доступа для фида + полный набор автотестов**
Ключевые изменения

* Перевели доступ к объектам отдела (посты) на **ролевую модель внутри отдела**:

  * Используются `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).
2025-09-11 17:56:33 +03:00

82 lines
3.1 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.

from __future__ import annotations
from typing import Any
from django.contrib.auth import get_user_model
from django.db import transaction
from django.db.models import QuerySet
from documents.models import Document, DocumentAcknowledgement
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.permissions import (SAFE_METHODS, BasePermission,
IsAuthenticated)
from rest_framework.response import Response
from rest_framework.viewsets import ModelViewSet
from ..permissions import AdminOrActionOrModelPerms
from .serializers import DocumentReadSerializer, DocumentWriteSerializer
User = get_user_model()
class DocumentViewSet(ModelViewSet):
"""Полный CRUD по документам + экшен `acknowledge`.
Создание/обновление поддерживает multipart (для файла) и назначение получателей.
"""
queryset: QuerySet[Document] = (
Document.objects.all()
.select_related("uploaded_by")
.prefetch_related("recipients")
)
permission_classes = [AdminOrActionOrModelPerms]
parser_classes = (MultiPartParser, FormParser, JSONParser)
def get_serializer_class(self):
"""Выбор сериализатора: на чтение — Read, на запись — Write."""
if self.action in ("list", "retrieve"):
return DocumentReadSerializer
return DocumentWriteSerializer
@transaction.atomic
def create(self, request, *args, **kwargs):
"""Создаёт документ для рассылки.
Returns:
Response: 201 Created с данными документа (read-сериализатор).
"""
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
doc = serializer.save()
read = DocumentReadSerializer(doc, context={"request": request})
return Response(read.data, status=status.HTTP_201_CREATED)
@transaction.atomic
def update(self, request, *args, **kwargs):
"""Полное обновление документа."""
return super().update(request, *args, **kwargs)
@transaction.atomic
def partial_update(self, request, *args, **kwargs):
"""Частичное обновление документа."""
return super().partial_update(request, *args, **kwargs)
@action(methods=["post"], detail=True, permission_classes=[IsAuthenticated])
def acknowledge(self, request, pk=None):
"""Отметить ознакомление текущего пользователя с документом.
Args:
request: DRF Request
pk: ID документа
Returns:
Response: {"ok": true, "already": bool}
"""
doc = self.get_object()
obj, created = DocumentAcknowledgement.objects.get_or_create(
document=doc, user=request.user
)
return Response({"ok": True, "already": not created})