Ключевые изменения
* Перевели доступ к объектам отдела (посты) на **ролевую модель внутри отдела**:
* Используются `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).
220 lines
5.6 KiB
Python
220 lines
5.6 KiB
Python
# tests/conftest.py
|
||
import itertools
|
||
import mimetypes
|
||
import os
|
||
from datetime import datetime, date
|
||
import pytest
|
||
from django.contrib.auth import get_user_model
|
||
from django.contrib.auth.models import Permission
|
||
from django.contrib.contenttypes.models import ContentType
|
||
from django.core.files.base import ContentFile
|
||
from employees.models import Department, DepartmentRole, EmployeeDepartment
|
||
from rest_framework.test import APIClient
|
||
|
||
pytestmark = pytest.mark.django_db
|
||
|
||
User = get_user_model()
|
||
|
||
# -------------------------
|
||
# Speed & isolation
|
||
# -------------------------
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _fast_hashers_and_email(settings, tmp_path):
|
||
# быстрые хеши паролей и локальная почта
|
||
settings.PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
|
||
settings.EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
|
||
# изоляция медиа
|
||
media_root = tmp_path / "test_media"
|
||
media_root.mkdir(exist_ok=True)
|
||
settings.MEDIA_ROOT = media_root
|
||
yield
|
||
|
||
|
||
# -------------------------
|
||
# API clients
|
||
# -------------------------
|
||
|
||
|
||
@pytest.fixture
|
||
def api_client() -> APIClient:
|
||
"""DRF APIClient (имеет force_authenticate)."""
|
||
return APIClient()
|
||
|
||
|
||
@pytest.fixture
|
||
def auth_client_factory():
|
||
"""Фабрика аутентифицированных клиентов: client = auth_client_factory(user)."""
|
||
|
||
def _make(user=None):
|
||
client = APIClient()
|
||
if user:
|
||
client.force_authenticate(user=user)
|
||
return client
|
||
|
||
return _make
|
||
|
||
|
||
# -------------------------
|
||
# Factories
|
||
# -------------------------
|
||
|
||
_phone_seq = itertools.count(1000)
|
||
|
||
|
||
def _unique_phone() -> str:
|
||
# валидный E.164 и уникальный в рамках сессии
|
||
return f"+7999000{next(_phone_seq):03d}"
|
||
|
||
|
||
@pytest.fixture
|
||
def user_factory():
|
||
"""
|
||
user = user_factory(email="x@example.com", staff=False, superuser=False,
|
||
verified=True, active=True, **extra)
|
||
Создаёт пользователя напрямую (без менеджера create_user, чтобы не слать письма).
|
||
"""
|
||
|
||
def _make(
|
||
email: str,
|
||
*,
|
||
staff: bool = False,
|
||
superuser: bool = False,
|
||
verified: bool = True,
|
||
active: bool = True,
|
||
password: str = "pass",
|
||
**extra,
|
||
):
|
||
u = User.objects.create(
|
||
email=email,
|
||
phone_number=extra.pop("phone_number", _unique_phone()),
|
||
first_name=extra.pop("first_name", "FN"),
|
||
last_name=extra.pop("last_name", "LN"),
|
||
is_staff=staff,
|
||
is_superuser=superuser,
|
||
is_active=active,
|
||
email_verified=verified,
|
||
**extra,
|
||
)
|
||
u.set_password(password)
|
||
u.save()
|
||
return u
|
||
|
||
return _make
|
||
|
||
|
||
@pytest.fixture
|
||
def department_factory():
|
||
def _make(**kwargs) -> Department:
|
||
return Department.objects.create(**kwargs)
|
||
|
||
return _make
|
||
|
||
|
||
@pytest.fixture
|
||
def perm_for_department():
|
||
"""
|
||
p = perm_for_department("manage_department")
|
||
Гарантированно возвращает Permission для модели Department с нужным codename.
|
||
"""
|
||
|
||
def _make(codename: str) -> Permission:
|
||
ct = ContentType.objects.get_for_model(Department)
|
||
p, _ = Permission.objects.get_or_create(
|
||
content_type=ct,
|
||
codename=codename,
|
||
defaults={"name": codename},
|
||
)
|
||
return p
|
||
|
||
return _make
|
||
|
||
|
||
@pytest.fixture
|
||
def role_factory(perm_for_department):
|
||
"""
|
||
role = role_factory(dept, name="mgr", codes=["manage_department", ...])
|
||
"""
|
||
|
||
def _make(dept: Department, name: str = "role", codes=None) -> DepartmentRole:
|
||
r = DepartmentRole.objects.create(department=dept, name=name)
|
||
if codes:
|
||
perms = [perm_for_department(code) for code in codes]
|
||
r.permissions.add(*perms)
|
||
return r
|
||
|
||
return _make
|
||
|
||
|
||
@pytest.fixture
|
||
def link_factory():
|
||
"""
|
||
link = link_factory(employee, department, is_active=True, role=None, **dates)
|
||
"""
|
||
|
||
def _make(
|
||
employee, department, is_active=True, role=None, **dates
|
||
) -> EmployeeDepartment:
|
||
return EmployeeDepartment.objects.create(
|
||
employee=employee,
|
||
department=department,
|
||
is_active=is_active,
|
||
role=role,
|
||
**dates,
|
||
)
|
||
|
||
return _make
|
||
|
||
|
||
# -------------------------
|
||
# Test helpers
|
||
# -------------------------
|
||
|
||
|
||
@pytest.fixture
|
||
def extract_results():
|
||
"""
|
||
items = extract_results(resp.json()) — поддерживает DRF пагинацию и «плоский» список.
|
||
"""
|
||
|
||
def _do(payload):
|
||
if isinstance(payload, dict) and "results" in payload:
|
||
return payload["results"]
|
||
return payload
|
||
|
||
return _do
|
||
|
||
|
||
# -------------------------
|
||
# Small misc utilities
|
||
# -------------------------
|
||
|
||
|
||
@pytest.fixture
|
||
def data_uri_image(tmp_path):
|
||
"""
|
||
Возвращает data URI маленькой PNG-картинки (1x1), удобно для аватаров.
|
||
"""
|
||
# минимальный валидный PNG 1x1 (чёрный пиксель)
|
||
import base64
|
||
|
||
png_b64 = (
|
||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR4nGMA"
|
||
"AQAABQABDQottAAAAABJRU5ErkJggg=="
|
||
)
|
||
return f"data:image/png;base64,{png_b64}"
|
||
|
||
|
||
@pytest.fixture
|
||
def content_file_from_b64():
|
||
"""
|
||
ContentFile <- base64 строки (без data: префикса).
|
||
"""
|
||
import base64
|
||
|
||
def _make(b64: str, name: str = "upload.bin") -> ContentFile:
|
||
return ContentFile(base64.b64decode(b64), name=name)
|
||
|
||
return _make
|