Новый эндпоинт POST /conferences/{id}/mute-participant: права проверяются
ЗАНОВО по владельцу конференции в БД (ConferenceService.mute_participant),
не по метаданным LiveKit-токена вызывающего — те лишь подсказка для UI и
потенциально подделываемы клиентом. Обычный участник получает 403, чужая/
несуществующая конференция — 404, участник не в комнате LiveKit — отдельный
404 (participant_not_in_room).
Само выключение — серверный вызов api.LiveKitAPI (services/room_control.py,
тот же паттерн, что services/egress.py): backend аутентифицируется
СОБСТВЕННЫМИ api_key/api_secret, а не токеном организатора, поэтому
дополнительный LiveKit-грант в токене организатора не нужен — мьютит сервер
от своего имени. Если трек данного source не опубликован (с 0.0.15 участники
заходят с выключенными микрофоном/камерой) — не ошибка, а no-op: искомое
состояние уже достигнуто, ответ muted:false.
Уведомление участника — тот же общий канал комнаты, что и очередь рук
(hand_queue_channel): рассылается всем, получатель сам сверяет identity
(ForcedMuteWatcher, рендерится внутри LiveKitRoom). Само выключение трека
участник видит сразу через штатный useTrackToggle (LiveKit сам присылает
TrackMuted), тост только поясняет причину — иначе не отличить от глюка.
Включить себя обратно можно сразу тем же тулбаром, сервер это не блокирует.
Кнопки — на чужой плитке камеры, видны только организатору по наведению
(на тач-устройствах — всегда, как и булавка закрепления).
Тесты: владелец мьютит успешно и публикует broadcast, уже-выключенный трек —
muted:false без broadcast, администратор мьютит чужую конференцию, обычный
участник получает 403 без обращения к LiveKit, конференция не найдена и
участник не в комнате — соответствующие 404.
232 lines
9.9 KiB
Python
232 lines
9.9 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 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
|
||
|
||
|
||
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
|