"""Pydantic-схемы team-management API (#2554, эпик #2549). CRUD сотрудников (`tradein_users.role = 'employee'`), квоты, история оценок. Org-изоляция (manager видит/меняет только своих employee) реализована в `app.api.v1.team`, эти схемы — только форма запросов/ответов. """ from __future__ import annotations import re from datetime import datetime 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, # а не только на уровне БД. _USERNAME_RE = re.compile(r"^[A-Za-z0-9._-]{3,64}$") 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 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