Compare commits

..

11 Commits

Author SHA1 Message Date
briquet 71b9255fc0 🐛(backend) lock rooms while updating them
Before this commit, a room update and a soft deletion racing each other
could leave the room active.

Both `perform_update`s now re-read the room with `SELECT ... FOR UPDATE`
inside a transaction to avoid this race condition.
2026-10-12 20:39:43 +02:00
briquet 833ba48d65 ⬆️(backend) upgrade pylint to 4.1.3
pylint was pinned below 4.0 because pylint-django used to require it.
pylint-django 2.8.0 accepts pylint 4.

pylint 3.x kept too-many-lines pragma line numbers across modules: a C0302
disable in one file could make the check report on the wrong line
in another and fail depending on how files were spread across jobs.
pylint 4.1 resets them per module.

cf https://github.com/pylint-dev/pylint/pull/11191
2026-10-12 18:57:18 +02:00
briquet f8f75c485b ✨(backend) purge soft-deleted rooms after a retention period
Add a ROOM_DELETED_RETENTION_DAYS setting, unset by default. The
purge_inactive_rooms command now also deletes rooms soft deleted more
than that many days ago, whether they are inactive or not. Inactive
soft-deleted rooms are now purged under ROOM_INACTIVITY_DELETION_DAYS
too. As for inactive rooms, a room holding a saved recording that has
not expired yet is kept.
2026-10-12 18:57:10 +02:00
leo b4b2cb8da6 ✨(rooms) soft delete rooms from internal and external API
Before this commit:
1. Rooms could be hard-deleted from the internal API (unused by the
   frontend).
2. The external API had no way to delete a room.
3. The admin could hard-delete rooms.

What this commit does:
1. Add a `deleted_at` field to Room. `Room.objects` hides soft-deleted
   rooms; `all_objects` stays the default manager so deleted rooms keep
   reserving their slug and PIN code.
2. Soft delete rooms from the internal API (replacing hard delete) and
   the external API (new DELETE, owner only, `rooms:delete` scope). The
   LiveKit room is closed and its participants disconnected; if closing
   fails, the soft delete is rolled back and an error is returned.
3. Answer 410 Gone on detail operations on a deleted room (after
   permission checks). Its can still be read but not modified.
4. Close LiveKit rooms recreated after deletion by participants still
   holding a valid token (room_started webhook).
5. Frontend: participants in the call land on the feedback page without
   the rejoin button, lobby participants see a "meeting deleted" screen,
   and opening the link shows an error instead of recreating the room.

Untouched by this commit:
- The admin still hard-deletes rooms, without closing the LiveKit room,
  and lists soft-deleted rooms alongside active ones.
- Purging soft-deleted rooms (hard deletion) is not handled.
- Scheduled deletion is not handled.
2026-10-12 18:57:02 +02:00
lebaudantoine 0194f7f3f3 ⚡️(backend) send recording notification emails from a Celery task
Emails were rendered and sent inline in the LiveKit webhook, so a
slow SMTP server blocked the request and a transient failure lost
the email. Delivery now runs in a worker, with retries and backoff.

The task is generic (template, subject, recipients, context,
language) so other emails can reuse it. The subject is translated
in the worker, and dates are formatted per owner before queuing,
so the arguments stay JSON-serializable.

Without Celery, the task still runs synchronously. Success now
means "queued", not "delivered".
2026-10-11 23:37:00 +02:00
Roee Sivan 0bde103911 🔧(project) silence gitlint regex-style deprecation warning
gitlint prints a warning for each ignore-by-title rule because it will switch
its regexes from match (start of the string) to search (anywhere) semantics.

Both ignore-by-title regexes are already anchored with ^, so they match the
same titles either way. Setting regex-style-search opts in to the new behavior
now and removes the warning.

Fixes #96
2026-10-11 23:34:02 +02:00
lebaudantoine c7c41abcb5 ✨(frontend) let users pick the recording encoding
Add a Recording tab to the settings dialog with two selectors, a
content profile and a resolution, building on the per-recording
encoding API.

The goal is to let an instance default to a cheaper profile and
resolution to save egress CPU, while users who really need a smooth
HD recording can still opt in. Most users only need a recording of
acceptable quality, so the default stays cheap and configuring it is
optional.

Profiles are labelled by content ("Talking heads", "Presentations and
text", ...) rather than by fps or bitrate: users know what they are
about to record, not which encoder settings fit it. Each profile shows
a one-line description of its trade-off, so picking one requires no
technical knowledge.

The panel gets a third row, matching the meeting language row of the
transcript panel. It is a shortcut to the settings, without adding
mental load about how to configure a recording. It reads as a short
statement with a trailing "Change" link, like the informational rows
above it, and stays on a single line in every locale: the statement
truncates if a translation gets too long, the link never wraps.

The selected options are intentionally not shown in the panel.
Several users can access it, and each would see their own local
configuration while the running recording may use another user's
encoding, which would be misleading. The issue is milder in the
settings tab, which is frozen while a recording is starting or in
progress.

The selectors are disabled from the moment a start request is sent
until the recording is saved, for both screen recordings and
transcripts. Settings are sent once, when the recording starts, so
changing them afterwards would have no effect and suggest otherwise.

Choices are persisted in localStorage to keep things simple,
consistent with the meeting language, which is already stored
client-side.

The backend now exposes the available resolutions and profiles, and
their defaults, in the frontend configuration, so the UI follows
whatever the operator configures instead of hardcoding values. Stored
choices are checked against it before being sent: a profile removed by
the operator falls back to the instance default instead of being
rejected by the API. No encoding is sent when per-recording encoding
is disabled, as the backend would reject it. Operator-defined values
without a translation are shown by their raw key.

Alternatives considered:

- Storing the preference in room metadata: it would turn a pre-start,
  personal choice into shared editable state, requiring a write
  endpoint, permission checks and handling of concurrent edits.
- Notifying participants of the chosen encoding: notifications are
  one-off, so users who join or reload after the recording started
  would miss them, and the information is noise for most participants.

Next steps:

This is a deliberately small first iteration, meant to deliver most
of the value and evolve with feedback and usage. Possible next steps:
- persist the preferences in the backend instead of localStorage
- store the encoding chosen for a recording on the room (or on a
  session, if such a model is introduced) and read it from there
  rather than from user settings, so every user sees the same value
2026-10-11 23:02:57 +02:00
Roee Sivan 531774fbda 🐛(frontend) keep the admin panel in place when changing a setting
React Aria renders the radio and switch inputs visually hidden and
absolutely positioned. Their labels were not positioned, so in the
side panel the inputs were anchored to the aside and did not scroll
with its content. Focusing one made Chrome scroll the aside, which
has no scrollbar, hiding the panel content.

Position the labels so each hidden input stays inside its control.
2026-10-11 16:08:25 +02:00
Ovgodd 86d97b427c 🐛(frontend) play the speaker test when no output is picked
The speaker test called setSinkId('default'). Firefox rejects it
and playback stopped. Skip that id and play on the system output.
2026-10-11 00:16:47 +02:00
Ovgodd b0bb5bc61b 🐛(frontend) omit the default speaker id sent to LiveKit
Firefox rejects setSinkId('default') when a remote audio track
is attached. Omit that id and only send a real device id.
2026-10-11 00:16:47 +02:00
Ovgodd 3773e43d93 ♿️(frontend) show a visible focus outline on menu items
Keyboard focus was cleared with outline:none.
2026-10-09 18:13:14 +02:00
93 changed files with 2531 additions and 515 deletions
+3
View File
@@ -22,6 +22,9 @@
# See http://jorisroovers.github.io/gitlint/user_defined_rules for details
extra-path=gitlint/
# Use "search" semantics for regexes (gitlint's future default)
regex-style-search=True
# [title-max-length]
# line-length=80
+8
View File
@@ -10,7 +10,10 @@ and this project adheres to
### Added
- ✨(frontend) let users pick the recording encoding
- ✨(backend) introduce a token exchange endpoint for iframe embeds
- ⚡️(backend) send recording notification emails from a Celery task
- ✨(backend) add room soft-deletion to the external API
### Changed
@@ -18,6 +21,8 @@ and this project adheres to
- ⚡️(backend) reduce domain queries on the application token endpoint
- ♻️️️(backend) use a dedicated auth scheme for LiveKit token auth
- ♻️(all) stop relying on cookies for the lobby flow
- ♿️(frontend) show a visible focus outline on menu items #1797
- ⬆️(backend) upgrade pylint to 4.1.3
### Fixed
@@ -25,6 +30,9 @@ and this project adheres to
- 🔒️(frontend) upgrade tiff to fix CVE-2026-4775
- 🔒️(backend) bind accepted lobby entries to the current username
- 🔒️(backend) sign lobby participant id
- 🐛(frontend) omit the default speaker id sent to LiveKit
- 🐛(frontend) play the speaker test when no output is picked
- 🐛(frontend) keep the admin panel in place when changing a setting #1804
## [1.34.0] - 2026-10-07
+8
View File
@@ -16,6 +16,14 @@ the following command inside your docker container:
## [Unreleased]
### Purging deleted rooms
Deleting a room now only soft deletes it: it is hidden but keeps its slug and PIN code. The `purge_inactive_rooms` command also permanently deletes soft-deleted rooms. See [the room purge documentation](docs/features/room-purge.md).
- Inactive soft-deleted rooms are purged along with the other inactive rooms: if `ROOM_INACTIVITY_DELETION_DAYS` is already set, nothing else is needed.
- The new `ROOM_DELETED_RETENTION_DAYS` setting purges soft-deleted rooms that many days after their deletion, whether they are inactive or not. It is unset by default.
- As for inactive rooms, a deleted room holding a saved recording its users may still access is kept until that recording expires.
### Marketing / Brevo integration now uses `django-lasuite`
The in-house marketing service (`core.services.marketing`) has been removed and
+11 -5
View File
@@ -1,6 +1,6 @@
# Room purge
Rooms pile up over time and most of them are only used once. The `purge_inactive_rooms` management command permanently deletes the rooms that have not been started for a configurable number of days. It is disabled by default.
Rooms pile up over time and most of them are only used once. The `purge_inactive_rooms` management command permanently deletes the rooms that have not been started for a configurable number of days, and the rooms deleted by their owner for longer than a configurable retention period. It is disabled by default.
## How it works
@@ -12,16 +12,21 @@ A room is inactive when:
Rooms that existed before this feature was deployed are considered started on the day of the release, so none of them can be purged before a full inactivity period has elapsed.
The command is meant to run once a day. The Helm chart schedules it in `backend.cronjobs` (`purge-inactive-rooms`, 01:00); it does nothing until `ROOM_INACTIVITY_DELETION_DAYS` is set.
Deleting a room through the API only soft deletes it: the room is hidden, its link tells visitors the meeting was deleted, and it keeps its slug and PIN code. A soft-deleted room is purged:
- when it is inactive, as described above: a deleted room is never considered started again, so its inactivity period keeps running, or
- when it was deleted more than `ROOM_DELETED_RETENTION_DAYS` days ago.
The command is meant to run once a day. The Helm chart schedules it in `backend.cronjobs` (`purge-inactive-rooms`, 01:00); it does nothing until `ROOM_INACTIVITY_DELETION_DAYS` or `ROOM_DELETED_RETENTION_DAYS` is set.
```bash
python manage.py purge_inactive_rooms # delete the inactive rooms
python manage.py purge_inactive_rooms # delete the inactive and deleted rooms
python manage.py purge_inactive_rooms --dry-run # only list the rooms that would be deleted
```
## Rooms that are kept
A recording can only be reached through its room. An inactive room is kept as long as it holds a saved recording its users may still access:
A recording can only be reached through its room. A room is kept, past its inactivity or retention period, as long as it holds a saved recording its users may still access:
- with `RECORDING_EXPIRATION_DAYS` set, a saved recording created less than that many days ago,
- with `RECORDING_EXPIRATION_DAYS` unset, any saved recording.
@@ -33,7 +38,8 @@ The room is deleted from the database, along with its accesses, its telephony PI
The recording **files in the bucket are left untouched**: the backend never deletes anything from the storage, it only drops the database entries pointing at it. Removing the files is the job of the bucket lifecycle policy, which should match `RECORDING_EXPIRATION_DAYS` (see the [recording documentation](recording.md)). When the two do not match, the purge leaves objects behind: they become unreachable, since serving a recording requires its database entry, but they keep costing storage.
⚠️ When a room is purged, all it's configuration and access rights are also deleted. Its slug becomes available again
and can be reused when a meeting is created from that same URL.
and can be reused when a meeting is created from that same URL. This also applies to a soft-deleted room: once purged, its link
no longer tells visitors the meeting was deleted, and its PIN code can be given to another room.
* With `ALLOW_UNREGISTERED_ROOMS=false`, only an authenticated user can navigate to a previously existing link after the room has been purged. Doing so recreates the room in the database with a fresh configuration, with that user associated with it and granted admin rights.
+1
View File
@@ -113,6 +113,7 @@ DJANGO_EMAIL_FROM=<your email address>
#DJANGO_EMAIL_USE_TLS=true # A flag to enable or disable TLS for email sending.
#DJANGO_EMAIL_USE_SSL=true # A flag to enable or disable SSL for email sending.
#DJANGO_EMAIL_TIMEOUT=30 # Seconds before a stalled SMTP connection times out.
DJANGO_EMAIL_BRAND_NAME=<brand name used in email templates> # e.g. "La Suite Numérique"
+2
View File
@@ -357,6 +357,7 @@ These are the environmental options available on meet backend.
| DJANGO_EMAIL_PORT | Port to connect to the email server | |
| DJANGO_EMAIL_USE_TLS | Enable TLS on email connection | false |
| DJANGO_EMAIL_USE_SSL | Enable SSL on email connection | false |
| DJANGO_EMAIL_TIMEOUT | Seconds before a stalled SMTP connection times out | 30 |
| DJANGO_EMAIL_FROM | Email from account | from@example.com |
| EMAIL_BRAND_NAME | Email branding name | |
| EMAIL_SUPPORT_EMAIL | Support email address | |
@@ -407,6 +408,7 @@ These are the environmental options available on meet backend.
| RESOURCE_DEFAULT_ACCESS_LEVEL | Default resource access level for rooms | public |
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
| ROOM_INACTIVITY_DELETION_DAYS | Days without being started after which a room is purged. Unset to never purge | |
| ROOM_DELETED_RETENTION_DAYS | Days after its deletion after which a room is purged. Unset to purge deleted rooms only once inactive | |
| RECORDING_ENABLE | Record meeting option | false |
| RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings |
| RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
-6
View File
@@ -24,12 +24,6 @@
"matchManagers": ["pep621"],
"matchPackageNames": ["redis"]
},
{
"groupName": "allowed pylint versions",
"matchManagers": ["pep621"],
"matchPackageNames": ["pylint"],
"allowedVersions": "<4.0.0"
},
{
"groupName": "allowed django versions",
"matchManagers": ["pep621"],
-4
View File
@@ -31,10 +31,6 @@ persistent=yes
# Specify a configuration file.
#rcfile=
# When enabled, pylint would attempt to guess common misconfiguration and emit
# user-friendly hints instead of false-positive error messages
suggestion-mode=yes
# Allow loading of arbitrary C extensions. Extensions are imported into the
# active Python interpreter and may run arbitrary code.
unsafe-load-any-extension=no
+9 -2
View File
@@ -286,9 +286,16 @@ class RoomAdmin(admin.ModelAdmin):
"get_owner",
"created_at",
"last_started_at",
"deleted_at",
]
list_filter = ["access_level", "created_at", "last_started_at", "deleted_at"]
readonly_fields = [
"id",
"created_at",
"updated_at",
"last_started_at",
"deleted_at",
]
list_filter = ["access_level", "created_at", "last_started_at"]
readonly_fields = ["id", "created_at", "updated_at", "last_started_at"]
def get_queryset(self, request):
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
+1
View File
@@ -9,6 +9,7 @@ class AnalyticsEvent(StrEnum):
# Rooms
ROOM_CREATED = "room_created"
ROOM_UPDATED = "room_updated"
ROOM_DELETED = "room_deleted"
# Roomkit (meeting-room SIP devices)
ROOMKIT_JOINED = "roomkit_joined"
+11
View File
@@ -44,6 +44,17 @@ def get_frontend_configuration(request):
"available_modes": settings.RECORDING_WORKER_CLASSES.keys(),
"expiration_days": settings.RECORDING_EXPIRATION_DAYS,
"max_duration": settings.RECORDING_MAX_DURATION,
"encoding": {
"is_custom_enabled": settings.RECORDING_CUSTOM_ENCODING_ENABLED,
"available_resolutions": list(
settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS.keys()
),
"available_profiles": list(
settings.RECORDING_ENCODING_AVAILABLE_PROFILES.keys()
),
"default_resolution": settings.RECORDING_ENCODING_DEFAULT_RESOLUTION,
"default_profile": settings.RECORDING_ENCODING_DEFAULT_PROFILE,
},
},
"background_image": {
"upload_is_enabled": settings.FILE_UPLOAD_ENABLED,
+30
View File
@@ -0,0 +1,30 @@
"""Exceptions and guards shared by the API endpoints."""
from django.utils.translation import gettext_lazy as _
from rest_framework import exceptions, status
from core import models
class RoomSoftDeleted(exceptions.APIException):
"""Raised when the requested room has been soft deleted."""
status_code = status.HTTP_410_GONE
default_detail = _("This room has been deleted.")
default_code = "room_deleted"
def ensure_room_not_deleted(resource):
"""Raise a 410 Gone if the resource is a soft-deleted room.
Accepts a room or its parent resource, as referenced by accesses. Call it
after permissions are checked, to avoid revealing room.
"""
if isinstance(resource, models.Room):
room = resource
else:
room = getattr(resource, "room", None)
if room is not None and room.is_deleted:
raise RoomSoftDeleted()
+17
View File
@@ -0,0 +1,17 @@
"""Row locks guarding the API write paths."""
import contextlib
from django.db import transaction
from core import models
from core.api.exceptions import ensure_room_not_deleted
@contextlib.contextmanager
def room_locked_for_update(room):
"""Lock a room's row for the duration of a write."""
with transaction.atomic():
locked_room = models.Room.all_objects.select_for_update().get(pk=room.pk)
ensure_room_not_deleted(locked_room)
yield locked_room
+2
View File
@@ -26,6 +26,7 @@ from rest_framework.exceptions import PermissionDenied
from timezone_field.rest_framework import TimeZoneSerializerField
from core import models, utils
from core.api.exceptions import ensure_room_not_deleted
logger = logging.getLogger(__name__)
@@ -114,6 +115,7 @@ class ResourceAccessSerializerMixin:
_("You must be administrator or owner of a room to add accesses to it.")
)
ensure_room_not_deleted(resource)
return resource
+46 -9
View File
@@ -42,7 +42,9 @@ from rest_framework.settings import api_settings
from core import analytics, enums, models, utils
from core.api import throttling
from core.api.exceptions import ensure_room_not_deleted
from core.api.filters import ListFileFilter
from core.api.locks import room_locked_for_update
from core.enums import MEDIA_STORAGE_URL_PATTERN
from core.recording.enums import FileExtension
from core.recording.event.authentication import RecordingProcessWebhookAuthentication
@@ -77,7 +79,7 @@ from core.services.participants_management import (
ParticipantsManagementException,
)
from core.services.room_creation import RoomCreation
from core.services.room_management import RoomManagement
from core.services.room_management import RoomManagement, RoomManagementException
from core.services.room_roles import (
RoomRoleError,
RoomRoleService,
@@ -261,7 +263,7 @@ class UserViewSet(
return drf_response.Response(data)
class RoomViewSet(
class RoomViewSet( # pylint: disable=too-many-public-methods
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.UpdateModelMixin,
@@ -287,10 +289,9 @@ class RoomViewSet(
filter_kwargs = {"pk": self.kwargs["pk"]}
except ValueError:
filter_kwargs = {"slug": slugify(self.kwargs["pk"])}
queryset = self.filter_queryset(self.get_queryset())
obj = get_object_or_404(queryset, **filter_kwargs)
# May raise a permission denied
obj = get_object_or_404(models.Room.all_objects, **filter_kwargs)
self.check_object_permissions(self.request, obj)
ensure_room_not_deleted(obj)
return obj
def retrieve(self, request, *args, **kwargs):
@@ -342,6 +343,27 @@ class RoomViewSet(
serializer = self.get_serializer(queryset, many=True)
return drf_response.Response(serializer.data)
def perform_destroy(self, instance):
"""Soft delete the room and close its LiveKit room.
The room and its recordings are kept in database for traceability.
"""
try:
RoomManagement.soft_delete(instance)
except RoomManagementException as e:
raise drf_exceptions.APIException(
"Could not delete the room, please try again."
) from e
analytics.capture(
self.request.user,
analytics.AnalyticsEvent.ROOM_DELETED,
{
"room_id": str(instance.pk),
"access_level": instance.access_level,
},
)
def perform_create(self, serializer):
"""Set the current user as owner of the newly created room.
@@ -384,12 +406,20 @@ class RoomViewSet(
)
def perform_update(self, serializer):
"""Persist the room update, then sync metadata to LiveKit."""
"""Persist the room update under a row lock, then sync metadata to LiveKit.
old_configuration = serializer.instance.configuration
old_access_level = serializer.instance.access_level
The lock is what keeps a concurrent soft delete from being undone: the
full-row write of `save()` would otherwise carry back the `deleted_at`
read before the deletion committed.
"""
room = serializer.save()
with room_locked_for_update(serializer.instance) as locked_room:
serializer.instance = locked_room
old_configuration = locked_room.configuration
old_access_level = locked_room.access_level
room = serializer.save()
if (
room.configuration == old_configuration
@@ -1039,6 +1069,13 @@ class ResourceAccessViewSet(
return queryset
def get_object(self):
"""Accesses to a soft-deleted room can be read but no longer modified."""
access = super().get_object()
if self.request.method not in drf_permissions.SAFE_METHODS:
ensure_room_not_deleted(access.resource)
return access
class RecordingViewSet(
mixins.DestroyModelMixin,
+40 -8
View File
@@ -21,11 +21,14 @@ from rest_framework import (
from rest_framework import (
status as drf_status,
)
from rest_framework.generics import get_object_or_404
from core import analytics, api, models
from core.api.exceptions import ensure_room_not_deleted
from core.api.feature_flag import FeatureFlag
from core.api.locks import room_locked_for_update
from core.services.jwt_token import JwtTokenService
from core.services.room_management import RoomManagement
from core.services.room_management import RoomManagement, RoomManagementException
from core.services.transit_code import TransitCodeService
from ..services.provisional_user_service import (
@@ -142,6 +145,7 @@ class ApplicationViewSet(viewsets.ViewSet):
class RoomViewSet(
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.RetrieveModelMixin,
mixins.ListModelMixin,
mixins.UpdateModelMixin,
@@ -159,9 +163,13 @@ class RoomViewSet(
- create: Create a new room owned by the user (requires 'rooms:create' scope)
- partial_update: Update a room's access level and configuration, for
administrators and owners only (requires 'rooms:update' scope)
- destroy: Soft delete a room and close its LiveKit room, for owners only
(requires 'rooms:delete' scope)
Detail operations on a soft-deleted room answer 410 Gone.
"""
http_method_names = ["get", "post", "patch", "head", "options"]
http_method_names = ["get", "post", "patch", "delete", "head", "options"]
authentication_classes = [
authentication.ApplicationJWTAuthentication,
@@ -176,6 +184,17 @@ class RoomViewSet(
queryset = models.Room.objects.all()
serializer_class = serializers.RoomSerializer
def get_object(self):
"""Get the room, answer 410 if it has been deleted.
Permissions are checked first so a deleted room is only revealed to
users who would have been granted access to it.
"""
room = get_object_or_404(models.Room.all_objects, pk=self.kwargs["pk"])
self.check_object_permissions(self.request, room)
ensure_room_not_deleted(room)
return room
def list(self, request, *args, **kwargs):
"""Limit listed rooms to the ones related to the authenticated user."""
@@ -239,15 +258,28 @@ class RoomViewSet(
self._track_room_event(room, analytics.AnalyticsEvent.ROOM_CREATED)
def perform_destroy(self, instance):
"""Soft delete the room, close its LiveKit room, then log and track it."""
try:
RoomManagement.soft_delete(instance)
except RoomManagementException as e:
raise drf_exceptions.APIException(
"Could not delete the room, please try again."
) from e
self._track_room_event(instance, analytics.AnalyticsEvent.ROOM_DELETED)
def perform_update(self, serializer: serializers.RoomSerializer):
"""Persist the room update, sync it to LiveKit, then log and track it."""
"""Persist the room update under a row lock, sync it to LiveKit, then track it."""
previous_values = {
"access_level": serializer.instance.access_level,
"configuration": copy.deepcopy(serializer.instance.configuration),
}
with room_locked_for_update(serializer.instance) as locked_room:
serializer.instance = locked_room
room = serializer.save()
previous_values = {
"access_level": locked_room.access_level,
"configuration": copy.deepcopy(locked_room.configuration),
}
room = serializer.save()
# Report the fields that actually changed, not the ones that were submitted.
updated_fields = sorted(
+1 -1
View File
@@ -50,4 +50,4 @@ class SecretField(models.CharField):
setattr(model_instance, self.attname, hashed_secret)
return hashed_secret
return super().pre_save(model_instance, add)
return super().pre_save(model_instance, add) # pylint: disable=no-member
@@ -1,6 +1,8 @@
"""Purge inactive rooms."""
"""Purge inactive and soft-deleted rooms."""
import operator
from datetime import timedelta
from functools import reduce
from itertools import batched
from logging import getLogger
@@ -22,10 +24,12 @@ class Command(BaseCommand):
- rooms which were last started before that period
- rooms never started and created before that period
Also delete rooms soft deleted more than ROOM_DELETED_RETENTION_DAYS days ago.
Rooms holding a saved recording that has not expired are kept.
"""
help = "Purge inactive rooms"
help = "Purge inactive and soft-deleted rooms"
def add_arguments(self, parser):
parser.add_argument(
@@ -35,53 +39,68 @@ class Command(BaseCommand):
)
def handle(self, *args, **options):
"""Browse inactive rooms and delete them chunk by chunk."""
"""Browse purgeable rooms and delete them chunk by chunk."""
if not settings.ROOM_INACTIVITY_DELETION_DAYS:
if not (
settings.ROOM_INACTIVITY_DELETION_DAYS
or settings.ROOM_DELETED_RETENTION_DAYS
):
self.stdout.write(
"Purging inactive rooms is disabled "
"(ROOM_INACTIVITY_DELETION_DAYS is not set)."
"Purging rooms is disabled (neither ROOM_INACTIVITY_DELETION_DAYS "
"nor ROOM_DELETED_RETENTION_DAYS is set)."
)
return
now = timezone.now()
inactive_rooms = self.get_inactive_rooms(now)
purgeable_rooms = self.get_purgeable_rooms(now)
inactive_count = inactive_rooms.count()
if not inactive_count:
self.stdout.write("No inactive room to purge.")
purgeable_count = purgeable_rooms.count()
if not purgeable_count:
self.stdout.write("No room to purge.")
return
if options["dry_run"]:
self.stdout.write(
f"[dry-run] {inactive_count} inactive room(s) would be purged:"
)
names = inactive_rooms.values_list("name", flat=True)
for name in names.iterator(chunk_size=CHUNK_SIZE):
self.stdout.write(f"- {name}")
self.stdout.write(f"[dry-run] {purgeable_count} room(s) would be purged:")
rooms = purgeable_rooms.values_list("name", "deleted_at")
for name, deleted_at in rooms.iterator(chunk_size=CHUNK_SIZE):
self.stdout.write(f"- {name}{' (deleted)' if deleted_at else ''}")
return
purged_count = 0
rooms = inactive_rooms.values_list("pk", "slug").iterator(chunk_size=CHUNK_SIZE)
rooms = purgeable_rooms.values_list("pk", "slug", "deleted_at").iterator(
chunk_size=CHUNK_SIZE
)
for chunk in batched(rooms, CHUNK_SIZE, strict=False):
for room_id, slug in chunk:
logger.info("Purging inactive room %s (%s)", room_id, slug)
for room_id, slug, deleted_at in chunk:
state = "deleted" if deleted_at else "inactive"
logger.info("Purging %s room %s (%s)", state, room_id, slug)
_, deleted_by_model = inactive_rooms.filter(
pk__in=[room_id for room_id, _ in chunk]
_, deleted_by_model = purgeable_rooms.filter(
pk__in=[room_id for room_id, _, _ in chunk]
).delete()
purged_count += deleted_by_model.get("core.Room", 0)
self.stdout.write(f"Purged {purged_count} inactive room(s).")
self.stdout.write(f"Purged {purged_count} room(s).")
@staticmethod
def get_inactive_rooms(now):
"""Return the rooms inactive for too long that no recording protects."""
def get_purgeable_rooms(now):
"""Return the inactive or long-deleted rooms that no recording protects.
threshold = now - timedelta(days=settings.ROOM_INACTIVITY_DELETION_DAYS)
is_inactive = Q(last_started_at__lt=threshold) | Q(
last_started_at__isnull=True, created_at__lt=threshold
)
Soft-deleted rooms are hidden by `Room.objects`, hence `Room.all_objects`.
"""
conditions = []
if settings.ROOM_INACTIVITY_DELETION_DAYS:
threshold = now - timedelta(days=settings.ROOM_INACTIVITY_DELETION_DAYS)
conditions.append(
Q(last_started_at__lt=threshold)
| Q(last_started_at__isnull=True, created_at__lt=threshold)
)
if settings.ROOM_DELETED_RETENTION_DAYS:
deleted_before = now - timedelta(days=settings.ROOM_DELETED_RETENTION_DAYS)
conditions.append(Q(deleted_at__lt=deleted_before))
if not conditions:
return Room.all_objects.none()
protected_recordings = Recording.objects.filter(
room=OuterRef("pk"), status__in=RecordingStatusChoices.saved_statuses()
@@ -91,4 +110,6 @@ class Command(BaseCommand):
created_at__gte=now - timedelta(days=settings.RECORDING_EXPIRATION_DAYS)
)
return Room.objects.filter(is_inactive, ~Exists(protected_recordings))
return Room.all_objects.filter(
reduce(operator.or_, conditions), ~Exists(protected_recordings)
)
@@ -0,0 +1,29 @@
# Generated by Django 5.2.14 on 2026-09-16 10:00
import django.db.models.manager
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0026_alter_application_scopes'),
]
operations = [
migrations.AddField(
model_name='room',
name='deleted_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AlterModelOptions(
name='room',
options={'default_manager_name': 'all_objects', 'ordering': ('name',), 'verbose_name': 'Room', 'verbose_name_plural': 'Rooms'},
),
migrations.AlterModelManagers(
name='room',
managers=[
('all_objects', django.db.models.manager.Manager()),
],
),
]
+56 -1
View File
@@ -407,6 +407,33 @@ class ResourceAccess(BaseModel):
return super().delete(*args, **kwargs)
class RoomQuerySet(models.QuerySet):
"""QuerySet exposing the room lifecycle filters."""
def active(self):
"""Rooms that have not been soft deleted."""
return self.filter(deleted_at__isnull=True)
def deleted(self):
"""Rooms that have been soft deleted."""
return self.filter(deleted_at__isnull=False)
class RoomManager(models.Manager.from_queryset(RoomQuerySet)):
"""Manager hiding soft-deleted rooms, exposed as ``Room.objects``.
It is deliberately not the model's default manager: Django relies on
``_default_manager`` for unique validation, which must see deleted rooms as
they still guard their slug and pin code. Other object relations (e.g.
``recording.room``) go through the base manager and still resolve deleted
rooms, which keeps recordings and their notifications working.
"""
def get_queryset(self):
"""Exclude soft-deleted rooms."""
return super().get_queryset().active()
class Room(Resource):
"""Model for one room"""
@@ -430,6 +457,7 @@ class Room(Resource):
verbose_name=_("Visio room configuration"),
help_text=_("Values for Visio parameters to configure the room."),
)
deleted_at = models.DateTimeField(null=True, blank=True)
pin_code = models.CharField(
max_length=None,
unique=True,
@@ -446,8 +474,13 @@ class Room(Resource):
editable=False,
)
# Managers
objects = RoomManager()
all_objects = models.Manager.from_queryset(RoomQuerySet)()
class Meta:
db_table = "meet_room"
default_manager_name = "all_objects"
ordering = ("name",)
verbose_name = _("Room")
verbose_name_plural = _("Rooms")
@@ -470,6 +503,28 @@ class Room(Resource):
)
super().save(*args, **kwargs)
@property
def is_deleted(self):
"""Whether the room has been soft deleted."""
return self.deleted_at is not None
def soft_delete(self):
"""Soft delete the room.
The room is hidden from the default manager making it impossible to
list, join or update.
"""
deleted_at = timezone.now()
updated = Room.all_objects.filter(pk=self.pk, deleted_at__isnull=True).update(
deleted_at=deleted_at
)
if not updated:
raise RuntimeError("This room is already deleted.")
self.deleted_at = deleted_at
def clean_fields(self, exclude=None):
"""
Automatically generate the slug from the name and make sure it does not look like a UUID.
@@ -505,7 +560,7 @@ class Room(Resource):
for _ in range(settings.ROOM_TELEPHONY_PIN_MAX_RETRIES):
pin_code = str(secrets.randbelow(max_value)).zfill(length)
if not Room.objects.filter(pin_code=pin_code).exists():
if not Room.all_objects.filter(pin_code=pin_code).exists():
return pin_code
# Log a warning as a temporary measure until backend observability is implemented.
@@ -7,18 +7,18 @@ from datetime import datetime, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from django.conf import settings
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.translation import get_language, gettext, override
from django.utils.translation import get_language, gettext, gettext_noop, override
from django.utils.translation import gettext_lazy as _
import aiohttp
import requests
from asgiref.sync import async_to_sync
from celery.exceptions import OperationalError as CeleryOperationalError
from livekit import api as livekit_api
from core import models, utils
from core.analytics import UserFeatureFlag, is_user_feature_flag_enabled
from core.tasks.mail import send_email
from core.utils import generate_download_s3_url
logger = logging.getLogger(__name__)
@@ -68,10 +68,14 @@ class NotificationService:
@staticmethod
def _notify_user_by_email(recording) -> bool:
"""
Send an email notification to recording owners when their recording is ready.
Queue an email notification to recording owners when their recording is ready.
The email includes a direct link that redirects owners to a dedicated download
page in the frontend where they can access their specific recording.
Rendering and SMTP delivery happen in a Celery worker (``send_email``), so
this only does one DB query and enqueues one message per owner.
Returns True when every email was queued (or sent, if Celery is disabled).
"""
owner_accesses = (
@@ -88,10 +92,6 @@ class NotificationService:
return False
context = {
"brandname": settings.EMAIL_BRAND_NAME,
"support_email": settings.EMAIL_SUPPORT_EMAIL,
"logo_img": settings.EMAIL_LOGO_IMG,
"domain": settings.EMAIL_DOMAIN,
"room_name": recording.room.name,
"recording_expiration_days": settings.RECORDING_EXPIRATION_DAYS,
"link": f"{get_recording_download_base_url()}/{recording.id}",
@@ -104,37 +104,22 @@ class NotificationService:
# 2. The number of recipients per recording is typically small (not thousands)
for access in owner_accesses:
user = access.user
language = user.language or get_language()
with override(language):
personalized_context = {
"recording_date": recording.created_at.astimezone(
user.timezone
).strftime("%Y-%m-%d"),
"recording_time": recording.created_at.astimezone(
user.timezone
).strftime("%H:%M"),
**context,
}
msg_html = render_to_string(
"mail/html/screen_recording.html", personalized_context
local_created_at = recording.created_at.astimezone(user.timezone)
try:
send_email.delay(
template="screen_recording",
subject=gettext_noop("Your recording is ready"),
recipients=[user.email],
language=user.language or get_language(),
context={
**context,
"recording_date": local_created_at.strftime("%Y-%m-%d"),
"recording_time": local_created_at.strftime("%H:%M"),
},
)
msg_plain = render_to_string(
"mail/text/screen_recording.txt", personalized_context
)
subject = gettext("Your recording is ready") # Force translation
try:
send_mail(
subject.capitalize(),
msg_plain,
settings.EMAIL_FROM,
[user.email],
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("notification could not be sent: %s", exception)
has_failures = True
except (smtplib.SMTPException, CeleryOperationalError) as exception:
logger.error("notification could not be sent: %s", exception)
has_failures = True
return not has_failures
+33 -10
View File
@@ -271,20 +271,24 @@ class LiveKitEventsService:
)
raise ActionFailedError("Failed to process room started event") from e
room_updated_count = models.Room.objects.filter(pk=room_id).update(
try:
room = models.Room.all_objects.get(id=room_id)
except models.Room.DoesNotExist as err:
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
# The block below is intended to fix the issue where long-lived livekit
# tokens allow users who already entered a room to re-create it,
# even if it was closed.
if room.is_deleted:
self._close_deleted_room(room_id)
return
# Update through the queryset to skip the full_clean run by save()
models.Room.all_objects.filter(pk=room.pk).update(
last_started_at=timezone.now()
)
if not room_updated_count:
raise ActionFailedError(f"Room with ID {room_id} does not exist")
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
try:
room = models.Room.objects.get(pk=room_id)
except models.Room.DoesNotExist as err:
raise ActionFailedError(
f"Room with ID {room_id} does not exist"
) from err
try:
self.sip_management.ensure_dispatch_rule(room)
except SIPException as e:
@@ -292,6 +296,25 @@ class LiveKitEventsService:
f"Failed to create sip dispatch rule for room {room_id}"
) from e
@staticmethod
def _close_deleted_room(room_id):
"""Close a LiveKit room recreated after its room was soft deleted.
LiveKit auto-creates a room on join, so a participant still holding a
valid token can bring a deleted room back to life until the token expires.
"""
logger.warning(
"LiveKit room %s started for a deleted room, closing it", room_id
)
try:
RoomManagement.delete_room(str(room_id))
except RoomNotFoundException:
logger.info("LiveKit room %s is already closed", room_id)
except RoomManagementException as e:
raise ActionFailedError(f"Failed to close deleted room {room_id}") from e
def _handle_room_finished(self, data):
"""Handle 'room_finished' event."""
@@ -2,10 +2,13 @@
# pylint: disable=no-name-in-module
import contextlib
import json
from logging import getLogger
from typing import Dict, Optional
from django.conf import settings
from asgiref.sync import async_to_sync
from livekit.api import (
DeleteRoomRequest,
@@ -15,6 +18,9 @@ from livekit.api import (
)
from core import utils
from core.models import Room
from .sip_management import SIPException, SIPManagement
logger = getLogger(__name__)
@@ -115,6 +121,54 @@ class RoomManagement:
finally:
await lkapi.aclose()
@classmethod
def soft_delete(cls, room: Room):
"""Soft delete a room, remove its SIP routing and close its LiveKit room."""
room.soft_delete()
rule_deleted = False
try:
rule_deleted = cls._delete_dispatch_rule(room)
with contextlib.suppress(RoomNotFoundException):
cls.delete_room(str(room.id))
except Exception:
Room.all_objects.filter(pk=room.pk, deleted_at=room.deleted_at).update(
deleted_at=None
)
room.deleted_at = None
if rule_deleted:
cls._restore_dispatch_rule(room)
raise
@staticmethod
def _delete_dispatch_rule(room: Room):
"""Delete a room's SIP dispatch rule, so its PIN no longer routes calls.
This cannot be left to the room_finished webhook: a roomkit join creates
the rule before any LiveKit room exists, so no room may ever finish.
Returns whether a rule was deleted
"""
if not (settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED):
return False
try:
return SIPManagement().delete_dispatch_rule(room.id)
except SIPException as e:
raise RoomManagementException("Could not delete dispatch rule") from e
@staticmethod
def _restore_dispatch_rule(room: Room):
"""Recreate a room's SIP dispatch rule after a failed soft delete."""
try:
SIPManagement().ensure_dispatch_rule(room)
except Exception: # pylint: disable=broad-exception-caught
logger.exception("Could not restore dispatch rule for room %s", room.id)
@classmethod
def sync_room_metadata(cls, room):
"""Push a room's configuration and access level to its LiveKit room metadata.
+15 -4
View File
@@ -124,7 +124,11 @@ class SIPManagement:
@async_to_sync
async def delete_dispatch_rule(self, room_id):
"""Delete all SIP inbound dispatch rules associated with a specific room."""
"""Delete all SIP inbound dispatch rules associated with a specific room.
A rule deleted meanwhile (e.g. by both a room deletion and its
room_finished webhook) is not an error.
"""
rules_ids = await self._list_dispatch_rules_ids(room_id)
@@ -138,9 +142,16 @@ class SIPManagement:
lkapi = utils.create_livekit_client()
try:
for rule_id in rules_ids:
await lkapi.sip.delete_sip_dispatch_rule(
delete=DeleteSIPDispatchRuleRequest(sip_dispatch_rule_id=rule_id)
)
try:
await lkapi.sip.delete_sip_dispatch_rule(
delete=DeleteSIPDispatchRuleRequest(
sip_dispatch_rule_id=rule_id
)
)
except TwirpError as e:
if e.code != TwirpErrorCode.NOT_FOUND:
raise
logger.info("Dispatch rule %s was already deleted", rule_id)
return True
+2
View File
@@ -2,8 +2,10 @@
from core.tasks.connection_test import delete_connection_test_room
from core.tasks.file import process_file_deletion
from core.tasks.mail import send_email
__all__ = (
"delete_connection_test_room",
"process_file_deletion",
"send_email",
)
+74
View File
@@ -0,0 +1,74 @@
"""Send mail using celery task."""
import logging
import smtplib
from django.conf import settings
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.translation import get_language, gettext, override
from core.tasks._task import task
logger = logging.getLogger(__name__)
def get_base_email_context() -> dict:
"""Context shared by every email template (branding, support, domain)."""
return {
"brandname": settings.EMAIL_BRAND_NAME,
"support_email": settings.EMAIL_SUPPORT_EMAIL,
"logo_img": settings.EMAIL_LOGO_IMG,
"domain": settings.EMAIL_DOMAIN,
}
@task(
autoretry_for=(smtplib.SMTPException, ConnectionError, TimeoutError),
retry_backoff=True,
retry_backoff_max=600,
retry_jitter=True,
max_retries=5,
)
def send_email(
*,
template: str,
subject: str,
recipients: list[str],
context: dict | None = None,
language: str | None = None,
):
"""Render a mail template and send it.
Generic on purpose: any feature can reuse it with its own template and context.
Args:
template: Template base name, e.g. "screen_recording". Both
"mail/html/<template>.html" and "mail/text/<template>.txt" must exist.
subject: Untranslated subject msgid. Mark it with ``gettext_noop`` at the
call site; it is translated here, in ``language``.
recipients: Email addresses, all receiving the same rendered message.
context: Template context. Must be JSON-serializable (str, int, list, dict…):
pre-format dates, cast UUIDs to str. Merged over the base branding context.
language: Language to render the email in. Defaults to the active language.
Raises:
OSError: SMTP or network failure, after retries are exhausted (or
immediately when Celery is disabled and the call runs synchronously).
"""
full_context = {**get_base_email_context(), **(context or {})}
with override(language or get_language()):
msg_html = render_to_string(f"mail/html/{template}.html", full_context)
msg_plain = render_to_string(f"mail/text/{template}.txt", full_context)
translated_subject = gettext(subject)
send_mail(
translated_subject,
msg_plain,
settings.EMAIL_FROM,
recipients,
html_message=msg_html,
fail_silently=False,
)
logger.info("Email '%s' sent to %d recipient(s)", template, len(recipients))
@@ -9,6 +9,7 @@ from django.core.management import call_command
from django.utils import timezone
import pytest
from rest_framework.test import APIClient
from core import factories, models
@@ -19,14 +20,25 @@ COMMAND_MODULE = "core.management.commands.purge_inactive_rooms"
BEFORE_PERIOD = timedelta(days=366)
WITHIN_PERIOD = timedelta(days=364)
BEFORE_RETENTION = timedelta(days=31)
WITHIN_RETENTION = timedelta(days=29)
@pytest.fixture(name="purge_enabled", autouse=True)
def fixture_purge_enabled(settings):
"""Enable the purge of the rooms inactive for a year."""
settings.ROOM_INACTIVITY_DELETION_DAYS = 365
settings.ROOM_DELETED_RETENTION_DAYS = None
settings.RECORDING_EXPIRATION_DAYS = 30
@pytest.fixture(name="retention_only")
def fixture_retention_only(settings):
"""Only purge the rooms soft deleted more than 30 days ago."""
settings.ROOM_INACTIVITY_DELETION_DAYS = None
settings.ROOM_DELETED_RETENTION_DAYS = 30
def create_at(date, factory, **kwargs):
"""Build an object with the factory as if it was created at the given date."""
with mock.patch("django.utils.timezone.now", return_value=date):
@@ -41,12 +53,12 @@ def call_purge(*args):
def room_exists(room):
"""Tell whether the room is still in database."""
return models.Room.objects.filter(pk=room.pk).exists()
"""Tell whether the room is still in database, soft deleted or not."""
return models.Room.all_objects.filter(pk=room.pk).exists()
def test_purge_inactive_rooms_disabled(settings):
"""Should delete nothing when no inactivity period is configured."""
"""Should delete nothing when neither period is configured."""
settings.ROOM_INACTIVITY_DELETION_DAYS = None
room = create_at(timezone.now() - BEFORE_PERIOD, factories.RoomFactory)
@@ -68,7 +80,7 @@ def test_purge_inactive_rooms_without_recording_expiration(settings):
status=models.RecordingStatusChoices.SAVED,
)
assert call_purge() == "Purged 1 inactive room(s).\n"
assert call_purge() == "Purged 1 room(s).\n"
assert not room_exists(room)
assert room_exists(room_with_recording)
@@ -97,7 +109,7 @@ def test_purge_inactive_rooms_started_before_period(caplog):
with caplog.at_level(logging.INFO, logger=COMMAND_MODULE):
output = call_purge()
assert output == "Purged 1 inactive room(s).\n"
assert output == "Purged 1 room(s).\n"
assert not room_exists(room)
assert f"Purging inactive room {room.pk} ({room.slug})" in caplog.text
@@ -120,7 +132,7 @@ def test_purge_inactive_rooms_started_within_period():
last_started_at=now - WITHIN_PERIOD,
)
assert call_purge() == "No inactive room to purge.\n"
assert call_purge() == "No room to purge.\n"
assert room_exists(room)
@@ -129,7 +141,7 @@ def test_purge_inactive_rooms_never_started_created_within_period():
"""Should keep a room that was never started but created within the period."""
room = create_at(timezone.now() - WITHIN_PERIOD, factories.RoomFactory)
assert call_purge() == "No inactive room to purge.\n"
assert call_purge() == "No room to purge.\n"
assert room_exists(room)
@@ -221,7 +233,7 @@ def test_purge_inactive_rooms_dry_run():
factories.RoomFactory(name="Recent room")
assert call_purge("--dry-run") == (
"[dry-run] 2 inactive room(s) would be purged:\n- Alpha room\n- Beta room\n"
"[dry-run] 2 room(s) would be purged:\n- Alpha room\n- Beta room\n"
)
assert all(room_exists(room) for room in rooms)
@@ -235,5 +247,134 @@ def test_purge_inactive_rooms_several_chunks():
with mock.patch(f"{COMMAND_MODULE}.CHUNK_SIZE", 2):
output = call_purge()
assert output == "Purged 5 inactive room(s).\n"
assert output == "Purged 5 room(s).\n"
assert not any(room_exists(room) for room in rooms)
def test_purge_inactive_rooms_soft_deleted(caplog):
"""Should delete an inactive room even if it was soft deleted recently."""
now = timezone.now()
room = create_at(
now - BEFORE_PERIOD,
factories.RoomFactory,
deleted_at=now - timedelta(days=1),
)
with caplog.at_level(logging.INFO, logger=COMMAND_MODULE):
output = call_purge()
assert output == "Purged 1 room(s).\n"
assert not room_exists(room)
assert f"Purging deleted room {room.pk} ({room.slug})" in caplog.text
@pytest.mark.usefixtures("retention_only")
def test_purge_deleted_rooms_before_retention(caplog):
"""Should delete a room soft deleted before the retention period."""
room = factories.RoomFactory(deleted_at=timezone.now() - BEFORE_RETENTION)
with caplog.at_level(logging.INFO, logger=COMMAND_MODULE):
output = call_purge()
assert output == "Purged 1 room(s).\n"
assert not room_exists(room)
assert f"Purging deleted room {room.pk} ({room.slug})" in caplog.text
@pytest.mark.usefixtures("retention_only")
def test_purge_deleted_rooms_within_retention():
"""Should keep a room soft deleted within the retention period."""
room = factories.RoomFactory(deleted_at=timezone.now() - WITHIN_RETENTION)
assert call_purge() == "No room to purge.\n"
assert room_exists(room)
@pytest.mark.usefixtures("retention_only")
def test_purge_deleted_rooms_retention_only_keeps_inactive_rooms():
"""Should keep inactive rooms that are not deleted when only retention is set."""
room = create_at(timezone.now() - BEFORE_PERIOD, factories.RoomFactory)
assert call_purge() == "No room to purge.\n"
assert room_exists(room)
def test_purge_deleted_rooms_within_retention_but_inactive(settings):
"""Should delete an inactive room even if it was deleted within the retention."""
settings.ROOM_DELETED_RETENTION_DAYS = 30
now = timezone.now()
room = create_at(
now - BEFORE_PERIOD,
factories.RoomFactory,
deleted_at=now - WITHIN_RETENTION,
)
call_purge()
assert not room_exists(room)
@pytest.mark.usefixtures("retention_only")
def test_purge_deleted_rooms_recording_not_expired():
"""Should keep a deleted room holding a saved recording that has not expired."""
room = factories.RoomFactory(deleted_at=timezone.now() - BEFORE_RETENTION)
factories.RecordingFactory(room=room, status=models.RecordingStatusChoices.SAVED)
assert call_purge() == "No room to purge.\n"
assert room_exists(room)
@pytest.mark.usefixtures("retention_only")
def test_purge_deleted_rooms_recording_expired():
"""Should delete a deleted room along with its expired recordings."""
now = timezone.now()
room = create_at(
now - timedelta(days=40),
factories.RoomFactory,
deleted_at=now - BEFORE_RETENTION,
)
recording = create_at(
now - timedelta(days=40),
factories.RecordingFactory,
room=room,
status=models.RecordingStatusChoices.SAVED,
)
call_purge()
assert not room_exists(room)
assert not models.Recording.objects.filter(pk=recording.pk).exists()
def test_purge_rooms_dry_run_flags_deleted_rooms(settings):
"""Should flag the soft-deleted rooms listed on a dry run."""
settings.ROOM_DELETED_RETENTION_DAYS = 30
now = timezone.now()
rooms = [
create_at(now - BEFORE_PERIOD, factories.RoomFactory, name="Alpha room"),
factories.RoomFactory(name="Beta room", deleted_at=now - BEFORE_RETENTION),
]
factories.RoomFactory(name="Gamma room", deleted_at=now - WITHIN_RETENTION)
assert call_purge("--dry-run") == (
"[dry-run] 2 room(s) would be purged:\n- Alpha room\n- Beta room (deleted)\n"
)
assert all(room_exists(room) for room in rooms)
@pytest.mark.usefixtures("retention_only")
def test_purge_deleted_rooms_frees_slug():
"""Should let a new room take the slug of a purged deleted room."""
factories.RoomFactory(name="my room", deleted_at=timezone.now() - BEFORE_RETENTION)
client = APIClient()
client.force_login(factories.UserFactory())
call_purge()
response = client.post("/api/v1.0/rooms/", {"name": "My Room!"})
assert response.status_code == 201
assert models.Room.objects.get().slug == "my-room"
@@ -12,6 +12,7 @@ from unittest import mock
from django.contrib.sites.models import Site
import pytest
from celery.exceptions import OperationalError as CeleryOperationalError
from core import factories, models
from core.analytics import UserFeatureFlag
@@ -142,7 +143,7 @@ def test_notify_user_by_email_success(mocked_current_site, settings):
notification_service = NotificationService()
with mock.patch("core.recording.event.notification.send_mail") as mock_send_mail:
with mock.patch("core.tasks.mail.send_mail") as mock_send_mail:
result = notification_service._notify_user_by_email(recording)
assert result is True
@@ -237,7 +238,7 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
notification_service = NotificationService()
with mock.patch(
"core.recording.event.notification.send_mail",
"core.tasks.mail.send_mail",
side_effect=smtplib.SMTPException("SMTP Error"),
) as mock_send_mail:
result = notification_service._notify_user_by_email(recording)
@@ -247,6 +248,60 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
assert "notification could not be sent:" in caplog.text
def test_notify_user_by_email_queues_generic_task(settings):
"""One generic send_email task is queued per owner, with serializable args."""
settings.RECORDING_DOWNLOAD_BASE_URL = "https://acme.com/recordings"
settings.RECORDING_EXPIRATION_DAYS = 7
recording = factories.RecordingFactory(room__name="Conference Room A")
recording.created_at = datetime.datetime(
2023, 5, 15, 14, 30, 0, tzinfo=datetime.timezone.utc
)
user = factories.UserFactory(
email="franc@test.com", language="fr-fr", timezone="Europe/Paris"
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER, user=user
)
with mock.patch("core.recording.event.notification.send_email.delay") as mock_delay:
result = notification_service._notify_user_by_email(recording)
assert result is True
mock_delay.assert_called_once_with(
template="screen_recording",
subject="Your recording is ready",
recipients=["franc@test.com"],
language="fr-fr",
context={
"room_name": "Conference Room A",
"recording_expiration_days": 7,
"link": f"https://acme.com/recordings/{recording.id}",
"recording_date": "2023-05-15",
"recording_time": "16:30",
},
)
# Celery's JSON serializer must accept every argument
json.dumps(mock_delay.call_args.kwargs)
def test_notify_user_by_email_broker_unavailable(caplog):
"""A broker outage is reported as a failure without crashing the caller."""
recording = factories.RecordingFactory()
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER
)
with mock.patch(
"core.recording.event.notification.send_email.delay",
side_effect=CeleryOperationalError("broker down"),
):
result = notification_service._notify_user_by_email(recording)
assert result is False
assert "notification could not be sent: broker down" in caplog.text
@mock.patch("core.recording.event.notification.requests.post")
@mock.patch("core.recording.event.notification.generate_download_s3_url")
@mock.patch.object(
@@ -117,6 +117,29 @@ def test_api_rooms_create_authenticated_existing_slug():
assert response.json() == {"slug": ["Room with this Slug already exists."]}
def test_api_rooms_create_authenticated_slug_held_by_soft_deleted_room():
"""
A deleted room keeps its slug: creating a room with the same name should
fail validation rather than hit the database constraint.
"""
RoomFactory(name="my room").soft_delete()
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/rooms/",
{
"name": "My Room!",
},
)
assert response.status_code == 400
assert response.json() == {"slug": ["Room with this Slug already exists."]}
assert Room.all_objects.count() == 1
def test_api_rooms_create_authenticated_user_default_access_level():
"""
The user's default room access level should be applied to the new room
@@ -2,11 +2,16 @@
Test rooms API endpoints in the Meet core app: delete.
"""
from unittest import mock
import pytest
from rest_framework.test import APIClient
from ...analytics import AnalyticsEvent
from ...factories import RoomFactory, UserFactory
from ...models import Room
from ...models import Room, RoomAccessLevel
from ...services.room_management import RoomManagement, RoomNotFoundException
from ...services.sip_management import SIPManagement
pytestmark = pytest.mark.django_db
@@ -83,12 +88,52 @@ def test_api_rooms_delete_administrators():
assert Room.objects.count() == 1
def test_api_rooms_delete_owners():
@mock.patch("core.api.viewsets.analytics.capture")
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
@mock.patch.object(RoomManagement, "delete_room")
def test_api_rooms_delete_owners(mock_delete_room, _, mock_capture):
"""
Authenticated users should be able to delete a room for which they are directly
owner.
owner. The room is soft deleted, its LiveKit room is closed and a ROOM_DELETED
analytics event is emitted.
"""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")], access_level=RoomAccessLevel.TRUSTED)
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/rooms/{room.id}/",
)
assert response.status_code == 204
mock_delete_room.assert_called_once_with(str(room.id))
assert Room.objects.exists() is False
assert Room.all_objects.get(id=room.id).deleted_at is not None
mock_capture.assert_called_once_with(
user,
AnalyticsEvent.ROOM_DELETED,
{"room_id": str(room.pk), "access_level": RoomAccessLevel.TRUSTED},
)
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
@mock.patch.object(
RoomManagement,
"delete_room",
side_effect=RoomNotFoundException("Room does not exist"),
)
def test_api_rooms_delete_owners_room_not_live(
mock_delete_room, mock_delete_dispatch_rule, settings
):
"""
Deleting a room that is not live in LiveKit should still soft delete it, and
delete its SIP dispatch rule, as no room_finished webhook will.
"""
settings.ROOM_TELEPHONY_ENABLED = True
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
client = APIClient()
@@ -99,4 +144,47 @@ def test_api_rooms_delete_owners():
)
assert response.status_code == 204
mock_delete_room.assert_called_once_with(str(room.id))
mock_delete_dispatch_rule.assert_called_once_with(room.id)
assert Room.objects.exists() is False
assert Room.all_objects.get(id=room.id).deleted_at is not None
@mock.patch.object(RoomManagement, "delete_room")
def test_api_rooms_delete_soft_deleted(mock_delete_room):
"""Deleting a room that is already soft deleted should return a 410."""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
room.soft_delete()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/rooms/{room.id}/",
)
assert response.status_code == 410
assert response.json() == {"detail": "This room has been deleted."}
mock_delete_room.assert_not_called()
@mock.patch.object(RoomManagement, "delete_room")
def test_api_rooms_delete_soft_deleted_not_owner(mock_delete_room):
"""
Deleting a soft-deleted room as a non-owner should return a 403,
not revealing that the room has been deleted.
"""
user = UserFactory()
room = RoomFactory(users=[(user, "administrator")])
room.soft_delete()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/rooms/{room.id}/",
)
assert response.status_code == 403
mock_delete_room.assert_not_called()
@@ -2,7 +2,8 @@
Test rooms API endpoints in the Meet core app: participants management.
"""
# pylint: disable=redefined-outer-name,unused-argument,protected-access,no-name-in-module,too-many-lines
# pylint: disable=redefined-outer-name,unused-argument,protected-access
# pylint: disable=no-name-in-module,too-many-lines
import random
from unittest import mock
@@ -193,6 +193,28 @@ def test_api_rooms_retrieve_anonymous_unregistered_not_allowed():
assert response.json() == {"detail": "No Room matches the given query."}
@pytest.mark.parametrize("allow_unregistered_rooms", [True, False])
@pytest.mark.parametrize("lookup", ["id", "slug"])
@mock.patch("core.utils.generate_token", return_value="foo")
def test_api_rooms_retrieve_soft_deleted(
mock_token, lookup, allow_unregistered_rooms, settings
):
"""
Retrieving a soft-deleted room should return a 410, and never fall back
to an unregistered room with the same slug.
"""
settings.ALLOW_UNREGISTERED_ROOMS = allow_unregistered_rooms
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
room.soft_delete()
client = APIClient()
response = client.get(f"/api/v1.0/rooms/{getattr(room, lookup)!s}/")
assert response.status_code == 410
assert response.json() == {"detail": "This room has been deleted."}
mock_token.assert_not_called()
@mock.patch("core.utils.generate_token", return_value="foo")
@override_settings(
LIVEKIT_CONFIGURATION={
@@ -11,8 +11,9 @@ from django.utils import timezone
import pytest
from rest_framework.test import APIClient
from ...api.serializers import RoomSerializer
from ...factories import RoomFactory, UserFactory
from ...models import RoomAccessLevel
from ...models import Room, RoomAccessLevel
from ...services.room_management import (
RoomManagement,
RoomManagementException,
@@ -468,3 +469,40 @@ def test_api_rooms_update_authenticated_with_user_access_token(privileged_role):
assert response.status_code == 200
room.refresh_from_db()
assert room.name == "new name"
@patch.object(RoomManagement, "sync_room_metadata")
def test_api_rooms_update_soft_deleted_after_load(mock_sync_room_metadata):
"""A room soft deleted while an update is in flight should not be restored."""
user = UserFactory()
room = RoomFactory(
name="Old name",
access_level=RoomAccessLevel.RESTRICTED,
users=[(user, "owner")],
)
original_is_valid = RoomSerializer.is_valid
def soft_delete_while_validating(self, **kwargs):
is_valid = original_is_valid(self, **kwargs)
Room.all_objects.get(pk=self.instance.pk).soft_delete()
return is_valid
client = APIClient()
client.force_login(user)
with patch.object(RoomSerializer, "is_valid", soft_delete_while_validating):
response = client.patch(
f"/api/v1.0/rooms/{room.id!s}/",
{"name": "New name", "access_level": RoomAccessLevel.PUBLIC},
)
assert response.status_code == 410
assert response.json() == {"detail": "This room has been deleted."}
refreshed_room = Room.all_objects.get(pk=room.pk)
assert refreshed_room.deleted_at is not None
assert refreshed_room.name == "Old name"
assert refreshed_room.access_level == RoomAccessLevel.RESTRICTED
mock_sync_room_metadata.assert_not_called()
@@ -1,7 +1,7 @@
"""
Test LiveKitEvents service.
"""
# pylint: disable=W0621,W0613, W0212, E0611
# pylint: disable=W0621,W0613, W0212, E0611, C0302
import logging
import uuid
@@ -27,7 +27,10 @@ from core.services.livekit_events import (
to_recording_event,
)
from core.services.lobby import LobbyService
from core.services.room_management import RoomManagementException
from core.services.room_management import (
RoomManagementException,
RoomNotFoundException,
)
from core.services.sip_management import (
SIPException,
SIPManagement,
@@ -796,6 +799,61 @@ def test_handle_room_started_raises_error_for_nonexistent_room(service):
service._handle_room_started(mock_data)
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
@mock.patch("core.services.room_management.RoomManagement.delete_room")
def test_handle_room_started_closes_deleted_room(
mock_delete_room, mock_ensure_dispatch_rule, service, settings
):
"""Should close a LiveKit room recreated for a soft-deleted room."""
settings.ROOM_TELEPHONY_ENABLED = True
room = RoomFactory()
room.soft_delete()
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
service._handle_room_started(mock_data)
mock_delete_room.assert_called_once_with(str(room.id))
mock_ensure_dispatch_rule.assert_not_called()
@mock.patch(
"core.services.room_management.RoomManagement.delete_room",
side_effect=RoomNotFoundException("Room does not exist"),
)
def test_handle_room_started_ignores_already_closed_deleted_room(
mock_delete_room, service
):
"""Should proceed silently when the deleted room is already closed in LiveKit."""
room = RoomFactory()
room.soft_delete()
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
service._handle_room_started(mock_data)
mock_delete_room.assert_called_once_with(str(room.id))
@mock.patch(
"core.services.room_management.RoomManagement.delete_room",
side_effect=RoomManagementException("Could not delete room"),
)
def test_handle_room_started_raises_error_when_closing_deleted_room_fails(
mock_delete_room, service
):
"""Should raise ActionFailedError when the deleted room cannot be closed."""
room = RoomFactory()
room.soft_delete()
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
expected_error = f"Failed to close deleted room {room.id}"
with pytest.raises(ActionFailedError, match=expected_error):
service._handle_room_started(mock_data)
@mock.patch.object(
api.WebhookReceiver, "receive", side_effect=Exception("Invalid payload")
)
@@ -2,16 +2,19 @@
from unittest import mock
from django.db import connection
import pytest
from livekit.api import TwirpError
from core.factories import RoomFactory
from core.models import RoomAccessLevel
from core.models import Room, RoomAccessLevel
from core.services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
from core.services.sip_management import SIPException, SIPManagement
@mock.patch("core.services.room_management.utils.create_livekit_client")
@@ -62,6 +65,221 @@ def test_delete_room_raises_management_exception(mock_create_livekit_client):
mock_api.aclose.assert_awaited_once()
@pytest.mark.django_db(transaction=True)
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
@mock.patch.object(RoomManagement, "delete_room")
def test_soft_delete_commits_before_closing_livekit_room(mock_delete_room, _):
"""The deletion is committed before the LiveKit room is closed, so the
room_started webhook of a participant reconnecting right away sees it."""
room = RoomFactory()
def assert_deletion_committed(room_name):
assert connection.in_atomic_block is False
assert Room.all_objects.get(id=room_name).is_deleted
mock_delete_room.side_effect = assert_deletion_committed
RoomManagement.soft_delete(room)
mock_delete_room.assert_called_once_with(str(room.id))
@pytest.mark.django_db
@pytest.mark.parametrize(
"error",
[
RoomManagementException("Could not delete room"),
ConnectionError("LiveKit is unreachable"),
],
)
@mock.patch.object(SIPManagement, "delete_dispatch_rule", return_value=False)
@mock.patch.object(RoomManagement, "delete_room")
def test_soft_delete_failure_rolls_back_and_can_be_retried(mock_delete_room, _, error):
"""A failed soft delete leaves the room untouched, in database and in memory,
so it can be retried."""
room = RoomFactory()
mock_delete_room.side_effect = error
with pytest.raises(type(error)):
RoomManagement.soft_delete(room)
assert room.deleted_at is None
assert Room.objects.filter(id=room.id).exists()
mock_delete_room.side_effect = None
RoomManagement.soft_delete(room)
assert mock_delete_room.call_count == 2
assert room.deleted_at is not None
assert Room.all_objects.get(id=room.id).deleted_at is not None
assert Room.objects.filter(id=room.id).exists() is False
@pytest.mark.django_db
@pytest.mark.parametrize("sip_setting", ["ROOM_TELEPHONY_ENABLED", "ROOMKIT_ENABLED"])
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
@mock.patch.object(
RoomManagement,
"delete_room",
side_effect=RoomNotFoundException("Room does not exist"),
)
def test_soft_delete_room_not_live_deletes_dispatch_rule(
mock_delete_room, mock_delete_dispatch_rule, sip_setting, settings
):
"""A roomkit join creates the dispatch rule before any LiveKit room exists."""
settings.ROOM_TELEPHONY_ENABLED = False
settings.ROOMKIT_ENABLED = False
setattr(settings, sip_setting, True)
room = RoomFactory()
RoomManagement.soft_delete(room)
mock_delete_room.assert_called_once_with(str(room.id))
mock_delete_dispatch_rule.assert_called_once_with(room.id)
assert Room.all_objects.get(id=room.id).is_deleted
@pytest.mark.django_db
def test_soft_delete_live_room_deletes_dispatch_rule_before_closing_it(settings):
"""The dispatch rule is deleted before the LiveKit room is closed."""
settings.ROOM_TELEPHONY_ENABLED = True
room = RoomFactory()
calls = mock.Mock()
with (
mock.patch.object(RoomManagement, "delete_room", calls.delete_room),
mock.patch.object(
SIPManagement, "delete_dispatch_rule", calls.delete_dispatch_rule
),
):
RoomManagement.soft_delete(room)
assert calls.mock_calls == [
mock.call.delete_dispatch_rule(room.id),
mock.call.delete_room(str(room.id)),
]
@pytest.mark.django_db
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
@mock.patch.object(RoomManagement, "delete_room")
def test_soft_delete_sip_disabled_skips_dispatch_rule(
mock_delete_room, mock_delete_dispatch_rule, settings
):
"""Without telephony nor roomkit, no dispatch rule is looked up."""
settings.ROOM_TELEPHONY_ENABLED = False
settings.ROOMKIT_ENABLED = False
room = RoomFactory()
RoomManagement.soft_delete(room)
mock_delete_room.assert_called_once_with(str(room.id))
mock_delete_dispatch_rule.assert_not_called()
assert Room.all_objects.get(id=room.id).is_deleted
@pytest.mark.django_db
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
@mock.patch.object(
SIPManagement,
"delete_dispatch_rule",
side_effect=SIPException("Could not delete dispatch rules"),
)
@mock.patch.object(RoomManagement, "delete_room")
def test_soft_delete_dispatch_rule_failure_rolls_back(
mock_delete_room, mock_delete_dispatch_rule, mock_ensure_dispatch_rule, settings
):
"""A dispatch rule that can't be deleted fails the soft delete like a LiveKit
room that can't be closed: it is rolled back so it can be retried, and the
LiveKit room is left open."""
settings.ROOMKIT_ENABLED = True
room = RoomFactory()
with pytest.raises(RoomManagementException):
RoomManagement.soft_delete(room)
mock_delete_room.assert_not_called()
mock_delete_dispatch_rule.assert_called_once_with(room.id)
mock_ensure_dispatch_rule.assert_not_called()
assert room.deleted_at is None
assert Room.objects.filter(id=room.id).exists()
@pytest.mark.django_db
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
@mock.patch.object(SIPManagement, "delete_dispatch_rule", return_value=True)
@mock.patch.object(
RoomManagement,
"delete_room",
side_effect=RoomManagementException("Could not delete room"),
)
def test_soft_delete_room_close_failure_restores_dispatch_rule(
_, __, mock_ensure_dispatch_rule, settings
):
"""A LiveKit room that can't be closed rolls the deletion back, including
its dispatch rule, so phone callers can still join the room with its PIN."""
settings.ROOM_TELEPHONY_ENABLED = True
room = RoomFactory()
with pytest.raises(RoomManagementException, match="Could not delete room"):
RoomManagement.soft_delete(room)
mock_ensure_dispatch_rule.assert_called_once_with(room)
assert room.deleted_at is None
assert Room.objects.filter(id=room.id).exists()
@pytest.mark.django_db
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
@mock.patch.object(SIPManagement, "delete_dispatch_rule", return_value=False)
@mock.patch.object(
RoomManagement,
"delete_room",
side_effect=RoomManagementException("Could not delete room"),
)
def test_soft_delete_room_close_failure_without_rule_skips_restore(
_, __, mock_ensure_dispatch_rule, settings
):
"""A room that had no dispatch rule does not get one on rollback, as nothing
would ever delete it."""
settings.ROOMKIT_ENABLED = True
room = RoomFactory()
with pytest.raises(RoomManagementException):
RoomManagement.soft_delete(room)
mock_ensure_dispatch_rule.assert_not_called()
assert room.deleted_at is None
@pytest.mark.django_db
@mock.patch.object(
SIPManagement,
"ensure_dispatch_rule",
side_effect=SIPException("Could not create dispatch rule"),
)
@mock.patch.object(SIPManagement, "delete_dispatch_rule", return_value=True)
@mock.patch.object(
RoomManagement,
"delete_room",
side_effect=RoomManagementException("Could not delete room"),
)
def test_soft_delete_restore_failure_raises_original_error(
_, __, ___, settings, caplog
):
"""A dispatch rule that can't be restored is logged without hiding the error
that caused the rollback."""
settings.ROOM_TELEPHONY_ENABLED = True
room = RoomFactory()
with pytest.raises(RoomManagementException, match="Could not delete room"):
RoomManagement.soft_delete(room)
assert f"Could not restore dispatch rule for room {room.id}" in caplog.text
assert room.deleted_at is None
assert Room.objects.filter(id=room.id).exists()
@mock.patch.object(RoomManagement, "update_metadata")
def test_sync_room_metadata_pushes_configuration_and_access_level(mock_update_metadata):
"""The room's configuration and access level are forwarded to LiveKit."""
@@ -257,6 +257,31 @@ def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rule
mock_api.aclose.assert_called_once()
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_already_deleted(mock_client_factory, mock_list_rules):
"""A rule deleted between listing and deletion (e.g. by both a room deletion and
its room_finished webhook) should not fail, nor stop the other deletions."""
sip_management = SIPManagement()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1", "rule-2"]
mock_api = create_mock_livekit_client()
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock(
side_effect=[
TwirpError(msg="sip dispatch rule not found", code="not_found", status=404),
None,
]
)
mock_client_factory.return_value = mock_api
result = sip_management.delete_dispatch_rule(room.id)
assert result is True
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 2
mock_api.aclose.assert_called_once()
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rules):
+98
View File
@@ -0,0 +1,98 @@
"""Tests for the generic send_email task."""
# pylint: disable=unused-argument,redefined-outer-name
import smtplib
from unittest import mock
from django.core.mail import get_connection
from django.utils.translation import gettext_noop
import pytest
from core.tasks.mail import send_email
@pytest.fixture
def email_settings(settings):
"""Branding settings shared by every email."""
settings.EMAIL_BRAND_NAME = "ACME"
settings.EMAIL_SUPPORT_EMAIL = "support@acme.com"
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
settings.EMAIL_DOMAIN = "acme.com"
settings.EMAIL_FROM = "notifications@acme.com"
return settings
@mock.patch("core.tasks.mail.send_mail")
@mock.patch("core.tasks.mail.render_to_string", side_effect=["<p>html</p>", "text"])
def test_send_email_renders_template_and_sends(
mock_render, mock_send_mail, email_settings
):
"""Both template variants are rendered with base + caller context, then sent."""
send_email(
template="some_template",
subject="Any subject",
recipients=["a@test.com", "b@test.com"],
context={"foo": "bar", "brandname": "Overridden"},
)
expected_context = {
"brandname": "Overridden",
"support_email": "support@acme.com",
"logo_img": "https://acme.com/logo",
"domain": "acme.com",
"foo": "bar",
}
assert mock_render.call_args_list == [
mock.call("mail/html/some_template.html", expected_context),
mock.call("mail/text/some_template.txt", expected_context),
]
mock_send_mail.assert_called_once_with(
"Any subject",
"text",
"notifications@acme.com",
["a@test.com", "b@test.com"],
html_message="<p>html</p>",
fail_silently=False,
)
@mock.patch("core.tasks.mail.send_mail")
def test_send_email_translates_subject_in_requested_language(
mock_send_mail, email_settings
):
"""The subject msgid is translated in the worker, in the recipient language."""
send_email(
template="screen_recording",
subject=gettext_noop("Your recording is ready"),
recipients=["franc@test.com"],
context={"room_name": "Room", "link": "https://acme.com/r/1"},
language="fr-fr",
)
subject, body, *_ = mock_send_mail.call_args[0]
assert subject == "Votre enregistrement est prêt"
assert "Votre enregistrement est prêt !" in body
@mock.patch(
"core.tasks.mail.send_mail", side_effect=smtplib.SMTPException("SMTP Error")
)
@mock.patch("core.tasks.mail.render_to_string", return_value="content")
def test_send_email_raises_on_smtp_error(mock_render, mock_send_mail, email_settings):
"""SMTP errors propagate so Celery can retry the task."""
with pytest.raises(smtplib.SMTPException):
send_email(
template="some_template",
subject="Any subject",
recipients=["a@test.com"],
)
def test_smtp_backend_uses_email_timeout(settings):
"""A stalled SMTP server cannot hold the worker forever."""
settings.EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
settings.EMAIL_TIMEOUT = 12
assert get_connection().timeout == 12
@@ -2,6 +2,8 @@
Test resource accesses API endpoints in the Meet core app.
"""
# pylint: disable=too-many-lines
import random
from unittest import mock
from uuid import uuid4
@@ -956,3 +958,99 @@ def test_api_room_user_access_delete_owners_last_owner():
assert response.status_code == 403
assert ResourceAccess.objects.count() == 1
# Soft-deleted rooms
def test_api_room_user_accesses_create_soft_deleted_room():
"""
Owners of a soft-deleted room should not be allowed to add accesses to it.
"""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
room.soft_delete()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/resource-accesses/",
{
"user": str(UserFactory().id),
"resource": str(room.id),
"role": "member",
},
)
assert response.status_code == 410
assert response.json() == {"detail": "This room has been deleted."}
assert ResourceAccess.objects.count() == 1
def test_api_room_user_accesses_create_soft_deleted_room_not_administrator():
"""
Users without privileges on a soft-deleted room should get a 403,
not revealing that the room has been deleted.
"""
user = UserFactory()
room = RoomFactory(users=[(user, "member")])
room.soft_delete()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/resource-accesses/",
{
"user": str(UserFactory().id),
"resource": str(room.id),
"role": "member",
},
)
assert response.status_code == 403
assert ResourceAccess.objects.count() == 1
def test_api_room_user_accesses_update_soft_deleted_room():
"""
Owners of a soft-deleted room should not be allowed to update its accesses.
"""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
access = UserResourceAccessFactory(resource=room, role="member")
room.soft_delete()
client = APIClient()
client.force_login(user)
response = client.patch(
f"/api/v1.0/resource-accesses/{access.id!s}/",
{"role": "administrator"},
format="json",
)
assert response.status_code == 410
access.refresh_from_db()
assert access.role == "member"
def test_api_room_user_access_delete_soft_deleted_room():
"""
Owners of a soft-deleted room should not be allowed to remove its accesses.
"""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")])
access = UserResourceAccessFactory(resource=room, role="member")
room.soft_delete()
client = APIClient()
client.force_login(user)
response = client.delete(
f"/api/v1.0/resource-accesses/{access.id!s}/",
)
assert response.status_code == 410
assert ResourceAccess.objects.filter(id=access.id).exists() is True
@@ -17,6 +17,7 @@ from lasuite.oidc_resource_server.authentication import ResourceServerAuthentica
from rest_framework.test import APIClient
from core.analytics import AnalyticsEvent
from core.external_api.serializers import RoomSerializer
from core.factories import ApplicationFactory, RoomFactory, UserFactory
from core.models import (
Application,
@@ -26,7 +27,12 @@ from core.models import (
RoomAccessLevel,
User,
)
from core.services.room_management import RoomManagement
from core.services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
from core.services.sip_management import SIPManagement
pytestmark = pytest.mark.django_db
@@ -563,6 +569,53 @@ def test_api_rooms_retrieve_not_found():
assert "no room matches the given query." in str(response.data).lower()
def test_api_rooms_retrieve_invalid_id():
"""Retrieving a room with a malformed id should return a 404."""
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get("/external-api/v1.0/rooms/not-a-uuid/")
assert response.status_code == 404
def test_api_rooms_retrieve_soft_deleted():
"""Retrieving a soft-deleted room should return a 410."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
room.soft_delete()
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 410
assert response.json() == {"detail": "This room has been deleted."}
def test_api_rooms_retrieve_soft_deleted_not_member():
"""Retrieving a soft-deleted room without any role on it should return a 403,
not revealing that the room has been deleted."""
user = UserFactory()
room = RoomFactory()
room.soft_delete()
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 403
def test_api_rooms_create_requires_authentication():
"""Creating rooms without authentication should return 401."""
@@ -1392,6 +1445,253 @@ def test_api_rooms_update_tracks_analytics(mock_update_metadata, mock_capture):
mock_update_metadata.assert_called_once()
@mock.patch("core.external_api.viewsets.analytics.capture")
@mock.patch.object(RoomManagement, "sync_room_metadata")
def test_api_rooms_update_soft_deleted_after_load(
mock_sync_room_metadata, mock_capture
):
"""A room soft deleted while an update is in flight should not be restored."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)], access_level=RoomAccessLevel.TRUSTED
)
original_is_valid = RoomSerializer.is_valid
def soft_delete_while_validating(self, **kwargs):
is_valid = original_is_valid(self, **kwargs)
Room.all_objects.get(pk=self.instance.pk).soft_delete()
return is_valid
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
with mock.patch.object(RoomSerializer, "is_valid", soft_delete_while_validating):
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 410
refreshed_room = Room.all_objects.get(pk=room.pk)
assert refreshed_room.deleted_at is not None
assert refreshed_room.access_level == RoomAccessLevel.TRUSTED
mock_sync_room_metadata.assert_not_called()
mock_capture.assert_not_called()
@mock.patch.object(RoomManagement, "delete_room")
def test_api_rooms_delete_requires_authentication(mock_delete_room):
"""Deleting a room without authentication should return 401."""
room = RoomFactory(users=[(UserFactory(), RoleChoices.OWNER)])
client = APIClient()
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 401
assert Room.objects.filter(id=room.id).exists() is True
mock_delete_room.assert_not_called()
@mock.patch.object(RoomManagement, "delete_room")
def test_api_rooms_delete_requires_scope(mock_delete_room):
"""Deleting a room requires the ROOMS_DELETE scope."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
# Token without ROOMS_DELETE scope
token = generate_test_token(
user, [ApplicationScope.ROOMS_RETRIEVE, ApplicationScope.ROOMS_UPDATE]
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 403
assert (
"insufficient permissions. required scope: rooms:delete"
in str(response.data).lower()
)
assert Room.objects.filter(id=room.id).exists() is True
mock_delete_room.assert_not_called()
@pytest.mark.parametrize("role", [RoleChoices.ADMIN, RoleChoices.MEMBER, None])
@mock.patch.object(RoomManagement, "delete_room")
def test_api_rooms_delete_without_ownership(mock_delete_room, role):
"""Only owners should be able to delete a room, administrators included."""
user = UserFactory()
users = [(user, role)] if role else []
room = RoomFactory(users=users)
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 403
assert Room.objects.filter(id=room.id).exists() is True
mock_delete_room.assert_not_called()
@mock.patch.object(RoomManagement, "delete_room")
def test_api_rooms_delete_unknown_room(mock_delete_room):
"""Deleting a room that does not exist should return 404."""
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.delete(f"/external-api/v1.0/rooms/{uuid.uuid4()}/")
assert response.status_code == 404
mock_delete_room.assert_not_called()
@mock.patch("core.external_api.viewsets.analytics.capture")
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
@mock.patch.object(RoomManagement, "delete_room")
def test_api_rooms_delete_success(mock_delete_room, _, mock_capture):
"""Owners should be able to delete a room: it is soft deleted, its LiveKit
room is closed and a ROOM_DELETED analytics event is emitted."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)], access_level=RoomAccessLevel.TRUSTED
)
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
application = Application.objects.get()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 204
mock_delete_room.assert_called_once_with(str(room.id))
assert Room.objects.filter(id=room.id).exists() is False
assert Room.all_objects.get(id=room.id).deleted_at is not None
mock_capture.assert_called_once()
captured_user, event, properties = mock_capture.call_args[0]
assert captured_user == user
assert event == AnalyticsEvent.ROOM_DELETED
assert properties == {
"room_id": str(room.pk),
"access_level": RoomAccessLevel.TRUSTED,
"client_id": str(application.client_id),
"external_api": True,
"auth_method": "ApplicationJWTAuthentication",
"$set": {"email": user.email},
}
@mock.patch("core.external_api.viewsets.analytics.capture")
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
@mock.patch.object(
RoomManagement,
"delete_room",
side_effect=RoomManagementException("Could not delete room"),
)
def test_api_rooms_delete_livekit_failure(mock_delete_room, _, mock_capture):
"""When the LiveKit room can't be closed, the deletion should be rolled back
and no analytics event emitted."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 500
assert response.json() == {"detail": "Could not delete the room, please try again."}
mock_delete_room.assert_called_once_with(str(room.id))
assert Room.objects.get(id=room.id).deleted_at is None
mock_capture.assert_not_called()
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
@mock.patch.object(
RoomManagement,
"delete_room",
side_effect=RoomNotFoundException("Room does not exist"),
)
def test_api_rooms_delete_room_not_live(
mock_delete_room, mock_delete_dispatch_rule, settings
):
"""Deleting a room that is not live in LiveKit should still soft delete it,
and delete its SIP dispatch rule, as no room_finished webhook will."""
settings.ROOMKIT_ENABLED = True
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 204
mock_delete_room.assert_called_once_with(str(room.id))
mock_delete_dispatch_rule.assert_called_once_with(room.id)
assert Room.objects.filter(id=room.id).exists() is False
assert Room.all_objects.get(id=room.id).deleted_at is not None
@mock.patch.object(RoomManagement, "delete_room")
def test_api_rooms_delete_soft_deleted(mock_delete_room):
"""Deleting a room that is already soft deleted should return a 410."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
room.soft_delete()
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 410
mock_delete_room.assert_not_called()
@mock.patch.object(RoomManagement, "delete_room")
def test_api_rooms_delete_soft_deleted_not_owner(mock_delete_room):
"""Deleting a soft-deleted room as a non-owner should return a 403,
not revealing that the room has been deleted."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.ADMIN)])
room.soft_delete()
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
assert response.status_code == 403
mock_delete_room.assert_not_called()
def test_api_rooms_response_no_url(settings):
"""Response should not include url field when APPLICATION_BASE_URL is None."""
settings.APPLICATION_BASE_URL = None
@@ -189,6 +189,21 @@ def test_models_rooms_is_public_property():
assert private_room.is_public is False
def test_models_rooms_soft_delete_concurrent():
"""A deletion racing another one on a stale instance matches no row and fails,
so the winner's timestamp is never overwritten."""
room = RoomFactory()
stale_room = Room.all_objects.get(pk=room.pk)
room.soft_delete()
assert stale_room.deleted_at is None
with pytest.raises(RuntimeError, match="This room is already deleted."):
stale_room.soft_delete()
assert Room.all_objects.get(pk=room.pk).deleted_at == room.deleted_at
@mock.patch.object(Room, "generate_unique_pin_code")
def test_telephony_and_roomkit_disabled_skips_pin_generation(
mock_generate_unique_pin_code, settings
+2 -1
View File
@@ -333,7 +333,8 @@ def detect_mimetype(file_buffer: bytes, filename: str | None = None) -> str:
str: The detected MIME type
Notes:
Originally from https://github.com/suitenumerique/drive/blob/564822d31f071c6dfacd112ef4b7146c73077cd9/src/backend/core/api/utils.py#L166 # pylint:disable=line-too-long
Originally from
https://github.com/suitenumerique/drive/blob/564822d31f071c6dfacd112ef4b7146c73077cd9/src/backend/core/api/utils.py#L166
"""
# Initialize magic detector
mime_detector = magic.Magic(mime=True)
+17 -8
View File
@@ -508,6 +508,7 @@ class Base(Configuration):
EMAIL_PORT = values.PositiveIntegerValue(None)
EMAIL_USE_TLS = values.BooleanValue(False)
EMAIL_USE_SSL = values.BooleanValue(False)
EMAIL_TIMEOUT = values.PositiveIntegerValue(30)
EMAIL_FROM = values.Value("from@example.com")
EMAIL_BRAND_NAME = values.Value(None)
EMAIL_SUPPORT_EMAIL = values.Value(None)
@@ -777,6 +778,9 @@ class Base(Configuration):
ROOM_INACTIVITY_DELETION_DAYS = values.PositiveIntegerValue(
None, environ_name="ROOM_INACTIVITY_DELETION_DAYS", environ_prefix=None
)
ROOM_DELETED_RETENTION_DAYS = values.PositiveIntegerValue(
None, environ_name="ROOM_DELETED_RETENTION_DAYS", environ_prefix=None
)
# if provided, treat as suspicious (possible privilege escalation attempt).
PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS = values.ListValue(
["hidden", "recorder", "agent"],
@@ -1474,21 +1478,26 @@ class Base(Configuration):
stacklevel=2,
)
if cls.ROOM_INACTIVITY_DELETION_DAYS:
for setting_name, rooms, period in (
("ROOM_INACTIVITY_DELETION_DAYS", "Inactive rooms", "inactivity period"),
("ROOM_DELETED_RETENTION_DAYS", "Deleted rooms", "retention period"),
):
purge_days = getattr(cls, setting_name)
if not purge_days:
continue
if not cls.RECORDING_EXPIRATION_DAYS:
warnings.warn(
"ROOM_INACTIVITY_DELETION_DAYS is set but "
"RECORDING_EXPIRATION_DAYS is not. Recordings never expire, so "
"inactive rooms holding a saved recording will never be purged.",
f"{setting_name} is set but RECORDING_EXPIRATION_DAYS is not. "
f"Recordings never expire, so {rooms.lower()} holding a saved "
"recording will never be purged.",
UserWarning,
stacklevel=2,
)
elif cls.RECORDING_EXPIRATION_DAYS >= cls.ROOM_INACTIVITY_DELETION_DAYS:
elif cls.RECORDING_EXPIRATION_DAYS >= purge_days:
warnings.warn(
"RECORDING_EXPIRATION_DAYS is greater than or equal to "
"ROOM_INACTIVITY_DELETION_DAYS. Inactive rooms holding a saved "
"recording will be kept past the inactivity period, until their "
"recordings expire.",
f"{setting_name}. {rooms} holding a saved recording will be "
f"kept past the {period}, until their recordings expire.",
UserWarning,
stacklevel=2,
)
+1 -1
View File
@@ -82,7 +82,7 @@ dev = [
"ipython==9.16.1",
"pyfakefs==6.2.0",
"pylint-django==2.8.0",
"pylint<4.0.0",
"pylint==4.1.3",
"pytest-cov==7.1.0",
"pytest-django==4.14.0",
"pytest==9.1.1",
+7 -7
View File
@@ -136,11 +136,11 @@ wheels = [
[[package]]
name = "astroid"
version = "3.3.11"
version = "4.3.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/18/74/dfb75f9ccd592bbedb175d4a32fc643cf569d7c218508bfbd6ea7ef9c091/astroid-3.3.11.tar.gz", hash = "sha256:1e5a5011af2920c7c67a53f65d536d65bfa7116feeaf2354d8b94f29573bb0ce", size = 400439, upload-time = "2025-07-13T18:04:23.177Z" }
sdist = { url = "https://files.pythonhosted.org/packages/2d/87/5732fa68bf100a095cfcbd108f919220d995db99e1a7502b8119a869fd62/astroid-4.3.4.tar.gz", hash = "sha256:d515a105722b72098bbe82d430d65e635f742b6cbac3bdfaf8b7c188b87c5e39", size = 444627, upload-time = "2026-10-08T09:36:44.122Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/af/0f/3b8fdc946b4d9cc8cc1e8af42c4e409468c84441b933d037e101b3d72d86/astroid-3.3.11-py3-none-any.whl", hash = "sha256:54c760ae8322ece1abd213057c4b5bba7c49818853fc901ef09719a60dbf9dec", size = 275612, upload-time = "2025-07-13T18:04:21.07Z" },
{ url = "https://files.pythonhosted.org/packages/16/d4/f23c0ac6e6de33ba5686cb21c672c95e0c2d3d4c9351f16d3b5fed818652/astroid-4.3.4-py3-none-any.whl", hash = "sha256:2bcd0d02648a443a4b818c952c3550091989daefac3c12d3b83b2289482e0818", size = 288310, upload-time = "2026-10-08T09:36:42.284Z" },
]
[[package]]
@@ -1416,7 +1416,7 @@ dev = [
{ name = "ipdb", specifier = "==0.13.13" },
{ name = "ipython", specifier = "==9.16.1" },
{ name = "pyfakefs", specifier = "==6.2.0" },
{ name = "pylint", specifier = "<4.0.0" },
{ name = "pylint", specifier = "==4.1.3" },
{ name = "pylint-django", specifier = "==2.8.0" },
{ name = "pytest", specifier = "==9.1.1" },
{ name = "pytest-cov", specifier = "==7.1.0" },
@@ -2032,7 +2032,7 @@ wheels = [
[[package]]
name = "pylint"
version = "3.3.9"
version = "4.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "astroid" },
@@ -2043,9 +2043,9 @@ dependencies = [
{ name = "platformdirs" },
{ name = "tomlkit" },
]
sdist = { url = "https://files.pythonhosted.org/packages/04/9d/81c84a312d1fa8133b0db0c76148542a98349298a01747ab122f9314b04e/pylint-3.3.9.tar.gz", hash = "sha256:d312737d7b25ccf6b01cc4ac629b5dcd14a0fcf3ec392735ac70f137a9d5f83a", size = 1525946, upload-time = "2025-10-05T18:41:43.786Z" }
sdist = { url = "https://files.pythonhosted.org/packages/38/c9/0bde4152a05a7686b431ce216fe3f3698b0c0d94c20bfa256dd935b608f6/pylint-4.1.3.tar.gz", hash = "sha256:9928603068edfa0d1a3c167f174b099d4b97c3db75d32d0fcdd029770b4713a9", size = 1756180, upload-time = "2026-10-11T07:49:10.69Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1a/a7/69460c4a6af7575449e615144aa2205b89408dc2969b87bc3df2f262ad0b/pylint-3.3.9-py3-none-any.whl", hash = "sha256:01f9b0462c7730f94786c283f3e52a1fbdf0494bbe0971a78d7277ef46a751e7", size = 523465, upload-time = "2025-10-05T18:41:41.766Z" },
{ url = "https://files.pythonhosted.org/packages/3f/ba/62b18e2e157d05f976bd490b349b55d8850c2a454a5c313c91af9d2e0baf/pylint-4.1.3-py3-none-any.whl", hash = "sha256:a85357cae24f33ad8d86c8f3daaa92c600ae4012b54a57299cee76000e9364cf", size = 588959, upload-time = "2026-10-11T07:49:08.272Z" },
]
[[package]]
+1
View File
@@ -6,4 +6,5 @@ export const keys = {
waitingParticipants: 'waitingParticipants',
roomCreationCallback: 'roomCreationCallback',
files: 'files',
startRecording: 'startRecording',
}
+7
View File
@@ -36,6 +36,13 @@ export interface ApiConfig {
available_modes?: RecordingMode[]
expiration_days?: number
max_duration?: number
encoding?: {
is_custom_enabled: boolean
available_resolutions: string[]
available_profiles: string[]
default_resolution?: string | null
default_profile?: string | null
}
}
background_image: {
upload_is_enabled: boolean
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M8.59456 12.8115C8.53273 12.5511 8.5 12.2794 8.5 12C8.5 11.7207 8.53272 11.449 8.59454 11.1886L7.60291 10.6161L8.60291 8.88402L9.59523 9.45694C9.98834 9.08508 10.4676 8.80338 11 8.64494V7.5H13V8.64494C13.5324 8.80337 14.0116 9.08506 14.4047 9.4569L15.3971 8.88393L16.3972 10.616L15.4054 11.1885C15.4673 11.449 15.5 11.7207 15.5 12C15.5 12.2793 15.4673 12.551 15.4055 12.8114L16.3972 13.3839L15.3972 15.116L14.4048 14.543C14.0117 14.9149 13.5325 15.1966 13.0001 15.355V16.5H11.0001V15.3551C10.4677 15.1967 9.98844 14.915 9.59532 14.5431L8.60297 15.1161L7.60291 13.384L8.59456 12.8115ZM12 13.5C12.8284 13.5 13.5 12.8284 13.5 12C13.5 11.1716 12.8284 10.5 12 10.5C11.1716 10.5 10.5 11.1716 10.5 12C10.5 12.8284 11.1716 13.5 12 13.5ZM15 4H5V20H19V8H15V4ZM3 2.9918C3 2.44405 3.44749 2 3.9985 2H16L20.9997 7L21 20.9925C21 21.5489 20.5551 22 20.0066 22H3.9934C3.44476 22 3 21.5447 3 21.0082V2.9918Z"></path></svg>

After

Width:  |  Height:  |  Size: 994 B

@@ -177,31 +177,18 @@ export const MainNotificationToast = () => {
changedAttributes: Record<string, string>,
participant: Participant
) => {
if (!participant.isLocal) return
if ('room_role' in changedAttributes) {
toastQueue.add(
{
participant,
type: NotificationType.RoleChanged,
newRole: changedAttributes['room_role'],
},
{
timeout: NotificationDuration.ROLE_CHANGED,
}
)
}
if ('is_speaker' in changedAttributes) {
toastQueue.add(
{
participant,
type: NotificationType.SpeakerChanged,
isSpeaker: changedAttributes['is_speaker'] === 'true',
},
{
timeout: NotificationDuration.ROLE_CHANGED,
}
)
}
if (!participant.isLocal || !('room_role' in changedAttributes)) return
const newRole = changedAttributes['room_role']
toastQueue.add(
{
participant,
type: NotificationType.RoleChanged,
newRole: newRole,
},
{
timeout: NotificationDuration.ROLE_CHANGED,
}
)
}
room.on(RoomEvent.ParticipantAttributesChanged, handleAttributeChanged)
@@ -22,5 +22,4 @@ export enum NotificationType {
RecordingSaving = 'recordingSaving',
PermissionsRemoved = 'permissionsRemoved',
RoleChanged = 'roleChanged',
SpeakerChanged = 'speakerChanged',
}
@@ -15,7 +15,6 @@ import { ToastPermissionsRemoved } from './ToastPermissionsRemoved'
import { ToastRecordingRequest } from './ToastRecordingRequest'
import { ToastAutoMuteLargeRoom } from './ToastAutoMuteLargeRoom'
import { ToastRoleChanged } from '@/features/notifications/components/ToastRoleChanged'
import { ToastSpeakerChanged } from './ToastSpeakerChanged'
interface ToastRegionProps extends AriaToastRegionProps {
state: ToastState<ToastData>
@@ -79,9 +78,6 @@ const renderToast = (
case NotificationType.RoleChanged:
return <ToastRoleChanged key={toast.key} toast={toast} state={state} />
case NotificationType.SpeakerChanged:
return <ToastSpeakerChanged key={toast.key} toast={toast} state={state} />
default:
return <Toast key={toast.key} toast={toast} state={state} />
}
@@ -1,27 +0,0 @@
import { useToast } from 'react-aria'
import { useRef } from 'react'
import { type ToastProps } from './Toast'
import { HStack } from '@/styled-system/jsx'
import { useTranslation } from 'react-i18next'
import { StyledToastContainer } from './StyledToastContainer'
export function ToastSpeakerChanged({ state, ...props }: Readonly<ToastProps>) {
const { t } = useTranslation('notifications', { keyPrefix: 'speakerChanged' })
const ref = useRef(null)
const { toastProps, contentProps } = useToast(props, state, ref)
return (
<StyledToastContainer {...toastProps} ref={ref}>
<HStack
justify="center"
alignItems="center"
{...contentProps}
padding={14}
gap={0}
>
{t(props.toast.content.isSpeaker ? 'granted' : 'removed')}
</HStack>
</StyledToastContainer>
)
}
@@ -1,62 +0,0 @@
import type { Participant, Track } from 'livekit-client'
import { fetchApi } from '@/api/fetchApi'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { useDefaultPublishSources } from '@/features/rooms/livekit/hooks/useRoomPublishSources'
import { reportError } from '@/features/analytics/telemetry'
export const SPEAKER_ATTRIBUTE = 'is_speaker'
export const getParticipantIsSpeaker = (participant: Participant): boolean =>
participant.attributes?.[SPEAKER_ATTRIBUTE] === 'true'
export const useParticipantSpeaker = () => {
const data = useRoomData()
const defaultSources = useDefaultPublishSources()
const updateSpeaker = async (
participant: Participant,
isSpeaker: boolean,
sources: Track.Source[]
) => {
if (!data?.id) {
throw new Error('Room id is not available')
}
const attributes: Record<string, string> = {
[SPEAKER_ATTRIBUTE]: isSpeaker ? 'true' : '',
}
try {
return await fetchApi(`rooms/${data.id}/update-participant/`, {
method: 'POST',
body: JSON.stringify({
participant_identity: participant.identity,
attributes,
permission: {
can_subscribe: participant.permissions?.canSubscribe,
can_publish_data: participant.permissions?.canPublishData,
can_update_metadata: participant.permissions?.canUpdateMetadata,
can_subscribe_metrics: participant.permissions?.canSubscribeMetrics,
can_publish: sources.length > 0,
can_publish_sources: sources,
},
}),
})
} catch (error) {
reportError('permissions_api_failure', error, {
context: `Failed to update speaker status of ${participant.identity}`,
})
throw error
}
}
const makeSpeaker = (participant: Participant) =>
updateSpeaker(participant, true, defaultSources)
const removeSpeaker = (
participant: Participant,
roomSources: Track.Source[]
) => updateSpeaker(participant, false, roomSources)
return { makeSpeaker, removeSpeaker }
}
@@ -14,7 +14,6 @@ import { type LocalParticipant, type Participant, Track } from 'livekit-client'
import { isLocal } from '@/utils/livekit'
import {
useIsSpeaking,
useParticipantAttribute,
useTrackMutedIndicator,
} from '@livekit/components-react'
import Source = Track.Source
@@ -87,16 +86,6 @@ type ParticipantListItemProps = {
export const ParticipantRow = ({ participant }: ParticipantListItemProps) => {
const { t } = useTranslation('rooms')
const name = participant.name || participant.identity
const isSpeaker =
useParticipantAttribute('is_speaker', { participant }) === 'true'
const roleLabels = [
getParticipantIsRoomOwner(participant) && t('participants.host'),
getParticipantIsRoomAdmin(participant) && t('participants.cohost'),
getParticipantIsRoomMember(participant) && t('participants.member'),
isSpeaker && t('participants.speaker'),
].filter(Boolean)
return (
<HStack
role="listitem"
@@ -118,7 +107,12 @@ export const ParticipantRow = ({ participant }: ParticipantListItemProps) => {
displayedName={name}
isLocal={isLocal(participant)}
/>
<Text variant="xsNote">{roleLabels.join(' · ')}</Text>
<Text variant="xsNote">
{getParticipantIsRoomOwner(participant) && t('participants.host')}
{getParticipantIsRoomAdmin(participant) && t('participants.cohost')}
{getParticipantIsRoomMember(participant) &&
t('participants.member')}
</Text>
</VStack>
</HStack>
<HStack flexShrink={0}>
@@ -3,10 +3,8 @@ import type { Participant } from 'livekit-client'
import { PinMenuItem } from './items/PinMenuItem'
import { RemoveMenuItem } from './items/RemoveMenuItem'
import { PromoteMenuItem } from './items/PromoteMenuItem'
import { SpeakerMenuItem } from './items/SpeakerMenuItem'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { useParticipantAttributes } from '@livekit/components-react'
import { useHasLockedSource } from '@/features/rooms/livekit/hooks/useRoomPublishSources'
export const ParticipantMenu = ({
participant,
@@ -20,13 +18,8 @@ export const ParticipantMenu = ({
const isAdmin = attributes?.room_role === 'administrator'
const isOwner = attributes?.room_role === 'owner'
const isAuthenticated = attributes?.is_authenticated == 'true'
const isSpeaker = attributes?.is_speaker === 'true'
const hasLockedSource = useHasLockedSource()
const canManage = !participant.isLocal && isLocalUserAdminOrOwner && !isOwner
const canToggleSpeaker =
canManage && !isAdmin && (isSpeaker || hasLockedSource)
return (
<RACMenu
@@ -48,9 +41,6 @@ export const ParticipantMenu = ({
displayedName={participant.name}
/>
)}
{canToggleSpeaker && (
<SpeakerMenuItem participant={participant} isSpeaker={isSpeaker} />
)}
</RACMenu>
)
}
@@ -1,50 +0,0 @@
import React, { useCallback } from 'react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import type { Participant } from 'livekit-client'
import { RiMicOffLine, RiUserVoiceLine } from '@remixicon/react'
import { useParticipantSpeaker } from '@/features/participants/api/updateParticipantSpeaker'
import { useRoomPublishSources } from '@/features/rooms/livekit/hooks/useRoomPublishSources'
import { menuRecipe } from '@/primitives/menuRecipe'
import { HStack } from '@/styled-system/jsx'
type SpeakerMenuItemProps = {
participant: Participant
isSpeaker: boolean
}
export const SpeakerMenuItem = React.memo(
({ participant, isSpeaker }: SpeakerMenuItemProps) => {
const { t } = useTranslation('rooms', { keyPrefix: 'participantMenu' })
const { makeSpeaker, removeSpeaker } = useParticipantSpeaker()
const roomSources = useRoomPublishSources()
const label = isSpeaker ? 'removeSpeaker' : 'makeSpeaker'
const Icon = isSpeaker ? RiMicOffLine : RiUserVoiceLine
const toggleSpeaker = useCallback(
() =>
isSpeaker
? removeSpeaker(participant, roomSources)
: makeSpeaker(participant),
[isSpeaker, participant, roomSources, makeSpeaker, removeSpeaker]
)
return (
<MenuItem
aria-label={t(`${label}.ariaLabel`, {
name: participant.name || participant.identity,
})}
className={menuRecipe({ icon: true }).item}
onAction={toggleSpeaker}
>
<HStack gap={0.25} minWidth={280}>
<Icon size={20} aria-hidden />
{t(`${label}.label`)}
</HStack>
</MenuItem>
)
}
)
SpeakerMenuItem.displayName = 'SpeakerMenuItem'
@@ -2,12 +2,22 @@ import { useMutation, UseMutationOptions } from '@tanstack/react-query'
import { fetchApi } from '@/api/fetchApi'
import type { ApiError } from '@/api/ApiError'
import type { ApiRoom } from '@/features/rooms/api/ApiRoom'
import { keys } from '@/api/queryKeys'
import { RecordingMode } from '../types'
import type { RecordingEncoding } from '../hooks/useRecordingEncoding'
export interface StartRecordingOptions {
language?: string
transcribe?: boolean
collect_metadata?: boolean
original_mode?: RecordingMode
encoding?: RecordingEncoding
}
export interface StartRecordingParams {
id: string
mode?: RecordingMode
options?: Record<string, string | boolean>
options?: StartRecordingOptions
}
const startRecording = ({
@@ -28,6 +38,7 @@ export function useStartRecording(
options?: UseMutationOptions<ApiRoom, ApiError, StartRecordingParams>
) {
return useMutation<ApiRoom, ApiError, StartRecordingParams>({
mutationKey: [keys.startRecording],
mutationFn: startRecording,
onSuccess: options?.onSuccess,
onError: options?.onError,
@@ -1,4 +1,4 @@
import { Div, H, Text } from '@/primitives'
import { Button, Div, H, Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { useRoomId } from '@/features/rooms/livekit/hooks/useRoomId'
@@ -22,8 +22,13 @@ import { ControlsButton } from './ControlsButton'
import { RowWrapper } from './RowWrapper'
import { VStack } from '@/styled-system/jsx'
import { Checkbox } from '@/primitives/Checkbox'
import { useTranscriptionLanguage } from '@/features/settings'
import {
SettingsDialogExtendedKey,
useTranscriptionLanguage,
} from '@/features/settings'
import { useMutateRecording } from '../hooks/useMutateRecording'
import { useRecordingEncoding } from '../hooks/useRecordingEncoding'
import { openSettingsDialog } from '@/stores/settings'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { FeatureFlags } from '@/features/analytics/enums'
@@ -53,6 +58,8 @@ export const ScreenRecordingSidePanel = () => {
const { selectedLanguageKey, isLanguageSetToAuto } =
useTranscriptionLanguage()
const { encoding, isCustomEncodingEnabled } = useRecordingEncoding()
const roomId = useRoomId()
const { startRecording, isPendingToStart, stopRecording, isPendingToStop } =
@@ -93,6 +100,7 @@ export const ScreenRecordingSidePanel = () => {
language: selectedLanguageKey,
}),
...(includeTranscript && hasTranscriptAccess && { transcribe: true }),
...(encoding && { encoding }),
}
await startRecording({
@@ -107,6 +115,8 @@ export const ScreenRecordingSidePanel = () => {
captureEvent('screen-recording-started', {
includeTranscript: includeTranscript,
language: selectedLanguageKey,
resolution: encoding?.resolution,
profile: encoding?.profile,
})
}
} catch (error) {
@@ -183,9 +193,41 @@ export const ScreenRecordingSidePanel = () => {
<RowWrapper iconName="cloud_download" position="first">
<Text variant="sm">{t('details.destination')}</Text>
</RowWrapper>
<RowWrapper iconName="mail" position="last">
<RowWrapper
iconName="mail"
position={isCustomEncodingEnabled ? 'middle' : 'last'}
>
<Text variant="sm">{t('details.receiver')}</Text>
</RowWrapper>
{isCustomEncodingEnabled && (
<RowWrapper iconName="file_settings_line" position="last">
<Text
variant="sm"
className={css({
minWidth: 0,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
})}
>
{t('details.quality')}
</Text>
<Text
variant="sm"
className={css({ flexShrink: 0, whiteSpace: 'nowrap' })}
>
<Button
variant="text"
size="xs"
onPress={() =>
openSettingsDialog(SettingsDialogExtendedKey.RECORDING)
}
>
{t('details.qualityEdit')}
</Button>
</Text>
</RowWrapper>
)}
{hasTranscriptAccess && (
<>
<div className={css({ height: '15px' })} />
@@ -29,6 +29,7 @@ import { NoAccessView } from './NoAccessView'
import { ControlsButton } from './ControlsButton'
import { RowWrapper } from './RowWrapper'
import { useMutateRecording } from '../hooks/useMutateRecording'
import { useRecordingEncoding } from '../hooks/useRecordingEncoding'
import { useIsMetadataCollectorEnabled } from '../hooks/useMetadataCollectorEnabled'
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
@@ -66,6 +67,8 @@ export const TranscriptSidePanel = () => {
const isMetadataCollectorEnabled = useIsMetadataCollectorEnabled()
const { encoding } = useRecordingEncoding()
const roomId = useRoomId()
const { startRecording, isPendingToStart, stopRecording, isPendingToStop } =
@@ -114,6 +117,7 @@ export const TranscriptSidePanel = () => {
...(withScreenRecording && {
transcribe: true,
original_mode: RecordingMode.Transcript,
...(encoding && { encoding }),
}),
collect_metadata: isMetadataCollectorEnabled,
}
@@ -0,0 +1,17 @@
import { useIsMutating } from '@tanstack/react-query'
import { keys } from '@/api/queryKeys'
import { RecordingMode } from '../types'
import { useRecordingStatuses } from './useRecordingStatuses'
/**
* True while a recording is being requested, starting, started or saving.
* Recording settings must not change during that window, as they were (or
* are about to be) sent to the backend for the current recording.
*/
export const useIsRecordingLocked = () => {
const screenRecording = useRecordingStatuses(RecordingMode.ScreenRecording)
const transcript = useRecordingStatuses(RecordingMode.Transcript)
const pendingStarts = useIsMutating({ mutationKey: [keys.startRecording] })
return pendingStarts > 0 || screenRecording.isActive || transcript.isActive
}
@@ -0,0 +1,121 @@
import { useCallback, useMemo } from 'react'
import { useSnapshot } from 'valtio'
import { useConfig } from '@/api/useConfig'
import {
recordingEncodingStore,
setRecordingEncodingPreference,
} from '@/stores/recordingEncoding'
export type RecordingEncoding = {
resolution?: string
profile?: string
}
export const DEFAULT_ENCODING_OPTION = '__default__'
/**
* Pick the stored value if the backend still offers it, otherwise the
* backend default, otherwise the "Default" option.
*/
const resolveValue = (
available: string[],
stored?: string,
fallback?: string | null
): string => {
if (stored && available.includes(stored)) return stored
if (fallback && available.includes(fallback)) return fallback
return DEFAULT_ENCODING_OPTION
}
/**
* Recording encoding preferences (resolution + profile).
*
* Values are persisted in localStorage, per browser, and always
* reconciled against what the backend currently exposes, so a stale value
* (e.g. a profile removed by the operator) never reaches the API.
*/
export const useRecordingEncoding = () => {
const { data } = useConfig()
const snap = useSnapshot(recordingEncodingStore)
const encodingConfig = data?.recording?.encoding
const availableResolutions = useMemo(
() => encodingConfig?.available_resolutions ?? [],
[encodingConfig]
)
const availableProfiles = useMemo(
() => encodingConfig?.available_profiles ?? [],
[encodingConfig]
)
const hasDefaultResolution = availableResolutions.includes(
encodingConfig?.default_resolution ?? ''
)
const hasDefaultProfile = availableProfiles.includes(
encodingConfig?.default_profile ?? ''
)
const isCustomEncodingEnabled = !!encodingConfig?.is_custom_enabled
const resolution = resolveValue(
availableResolutions,
snap.resolution,
encodingConfig?.default_resolution
)
const profile = resolveValue(
availableProfiles,
snap.profile,
encodingConfig?.default_profile
)
const setResolution = useCallback(
(value: string) => {
if (
value !== DEFAULT_ENCODING_OPTION &&
!availableResolutions.includes(value)
)
return
setRecordingEncodingPreference({ resolution: value })
},
[availableResolutions]
)
const setProfile = useCallback(
(value: string) => {
if (
value !== DEFAULT_ENCODING_OPTION &&
!availableProfiles.includes(value)
)
return
setRecordingEncodingPreference({ profile: value })
},
[availableProfiles]
)
// Payload for the start-recording API. Undefined when per-recording
// encoding is disabled, as the backend would reject it.
const encoding: RecordingEncoding | undefined = useMemo(() => {
// The API requires a resolution whenever the encoding is modified: a
// profile cannot be sent alone. So if the resolution is "Default", send
// no payload at all and let the backend apply its default encoding.
if (!isCustomEncodingEnabled || resolution === DEFAULT_ENCODING_OPTION)
return undefined
return {
resolution,
// Omitted when "Default": the backend applies its default profile.
...(profile !== DEFAULT_ENCODING_OPTION && { profile }),
}
}, [isCustomEncodingEnabled, resolution, profile])
return {
isCustomEncodingEnabled,
availableResolutions,
availableProfiles,
hasDefaultResolution,
hasDefaultProfile,
resolution,
profile,
setResolution,
setProfile,
encoding,
}
}
@@ -3,6 +3,12 @@ export { useIsRecordingModeEnabled } from './hooks/useIsRecordingModeEnabled'
export { useHasRecordingAccess } from './hooks/useHasRecordingAccess'
export { useHasFeatureWithoutAdminRights } from './hooks/useHasFeatureWithoutAdminRights'
export { useRecordingStatuses } from './hooks/useRecordingStatuses'
export {
useRecordingEncoding,
DEFAULT_ENCODING_OPTION,
} from './hooks/useRecordingEncoding'
export type { RecordingEncoding } from './hooks/useRecordingEncoding'
export { useIsRecordingLocked } from './hooks/useIsRecordingLocked'
// api
export { useStartRecording } from './api/startRecording'
@@ -13,6 +13,8 @@ export enum ApiLobbyStatus {
DENIED = 'denied',
TIMEOUT = 'timeout',
ACCEPTED = 'accepted',
// Client-side only: the room was deleted while waiting
DELETED = 'deleted',
}
export interface ApiRequestEntry {
@@ -45,6 +45,7 @@ import { userStore } from '@/stores/user'
import { WatchMediaDeviceErrors } from './WatchMediaDeviceErrors'
import { MeetDevtools } from '@/features/devtools'
import { VOICE_AUDIO_CONSTRAINTS } from '@/features/rooms/livekit/utils/constants'
import { resolveAudioOutputDeviceId } from '@/features/rooms/utils/resolveAudioOutputDeviceId'
export const Conference = ({
roomId,
@@ -85,6 +86,7 @@ export const Conference = ({
const {
status: fetchStatus,
isError: isFetchError,
error: fetchError,
data,
} = useQuery({
queryKey: fetchKey,
@@ -98,6 +100,8 @@ export const Conference = ({
if (error.statusCode == '404') {
createRoom({ slug: roomId, username })
}
// A deleted room can't be recreated, surface the error instead
if (error.statusCode == '410') throw error
}),
retry: false,
})
@@ -120,7 +124,7 @@ export const Conference = ({
...VOICE_AUDIO_CONSTRAINTS,
},
audioOutput: {
deviceId: userConfig.audioOutputDeviceId ?? undefined,
deviceId: resolveAudioOutputDeviceId(userConfig.audioOutputDeviceId),
},
}
// do not rely on the userConfig object directly as its reference may change on every render
@@ -198,6 +202,15 @@ export const Conference = ({
}, [apiConfig?.livekit])
const { t } = useTranslation('rooms')
if (fetchError?.statusCode == 410) {
return (
<ErrorScreen
title={t('error.deletedRoom.heading')}
body={t('error.deletedRoom.body')}
/>
)
}
if (isCreateError) {
// this error screen should be replaced by a proper waiting room for anonymous user.
return (
@@ -282,6 +295,7 @@ export const Conference = ({
return
case DisconnectReason.DUPLICATE_IDENTITY:
case DisconnectReason.PARTICIPANT_REMOVED:
case DisconnectReason.ROOM_DELETED:
navigateTo(
'feedback',
{},
@@ -74,7 +74,9 @@ export const Lobby = ({
const { openLoginHint } = useLoginHint()
const handleSubmit = async () => {
const { data } = await refetchRoom()
const { data, error } = await refetchRoom()
if (error?.statusCode == 410) return
if (!data?.livekit) {
// Display a message to inform the user that by logging in, they won't have to wait for room entry approval.
@@ -88,7 +90,22 @@ export const Lobby = ({
enterRoom()
}
switch (status) {
const isRoomDeleted = isError && error?.statusCode == 410
const lobbyStatus = isRoomDeleted ? ApiLobbyStatus.DELETED : status
switch (lobbyStatus) {
case ApiLobbyStatus.DELETED:
return (
<VStack alignItems="center" textAlign="center">
<H lvl={1} margin={false} centered>
{t('deleted.title')}
</H>
<Text as="p" variant="note">
{t('deleted.body')}
</Text>
</VStack>
)
case ApiLobbyStatus.TIMEOUT:
return (
<VStack alignItems="center" textAlign="center">
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { keys } from '@/api/queryKeys'
import { ApiError } from '@/api/ApiError'
import {
requestEntry,
ApiLobbyStatus,
@@ -40,24 +41,33 @@ export const useLobby = ({
const { data: waitingData } = useQuery({
queryKey: [keys.requestEntry, roomId],
queryFn: async () => {
const response = await requestEntry({
roomId,
username,
})
try {
const response = await requestEntry({
roomId,
username,
})
if (response.id) {
setLobbyParticipantId(roomId, response.id)
if (response.id) {
setLobbyParticipantId(roomId, response.id)
}
if (response.status === ApiLobbyStatus.ACCEPTED) {
clearWaitingTimeout()
setStatus(ApiLobbyStatus.ACCEPTED)
onAccepted(response)
} else if (response.status === ApiLobbyStatus.DENIED) {
clearWaitingTimeout()
setStatus(ApiLobbyStatus.DENIED)
}
return response
} catch (error) {
// The room was deleted while waiting, stop polling
if (error instanceof ApiError && error.statusCode === 410) {
clearWaitingTimeout()
setStatus(ApiLobbyStatus.DELETED)
return { status: ApiLobbyStatus.DELETED }
}
throw error
}
if (response.status === ApiLobbyStatus.ACCEPTED) {
clearWaitingTimeout()
setStatus(ApiLobbyStatus.ACCEPTED)
onAccepted(response)
} else if (response.status === ApiLobbyStatus.DENIED) {
clearWaitingTimeout()
setStatus(ApiLobbyStatus.DENIED)
}
return response
},
refetchInterval: POLL_INTERVAL_MS,
refetchOnWindowFocus: false,
@@ -4,6 +4,7 @@ import { RiVolumeUpLine } from '@remixicon/react'
import { styled } from '@/styled-system/jsx'
import { Button } from '@/primitives'
import { canTestAudioOutput } from '@/features/rooms/utils/canTestAudioOutput'
import { resolveAudioOutputDeviceId } from '@/features/rooms/utils/resolveAudioOutputDeviceId'
// Speaker test in the audiooutput menu footer (Meet-style UX). Outputs have
// no track: the test plays a bundled file through the selected sink, and
@@ -66,6 +67,8 @@ export const OutputSoundTester = ({
const audioRef = useRef<HTMLAudioElement>(null)
const [isPlaying, setIsPlaying] = useState(false)
const outputId = resolveAudioOutputDeviceId(sinkId)
const latestSinkIdRef = useRef(sinkId)
latestSinkIdRef.current = sinkId
@@ -79,15 +82,15 @@ export const OutputSoundTester = ({
}, [])
useEffect(() => {
if (!sinkId || !canTestAudioOutput()) return
audioRef.current?.setSinkId(sinkId).catch(() => {
if (!outputId || !canTestAudioOutput()) return
audioRef.current?.setSinkId(outputId).catch(() => {
// Re-routing failed (stale or unplugged device): stop the test rather
// than keep playing through the previous sink.
if (latestSinkIdRef.current === sinkId) {
stopPlayback()
}
})
}, [sinkId, stopPlayback])
}, [outputId, sinkId, stopPlayback])
useEffect(() => {
const audio = audioRef.current
@@ -108,8 +111,8 @@ export const OutputSoundTester = ({
// Confirm routing before starting: a no-op when already routed,
// but rejects on a stale device id, so the test never plays
// through the wrong sink.
if (sinkId && canTestAudioOutput()) {
await audio.setSinkId(sinkId)
if (outputId && canTestAudioOutput()) {
await audio.setSinkId(outputId)
}
await audio.play()
setIsPlaying(true)
@@ -1,19 +1,12 @@
import { RoomEvent, Track } from 'livekit-client'
import { useCallback } from 'react'
import { useCallback, useMemo } from 'react'
import { useConfig } from '@/api/useConfig'
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
import { useRemoteParticipants } from '@livekit/components-react'
import { useUpdateParticipantsPermissions } from '@/features/rooms/api/updateParticipantsPermissions'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { isSubsetOf } from '@/features/rooms/utils/isSubsetOf'
import { getParticipantIsRoomAdminOrOwner } from '@/features/rooms/utils/getParticipantIsRoomAdminOrOwner'
import {
getParticipantIsSpeaker,
useParticipantSpeaker,
} from '@/features/participants/api/updateParticipantSpeaker'
import {
useDefaultPublishSources,
useRoomPublishSources,
} from '@/features/rooms/livekit/hooks/useRoomPublishSources'
import Source = Track.Source
import {
NotificationType,
@@ -40,9 +33,8 @@ export const usePublishSourcesManager = () => {
const { mutateAsync: patchRoom } = usePatchRoom()
const data = useRoomData()
const { data: configData } = useConfig()
const configuration = data?.configuration
const currentSources = useRoomPublishSources()
const defaultSources = useDefaultPublishSources()
const { notifyParticipants } = useNotifyParticipants()
@@ -50,18 +42,25 @@ export const usePublishSourcesManager = () => {
const roomId = data?.slug
const { updateParticipantsPermissions } = useUpdateParticipantsPermissions()
const { removeSpeaker } = useParticipantSpeaker()
const remoteParticipants = useRemoteParticipants({
updateOnlyOn: [RoomEvent.ParticipantAttributesChanged],
})
const restrictedRemoteParticipants = remoteParticipants.filter(
(participant) =>
!getParticipantIsRoomAdminOrOwner(participant) &&
!getParticipantIsSpeaker(participant)
const unprivilegedRemoteParticipants = remoteParticipants.filter(
(participant) => !getParticipantIsRoomAdminOrOwner(participant)
)
const speakers = remoteParticipants.filter(getParticipantIsSpeaker)
const currentSources = useMemo(() => {
const defaultSources = configData?.livekit?.default_sources ?? []
if (
configuration?.can_publish_sources == undefined ||
!Array.isArray(configuration?.can_publish_sources)
) {
return defaultSources
}
return configuration.can_publish_sources
}, [configData, configuration?.can_publish_sources])
const updateSource = useCallback(
async (sources: Source[], enabled: boolean) => {
@@ -85,16 +84,10 @@ export const usePublishSourcesManager = () => {
})
await updateParticipantsPermissions(
restrictedRemoteParticipants,
unprivilegedRemoteParticipants,
newSources
)
if (isSubsetOf(defaultSources, newSources)) {
await Promise.all(
speakers.map((speaker) => removeSpeaker(speaker, newSources))
)
}
if (!enabled) {
/*
* We can't rely solely on the ParticipantPermissionsChanged event here,
@@ -103,7 +96,7 @@ export const usePublishSourcesManager = () => {
* */
await notifyParticipants({
type: NotificationType.PermissionsRemoved,
destinationIdentities: restrictedRemoteParticipants.map(
destinationIdentities: unprivilegedRemoteParticipants.map(
(p) => p.identity
),
additionalData: {
@@ -126,12 +119,9 @@ export const usePublishSourcesManager = () => {
notifyParticipants,
configuration,
currentSources,
defaultSources,
roomId,
patchRoom,
restrictedRemoteParticipants,
speakers,
removeSpeaker,
unprivilegedRemoteParticipants,
updateParticipantsPermissions,
]
)
@@ -1,30 +0,0 @@
import { Track } from 'livekit-client'
import { useMemo } from 'react'
import { useConfig } from '@/api/useConfig'
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
import { isSubsetOf } from '@/features/rooms/utils/isSubsetOf'
import Source = Track.Source
const EMPTY_SOURCES: Source[] = []
export const useDefaultPublishSources = (): Source[] => {
const { data: configData } = useConfig()
return configData?.livekit?.default_sources ?? EMPTY_SOURCES
}
export const useRoomPublishSources = (): Source[] => {
const data = useRoomData()
const defaultSources = useDefaultPublishSources()
const roomSources = data?.configuration?.can_publish_sources
return useMemo(
() => (Array.isArray(roomSources) ? roomSources : defaultSources),
[defaultSources, roomSources]
)
}
export const useHasLockedSource = (): boolean => {
const defaultSources = useDefaultPublishSources()
const roomSources = useRoomPublishSources()
return !isSubsetOf(defaultSources, roomSources)
}
@@ -31,6 +31,7 @@ const buttonClass = css({
enum DisconnectReasonKey {
DuplicateIdentity = 'duplicateIdentity',
ParticipantRemoved = 'participantRemoved',
RoomDeleted = 'roomDeleted',
}
const FeedbackRoute = () => {
@@ -46,6 +47,8 @@ const FeedbackRoute = () => {
return DisconnectReasonKey.DuplicateIdentity
case DisconnectReason.PARTICIPANT_REMOVED:
return DisconnectReasonKey.ParticipantRemoved
case DisconnectReason.ROOM_DELETED:
return DisconnectReasonKey.RoomDeleted
}
}, [])
@@ -56,7 +59,10 @@ const FeedbackRoute = () => {
}
}, [])
const showBackButton = reasonKey !== DisconnectReasonKey.ParticipantRemoved
// Rejoining is not possible once removed or once the room is deleted
const showBackButton =
reasonKey !== DisconnectReasonKey.ParticipantRemoved &&
reasonKey !== DisconnectReasonKey.RoomDeleted
return (
<Screen layout="centered" footer={false}>
@@ -0,0 +1,11 @@
/**
* `'default'` means "use the system output". Firefox has no device with that
* id, so setSinkId('default') rejects. Omit it and the browser keeps the OS
* default, which is the same result.
*/
export const resolveAudioOutputDeviceId = (
deviceId: string | undefined
): string | undefined => {
if (!deviceId || deviceId === 'default') return undefined
return deviceId
}
@@ -13,6 +13,7 @@ import {
RiVideoOnLine,
RiEyeLine,
RiKeyboardBoxLine,
RiRecordCircleLine,
} from '@remixicon/react'
import { AccountTab } from './tabs/AccountTab'
import { NotificationsTab } from './tabs/NotificationsTab'
@@ -26,6 +27,12 @@ import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
import { SettingsDialogExtendedKey } from '@/features/settings/type'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { AccessibilityTab } from './tabs/AccessibilityTab'
import { RecordingTab } from './tabs/RecordingTab'
import {
RecordingMode,
useIsRecordingModeEnabled,
useRecordingEncoding,
} from '@/features/recording'
const tabsStyle = css({
maxHeight: '40.625rem', // fixme size copied from meet settings modal
@@ -67,6 +74,12 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
const isWideScreen = useMediaQuery('(min-width: 800px)') // fixme - hardcoded 50rem in pixel
const isAdminOrOwner = useIsAdminOrOwner()
const isScreenRecordingEnabled = useIsRecordingModeEnabled(
RecordingMode.ScreenRecording
)
const { isCustomEncodingEnabled } = useRecordingEncoding()
const showRecordingTab =
isAdminOrOwner && !!isScreenRecordingEnabled && isCustomEncodingEnabled
return (
<Dialog innerRef={dialogEl} {...props} role="dialog" type="flex">
@@ -121,6 +134,13 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
t(`tabs.${SettingsDialogExtendedKey.TRANSCRIPTION}`)}
</Tab>
)}
{showRecordingTab && (
<Tab icon highlight id={SettingsDialogExtendedKey.RECORDING}>
<RiRecordCircleLine />
{isWideScreen &&
t(`tabs.${SettingsDialogExtendedKey.RECORDING}`)}
</Tab>
)}
<Tab icon highlight id={SettingsDialogExtendedKey.ACCESSIBILITY}>
<RiEyeLine />
{isWideScreen &&
@@ -140,6 +160,8 @@ export const SettingsDialogExtended = (props: SettingsDialogExtended) => {
<ShortcutTab id={SettingsDialogExtendedKey.SHORTCUTS} />
{/* Transcription tab won't be accessible if the tab is not active in the tab list */}
<TranscriptionTab id={SettingsDialogExtendedKey.TRANSCRIPTION} />
{/* Recording tab won't be accessible if the tab is not active in the tab list */}
<RecordingTab id={SettingsDialogExtendedKey.RECORDING} />
<AccessibilityTab id={SettingsDialogExtendedKey.ACCESSIBILITY} />
</div>
</Tabs>
@@ -0,0 +1,104 @@
import { TabPanel, type TabPanelProps } from '@/primitives/Tabs'
import { Field, H, Text } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { useMemo } from 'react'
import {
DEFAULT_ENCODING_OPTION,
useIsRecordingLocked,
useRecordingEncoding,
} from '@/features/recording'
export type RecordingTabProps = Pick<TabPanelProps, 'id'>
export const RecordingTab = ({ id }: RecordingTabProps) => {
const { t } = useTranslation('settings', { keyPrefix: 'recording' })
const {
availableResolutions,
availableProfiles,
hasDefaultResolution,
hasDefaultProfile,
resolution,
profile,
setResolution,
setProfile,
} = useRecordingEncoding()
const isLocked = useIsRecordingLocked()
// The API requires a resolution to modify the encoding: with a "Default"
// resolution no payload is sent, so the profile cannot apply. Show it as
// "Default" and disabled until a resolution is chosen.
const isResolutionDefault = resolution === DEFAULT_ENCODING_OPTION
const selectedProfile = isResolutionDefault ? '' : profile
const defaultItem = {
value: DEFAULT_ENCODING_OPTION,
label: t('defaultOption'),
}
// Resolutions and profiles are operator-defined: fall back to the raw key
// when no translation exists for a custom value.
const resolutionItems = useMemo(
() => [
...(hasDefaultResolution ? [] : [defaultItem]),
...availableResolutions.map((key) => ({
value: key,
label: t(`resolution.options.${key}`, { defaultValue: key }),
})),
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[hasDefaultResolution, availableResolutions, t]
)
const profileItems = useMemo(
() => [
...(hasDefaultProfile ? [] : [defaultItem]),
...availableProfiles.map((key) => ({
value: key,
label: t(`profile.options.${key}.label`, { defaultValue: key }),
})),
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[hasDefaultProfile, availableProfiles, t]
)
const profileDescription = selectedProfile
? t(`profile.options.${selectedProfile}.description`, { defaultValue: '' })
: ''
return (
<TabPanel padding={'md'} flex id={id}>
<H lvl={2}>{t('heading')}</H>
{isLocked && (
<Text variant="note" role="status" margin="sm">
{t('locked')}
</Text>
)}
<Field
type="select"
label={t('resolution.label')}
description={t('resolution.description')}
items={resolutionItems}
selectedKey={resolution ?? null}
isDisabled={isLocked}
onSelectionChange={(key) => {
if (isLocked || key === null) return
setResolution(String(key))
}}
/>
<Field
type="select"
label={t('profile.label')}
description={profileDescription || undefined}
items={profileItems}
selectedKey={selectedProfile ?? null}
isDisabled={isLocked || isResolutionDefault}
onSelectionChange={(key) => {
if (isLocked || isResolutionDefault || key === null) return
setProfile(String(key))
}}
/>
</TabPanel>
)
}
@@ -7,4 +7,5 @@ export enum SettingsDialogExtendedKey {
TRANSCRIPTION = 'transcription',
SHORTCUTS = 'shortcuts',
ACCESSIBILITY = 'accessibility',
RECORDING = 'recording',
}
@@ -68,9 +68,5 @@
"member": "Mitglied"
}
},
"speakerChanged": {
"granted": "Du bist jetzt Sprecher. Du kannst dein Mikrofon, deine Kamera und die Bildschirmfreigabe nutzen.",
"removed": "Du bist nicht mehr Sprecher."
},
"openMenu": "Menü öffnen"
}
+13 -11
View File
@@ -3,7 +3,8 @@
"heading": {
"normal": "Du hast das Meeting verlassen",
"duplicateIdentity": "Du bist dem Meeting von einem anderen Gerät aus beigetreten",
"participantRemoved": "Du wurdest vom Host aus dem Meeting entfernt"
"participantRemoved": "Du wurdest vom Host aus dem Meeting entfernt",
"roomDeleted": "Dieses Meeting wurde gelöscht"
},
"home": "Zur Startseite zurückkehren",
"back": "Dem Meeting erneut beitreten"
@@ -94,6 +95,10 @@
"timeoutInvite": {
"title": "Du kannst diesem Meeting nicht beitreten",
"body": "Niemand hat auf deine Anfrage reagiert"
},
"deleted": {
"title": "Du kannst diesem Meeting nicht beitreten",
"body": "Dieses Meeting wurde gelöscht."
}
},
"leaveRoomPrompt": "Hiermit verlässt du das Meeting.",
@@ -215,6 +220,10 @@
"heading": "Authentifizierung erforderlich",
"body": "Dieser Raum wurde noch nicht erstellt. Bitte authentifiziere dich, um ihn zu erstellen, oder warte, bis eine authentifizierte Person dies tut."
},
"deletedRoom": {
"heading": "Meeting gelöscht",
"body": "Dieses Meeting wurde gelöscht und kann nicht mehr betreten werden."
},
"screenShare": {
"title": "Bildschirmfreigabe nicht möglich",
"ariaLabel": "Bildschirmfreigabe nicht möglich",
@@ -535,7 +544,9 @@
"destination": "Diese Aufzeichnung wird vorübergehend auf unseren Servern gespeichert",
"loading": "Aufzeichnung wird gestartet",
"linkMore": "Mehr erfahren",
"transcription": "Diese Aufzeichnung transkribieren"
"transcription": "Diese Aufzeichnung transkribieren",
"quality": "Videoqualität anpassbar.",
"qualityEdit": "Ändern"
},
"button": {
"start": "Meeting-Aufzeichnung starten",
@@ -659,7 +670,6 @@
"host": "Host",
"cohost": "Co-host",
"member": "Mitglied",
"speaker": "Sprecher",
"contributors": "Teilnehmende",
"collapsable": {
"open": "{{name}}-Liste öffnen",
@@ -715,14 +725,6 @@
"demote": {
"label": "Co-Host-Rolle entfernen",
"ariaLabel": "Die Co-Host-Rolle von {{name}} entfernen"
},
"makeSpeaker": {
"label": "Zum Sprecher machen",
"ariaLabel": "{{name}} zum Sprecher machen"
},
"removeSpeaker": {
"label": "Sprecherrolle entfernen",
"ariaLabel": "Sprecherrolle von {{name}} entfernen"
}
},
"pinAnnouncements": {
+37 -1
View File
@@ -184,10 +184,46 @@
"accessibility": "Barrierefreiheit",
"transcription": "Transkription",
"shortcuts": "Tastenkürzel",
"rooms": "Räume"
"rooms": "Räume",
"recording": "Aufzeichnung"
},
"roomDefaults": {
"heading": "Standardeinstellungen für Räume",
"description": "Wählen Sie die Einstellungen, die standardmäßig auf neue von Ihnen erstellte Räume angewendet werden. Sie können sie für jedes Meeting weiterhin in den Moderationseinstellungen ändern."
},
"recording": {
"heading": "Aufzeichnungsqualität",
"locked": "Diese Einstellungen können nicht geändert werden, während eine Aufzeichnung startet oder läuft.",
"profile": {
"label": "Inhaltstyp",
"options": {
"talking_heads": {
"label": "Sprechende Personen",
"description": "Optimiert für Personen vor der Kamera mit gelegentlichen Folien. Kleinste Dateien."
},
"text": {
"label": "Präsentationen und Text",
"description": "Hält Text beim Teilen von Folien, Dokumenten oder Code scharf."
},
"mixed": {
"label": "Gemischte Inhalte",
"description": "Ausgewogen für Kameras, Bildschirmfreigabe und moderate Bewegung."
},
"full": {
"label": "Maximale Qualität",
"description": "Höchste Wiedergabetreue und flüssigste Bewegung. Größte Dateien."
}
}
},
"resolution": {
"label": "Auflösung",
"description": "Höhere Auflösungen erzeugen größere Dateien.",
"options": {
"540p": "540p (qHD)",
"720p": "720p (HD)",
"1080p": "1080p (Full HD)"
}
},
"defaultOption": "Standard"
}
}
@@ -68,9 +68,5 @@
"member": "Member"
}
},
"speakerChanged": {
"granted": "You are now a speaker. You can use your microphone, camera and screen sharing.",
"removed": "You are no longer a speaker."
},
"openMenu": "Open menu"
}
+13 -11
View File
@@ -3,7 +3,8 @@
"heading": {
"normal": "You have left the meeting",
"duplicateIdentity": "You have joined the meeting from another device",
"participantRemoved": "You have been removed from the meeting by a host"
"participantRemoved": "You have been removed from the meeting by a host",
"roomDeleted": "This meeting has been deleted"
},
"home": "Return to home",
"back": "Rejoin the meeting"
@@ -94,6 +95,10 @@
"timeoutInvite": {
"title": "You cannot join this call",
"body": "No one responded to your request"
},
"deleted": {
"title": "You cannot join this call",
"body": "This meeting has been deleted."
}
},
"leaveRoomPrompt": "This will make you leave the meeting.",
@@ -215,6 +220,10 @@
"heading": "Authentication Required",
"body": "This room has not been created yet. Please authenticate to create it or wait for an authenticated user to do so."
},
"deletedRoom": {
"heading": "Meeting deleted",
"body": "This meeting has been deleted and can no longer be joined."
},
"screenShare": {
"title": "Unable to share your screen",
"ariaLabel": "Unable to share your screen",
@@ -535,7 +544,9 @@
"destination": "This recording will be temporarily stored on our servers",
"loading": "Starting recording",
"linkMore": "Learn more",
"transcription": "Transcribe this recording"
"transcription": "Transcribe this recording",
"quality": "Adjustable video quality.",
"qualityEdit": "Change"
},
"button": {
"start": "Start recording the meeting",
@@ -659,7 +670,6 @@
"host": "Host",
"cohost": "Co-host",
"member": "Member",
"speaker": "Speaker",
"contributors": "Contributors",
"collapsable": {
"open": "Open {{name}} list",
@@ -715,14 +725,6 @@
"demote": {
"label": "Remove co-host role",
"ariaLabel": "Remove {{name}}'s co-host role"
},
"makeSpeaker": {
"label": "Make speaker",
"ariaLabel": "Make {{name}} a speaker"
},
"removeSpeaker": {
"label": "Remove speaker",
"ariaLabel": "Remove {{name}} as speaker"
}
},
"pinAnnouncements": {
+37 -1
View File
@@ -184,10 +184,46 @@
"accessibility": "Accessibility",
"transcription": "Transcription",
"shortcuts": "Shortcuts",
"rooms": "Rooms"
"rooms": "Rooms",
"recording": "Recording"
},
"roomDefaults": {
"heading": "Default room settings",
"description": "Choose the settings applied by default to the new rooms you create. You can still change them for each meeting from the host settings."
},
"recording": {
"heading": "Recording quality",
"locked": "These settings can't be changed while a recording is starting or in progress.",
"profile": {
"label": "Content type",
"options": {
"talking_heads": {
"label": "Talking heads",
"description": "Optimized for people speaking on camera with occasional slides. Smallest files."
},
"text": {
"label": "Presentations and text",
"description": "Keeps text sharp when sharing slides, documents or code."
},
"mixed": {
"label": "Mixed content",
"description": "Balanced for a mix of cameras, screen sharing and moderate motion."
},
"full": {
"label": "Maximum quality",
"description": "Highest fidelity and smoothest motion. Largest files."
}
}
},
"resolution": {
"label": "Resolution",
"description": "Higher resolutions produce larger files.",
"options": {
"540p": "540p (qHD)",
"720p": "720p (HD)",
"1080p": "1080p (Full HD)"
}
},
"defaultOption": "Default"
}
}
@@ -68,9 +68,5 @@
"member": "Miembro"
}
},
"speakerChanged": {
"granted": "Ahora eres ponente. Puedes usar tu micrófono, tu cámara y compartir pantalla.",
"removed": "Ya no eres ponente."
},
"openMenu": "Abrir el menú"
}
+13 -11
View File
@@ -3,7 +3,8 @@
"heading": {
"normal": "Has salido de la reunión",
"duplicateIdentity": "Te has unido a la reunión desde otro dispositivo",
"participantRemoved": "Un administrador te ha expulsado de la llamada"
"participantRemoved": "Un administrador te ha expulsado de la llamada",
"roomDeleted": "Esta reunión ha sido eliminada"
},
"home": "Volver al inicio",
"back": "Volver a la reunión"
@@ -94,6 +95,10 @@
"timeoutInvite": {
"title": "No puedes participar en esta llamada",
"body": "Nadie ha respondido a tu solicitud de participación en la llamada"
},
"deleted": {
"title": "No puedes participar en esta llamada",
"body": "Esta reunión ha sido eliminada."
}
},
"leaveRoomPrompt": "Volver al inicio hará que salgas de la reunión.",
@@ -215,6 +220,10 @@
"heading": "Autenticación necesaria",
"body": "Esta reunión todavía no se ha creado. Autentícate para crearla o espera a que lo haga un usuario autenticado."
},
"deletedRoom": {
"heading": "Reunión eliminada",
"body": "Esta reunión ha sido eliminada y ya no es posible unirse a ella."
},
"screenShare": {
"title": "No se puede compartir tu pantalla",
"ariaLabel": "No se puede compartir tu pantalla",
@@ -534,7 +543,9 @@
"destination": "Esta grabación se conservará temporalmente en nuestros servidores",
"loading": "Iniciando la grabación",
"linkMore": "Más información",
"transcription": "Transcribir esta grabación"
"transcription": "Transcribir esta grabación",
"quality": "Calidad de vídeo ajustable.",
"qualityEdit": "Cambiar"
},
"button": {
"start": "Empezar a grabar la reunión",
@@ -659,7 +670,6 @@
"host": "Organizador de la reunión",
"cohost": "Coorganizador",
"member": "Miembro",
"speaker": "Ponente",
"collapsable": {
"open": "Abrir la lista {{name}}",
"close": "Cerrar la lista {{name}}"
@@ -714,14 +724,6 @@
"demote": {
"label": "Retirar el rol de coorganizador",
"ariaLabel": "Retirar el rol de coorganizador a {{name}}"
},
"makeSpeaker": {
"label": "Nombrar ponente",
"ariaLabel": "Nombrar ponente a {{name}}"
},
"removeSpeaker": {
"label": "Quitar rol de ponente",
"ariaLabel": "Quitar el rol de ponente a {{name}}"
}
},
"pinAnnouncements": {
+37 -1
View File
@@ -184,10 +184,46 @@
"accessibility": "Accesibilidad",
"transcription": "Transcripción",
"shortcuts": "Atajos",
"rooms": "Reuniones"
"rooms": "Reuniones",
"recording": "Grabación"
},
"roomDefaults": {
"heading": "Ajustes predeterminados de las reuniones",
"description": "Elige la configuración que se aplica de forma predeterminada a las nuevas reuniones que crees. Siempre podrás modificarla para cada reunión desde el menu de configuración."
},
"recording": {
"heading": "Calidad de la grabación",
"locked": "Estos ajustes no se pueden modificar mientras una grabación se está iniciando o está en curso.",
"profile": {
"label": "Tipo de contenido",
"options": {
"talking_heads": {
"label": "Personas hablando",
"description": "Optimizado para personas ante la cámara con diapositivas ocasionales. Archivos más ligeros."
},
"text": {
"label": "Presentaciones y texto",
"description": "Mantiene el texto nítido al compartir diapositivas, documentos o código."
},
"mixed": {
"label": "Contenido mixto",
"description": "Equilibrado entre cámaras, pantalla compartida y movimiento moderado."
},
"full": {
"label": "Calidad máxima",
"description": "Máxima fidelidad y movimiento más fluido. Archivos más pesados."
}
}
},
"resolution": {
"label": "Resolución",
"description": "Las resoluciones más altas generan archivos más pesados.",
"options": {
"540p": "540p (qHD)",
"720p": "720p (HD)",
"1080p": "1080p (Full HD)"
}
},
"defaultOption": "Predeterminado"
}
}
@@ -68,9 +68,5 @@
"member": "Membre"
}
},
"speakerChanged": {
"granted": "Vous êtes maintenant intervenant. Vous pouvez utiliser votre micro, votre caméra et partager votre écran.",
"removed": "Vous n'êtes plus intervenant."
},
"openMenu": "Ouvrir le menu"
}
+13 -11
View File
@@ -3,7 +3,8 @@
"heading": {
"normal": "Vous avez quitté la réunion",
"duplicateIdentity": "Vous avez rejoint la réunion depuis un autre appareil",
"participantRemoved": "Vous avez été exclu de l'appel par un administrateur"
"participantRemoved": "Vous avez été exclu de l'appel par un administrateur",
"roomDeleted": "Cette réunion a été supprimée"
},
"home": "Retourner à l'accueil",
"back": "Réintégrer la réunion"
@@ -94,6 +95,10 @@
"timeoutInvite": {
"title": "Vous ne pouvez pas participer à cet appel",
"body": "Personne n'a répondu à votre demande de participation à l'appel"
},
"deleted": {
"title": "Vous ne pouvez pas participer à cet appel",
"body": "Cette réunion a été supprimée."
}
},
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
@@ -215,6 +220,10 @@
"heading": "Authentification requise",
"body": "Cette réunion n'a pas encore été créée. Veuillez vous authentifier pour la créer ou attendre qu'un utilisateur authentifié le fasse."
},
"deletedRoom": {
"heading": "Réunion supprimée",
"body": "Cette réunion a été supprimée et n'est plus accessible."
},
"screenShare": {
"title": "Impossible de partager votre écran",
"ariaLabel": "Impossible de partager votre écran",
@@ -535,7 +544,9 @@
"destination": "Cet enregistrement sera conservé temporairement sur nos serveurs",
"loading": "Démarrage de l'enregistrement",
"linkMore": "En savoir plus",
"transcription": "Transcrire cet enregistrement"
"transcription": "Transcrire cet enregistrement",
"quality": "Qualité vidéo ajustable.",
"qualityEdit": "Modifier"
},
"button": {
"start": "Commencer à enregistrer la réunion",
@@ -660,7 +671,6 @@
"host": "Organisateur de la réunion",
"cohost": "Co-organisateur",
"member": "Membre",
"speaker": "Intervenant",
"collapsable": {
"open": "Ouvrir la liste {{name}}",
"close": "Fermer la liste {{name}}"
@@ -715,14 +725,6 @@
"demote": {
"label": "Retirer le rôle de co-organisateur",
"ariaLabel": "Retirer le rôle de co-organisateur à {{name}}"
},
"makeSpeaker": {
"label": "Désigner comme intervenant",
"ariaLabel": "Désigner {{name}} comme intervenant"
},
"removeSpeaker": {
"label": "Retirer le rôle d'intervenant",
"ariaLabel": "Retirer le rôle d'intervenant de {{name}}"
}
},
"pinAnnouncements": {
+37 -1
View File
@@ -184,10 +184,46 @@
"accessibility": "Accessibilité",
"transcription": "Transcription",
"shortcuts": "Raccourcis",
"rooms": "Réunions"
"rooms": "Réunions",
"recording": "Enregistrement"
},
"roomDefaults": {
"heading": "Paramètres par défaut des réunions",
"description": "Choisissez les paramètres appliqués par défaut aux nouvelles réunions que vous créez. Vous pourrez toujours les modifier pour chaque réunion depuis les paramètres d’administration."
},
"recording": {
"heading": "Qualité de l'enregistrement",
"locked": "Ces paramètres ne peuvent pas être modifiés pendant le démarrage ou le déroulement d'un enregistrement.",
"profile": {
"label": "Type de contenu",
"options": {
"talking_heads": {
"label": "Visioconférence",
"description": "Optimisé pour des personnes à la caméra avec quelques diapositives. Fichiers les plus légers."
},
"text": {
"label": "Présentations et texte",
"description": "Garde le texte net lors du partage de diapositives, de documents ou de code."
},
"mixed": {
"label": "Contenu mixte",
"description": "Équilibré entre caméras, partage d'écran et mouvements modérés."
},
"full": {
"label": "Qualité maximale",
"description": "Meilleure fidélité et mouvements les plus fluides. Fichiers les plus lourds."
}
}
},
"resolution": {
"label": "Résolution",
"description": "Une résolution plus élevée produit des fichiers plus lourds.",
"options": {
"540p": "540p (qHD)",
"720p": "720p (HD)",
"1080p": "1080p (Full HD)"
}
},
"defaultOption": "Par défaut"
}
}
@@ -68,9 +68,5 @@
"member": "Lid"
}
},
"speakerChanged": {
"granted": "Je bent nu spreker. Je kunt je microfoon, camera en schermdeling gebruiken.",
"removed": "Je bent geen spreker meer."
},
"openMenu": "Menu openen"
}
+13 -11
View File
@@ -3,7 +3,8 @@
"heading": {
"normal": "Je hebt de vergadering verlaten",
"duplicateIdentity": "U heeft de vergadering via een ander apparaat geopend",
"participantRemoved": "U bent door een beheerder uit het gesprek verwijderd"
"participantRemoved": "U bent door een beheerder uit het gesprek verwijderd",
"roomDeleted": "Deze vergadering is verwijderd"
},
"home": "Keer terug naar het hoofdscherm",
"back": "Sluit weer bij de vergadering aan"
@@ -94,6 +95,10 @@
"timeoutInvite": {
"title": "U kunt niet deelnemen aan dit gesprek",
"body": "Niemand heeft gereageerd op uw verzoek om deel te nemen aan het gesprek"
},
"deleted": {
"title": "U kunt niet deelnemen aan dit gesprek",
"body": "Deze vergadering is verwijderd."
}
},
"leaveRoomPrompt": "Dat zal u de vergadering doen verlaten.",
@@ -215,6 +220,10 @@
"heading": "Verificatie vereist",
"body": "Deze ruimte is nog niet gemaakt. Logt u alstublieft in om hem aan te maken, of wacht tot een ingelogde gebruiker dat doet."
},
"deletedRoom": {
"heading": "Vergadering verwijderd",
"body": "Deze vergadering is verwijderd en u kunt er niet meer aan deelnemen."
},
"screenShare": {
"title": "Kan uw scherm niet delen",
"ariaLabel": "Kan uw scherm niet delen",
@@ -535,7 +544,9 @@
"destination": "Deze opname wordt tijdelijk op onze servers bewaard",
"loading": "Opname wordt gestart",
"linkMore": "Meer informatie",
"transcription": "Transcribeer deze opname"
"transcription": "Transcribeer deze opname",
"quality": "Videokwaliteit aanpasbaar.",
"qualityEdit": "Wijzigen"
},
"button": {
"start": "Start met opnemen van de vergadering",
@@ -659,7 +670,6 @@
"host": "Host",
"cohost": "Co-host",
"member": "Lid",
"speaker": "Spreker",
"contributors": "Deelnemers",
"collapsable": {
"open": "Open {{name}} lijst",
@@ -715,14 +725,6 @@
"demote": {
"label": "Co-hostrol verwijderen",
"ariaLabel": "De co-hostrol van {{name}} verwijderen"
},
"makeSpeaker": {
"label": "Spreker maken",
"ariaLabel": "{{name}} spreker maken"
},
"removeSpeaker": {
"label": "Sprekersrol verwijderen",
"ariaLabel": "Sprekersrol van {{name}} verwijderen"
}
},
"pinAnnouncements": {
+37 -1
View File
@@ -184,10 +184,46 @@
"accessibility": "Toegankelijkheid",
"transcription": "Transcriptie",
"shortcuts": "Sneltoetsen",
"rooms": "Vergaderingen"
"rooms": "Vergaderingen",
"recording": "Opname"
},
"roomDefaults": {
"heading": "Standaardinstellingen voor vergaderingen",
"description": "Kies de instellingen die standaard worden toegepast op nieuwe vergaderingen die u aanmaakt. U kunt ze voor elke vergadering nog steeds wijzigen via de hostinstellingen."
},
"recording": {
"heading": "Opnamekwaliteit",
"locked": "Deze instellingen kunnen niet worden gewijzigd terwijl een opname start of bezig is.",
"profile": {
"label": "Type inhoud",
"options": {
"talking_heads": {
"label": "Sprekende personen",
"description": "Geoptimaliseerd voor mensen voor de camera met af en toe dia's. Kleinste bestanden."
},
"text": {
"label": "Presentaties en tekst",
"description": "Houdt tekst scherp bij het delen van dia's, documenten of code."
},
"mixed": {
"label": "Gemengde inhoud",
"description": "Gebalanceerd voor camera's, schermdeling en matige beweging."
},
"full": {
"label": "Maximale kwaliteit",
"description": "Hoogste getrouwheid en vloeiendste beweging. Grootste bestanden."
}
}
},
"resolution": {
"label": "Resolutie",
"description": "Hogere resoluties leveren grotere bestanden op.",
"options": {
"540p": "540p (qHD)",
"720p": "720p (HD)",
"1080p": "1080p (Full HD)"
}
},
"defaultOption": "Standaard"
}
}
+2
View File
@@ -9,6 +9,8 @@ import { type StyledVariantProps } from '@/styled-system/types'
const StyledRadio = styled(RACRadio, {
base: {
display: 'flex',
// anchor the visually hidden input, so it scrolls with the radio
position: 'relative',
alignItems: 'center',
gap: 0.375,
forcedColorAdjust: 'none',
+2
View File
@@ -9,6 +9,8 @@ import { RiCheckLine, RiCloseFill } from '@remixicon/react'
const StyledSwitch = styled(RACSwitch, {
base: {
display: 'flex',
// anchor the visually hidden input, so it scrolls with the switch
position: 'relative',
alignItems: 'center',
gap: '0.571rem',
color: 'black',
+9 -2
View File
@@ -25,12 +25,15 @@ export const menuRecipe = sva({
'&[data-focused]': {
color: 'primary.text',
backgroundColor: 'primaryDark.100',
outline: 'none!',
},
'&[data-hovered]': {
color: 'primary.text',
backgroundColor: 'primaryDark.100',
outline: 'none!',
},
'&[data-focus-visible]': {
outline: '2px solid',
outlineColor: 'focusRing',
outlineOffset: '2px',
},
},
},
@@ -49,6 +52,10 @@ export const menuRecipe = sva({
dark: {
item: {
color: 'white',
'&[data-focus-visible]': {
outlineColor: 'white!',
outlineOffset: '2px!',
},
},
},
},
@@ -0,0 +1,48 @@
import { proxy, subscribe } from 'valtio'
import { STORAGE_KEYS } from '@/utils/storageKeys'
import { reportError } from '@/features/analytics/telemetry'
export type RecordingEncodingPreference = {
resolution?: string
profile?: string
}
/**
* Recording encoding preferences, shared by everyone using this browser.
*/
type State = RecordingEncodingPreference
const DEFAULT_STATE: State = {}
function getRecordingEncodingState(): State {
try {
const stored = localStorage.getItem(STORAGE_KEYS.RECORDING_ENCODING)
if (!stored) return DEFAULT_STATE
const parsed = JSON.parse(stored)
if (!parsed) return DEFAULT_STATE
return {
...DEFAULT_STATE,
...parsed,
}
} catch (error: unknown) {
reportError('generic_failure', error, {
context: '[RecordingEncodingStore] Failed to parse stored settings:',
})
return DEFAULT_STATE
}
}
export const recordingEncodingStore = proxy<State>(getRecordingEncodingState())
subscribe(recordingEncodingStore, () => {
localStorage.setItem(
STORAGE_KEYS.RECORDING_ENCODING,
JSON.stringify(recordingEncodingStore)
)
})
export const setRecordingEncodingPreference = (
preference: RecordingEncodingPreference
) => {
Object.assign(recordingEncodingStore, preference)
}
+1
View File
@@ -6,4 +6,5 @@ export const STORAGE_KEYS = {
USER_PREFERENCES: 'app_user_preferences',
USER: 'app_user',
ACCESSIBILITY: 'app_accessibility_settings',
RECORDING_ENCODING: 'app_recording_encoding',
} as const