first commit
This commit is contained in:
1
backend/schemas/__init__.py
Normal file
1
backend/schemas/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Pydantic-схемы (DTO) для входных/выходных данных API."""
|
||||
158
backend/schemas/admin.py
Normal file
158
backend/schemas/admin.py
Normal 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
113
backend/schemas/auth.py
Normal 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
83
backend/schemas/chat.py
Normal 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
|
||||
212
backend/schemas/conferences.py
Normal file
212
backend/schemas/conferences.py
Normal 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
|
||||
Reference in New Issue
Block a user