Compare commits

..

19 Commits

Author SHA1 Message Date
leo 1f9f54e896 wip 2026-09-08 18:15:45 +02:00
leo 93e0ba945e lint 2026-09-08 17:56:27 +02:00
leo 10f8dddf1a wip 2026-09-08 17:49:48 +02:00
leo 9e01d297a1 wip 2026-09-08 17:36:50 +02:00
leo 7247b05a56 wip 2026-09-08 17:12:17 +02:00
lebaudantoine bf76ab1ddf 🔒️(backend) enforce display name setting on rename API
AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME was only enforced at
LiveKit token generation and by hiding the name field in the frontend.
The `rooms/{id}/rename/` endpoint never checked it, so any authenticated
user with a valid room token could rename themselves via the API even
when the self-hoster had disabled it.

Return 403 from the rename action for authenticated users when the
setting is disabled, mirroring the `can_edit` rule in
`core.utils.generate_token`. Anonymous participants are unaffected, as
they have no account name to fall back on.

Add tests covering the disabled/enabled cases for authenticated users
and the anonymous exception.
2026-09-08 01:26:27 +02:00
lebaudantoine 7565ede0a7 🔖(minor) bump release to 1.31.0 2026-09-08 00:45:01 +02:00
lebaudantoine 1a15e9f44e (frontend) align feedback buttons with rating card
Match the button row width to the rating card (100%, max 410px) and
make both buttons share it equally so their edges line up with the card.
2026-09-07 23:55:05 +02:00
lebaudantoine 7838d8acfe 🐛(frontend) refetch waiting participants when the lobby becomes disabled
When the lobby is disabled mid-meeting (e.g. the room is switched to
public), the waiting participants list stopped being refetched, so
the previously cached list stayed visible with stale data.

Trigger a refetch in that case as well, so the list is cleared and
the moderator UI no longer shows waiting participants for a lobby
that is no longer active.
2026-09-07 23:30:27 +02:00
lebaudantoine 3bb388b937 (backend) sort waiting participants by their arrival time
Highlighted by a suggestion from @florent, the waiting participant
list was not sorted, so moderators could see participants in an
arbitrary order.

Add an explicit `entered_at` attribute on each waiting participant,
so the list can be sorted by arrival time. Participants are now
shown in a stable order of arrival, both across polls and across
moderators.
2026-09-07 23:30:27 +02:00
lebaudantoine e1cc8105db 💄(frontend) position the login hint dynamically next to the button
Compute the position of the login hint at render time so it is
always displayed close to the login button, regardless of the
button's placement or the current viewport size.
2026-09-07 20:12:57 +02:00
lebaudantoine 7844dfcc12 📈(frontend) track missing lobby participant on accept/reject
When a moderator accepts or rejects a lobby entry that no longer
exists, emit a tracking event so we can measure how often it
happens.

This signal will help tune the lobby polling interval: too many
"not found" events means the moderator side is working from a stale
list. Keep raising the error to the client on top of tracking it,
so the frontend still surfaces the issue (its current handling of
this case is still incomplete).
2026-09-07 20:12:57 +02:00
lebaudantoine ef71003721 🔊(backend) log request duration in Gunicorn workers
Include the time taken by each request in the Gunicorn worker
access logs, so we can spot slow endpoints and correlate latency
patterns directly from the logs.
2026-09-07 20:12:57 +02:00
lebaudantoine f74d23c57e ️(backend) refactor presence cache to bound key lookups per room
The previous presence cache lookup keyed off a scan over the whole
cache, so its cost was O(db_size) rather than O(room_size).
Combined with the recent switch to cursor-based `SCAN` at an
inappropriate page size, this caused a lot of Redis round-trips and
noticeably slowed down the backend pods under load.

Refactor the presence cache to keep a per-room set of all its
participant keys. Lookups now iterate that set instead of scanning
the whole database.

Complexity is now bounded by room size, not database size, which
should restore the backend performance to its previous levels while
keeping the lobby behavior unchanged.
2026-09-07 20:12:57 +02:00
lebaudantoine acedb21045 ️(backend) refactor lobby storage to bound key lookups per room
The previous lobby lookup keyed off a scan over the whole cache, so
its cost was O(db_size) rather than O(room_size). Combined with the
recent switch to cursor-based `SCAN` at an inappropriate page size,
this caused a lot of Redis round-trips and noticeably slowed down
the backend pods under load.

Refactor the lobby storage to keep a per-room set of all its lobby
keys. Lookups now iterate that set instead of scanning the whole
database:

* Membership in the set acts as a memory of who is supposedly in
  the lobby for a given room.
* Individual keys are then read to check who is actually still
  waiting or accepted.

Complexity is now bounded by room size, not database size, which
should restore the backend performance to its previous levels while
keeping the lobby behavior unchanged.
2026-09-07 20:12:57 +02:00
lebaudantoine 67e7d382e3 ️(frontend) add trailing slash on the /me endpoint call
The `/me` endpoint was called without a trailing slash, so every
request was going through a 301 redirect before hitting the actual
endpoint.

This endpoint is called by every user at least once per session, so
based on the logs, avoiding the redirect should cut the volume of
requests hitting it by around 10%.
2026-09-07 20:12:57 +02:00
lebaudantoine 164ac8d948 ️(frontend) increase lobby polling interval on both sides
Increase the polling interval used by the lobby feature, on both the
waiting participant side and the moderator side.

The goal is to reduce the volume of requests the lobby generates,
trading a bit of data freshness for better performance.

It will de facto reduce pressure on the backend.

We will observe the impact in production, and revisit these
intervals if the delays turn out to be too aggressive.
2026-09-07 20:12:57 +02:00
lebaudantoine e3deb37fbe 🔒️(frontend) upgrade base image to 1.30.4-alpine3.24
Bump the frontend base image to `1.30.4-alpine3.24`, which picks up
fixes for the CVEs listed below and lets us drop the individual
dependency pins that were only there to address earlier known CVEs.

Address the following HIGH severity CVEs in libuuid / util-linux,
reported by Trivy. Bumping to 2.41.6-r1 (bundled in the new base
image) covers all of them:

* CVE-2026-53612 — TOCTOU in mount post-mount ownership/mode
  changes.
* CVE-2026-53613 — TOCTOU in mount via ancestor directory swap.
* CVE-2026-53614 — SUID mount(8) nosuid/noexec bypass via
  LIBMOUNT_FORCE_MOUNT2.
* CVE-2026-76642 — failed external mount helper still runs
  privileged X-mount post-hooks.
* CVE-2026-78408 — nsenter --join-cgroup leaks root cgroup
  migration authority (fixed in 2.41.6-r1).
* CVE-2026-78410 — restricted bind mounts do not pin the source,
  allowing X-mount.owner/group/mode escalation.
2026-09-07 16:40:03 +02:00
lebaudantoine 33929324d0 🐛(frontend) keep feedback buttons on one line for fr/es/en
A minor layout regression appeared when switching to the Marianne
font: the feedback buttons wrapped onto two lines instead of
staying on one.

Adjust the layout so the buttons stay on a single line regardless
of the font in use.
2026-09-07 16:40:03 +02:00
52 changed files with 900 additions and 300 deletions
+18 -1
View File
@@ -8,15 +8,27 @@ 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
@@ -24,7 +36,12 @@ and this project adheres to
- 🐛(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
- 🔒️(backend) reject inactive users in resource server backend
- 🐛(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
+3 -11
View File
@@ -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
@@ -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'
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -9,7 +9,7 @@ resolution-markers = [
[[package]]
name = "agents"
version = "1.29.0"
version = "1.31.0"
source = { virtual = "." }
dependencies = [
{ name = "httpx" },
+9
View File
@@ -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)
@@ -286,10 +286,6 @@ class ResourceServerBackend(LaSuiteBackend):
if user is None and settings.OIDC_CREATE_USER:
user = self.create_user(sub)
if user is not None and not user.is_active:
logger.warning("Inactive user attempted authentication: %s", user.pk)
raise SuspiciousOperation("User account is disabled.")
return user
def create_user(self, sub):
@@ -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),
),
]
+4 -6
View File
@@ -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):
+58 -4
View File
@@ -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."""
+84 -17
View File
@@ -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)
+48 -21
View File
@@ -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")
+169 -22
View File
@@ -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"])
@@ -1,96 +0,0 @@
"""Tests for the external API ResourceServerBackend."""
from django.core.exceptions import SuspiciousOperation
import pytest
import responses
from rest_framework.test import APIClient
from core.external_api.authentication import ResourceServerBackend
from core.factories import UserFactory
from core.models import User
pytestmark = pytest.mark.django_db
def _payload(sub):
return {"sub": sub, "active": True, "scope": "lasuite_meet", "client_id": "app"}
def test_resource_server_backend_get_or_create_user_active():
"""An existing active user matching the sub should be returned."""
user = UserFactory()
result = ResourceServerBackend().get_or_create_user(
access_token="token", id_token=None, payload=_payload(user.sub)
)
assert result == user
def test_resource_server_backend_get_or_create_user_inactive():
"""An inactive user should be rejected even with a valid token."""
user = UserFactory(is_active=False)
with pytest.raises(SuspiciousOperation, match="User account is disabled."):
ResourceServerBackend().get_or_create_user(
access_token="token", id_token=None, payload=_payload(user.sub)
)
def test_resource_server_backend_get_or_create_user_creates(settings):
"""An unknown sub should create an active user when OIDC_CREATE_USER is set."""
settings.OIDC_CREATE_USER = True
result = ResourceServerBackend().get_or_create_user(
access_token="token", id_token=None, payload=_payload("new-sub")
)
assert result.sub == "new-sub"
assert result.is_active is True
assert User.objects.filter(sub="new-sub").exists()
def test_resource_server_backend_get_or_create_user_no_creation(settings):
"""An unknown sub should return None when OIDC_CREATE_USER is unset."""
settings.OIDC_CREATE_USER = False
result = ResourceServerBackend().get_or_create_user(
access_token="token", id_token=None, payload=_payload("new-sub")
)
assert result is None
assert not User.objects.filter(sub="new-sub").exists()
@responses.activate
def test_api_rooms_list_resource_server_inactive_user(settings):
"""End to end: a valid introspected token for an inactive user should get 401."""
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
settings.OIDC_OP_URL = "https://oidc.example.com"
user = UserFactory(is_active=False)
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"active": True,
"sub": user.sub,
"scope": "lasuite_meet",
"client_id": "app",
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer rs-token")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "login failed" in str(response.data).lower()
-3
View File
@@ -499,6 +499,3 @@ def build_telephony_config():
"default_country": country,
"international_phone_number": international,
}
CACHE_SCAN_ITERSIZE = 500
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -1187,7 +1187,7 @@ wheels = [
[[package]]
name = "meet"
version = "1.30.0"
version = "1.31.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+2 -12
View File
@@ -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
+2 -2
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.30.0",
"version": "1.31.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -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
+7 -15
View File
@@ -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": {
+2 -2
View File
@@ -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 -1
View File
@@ -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": {
+2 -2
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "1.30.0",
"version": "1.31.0",
"author": "",
"license": "ISC",
"description": "",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -1507,7 +1507,7 @@ wheels = [
[[package]]
name = "summary"
version = "1.30.0"
version = "1.31.0"
source = { editable = "." }
dependencies = [
{ name = "celery" },