All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 12s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m28s
CI Trade-In / backend-tests (pull_request) Successful in 2m27s
После cutover'а на DB-auth (#2558) аккаунты `kopylov` и `praktika` живут с `role='manager'`, а team-API жёстко фильтровал `role='employee'` — сбросить менеджеру пароль или заблокировать его было НЕЧЕМ, кроме ручного psql на проде. Всплыло 2026-07-31: «Практика» весь день билась в логин (5 failed, 0 успешных), а восстановить доступ через UI админ не мог. Что меняется: - `_fetch_employee_row` берёт actor: admin → `role IN ('employee','manager')`, manager → по-прежнему только `role='employee'` + свои по `manager_id`. - `GET /employees` без фильтра отдаёт admin'у и менеджеров (`?manager_id=` — без изменений, только сотрудники этого менеджера). - `EmployeeOut.role` — новое поле, UI показывает бейдж «менеджер» и склоняет тексты («Заблокировать менеджера ...» вместо «сотрудника»). Инвариант self-lockout сохранён и усилен тестом: строки `role='admin'` недостижимы через этот роутер ни для кого, включая самого админа, поэтому ни block, ни смена пароля с `revoke_user_sessions` не могут вырубить действующего админа. Раздача роли admin остаётся вне API. Тесты: 6 новых (список с менеджерами, сброс пароля менеджеру + отзыв сессий, блокировка, manager не достаёт до чужого менеджера, admin не достаёт до admin-строки), 3 существующих обновлены под новое ожидание списка.
116 lines
4.8 KiB
Python
116 lines
4.8 KiB
Python
"""Pydantic-схемы team-management API (#2554, эпик #2549).
|
||
|
||
CRUD управляемых аккаунтов (`tradein_users.role IN ('employee','manager')` —
|
||
manager'ы доступны только actor'у-admin, см. `app.api.v1.team`), квоты, история
|
||
оценок. Org-изоляция (manager видит/меняет только своих employee) реализована в
|
||
`app.api.v1.team`, эти схемы — только форма запросов/ответов.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from datetime import datetime
|
||
from typing import Literal
|
||
|
||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||
|
||
# ASCII-only — не-ASCII username ломает downstream identity-пропагацию
|
||
# (`app.core.rbac._propagate_authenticated_user` кодирует latin-1 с
|
||
# errors="replace"), поэтому валидация формы обязательна на границе API,
|
||
# а не только на уровне БД.
|
||
#
|
||
# `\Z`, НЕ `$` — deep-review seed #2564: в Python `$` матчит перед trailing
|
||
# newline (`re.match(r'...\$', 'admin\n')` → True), а Postgres `~` в CHECK
|
||
# tradein_users_username_ascii_ck (миграция 193) — False. С `$` строка
|
||
# "admin\n" проходила бы Pydantic-валидацию и падала уже в БД → 500 вместо
|
||
# честного 422. `\Z` — конец строки БЕЗ поблажки на trailing newline, совпадает
|
||
# с семантикой Postgres `~`.
|
||
_USERNAME_RE = re.compile(r"^[A-Za-z0-9._-]{3,64}\Z")
|
||
|
||
|
||
class QuotaStatusOut(BaseModel):
|
||
"""Статус месячной квоты оценок — вложен в `EmployeeOut`."""
|
||
|
||
model_config = ConfigDict(from_attributes=True)
|
||
|
||
limit: int
|
||
used: int
|
||
remaining: int
|
||
unlimited: bool
|
||
|
||
|
||
class EmployeeCreateRequest(BaseModel):
|
||
"""`POST /employees` — создать сотрудника. Роль всегда `employee` (не в теле)."""
|
||
|
||
username: str
|
||
password: str
|
||
display_name: str | None = None
|
||
org_name: str | None = None
|
||
email: str | None = None
|
||
monthly_limit: int | None = Field(default=None, ge=1)
|
||
# Только для actor.role == admin — опциональная привязка к конкретному manager.
|
||
# Для actor.role == manager это поле ИГНОРИРУЕТСЯ (принудительно свой id) —
|
||
# см. app.api.v1.team.create_employee.
|
||
manager_id: int | None = None
|
||
|
||
@field_validator("username")
|
||
@classmethod
|
||
def _validate_username(cls, v: str) -> str:
|
||
if not _USERNAME_RE.match(v):
|
||
raise ValueError(
|
||
"username must be 3-64 ASCII chars: letters, digits, dot, underscore, hyphen"
|
||
)
|
||
return v
|
||
|
||
|
||
class EmployeeUpdateRequest(BaseModel):
|
||
"""`PATCH /employees/{id}` — частичное обновление, все поля опциональны."""
|
||
|
||
is_active: bool | None = None
|
||
monthly_limit: int | None = Field(default=None, ge=1)
|
||
display_name: str | None = None
|
||
org_name: str | None = None
|
||
email: str | None = None
|
||
new_password: str | None = None
|
||
|
||
|
||
class EmployeeOut(BaseModel):
|
||
"""Одна строка в `GET /employees` + ответ `POST`/`PATCH /employees/{id}`."""
|
||
|
||
model_config = ConfigDict(from_attributes=True)
|
||
|
||
id: int
|
||
username: str
|
||
# 'employee' | 'manager' — admin управляет обоими, manager видит только
|
||
# employee (см. app.api.v1.team, модульный docstring). Строки role='admin'
|
||
# через этот API не отдаются никогда, поэтому в Literal их нет.
|
||
role: Literal["employee", "manager"]
|
||
display_name: str | None = None
|
||
org_name: str | None = None
|
||
email: str | None = None
|
||
is_active: bool
|
||
manager_id: int | None = None
|
||
created_at: datetime
|
||
quota: QuotaStatusOut
|
||
|
||
|
||
class EmployeeHistoryEntry(BaseModel):
|
||
"""Одна строка истории оценок сотрудника — `GET /employees/{id}/history`.
|
||
|
||
Источник — `user_events` (event_type='estimate_request', паттерн
|
||
`app.api.v1.audit.account_drilldown`), LEFT JOIN на `trade_in_estimates`
|
||
за фактическим результатом (median_price/confidence/n_analogs) — join
|
||
может не сматчиться (старая запись без estimate_id / оценка insufficient_data),
|
||
поэтому все result-поля nullable.
|
||
"""
|
||
|
||
model_config = ConfigDict(from_attributes=True)
|
||
|
||
estimate_id: str | None = None
|
||
address: str | None = None
|
||
area_m2: str | None = None
|
||
rooms: str | None = None
|
||
median_price: int | None = None
|
||
confidence: str | None = None
|
||
n_analogs: int | None = None
|
||
created_at: datetime
|