Corp/backend/api/client.py
Игорь Надеин cfc76e43b7 **feat(employees): Position & Department API, доработка прав головы отдела, усиление пермишенов и полный набор тестов**
### Что сделано

**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**, корректно выстроена модель прав (глава/роль/глобальные), а сценарии смены/снятия главы полностью покрыты тестами.
2025-08-25 17:01:58 +03:00

183 lines
5.9 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 dataclasses import dataclass
from typing import Any, Dict, Optional
import requests
from django.conf import settings
# ==== Настройки с дефолтами ====
@dataclass
class ApiConfig:
base_url: str
token_obtain_path: str = "/auth/token/"
token_refresh_path: str = "/auth/token/refresh/"
timeout: int = 15
default_headers: Dict[str, str] = None
login_url_name: str = "auth_front:login" # куда редиректить, если токены протухли
def load_config() -> ApiConfig:
base = getattr(settings, "API_BASE_URL", "http://localhost:8000/api")
return ApiConfig(
base_url=base.rstrip("/"),
token_obtain_path=getattr(settings, "API_TOKEN_OBTAIN_PATH", "/auth/token/"),
token_refresh_path=getattr(
settings, "API_TOKEN_REFRESH_PATH", "/auth/token/refresh/"
),
timeout=getattr(settings, "API_TIMEOUT", 15),
default_headers=getattr(
settings, "API_DEFAULT_HEADERS", {"Accept": "application/json"}
),
login_url_name=getattr(settings, "API_LOGIN_URL_NAME", "auth_front:login"),
)
# ==== Ответ-обёртка ====
@dataclass
class ApiResponse:
ok: bool
status: int
json: Any | None
text: str | None
SESSION_KEY_ACCESS = "api.jwt_access"
SESSION_KEY_REFRESH = "api.jwt_refresh"
class ApiClient:
"""
Унифицированный клиент DRF-API с JWT:
- хранит токены в request.session;
- автоматический refresh при 401 (1 попытка);
- единая сессия requests.Session;
- get/post/patch/put/delete с одинаковыми сигнатурами.
"""
def __init__(self, request, config: Optional[ApiConfig] = None):
self.request = request
self.cfg = config or load_config()
self.session = requests.Session()
self.access = request.session.get(SESSION_KEY_ACCESS)
self.refresh = request.session.get(SESSION_KEY_REFRESH)
# --- базовое ---
def _auth_headers(self) -> Dict[str, str]:
return {"Authorization": f"Bearer {self.access}"} if self.access else {}
def _make_url(self, path: str) -> str:
return f"{self.cfg.base_url}/{path.lstrip('/')}"
def _request(
self,
method: str,
path: str,
*,
params=None,
json=None,
headers=None,
retry=True,
) -> ApiResponse:
url = self._make_url(path)
hdrs = dict(self.cfg.default_headers or {})
if headers:
hdrs.update(headers)
hdrs.update(self._auth_headers())
resp = self.session.request(
method,
url,
params=params,
json=json,
headers=hdrs,
timeout=self.cfg.timeout,
)
if resp.status_code == 401 and retry and self.refresh:
if self.refresh_tokens():
hdrs = dict(self.cfg.default_headers or {})
hdrs.update(self._auth_headers())
resp = self.session.request(
method,
url,
params=params,
json=json,
headers=hdrs,
timeout=self.cfg.timeout,
)
try:
data = resp.json()
except Exception:
data = None
return ApiResponse(
ok=resp.ok, status=resp.status_code, json=data, text=resp.text
)
# --- публичные методы HTTP ---
def get(self, path: str, **kw) -> ApiResponse:
return self._request("GET", path, **kw)
def post(self, path: str, **kw) -> ApiResponse:
return self._request("POST", path, **kw)
def patch(self, path: str, **kw) -> ApiResponse:
return self._request("PATCH", path, **kw)
def put(self, path: str, **kw) -> ApiResponse:
return self._request("PUT", path, **kw)
def delete(self, path: str, **kw) -> ApiResponse:
return self._request("DELETE", path, **kw)
# --- аутентификация ---
def login(self, email: str, password: str) -> bool:
url = self._make_url(self.cfg.token_obtain_path)
resp = self.session.post(url, json={"email": email, "password": password}, timeout=self.cfg.timeout)
if resp.status_code >= 400:
return False
if "application/json" not in (resp.headers.get("Content-Type") or ""):
return False
try:
data = resp.json()
except ValueError:
return False
access, refresh = data.get("access"), data.get("refresh")
if not access or not refresh:
return False
self.access = access
self.refresh = refresh
self.request.session["api.jwt_access"] = access
self.request.session["api.jwt_refresh"] = refresh
self.request.session.modified = True
return True
def logout(self):
self.request.session.pop(SESSION_KEY_ACCESS, None)
self.request.session.pop(SESSION_KEY_REFRESH, None)
self.request.session.modified = True
self.access = None
self.refresh = None
def refresh_tokens(self) -> bool:
url = self._make_url(self.cfg.token_refresh_path)
resp = self.session.post(
url, json={"refresh": self.refresh}, timeout=self.cfg.timeout
)
if resp.ok:
data = resp.json()
new_access = data.get("access")
if new_access:
self.access = new_access
self.request.session[SESSION_KEY_ACCESS] = new_access
self.request.session.modified = True
return True
# не смогли рефрешнуть — чистим
self.logout()
return False
# Удобный фабричный хелпер
def get_api_client(request) -> ApiClient:
return ApiClient(request)