first commit
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled

This commit is contained in:
2026-07-23 02:38:05 +03:00
commit 8757bec8ac
335 changed files with 61527 additions and 0 deletions

View File

@@ -0,0 +1 @@
"""Pydantic-схемы (DTO) для входных/выходных данных API."""

158
backend/schemas/admin.py Normal file
View File

@@ -0,0 +1,158 @@
"""Pydantic-схемы админ-API (`/api/v1/admin/*`)."""
import uuid
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, EmailStr, Field
from core.plugins.config import AiLevel, SummaryRecipientsMode
from schemas.conferences import ConferenceOut
from services.ai_levels import AiLevelStatus
class AdminConferenceOut(ConferenceOut):
"""Конференция в ответах админ-API — `ConferenceOut` + данные владельца.
`owner_name`/`owner_email` — `None` для конференции без владельца
(`conferences.owner_id IS NULL`, ADR-001) — колонка «Владелец» в таблице
админки (`frontend/src/api/admin.ts`).
"""
owner_name: str | None = None
owner_email: str | None = None
class AdminConferenceListOut(BaseModel):
"""Страница списка конференций (`GET /admin/conferences`)."""
items: list[AdminConferenceOut]
total: int
class AdminUserOut(BaseModel):
"""Пользователь в ответах админ-API (профиль + служебные поля модерации).
`avatar_url`/`team_name` заполняются явно роутером (не через
`from_attributes` — оба поля вычисляемые, не хранятся как атрибут ORM),
см. `api/admin.py::_to_admin_user_out`.
"""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
email: str
name_user: str
role: str
is_blocked: bool
email_verified: bool
created_at: datetime
team_id: uuid.UUID | None = None
avatar_url: str | None = None
team_name: str | None = None
class AdminUserListOut(BaseModel):
"""Страница списка пользователей (`GET /admin/users`)."""
items: list[AdminUserOut]
total: int
class AdminUserCreateIn(BaseModel):
"""Тело создания пользователя администратором (`POST /admin/users`).
Та же политика пароля, что при самостоятельной регистрации
(`RegisterIn.password`, min 8 символов). `team_id` — опциональная
привязка к команде, в отличие от публичной регистрации не завязана на
настройку `registration_team_choice` (админ назначает команду всегда).
"""
name_user: str = Field(min_length=1, max_length=255)
email: EmailStr
password: str = Field(min_length=8)
team_id: uuid.UUID | None = None
class AdminUserUpdateIn(BaseModel):
"""Тело правки пользователя администратором — роль, блокировка, ФИО и/или команда.
`team_id` не различает «поле не передано» и «явный `null`» через
сравнение с `None` — роутер читает `model_fields_set`, чтобы явный сброс
команды (`{"team_id": null}`) отличался от отсутствия поля в запросе
(тот же паттерн, что `summary_recipients` в `schemas/conferences.py`).
`name_user` — та же правка, что доступна пользователю в своём профиле
(редактирование по тем же правилам).
"""
role: Literal["admin", "user"] | None = None
is_blocked: bool | None = None
team_id: uuid.UUID | None = None
name_user: str | None = Field(default=None, min_length=1, max_length=255)
class TeamOut(BaseModel):
"""Команда в ответах админ-API."""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
name: str
created_at: datetime
class TeamListOut(BaseModel):
"""Список команд (`GET /admin/teams`), отсортирован по названию."""
items: list[TeamOut]
total: int
class TeamCreateIn(BaseModel):
"""Тело создания команды; `name` обрезается от пробелов до проверки длины."""
model_config = ConfigDict(str_strip_whitespace=True)
name: str = Field(min_length=1, max_length=255)
class TeamUpdateIn(BaseModel):
"""Тело переименования команды; `name` обрезается от пробелов до проверки длины."""
model_config = ConfigDict(str_strip_whitespace=True)
name: str = Field(min_length=1, max_length=255)
class InvitationsSendIn(BaseModel):
"""Тело ручной рассылки .ics-приглашений (`POST /admin/conferences/{id}/invitations`).
Пустой список получателей не отличается от отсутствующего поля — оба
трактуются как «получатели по умолчанию» (см. `workers/tasks/invitations.py`).
"""
emails: list[EmailStr] | None = None
class SettingsOut(BaseModel):
"""Эффективные настройки инстанса для отображения в админке.
`transcription_queue_served` — обслуживается ли очередь `transcription`
хотя бы одним воркером Celery
прямо сейчас (`services.pipeline_producer.transcription_queue_served`);
`False` при включённой транскрибации — сигнал админке показать
предупреждение рядом с чекбоксом AI («модуль включён, но задачи некому
обрабатывать»), не связано с доступностью уровня AI (`ai_levels`,
которая смотрит только на железо/скачанные модели).
"""
chat_enabled: bool
transcription_enabled: bool
ai_level: AiLevel
ai_levels: list[AiLevelStatus]
transcription_queue_served: bool
summary_recipients: SummaryRecipientsMode
display_timezone: str
registration_team_choice: bool
registration_email_domain_enabled: bool
registration_email_domain: str | None = None

113
backend/schemas/auth.py Normal file
View File

@@ -0,0 +1,113 @@
"""Pydantic-схемы для аутентификации и профиля пользователя."""
import uuid
from pydantic import BaseModel, ConfigDict, EmailStr, Field
class RegisterIn(BaseModel):
"""Тело запроса регистрации нового пользователя.
`team_id` допустим только при включённой настройке инстанса
`registration_team_choice` (см. `GET /auth/registration-options`) и
существующей команде — иначе `POST /auth/register` вернёт 400.
"""
email: EmailStr
name_user: str = Field(min_length=1, max_length=255)
password: str = Field(min_length=8)
team_id: uuid.UUID | None = None
class VerifyEmailIn(BaseModel):
"""Тело запроса подтверждения email."""
token: str = Field(min_length=1)
class TokenOut(BaseModel):
"""Ответ с access-токеном; refresh-токен передаётся отдельно в httpOnly cookie."""
access_token: str
token_type: str = "bearer"
class UserOut(BaseModel):
"""Публичное представление пользователя (профиль текущего пользователя).
`email: str`, а не `EmailStr` — намеренно: это response-схема,
отражающая уже сохранённые в БД данные, а не принимающая новый ввод.
`EmailStr` дополнительно отсекает синтаксически валидные, но
зарезервированные домены (`.local`, `.test` и т.п. из RFC 6761) — валидный
email на входе (`RegisterIn`, ниже) мог быть заведён напрямую в БД (seed,
ручная миграция) с таким доменом; строгая `EmailStr` на выходе привела бы
к 500 `ResponseValidationError` для уже существующих пользователей.
"""
model_config = ConfigDict(from_attributes=True)
id: uuid.UUID
email: str
name_user: str
role: str
class UserListItemOut(BaseModel):
"""Элемент списка пользователей (пикер участников конференции/мультиселект брони)."""
id: uuid.UUID
display_name: str
avatar_url: str | None = None
class ProfileUpdateIn(BaseModel):
"""Тело правки профиля текущего пользователя.
Email НЕ принимается — read-only поле профиля. `team_id` не различает
«поле не передано» и «явный `null`» через сравнение с `None` — роутер
читает `model_fields_set` (тот же приём, что `AdminUserUpdateIn.team_id`).
"""
name_user: str | None = Field(default=None, min_length=1, max_length=255)
team_id: uuid.UUID | None = None
class UserProfileOut(UserOut):
"""Профиль пользователя (свой либо открытый администратором) — `UserOut` + аватар/команда."""
avatar_url: str | None = None
team_id: uuid.UUID | None = None
team_name: str | None = None
class PasswordChangeIn(BaseModel):
"""Тело смены пароля текущим пользователем (`POST /users/me/password`).
Политика сложности `new_password` — та же, что при регистрации
(`RegisterIn.password`, min 8 символов).
"""
current_password: str
new_password: str = Field(min_length=8)
class RegistrationTeamOptionOut(BaseModel):
"""Команда в списке опций публичной карточки регистрации."""
id: uuid.UUID
name: str
class RegistrationOptionsOut(BaseModel):
"""Публичные опции формы регистрации (`GET /auth/registration-options`).
`teams` отдаётся только при `team_choice_enabled=True` — иначе пустой
список (справочник команд не раскрывается, пока выбор выключен).
`email_domain` — эталонный домен при включённой верификации регистрации
по домену email (настройка инстанса `registration_email_domain`), иначе
`None`.
"""
team_choice_enabled: bool
teams: list[RegistrationTeamOptionOut]
email_domain: str | None = None

83
backend/schemas/chat.py Normal file
View File

@@ -0,0 +1,83 @@
"""Pydantic-схемы протокола WS-чата конференции (`api/chat.py`).
Входящий протокол — дискриминированное объединение по полю `type`: первым
сообщением клиент обязан прислать `auth` (LiveKit access-токен, не
query-параметр — не палим токен в логах nginx), далее — произвольное число
`message`. Исходящий протокол — `history` (один раз, сразу после успешной
аутентификации), `message` (broadcast через Redis pub/sub) и `error`.
"""
from datetime import UTC, datetime
from typing import Annotated, Any, Literal
from pydantic import BaseModel, Field, TypeAdapter, field_serializer, field_validator
# Ограничение длины текста сообщения.
MAX_MESSAGE_LENGTH = 2000
def _to_iso_z(value: datetime) -> str:
"""Отформатировать aware-datetime как UTC ISO-строку с суффиксом `Z`."""
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
class ChatAuthIn(BaseModel):
"""Первое сообщение клиента — аутентификация LiveKit access-токеном."""
type: Literal["auth"]
token: str
class ChatMessageIn(BaseModel):
"""Сообщение клиента с текстом чата — text обрезается по пробелам и не должен быть пустым."""
type: Literal["message"]
text: str = Field(min_length=1, max_length=MAX_MESSAGE_LENGTH)
@field_validator("text", mode="before")
@classmethod
def _strip(cls, value: Any) -> Any:
return value.strip() if isinstance(value, str) else value
# Дискриминированное объединение входящих сообщений клиента по полю `type`.
ChatClientEnvelope = Annotated[ChatAuthIn | ChatMessageIn, Field(discriminator="type")]
chat_client_envelope_adapter: TypeAdapter[ChatAuthIn | ChatMessageIn] = TypeAdapter(
ChatClientEnvelope
)
class ChatMessageOut(BaseModel):
"""Одно сообщение чата в исходящем протоколе (history/broadcast)."""
id: int
author_id: str | None
author_name: str
is_guest: bool
text: str
created_at: datetime
@field_serializer("created_at")
def _serialize_created_at(self, value: datetime) -> str:
return _to_iso_z(value)
class ChatHistoryOut(BaseModel):
"""История последних сообщений открытой сессии — отправляется один раз после auth."""
type: Literal["history"] = "history"
messages: list[ChatMessageOut]
class ChatMessageEventOut(BaseModel):
"""Одно новое сообщение чата — broadcast через Redis pub/sub (в т.ч. отправителю)."""
type: Literal["message"] = "message"
message: ChatMessageOut
class ChatErrorOut(BaseModel):
"""Сообщение об ошибке протокола (например, невалидный текст) без разрыва соединения."""
type: Literal["error"] = "error"
code: str

View File

@@ -0,0 +1,212 @@
"""Pydantic-схемы для конференций (`/api/v1/conferences`) и их join-потока (ADR-001)."""
import uuid
from datetime import UTC, datetime, timedelta
from pydantic import BaseModel, EmailStr, Field, field_serializer, field_validator, model_validator
from core.plugins.config import SummaryRecipientsMode
from services.recurrence import RecurrenceRule
# Допуск в прошлое при плановом создании/правке — небольшой запас на задержку
# сети/рассинхронизацию часов клиента (перенесено из старых `schemas/bookings.py`).
PAST_TOLERANCE = timedelta(minutes=1)
def _to_iso_z(value: datetime) -> str:
"""Отформатировать aware-datetime как UTC ISO-строку с суффиксом `Z`."""
return value.astimezone(UTC).isoformat().replace("+00:00", "Z")
def _require_aware_utc(value: datetime) -> datetime:
"""Требовать явную таймзону и привести значение к UTC (в БД и API — только UTC)."""
if value.tzinfo is None:
raise ValueError("datetime_must_be_timezone_aware")
return value.astimezone(UTC)
class InviteeIn(BaseModel):
"""Один приглашённый участник в теле создания/правки конференции (ADR-003).
Ровно одно из `user_id`/`email` — зарегистрированный пользователь ИЛИ
внешний адрес; email нормализуется в lower-case (совпадает с хранением в
`conference_invitees.email`).
"""
user_id: uuid.UUID | None = None
email: EmailStr | None = None
@field_validator("email")
@classmethod
def _normalize_email(cls, value: str | None) -> str | None:
return value.lower() if value is not None else None
@model_validator(mode="after")
def _validate(self) -> "InviteeIn":
if (self.user_id is None) == (self.email is None):
raise ValueError("invitee_requires_exactly_one_identity")
return self
class InviteeOut(BaseModel):
"""Приглашённый в ответе API. Организатор — всегда первый элемент `participants`."""
user_id: uuid.UUID | None = None
email: str | None = None
name: str | None = None
avatar_url: str | None = None
is_organizer: bool = False
class ConferenceCreateIn(BaseModel):
"""Тело запроса создания конференции.
Без `scheduled_at` — мгновенная конференция (создатель входит сразу же,
ответ содержит `join`); с `scheduled_at` — плановая (`status=scheduled`).
"""
title: str | None = Field(default=None, max_length=255)
scheduled_at: datetime | None = None
duration_minutes: int | None = Field(default=None, gt=0)
is_pinned: bool = False
recurrence: RecurrenceRule | None = None
is_closed: bool = False
password: str | None = Field(default=None, min_length=4)
# Переопределение рассылки саммари: `None` — дефолт
# инстанса (`instance_settings['summary_recipients']`).
summary_recipients: SummaryRecipientsMode | None = None
# Состав приглашённых (ADR-003); `None` — без участников (кроме
# организатора, который добавляется автоматически и неудаляемо).
participants: list[InviteeIn] | None = None
@field_validator("scheduled_at")
@classmethod
def _normalize_scheduled_at(cls, value: datetime | None) -> datetime | None:
return _require_aware_utc(value) if value is not None else None
@model_validator(mode="after")
def _validate(self) -> "ConferenceCreateIn":
if self.is_closed and not self.password:
raise ValueError("closed_conference_requires_password")
if self.recurrence is not None and not self.is_pinned:
raise ValueError("recurrence_requires_pinned")
if self.scheduled_at is not None and self.scheduled_at < datetime.now(UTC) - PAST_TOLERANCE:
raise ValueError("scheduled_at_in_the_past")
return self
class ConferenceUpdateIn(BaseModel):
"""Тело запроса частичной правки конференции — все поля опциональны."""
title: str | None = Field(default=None, max_length=255)
scheduled_at: datetime | None = None
duration_minutes: int | None = Field(default=None, gt=0)
is_pinned: bool | None = None
recurrence: RecurrenceRule | None = None
is_closed: bool | None = None
password: str | None = Field(default=None, min_length=4)
# `None` не различает «не передано» и «явный сброс на дефолт инстанса» —
# сервис читает `model_fields_set` (тот же паттерн, что у `recurrence`).
summary_recipients: SummaryRecipientsMode | None = None
# `None` — не менять состав; список — полная замена (diff считает backend,
# ADR-003, п.3). Организатор неудаляем и в списке не нужен — молча
# дедуплицируется, если всё же передан.
participants: list[InviteeIn] | None = None
@field_validator("scheduled_at")
@classmethod
def _normalize_scheduled_at(cls, value: datetime | None) -> datetime | None:
return _require_aware_utc(value) if value is not None else None
class JoinOut(BaseModel):
"""Данные, необходимые клиенту для подключения к LiveKit-комнате конференции."""
livekit_url: str
token: str
room_name: str
conference_id: uuid.UUID
# Тоггл инстанса `chat.enabled` на момент входа — клиент решает,
# показывать ли UI чата, не дожидаясь ошибки WS-подключения.
chat_enabled: bool
class ConferenceOut(BaseModel):
"""Конференция в ответе API.
`participants` заполняется только в детальных ответах (создание, правка,
`GET /conferences/{id}`) — списочные эндпоинты (`/my`, `/calendar`) состав
не раздувают и оставляют его пустым (ADR-003, п.5).
"""
id: uuid.UUID
number: str
slug: str
title: str | None
status: str
is_pinned: bool
is_closed: bool
scheduled_at: datetime | None
duration_minutes: int | None
recurrence: RecurrenceRule | None
next_occurrence: datetime | None = None
created_at: datetime
join: JoinOut | None = None
# `None` = используется дефолт инстанса (`instance_settings['summary_recipients']`).
summary_recipients: SummaryRecipientsMode | None = None
owner_id: uuid.UUID | None = None
# Относительно ТЕКУЩЕГО пользователя запроса (не обязательно владелец).
is_owner: bool = False
organizer_name: str | None = None
participants: list[InviteeOut] = Field(default_factory=list)
@field_serializer("scheduled_at", "created_at", "next_occurrence")
def _serialize_utc_z(self, value: datetime | None) -> str | None:
return _to_iso_z(value) if value is not None else None
class OccurrenceOut(BaseModel):
"""Одно вхождение конференции (закреплённой с повторением или разовой) в календаре."""
conference_id: uuid.UUID
title: str | None
starts_at: datetime
ends_at: datetime
number: str
slug: str
is_pinned: bool
is_closed: bool
@field_serializer("starts_at", "ends_at")
def _serialize_utc_z(self, value: datetime) -> str:
return _to_iso_z(value)
class ResolveOut(BaseModel):
"""Публичное представление конференции по номеру/ссылке (экран входа, без auth).
Для завершённой (`status=ended`) конференции `is_closed`/`requires_password`
намеренно не заполняются (`None`) — вход всё равно невозможен (410 у
`join`/`guest-join`), а признак закрытости уже неактуален (ADR-001, п.4,
уточнение резолва).
"""
id: uuid.UUID
title: str | None
status: str
is_closed: bool | None = None
requires_password: bool | None = None
class JoinIn(BaseModel):
"""Тело запроса входа зарегистрированного пользователя — пароль закрытой конференции."""
password: str | None = None
class GuestJoinIn(BaseModel):
"""Тело запроса гостевого входа: представиться (имя обязательно, email — факультативно)."""
display_name: str = Field(min_length=1, max_length=255)
email: EmailStr | None = None
password: str | None = None