first commit
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled

This commit is contained in:
2026-07-23 02:38:05 +03:00
commit 8757bec8ac
335 changed files with 61527 additions and 0 deletions

View 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