Corp/backend/api/client.py
Игорь Надеин c78d1e5fea Ключевые изменения
- **EmployeeActionSerializer**
  - Добавлен `history[]` со снапшотами simple-history и diff по полям: `action`, `date`, `comment`, `extra`, `employee_id`.
  - Локализация названий событий через `employees.constants.ACTION_CHOICES`.
  - Поля `action_display`, `date_display` для удобного фронтового рендера.

- **EmployeeSerializer**
  - Возвращает `actions` (массив событий) с историей, **только** если в контексте выставлено `include_action_history=True`.
  - Навыки: чтение — `skills` как список объектов `{id, name}`, запись — через `skills_ids`.
  - Должность: чтение — объект `{id, name}`, запись — через `position_id`.
  - Отделы: добавлен флаг `is_head` (сотрудник — руководитель отдела).

- **EmployeeListSerializer**
  - Те же правки для навыков/должности и `is_head`, без `actions` (для производительности).

- **Views (EmployeeViewSet)**
  - `get_queryset`: префетч `actions` добавляется **только** для `retrieve/me`, иначе не тянем историю.
  - `get_serializer_context`: для `retrieve` и `me` выставляем `include_action_history=True`.
  - Экшен `me`: выборка с `Prefetch("actions", order_by -date)` и передачей `include_action_history=True`.

- **Разное**
  - Используем `ACTION_CHOICES` для «человеческих» названий событий в истории.
  - `HistoryActionMixin` оставлен без изменений; история объектов доступна по `/{basename}/{pk}/history/`.

Почему так
- История кадровых событий бывает тяжёлой → грузим и отдаём её только там, где нужно (`retrieve/me`).
- Явное разделение read/write для навыков и должности упрощает фронту жизнь и убирает двусмысленность.
- `is_head` позволяет фронту не вычислять этот флаг отдельно.

Файлы
- `backend/api/v1/employees/serializers.py`
- `backend/api/v1/employees/views.py`
- (использованы константы из) `backend/employees/constants.py`

Тесты/проверка
1) GET `/api/v1/employees/1/` → поле `actions[]` заполнено, внутри каждого элемента `history[]` со снапшотами и переводами событий.
2) GET `/api/v1/employees/?page=1` → `actions` отсутствуют (по замыслу), навыки и должность приходят в «красивом» виде.
3) GET `/api/v1/employees/me/` → аналогично `retrieve`, с историей.

* UI (templates/employees/employee\_detail.html)

  * Навыки: больше не итерируем по строке, выводим объекты `{id,name}` (`s.name`).
    Защита от пустых списков.
  * Отделы: убрал «аватарки» у элементов списка, оставил иконку у кнопки раздела.
    Роль сотрудника показывается «Название отдела — Роль».
    Для руководителя добавлен понятный бейдж с иконкой `bi-award-fill` + tooltip «Руководитель отдела».
  * Кадровые события: добавлен раздел «Кадровые события» (для аутентифицированных).
    Отрисовывает список всех событий (дата, комментарий, extra), а также историю каждого события (`action.history`) в `<details>`.
    В истории показываем только название события и новые значения полей (без «создано/изменено» и без зачёркнутых старых значений).
    Перевод кодов событий (`hired`, `dismissed`, …) в человекочитаемый вид согласно `employees/constants.py`.
  * Аккордеон: кнопка и выпадающая панель объединены в один блок `.ios-acc` единой ширины.
    Добавлена разделительная линия между заголовком и контентом (`.ios-acc-body { border-top: ... }`).
    Исправлены дёрганья при анимации: убраны внешние отступы у `.collapse`, возврат стандартной скорости Bootstrap (`.35s ease`), `overflow:hidden` на фазе `collapsing`.

* Views (employees/views.py — веб-приложение)

  * В `employee_detail` и `employee_me` дополнительно запрашиваем полную историю кадровых событий:
    `GET v1/employee-actions/?employee=<id>&ordering=-date` и передаём в шаблон `employee_actions`.
    Тем самым отображаем все события, а не только последнее.
  * Шаблон умеет брать события из `employee_actions`, а при отсутствии — из `emp.actions`.

* API (backend/api/v1/employees/serializers.py)

  * `EmployeeActionSerializer`: добавлен `history` (MethodField) с формированием массива изменений из `HistoricalRecords` (без старых значений; только `changes[field].new`, дата/пользователь).
    Добален `action_display` и `date_display` для удобного рендера на фронте.
  * `EmployeeSerializer`: возвращает `departments` через `get_departments()` (id, name, role\_id, role\_name, is\_head) и `skills` как массив объектов `{id,name}`.

* Прочее/фиксы

  * Безопасные проверки на пустые коллекции (контакты, навыки, отделы, события).
  * Устранена ошибка шаблона «Failed lookup…» при попытке обратиться к отсутствующим ключам.
  * Не изменяем скорость анимации: возвращён дефолт Bootstrap; корректное закрытие панелей.

Refs: employees/constants.py (ACTION\_CHOICES), templates/employees/employee\_detail.html, employees/views.py, api/v1/employees/serializers.py
2025-08-27 17:58:46 +03:00

192 lines
6.5 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.

# backend\api\client.py
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,
data=None,
files=None,
headers=None,
retry=True,
) -> ApiResponse:
url = self._make_url(path)
hdrs = dict(self.cfg.default_headers or {})
# Не выставляем Content-Type вручную — requests сделает это сам (особенно важно для multipart)
if headers:
# если вдруг передали Content-Type, но у нас files — лучше его удалить
if files and "Content-Type" in headers:
headers = {k: v for k, v in headers.items() if k.lower() != "content-type"}
hdrs.update(headers)
hdrs.update(self._auth_headers())
resp = self.session.request(
method,
url,
params=params,
json=None if files or data else json, # при multipart json не используем
data=data,
files=files,
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=None if files or data else json,
data=data,
files=files,
headers=hdrs,
timeout=self.cfg.timeout,
)
try:
data_json = resp.json()
except Exception:
data_json = None
return ApiResponse(ok=resp.ok, status=resp.status_code, json=data_json, 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)