feat(room): очередь поднятых рук видна всем + отключаемый модуль
Раньше HandQueueMenu.tsx рендерился только организатору — теперь очередь видит любой участник, но опустить чужую руку по-прежнему может только организатор (сервер это уже проверял, менял только фронт). Кнопка «Опустить» показывается у записи, только если это своя рука или пользователь — организатор. Модуль «поднятие руки» (кнопка «Рука» + очередь целиком) — отключаемый в админке (instance_settings.hand_queue, дефолт enabled=true, как у chat_enabled). Настройка едет участнику в JoinOut ещё до входа в комнату; выключенный модуль гасит кнопки и на фронте, и на бэке — raise_hand/lower_hand отклоняются кодом hand_queue_disabled, если модуль выключен, даже если у клиента на руках старый JoinOut.
This commit is contained in:
@@ -456,6 +456,7 @@ def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -
|
||||
"""Собрать `SettingsOut` из эффективной конфигурации + доступность уровней AI."""
|
||||
return SettingsOut(
|
||||
chat_enabled=cfg.chat.enabled,
|
||||
hand_queue_enabled=cfg.hand_queue.enabled,
|
||||
transcription_enabled=cfg.transcriber.enabled,
|
||||
ai_level=cfg.ai_level,
|
||||
ai_levels=detect_ai_levels(cfg),
|
||||
|
||||
@@ -71,6 +71,7 @@ async def chat_websocket(
|
||||
except ChatAuthError as exc:
|
||||
await _close_quietly(websocket, exc.close_code)
|
||||
return
|
||||
hand_queue_enabled = await service.hand_queue_enabled()
|
||||
|
||||
pubsub = redis_client.pubsub()
|
||||
channel = chat_channel(conference.id)
|
||||
@@ -94,7 +95,11 @@ async def chat_websocket(
|
||||
|
||||
async with asyncio.TaskGroup() as tg:
|
||||
tg.create_task(_pump_pubsub_to_websocket(websocket, pubsub, channel, seen_ids))
|
||||
tg.create_task(_pump_websocket_to_service(websocket, service, conference, identity))
|
||||
tg.create_task(
|
||||
_pump_websocket_to_service(
|
||||
websocket, service, conference, identity, hand_queue_enabled
|
||||
)
|
||||
)
|
||||
except* WebSocketDisconnect:
|
||||
# Штатное закрытие соединения клиентом — не ошибка.
|
||||
pass
|
||||
@@ -163,9 +168,20 @@ async def _pump_pubsub_to_websocket(
|
||||
|
||||
|
||||
async def _pump_websocket_to_service(
|
||||
websocket: WebSocket, service: ChatService, conference: Conference, identity: ChatIdentity
|
||||
websocket: WebSocket,
|
||||
service: ChatService,
|
||||
conference: Conference,
|
||||
identity: ChatIdentity,
|
||||
hand_queue_enabled: bool,
|
||||
) -> None:
|
||||
"""Читать сообщения клиента (текст чата / поднять-опустить руку), валидировать и обработать."""
|
||||
"""Читать сообщения клиента (текст чата / поднять-опустить руку), валидировать и обработать.
|
||||
|
||||
`hand_queue_enabled` — снятый один раз при подключении тоггл модуля
|
||||
«поднятие руки» (см. `ChatService.hand_queue_enabled`): при `False`
|
||||
`raise_hand`/`lower_hand` отклоняются кодом `hand_queue_disabled` — вторая
|
||||
линия защиты сверх того, что фронт при выключенном модуле вообще не
|
||||
рисует кнопки (см. `RoomToolbar`/`HandQueueMenu`).
|
||||
"""
|
||||
is_organizer = conference.owner_id is not None and conference.owner_id == identity.user_id
|
||||
while True:
|
||||
raw = await websocket.receive_text()
|
||||
@@ -178,11 +194,21 @@ async def _pump_websocket_to_service(
|
||||
if isinstance(envelope, ChatMessageIn):
|
||||
await service.persist_and_publish(conference, identity=identity, text=envelope.text)
|
||||
elif isinstance(envelope, RaiseHandIn):
|
||||
if not hand_queue_enabled:
|
||||
await websocket.send_json(
|
||||
ChatErrorOut(code="hand_queue_disabled").model_dump(mode="json")
|
||||
)
|
||||
continue
|
||||
await hand_queue.raise_hand(
|
||||
conference.id, identity=_identity_key(identity), name=identity.author_name
|
||||
)
|
||||
await hand_queue.publish_snapshot(conference.id)
|
||||
else:
|
||||
if not hand_queue_enabled:
|
||||
await websocket.send_json(
|
||||
ChatErrorOut(code="hand_queue_disabled").model_dump(mode="json")
|
||||
)
|
||||
continue
|
||||
target = envelope.identity or _identity_key(identity)
|
||||
if target != _identity_key(identity) and not is_organizer:
|
||||
await websocket.send_json(
|
||||
|
||||
@@ -33,12 +33,19 @@ class ChatConfig(BaseModel):
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class HandQueueConfig(BaseModel):
|
||||
"""Конфигурация переключателя модуля «поднятие руки» (кнопка + очередь целиком)."""
|
||||
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class PluginsConfig(BaseModel):
|
||||
"""Корневая модель конфигурации для `config/plugins.yaml`."""
|
||||
|
||||
transcriber: TranscriberConfig = Field(default_factory=TranscriberConfig)
|
||||
summarizer: SummarizerConfig = Field(default_factory=SummarizerConfig)
|
||||
chat: ChatConfig = Field(default_factory=ChatConfig)
|
||||
hand_queue: HandQueueConfig = Field(default_factory=HandQueueConfig)
|
||||
|
||||
|
||||
def load_plugins_config(path: str | Path) -> PluginsConfig:
|
||||
@@ -92,6 +99,7 @@ class InstanceConfig(BaseModel):
|
||||
transcriber: TranscriberConfig
|
||||
summarizer: SummarizerConfig
|
||||
chat: ChatConfig
|
||||
hand_queue: HandQueueConfig = Field(default_factory=HandQueueConfig)
|
||||
ai_level: AiLevel = "min"
|
||||
summary_recipients: SummaryRecipientsMode = "all"
|
||||
display_timezone: str = "Europe/Moscow"
|
||||
|
||||
@@ -147,6 +147,7 @@ class SettingsOut(BaseModel):
|
||||
"""
|
||||
|
||||
chat_enabled: bool
|
||||
hand_queue_enabled: bool
|
||||
transcription_enabled: bool
|
||||
ai_level: AiLevel
|
||||
ai_levels: list[AiLevelStatus]
|
||||
|
||||
@@ -130,6 +130,9 @@ class JoinOut(BaseModel):
|
||||
# Тоггл инстанса `chat.enabled` на момент входа — клиент решает,
|
||||
# показывать ли UI чата, не дожидаясь ошибки WS-подключения.
|
||||
chat_enabled: bool
|
||||
# Тоггл инстанса `hand_queue.enabled` на момент входа — гасит модуль
|
||||
# «поднятие руки» целиком (кнопка + очередь), см. `HandQueueConfig`.
|
||||
hand_queue_enabled: bool
|
||||
# Рычаги нагрузки медиа (`instance_settings.media_limits`) — отдаются
|
||||
# прямо в join-ответе, а не только в админке: участнику нужно иметь их
|
||||
# на руках ДО публикации своего трека (см. `services/conference_access.py`).
|
||||
|
||||
@@ -148,6 +148,15 @@ class ChatService:
|
||||
raise WrongRoomError
|
||||
return conference
|
||||
|
||||
async def hand_queue_enabled(self) -> bool:
|
||||
"""Тоггл инстанса `hand_queue.enabled` — снимается один раз при подключении WS
|
||||
(см. `api/chat.py::chat_websocket`), а не на каждое сообщение: та же
|
||||
осознанная «застылость» на время жизни соединения, что и у
|
||||
`is_organizer` в `_pump_websocket_to_service` — переключение модуля
|
||||
администратором применяется со следующего подключения."""
|
||||
cfg = await InstanceSettingsService(self._session).get()
|
||||
return cfg.hand_queue.enabled
|
||||
|
||||
async def history(self, conference: Conference) -> list[ChatMessageOut]:
|
||||
"""Последние сообщения открытой сессии конференции (пусто, если сессии ещё нет)."""
|
||||
session_record = await self._sessions.get_open_by_conference(conference.id)
|
||||
|
||||
@@ -53,6 +53,7 @@ def build_join(
|
||||
identity: str,
|
||||
name: str,
|
||||
chat_enabled: bool,
|
||||
hand_queue_enabled: bool,
|
||||
publish_quality_cap: PublishQualityCap,
|
||||
stage_max_tiles: StageMaxTiles,
|
||||
avatar_url: str | None = None,
|
||||
@@ -61,9 +62,10 @@ def build_join(
|
||||
"""Построить ответ join: LiveKit access-токен для входа в комнату конференции.
|
||||
|
||||
Имя LiveKit-комнаты всегда равно `conference.slug` (ADR-001, п.4).
|
||||
`chat_enabled`/`publish_quality_cap`/`stage_max_tiles` — снятые вызывающей
|
||||
стороной значения `instance_settings`: читаются здесь параметрами, а не
|
||||
заново из БД, чтобы не плодить отдельный запрос настроек на каждый join.
|
||||
`chat_enabled`/`hand_queue_enabled`/`publish_quality_cap`/`stage_max_tiles`
|
||||
— снятые вызывающей стороной значения `instance_settings`: читаются здесь
|
||||
параметрами, а не заново из БД, чтобы не плодить отдельный запрос настроек
|
||||
на каждый join.
|
||||
`avatar_url`/`is_organizer`
|
||||
прокидываются в метаданные токена как JSON `{"avatar_url": ..., "is_organizer": true}`
|
||||
— поля добавляются, только если заданы (гость без аватара и не-организатор
|
||||
@@ -91,6 +93,7 @@ def build_join(
|
||||
room_name=conference.slug,
|
||||
conference_id=conference.id,
|
||||
chat_enabled=chat_enabled,
|
||||
hand_queue_enabled=hand_queue_enabled,
|
||||
publish_quality_cap=publish_quality_cap,
|
||||
stage_max_tiles=stage_max_tiles,
|
||||
)
|
||||
|
||||
@@ -155,6 +155,7 @@ class ConferenceService:
|
||||
identity=str(owner_id),
|
||||
name=owner_name,
|
||||
chat_enabled=cfg.chat.enabled,
|
||||
hand_queue_enabled=cfg.hand_queue.enabled,
|
||||
publish_quality_cap=cfg.media_limits.publish_quality_cap,
|
||||
stage_max_tiles=cfg.media_limits.stage_max_tiles,
|
||||
avatar_url=resolve_avatar_url(self._media_root, owner_avatar_path),
|
||||
@@ -251,6 +252,7 @@ class ConferenceService:
|
||||
identity=str(user.id),
|
||||
name=user.name_user,
|
||||
chat_enabled=cfg.chat.enabled,
|
||||
hand_queue_enabled=cfg.hand_queue.enabled,
|
||||
publish_quality_cap=cfg.media_limits.publish_quality_cap,
|
||||
stage_max_tiles=cfg.media_limits.stage_max_tiles,
|
||||
avatar_url=resolve_avatar_url(self._media_root, user.avatar_path),
|
||||
@@ -275,6 +277,7 @@ class ConferenceService:
|
||||
identity=f"guest:{guest.id}",
|
||||
name=data.display_name,
|
||||
chat_enabled=cfg.chat.enabled,
|
||||
hand_queue_enabled=cfg.hand_queue.enabled,
|
||||
publish_quality_cap=cfg.media_limits.publish_quality_cap,
|
||||
stage_max_tiles=cfg.media_limits.stage_max_tiles,
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Хранилище настроек инстанса (`instance_settings`, key-value JSONB) и их бутстрап.
|
||||
|
||||
Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`,
|
||||
`ai_level`, `summary_recipients`, `display_timezone`,
|
||||
`hand_queue`, `ai_level`, `summary_recipients`, `display_timezone`,
|
||||
`registration_team_choice`, `registration_email_domain`, `contact_email`,
|
||||
`media_limits`) —
|
||||
новая настройка не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`)
|
||||
@@ -28,6 +28,7 @@ from core.config import Settings
|
||||
from core.plugins.config import (
|
||||
AiLevel,
|
||||
ChatConfig,
|
||||
HandQueueConfig,
|
||||
InstanceConfig,
|
||||
MediaLimitsConfig,
|
||||
PluginsConfig,
|
||||
@@ -45,6 +46,7 @@ from services.ai_tiers import TIERS
|
||||
_KEY_TRANSCRIBER = "transcriber"
|
||||
_KEY_SUMMARIZER = "summarizer"
|
||||
_KEY_CHAT = "chat"
|
||||
_KEY_HAND_QUEUE = "hand_queue"
|
||||
_KEY_AI_LEVEL = "ai_level"
|
||||
_KEY_SUMMARY_RECIPIENTS = "summary_recipients"
|
||||
_KEY_DISPLAY_TIMEZONE = "display_timezone"
|
||||
@@ -100,6 +102,7 @@ class SettingsUpdateIn(BaseModel):
|
||||
"""
|
||||
|
||||
chat_enabled: bool | None = None
|
||||
hand_queue_enabled: bool | None = None
|
||||
transcription_enabled: bool | None = None
|
||||
ai_level: AiLevel | None = None
|
||||
summary_recipients: SummaryRecipientsMode | None = None
|
||||
@@ -154,6 +157,7 @@ def build_bootstrap_defaults(
|
||||
_KEY_TRANSCRIBER: plugins.transcriber.model_dump(mode="json"),
|
||||
_KEY_SUMMARIZER: plugins.summarizer.model_dump(mode="json"),
|
||||
_KEY_CHAT: plugins.chat.model_dump(mode="json"),
|
||||
_KEY_HAND_QUEUE: plugins.hand_queue.model_dump(mode="json"),
|
||||
_KEY_AI_LEVEL: dict(_DEFAULT_AI_LEVEL_VALUE),
|
||||
_KEY_SUMMARY_RECIPIENTS: dict(_DEFAULT_SUMMARY_RECIPIENTS_VALUE),
|
||||
_KEY_DISPLAY_TIMEZONE: dict(_DEFAULT_DISPLAY_TIMEZONE_VALUE),
|
||||
@@ -286,6 +290,10 @@ class InstanceSettingsService:
|
||||
cfg.chat = ChatConfig(enabled=patch.chat_enabled)
|
||||
await self._set(_KEY_CHAT, cfg.chat.model_dump(mode="json"))
|
||||
|
||||
if patch.hand_queue_enabled is not None:
|
||||
cfg.hand_queue = HandQueueConfig(enabled=patch.hand_queue_enabled)
|
||||
await self._set(_KEY_HAND_QUEUE, cfg.hand_queue.model_dump(mode="json"))
|
||||
|
||||
if patch.registration_team_choice is not None:
|
||||
cfg.registration_team_choice = patch.registration_team_choice
|
||||
await self._set(
|
||||
@@ -399,6 +407,7 @@ async def load_effective_config(session: AsyncSession) -> InstanceConfig:
|
||||
transcriber=plugins.transcriber,
|
||||
summarizer=plugins.summarizer,
|
||||
chat=plugins.chat,
|
||||
hand_queue=plugins.hand_queue,
|
||||
)
|
||||
else:
|
||||
cfg = _build_config(rows)
|
||||
@@ -492,6 +501,7 @@ def _build_config(rows: dict[str, Any]) -> InstanceConfig:
|
||||
transcriber=TranscriberConfig.model_validate(rows.get(_KEY_TRANSCRIBER, {})),
|
||||
summarizer=SummarizerConfig.model_validate(rows.get(_KEY_SUMMARIZER, {})),
|
||||
chat=ChatConfig.model_validate(rows.get(_KEY_CHAT, {})),
|
||||
hand_queue=HandQueueConfig.model_validate(rows.get(_KEY_HAND_QUEUE, {})),
|
||||
ai_level=rows.get(_KEY_AI_LEVEL, _DEFAULT_AI_LEVEL_VALUE).get("level", "min"),
|
||||
summary_recipients=rows.get(_KEY_SUMMARY_RECIPIENTS, _DEFAULT_SUMMARY_RECIPIENTS_VALUE).get(
|
||||
"mode", "all"
|
||||
|
||||
@@ -646,12 +646,17 @@ async def test_put_settings_partial_update(
|
||||
|
||||
response = await client.put(
|
||||
"/api/v1/admin/settings",
|
||||
json={"chat_enabled": False, "display_timezone": "Asia/Yekaterinburg"},
|
||||
json={
|
||||
"chat_enabled": False,
|
||||
"hand_queue_enabled": False,
|
||||
"display_timezone": "Asia/Yekaterinburg",
|
||||
},
|
||||
headers=_auth_headers(admin),
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
body = response.json()
|
||||
assert body["chat_enabled"] is False
|
||||
assert body["hand_queue_enabled"] is False
|
||||
assert body["display_timezone"] == "Asia/Yekaterinburg"
|
||||
assert body["ai_level"] == "min"
|
||||
assert body["transcription_queue_served"] is False
|
||||
|
||||
@@ -8,13 +8,15 @@ import uuid
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from core.security import hash_password
|
||||
from core.security import create_access_token, hash_password
|
||||
from models.conference import Conference
|
||||
from models.guest import GuestAccess
|
||||
from models.user import User
|
||||
from services.conference_ids import generate_number, generate_slug
|
||||
from services.instance_settings import InstanceSettingsService, SettingsUpdateIn
|
||||
from services.livekit_tokens import create_room_access_token
|
||||
from tests.conftest import ASGIWebSocketSession
|
||||
|
||||
@@ -270,3 +272,66 @@ async def test_organizer_joining_late_sees_already_raised_hands(
|
||||
initial_queue = await _connect_auth_and_queue(ws_owner, _user_token(conference, owner))
|
||||
|
||||
assert _identities(initial_queue) == [str(alice.id)]
|
||||
|
||||
|
||||
# --- Отключаемый модуль (`instance_settings.hand_queue`) ---------------------
|
||||
|
||||
|
||||
async def test_hand_queue_disabled_rejects_raise_and_lower(
|
||||
db_session: AsyncSession, ws_client: WSFactory
|
||||
) -> None:
|
||||
"""Выключенный модуль — вторая линия защиты сверх фронта: `raise_hand`/
|
||||
`lower_hand` отклоняются кодом `hand_queue_disabled`, очередь не меняется."""
|
||||
conference = await _make_conference(db_session)
|
||||
alice = await _make_user(db_session, name="Alice")
|
||||
await db_session.commit()
|
||||
|
||||
await InstanceSettingsService(db_session).update(SettingsUpdateIn(hand_queue_enabled=False))
|
||||
|
||||
ws = ws_client(_chat_path(conference.id))
|
||||
await _connect_auth_and_queue(ws, _user_token(conference, alice))
|
||||
|
||||
await ws.send_json({"type": "raise_hand"})
|
||||
error = await ws.receive_json()
|
||||
assert error == {"type": "error", "code": "hand_queue_disabled"}
|
||||
|
||||
await ws.send_json({"type": "lower_hand"})
|
||||
error = await ws.receive_json()
|
||||
assert error == {"type": "error", "code": "hand_queue_disabled"}
|
||||
|
||||
|
||||
async def test_join_out_reflects_hand_queue_enabled_toggle(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
user = await _make_user(db_session, name="Toggle Tester")
|
||||
await db_session.commit()
|
||||
headers = {"Authorization": f"Bearer {create_access_token(user.id, user.role)}"}
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/conferences", json={"title": "Standup"}, headers=headers
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
assert response.json()["join"]["hand_queue_enabled"] is True
|
||||
|
||||
await InstanceSettingsService(db_session).update(SettingsUpdateIn(hand_queue_enabled=False))
|
||||
|
||||
response = await client.post(
|
||||
"/api/v1/conferences", json={"title": "Standup 2"}, headers=headers
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
assert response.json()["join"]["hand_queue_enabled"] is False
|
||||
|
||||
|
||||
async def test_guest_join_out_reflects_hand_queue_enabled(
|
||||
client: httpx.AsyncClient, db_session: AsyncSession
|
||||
) -> None:
|
||||
conference = await _make_conference(db_session)
|
||||
await db_session.commit()
|
||||
|
||||
await InstanceSettingsService(db_session).update(SettingsUpdateIn(hand_queue_enabled=False))
|
||||
|
||||
response = await client.post(
|
||||
f"/api/v1/conferences/{conference.id}/guest-join", json={"display_name": "Dave"}
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["hand_queue_enabled"] is False
|
||||
|
||||
@@ -65,6 +65,7 @@ _MANAGED_KEYS = (
|
||||
"transcriber",
|
||||
"summarizer",
|
||||
"chat",
|
||||
"hand_queue",
|
||||
"ai_level",
|
||||
"summary_recipients",
|
||||
"display_timezone",
|
||||
@@ -121,6 +122,7 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
|
||||
"transcriber",
|
||||
"summarizer",
|
||||
"chat",
|
||||
"hand_queue",
|
||||
"ai_level",
|
||||
"summary_recipients",
|
||||
"display_timezone",
|
||||
@@ -132,6 +134,9 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
|
||||
cfg = await service.get()
|
||||
assert cfg.transcriber.provider == "faster_whisper_cpu"
|
||||
assert cfg.ai_level == "min"
|
||||
# Дефолт обязан сохранять поведение существующих инсталляций — модуль
|
||||
# «поднятие руки» был доступен всегда, тоггл включён по умолчанию.
|
||||
assert cfg.hand_queue.enabled is True
|
||||
assert cfg.summary_recipients == "all"
|
||||
assert cfg.display_timezone == "Europe/Moscow"
|
||||
assert cfg.registration_team_choice is False
|
||||
@@ -162,6 +167,22 @@ async def test_ensure_bootstrapped_is_idempotent_and_keeps_admin_edits(
|
||||
assert cfg.display_timezone == "Asia/Yekaterinburg"
|
||||
|
||||
|
||||
async def test_update_hand_queue_enabled(
|
||||
db_session: AsyncSession, clean_instance_settings: None
|
||||
) -> None:
|
||||
service = InstanceSettingsService(db_session)
|
||||
await service.ensure_bootstrapped(PLUGINS_YAML)
|
||||
|
||||
cfg = await service.update(SettingsUpdateIn(hand_queue_enabled=False))
|
||||
assert cfg.hand_queue.enabled is False
|
||||
|
||||
cfg = await service.get()
|
||||
assert cfg.hand_queue.enabled is False
|
||||
|
||||
cfg = await service.update(SettingsUpdateIn(hand_queue_enabled=True))
|
||||
assert cfg.hand_queue.enabled is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("preset", "chat_enabled", "ai_enabled", "ai_level"),
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user