213 lines
9.2 KiB
Python
213 lines
9.2 KiB
Python
"""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
|