Files
vidconf/backend/schemas/conferences.py
Max Ronzhin 173d384f06
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
feat(admin): рычаги нагрузки медиа — потолок качества публикации и лимит плиток
instance_settings.media_limits (publish_quality_cap: off/720p/360p/180p,
stage_max_tiles: 4/9/16/25) — новая вкладка «Нагрузка» в админке, дефолты
(off/25) сохраняют текущее поведение существующих инсталляций.

Настройка отдаётся не только GET /admin/settings, но и в join-ответе
(JoinOut) — участнику нужно иметь её на руках ДО публикации трека, а
/admin/settings доступен только администратору.

Потолок качества применяется через RoomOptions.publishDefaults
(videoEncoding + videoSimulcastLayers на пресетах VideoPresets LiveKit) —
режет битрейт верхнего слоя симулкаста, реальное разрешение WebRTC
подстраивает сам. Лимит плиток — фильтрация STAGE_GRID_LAYOUTS по
columns*rows в StageGrid, лишние участники уходят на страницу пагинации
вместо подписки.

Значение приезжает в joinState вместе с токеном ДО первого рендера
LiveKitRoom (RoomPage не рендерит его, пока joinState не заполнен целиком),
поэтому смена настройки не переподключает уже вошедшего участника —
roomOptions пересчитывается по стабильной ссылке на joinState, которая
после подключения не меняется.

Проверено вживую на локальном стенде (docker compose --profile media):
сохранение/персист настроек, join отдаёт актуальные значения, уже
подключённый участник не разрывается при смене настройки в другом окне.
2026-08-02 19:53:23 +03:00

237 lines
10 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 PublishQualityCap, StageMaxTiles, SummaryRecipientsMode
from schemas.room_events import ForcedMuteSource
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
# Рычаги нагрузки медиа (`instance_settings.media_limits`) — отдаются
# прямо в join-ответе, а не только в админке: участнику нужно иметь их
# на руках ДО публикации своего трека (см. `services/conference_access.py`).
publish_quality_cap: PublishQualityCap
stage_max_tiles: StageMaxTiles
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
class MuteParticipantIn(BaseModel):
"""Тело запроса принудительного мьюта участника организатором (задача B2).
`identity` — тот же формат, что и `Participant.identity` в LiveKit
(`str(user_id)` либо `guest:{id}`); клиент берёт его из `useParticipants()`
LiveKit, не подбирает вручную.
"""
identity: str = Field(min_length=1)
source: ForcedMuteSource
class MuteParticipantOut(BaseModel):
"""Ответ на принудительный мьют — `muted=False`, если трек и так не был опубликован."""
muted: bool