mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-08 16:35:49 +00:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f9f54e896 | |||
| 93e0ba945e | |||
| 10f8dddf1a | |||
| 9e01d297a1 | |||
| 7247b05a56 | |||
| bf76ab1ddf | |||
| 7565ede0a7 | |||
| 1a15e9f44e | |||
| 7838d8acfe | |||
| 3bb388b937 | |||
| e1cc8105db | |||
| 7844dfcc12 | |||
| ef71003721 | |||
| f74d23c57e | |||
| acedb21045 | |||
| 67e7d382e3 | |||
| 164ac8d948 | |||
| e3deb37fbe | |||
| 33929324d0 | |||
| adc74f846c | |||
| 78ba03a52b |
@@ -8,21 +8,40 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔒️(backend) enforce display name setting on rename API
|
||||
- 🩹(backend) handle failed and aborted egresses
|
||||
- 🩹(frontend) notify participants when a recording fails or is aborted
|
||||
|
||||
## [1.31.0] - 2026-09-08
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(frontend) add 1080p sending resolution option #1660
|
||||
- ✨(backend) add Traefik support via configurable media-auth url header #1649
|
||||
- ✨(backend) update a room's attributes from the external API
|
||||
- 🔊(backend) log request duration in Gunicorn workers
|
||||
- 📈(frontend) track missing lobby participant on accept/reject
|
||||
- ✨(backend) sort waiting participants by their arrival time
|
||||
|
||||
### Changed
|
||||
|
||||
- ⬆️(dev) pin LiveKit server to v1.13.6
|
||||
- 🔒(frontend) upgrade base image to 1.30.4-alpine3.24
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(backend) allow any printable ASCII characters in user sub field #1673
|
||||
- 🐛(frontend) keep the sending resolution picked while the camera is off #1667
|
||||
- 🐛(frontend) restore automatic lower-hand on speaking
|
||||
- 🐛(frontend) center Avatar initials with a font-aware cap-height ratio
|
||||
- 🐛(frontend) keep feedback buttons on one line for fr/es/en
|
||||
- ⚡️(frontend) increase lobby polling interval on both sides
|
||||
- ⚡️(frontend) add trailing slash on the /me endpoint call
|
||||
- ⚡️(backend) refactor lobby storage to bound key lookups per room
|
||||
- ⚡️(backend) refactor presence cache to bound key lookups per room
|
||||
- 💄(frontend) position the login hint dynamically next to the button
|
||||
|
||||
## [1.30.0] - 2026-09-01
|
||||
|
||||
|
||||
@@ -54,19 +54,11 @@ RUN npx webpack --mode production
|
||||
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
|
||||
FROM nginxinc/nginx-unprivileged:1.30.4-alpine3.24 AS frontend-production
|
||||
|
||||
USER root
|
||||
|
||||
# Security patches for known CVEs
|
||||
RUN apk update && apk upgrade \
|
||||
libcrypto3>=3.5.7-r0 \
|
||||
libssl3>=3.5.7-r0 \
|
||||
musl \
|
||||
musl-utils \
|
||||
zlib>=1.3.2-r0 \
|
||||
libexpat>=2.8.4-r0 \
|
||||
&& apk del curl
|
||||
RUN apk del curl
|
||||
USER nginx
|
||||
|
||||
USER nginx
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
:root {
|
||||
--fonts-sans: 'Marianne', ui-sans-serif, system-ui, sans-serif;
|
||||
--avatar-cap-height: 0.7;
|
||||
}
|
||||
|
||||
.Header-beforeLogo {
|
||||
|
||||
@@ -14,3 +14,4 @@ accesslog = "-"
|
||||
# Using '-' for the error log file makes gunicorn log errors to stderr
|
||||
errorlog = "-"
|
||||
loglevel = "info"
|
||||
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(M)s'
|
||||
|
||||
@@ -34,6 +34,7 @@ Let's say you want to change the font of our application to a custom font. You c
|
||||
|
||||
:root {
|
||||
--fonts-sans: 'Roboto', ui-sans-serif, system-ui, sans-serif;
|
||||
--avatar-cap-height: 0.7;
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.29.0"
|
||||
version = "1.31.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.6.7",
|
||||
|
||||
Generated
+1
-1
@@ -9,7 +9,7 @@ resolution-markers = [
|
||||
|
||||
[[package]]
|
||||
name = "agents"
|
||||
version = "1.29.0"
|
||||
version = "1.31.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
|
||||
@@ -909,6 +909,15 @@ class RoomViewSet(
|
||||
"""Rename the current participant in the room."""
|
||||
room = self.get_object()
|
||||
|
||||
if (
|
||||
not settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
|
||||
and request.user.is_authenticated
|
||||
):
|
||||
return drf_response.Response(
|
||||
{"error": "Authenticated participants cannot edit their display name"},
|
||||
status=drf_status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
serializer = serializers.RenameParticipantSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.14 on 2026-09-08 15:12
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0022_user_default_room_access_level_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='recording',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('initiated', 'Initiated'), ('active', 'Active'), ('stopped', 'Stopped'), ('saved', 'Saved'), ('aborted', 'Aborted'), ('failed', 'Failed'), ('failed_to_start', 'Failed to Start'), ('failed_to_stop', 'Failed to Stop'), ('notification_succeeded', 'Notification succeeded'), ('external_process_successful', 'External process successful'), ('external_process_failed', 'External process failed')], default='initiated', max_length=50),
|
||||
),
|
||||
]
|
||||
@@ -57,7 +57,8 @@ class RecordingStatusChoices(models.TextChoices):
|
||||
ACTIVE = "active", _("Active")
|
||||
STOPPED = "stopped", _("Stopped")
|
||||
SAVED = "saved", _("Saved")
|
||||
ABORTED = "aborted", _("Aborted")
|
||||
ABORTED = "aborted", _("Aborted") # from livekit egress
|
||||
FAILED = "failed", _("Failed") # from livekit egress
|
||||
FAILED_TO_START = "failed_to_start", _("Failed to Start")
|
||||
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
|
||||
NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded")
|
||||
@@ -79,17 +80,13 @@ class RecordingStatusChoices(models.TextChoices):
|
||||
cls.STOPPED,
|
||||
cls.SAVED,
|
||||
cls.ABORTED,
|
||||
cls.FAILED,
|
||||
cls.EXTERNAL_PROCESS_SUCCESSFUL,
|
||||
cls.EXTERNAL_PROCESS_FAILED,
|
||||
cls.FAILED_TO_START,
|
||||
cls.FAILED_TO_STOP,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def is_unsuccessful(cls, status):
|
||||
"""Determine if the recording status represents an unsuccessful state."""
|
||||
return status in {cls.ABORTED, cls.FAILED_TO_START, cls.FAILED_TO_STOP}
|
||||
|
||||
|
||||
class RecordingModeChoices(models.TextChoices):
|
||||
"""Recording mode choices."""
|
||||
@@ -582,6 +579,7 @@ class Recording(BaseModel):
|
||||
4. NOTIFICATION_SUCCEEDED: External service has been notified of this recording
|
||||
|
||||
Error States:
|
||||
- FAILED: Livekit egress returned EGRESS_FAILED
|
||||
- FAILED_TO_START: Worker failed to initialize recording
|
||||
- FAILED_TO_STOP: Worker failed during stop operation
|
||||
- ABORTED: Recording was terminated before completion
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
# pylint: disable=no-member
|
||||
|
||||
from enum import Enum
|
||||
from logging import getLogger
|
||||
|
||||
from livekit import api
|
||||
@@ -26,19 +27,81 @@ class RecordingNotSavableError(Exception):
|
||||
"""Recording cannot be saved because it is either in an error state or has already been saved"""
|
||||
|
||||
|
||||
class RecordingEvent(Enum):
|
||||
"""Recording outcomes participants are notified about."""
|
||||
|
||||
LIMIT_REACHED = "limit reached"
|
||||
FAILED = "failed"
|
||||
ABORTED = "aborted"
|
||||
|
||||
|
||||
# Notification sent to the room's participants, per event and recording mode.
|
||||
NOTIFICATION_TYPES = {
|
||||
RecordingEvent.LIMIT_REACHED: {
|
||||
models.RecordingModeChoices.SCREEN_RECORDING: "screenRecordingLimitReached",
|
||||
models.RecordingModeChoices.TRANSCRIPT: "transcriptionLimitReached",
|
||||
},
|
||||
RecordingEvent.FAILED: {
|
||||
models.RecordingModeChoices.SCREEN_RECORDING: "screenRecordingFailed",
|
||||
models.RecordingModeChoices.TRANSCRIPT: "transcriptionFailed",
|
||||
},
|
||||
RecordingEvent.ABORTED: {
|
||||
models.RecordingModeChoices.SCREEN_RECORDING: "screenRecordingAborted",
|
||||
models.RecordingModeChoices.TRANSCRIPT: "transcriptionAborted",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class RecordingEventsService:
|
||||
"""Handles recording-related LiveKit webhook events."""
|
||||
|
||||
@staticmethod
|
||||
def _notify_participants(recording: Recording, event: RecordingEvent):
|
||||
"""Notify the room's participants that a recording ended on the given event."""
|
||||
|
||||
notification_type = NOTIFICATION_TYPES[event].get(recording.mode)
|
||||
if not notification_type:
|
||||
logger.warning(
|
||||
"Could not find notification type for: "
|
||||
"room=%s, recording_id=%s, mode=%s, event=%s",
|
||||
recording.room.id,
|
||||
recording.id,
|
||||
recording.mode,
|
||||
event.value,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
utils.notify_participants(
|
||||
room_name=str(recording.room.id),
|
||||
notification_data={"type": notification_type},
|
||||
)
|
||||
except utils.NotificationError as e:
|
||||
logger.exception(
|
||||
"Failed to notify participants about recording %s: "
|
||||
"room=%s, recording_id=%s, mode=%s",
|
||||
event.value,
|
||||
recording.room.id,
|
||||
recording.id,
|
||||
recording.mode,
|
||||
)
|
||||
raise RecordingEventsError(
|
||||
f"Failed to notify participants in room '{recording.room.id}' about "
|
||||
f"recording {event.value} (recording_id={recording.id})"
|
||||
) from e
|
||||
|
||||
@staticmethod
|
||||
def handle_update(recording: Recording, egress_status):
|
||||
"""Handle egress status updates and sync recording state to room metadata."""
|
||||
"""Handle egress status updates and sync recording state to room metadata.
|
||||
|
||||
Egress updates are sent for statuses EGRESS_ACTIVE and EGRESS_ENDING.
|
||||
"""
|
||||
|
||||
room_name = str(recording.room.id)
|
||||
|
||||
status_mapping = {
|
||||
api.EgressStatus.EGRESS_ACTIVE: "started",
|
||||
api.EgressStatus.EGRESS_ENDING: "saving",
|
||||
api.EgressStatus.EGRESS_ABORTED: "aborted",
|
||||
}
|
||||
|
||||
recording_status = status_mapping.get(egress_status)
|
||||
@@ -55,39 +118,38 @@ class RecordingEventsService:
|
||||
except RoomManagementException as e:
|
||||
logger.exception("Failed to update room's metadata: %s", e)
|
||||
|
||||
@staticmethod
|
||||
def handle_limit_reached(recording: Recording):
|
||||
@classmethod
|
||||
def handle_limit_reached(cls, recording: Recording):
|
||||
"""Stop recording and notify participants when limit is reached."""
|
||||
|
||||
recording.status = models.RecordingStatusChoices.STOPPED
|
||||
recording.save()
|
||||
|
||||
notification_mapping = {
|
||||
models.RecordingModeChoices.SCREEN_RECORDING: "screenRecordingLimitReached",
|
||||
models.RecordingModeChoices.TRANSCRIPT: "transcriptionLimitReached",
|
||||
}
|
||||
cls._notify_participants(recording, RecordingEvent.LIMIT_REACHED)
|
||||
|
||||
notification_type = notification_mapping.get(recording.mode)
|
||||
if not notification_type:
|
||||
return
|
||||
@classmethod
|
||||
def handle_failed(cls, recording: Recording):
|
||||
"""Set recording status to failed, matching egress status, and notify participants.
|
||||
|
||||
try:
|
||||
utils.notify_participants(
|
||||
room_name=str(recording.room.id),
|
||||
notification_data={"type": notification_type},
|
||||
)
|
||||
except utils.NotificationError as e:
|
||||
logger.exception(
|
||||
"Failed to notify participants about recording limit reached: "
|
||||
"room=%s, recording_id=%s, mode=%s",
|
||||
recording.room.id,
|
||||
recording.id,
|
||||
recording.mode,
|
||||
)
|
||||
raise RecordingEventsError(
|
||||
f"Failed to notify participants in room '{recording.room.id}' about "
|
||||
f"recording limit reached (recording_id={recording.id})"
|
||||
) from e
|
||||
EGRESS_FAILED: used when an actual runtime/pipeline error occurs after the
|
||||
egress has started
|
||||
"""
|
||||
recording.status = models.RecordingStatusChoices.FAILED
|
||||
recording.save()
|
||||
|
||||
cls._notify_participants(recording, RecordingEvent.FAILED)
|
||||
|
||||
@classmethod
|
||||
def handle_aborted(cls, recording: Recording):
|
||||
"""Set recording status to aborted, matching egress status, and notify participants.
|
||||
|
||||
EGRESS_ABORTED: used when the egress stops before it ever became
|
||||
active/recording
|
||||
"""
|
||||
recording.status = models.RecordingStatusChoices.ABORTED
|
||||
recording.save()
|
||||
|
||||
cls._notify_participants(recording, RecordingEvent.ABORTED)
|
||||
|
||||
@staticmethod
|
||||
def handle_complete(recording: Recording):
|
||||
|
||||
@@ -178,9 +178,36 @@ class LiveKitEventsService:
|
||||
egress_status = data.egress_info.status
|
||||
self.recording_events.handle_update(recording, egress_status)
|
||||
|
||||
def _handle_egress_ended(self, data):
|
||||
"""Handle 'egress_ended' event."""
|
||||
@staticmethod
|
||||
def _log_egress_error(data, recording, event):
|
||||
"""Log the reason LiveKit reported an unsuccessful egress."""
|
||||
|
||||
logger.error(
|
||||
"Egress %s for recording %s (room=%s, mode=%s): %s (error_code=%s)",
|
||||
event,
|
||||
recording.id,
|
||||
recording.room.id,
|
||||
recording.mode,
|
||||
data.egress_info.error or "no error reported",
|
||||
data.egress_info.error_code or "no error_code reported",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _log_notification_failure(recording, event):
|
||||
"""Log a participant notification error on an unsuccessful egress."""
|
||||
|
||||
logger.exception(
|
||||
"Failed to notify participants that recording %s %s (room=%s)",
|
||||
recording.id,
|
||||
event,
|
||||
recording.room.id,
|
||||
)
|
||||
|
||||
def _handle_egress_ended(self, data): # noqa: PLR0912
|
||||
"""Handle 'egress_ended' event."""
|
||||
# pylint: disable=too-many-branches
|
||||
|
||||
# Fetch recording
|
||||
try:
|
||||
recording = models.Recording.objects.select_related("room").get(
|
||||
worker_id=data.egress_info.egress_id
|
||||
@@ -190,6 +217,7 @@ class LiveKitEventsService:
|
||||
f"Recording with worker ID {data.egress_info.egress_id} does not exist"
|
||||
) from err
|
||||
|
||||
# Update room
|
||||
try:
|
||||
room_name = str(recording.room.id)
|
||||
RoomManagement.update_metadata(
|
||||
@@ -203,12 +231,15 @@ class LiveKitEventsService:
|
||||
except RoomManagementException as e:
|
||||
logger.exception("Failed to update room's metadata: %s", e)
|
||||
|
||||
# Stop metadata collector
|
||||
if recording.options.get("metadata_collector_dispatch_id", None) is not None:
|
||||
try:
|
||||
MetadataCollectorService().stop(recording)
|
||||
except MetadataCollectorException:
|
||||
logger.warning("Failed to stop the MetadataCollectorService")
|
||||
|
||||
# Handle case: EGRESS_LIMIT_REACHED
|
||||
# question: can we remove or factorize condition on ACTIVE ?
|
||||
if (
|
||||
data.egress_info.status == api.EgressStatus.EGRESS_LIMIT_REACHED
|
||||
and recording.status == models.RecordingStatusChoices.ACTIVE
|
||||
@@ -220,6 +251,31 @@ class LiveKitEventsService:
|
||||
f"Failed to process limit reached event for recording {recording}"
|
||||
) from e
|
||||
|
||||
# Handle case: EGRESS_ABORTED
|
||||
if (
|
||||
data.egress_info.status == api.EgressStatus.EGRESS_ABORTED
|
||||
and recording.status == models.RecordingStatusChoices.ACTIVE
|
||||
):
|
||||
self._log_egress_error(data, recording, "aborted")
|
||||
try:
|
||||
self.recording_events.handle_aborted(recording)
|
||||
except RecordingEventsError:
|
||||
self._log_notification_failure(recording, "aborted")
|
||||
return
|
||||
|
||||
# Handle case: EGRESS_FAILED
|
||||
if (
|
||||
data.egress_info.status == api.EgressStatus.EGRESS_FAILED
|
||||
and recording.status == models.RecordingStatusChoices.ACTIVE
|
||||
):
|
||||
self._log_egress_error(data, recording, "failed")
|
||||
try:
|
||||
self.recording_events.handle_failed(recording)
|
||||
except RecordingEventsError:
|
||||
self._log_notification_failure(recording, "failed")
|
||||
return
|
||||
|
||||
# Handle cases: EGRESS_COMPLETE & EGRESS_LIMIT_REACHED
|
||||
# Finalize the recording, the egress has uploaded the file to the storage
|
||||
if data.egress_info.status in [
|
||||
api.EgressStatus.EGRESS_COMPLETE,
|
||||
@@ -234,8 +290,6 @@ class LiveKitEventsService:
|
||||
recording.id,
|
||||
)
|
||||
|
||||
# Silently ignoring EGRESS_ABORTED, EGRESS_FAILED
|
||||
|
||||
@staticmethod
|
||||
def _is_connection_test_room(room_name: str) -> bool:
|
||||
"""Return True for ephemeral rooms created by the connection test endpoint."""
|
||||
|
||||
@@ -4,11 +4,12 @@ import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from typing import Dict, FrozenSet, Optional, Sequence, Tuple
|
||||
from uuid import UUID
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.utils import timezone
|
||||
|
||||
from core import models, utils
|
||||
|
||||
@@ -46,6 +47,7 @@ class LobbyParticipant:
|
||||
username: str
|
||||
color: str
|
||||
id: str
|
||||
entered_at: str
|
||||
|
||||
def to_dict(self) -> Dict[str, str]:
|
||||
"""Serialize the participant object to a dict representation."""
|
||||
@@ -54,6 +56,7 @@ class LobbyParticipant:
|
||||
"username": self.username,
|
||||
"id": self.id,
|
||||
"color": self.color,
|
||||
"entered_at": self.entered_at,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -68,6 +71,7 @@ class LobbyParticipant:
|
||||
username=data["username"],
|
||||
id=data["id"],
|
||||
color=data["color"],
|
||||
entered_at=data["entered_at"],
|
||||
)
|
||||
except (KeyError, ValueError) as e:
|
||||
logger.exception("Error creating Participant from dict:")
|
||||
@@ -86,6 +90,47 @@ class LobbyService:
|
||||
"""Generate cache key for participant(s) data."""
|
||||
return f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
|
||||
|
||||
@staticmethod
|
||||
def _get_index_key(room_id: UUID) -> str:
|
||||
"""Raw Redis key of the per-room participant index (a native SET)."""
|
||||
return cache.client.make_key(f"{settings.LOBBY_KEY_PREFIX}-index_{room_id!s}")
|
||||
|
||||
@staticmethod
|
||||
def _redis(write: bool = True):
|
||||
"""Raw redis-py client.
|
||||
|
||||
SADD/SREM/SMEMBERS are not exposed by the Django cache API; this is
|
||||
the documented django-redis escape hatch.
|
||||
"""
|
||||
return cache.client.get_client(write=write)
|
||||
|
||||
def _index_add(self, room_id: UUID, participant_id: str) -> None:
|
||||
"""Record a participant id in the room index."""
|
||||
index_key = self._get_index_key(room_id)
|
||||
pipe = self._redis().pipeline(transaction=False)
|
||||
pipe.sadd(index_key, participant_id)
|
||||
pipe.expire(index_key, settings.LOBBY_ACCEPTED_TIMEOUT)
|
||||
pipe.execute()
|
||||
|
||||
def _index_members(self, room_id: UUID) -> FrozenSet[str]:
|
||||
"""All participant ids currently indexed for the room."""
|
||||
members = self._redis(write=False).smembers(self._get_index_key(room_id))
|
||||
return frozenset(
|
||||
member.decode() if isinstance(member, bytes) else member
|
||||
for member in members
|
||||
)
|
||||
|
||||
def _index_touch(self, room_id: UUID) -> None:
|
||||
"""Re-arm the room index backstop TTL."""
|
||||
self._redis().expire(
|
||||
self._get_index_key(room_id), settings.LOBBY_ACCEPTED_TIMEOUT
|
||||
)
|
||||
|
||||
def _index_remove(self, room_id: UUID, *participant_ids: str) -> None:
|
||||
"""Drop participant ids from the room index."""
|
||||
if participant_ids:
|
||||
self._redis().srem(self._get_index_key(room_id), *participant_ids)
|
||||
|
||||
@staticmethod
|
||||
def _get_or_create_participant_id(request) -> str:
|
||||
"""Extract unique participant identifier from the request."""
|
||||
@@ -162,6 +207,7 @@ class LobbyService:
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color=utils.generate_color(participant_id),
|
||||
entered_at=timezone.now().isoformat(),
|
||||
)
|
||||
else:
|
||||
participant.status = LobbyParticipantStatus.ACCEPTED
|
||||
@@ -209,15 +255,12 @@ class LobbyService:
|
||||
cache.touch(
|
||||
self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT
|
||||
)
|
||||
self._index_touch(room_id)
|
||||
|
||||
def enter(
|
||||
self, room_id: UUID, participant_id: str, username: str
|
||||
) -> LobbyParticipant:
|
||||
"""Add participant to waiting lobby.
|
||||
|
||||
Create a new participant entry in waiting status and notify room
|
||||
participants of the new entry request.
|
||||
"""
|
||||
"""Add participant to waiting lobby."""
|
||||
|
||||
color = utils.generate_color(participant_id)
|
||||
|
||||
@@ -226,6 +269,7 @@ class LobbyService:
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color=color,
|
||||
entered_at=timezone.now().isoformat(),
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -245,6 +289,7 @@ class LobbyService:
|
||||
participant.to_dict(),
|
||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||
)
|
||||
self._index_add(room_id, participant_id)
|
||||
|
||||
return participant
|
||||
|
||||
@@ -266,28 +311,42 @@ class LobbyService:
|
||||
cache.delete(cache_key)
|
||||
return None
|
||||
|
||||
def list_waiting_participants(self, room_id: UUID) -> List[dict]:
|
||||
def list_waiting_participants(self, room_id: UUID) -> Sequence[dict]:
|
||||
"""List all waiting participants for a room."""
|
||||
|
||||
pattern = self._get_cache_key(room_id, "*")
|
||||
keys = list(cache.iter_keys(pattern, itersize=utils.CACHE_SCAN_ITERSIZE))
|
||||
member_ids = self._index_members(room_id)
|
||||
|
||||
if not keys:
|
||||
return []
|
||||
if not member_ids:
|
||||
return ()
|
||||
|
||||
data = cache.get_many(keys)
|
||||
keys_by_id = {
|
||||
participant_id: self._get_cache_key(room_id, participant_id)
|
||||
for participant_id in member_ids
|
||||
}
|
||||
data = cache.get_many(list(keys_by_id.values()))
|
||||
|
||||
dead_ids = []
|
||||
waiting_participants = []
|
||||
for cache_key, raw_participant in data.items():
|
||||
|
||||
for participant_id, cache_key in keys_by_id.items():
|
||||
raw_participant = data.get(cache_key)
|
||||
if raw_participant is None:
|
||||
dead_ids.append(participant_id)
|
||||
continue
|
||||
try:
|
||||
participant = LobbyParticipant.from_dict(raw_participant)
|
||||
except LobbyParticipantParsingError:
|
||||
cache.delete(cache_key)
|
||||
dead_ids.append(participant_id)
|
||||
continue
|
||||
if participant.status == LobbyParticipantStatus.WAITING:
|
||||
waiting_participants.append(participant.to_dict())
|
||||
|
||||
return waiting_participants
|
||||
self._index_remove(room_id, *dead_ids)
|
||||
|
||||
waiting_participants.sort(key=lambda p: p["entered_at"], reverse=True)
|
||||
|
||||
return tuple(waiting_participants)
|
||||
|
||||
def handle_participant_entry(
|
||||
self,
|
||||
@@ -341,16 +400,24 @@ class LobbyService:
|
||||
|
||||
participant.status = status
|
||||
cache.set(cache_key, participant.to_dict(), timeout=timeout)
|
||||
self._index_touch(room_id)
|
||||
|
||||
def clear_room_cache(self, room_id: UUID) -> None:
|
||||
"""Clear all participant entries from the cache for a specific room."""
|
||||
|
||||
cache.delete_pattern(
|
||||
self._get_cache_key(room_id, "*"), itersize=utils.CACHE_SCAN_ITERSIZE
|
||||
)
|
||||
member_ids = self._index_members(room_id)
|
||||
if member_ids:
|
||||
cache.delete_many(
|
||||
[
|
||||
self._get_cache_key(room_id, participant_id)
|
||||
for participant_id in member_ids
|
||||
]
|
||||
)
|
||||
self._redis().delete(self._get_index_key(room_id))
|
||||
|
||||
def clear_participant_cache(self, room_id: UUID, participant_id: str) -> None:
|
||||
"""Clear a given participant entry from the cache for a specific room."""
|
||||
|
||||
cache_key = self._get_cache_key(room_id, participant_id)
|
||||
cache.delete(cache_key)
|
||||
self._index_remove(room_id, participant_id)
|
||||
|
||||
@@ -1,25 +1,11 @@
|
||||
"""Presence cache.
|
||||
|
||||
Redis-backed memo of "this identity is currently connected to this room".
|
||||
|
||||
This module is intentionally a *pure cache store* with no dependency on other
|
||||
services, so that `participants_management` (which talks to LiveKit) can
|
||||
import it without creating an import cycle. The composition of "check cache,
|
||||
fall back to LiveKit" lives in
|
||||
`ParticipantsManagement.check_if_in_meeting_cached`.
|
||||
|
||||
Only positive answers are stored: a sticky negative would lock out someone
|
||||
who joins right after a miss for the whole TTL. The TTL is a safety net in
|
||||
case an invalidation webhook is lost.
|
||||
"""
|
||||
"""Presence cache."""
|
||||
|
||||
from typing import FrozenSet
|
||||
from uuid import UUID
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
from core.utils import CACHE_SCAN_ITERSIZE
|
||||
|
||||
|
||||
class PresenceCache:
|
||||
"""Store and invalidate (room, identity) presence entries."""
|
||||
@@ -29,24 +15,65 @@ class PresenceCache:
|
||||
"""Cache key for a (room, identity) presence entry."""
|
||||
return f"{settings.PRESENCE_KEY_PREFIX}_{room_id!s}_{identity}"
|
||||
|
||||
@staticmethod
|
||||
def _get_index_key(room_id: UUID | str) -> str:
|
||||
"""Raw Redis key of the per-room identity index (a native SET).
|
||||
|
||||
Built through django-redis' make_key so it lives under the same
|
||||
KEY_PREFIX/version namespace as the presence entries.
|
||||
"""
|
||||
return cache.client.make_key(
|
||||
f"{settings.PRESENCE_KEY_PREFIX}-index_{room_id!s}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _redis(write: bool = True):
|
||||
"""Raw redis-py client.
|
||||
|
||||
SADD/SREM/SMEMBERS are not exposed by the Django cache API; this is
|
||||
the documented django-redis escape hatch.
|
||||
"""
|
||||
return cache.client.get_client(write=write)
|
||||
|
||||
def _index_members(self, room_id: UUID | str) -> FrozenSet[str]:
|
||||
"""All identities currently indexed for the room."""
|
||||
members = self._redis(write=False).smembers(self._get_index_key(room_id))
|
||||
return frozenset(
|
||||
member.decode() if isinstance(member, bytes) else member
|
||||
for member in members
|
||||
)
|
||||
|
||||
def is_marked_present(self, room_id: UUID | str, identity: str) -> bool:
|
||||
"""Return True if a positive presence entry exists in cache."""
|
||||
return bool(cache.get(self._get_cache_key(room_id, identity)))
|
||||
|
||||
def mark_present(self, room_id: UUID | str, identity: str) -> None:
|
||||
"""Record that `identity` is in `room_id`."""
|
||||
"""Record that `identity` is in `room_id` and index it for the room."""
|
||||
cache.set(
|
||||
self._get_cache_key(room_id, identity),
|
||||
True,
|
||||
timeout=settings.PRESENCE_CACHE_TIMEOUT,
|
||||
)
|
||||
index_key = self._get_index_key(room_id)
|
||||
pipe = self._redis().pipeline(transaction=False)
|
||||
pipe.sadd(index_key, identity)
|
||||
pipe.expire(index_key, settings.PRESENCE_CACHE_TIMEOUT)
|
||||
pipe.execute()
|
||||
|
||||
def clear(self, room_id: UUID | str, identity: str) -> None:
|
||||
"""Forget presence for one participant (e.g. on participant_left)."""
|
||||
cache.delete(self._get_cache_key(room_id, identity))
|
||||
self._redis().srem(self._get_index_key(room_id), identity)
|
||||
|
||||
def clear_room(self, room_id: UUID | str) -> None:
|
||||
"""Forget presence for every participant of a room (on room_finished)."""
|
||||
cache.delete_pattern(
|
||||
self._get_cache_key(room_id, "*"), itersize=CACHE_SCAN_ITERSIZE
|
||||
)
|
||||
"""Forget presence for every participant of a room (on room_finished).
|
||||
|
||||
Deletes the indexed entries and the index itself with targeted
|
||||
commands instead of a full-keyspace pattern scan.
|
||||
"""
|
||||
identities = self._index_members(room_id)
|
||||
if identities:
|
||||
cache.delete_many(
|
||||
[self._get_cache_key(room_id, identity) for identity in identities]
|
||||
)
|
||||
self._redis().delete(self._get_index_key(room_id))
|
||||
|
||||
@@ -73,6 +73,90 @@ def test_handle_limit_reached_error(mock_notify, mode, notification_type, servic
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "notification_type"),
|
||||
(
|
||||
("screen_recording", "screenRecordingFailed"),
|
||||
("transcript", "transcriptionFailed"),
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_failed_success(mock_notify, mode, notification_type, service):
|
||||
"""Test handle_failed marks recording as failed and notifies participants."""
|
||||
|
||||
recording = RecordingFactory(status="active", mode=mode)
|
||||
service.handle_failed(recording)
|
||||
|
||||
assert recording.status == "failed"
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_failed_error(mock_notify, service):
|
||||
"""Test handle_failed raises RecordingEventsError when notification fails."""
|
||||
|
||||
mock_notify.side_effect = NotificationError("Error notifying")
|
||||
|
||||
recording = RecordingFactory(status="active", mode="screen_recording")
|
||||
|
||||
with pytest.raises(
|
||||
RecordingEventsError,
|
||||
match=r"Failed to notify participants in room '.+' "
|
||||
r"about recording failed \(recording_id=.+\)",
|
||||
):
|
||||
service.handle_failed(recording)
|
||||
|
||||
assert recording.status == "failed"
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(recording.room.id),
|
||||
notification_data={"type": "screenRecordingFailed"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "notification_type"),
|
||||
(
|
||||
("screen_recording", "screenRecordingAborted"),
|
||||
("transcript", "transcriptionAborted"),
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_aborted_success(mock_notify, mode, notification_type, service):
|
||||
"""Test handle_aborted marks recording as aborted and notifies participants."""
|
||||
|
||||
recording = RecordingFactory(status="active", mode=mode)
|
||||
service.handle_aborted(recording)
|
||||
|
||||
assert recording.status == "aborted"
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_aborted_error(mock_notify, service):
|
||||
"""Test handle_aborted raises RecordingEventsError when notification fails."""
|
||||
|
||||
mock_notify.side_effect = NotificationError("Error notifying")
|
||||
|
||||
recording = RecordingFactory(status="active", mode="screen_recording")
|
||||
|
||||
with pytest.raises(
|
||||
RecordingEventsError,
|
||||
match=r"Failed to notify participants in room '.+' "
|
||||
r"about recording aborted \(recording_id=.+\)",
|
||||
):
|
||||
service.handle_aborted(recording)
|
||||
|
||||
assert recording.status == "aborted"
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(recording.room.id),
|
||||
notification_data={"type": "screenRecordingAborted"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["active", "stopped"])
|
||||
@pytest.mark.parametrize(
|
||||
("notify_return_value", "expected_status"),
|
||||
|
||||
@@ -9,6 +9,7 @@ from unittest import mock
|
||||
from django.core.cache import cache
|
||||
|
||||
import pytest
|
||||
from freezegun import freeze_time
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ... import utils
|
||||
@@ -24,6 +25,7 @@ pytestmark = pytest.mark.django_db
|
||||
# Tests for request_entry endpoint
|
||||
|
||||
|
||||
@freeze_time("2025-01-01 10:00:00")
|
||||
def test_request_entry_anonymous(settings):
|
||||
"""Anonymous users should be allowed to request entry to a room."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
@@ -59,6 +61,7 @@ def test_request_entry_anonymous(settings):
|
||||
"username": "test_user",
|
||||
"status": "waiting",
|
||||
"color": "mocked-color",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
"livekit": None,
|
||||
}
|
||||
|
||||
@@ -71,6 +74,7 @@ def test_request_entry_anonymous(settings):
|
||||
assert participant_data.get("username") == "test_user"
|
||||
|
||||
|
||||
@freeze_time("2025-01-01 10:00:00")
|
||||
def test_request_entry_authenticated_user(settings):
|
||||
"""Authenticated users should be allowed to request entry."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
@@ -108,6 +112,7 @@ def test_request_entry_authenticated_user(settings):
|
||||
"username": "test_user",
|
||||
"status": "waiting",
|
||||
"color": "mocked-color",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
"livekit": None,
|
||||
}
|
||||
|
||||
@@ -120,6 +125,7 @@ def test_request_entry_authenticated_user(settings):
|
||||
assert participant_data.get("username") == "test_user"
|
||||
|
||||
|
||||
@freeze_time("2025-01-01 10:00:00")
|
||||
def test_request_entry_with_existing_participants(settings):
|
||||
"""Anonymous users should be allowed to request entry to a room with existing participants."""
|
||||
# Create a restricted access room
|
||||
@@ -138,6 +144,7 @@ def test_request_entry_with_existing_participants(settings):
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
},
|
||||
)
|
||||
cache.set(
|
||||
@@ -147,6 +154,7 @@ def test_request_entry_with_existing_participants(settings):
|
||||
"username": "user2",
|
||||
"status": "accepted",
|
||||
"color": "#654321",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -178,6 +186,7 @@ def test_request_entry_with_existing_participants(settings):
|
||||
assert response.json() == {
|
||||
"id": participant_id,
|
||||
"username": "test_user",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
"status": "waiting",
|
||||
"color": "mocked-color",
|
||||
"livekit": None,
|
||||
@@ -192,6 +201,7 @@ def test_request_entry_with_existing_participants(settings):
|
||||
assert participant_data.get("username") == "test_user"
|
||||
|
||||
|
||||
@freeze_time("2025-01-01 10:00:00")
|
||||
def test_request_entry_public_room(settings):
|
||||
"""Entry requests to public rooms should return ACCEPTED status with LiveKit config."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
@@ -230,6 +240,7 @@ def test_request_entry_public_room(settings):
|
||||
assert response.json() == {
|
||||
"id": "123",
|
||||
"username": "test_user",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
"status": "accepted",
|
||||
"color": "mocked-color",
|
||||
"livekit": {"token": "test-token"},
|
||||
@@ -240,6 +251,7 @@ def test_request_entry_public_room(settings):
|
||||
assert not lobby_keys
|
||||
|
||||
|
||||
@freeze_time("2025-01-01 10:00:00")
|
||||
def test_request_entry_authenticated_user_public_room(settings):
|
||||
"""While authenticated, entry request to public rooms should get accepted."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
@@ -282,6 +294,7 @@ def test_request_entry_authenticated_user_public_room(settings):
|
||||
assert response.json() == {
|
||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
"username": "test_user",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
"status": "accepted",
|
||||
"color": "mocked-color",
|
||||
"livekit": {"token": "test-token"},
|
||||
@@ -292,6 +305,7 @@ def test_request_entry_authenticated_user_public_room(settings):
|
||||
assert not lobby_keys
|
||||
|
||||
|
||||
@freeze_time("2025-01-01 10:00:00")
|
||||
def test_request_entry_waiting_participant_public_room(settings):
|
||||
"""While waiting, entry request to public rooms should get accepted."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
@@ -308,6 +322,7 @@ def test_request_entry_waiting_participant_public_room(settings):
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -338,6 +353,7 @@ def test_request_entry_waiting_participant_public_room(settings):
|
||||
"username": "user1",
|
||||
"status": "accepted",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
"livekit": {"token": "test-token"},
|
||||
}
|
||||
|
||||
@@ -443,6 +459,7 @@ def test_allow_participant_to_enter_success(settings, allow_entry, updated_statu
|
||||
"status": "waiting",
|
||||
"username": "foo",
|
||||
"color": "123",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -578,6 +595,7 @@ def test_list_waiting_participants_success(settings):
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
},
|
||||
)
|
||||
cache.set(
|
||||
@@ -587,28 +605,35 @@ def test_list_waiting_participants_success(settings):
|
||||
"username": "user2",
|
||||
"status": "waiting",
|
||||
"color": "#654321",
|
||||
"entered_at": "2025-01-01T10:05:00+00:00",
|
||||
},
|
||||
)
|
||||
lobby_service = LobbyService()
|
||||
lobby_service._index_add(room.id, "2f7f162f-e7d1-421b-90e7-02bfbfbf8def")
|
||||
lobby_service._index_add(room.id, "f4ca3ab8a6c04ad88097b8da33f60f10")
|
||||
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
participants = response.json().get("participants")
|
||||
assert sorted(participants, key=lambda p: p["id"]) == [
|
||||
{
|
||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
},
|
||||
{
|
||||
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
|
||||
"username": "user2",
|
||||
"status": "waiting",
|
||||
"color": "#654321",
|
||||
},
|
||||
]
|
||||
assert response.json() == {
|
||||
"participants": [
|
||||
{
|
||||
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
|
||||
"username": "user2",
|
||||
"status": "waiting",
|
||||
"color": "#654321",
|
||||
"entered_at": "2025-01-01T10:05:00+00:00",
|
||||
},
|
||||
{
|
||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_list_waiting_participants_empty(settings):
|
||||
|
||||
@@ -372,6 +372,67 @@ def test_rename_participant_unexpected_twirp_error(mock_livekit_client, room, to
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["John Doe", "Admin", "Room Owner"])
|
||||
def test_rename_participant_forbidden_when_display_name_edit_disabled(
|
||||
mock_livekit_client, settings, room, token, name
|
||||
):
|
||||
"""
|
||||
Test rename is rejected for authenticated users when the self-hoster
|
||||
disables AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME.
|
||||
"""
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url, {"name": name}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.data == {
|
||||
"error": "Authenticated participants cannot edit their display name"
|
||||
}
|
||||
mock_livekit_client.room.update_participant.assert_not_called()
|
||||
|
||||
|
||||
def test_rename_participant_allowed_when_display_name_edit_enabled(
|
||||
mock_livekit_client, settings, room, token
|
||||
):
|
||||
"""Test rename still works for authenticated users when the setting is enabled."""
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = True
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
mock_livekit_client.room.update_participant.assert_called_once()
|
||||
|
||||
|
||||
def test_rename_participant_anonymous_allowed_when_display_name_edit_disabled(
|
||||
mock_livekit_client, settings, room, anonymous_token
|
||||
):
|
||||
"""
|
||||
Test the setting only restricts authenticated users: anonymous participants
|
||||
have no account name to fall back on and can still rename themselves.
|
||||
"""
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"name": "Guest User"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
mock_livekit_client.room.update_participant.assert_called_once()
|
||||
|
||||
|
||||
def test_rename_participant_success_anonymous(
|
||||
mock_livekit_client, room, anonymous_token
|
||||
):
|
||||
|
||||
@@ -3,6 +3,7 @@ Test LiveKitEvents service.
|
||||
"""
|
||||
# pylint: disable=W0621,W0613, W0212, E0611
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
@@ -105,7 +106,6 @@ def test_handle_egress_ended_success( # pylint: disable=too-many-arguments, too
|
||||
(
|
||||
(EgressStatus.EGRESS_ACTIVE, "started"),
|
||||
(EgressStatus.EGRESS_ENDING, "saving"),
|
||||
(EgressStatus.EGRESS_ABORTED, "aborted"),
|
||||
),
|
||||
)
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
@@ -130,6 +130,7 @@ def test_handle_egress_updated_success(
|
||||
"egress_status",
|
||||
(
|
||||
EgressStatus.EGRESS_FAILED,
|
||||
EgressStatus.EGRESS_ABORTED,
|
||||
EgressStatus.EGRESS_LIMIT_REACHED,
|
||||
),
|
||||
)
|
||||
@@ -348,7 +349,7 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
|
||||
"notify_return_value, recording_status",
|
||||
[(True, "notification_succeeded"), (False, "saved")],
|
||||
)
|
||||
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913, PLR0917
|
||||
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
|
||||
mock_update_metadata,
|
||||
mock_notify,
|
||||
mock_notify_external_services,
|
||||
@@ -375,14 +376,108 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913, PLR0917
|
||||
assert recording.status == recording_status
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("egress_status", "recording_status", "notification_type"),
|
||||
(
|
||||
(EgressStatus.EGRESS_ABORTED, "aborted", "screenRecordingAborted"),
|
||||
(EgressStatus.EGRESS_FAILED, "failed", "screenRecordingFailed"),
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_unsuccessful_egress( # noqa: PLR0913
|
||||
mock_update_metadata,
|
||||
mock_notify,
|
||||
egress_status,
|
||||
recording_status,
|
||||
notification_type,
|
||||
service,
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
"""Should flag the recording and notify participants on aborted/failed egress."""
|
||||
|
||||
recording = RecordingFactory(
|
||||
worker_id="worker-1", status="active", mode="screen_recording"
|
||||
)
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = egress_status
|
||||
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||
)
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == recording_status
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("egress_status", "recording_status"),
|
||||
(
|
||||
(EgressStatus.EGRESS_ABORTED, "aborted"),
|
||||
(EgressStatus.EGRESS_FAILED, "failed"),
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_unsuccessful_egress_notification_fails(
|
||||
mock_update_metadata,
|
||||
mock_notify,
|
||||
egress_status,
|
||||
recording_status,
|
||||
service,
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
"""Test that notification failure does not disrupt the update."""
|
||||
|
||||
mock_notify.side_effect = NotificationError("Error notifying")
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = egress_status
|
||||
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == recording_status
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("egress_status", "event"),
|
||||
(
|
||||
(EgressStatus.EGRESS_ABORTED, "aborted"),
|
||||
(EgressStatus.EGRESS_FAILED, "failed"),
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_logs_livekit_error( # noqa: PLR0913
|
||||
mock_update_metadata, mock_notify, egress_status, event, service, caplog
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
"""Should log the reason LiveKit reported an unsuccessful egress."""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = egress_status
|
||||
mock_data.egress_info.error = "could not connect to the room"
|
||||
mock_data.egress_info.error_code = 500
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
assert f"Egress {event} for recording {recording.id}" in caplog.text
|
||||
assert "could not connect to the room" in caplog.text
|
||||
assert "error_code=500" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"egress_status",
|
||||
[
|
||||
EgressStatus.EGRESS_STARTING,
|
||||
EgressStatus.EGRESS_ACTIVE,
|
||||
EgressStatus.EGRESS_ENDING,
|
||||
EgressStatus.EGRESS_FAILED,
|
||||
EgressStatus.EGRESS_ABORTED,
|
||||
],
|
||||
)
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Test lobby service.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621,W0613, W0212, R0913
|
||||
# pylint: disable=W0621,W0613, W0212, R0913, C0302
|
||||
# ruff: noqa: PLR0913, PLR0917
|
||||
|
||||
import uuid
|
||||
@@ -14,6 +14,7 @@ from django.core.cache import cache
|
||||
from django.http import HttpResponse
|
||||
|
||||
import pytest
|
||||
from freezegun import freeze_time
|
||||
|
||||
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||
from core.models import RoleChoices, RoomAccessLevel
|
||||
@@ -24,7 +25,6 @@ from core.services.lobby import (
|
||||
LobbyParticipantStatus,
|
||||
LobbyService,
|
||||
)
|
||||
from core.services.presence import CACHE_SCAN_ITERSIZE
|
||||
from core.utils import NotificationError
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -56,6 +56,7 @@ def participant_dict():
|
||||
"username": "test-username",
|
||||
"id": "test-participant-id",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +68,7 @@ def participant_data():
|
||||
username="test-username",
|
||||
id="test-participant-id",
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
)
|
||||
|
||||
|
||||
@@ -78,6 +80,7 @@ def test_lobby_participant_to_dict(participant_data):
|
||||
assert result["username"] == "test-username"
|
||||
assert result["id"] == "test-participant-id"
|
||||
assert result["color"] == "#123456"
|
||||
assert result["entered_at"] == "2025-01-01T10:00:00+00:00"
|
||||
|
||||
|
||||
def test_lobby_participant_from_dict_success(participant_dict):
|
||||
@@ -88,6 +91,20 @@ def test_lobby_participant_from_dict_success(participant_dict):
|
||||
assert participant.username == "test-username"
|
||||
assert participant.id == "test-participant-id"
|
||||
assert participant.color == "#123456"
|
||||
assert participant.entered_at == "2025-01-01T10:00:00+00:00"
|
||||
|
||||
|
||||
def test_lobby_participant_from_dict_missing_entered_at():
|
||||
"""`entered_at` is mandatory; data without it is rejected."""
|
||||
data = {
|
||||
"status": "waiting",
|
||||
"username": "test-username",
|
||||
"id": "test-participant-id",
|
||||
"color": "#123456",
|
||||
}
|
||||
|
||||
with pytest.raises(LobbyParticipantParsingError, match="Invalid participant data"):
|
||||
LobbyParticipant.from_dict(data)
|
||||
|
||||
|
||||
def test_lobby_participant_from_dict_default_status():
|
||||
@@ -96,6 +113,7 @@ def test_lobby_participant_from_dict_default_status():
|
||||
"username": "test-username",
|
||||
"id": "test-participant-id",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
}
|
||||
|
||||
participant = LobbyParticipant.from_dict(data_without_status)
|
||||
@@ -121,6 +139,7 @@ def test_lobby_participant_from_dict_invalid_status():
|
||||
"username": "test-username",
|
||||
"id": "test-participant-id",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
}
|
||||
|
||||
with pytest.raises(LobbyParticipantParsingError, match="Invalid participant data"):
|
||||
@@ -265,6 +284,7 @@ def test_request_entry_public_room(
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
)
|
||||
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
@@ -303,6 +323,7 @@ def test_request_entry_trusted_room(
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
)
|
||||
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
@@ -345,6 +366,7 @@ def test_request_entry_new_participant(
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
)
|
||||
mock_enter.return_value = participant_data
|
||||
|
||||
@@ -372,6 +394,7 @@ def test_request_entry_waiting_participant(
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
)
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||
@@ -400,6 +423,7 @@ def test_request_entry_accepted_participant(
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
)
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||
@@ -440,6 +464,7 @@ def test_request_entry_participant_with_role(
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
)
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||
@@ -466,18 +491,23 @@ def test_request_entry_participant_with_role(
|
||||
def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
|
||||
"""Test refreshing waiting status for a participant."""
|
||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||
lobby_service._index_touch = mock.Mock()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
lobby_service.refresh_waiting_status(room.id, participant_id)
|
||||
mock_cache.touch.assert_called_once_with(
|
||||
"mocked_cache_key", settings.LOBBY_WAITING_TIMEOUT
|
||||
)
|
||||
lobby_service._index_touch.assert_called_once_with(room.id)
|
||||
|
||||
|
||||
# pylint: disable=R0917
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
@mock.patch("core.utils.generate_color")
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.lobby.LobbyService._index_add")
|
||||
@freeze_time("2025-01-01 10:00:00")
|
||||
def test_enter_success(
|
||||
mock_index_add,
|
||||
mock_notify,
|
||||
mock_generate_color,
|
||||
mock_cache,
|
||||
@@ -497,6 +527,7 @@ def test_enter_success(
|
||||
assert participant.username == username
|
||||
assert participant.id == participant_id
|
||||
assert participant.color == "#123456"
|
||||
assert participant.entered_at == "2025-01-01T10:00:00+00:00"
|
||||
|
||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
@@ -508,13 +539,16 @@ def test_enter_success(
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(room.pk), notification_data={"type": "participantWaiting"}
|
||||
)
|
||||
mock_index_add.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
# pylint: disable=R0917
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
@mock.patch("core.utils.generate_color")
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.lobby.LobbyService._index_add")
|
||||
def test_enter_with_notification_error(
|
||||
mock_index_add,
|
||||
mock_notify,
|
||||
mock_generate_color,
|
||||
mock_cache,
|
||||
@@ -541,6 +575,7 @@ def test_enter_with_notification_error(
|
||||
participant.to_dict(),
|
||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||
)
|
||||
mock_index_add.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
@@ -579,14 +614,15 @@ def test_get_participant_parsing_error(
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
def test_list_waiting_participants_empty(mock_cache, lobby_service):
|
||||
"""Test listing waiting participants when none exist."""
|
||||
mock_cache.iter_keys.return_value = []
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
lobby_service._index_members = mock.Mock(return_value=[])
|
||||
lobby_service._index_remove = mock.Mock()
|
||||
|
||||
result = lobby_service.list_waiting_participants(room.id)
|
||||
|
||||
assert result == []
|
||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
||||
assert result == ()
|
||||
lobby_service._index_members.assert_called_once_with(room.id)
|
||||
lobby_service._index_remove.assert_not_called()
|
||||
mock_cache.get_many.assert_not_called()
|
||||
|
||||
|
||||
@@ -595,7 +631,8 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
|
||||
"""Test listing waiting participants with valid data."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||
mock_cache.iter_keys.return_value = [cache_key]
|
||||
lobby_service._index_members = mock.Mock(return_value=["participant1"])
|
||||
lobby_service._index_remove = mock.Mock()
|
||||
mock_cache.get_many.return_value = {cache_key: participant_dict}
|
||||
|
||||
result = lobby_service.list_waiting_participants(room.id)
|
||||
@@ -603,8 +640,8 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
|
||||
assert len(result) == 1
|
||||
assert result[0]["status"] == "waiting"
|
||||
assert result[0]["username"] == "test-username"
|
||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
||||
lobby_service._index_members.assert_called_once_with(room.id)
|
||||
lobby_service._index_remove.assert_called_once_with(room.id)
|
||||
mock_cache.get_many.assert_called_once_with([cache_key])
|
||||
|
||||
|
||||
@@ -620,6 +657,7 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
||||
"username": "user1",
|
||||
"id": "participant1",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
}
|
||||
|
||||
participant2 = {
|
||||
@@ -627,9 +665,13 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
||||
"username": "user2",
|
||||
"id": "participant2",
|
||||
"color": "#654321",
|
||||
"entered_at": "2025-01-01T10:05:00+00:00",
|
||||
}
|
||||
|
||||
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
|
||||
lobby_service._index_members = mock.Mock(
|
||||
return_value=["participant1", "participant2"]
|
||||
)
|
||||
lobby_service._index_remove = mock.Mock()
|
||||
mock_cache.get_many.return_value = {
|
||||
cache_key1: participant1,
|
||||
cache_key2: participant2,
|
||||
@@ -639,15 +681,15 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
||||
|
||||
assert len(result) == 2
|
||||
|
||||
# Verify both participants are in the result
|
||||
assert any(p["id"] == "participant1" and p["username"] == "user1" for p in result)
|
||||
assert any(p["id"] == "participant2" and p["username"] == "user2" for p in result)
|
||||
# Most recent entry comes first
|
||||
assert [p["id"] for p in result] == ["participant2", "participant1"]
|
||||
assert result[0]["username"] == "user2"
|
||||
assert result[1]["username"] == "user1"
|
||||
|
||||
# Verify all participants have waiting status
|
||||
assert all(p["status"] == "waiting" for p in result)
|
||||
|
||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
||||
lobby_service._index_members.assert_called_once_with(room.id)
|
||||
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
|
||||
|
||||
|
||||
@@ -656,12 +698,13 @@ def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service):
|
||||
"""Test listing waiting participants with corrupted data."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||
mock_cache.iter_keys.return_value = [cache_key]
|
||||
lobby_service._index_members = mock.Mock(return_value=["participant1"])
|
||||
lobby_service._index_remove = mock.Mock()
|
||||
mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}}
|
||||
|
||||
result = lobby_service.list_waiting_participants(room.id)
|
||||
|
||||
assert result == []
|
||||
assert result == ()
|
||||
mock_cache.delete.assert_called_once_with(cache_key)
|
||||
|
||||
|
||||
@@ -677,11 +720,15 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
|
||||
"username": "user2",
|
||||
"id": "participant2",
|
||||
"color": "#654321",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
}
|
||||
|
||||
corrupted_participant = {"invalid": "data"}
|
||||
|
||||
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
|
||||
lobby_service._index_members = mock.Mock(
|
||||
return_value=["participant1", "participant2"]
|
||||
)
|
||||
lobby_service._index_remove = mock.Mock()
|
||||
mock_cache.get_many.return_value = {
|
||||
cache_key1: corrupted_participant,
|
||||
cache_key2: valid_participant,
|
||||
@@ -699,8 +746,6 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
|
||||
mock_cache.delete.assert_called_once_with(cache_key1)
|
||||
|
||||
# Verify both cache keys were queried
|
||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
||||
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
|
||||
|
||||
|
||||
@@ -716,15 +761,20 @@ def test_list_waiting_participants_non_waiting(mock_cache, lobby_service):
|
||||
"username": "user1",
|
||||
"id": "participant1",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
}
|
||||
participant2 = {
|
||||
"status": "accepted",
|
||||
"username": "user2",
|
||||
"id": "participant2",
|
||||
"color": "#654321",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
}
|
||||
|
||||
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
|
||||
lobby_service._index_members = mock.Mock(
|
||||
return_value=["participant1", "participant2"]
|
||||
)
|
||||
lobby_service._index_remove = mock.Mock()
|
||||
mock_cache.get_many.return_value = {
|
||||
cache_key1: participant1,
|
||||
cache_key2: participant2,
|
||||
@@ -816,10 +866,12 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
|
||||
"username": "test-username",
|
||||
"id": participant_id,
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
}
|
||||
|
||||
mock_cache.get.return_value = participant_dict
|
||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||
lobby_service._index_touch = mock.Mock()
|
||||
|
||||
lobby_service._update_participant_status(
|
||||
room.id,
|
||||
@@ -833,10 +885,12 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
|
||||
"username": "test-username",
|
||||
"id": participant_id,
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
}
|
||||
mock_cache.set.assert_called_once_with(
|
||||
"mocked_cache_key", expected_data, timeout=60
|
||||
)
|
||||
lobby_service._index_touch.assert_called_once_with(room.id)
|
||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@@ -857,6 +911,7 @@ def test_clear_room_cache(settings, lobby_service):
|
||||
username="participant1",
|
||||
id="participant1",
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
),
|
||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||
)
|
||||
@@ -867,6 +922,7 @@ def test_clear_room_cache(settings, lobby_service):
|
||||
username="participant2",
|
||||
id="participant2",
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
),
|
||||
timeout=settings.LOBBY_ACCEPTED_TIMEOUT,
|
||||
)
|
||||
@@ -877,13 +933,18 @@ def test_clear_room_cache(settings, lobby_service):
|
||||
username="participant3",
|
||||
id="participant3",
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
),
|
||||
timeout=settings.LOBBY_DENIED_TIMEOUT,
|
||||
)
|
||||
|
||||
for participant_id in ("participant1", "participant2", "participant3"):
|
||||
lobby_service._index_add(room_id, participant_id)
|
||||
|
||||
lobby_service.clear_room_cache(room_id)
|
||||
|
||||
assert cache.keys(f"test-lobby_{room_id!s}_*") == []
|
||||
assert lobby_service._index_members(room_id) == frozenset()
|
||||
|
||||
|
||||
def test_clear_room_empty(settings, lobby_service):
|
||||
@@ -908,12 +969,16 @@ def test_clear_participant_cache(lobby_service):
|
||||
"username": "test-username",
|
||||
"id": participant_id,
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
}
|
||||
cache.set(cache_key, participant_data, timeout=settings.LOBBY_WAITING_TIMEOUT)
|
||||
lobby_service._index_add(room_id, participant_id)
|
||||
assert cache.get(cache_key) is not None
|
||||
assert participant_id in lobby_service._index_members(room_id)
|
||||
|
||||
lobby_service.clear_participant_cache(room_id, participant_id)
|
||||
assert cache.get(cache_key) is None
|
||||
assert participant_id not in lobby_service._index_members(room_id)
|
||||
|
||||
|
||||
def test_clear_participant_cache_nonexistent(lobby_service):
|
||||
@@ -927,3 +992,85 @@ def test_clear_participant_cache_nonexistent(lobby_service):
|
||||
lobby_service.clear_participant_cache(room_id, participant_id)
|
||||
|
||||
assert cache.get(cache_key) is None
|
||||
|
||||
|
||||
def test_index_add_members_remove_roundtrip(lobby_service):
|
||||
"""The room index records, lists and forgets participant ids."""
|
||||
room_id = uuid.uuid4()
|
||||
|
||||
assert lobby_service._index_members(room_id) == frozenset()
|
||||
|
||||
lobby_service._index_add(room_id, "participant1")
|
||||
lobby_service._index_add(room_id, "participant2")
|
||||
|
||||
assert sorted(lobby_service._index_members(room_id)) == [
|
||||
"participant1",
|
||||
"participant2",
|
||||
]
|
||||
|
||||
# The index carries a backstop TTL so abandoned rooms cannot leak it.
|
||||
ttl = lobby_service._redis().ttl(lobby_service._get_index_key(room_id))
|
||||
assert 0 < ttl <= settings.LOBBY_ACCEPTED_TIMEOUT
|
||||
|
||||
lobby_service._index_remove(room_id, "participant1")
|
||||
assert lobby_service._index_members(room_id) == frozenset(["participant2"])
|
||||
|
||||
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_enter_registers_participant_in_room_index(
|
||||
mock_notify, lobby_service, participant_id, username
|
||||
):
|
||||
"""Entering the lobby must index the participant id for the room."""
|
||||
room_id = uuid.uuid4()
|
||||
|
||||
lobby_service.enter(room_id, participant_id, username)
|
||||
|
||||
assert lobby_service._index_members(room_id) == frozenset([participant_id])
|
||||
|
||||
|
||||
def test_list_waiting_participants_prunes_stale_index_ids(settings, lobby_service):
|
||||
"""Indexed ids whose cache entry expired are pruned and not listed."""
|
||||
settings.LOBBY_KEY_PREFIX = "test-lobby-prune"
|
||||
room_id = uuid.uuid4()
|
||||
|
||||
cache.set(
|
||||
f"test-lobby-prune_{room_id!s}_participant1",
|
||||
{
|
||||
"id": "participant1",
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
},
|
||||
timeout=100,
|
||||
)
|
||||
lobby_service._index_add(room_id, "participant1")
|
||||
# participant2 is indexed but its cache entry has expired.
|
||||
lobby_service._index_add(room_id, "participant2")
|
||||
|
||||
result = lobby_service.list_waiting_participants(room_id)
|
||||
|
||||
assert [participant["id"] for participant in result] == ["participant1"]
|
||||
assert lobby_service._index_members(room_id) == frozenset(["participant1"])
|
||||
|
||||
|
||||
def test_refresh_waiting_status_rearms_room_index_ttl(lobby_service, participant_id):
|
||||
"""A lone waiter's polling must keep the room index alive.
|
||||
|
||||
Regression test: the index backstop TTL is only armed at enter() time,
|
||||
so a participant whose rolling WAITING refreshes outlast it would keep
|
||||
their entry alive while silently vanishing from the moderator list.
|
||||
Refreshing the waiting status must therefore re-arm the index TTL.
|
||||
"""
|
||||
room_id = uuid.uuid4()
|
||||
lobby_service._index_add(room_id, participant_id)
|
||||
|
||||
index_key = lobby_service._get_index_key(room_id)
|
||||
redis_client = lobby_service._redis()
|
||||
redis_client.expire(index_key, 10)
|
||||
assert redis_client.ttl(index_key) <= 10
|
||||
|
||||
lobby_service.refresh_waiting_status(room_id, participant_id)
|
||||
|
||||
assert redis_client.ttl(index_key) > 10
|
||||
assert lobby_service._index_members(room_id) == frozenset([participant_id])
|
||||
|
||||
@@ -90,19 +90,18 @@ def test_presence_clear_and_clear_room():
|
||||
assert presence.is_marked_present(other_room, "a") is True
|
||||
|
||||
|
||||
def test_presence_clear_room_scans_in_pages():
|
||||
"""clear_room removes every match, even across several SCAN pages,
|
||||
and only within the room."""
|
||||
def test_presence_clear_room_removes_many_entries_and_the_index():
|
||||
"""clear_room removes every entry of the room through the index — never
|
||||
a keyspace scan — and leaves other rooms untouched."""
|
||||
room_id, other_room = str(uuid4()), str(uuid4())
|
||||
presence = PresenceCache()
|
||||
for i in range(7):
|
||||
presence.mark_present(room_id, f"user-{i}")
|
||||
presence.mark_present(other_room, "user-0")
|
||||
|
||||
# An itersize smaller than the match count forces delete_pattern to
|
||||
# page through several SCAN cursors rather than finish in one pass.
|
||||
with mock.patch("core.utils.CACHE_SCAN_ITERSIZE", 3):
|
||||
presence.clear_room(room_id)
|
||||
presence.clear_room(room_id)
|
||||
|
||||
assert all(not presence.is_marked_present(room_id, f"user-{i}") for i in range(7))
|
||||
assert presence.is_marked_present(other_room, "user-0") is True
|
||||
assert presence._index_members(room_id) == frozenset([])
|
||||
assert presence._index_members(other_room) == frozenset(["user-0"])
|
||||
|
||||
@@ -499,6 +499,3 @@ def build_telephony_config():
|
||||
"default_country": country,
|
||||
"international_phone_number": international,
|
||||
}
|
||||
|
||||
|
||||
CACHE_SCAN_ITERSIZE = 500
|
||||
|
||||
@@ -865,7 +865,7 @@ class Base(Configuration):
|
||||
"room_lobby", environ_name="LOBBY_KEY_PREFIX", environ_prefix=None
|
||||
)
|
||||
LOBBY_WAITING_TIMEOUT = values.PositiveIntegerValue(
|
||||
3, environ_name="LOBBY_WAITING_TIMEOUT", environ_prefix=None
|
||||
6, environ_name="LOBBY_WAITING_TIMEOUT", environ_prefix=None
|
||||
)
|
||||
LOBBY_DENIED_TIMEOUT = values.PositiveIntegerValue(
|
||||
5, environ_name="LOBBY_DENIED_TIMEOUT", environ_prefix=None
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "1.30.0"
|
||||
version = "1.31.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
|
||||
Generated
+1
-1
@@ -1187,7 +1187,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "meet"
|
||||
version = "1.30.0"
|
||||
version = "1.31.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
+2
-12
@@ -42,20 +42,10 @@ ENV VITE_APP_TITLE=${VITE_APP_TITLE}
|
||||
RUN npm run build
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
|
||||
FROM nginxinc/nginx-unprivileged:1.30.4-alpine3.24 AS frontend-production
|
||||
|
||||
USER root
|
||||
|
||||
# Security patches for known CVEs
|
||||
RUN apk update && apk upgrade \
|
||||
libcrypto3>=3.5.7-r0 \
|
||||
libssl3>=3.5.7-r0 \
|
||||
musl \
|
||||
musl-utils \
|
||||
zlib>=1.3.2-r0 \
|
||||
libexpat>=2.8.4-r0 \
|
||||
&& apk del curl
|
||||
|
||||
RUN apk del curl
|
||||
USER nginx
|
||||
|
||||
# Un-privileged user running the application
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "1.30.0",
|
||||
"version": "1.31.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "1.30.0",
|
||||
"version": "1.31.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
|
||||
"@fontsource-variable/lexend": "5.3.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.30.0",
|
||||
"version": "1.31.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { css, cva, RecipeVariantProps } from '@/styled-system/css'
|
||||
import React, { useLayoutEffect, useMemo } from 'react'
|
||||
import React, { useMemo } from 'react'
|
||||
|
||||
const avatar = cva({
|
||||
base: {
|
||||
@@ -28,24 +28,17 @@ const avatar = cva({
|
||||
},
|
||||
})
|
||||
|
||||
// Instantiating a segmenter is expensive; create it once and reuse it.
|
||||
const graphemeSegmenter =
|
||||
typeof Intl !== 'undefined' && 'Segmenter' in Intl
|
||||
? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
||||
: undefined
|
||||
|
||||
/**
|
||||
* Returns the first user-perceived character. Some Unicode characters span
|
||||
* multiple UTF-16 code units, so a naive index into the string can split them
|
||||
* and yield a broken glyph.
|
||||
*/
|
||||
const getFirstGrapheme = (value: string): string => {
|
||||
if (!value) return ''
|
||||
if (graphemeSegmenter) {
|
||||
const [first] = graphemeSegmenter.segment(value)
|
||||
return first?.segment ?? ''
|
||||
}
|
||||
// Fallback: keeps single code points intact (including surrogate pairs).
|
||||
return Array.from(value)[0] ?? ''
|
||||
}
|
||||
|
||||
@@ -66,36 +59,6 @@ export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
export const Avatar = React.memo(
|
||||
({ name, bgColor, context, notification, style, ...props }: AvatarProps) => {
|
||||
const initials = useMemo(() => getInitials(name), [name])
|
||||
const textRef = React.useRef<SVGTextElement>(null)
|
||||
const [offsetY, setOffsetY] = React.useState(0)
|
||||
|
||||
// Optically center the initials: measure the ink bounding box of the
|
||||
// rendered glyphs and shift them so the box's center sits at the middle
|
||||
// of the viewBox. Works for any font, weight or glyph shape, unlike a
|
||||
// hand-tuned dy offset. getBBox() is in local (pre-transform)
|
||||
// coordinates, so applying the translation never changes the measure.
|
||||
useLayoutEffect(() => {
|
||||
const text = textRef.current
|
||||
if (!text) return
|
||||
|
||||
const center = () => {
|
||||
const box = text.getBBox()
|
||||
// A hidden element measures as an empty box; keep the default then.
|
||||
if (box.height === 0) return
|
||||
setOffsetY(50 - (box.y + box.height / 2))
|
||||
}
|
||||
|
||||
center()
|
||||
// Glyph metrics can change once webfonts finish loading.
|
||||
let cancelled = false
|
||||
document.fonts?.ready.then(() => {
|
||||
if (!cancelled) center()
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [initials])
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ backgroundColor: bgColor, ...style }}
|
||||
@@ -108,15 +71,16 @@ export const Avatar = React.memo(
|
||||
className={css({ width: '100%', height: '100%', display: 'block' })}
|
||||
>
|
||||
<text
|
||||
ref={textRef}
|
||||
x="50"
|
||||
y="50"
|
||||
transform={`translate(0 ${offsetY})`}
|
||||
y={50}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize="52"
|
||||
fontWeight="500"
|
||||
fill="currentColor"
|
||||
className={css({
|
||||
transform:
|
||||
'translateY(calc(var(--avatar-cap-height, 0.7) * 0.5em))',
|
||||
})}
|
||||
>
|
||||
{initials}
|
||||
</text>
|
||||
|
||||
@@ -18,7 +18,7 @@ export const fetchUser = (
|
||||
}
|
||||
): Promise<ApiUser | false> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
fetchApi<ApiUser>('/users/me')
|
||||
fetchApi<ApiUser>('/users/me/')
|
||||
.then(resolve)
|
||||
.catch((error) => {
|
||||
// we assume that a 401 means the user is not logged in
|
||||
|
||||
@@ -96,6 +96,10 @@ export const MainNotificationToast = () => {
|
||||
case NotificationType.ScreenRecordingStopped:
|
||||
case NotificationType.TranscriptionLimitReached:
|
||||
case NotificationType.ScreenRecordingLimitReached:
|
||||
case NotificationType.TranscriptionFailed:
|
||||
case NotificationType.ScreenRecordingFailed:
|
||||
case NotificationType.TranscriptionAborted:
|
||||
case NotificationType.ScreenRecordingAborted:
|
||||
toastQueue.add(
|
||||
{
|
||||
participant,
|
||||
|
||||
@@ -10,11 +10,15 @@ export enum NotificationType {
|
||||
TranscriptionStarted = 'transcriptionStarted',
|
||||
TranscriptionStopped = 'transcriptionStopped',
|
||||
TranscriptionLimitReached = 'transcriptionLimitReached',
|
||||
TranscriptionFailed = 'transcriptionFailed',
|
||||
TranscriptionAborted = 'transcriptionAborted',
|
||||
TranscriptionRequested = 'transcriptionRequested',
|
||||
ScreenRecordingStarted = 'screenRecordingStarted',
|
||||
ScreenRecordingStopped = 'screenRecordingStopped',
|
||||
ScreenRecordingRequested = 'screenRecordingRequested',
|
||||
ScreenRecordingLimitReached = 'screenRecordingLimitReached',
|
||||
ScreenRecordingFailed = 'screenRecordingFailed',
|
||||
ScreenRecordingAborted = 'screenRecordingAborted',
|
||||
RecordingSaving = 'recordingSaving',
|
||||
PermissionsRemoved = 'permissionsRemoved',
|
||||
RoleChanged = 'roleChanged',
|
||||
|
||||
@@ -22,12 +22,20 @@ export function ToastAnyRecording({ state, ...props }: Readonly<ToastProps>) {
|
||||
return 'transcript.stopped'
|
||||
case NotificationType.TranscriptionLimitReached:
|
||||
return 'transcript.limitReached'
|
||||
case NotificationType.TranscriptionFailed:
|
||||
return 'transcript.failed'
|
||||
case NotificationType.TranscriptionAborted:
|
||||
return 'transcript.aborted'
|
||||
case NotificationType.ScreenRecordingStarted:
|
||||
return 'screenRecording.started'
|
||||
case NotificationType.ScreenRecordingStopped:
|
||||
return 'screenRecording.stopped'
|
||||
case NotificationType.ScreenRecordingLimitReached:
|
||||
return 'screenRecording.limitReached'
|
||||
case NotificationType.ScreenRecordingFailed:
|
||||
return 'screenRecording.failed'
|
||||
case NotificationType.ScreenRecordingAborted:
|
||||
return 'screenRecording.aborted'
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
@@ -58,6 +58,10 @@ const renderToast = (
|
||||
case NotificationType.ScreenRecordingStarted:
|
||||
case NotificationType.ScreenRecordingStopped:
|
||||
case NotificationType.ScreenRecordingLimitReached:
|
||||
case NotificationType.TranscriptionFailed:
|
||||
case NotificationType.ScreenRecordingFailed:
|
||||
case NotificationType.TranscriptionAborted:
|
||||
case NotificationType.ScreenRecordingAborted:
|
||||
return <ToastAnyRecording key={toast.key} toast={toast} state={state} />
|
||||
|
||||
case NotificationType.TranscriptionRequested:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ApiError } from '@/api/ApiError'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { captureEvent } from '@/features/analytics/telemetry'
|
||||
import { useMutation, type UseMutationOptions } from '@tanstack/react-query'
|
||||
|
||||
export interface EnterRoomParams {
|
||||
@@ -17,13 +18,23 @@ export const enterRoom = async ({
|
||||
allowEntry,
|
||||
participantId,
|
||||
}: EnterRoomParams): Promise<EnterRoomResponse> => {
|
||||
return await fetchApi<EnterRoomResponse>(`/rooms/${roomId}/enter/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
participant_id: participantId,
|
||||
allow_entry: allowEntry,
|
||||
}),
|
||||
})
|
||||
try {
|
||||
return await fetchApi<EnterRoomResponse>(`/rooms/${roomId}/enter/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
participant_id: participantId,
|
||||
allow_entry: allowEntry,
|
||||
}),
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.statusCode === 404) {
|
||||
captureEvent('lobby_entry_participant_gone', {
|
||||
room_id: roomId,
|
||||
allow_entry: allowEntry,
|
||||
})
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function useEnterRoom(
|
||||
|
||||
@@ -8,6 +8,7 @@ export type WaitingParticipant = {
|
||||
status: string
|
||||
username: string
|
||||
color: string
|
||||
entered_at: string
|
||||
}
|
||||
|
||||
export type WaitingParticipantsResponse = {
|
||||
|
||||
@@ -9,6 +9,14 @@ import {
|
||||
} from '../../participants/api/listWaitingParticipants'
|
||||
import { reportError } from '@/features/analytics/telemetry'
|
||||
|
||||
const toTimestamp = (participant: WaitingParticipant): number =>
|
||||
Date.parse(participant.entered_at)
|
||||
|
||||
export const sortWaitingParticipants = (
|
||||
participants: WaitingParticipant[]
|
||||
): WaitingParticipant[] =>
|
||||
[...participants].sort((a, b) => toTimestamp(a) - toTimestamp(b))
|
||||
|
||||
export const useWaitingParticipants = () => {
|
||||
const roomData = useRoomData()
|
||||
const roomId = roomData?.id || '' // FIXME - bad practice
|
||||
@@ -22,7 +30,10 @@ export const useWaitingParticipants = () => {
|
||||
})
|
||||
|
||||
const waitingParticipants = useMemo(
|
||||
() => (canManageLobby ? waitingData?.participants || [] : []),
|
||||
() =>
|
||||
canManageLobby
|
||||
? sortWaitingParticipants(waitingData?.participants || [])
|
||||
: [],
|
||||
[waitingData, canManageLobby]
|
||||
)
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export enum RecordingStatus {
|
||||
Stopped = 'stopped',
|
||||
Saved = 'saved',
|
||||
Aborted = 'aborted',
|
||||
Failed = 'failed',
|
||||
FailedToStart = 'failedToStart',
|
||||
FailedToStop = 'failedToStop',
|
||||
NotificationSucceed = 'notification_succeeded',
|
||||
|
||||
@@ -12,8 +12,8 @@ import { keys } from '@/api/queryKeys'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { ApiError } from '@/api/ApiError'
|
||||
|
||||
export const POLL_INTERVAL_MS = 1000
|
||||
export const LAZY_POLL_INTERVAL_MS = 10_000
|
||||
export const POLL_INTERVAL_MS = 4_000
|
||||
export const LAZY_POLL_INTERVAL_MS = 15_000
|
||||
|
||||
export const LobbyProvider = () => {
|
||||
const room = useRoomContext()
|
||||
@@ -79,7 +79,7 @@ export const LobbyProvider = () => {
|
||||
// 3. Rights regained.
|
||||
const prevCanManageLobby = usePrevious(canManageLobby)
|
||||
useEffect(() => {
|
||||
if (!prevCanManageLobby && canManageLobby && isConnected) {
|
||||
if (prevCanManageLobby != canManageLobby && isConnected) {
|
||||
fetchIfManager()
|
||||
}
|
||||
}, [
|
||||
|
||||
@@ -16,7 +16,7 @@ const Card = styled('div', {
|
||||
borderRadius: '0.25rem',
|
||||
boxShadow: '',
|
||||
width: '100%',
|
||||
maxWidth: '380px',
|
||||
maxWidth: '410px',
|
||||
minHeight: '196px',
|
||||
},
|
||||
})
|
||||
@@ -229,7 +229,7 @@ const ConfirmationMessage = ({ onNext }: { onNext: () => void }) => {
|
||||
return (
|
||||
<Card
|
||||
style={{
|
||||
maxWidth: '380px',
|
||||
maxWidth: '410px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from '../api/requestEntry'
|
||||
|
||||
export const WAIT_TIMEOUT_MS = 600000 // 10 minutes
|
||||
export const POLL_INTERVAL_MS = 1000
|
||||
export const POLL_INTERVAL_MS = 3_000
|
||||
|
||||
export const useLobby = ({
|
||||
roomId,
|
||||
|
||||
@@ -24,7 +24,8 @@ const Heading = styled('h1', {
|
||||
})
|
||||
|
||||
const buttonClass = css({
|
||||
width: { base: '100%', xsm: 'auto' },
|
||||
width: '100%',
|
||||
flex: 1,
|
||||
})
|
||||
|
||||
enum DisconnectReasonKey {
|
||||
@@ -64,8 +65,8 @@ const FeedbackRoute = () => {
|
||||
<Heading>{t(`feedback.heading.${reasonKey || 'normal'}`)}</Heading>
|
||||
<Stack
|
||||
direction={{ base: 'column', xsm: 'row' }}
|
||||
width={{ base: '100%', xsm: 'auto' }}
|
||||
maxWidth="380px"
|
||||
width="100%"
|
||||
maxWidth="410px"
|
||||
>
|
||||
{showBackButton && (
|
||||
<Button
|
||||
|
||||
@@ -35,30 +35,21 @@ const LoginHint = () => {
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '103px',
|
||||
right: '110px',
|
||||
top: 'calc(100% + 12px)',
|
||||
right: 0,
|
||||
zIndex: '100',
|
||||
outline: 'none',
|
||||
padding: '1.25rem',
|
||||
maxWidth: '350px',
|
||||
width: 'max-content',
|
||||
maxWidth: 'min(350px, calc(100vw - 2rem))',
|
||||
boxShadow: '0 2px 5px rgba(0 0 0 / 0.1)',
|
||||
borderRadius: '1rem',
|
||||
backgroundColor: 'primary.200',
|
||||
display: 'none',
|
||||
xsm: {
|
||||
display: 'block',
|
||||
},
|
||||
sm: {
|
||||
top: '131px',
|
||||
right: '100px',
|
||||
zIndex: '100',
|
||||
},
|
||||
_after: {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: '-10px',
|
||||
right: '20%',
|
||||
marginLeft: '-10px',
|
||||
right: '1.5rem',
|
||||
borderWidth: '0 10px 10px 10px',
|
||||
borderStyle: 'solid',
|
||||
borderColor: 'transparent transparent #E3E3FB transparent',
|
||||
@@ -171,12 +162,13 @@ export const Header = () => {
|
||||
<>
|
||||
<div
|
||||
className={css({
|
||||
position: 'relative',
|
||||
display: { base: 'none', xsm: 'block' },
|
||||
})}
|
||||
>
|
||||
<LoginButton proConnectHint={false} />
|
||||
<LoginHint />
|
||||
</div>
|
||||
<LoginHint />
|
||||
</>
|
||||
)}
|
||||
{!!user && (
|
||||
|
||||
@@ -36,12 +36,16 @@
|
||||
"started": "{{name}} hat die Meeting-Transkription gestartet.",
|
||||
"stopped": "{{name}} hat die Meeting-Transkription gestoppt.",
|
||||
"limitReached": "Die Transkription hat die maximal zulässige Dauer überschritten und wird automatisch gespeichert.",
|
||||
"failed": "Die Transkription wurde unerwartet beendet und konnte nicht gespeichert werden.",
|
||||
"aborted": "Die Transkription konnte nicht gestartet werden.",
|
||||
"requested": "{{name}} möchte die Meeting-Transkription starten."
|
||||
},
|
||||
"screenRecording": {
|
||||
"started": "{{name}} hat die Meeting-Aufzeichnung gestartet.",
|
||||
"stopped": "{{name}} hat die Meeting-Aufzeichnung gestoppt.",
|
||||
"limitReached": "Die Aufzeichnung hat die maximal zulässige Dauer überschritten und wird automatisch gespeichert.",
|
||||
"failed": "Die Aufzeichnung wurde unerwartet beendet und konnte nicht gespeichert werden.",
|
||||
"aborted": "Die Aufzeichnung konnte nicht gestartet werden.",
|
||||
"requested": "{{name}} möchte die Meeting-Aufzeichnung starten."
|
||||
},
|
||||
"recordingSave": {
|
||||
|
||||
@@ -36,12 +36,16 @@
|
||||
"started": "{{name}} started the meeting transcription.",
|
||||
"stopped": "{{name}} stopped the meeting transcription.",
|
||||
"limitReached": "The transcription has exceeded the maximum allowed duration and will be automatically saved.",
|
||||
"failed": "The transcription stopped unexpectedly and could not be saved.",
|
||||
"aborted": "The transcription could not be started.",
|
||||
"requested": "{{name}} wants to start the meeting transcription."
|
||||
},
|
||||
"screenRecording": {
|
||||
"started": "{{name}} started the meeting recording.",
|
||||
"stopped": "{{name}} stopped the meeting recording.",
|
||||
"limitReached": "The recording has exceeded the maximum allowed duration and will be automatically saved.",
|
||||
"failed": "The recording stopped unexpectedly and could not be saved.",
|
||||
"aborted": "The recording could not be started.",
|
||||
"requested": "{{name}} wants to start the meeting recording."
|
||||
},
|
||||
"recordingSave": {
|
||||
|
||||
@@ -36,12 +36,16 @@
|
||||
"started": "{{name}} ha iniciado la transcripción de la reunión.",
|
||||
"stopped": "{{name}} ha detenido la transcripción de la reunión.",
|
||||
"limitReached": "La transcripción ha superado la duración máxima permitida, se va a guardar automáticamente.",
|
||||
"failed": "La transcripción se ha interrumpido de forma inesperada y no se ha podido guardar.",
|
||||
"aborted": "No se ha podido iniciar la transcripción.",
|
||||
"requested": "{{name}} ha solicitado iniciar la transcripción."
|
||||
},
|
||||
"screenRecording": {
|
||||
"started": "{{name}} ha iniciado la grabación de la reunión.",
|
||||
"stopped": "{{name}} ha detenido la grabación de la reunión.",
|
||||
"limitReached": "La grabación ha superado la duración máxima permitida, se va a guardar automáticamente.",
|
||||
"failed": "La grabación se ha interrumpido de forma inesperada y no se ha podido guardar.",
|
||||
"aborted": "No se ha podido iniciar la grabación.",
|
||||
"requested": "{{name}} ha solicitado iniciar la grabación."
|
||||
},
|
||||
"recordingSave": {
|
||||
|
||||
@@ -36,12 +36,16 @@
|
||||
"started": "{{name}} a démarré la transcription de la réunion.",
|
||||
"stopped": "{{name}} a arrêté la transcription de la réunion.",
|
||||
"limitReached": "La transcription a dépassé la durée maximale autorisée, elle va être automatiquement sauvegardée.",
|
||||
"failed": "La transcription s'est interrompue de façon inattendue et n'a pas pu être sauvegardée.",
|
||||
"aborted": "La transcription n'a pas pu être démarrée.",
|
||||
"requested": "{{name}} a demandé à démarrer la transcription."
|
||||
},
|
||||
"screenRecording": {
|
||||
"started": "{{name}} a démarré l'enregistrement de la réunion.",
|
||||
"stopped": "{{name}} a arrêté l'enregistrement de la réunion.",
|
||||
"limitReached": "L'enregistrement a dépassé la durée maximale autorisée, il va être automatiquement sauvegardé.",
|
||||
"failed": "L'enregistrement s'est interrompu de façon inattendue et n'a pas pu être sauvegardé.",
|
||||
"aborted": "L'enregistrement n'a pas pu être démarré.",
|
||||
"requested": "{{name}} a demandé à démarrer l'enregistrement."
|
||||
},
|
||||
"recordingSave": {
|
||||
|
||||
@@ -36,12 +36,16 @@
|
||||
"started": "{{name}} is de transcriptie van de vergadering gestart.",
|
||||
"stopped": "{{name}} heeft de transcriptie van de vergadering gestopt.",
|
||||
"limitReached": "De transcriptie heeft de maximaal toegestane duur overschreden en wordt automatisch opgeslagen.",
|
||||
"failed": "De transcriptie is onverwacht gestopt en kon niet worden opgeslagen.",
|
||||
"aborted": "De transcriptie kon niet worden gestart.",
|
||||
"requested": "{{name}} wil graag de transcriptie starten."
|
||||
},
|
||||
"screenRecording": {
|
||||
"started": "{{name}} is begonnen met het opnemen van de vergadering.",
|
||||
"stopped": "{{name}} is gestopt met het opnemen van de vergadering.",
|
||||
"limitReached": "De opname heeft de maximaal toegestane duur overschreden en wordt automatisch opgeslagen.",
|
||||
"failed": "De opname is onverwacht gestopt en kon niet worden opgeslagen.",
|
||||
"aborted": "De opname kon niet worden gestart.",
|
||||
"requested": "{{name}} wil graag de opname starten."
|
||||
},
|
||||
"recordingSave": {
|
||||
|
||||
@@ -6,6 +6,10 @@ body,
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:root {
|
||||
--avatar-cap-height: 0.7;
|
||||
}
|
||||
|
||||
html.font-lexend {
|
||||
--fonts-sans: 'Lexend Variable', ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.30.0",
|
||||
"version": "1.31.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mail_mjml",
|
||||
"version": "1.30.0",
|
||||
"version": "1.31.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@html-to/text-cli": "0.6.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.30.0",
|
||||
"version": "1.31.0",
|
||||
"description": "An util to generate html and text django's templates from mjml templates",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.30.0",
|
||||
"version": "1.31.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sdk",
|
||||
"version": "1.30.0",
|
||||
"version": "1.31.0",
|
||||
"license": "ISC",
|
||||
"workspaces": [
|
||||
"./library",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.30.0",
|
||||
"version": "1.31.0",
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "summary"
|
||||
version = "1.30.0"
|
||||
version = "1.31.0"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.105.0",
|
||||
|
||||
Generated
+1
-1
@@ -1507,7 +1507,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "summary"
|
||||
version = "1.30.0"
|
||||
version = "1.31.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "celery" },
|
||||
|
||||
Reference in New Issue
Block a user