Files
vidconf/backend/schemas/conferences.py

213 lines
9.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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