first commit
This commit is contained in:
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