### Что сделано
**Position API**
* Добавлен `PositionViewSet` с CRUD и экшенами:
* `POST /positions/{id}/set_groups`
* `POST /positions/{id}/add_groups`
* `POST /positions/{id}/remove_groups`
* `GET /positions/{id}/permissions` (агрегирует permissions из связанных групп)
* Чтение: `IsAuthenticated`; запись: staff/superuser **или** наличие модельных прав (`employees.add|change|delete_position`), экшены групп — кастомное право `employees.assign_position_groups`.
* Выставление требуемого права через `view.required_perm_code` в `get_permissions()`.
* Поиск/сортировка: `?search=`, `?ordering=`.
* **Отключена пагинация** (возвращаем plain list, как ожидают тесты).
* Сериализатор включает `groups` и `groups_verbose`.
* Роутер: зарегистрирован `positions` в `api:api_v1`.
**Кастомный пермишен для Position**
* В `Position.Meta.permissions` добавлен `("assign_position_groups", "Can assign auth groups to positions")`.
* В `employees.apps.EmployeesConfig.ready()` добавлен `post_migrate`-хук для гарантированного создания этого пермишена (страхует тестовую БД).
**DepartmentRole API**
* Добавлен `DepartmentRoleViewSet` (CRUD + экшены управления правами роли):
* `GET /department-roles/{id}/perms`
* `POST /department-roles/{id}/set_perms`
* `POST /department-roles/{id}/add_perms`
* `POST /department-roles/{id}/remove_perms`
* Фильтр: `?department=<id>` (+ безопасная обработка нечислового значения → пустой список).
* Права:
* CRUD: требуется `manage_department` (через главу отдела, роль с правом, staff/superuser **или** глобальное модельное право `employees.manage_department`);
* управление правами роли: `assign_department_role` (аналогично).
* Сериализатор `DepartmentRoleSerializer` с полями `permissions` и `permissions_verbose`.
**Пермишены и проверки доступа**
* `IsDeptManagerForWrite`:
* Научили работать с объектами, у которых есть `obj.department` (например, `DepartmentRole`).
* Добавили поддержку **глобальных** модельных прав (`user.has_perm("employees.<code>")`), чтобы уполномоченные пользователи могли управлять **любым** отделом.
* `IsStaffOrHasModelPerm`:
* Перед `user.has_perm(...)` сбрасываем кэш прав (`_perm_cache`, `_user_perm_cache`, `_group_perm_cache`) — важно для тестов, где права выдают в ходе сценария.
**Department.save(): членство и права главы**
* Перевели создание/активацию линка `EmployeeDepartment` для **нового** главы на **синхронный** режим (без `on_commit`) при **смене** главы.
* **Не** создаём линк на первичном создании отдела с уже заданным `head` (чтобы не ловить `UNIQUE` в кейсах, где тесты добавляют линк вручную).
* При смене главы:
* для **нового** главы — гарантируем наличие активной ссылки (`is_active=True`, `date_from` при необходимости);
* для **старого** главы:
* если `head -> None` — гарантируем наличие ссылки и деактивируем её (`is_active=False`, проставляем `date_to`);
* если смена `A -> B` — гарантируем, что ссылка старого главы существует и остаётся активной (бизнес-логика и тесты).
* Таким образом, права «главы» корректно переходят и отражаются в ссылках членства.
**Маршрутизация**
* Зарегистрированы в `api/v1/urls.py`:
* `positions`
* `department-roles`
**Тесты**
* **positions:** полный набор на права, экшены групп, поиск/сортировку, валидацию payload’ов; фикс хелпера телефонов (уникальные номера).
* **department-roles:** основной набор + расширенный (`extra`) на:
* роли с правами внутри отдела (manage/assign),
* глобальные модельные права (доступ к любому отделу),
* кросс-доступ (роль в отделе A не даёт прав в отделе B),
* семантику `set_perms` (полная замена, дедуп), идемпотентность `add/remove`,
* валидацию payload’ов, 404 при удалении несуществующей роли.
* **department\_head\_rights:** сценарии смены/снятия главы:
* старый глава теряет права (если нет роли),
* новому создаётся активная ссылка и права работают сразу,
* снятие головы → деактивация ссылки + `date_to`,
* у старого главы права сохраняются, если назначена роль с `manage_department`,
* смена головы не деактивирует ссылку старого главы по умолчанию.
* Все тесты зелёные.
### Технические детали / файлы
* `employees/models.py` — правки `Position.Meta.permissions`, логика `Department.save()`.
* `employees/apps.py` — `post_migrate` для `assign_position_groups`.
* `employees/permissions.py` — обновлён `IsDeptManagerForWrite`, `IsStaffOrHasModelPerm`.
* `api/v1/employees/serializers.py` — `PositionSerializer`, `DepartmentRoleSerializer`.
* `api/v1/employees/views.py` — `PositionViewSet` (без пагинации), `DepartmentRoleViewSet` (CRUD + экшены), безопасный фильтр `department`.
* `api/v1/urls.py` — регистрация роутеров.
* `tests/api/v1/employees/` — новые файлы тестов и доработки существующих.
### Миграции/заметки
* Требуется миграция для добавления `assign_position_groups` в `Position.Meta.permissions`.
* `post_migrate` гарантирует наличие права в тестовой БД.
* После деплоя: `python manage.py migrate`.
---
Таким образом, добавлены полноценные API для **Position** и **DepartmentRole**, корректно выстроена модель прав (глава/роль/глобальные), а сценарии смены/снятия главы полностью покрыты тестами.
189 lines
5.6 KiB
Python
189 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
|