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:
2026-08-04 18:27:14 +03:00
parent 65fbcf952c
commit 10a3f8b3b4
21 changed files with 274 additions and 62 deletions

View File

@@ -456,6 +456,7 @@ def _to_settings_out(cfg: InstanceConfig, *, transcription_queue_served: bool) -
"""Собрать `SettingsOut` из эффективной конфигурации + доступность уровней AI.""" """Собрать `SettingsOut` из эффективной конфигурации + доступность уровней AI."""
return SettingsOut( return SettingsOut(
chat_enabled=cfg.chat.enabled, chat_enabled=cfg.chat.enabled,
hand_queue_enabled=cfg.hand_queue.enabled,
transcription_enabled=cfg.transcriber.enabled, transcription_enabled=cfg.transcriber.enabled,
ai_level=cfg.ai_level, ai_level=cfg.ai_level,
ai_levels=detect_ai_levels(cfg), ai_levels=detect_ai_levels(cfg),

View File

@@ -71,6 +71,7 @@ async def chat_websocket(
except ChatAuthError as exc: except ChatAuthError as exc:
await _close_quietly(websocket, exc.close_code) await _close_quietly(websocket, exc.close_code)
return return
hand_queue_enabled = await service.hand_queue_enabled()
pubsub = redis_client.pubsub() pubsub = redis_client.pubsub()
channel = chat_channel(conference.id) channel = chat_channel(conference.id)
@@ -94,7 +95,11 @@ async def chat_websocket(
async with asyncio.TaskGroup() as tg: async with asyncio.TaskGroup() as tg:
tg.create_task(_pump_pubsub_to_websocket(websocket, pubsub, channel, seen_ids)) 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: except* WebSocketDisconnect:
# Штатное закрытие соединения клиентом — не ошибка. # Штатное закрытие соединения клиентом — не ошибка.
pass pass
@@ -163,9 +168,20 @@ async def _pump_pubsub_to_websocket(
async def _pump_websocket_to_service( 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: ) -> 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 is_organizer = conference.owner_id is not None and conference.owner_id == identity.user_id
while True: while True:
raw = await websocket.receive_text() raw = await websocket.receive_text()
@@ -178,11 +194,21 @@ async def _pump_websocket_to_service(
if isinstance(envelope, ChatMessageIn): if isinstance(envelope, ChatMessageIn):
await service.persist_and_publish(conference, identity=identity, text=envelope.text) await service.persist_and_publish(conference, identity=identity, text=envelope.text)
elif isinstance(envelope, RaiseHandIn): 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( await hand_queue.raise_hand(
conference.id, identity=_identity_key(identity), name=identity.author_name conference.id, identity=_identity_key(identity), name=identity.author_name
) )
await hand_queue.publish_snapshot(conference.id) await hand_queue.publish_snapshot(conference.id)
else: 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) target = envelope.identity or _identity_key(identity)
if target != _identity_key(identity) and not is_organizer: if target != _identity_key(identity) and not is_organizer:
await websocket.send_json( await websocket.send_json(

View File

@@ -33,12 +33,19 @@ class ChatConfig(BaseModel):
enabled: bool = True enabled: bool = True
class HandQueueConfig(BaseModel):
"""Конфигурация переключателя модуля «поднятие руки» (кнопка + очередь целиком)."""
enabled: bool = True
class PluginsConfig(BaseModel): class PluginsConfig(BaseModel):
"""Корневая модель конфигурации для `config/plugins.yaml`.""" """Корневая модель конфигурации для `config/plugins.yaml`."""
transcriber: TranscriberConfig = Field(default_factory=TranscriberConfig) transcriber: TranscriberConfig = Field(default_factory=TranscriberConfig)
summarizer: SummarizerConfig = Field(default_factory=SummarizerConfig) summarizer: SummarizerConfig = Field(default_factory=SummarizerConfig)
chat: ChatConfig = Field(default_factory=ChatConfig) chat: ChatConfig = Field(default_factory=ChatConfig)
hand_queue: HandQueueConfig = Field(default_factory=HandQueueConfig)
def load_plugins_config(path: str | Path) -> PluginsConfig: def load_plugins_config(path: str | Path) -> PluginsConfig:
@@ -92,6 +99,7 @@ class InstanceConfig(BaseModel):
transcriber: TranscriberConfig transcriber: TranscriberConfig
summarizer: SummarizerConfig summarizer: SummarizerConfig
chat: ChatConfig chat: ChatConfig
hand_queue: HandQueueConfig = Field(default_factory=HandQueueConfig)
ai_level: AiLevel = "min" ai_level: AiLevel = "min"
summary_recipients: SummaryRecipientsMode = "all" summary_recipients: SummaryRecipientsMode = "all"
display_timezone: str = "Europe/Moscow" display_timezone: str = "Europe/Moscow"

View File

@@ -147,6 +147,7 @@ class SettingsOut(BaseModel):
""" """
chat_enabled: bool chat_enabled: bool
hand_queue_enabled: bool
transcription_enabled: bool transcription_enabled: bool
ai_level: AiLevel ai_level: AiLevel
ai_levels: list[AiLevelStatus] ai_levels: list[AiLevelStatus]

View File

@@ -130,6 +130,9 @@ class JoinOut(BaseModel):
# Тоггл инстанса `chat.enabled` на момент входа — клиент решает, # Тоггл инстанса `chat.enabled` на момент входа — клиент решает,
# показывать ли UI чата, не дожидаясь ошибки WS-подключения. # показывать ли UI чата, не дожидаясь ошибки WS-подключения.
chat_enabled: bool chat_enabled: bool
# Тоггл инстанса `hand_queue.enabled` на момент входа — гасит модуль
# «поднятие руки» целиком (кнопка + очередь), см. `HandQueueConfig`.
hand_queue_enabled: bool
# Рычаги нагрузки медиа (`instance_settings.media_limits`) — отдаются # Рычаги нагрузки медиа (`instance_settings.media_limits`) — отдаются
# прямо в join-ответе, а не только в админке: участнику нужно иметь их # прямо в join-ответе, а не только в админке: участнику нужно иметь их
# на руках ДО публикации своего трека (см. `services/conference_access.py`). # на руках ДО публикации своего трека (см. `services/conference_access.py`).

View File

@@ -148,6 +148,15 @@ class ChatService:
raise WrongRoomError raise WrongRoomError
return conference 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]: async def history(self, conference: Conference) -> list[ChatMessageOut]:
"""Последние сообщения открытой сессии конференции (пусто, если сессии ещё нет).""" """Последние сообщения открытой сессии конференции (пусто, если сессии ещё нет)."""
session_record = await self._sessions.get_open_by_conference(conference.id) session_record = await self._sessions.get_open_by_conference(conference.id)

View File

@@ -53,6 +53,7 @@ def build_join(
identity: str, identity: str,
name: str, name: str,
chat_enabled: bool, chat_enabled: bool,
hand_queue_enabled: bool,
publish_quality_cap: PublishQualityCap, publish_quality_cap: PublishQualityCap,
stage_max_tiles: StageMaxTiles, stage_max_tiles: StageMaxTiles,
avatar_url: str | None = None, avatar_url: str | None = None,
@@ -61,9 +62,10 @@ def build_join(
"""Построить ответ join: LiveKit access-токен для входа в комнату конференции. """Построить ответ join: LiveKit access-токен для входа в комнату конференции.
Имя LiveKit-комнаты всегда равно `conference.slug` (ADR-001, п.4). Имя LiveKit-комнаты всегда равно `conference.slug` (ADR-001, п.4).
`chat_enabled`/`publish_quality_cap`/`stage_max_tiles` — снятые вызывающей `chat_enabled`/`hand_queue_enabled`/`publish_quality_cap`/`stage_max_tiles`
стороной значения `instance_settings`: читаются здесь параметрами, а не — снятые вызывающей стороной значения `instance_settings`: читаются здесь
заново из БД, чтобы не плодить отдельный запрос настроек на каждый join. параметрами, а не заново из БД, чтобы не плодить отдельный запрос настроек
на каждый join.
`avatar_url`/`is_organizer` `avatar_url`/`is_organizer`
прокидываются в метаданные токена как JSON `{"avatar_url": ..., "is_organizer": true}` прокидываются в метаданные токена как JSON `{"avatar_url": ..., "is_organizer": true}`
— поля добавляются, только если заданы (гость без аватара и не-организатор — поля добавляются, только если заданы (гость без аватара и не-организатор
@@ -91,6 +93,7 @@ def build_join(
room_name=conference.slug, room_name=conference.slug,
conference_id=conference.id, conference_id=conference.id,
chat_enabled=chat_enabled, chat_enabled=chat_enabled,
hand_queue_enabled=hand_queue_enabled,
publish_quality_cap=publish_quality_cap, publish_quality_cap=publish_quality_cap,
stage_max_tiles=stage_max_tiles, stage_max_tiles=stage_max_tiles,
) )

View File

@@ -155,6 +155,7 @@ class ConferenceService:
identity=str(owner_id), identity=str(owner_id),
name=owner_name, name=owner_name,
chat_enabled=cfg.chat.enabled, chat_enabled=cfg.chat.enabled,
hand_queue_enabled=cfg.hand_queue.enabled,
publish_quality_cap=cfg.media_limits.publish_quality_cap, publish_quality_cap=cfg.media_limits.publish_quality_cap,
stage_max_tiles=cfg.media_limits.stage_max_tiles, stage_max_tiles=cfg.media_limits.stage_max_tiles,
avatar_url=resolve_avatar_url(self._media_root, owner_avatar_path), avatar_url=resolve_avatar_url(self._media_root, owner_avatar_path),
@@ -251,6 +252,7 @@ class ConferenceService:
identity=str(user.id), identity=str(user.id),
name=user.name_user, name=user.name_user,
chat_enabled=cfg.chat.enabled, chat_enabled=cfg.chat.enabled,
hand_queue_enabled=cfg.hand_queue.enabled,
publish_quality_cap=cfg.media_limits.publish_quality_cap, publish_quality_cap=cfg.media_limits.publish_quality_cap,
stage_max_tiles=cfg.media_limits.stage_max_tiles, stage_max_tiles=cfg.media_limits.stage_max_tiles,
avatar_url=resolve_avatar_url(self._media_root, user.avatar_path), avatar_url=resolve_avatar_url(self._media_root, user.avatar_path),
@@ -275,6 +277,7 @@ class ConferenceService:
identity=f"guest:{guest.id}", identity=f"guest:{guest.id}",
name=data.display_name, name=data.display_name,
chat_enabled=cfg.chat.enabled, chat_enabled=cfg.chat.enabled,
hand_queue_enabled=cfg.hand_queue.enabled,
publish_quality_cap=cfg.media_limits.publish_quality_cap, publish_quality_cap=cfg.media_limits.publish_quality_cap,
stage_max_tiles=cfg.media_limits.stage_max_tiles, stage_max_tiles=cfg.media_limits.stage_max_tiles,
) )

View File

@@ -1,7 +1,7 @@
"""Хранилище настроек инстанса (`instance_settings`, key-value JSONB) и их бутстрап. """Хранилище настроек инстанса (`instance_settings`, key-value JSONB) и их бутстрап.
Ключи зеркалят секции конфигурации (`transcriber`, `summarizer`, `chat`, Ключи зеркалят секции конфигурации (`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`, `registration_team_choice`, `registration_email_domain`, `contact_email`,
`media_limits`) — `media_limits`) —
новая настройка не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`) новая настройка не требует миграции, только новая строка. Бутстрап (`ensure_bootstrapped`)
@@ -28,6 +28,7 @@ from core.config import Settings
from core.plugins.config import ( from core.plugins.config import (
AiLevel, AiLevel,
ChatConfig, ChatConfig,
HandQueueConfig,
InstanceConfig, InstanceConfig,
MediaLimitsConfig, MediaLimitsConfig,
PluginsConfig, PluginsConfig,
@@ -45,6 +46,7 @@ from services.ai_tiers import TIERS
_KEY_TRANSCRIBER = "transcriber" _KEY_TRANSCRIBER = "transcriber"
_KEY_SUMMARIZER = "summarizer" _KEY_SUMMARIZER = "summarizer"
_KEY_CHAT = "chat" _KEY_CHAT = "chat"
_KEY_HAND_QUEUE = "hand_queue"
_KEY_AI_LEVEL = "ai_level" _KEY_AI_LEVEL = "ai_level"
_KEY_SUMMARY_RECIPIENTS = "summary_recipients" _KEY_SUMMARY_RECIPIENTS = "summary_recipients"
_KEY_DISPLAY_TIMEZONE = "display_timezone" _KEY_DISPLAY_TIMEZONE = "display_timezone"
@@ -100,6 +102,7 @@ class SettingsUpdateIn(BaseModel):
""" """
chat_enabled: bool | None = None chat_enabled: bool | None = None
hand_queue_enabled: bool | None = None
transcription_enabled: bool | None = None transcription_enabled: bool | None = None
ai_level: AiLevel | None = None ai_level: AiLevel | None = None
summary_recipients: SummaryRecipientsMode | None = None summary_recipients: SummaryRecipientsMode | None = None
@@ -154,6 +157,7 @@ def build_bootstrap_defaults(
_KEY_TRANSCRIBER: plugins.transcriber.model_dump(mode="json"), _KEY_TRANSCRIBER: plugins.transcriber.model_dump(mode="json"),
_KEY_SUMMARIZER: plugins.summarizer.model_dump(mode="json"), _KEY_SUMMARIZER: plugins.summarizer.model_dump(mode="json"),
_KEY_CHAT: plugins.chat.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_AI_LEVEL: dict(_DEFAULT_AI_LEVEL_VALUE),
_KEY_SUMMARY_RECIPIENTS: dict(_DEFAULT_SUMMARY_RECIPIENTS_VALUE), _KEY_SUMMARY_RECIPIENTS: dict(_DEFAULT_SUMMARY_RECIPIENTS_VALUE),
_KEY_DISPLAY_TIMEZONE: dict(_DEFAULT_DISPLAY_TIMEZONE_VALUE), _KEY_DISPLAY_TIMEZONE: dict(_DEFAULT_DISPLAY_TIMEZONE_VALUE),
@@ -286,6 +290,10 @@ class InstanceSettingsService:
cfg.chat = ChatConfig(enabled=patch.chat_enabled) cfg.chat = ChatConfig(enabled=patch.chat_enabled)
await self._set(_KEY_CHAT, cfg.chat.model_dump(mode="json")) 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: if patch.registration_team_choice is not None:
cfg.registration_team_choice = patch.registration_team_choice cfg.registration_team_choice = patch.registration_team_choice
await self._set( await self._set(
@@ -399,6 +407,7 @@ async def load_effective_config(session: AsyncSession) -> InstanceConfig:
transcriber=plugins.transcriber, transcriber=plugins.transcriber,
summarizer=plugins.summarizer, summarizer=plugins.summarizer,
chat=plugins.chat, chat=plugins.chat,
hand_queue=plugins.hand_queue,
) )
else: else:
cfg = _build_config(rows) cfg = _build_config(rows)
@@ -492,6 +501,7 @@ def _build_config(rows: dict[str, Any]) -> InstanceConfig:
transcriber=TranscriberConfig.model_validate(rows.get(_KEY_TRANSCRIBER, {})), transcriber=TranscriberConfig.model_validate(rows.get(_KEY_TRANSCRIBER, {})),
summarizer=SummarizerConfig.model_validate(rows.get(_KEY_SUMMARIZER, {})), summarizer=SummarizerConfig.model_validate(rows.get(_KEY_SUMMARIZER, {})),
chat=ChatConfig.model_validate(rows.get(_KEY_CHAT, {})), 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"), 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( summary_recipients=rows.get(_KEY_SUMMARY_RECIPIENTS, _DEFAULT_SUMMARY_RECIPIENTS_VALUE).get(
"mode", "all" "mode", "all"

View File

@@ -646,12 +646,17 @@ async def test_put_settings_partial_update(
response = await client.put( response = await client.put(
"/api/v1/admin/settings", "/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), headers=_auth_headers(admin),
) )
assert response.status_code == 200, response.text assert response.status_code == 200, response.text
body = response.json() body = response.json()
assert body["chat_enabled"] is False assert body["chat_enabled"] is False
assert body["hand_queue_enabled"] is False
assert body["display_timezone"] == "Asia/Yekaterinburg" assert body["display_timezone"] == "Asia/Yekaterinburg"
assert body["ai_level"] == "min" assert body["ai_level"] == "min"
assert body["transcription_queue_served"] is False assert body["transcription_queue_served"] is False

View File

@@ -8,13 +8,15 @@ import uuid
from collections.abc import Callable from collections.abc import Callable
from typing import Any from typing import Any
import httpx
from sqlalchemy.ext.asyncio import AsyncSession 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.conference import Conference
from models.guest import GuestAccess from models.guest import GuestAccess
from models.user import User from models.user import User
from services.conference_ids import generate_number, generate_slug 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 services.livekit_tokens import create_room_access_token
from tests.conftest import ASGIWebSocketSession 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)) initial_queue = await _connect_auth_and_queue(ws_owner, _user_token(conference, owner))
assert _identities(initial_queue) == [str(alice.id)] 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

View File

@@ -65,6 +65,7 @@ _MANAGED_KEYS = (
"transcriber", "transcriber",
"summarizer", "summarizer",
"chat", "chat",
"hand_queue",
"ai_level", "ai_level",
"summary_recipients", "summary_recipients",
"display_timezone", "display_timezone",
@@ -121,6 +122,7 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
"transcriber", "transcriber",
"summarizer", "summarizer",
"chat", "chat",
"hand_queue",
"ai_level", "ai_level",
"summary_recipients", "summary_recipients",
"display_timezone", "display_timezone",
@@ -132,6 +134,9 @@ async def test_ensure_bootstrapped_imports_yaml_defaults(
cfg = await service.get() cfg = await service.get()
assert cfg.transcriber.provider == "faster_whisper_cpu" assert cfg.transcriber.provider == "faster_whisper_cpu"
assert cfg.ai_level == "min" assert cfg.ai_level == "min"
# Дефолт обязан сохранять поведение существующих инсталляций — модуль
# «поднятие руки» был доступен всегда, тоггл включён по умолчанию.
assert cfg.hand_queue.enabled is True
assert cfg.summary_recipients == "all" assert cfg.summary_recipients == "all"
assert cfg.display_timezone == "Europe/Moscow" assert cfg.display_timezone == "Europe/Moscow"
assert cfg.registration_team_choice is False 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" 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( @pytest.mark.parametrize(
("preset", "chat_enabled", "ai_enabled", "ai_level"), ("preset", "chat_enabled", "ai_enabled", "ai_level"),
[ [

View File

@@ -33,3 +33,6 @@ summarizer:
chat: chat:
enabled: true enabled: true
hand_queue:
enabled: true

View File

@@ -19,6 +19,8 @@ export interface AiLevelStatus {
/** Эффективные настройки инстанса. */ /** Эффективные настройки инстанса. */
export interface SettingsOut { export interface SettingsOut {
chat_enabled: boolean chat_enabled: boolean
/** Включён ли модуль «поднятие руки» — кнопка «Рука» и очередь целиком. */
hand_queue_enabled: boolean
/** Единый переключатель модуля AI (транскрибация + суммаризация). */ /** Единый переключатель модуля AI (транскрибация + суммаризация). */
transcription_enabled: boolean transcription_enabled: boolean
/** Есть ли хотя бы один Celery-воркер, обслуживающий очередь транскрибации. */ /** Есть ли хотя бы один Celery-воркер, обслуживающий очередь транскрибации. */
@@ -48,6 +50,7 @@ export interface SettingsOut {
/** Тело частичного обновления настроек инстанса — все поля опциональны. */ /** Тело частичного обновления настроек инстанса — все поля опциональны. */
export interface SettingsUpdateIn { export interface SettingsUpdateIn {
chat_enabled?: boolean chat_enabled?: boolean
hand_queue_enabled?: boolean
transcription_enabled?: boolean transcription_enabled?: boolean
/** Недоступный уровень (см. `ai_levels`) — backend отвечает 400. */ /** Недоступный уровень (см. `ai_levels`) — backend отвечает 400. */
ai_level?: AiLevel ai_level?: AiLevel

View File

@@ -61,6 +61,8 @@ export interface ConferenceJoinData {
conference_id: string conference_id: string
/** Включён ли чат для этой конференции — при `false` панель/кнопка чата не рендерятся. */ /** Включён ли чат для этой конференции — при `false` панель/кнопка чата не рендерятся. */
chat_enabled: boolean chat_enabled: boolean
/** Включён ли модуль «поднятие руки» — при `false` кнопка «Рука» и очередь не рендерятся. */
hand_queue_enabled: boolean
/** Потолок качества публикации видео на момент входа — см. `PublishQualityCap`. */ /** Потолок качества публикации видео на момент входа — см. `PublishQualityCap`. */
publish_quality_cap: PublishQualityCap publish_quality_cap: PublishQualityCap
/** Максимум одновременно видимых плиток сцены (`StageGrid`) на момент входа. */ /** Максимум одновременно видимых плиток сцены (`StageGrid`) на момент входа. */

View File

@@ -68,6 +68,7 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
const { user } = useAuth() const { user } = useAuth()
const [chatEnabled, setChatEnabled] = useState(data.chat_enabled) const [chatEnabled, setChatEnabled] = useState(data.chat_enabled)
const [handQueueEnabled, setHandQueueEnabled] = useState(data.hand_queue_enabled)
const [aiEnabled, setAiEnabled] = useState(data.transcription_enabled) const [aiEnabled, setAiEnabled] = useState(data.transcription_enabled)
const [aiLevel, setAiLevel] = useState<AiLevel>(data.ai_level) const [aiLevel, setAiLevel] = useState<AiLevel>(data.ai_level)
const [recipients, setRecipients] = useState<SummaryRecipientsMode>(data.summary_recipients) const [recipients, setRecipients] = useState<SummaryRecipientsMode>(data.summary_recipients)
@@ -132,6 +133,7 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
// валился бы в 400, блокируя правку вообще любой другой настройки. // валился бы в 400, блокируя правку вообще любой другой настройки.
const payload: SettingsUpdateIn = {} const payload: SettingsUpdateIn = {}
if (chatEnabled !== data.chat_enabled) payload.chat_enabled = chatEnabled if (chatEnabled !== data.chat_enabled) payload.chat_enabled = chatEnabled
if (handQueueEnabled !== data.hand_queue_enabled) payload.hand_queue_enabled = handQueueEnabled
if (aiEnabled !== data.transcription_enabled) payload.transcription_enabled = aiEnabled if (aiEnabled !== data.transcription_enabled) payload.transcription_enabled = aiEnabled
if (aiLevel !== data.ai_level) payload.ai_level = aiLevel if (aiLevel !== data.ai_level) payload.ai_level = aiLevel
if (recipients !== data.summary_recipients) payload.summary_recipients = recipients if (recipients !== data.summary_recipients) payload.summary_recipients = recipients
@@ -175,6 +177,21 @@ function AdminSettingsForm({ data }: { data: SettingsOut }) {
</label> </label>
</div> </div>
<div className="toggle-row">
<div className="toggle-copy">
<strong>Поднятие руки</strong>
<span>Кнопка «Рука» и очередь поднятых рук в комнате целиком</span>
</div>
<label className="switch">
<input
type="checkbox"
checked={handQueueEnabled}
onChange={(e) => setHandQueueEnabled(e.target.checked)}
/>
<span className="slider" />
</label>
</div>
<div className="toggle-row"> <div className="toggle-row">
<div className="toggle-copy"> <div className="toggle-copy">
<strong>Транскрибация и суммаризация (AI)</strong> <strong>Транскрибация и суммаризация (AI)</strong>

View File

@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { Hand, ListOrdered } from 'lucide-react' import { Hand, ListOrdered } from 'lucide-react'
import { useLocalParticipant } from '@livekit/components-react'
import { useIsOrganizer } from '@/hooks/useIsOrganizer' import { useIsOrganizer } from '@/hooks/useIsOrganizer'
import type { HandQueueEntry } from '@/hooks/useChat' import type { HandQueueEntry } from '@/hooks/useChat'
@@ -9,8 +10,15 @@ interface HandQueueMenuProps {
} }
/** /**
* Кнопка «Очередь» в тулбаре с поповером над ней — видна только организатору * Кнопка «Очередь» в тулбаре с поповером над ней — видна ВСЕМ участникам
* (задача B1). Тот же самодостаточный паттерн, что и `StageViewMenu` («Вид»): * (сессия «28-hand-queue-for-all»: раньше очередь видел только организатор).
* Опустить чужую запись может по-прежнему только организатор — обычный
* участник видит кнопку «Опустить» только напротив СВОЕЙ записи (или не
* видит её вовсе, если сам руку не поднимал): сервер (`api/chat.py`) всё
* равно отклонит попытку опустить чужую руку кодом `forbidden`, но мёртвая
* кнопка, которая молча не работает, хуже отсутствующей.
*
* Тот же самодостаточный паттерн, что и `StageViewMenu` («Вид»):
* собственное состояние открытия, закрытие по клику вне/Escape, поповер * собственное состояние открытия, закрытие по клику вне/Escape, поповер
* `.tb-menu` над кнопкой — а не боковая панель на весь экран (как чат): * `.tb-menu` над кнопкой — а не боковая панель на весь экран (как чат):
* очередь рук — короткий список, а не история переписки, разворачивать её * очередь рук — короткий список, а не история переписки, разворачивать её
@@ -19,11 +27,12 @@ interface HandQueueMenuProps {
* Размер поповера подстраивается под число записей — `.hand-queue-list` * Размер поповера подстраивается под число записей — `.hand-queue-list`
* растёт вместе со списком и не даёт пустого места при 12 поднятых руках, * растёт вместе со списком и не даёт пустого места при 12 поднятых руках,
* но не бесконечно: после ~10 строк список упирается в `max-height` и дальше * но не бесконечно: после ~10 строк список упирается в `max-height` и дальше
* скроллится (см. room.css) — иначе организатор на энергичной встрече * скроллится (см. room.css) — иначе участник на энергичной встрече получил
* получил бы поповер выше экрана. * бы поповер выше экрана.
*/ */
export function HandQueueMenu({ queue, onLower }: HandQueueMenuProps) { export function HandQueueMenu({ queue, onLower }: HandQueueMenuProps) {
const isOrganizer = useIsOrganizer() const isOrganizer = useIsOrganizer()
const { localParticipant } = useLocalParticipant()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const wrapRef = useRef<HTMLDivElement>(null) const wrapRef = useRef<HTMLDivElement>(null)
@@ -47,8 +56,6 @@ export function HandQueueMenu({ queue, onLower }: HandQueueMenuProps) {
} }
}, [open]) }, [open])
if (!isOrganizer) return null
return ( return (
<div className="tb-menu-wrap" ref={wrapRef}> <div className="tb-menu-wrap" ref={wrapRef}>
<button <button
@@ -74,13 +81,17 @@ export function HandQueueMenu({ queue, onLower }: HandQueueMenuProps) {
<p className="chat-empty">Пока никто не поднял руку</p> <p className="chat-empty">Пока никто не поднял руку</p>
) : ( ) : (
<ol className="hand-queue-list"> <ol className="hand-queue-list">
{queue.map((entry, index) => ( {queue.map((entry, index) => {
const isOwn = entry.identity === localParticipant.identity
const canLower = isOrganizer || isOwn
return (
<li className="hand-queue-item" key={entry.identity}> <li className="hand-queue-item" key={entry.identity}>
<span className="hand-queue-position">{index + 1}</span> <span className="hand-queue-position">{index + 1}</span>
<span className="hand-queue-name"> <span className="hand-queue-name">
<Hand className="lucide" aria-hidden="true" /> <Hand className="lucide" aria-hidden="true" />
{entry.name} {entry.name}
</span> </span>
{canLower && (
<button <button
type="button" type="button"
className="hand-queue-lower" className="hand-queue-lower"
@@ -88,8 +99,10 @@ export function HandQueueMenu({ queue, onLower }: HandQueueMenuProps) {
> >
Опустить Опустить
</button> </button>
)}
</li> </li>
))} )
})}
</ol> </ol>
)} )}
</div> </div>

View File

@@ -17,7 +17,6 @@ import { Track, type ScreenShareCaptureOptions } from 'livekit-client'
import { DisconnectButton, useLocalParticipant, useTrackToggle } from '@livekit/components-react' import { DisconnectButton, useLocalParticipant, useTrackToggle } from '@livekit/components-react'
import { useToast } from '@/components/ui/ToastProvider' import { useToast } from '@/components/ui/ToastProvider'
import { useIsCompactViewport } from '@/hooks/useIsCompactViewport' import { useIsCompactViewport } from '@/hooks/useIsCompactViewport'
import { useIsOrganizer } from '@/hooks/useIsOrganizer'
import type { HandQueueEntry } from '@/hooks/useChat' import type { HandQueueEntry } from '@/hooks/useChat'
import { StageViewMenu, type StageViewProps } from '@/components/room/StageViewOptions' import { StageViewMenu, type StageViewProps } from '@/components/room/StageViewOptions'
import { HandQueueMenu } from '@/components/room/HandQueueMenu' import { HandQueueMenu } from '@/components/room/HandQueueMenu'
@@ -59,6 +58,8 @@ interface RoomToolbarProps extends StageViewProps {
pipSupported: boolean pipSupported: boolean
pipActive: boolean pipActive: boolean
onTogglePiP: () => void onTogglePiP: () => void
/** `JoinOut.hand_queue_enabled` — при `false` кнопка «Рука» и очередь не рендерятся вовсе. */
handQueueEnabled: boolean
/** /**
* Очередь поднятых рук целиком (задача B1, `useChat().handQueue`) — сама * Очередь поднятых рук целиком (задача B1, `useChat().handQueue`) — сама
* решает, поднята ли СВОЯ рука (сравнивая с `localParticipant.identity` * решает, поднята ли СВОЯ рука (сравнивая с `localParticipant.identity`
@@ -94,9 +95,11 @@ interface RoomToolbarProps extends StageViewProps {
* (`DeviceSettingsDialog`). «Рука» — сознательное исключение из этой * (`DeviceSettingsDialog`). «Рука» — сознательное исключение из этой
* экономии: поднять руку посреди разговора — действие со временем жизни в * экономии: поднять руку посреди разговора — действие со временем жизни в
* секунды, прятать его в шторку настроек означало бы делать его практически * секунды, прятать его в шторку настроек означало бы делать его практически
* недоступным с телефона. «Очередь» показывается только организатору — * недоступным с телефона. «Очередь» видна ЛЮБОМУ участнику (сессия
* встречается редко, но по той же причине оставлена в тулбаре, а не в * «28-hand-queue-for-all»: раньше только организатору), по той же причине
* шторке: организатору с телефона тоже нужно видеть очередь сразу. * оставлена в тулбаре, а не в шторке: любому участнику с телефона тоже нужно
* видеть очередь сразу. Обе кнопки целиком гасятся `handQueueEnabled`
* (`JoinOut.hand_queue_enabled`, отключаемый модуль в админке).
* Полноэкранный режим на мобильном ОСТАЁТСЯ в тулбаре (не спрятан в шторку, * Полноэкранный режим на мобильном ОСТАЁТСЯ в тулбаре (не спрятан в шторку,
* как демонстрация/мини-плеер) — по решению оператора вход в него должен * как демонстрация/мини-плеер) — по решению оператора вход в него должен
* быть по аналогии с десктопом; если из-за этого кнопки не помещаются в один * быть по аналогии с десктопом; если из-за этого кнопки не помещаются в один
@@ -115,6 +118,7 @@ export function RoomToolbar({
pipSupported, pipSupported,
pipActive, pipActive,
onTogglePiP, onTogglePiP,
handQueueEnabled,
handQueue, handQueue,
onRaiseHand, onRaiseHand,
onLowerHand, onLowerHand,
@@ -128,21 +132,22 @@ export function RoomToolbar({
}: RoomToolbarProps) { }: RoomToolbarProps) {
const toast = useToast() const toast = useToast()
const isCompact = useIsCompactViewport() const isCompact = useIsCompactViewport()
const isOrganizer = useIsOrganizer()
const { localParticipant } = useLocalParticipant() const { localParticipant } = useLocalParticipant()
const handRaised = handQueue.some((entry) => entry.identity === localParticipant.identity) const handRaised = handQueue.some((entry) => entry.identity === localParticipant.identity)
// Сколько кнопок реально видно на мобильном (демонстрация/мини-окно там // Сколько кнопок реально видно на мобильном (демонстрация/мини-окно там
// скрыты через CSS всегда, «Вид» не рендерится вовсе — см. докстринг) — // скрыты через CSS всегда, «Вид» не рендерится вовсе — см. докстринг) —
// считаем в JS, а не через CSS-селекторы вида `:nth-child`: у «Очереди» // считаем в JS, а не через CSS-селекторы вида `:nth-child`: у «Очереди»
// (только у организатора) в DOM есть своя обёртка `.tb-menu-wrap`, из-за // в DOM есть своя обёртка `.tb-menu-wrap`, из-за которой позиция остальных
// которой позиция остальных кнопок «плывёт», и подсчёт по структуре DOM // кнопок «плывёт», и подсчёт по структуре DOM был бы хрупким. От порога
// был бы хрупким. От порога зависит `.tb-wrap-grid` в room.css — ниже он // зависит `.tb-wrap-grid` в room.css — ниже он переключает перенос на
// переключает перенос на 2 строки с «как поместится» (flex-wrap) на // 2 строки с «как поместится» (flex-wrap) на равномерную сетку 4 колонки
// равномерную сетку 4 колонки (7 → 4+3, 8 → 4+4). // (7 → 4+3, 8 → 4+4). «Рука»/«Очередь» — обе теперь видны любому участнику
// (сессия «28-hand-queue-for-all»), их наличие зависит только от
// `handQueueEnabled`, не от роли.
const mobileButtonCount = const mobileButtonCount =
3 /* микрофон, камера, рука */ + 2 /* микрофон, камера */ +
(isOrganizer ? 1 : 0) /* очередь */ + (handQueueEnabled ? 2 : 0) /* рука, очередь */ +
1 /* настройки */ + 1 /* настройки */ +
(fullscreenSupported ? 1 : 0) + (fullscreenSupported ? 1 : 0) +
(chatVisible ? 1 : 0) + (chatVisible ? 1 : 0) +
@@ -214,6 +219,7 @@ export function RoomToolbar({
<span className="label">Демонстрация</span> <span className="label">Демонстрация</span>
</button> </button>
{handQueueEnabled && (
<button <button
type="button" type="button"
className={`tb-btn${handRaised ? ' is-hand-raised' : ''}`} className={`tb-btn${handRaised ? ' is-hand-raised' : ''}`}
@@ -229,8 +235,9 @@ export function RoomToolbar({
</span> </span>
<span className="label">Рука</span> <span className="label">Рука</span>
</button> </button>
)}
<HandQueueMenu queue={handQueue} onLower={onLowerHandById} /> {handQueueEnabled && <HandQueueMenu queue={handQueue} onLower={onLowerHandById} />}
{!isCompact && ( {!isCompact && (
<StageViewMenu <StageViewMenu

View File

@@ -146,6 +146,7 @@ export function JoinPage() {
title: resolved.title, title: resolved.title,
conferenceId: data.conference_id, conferenceId: data.conference_id,
chatEnabled: data.chat_enabled, chatEnabled: data.chat_enabled,
handQueueEnabled: data.hand_queue_enabled,
publishQualityCap: data.publish_quality_cap, publishQualityCap: data.publish_quality_cap,
stageMaxTiles: data.stage_max_tiles, stageMaxTiles: data.stage_max_tiles,
}, },

View File

@@ -43,6 +43,7 @@ export function LobbyPage() {
title: conference.title, title: conference.title,
conferenceId: conference.join.conference_id, conferenceId: conference.join.conference_id,
chatEnabled: conference.join.chat_enabled, chatEnabled: conference.join.chat_enabled,
handQueueEnabled: conference.join.hand_queue_enabled,
publishQualityCap: conference.join.publish_quality_cap, publishQualityCap: conference.join.publish_quality_cap,
stageMaxTiles: conference.join.stage_max_tiles, stageMaxTiles: conference.join.stage_max_tiles,
number: conference.number, number: conference.number,

View File

@@ -55,6 +55,8 @@ interface RoomJoinState {
number?: string number?: string
/** `JoinOut.chat_enabled` — при `false` кнопка чата и панель не рендерятся. */ /** `JoinOut.chat_enabled` — при `false` кнопка чата и панель не рендерятся. */
chatEnabled?: boolean chatEnabled?: boolean
/** `JoinOut.hand_queue_enabled` — при `false` кнопка «Рука» и очередь не рендерятся. */
handQueueEnabled?: boolean
/** /**
* Рычаги нагрузки медиа (`JoinOut.publish_quality_cap`/`stage_max_tiles`, * Рычаги нагрузки медиа (`JoinOut.publish_quality_cap`/`stage_max_tiles`,
* `instance_settings.media_limits`) — приезжают вместе с токеном, ДО * `instance_settings.media_limits`) — приезжают вместе с токеном, ДО
@@ -208,6 +210,7 @@ export function RoomPage() {
title: info.title, title: info.title,
conferenceId: result.conference_id, conferenceId: result.conference_id,
chatEnabled: result.chat_enabled, chatEnabled: result.chat_enabled,
handQueueEnabled: result.hand_queue_enabled,
publishQualityCap: result.publish_quality_cap, publishQualityCap: result.publish_quality_cap,
stageMaxTiles: result.stage_max_tiles, stageMaxTiles: result.stage_max_tiles,
}) })
@@ -260,10 +263,16 @@ export function RoomPage() {
const chatVisible = Boolean(joinState?.chatEnabled) && !chat.unavailable const chatVisible = Boolean(joinState?.chatEnabled) && !chat.unavailable
// Identity участников с поднятой рукой — множеством, для дешёвого `.has()` // Identity участников с поднятой рукой — множеством, для дешёвого `.has()`
// на каждой плитке сцены (см. `RoomParticipantTile`). // на каждой плитке сцены (см. `RoomParticipantTile`). Выключенный модуль
// (`handQueueEnabled=false`) гасит и эти значки — иначе на плитке мог бы
// повиснуть индикатор от руки, поднятой до отключения модуля (сервер
// хранит очередь в Redis независимо от тоггла, см. `services/hand_queue.py`).
const raisedHandIdentities = useMemo( const raisedHandIdentities = useMemo(
() => new Set(chat.handQueue.map((entry) => entry.identity)), () =>
[chat.handQueue], joinState?.handQueueEnabled
? new Set(chat.handQueue.map((entry) => entry.identity))
: new Set<string>(),
[chat.handQueue, joinState?.handQueueEnabled],
) )
// Корневой контейнер комнаты — цель для fullscreen и источник video-элемента // Корневой контейнер комнаты — цель для fullscreen и источник video-элемента
@@ -528,6 +537,7 @@ export function RoomPage() {
pipSupported={pip.supported} pipSupported={pip.supported}
pipActive={pip.active} pipActive={pip.active}
onTogglePiP={pip.toggle} onTogglePiP={pip.toggle}
handQueueEnabled={Boolean(joinState.handQueueEnabled)}
handQueue={chat.handQueue} handQueue={chat.handQueue}
onRaiseHand={chat.raiseHand} onRaiseHand={chat.raiseHand}
onLowerHand={() => chat.lowerHand()} onLowerHand={() => chat.lowerHand()}