"""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