Ключевые изменения
* Перевели доступ к объектам отдела (посты) на **ролевую модель внутри отдела**:
* Используются `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).
208 lines
7.5 KiB
Python
208 lines
7.5 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Any, Dict, Sequence
|
|
|
|
from django.contrib.auth import get_user_model
|
|
from documents.models import Document, DocumentAcknowledgement
|
|
from rest_framework import serializers
|
|
|
|
from ..employees.serializers import EmployeeBriefSerializer
|
|
|
|
User = get_user_model()
|
|
|
|
|
|
class DocumentReadSerializer(serializers.ModelSerializer):
|
|
"""Сериализатор для чтения документа.
|
|
|
|
Включает список получателей и флаг, ознакомился ли текущий пользователь.
|
|
"""
|
|
|
|
uploaded_by = EmployeeBriefSerializer(read_only=True)
|
|
recipients = EmployeeBriefSerializer(many=True, read_only=True)
|
|
file_url = serializers.FileField(source="file", read_only=True)
|
|
is_acknowledged = serializers.SerializerMethodField()
|
|
|
|
class Meta:
|
|
model = Document
|
|
fields = (
|
|
"id",
|
|
"title",
|
|
"description",
|
|
"uploaded_by",
|
|
"uploaded_at",
|
|
"sent_to_all",
|
|
"recipients",
|
|
"file_url",
|
|
"is_acknowledged",
|
|
)
|
|
|
|
def get_is_acknowledged(self, obj: Document) -> bool:
|
|
"""Проверяет, отмечал ли текущий пользователь ознакомление.
|
|
|
|
Args:
|
|
obj (Document): Документ.
|
|
|
|
Returns:
|
|
bool: True, если текущий пользователь уже ознакомился.
|
|
"""
|
|
request = self.context.get("request")
|
|
if not request or not request.user.is_authenticated:
|
|
return False
|
|
return DocumentAcknowledgement.objects.filter(
|
|
document=obj, user=request.user
|
|
).exists()
|
|
|
|
|
|
class DocumentWriteSerializer(serializers.ModelSerializer):
|
|
"""Сериализатор для записи/обновления документа.
|
|
|
|
Поддерживает:
|
|
- загрузку файла (multipart)
|
|
- `sent_to_all`
|
|
- `recipient_ids` (список ID пользователей), если `sent_to_all == False`
|
|
"""
|
|
|
|
# Принимаем список ID вместо M2M-структуры
|
|
recipient_ids = serializers.ListField(
|
|
child=serializers.IntegerField(min_value=1),
|
|
write_only=True,
|
|
required=False,
|
|
allow_empty=True,
|
|
help_text="Список ID сотрудников (используется, когда sent_to_all=false).",
|
|
)
|
|
|
|
class Meta:
|
|
model = Document
|
|
fields = (
|
|
"id",
|
|
"title",
|
|
"description",
|
|
"file",
|
|
"sent_to_all",
|
|
"recipient_ids",
|
|
)
|
|
|
|
def validate(self, attrs: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Общая валидация полей.
|
|
|
|
Args:
|
|
attrs (dict): Данные.
|
|
|
|
Returns:
|
|
dict: Провалидированные данные.
|
|
|
|
Raises:
|
|
serializers.ValidationError: При логических несоответствиях.
|
|
"""
|
|
sent_to_all = attrs.get("sent_to_all", True)
|
|
recipient_ids = attrs.get("recipient_ids", [])
|
|
if sent_to_all is False and not recipient_ids:
|
|
raise serializers.ValidationError(
|
|
{
|
|
"recipient_ids": "Укажите получателей или установите sent_to_all=true."
|
|
}
|
|
)
|
|
return attrs
|
|
|
|
def _set_recipients(self, doc: Document, recipient_ids: Sequence[int]) -> int:
|
|
"""Привязывает получателей по списку ID.
|
|
|
|
Args:
|
|
doc (Document): Документ.
|
|
recipient_ids (Sequence[int]): Идентификаторы пользователей.
|
|
|
|
Returns:
|
|
int: Количество привязанных получателей.
|
|
"""
|
|
if not recipient_ids:
|
|
doc.recipients.clear()
|
|
return 0
|
|
users = User.objects.filter(is_active=True, id__in=list(set(recipient_ids)))
|
|
doc.recipients.set(users)
|
|
return users.count()
|
|
|
|
def create(self, validated_data: Dict[str, Any]) -> Document:
|
|
"""Создание документа.
|
|
|
|
ВАЖНО: чтобы уведомления (signals) не улетели до установки M2M,
|
|
мы временно отключим `post_save` сигнал и вызовем уведомление вручную уже после назначения получателей.
|
|
|
|
Args:
|
|
validated_data (dict): Данные.
|
|
|
|
Returns:
|
|
Document: Созданный документ.
|
|
|
|
Raises:
|
|
serializers.ValidationError: Если входные данные недопустимы.
|
|
"""
|
|
recipient_ids = validated_data.pop("recipient_ids", [])
|
|
request = self.context.get("request")
|
|
uploader = getattr(request, "user", None)
|
|
|
|
# Подключим/отключим сигнал аккуратно
|
|
from django.db.models.signals import post_save
|
|
|
|
try:
|
|
from documents.signals import on_document_saved # type: ignore
|
|
except Exception:
|
|
on_document_saved = None
|
|
|
|
if on_document_saved:
|
|
post_save.disconnect(on_document_saved, sender=Document)
|
|
|
|
try:
|
|
doc = Document.objects.create(
|
|
uploaded_by=uploader, # type: ignore[arg-type]
|
|
**validated_data,
|
|
)
|
|
if validated_data.get("sent_to_all") is False:
|
|
self._set_recipients(doc, recipient_ids)
|
|
finally:
|
|
if on_document_saved:
|
|
post_save.connect(on_document_saved, sender=Document)
|
|
|
|
# Ручное уведомление после полной подготовки (если доступно)
|
|
try:
|
|
from documents.notification import \
|
|
notify_users_about_document # type: ignore
|
|
|
|
notify_users_about_document(doc)
|
|
except Exception:
|
|
# если уведомлялка неактивна — просто молча пропустим
|
|
pass
|
|
|
|
return doc
|
|
|
|
def update(self, instance: Document, validated_data: Dict[str, Any]) -> Document:
|
|
"""Обновление документа.
|
|
|
|
Сценарии:
|
|
- можно заменить файл/описание/заголовок
|
|
- переключить `sent_to_all`
|
|
- обновить получателей через `recipient_ids`
|
|
|
|
Args:
|
|
instance (Document): Документ для обновления.
|
|
validated_data (dict): Новые данные.
|
|
|
|
Returns:
|
|
Document: Обновлённый документ.
|
|
"""
|
|
recipient_ids = validated_data.pop("recipient_ids", None)
|
|
|
|
for field in ("title", "description", "sent_to_all", "file"):
|
|
if field in validated_data:
|
|
setattr(instance, field, validated_data[field])
|
|
instance.save()
|
|
|
|
if recipient_ids is not None:
|
|
# Если теперь "всем" — очистим M2M
|
|
if validated_data.get("sent_to_all", instance.sent_to_all):
|
|
instance.recipients.clear()
|
|
else:
|
|
self._set_recipients(instance, recipient_ids)
|
|
|
|
# уведомления по обновлению — по желанию; оставим тихо
|
|
return instance
|