Compare commits

..

7 Commits

Author SHA1 Message Date
lebaudantoine 7e32219797 💄(frontend) position the login hint dynamically next to the button
Compute the position of the login hint at render time so it is
always displayed close to the login button, regardless of the
button's placement or the current viewport size.
2026-09-07 19:05:05 +02:00
lebaudantoine 93e4dcbe17 📈(frontend) track missing lobby participant on accept/reject
When a moderator accepts or rejects a lobby entry that no longer
exists, emit a tracking event so we can measure how often it
happens.

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

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

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

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

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

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

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

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

It will de facto reduce pressure on the backend.

We will observe the impact in production, and revisit these
intervals if the delays turn out to be too aggressive.
2026-09-07 19:05:02 +02:00
63 changed files with 380 additions and 905 deletions
+2 -2
View File
@@ -252,13 +252,13 @@ jobs:
- name: Start MinIO
run: |
docker pull quay.io/minio/minio
docker pull minio/minio
docker run -d --name minio \
-p 9000:9000 \
-e "MINIO_ACCESS_KEY=meet" \
-e "MINIO_SECRET_KEY=password" \
-v /data/media:/data \
quay.io/minio/minio server --console-address :9001 /data
minio/minio server --console-address :9001 /data
# Tool to wait for a service to be ready
- name: Install Dockerize
-27
View File
@@ -10,38 +10,11 @@ and this project adheres to
### Added
- ✨(backend) make the LiveKit default video codec configurable
### Changed
- 📈(frontend) include LiveKit SIDs in the connection analytics event
- 🔇(backend) silence expected 401 warnings on /me
- 🔇(backend) silence noisy request summary info logs
- ⚡️(frontend) defer loading the Crisp script until idle
- ⬆️(frontend) upgrade react-i18next from 17.0.10 to 17.0.12
- ⬆️(frontend) upgrade posthog-js from 1.414.0 to 1.418.10
- ⬆️(addons) upgrade i18next from 26.3.6 to 26.4.0
- ⬆️(frontend) upgrade humanize-duration from 3.33.2 to 3.34.1
- ♻️(backend) delete files synchronously
### Fixed
- 🐛(backend) remove the temporary upload object when a file is deleted
- 🐛(backend) acknowledge unknown LiveKit webhook events instead of 422
- 🔒️(backend) enforce display name setting on rename API
- 🔒️(backend) reject inactive users in resource server backend
- 🐛(frontend) fix file permissions in the Docker image
## [1.31.0] - 2026-09-08
### Added
- ✨(frontend) add 1080p sending resolution option #1660
- ✨(backend) add Traefik support via configurable media-auth url header #1649
- ✨(backend) update a room's attributes from the external API
- 🔊(backend) log request duration in Gunicorn workers
- 📈(frontend) track missing lobby participant on accept/reject
- ✨(backend) sort waiting participants by their arrival time
### Changed
+2 -2
View File
@@ -17,7 +17,7 @@ services:
minio:
user: ${DOCKER_USER:-1000}
image: quay.io/minio/minio
image: minio/minio
environment:
- MINIO_ROOT_USER=meet
- MINIO_ROOT_PASSWORD=password
@@ -35,7 +35,7 @@ services:
- ./data/media:/data
createbuckets:
image: quay.io/minio/mc
image: minio/mc
depends_on:
minio:
condition: service_healthy
+4 -4
View File
@@ -10,7 +10,7 @@
"license": "MIT",
"dependencies": {
"core-js": "3.50.0",
"i18next": "26.4.0",
"i18next": "26.3.6",
"i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1"
},
@@ -9367,9 +9367,9 @@
}
},
"node_modules/i18next": {
"version": "26.4.0",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.0.tgz",
"integrity": "sha512-rsmK5bFqsD1AetSFSIa43wtNR4WpvvH4p0tLEsTxkC7QTrfdFm06nbQ95bh8Og4wwaCnUEcm9DVYL2cgxitiQg==",
"version": "26.3.6",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
"funding": [
{
"type": "individual",
+1 -1
View File
@@ -27,7 +27,7 @@
},
"dependencies": {
"core-js": "3.50.0",
"i18next": "26.4.0",
"i18next": "26.3.6",
"i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1"
},
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "agents"
version = "1.31.0"
version = "1.29.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.6.7",
+1 -1
View File
@@ -9,7 +9,7 @@ resolution-markers = [
[[package]]
name = "agents"
version = "1.31.0"
version = "1.29.0"
source = { virtual = "." }
dependencies = [
{ name = "httpx" },
+45 -3
View File
@@ -3,25 +3,48 @@
from django import forms
from django.contrib import admin, messages
from django.contrib.auth import admin as auth_admin
from django.db import transaction
from django.utils.html import format_html
from django.utils.translation import gettext_lazy as _
from core.recording.event import notification
from . import models
from .tasks.file import process_file_deletion
from .utils import generate_download_s3_url
def hard_delete_file(file):
"""Hard delete a file, soft deleting it first when needed."""
if file.deleted_at is None:
file.soft_delete()
file.hard_delete()
transaction.on_commit(lambda: process_file_deletion.delay(file.id))
class FileInlineFormSet(forms.BaseInlineFormSet):
"""Inline formset overriding delete behavior for files."""
def delete_existing(self, obj, commit=True):
"""Hard delete files instead of calling model.delete()."""
hard_delete_file(obj)
class FileInline(admin.TabularInline):
"""Inline class for the File model."""
model = models.File
formset = FileInlineFormSet
fk_name = "creator"
extra = 0
fields = ("id", "title", "type", "upload_state", "created_at")
readonly_fields = ("id", "created_at", "upload_state", "type")
show_change_link = True
def get_queryset(self, request):
"""Hide hard deleted files in the inline."""
return super().get_queryset(request).filter(hard_deleted_at__isnull=True)
@admin.register(models.User)
class UserAdmin(auth_admin.UserAdmin):
@@ -123,6 +146,7 @@ class FileAdmin(admin.ModelAdmin):
"creator",
"upload_state",
"deleted_at",
"hard_deleted_at",
"created_at",
"updated_at",
)
@@ -132,6 +156,7 @@ class FileAdmin(admin.ModelAdmin):
"created_at",
"updated_at",
"deleted_at",
"hard_deleted_at",
)
search_fields = (
"id",
@@ -149,6 +174,7 @@ class FileAdmin(admin.ModelAdmin):
"created_at",
"updated_at",
"deleted_at",
"hard_deleted_at",
"description",
"malware_detection_info",
"is_ready",
@@ -187,7 +213,15 @@ class FileAdmin(admin.ModelAdmin):
)
},
),
(_("Deletion"), {"fields": ("deleted_at",)}),
(
_("Deletion"),
{
"fields": (
"deleted_at",
"hard_deleted_at",
)
},
),
(
_("Derived info"),
{
@@ -214,10 +248,18 @@ class FileAdmin(admin.ModelAdmin):
'<a href="{}" target="_blank" rel="noopener noreferrer">Open File</a>', url
)
def get_queryset(self, request):
"""Hide hard deleted files in admin listing and lookups."""
return super().get_queryset(request).filter(hard_deleted_at__isnull=True)
def delete_model(self, request, obj):
"""Hard delete instead of calling model.delete()."""
hard_delete_file(obj)
def delete_queryset(self, request, queryset):
"""Delete one by one so storage is cleaned up too."""
"""Hard delete all selected files."""
for file in queryset:
file.delete()
hard_delete_file(file)
def has_add_permission(self, request):
return False
-1
View File
@@ -71,7 +71,6 @@ def get_frontend_configuration(request):
"force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL,
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
"default_video_codec": settings.LIVEKIT_DEFAULT_VIDEO_CODEC,
},
"authenticated_users_can_edit_display_name": (
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
+1 -1
View File
@@ -134,7 +134,7 @@ class FilePermission(IsAuthenticated):
Return a 404 on deleted files or if the user is not the owner
"""
if obj.is_deleted:
if obj.deleted_at is not None or obj.hard_deleted_at is not None:
raise Http404
if obj.creator != request.user:
+2
View File
@@ -456,6 +456,7 @@ class ListFileSerializer(serializers.ModelSerializer):
"type",
"creator",
"deleted_at",
"hard_deleted_at",
"filename",
"upload_state",
"mimetype",
@@ -470,6 +471,7 @@ class ListFileSerializer(serializers.ModelSerializer):
"updated_at",
"creator",
"deleted_at",
"hard_deleted_at",
"filename",
"upload_state",
"mimetype",
+9 -11
View File
@@ -82,6 +82,7 @@ from core.services.room_roles import (
)
from core.services.subtitle import SubtitleException, SubtitleService
from core.tasks.connection_test import delete_connection_test_room
from core.tasks.file import process_file_deletion
from core.utils import generate_token
from ..authentication.livekit import LiveKitTokenAuthentication
@@ -908,15 +909,6 @@ class RoomViewSet(
"""Rename the current participant in the room."""
room = self.get_object()
if (
not settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
and request.user.is_authenticated
):
return drf_response.Response(
{"error": "Authenticated participants cannot edit their display name"},
status=drf_status.HTTP_403_FORBIDDEN,
)
serializer = serializers.RenameParticipantSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
@@ -1198,7 +1190,7 @@ class FileViewSet(
permission_classes = [
permissions.FilePermission,
]
queryset = models.File.objects.all()
queryset = models.File.objects.filter(hard_deleted_at__isnull=True)
default_serializer_class = serializers.FileSerializer
serializer_classes = {
"list": serializers.ListFileSerializer,
@@ -1352,7 +1344,7 @@ class FileViewSet(
)
if validation_error is not None:
file.delete()
self._complete_file_deletion(file)
else:
file.upload_state = models.FileUploadStateChoices.READY
file.mimetype = mimetype
@@ -1394,6 +1386,12 @@ class FileViewSet(
return drf_response.Response(serializer.data, status=drf_status.HTTP_200_OK)
def _complete_file_deletion(self, file):
"""Delete a file completely."""
file.soft_delete()
file.hard_delete()
transaction.on_commit(lambda: process_file_deletion.delay(file.id))
def _authorize_subrequest(self, request, pattern):
"""
Authorize access based on the original URL of an Nginx subrequest
@@ -286,10 +286,6 @@ class ResourceServerBackend(LaSuiteBackend):
if user is None and settings.OIDC_CREATE_USER:
user = self.create_user(sub)
if user is not None and not user.is_active:
logger.warning("Inactive user attempted authentication: %s", user.pk)
raise SuspiciousOperation("User account is disabled.")
return user
def create_user(self, sub):
-25
View File
@@ -1,25 +0,0 @@
"""Logging filters for the core application."""
import logging
from django.conf import settings
class SilenceExpected401(logging.Filter):
"""Drop the expected 401 from anonymous hits on the /me endpoint.
The frontend probes `/users/me/` to check authentication; a 401 for
anonymous users is normal, not a warning worth logging.
"""
def filter(self, record):
"""Return False for a 401 on a silenced path, True otherwise."""
if getattr(record, "status_code", None) != 401:
return True
request = getattr(record, "request", None)
path = getattr(request, "path", None)
if not path:
return True
return path not in settings.LOGGING_SILENCED_401_PATHS
@@ -6,6 +6,7 @@ from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from core.models import File, FileUploadStateChoices
from core.tasks.file import process_file_deletion
class Command(BaseCommand):
@@ -31,19 +32,16 @@ class Command(BaseCommand):
files = File.objects.filter(
upload_state=FileUploadStateChoices.PENDING,
created_at__lt=threshold,
hard_deleted_at__isnull=True,
)
count = 0
failed = []
for file in files.iterator():
try:
file.delete()
count += 1
except Exception as exc: # noqa: BLE001 # pylint: disable=broad-exception-caught
failed.append(file.pk)
self.stderr.write(f"[ERROR] Failed to clean file '{file.pk}': {exc}")
# This check shouldn't happen, but just in case we do it to avoid an error
if not file.deleted_at:
file.soft_delete()
file.hard_delete()
process_file_deletion(file.id)
count += 1
self.stdout.write(f"Cleaned {count} stale pending file(s).")
if failed:
raise CommandError(f"Failed to clean {len(failed)} file(s).")
@@ -3,33 +3,38 @@
from datetime import timedelta
from django.conf import settings
from django.core.management.base import BaseCommand, CommandError
from django.core.management.base import BaseCommand
from django.db.models import Q
from django.utils import timezone
from core.models import File
from core.tasks.file import process_file_deletion
class Command(BaseCommand):
"""Purge files (object storage and database object) whose trash bin retention has expired."""
"""
Purge deleted files (object storage and database object):
- files marked as hard deleted in database
- files marked as soft deleted and for which the trashbin retention period has expired
"""
help = "Purge deleted files"
def handle(self, *args, **options):
"""Delete files soft deleted for longer than the grace period."""
"""Browse purgeable files and queue them through the file deletion task."""
threshold = timezone.now() - timedelta(days=settings.FILE_PURGE_GRACE_DAYS)
is_hard_deleted = Q(hard_deleted_at__isnull=False)
is_purgeable = Q(
deleted_at__lte=timezone.now()
- timedelta(days=settings.FILE_PURGE_GRACE_DAYS)
)
count = 0
failed = []
for file in File.objects.filter(deleted_at__lte=threshold).iterator():
try:
file.delete()
count += 1
except Exception as exc: # noqa: BLE001 # pylint: disable=broad-exception-caught
failed.append(file.pk)
self.stderr.write(f"[ERROR] Failed to purge file '{file.pk}': {exc}")
for file in File.objects.filter(is_hard_deleted | is_purgeable).iterator():
if file.hard_deleted_at is None:
file.hard_delete()
process_file_deletion.delay(file.id)
count += 1
self.stdout.write(f"Purged {count} deleted file(s).")
if failed:
raise CommandError(f"Failed to purge {len(failed)} file(s).")
@@ -1,15 +0,0 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("core", "0022_user_default_room_access_level_and_more"),
]
operations = [
migrations.RemoveField(
model_name="file",
name="hard_deleted_at",
),
]
+50 -22
View File
@@ -17,8 +17,7 @@ from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.postgres.fields import ArrayField
from django.core import mail, validators
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.files.storage import default_storage
from django.db import models
from django.db import models, transaction
from django.utils import timezone
from django.utils.text import capfirst, slugify
from django.utils.translation import gettext_lazy as _
@@ -917,6 +916,7 @@ class File(BaseModel):
null=True,
)
deleted_at = models.DateTimeField(null=True, blank=True)
hard_deleted_at = models.DateTimeField(null=True, blank=True)
filename = models.CharField(max_length=255, null=False, blank=False)
@@ -954,10 +954,11 @@ class File(BaseModel):
return super().save(*args, **kwargs)
@property
def is_deleted(self):
"""Return whether the file is in the trash bin."""
return self.deleted_at is not None
def delete(self, using=None, keep_parents=False):
if self.deleted_at is None:
raise RuntimeError("The file must be soft deleted before being deleted.")
return super().delete(using, keep_parents)
@property
def is_ready(self):
@@ -1017,33 +1018,60 @@ class File(BaseModel):
"""
Compute and return abilities for a given user on the file.
"""
# Characteristics that are based only on specific access
is_creator = user == self.creator
can_edit = is_creator and not self.is_deleted
retrieve = is_creator
is_deleted = self.deleted_at is not None
can_update = is_creator and not is_deleted and user.is_authenticated
can_hard_delete = is_creator and user.is_authenticated
can_destroy = can_hard_delete and not is_deleted
return {
"destroy": can_edit,
"retrieve": is_creator,
"media_auth": can_edit,
"partial_update": can_edit,
"update": can_edit,
"upload_ended": can_edit,
"destroy": can_destroy,
"hard_delete": can_hard_delete,
"retrieve": retrieve,
"media_auth": retrieve and not is_deleted,
"partial_update": can_update,
"update": can_update,
"upload_ended": can_update and user.is_authenticated,
}
@transaction.atomic
def soft_delete(self):
"""Move the file to the trash bin."""
"""
Soft delete the file.
We still keep the .delete() method untouched for programmatic purposes.
"""
if self.deleted_at:
raise RuntimeError("This file is already deleted.")
self.deleted_at = timezone.now()
self.save(update_fields=["deleted_at"])
def delete(self, using=None, keep_parents=False):
def hard_delete(self):
"""
Remove the file's objects from storage, then its row from the database.
Hard delete the file.
We still keep the .delete() method untouched for programmatic purposes.
"""
if self.hard_deleted_at:
raise ValidationError(
{
"hard_deleted_at": ValidationError(
_("This file is already hard deleted."),
code="file_hard_delete_already_effective",
)
}
)
Storage is removed first so that a storage failure leaves the row in place
and the periodic purge commands retry it on their next run.
"""
default_storage.delete(self.temporary_file_key) # Pending
default_storage.delete(self.file_key) # Final
return super().delete(using, keep_parents)
if self.deleted_at is None:
raise ValidationError(
{
"hard_deleted_at": ValidationError(
_("To hard delete a file, it must first be soft deleted."),
code="file_hard_delete_should_soft_delete_first",
)
}
)
self.hard_deleted_at = timezone.now()
self.save(update_fields=["hard_deleted_at"])
+10 -8
View File
@@ -52,6 +52,12 @@ class InvalidPayloadError(LiveKitWebhookError):
status_code = 400
class UnsupportedEventTypeError(LiveKitWebhookError):
"""Unsupported event type."""
status_code = 422
class ActionFailedError(LiveKitWebhookError):
"""Webhook action fails to process or complete."""
@@ -68,7 +74,6 @@ class LiveKitWebhookEventType(Enum):
# Participant events
PARTICIPANT_JOINED = "participant_joined"
PARTICIPANT_LEFT = "participant_left"
PARTICIPANT_CONNECTION_ABORTED = "participant_connection_aborted"
# Track events
TRACK_PUBLISHED = "track_published"
@@ -148,13 +153,10 @@ class LiveKitEventsService:
try:
webhook_type = LiveKitWebhookEventType(data.event)
except ValueError:
logger.warning(
"Ignoring unknown LiveKit webhook event type '%s' for room '%s'",
data.event,
room_name,
)
return
except ValueError as e:
raise UnsupportedEventTypeError(
f"Unknown webhook type: {data.event}"
) from e
# Handle according to received webhook type
handler = self._webhook_handlers.get(webhook_type.value)
-8
View File
@@ -9,7 +9,6 @@ from uuid import UUID
from django.conf import settings
from django.core.cache import cache
from django.utils import timezone
from core import models, utils
@@ -47,7 +46,6 @@ class LobbyParticipant:
username: str
color: str
id: str
entered_at: str
def to_dict(self) -> Dict[str, str]:
"""Serialize the participant object to a dict representation."""
@@ -56,7 +54,6 @@ class LobbyParticipant:
"username": self.username,
"id": self.id,
"color": self.color,
"entered_at": self.entered_at,
}
@classmethod
@@ -71,7 +68,6 @@ class LobbyParticipant:
username=data["username"],
id=data["id"],
color=data["color"],
entered_at=data["entered_at"],
)
except (KeyError, ValueError) as e:
logger.exception("Error creating Participant from dict:")
@@ -207,7 +203,6 @@ class LobbyService:
username=username,
id=participant_id,
color=utils.generate_color(participant_id),
entered_at=timezone.now().isoformat(),
)
else:
participant.status = LobbyParticipantStatus.ACCEPTED
@@ -269,7 +264,6 @@ class LobbyService:
username=username,
id=participant_id,
color=color,
entered_at=timezone.now().isoformat(),
)
try:
@@ -344,8 +338,6 @@ class LobbyService:
self._index_remove(room_id, *dead_ids)
waiting_participants.sort(key=lambda p: p["entered_at"], reverse=True)
return tuple(waiting_participants)
def handle_participant_entry(
+5 -1
View File
@@ -1,5 +1,9 @@
"""Celery tasks for the core app."""
from core.tasks.connection_test import delete_connection_test_room
from core.tasks.file import process_file_deletion
__all__ = ("delete_connection_test_room",)
__all__ = (
"delete_connection_test_room",
"process_file_deletion",
)
+36
View File
@@ -0,0 +1,36 @@
"""
Tasks related to files.
"""
import logging
from django.core.files.storage import default_storage
from core.models import File
from core.tasks._task import task
logger = logging.getLogger(__name__)
@task
def process_file_deletion(file_id):
"""
Process the deletion of a file.
Definitely delete it in the database.
Delete the files from the storage.
"""
logger.info("Processing item deletion for %s", file_id)
try:
file = File.objects.get(id=file_id)
except File.DoesNotExist:
logger.error("Item %s does not exist", file_id)
return
if file.hard_deleted_at is None:
logger.error("To process an item deletion, it must be hard deleted first.")
return
logger.info("Deleting file %s", file.file_key)
default_storage.delete(file.file_key)
file.delete()
@@ -1,11 +1,9 @@
"""Tests for the clean_pending_files management command."""
from datetime import timedelta
from io import BytesIO, StringIO
from unittest.mock import patch
from django.core.files.storage import default_storage
from django.core.management import CommandError, call_command
from django.core.management import call_command
from django.utils import timezone
import pytest
@@ -25,32 +23,31 @@ def test_clean_pending_files_recent_pending_not_deleted():
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.PENDING,
upload_bytes=b"hello",
)
# A pending upload only lives under the temporary key
default_storage.save(file.temporary_file_key, BytesIO(b"hello"))
call_command("clean_pending_files")
file.refresh_from_db()
assert file.deleted_at is None
assert default_storage.exists(file.temporary_file_key)
assert default_storage.exists(file.file_key)
def test_clean_pending_files_old_pending_deleted():
"""Pending files older than the threshold should be deleted, temporary object included."""
"""Pending files older than the threshold should be deleted."""
old_date = timezone.now() - timedelta(hours=49)
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.PENDING,
upload_bytes=b"hello",
)
# A pending upload only lives under the temporary key
default_storage.save(file.temporary_file_key, BytesIO(b"hello"))
assert default_storage.exists(file.file_key)
models.File.objects.filter(pk=file.pk).update(created_at=old_date)
call_command("clean_pending_files")
assert not models.File.objects.filter(pk=file.pk).exists()
assert not default_storage.exists(file.temporary_file_key)
assert not default_storage.exists(file.file_key)
def test_clean_pending_files_old_non_pending_not_deleted():
@@ -66,6 +63,7 @@ def test_clean_pending_files_old_non_pending_not_deleted():
file.refresh_from_db()
assert file.deleted_at is None
assert file.hard_deleted_at is None
def test_clean_pending_files_custom_hours():
@@ -74,8 +72,8 @@ def test_clean_pending_files_custom_hours():
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.PENDING,
upload_bytes=b"hello",
)
default_storage.save(file.temporary_file_key, BytesIO(b"hello"))
models.File.objects.filter(pk=file.pk).update(created_at=old_date)
# Default 24h threshold -> file not deleted
@@ -83,58 +81,10 @@ def test_clean_pending_files_custom_hours():
file.refresh_from_db()
assert file.deleted_at is None
assert default_storage.exists(file.temporary_file_key)
assert default_storage.exists(file.file_key)
# 8h threshold -> file deleted
call_command("clean_pending_files", "--hours=8")
assert not models.File.objects.filter(pk=file.pk).exists()
assert not default_storage.exists(file.temporary_file_key)
def test_clean_pending_files_storage_failure_keeps_row_and_continues():
"""
A storage failure on one file leaves its row in place for the next run,
does not stop the other files from being cleaned, and exits non-zero.
"""
out = StringIO()
err = StringIO()
old_date = timezone.now() - timedelta(hours=49)
failing_file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.PENDING,
)
default_storage.save(failing_file.temporary_file_key, BytesIO(b"hello"))
stale_file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.PENDING,
)
default_storage.save(stale_file.temporary_file_key, BytesIO(b"hello"))
models.File.objects.filter(pk__in=[failing_file.pk, stale_file.pk]).update(
created_at=old_date
)
original_delete = default_storage.delete
def flaky_delete(name):
if name == failing_file.temporary_file_key:
raise OSError("boom")
return original_delete(name)
with (
patch.object(default_storage, "delete", side_effect=flaky_delete),
pytest.raises(CommandError, match="Failed to clean 1 file"),
):
call_command("clean_pending_files", stdout=out, stderr=err)
assert "Cleaned 1 stale pending file(s)." in out.getvalue()
assert f"Failed to clean file '{failing_file.pk}': boom" in err.getvalue()
# The failing file is retried on the next run
assert models.File.objects.filter(pk=failing_file.pk).exists()
assert default_storage.exists(failing_file.temporary_file_key)
# The other file was cleaned despite the earlier failure
assert not models.File.objects.filter(pk=stale_file.pk).exists()
assert not default_storage.exists(stale_file.temporary_file_key)
assert not default_storage.exists(file.file_key)
@@ -6,12 +6,13 @@ from random import randint
from unittest.mock import patch
from django.core.files.storage import default_storage
from django.core.management import CommandError, call_command
from django.core.management import call_command
from django.utils import timezone
import pytest
from core import factories, models
from core.tasks.file import process_file_deletion
pytestmark = pytest.mark.django_db
@@ -24,7 +25,11 @@ def test_purge_deleted_files_no_deleted_files(django_assert_num_queries):
@pytest.mark.django_db(transaction=True)
def test_purge_deleted_files_success(settings):
"""Only soft deleted files past the grace period are purged."""
"""
Queue deletion for:
- hard-deleted files
- soft-deleted files past retention period + grace period.
"""
out = StringIO()
settings.FILE_PURGE_GRACE_DAYS = grace = randint(1, 20)
@@ -51,63 +56,30 @@ def test_purge_deleted_files_success(settings):
)
purgeable_file.soft_delete()
call_command("purge_deleted_files", stdout=out)
hard_deleted_file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
upload_bytes=b"hello",
)
hard_deleted_file.soft_delete()
hard_deleted_file.hard_delete()
assert "Purged 1 deleted file(s)." in out.getvalue()
with patch(
"core.management.commands.purge_deleted_files.process_file_deletion.delay",
side_effect=process_file_deletion,
) as mock_delay:
call_command("purge_deleted_files", stdout=out)
assert "Purged 2 deleted file(s)." in out.getvalue()
assert mock_delay.call_count == 2
called_ids = {call.args[0] for call in mock_delay.call_args_list}
assert called_ids == {purgeable_file.id, hard_deleted_file.id}
assert models.File.objects.filter(id=not_deleted_file.id).exists()
assert models.File.objects.filter(id=not_purgeable_file.id).exists()
assert not models.File.objects.filter(id=purgeable_file.id).exists()
assert not models.File.objects.filter(id=hard_deleted_file.id).exists()
assert default_storage.exists(not_deleted_file.file_key)
assert default_storage.exists(not_purgeable_file.file_key)
assert not default_storage.exists(purgeable_file.file_key)
@pytest.mark.django_db(transaction=True)
def test_purge_deleted_files_storage_failure_keeps_row_and_continues(settings):
"""
A storage failure on one file leaves its row in place for the next run,
does not stop the other files from being purged, and exits non-zero.
"""
out = StringIO()
err = StringIO()
settings.FILE_PURGE_GRACE_DAYS = 1
purge_now = timezone.now() - timedelta(days=2)
with patch("django.utils.timezone.now", return_value=purge_now):
failing_file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
upload_bytes=b"hello",
)
failing_file.soft_delete()
purgeable_file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
upload_bytes=b"hello",
)
purgeable_file.soft_delete()
original_delete = default_storage.delete
def flaky_delete(name):
if name == failing_file.file_key:
raise OSError("boom")
return original_delete(name)
with (
patch.object(default_storage, "delete", side_effect=flaky_delete),
pytest.raises(CommandError, match="Failed to purge 1 file"),
):
call_command("purge_deleted_files", stdout=out, stderr=err)
assert "Purged 1 deleted file(s)." in out.getvalue()
assert f"Failed to purge file '{failing_file.pk}': boom" in err.getvalue()
# The failing file is retried on the next run
assert models.File.objects.filter(id=failing_file.id).exists()
assert default_storage.exists(failing_file.file_key)
# The other file was purged despite the earlier failure
assert not models.File.objects.filter(id=purgeable_file.id).exists()
assert not default_storage.exists(purgeable_file.file_key)
assert not default_storage.exists(hard_deleted_file.file_key)
@@ -4,6 +4,8 @@ Tests for files API endpoint in meet's core app: list
from unittest import mock
from django.utils import timezone
import pytest
from faker import Faker
from rest_framework.pagination import PageNumberPagination
@@ -54,6 +56,14 @@ def test_api_files_list_format():
title="item 2",
)
# hard deleted item should not appear
factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
hard_deleted_at=timezone.now(),
title="hard deleted item",
creator=user,
)
response = client.get("/api/v1.0/files/")
assert response.status_code == 200
@@ -84,8 +94,10 @@ def test_api_files_list_format():
"size": None,
"description": None,
"deleted_at": None,
"hard_deleted_at": None,
"abilities": {
"destroy": True,
"hard_delete": True,
"media_auth": True,
"partial_update": True,
"retrieve": True,
@@ -3,7 +3,6 @@
import logging
from concurrent.futures import ThreadPoolExecutor
from io import BytesIO
from unittest import mock
from django.core.files.storage import default_storage
@@ -137,40 +136,6 @@ def test_api_file_upload_ended_mimetype_not_allowed(settings, caplog):
assert not models.File.objects.filter(id=file.id).exists()
assert not default_storage.exists(file.file_key)
assert not default_storage.exists(file.temporary_file_key)
def test_api_file_upload_ended_rejected_storage_failure_keeps_row(settings):
"""
When a rejected upload cannot be removed from storage, the row must survive
(reverted to pending) so the cleanup can be retried, and no phantom file
must be re-inserted under a new id.
"""
settings.FILE_UPLOAD_RESTRICTIONS = {
"background_image": {
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
"allowed_mimetypes": ["application/pdf"],
}
}
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory(
type=FileTypeChoices.BACKGROUND_IMAGE, filename="my_file.txt", creator=user
)
default_storage.save(file.temporary_file_key, BytesIO(b"my prose"))
with (
mock.patch.object(default_storage, "delete", side_effect=OSError("boom")),
pytest.raises(OSError, match="boom"),
):
client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
assert models.File.objects.count() == 1
file.refresh_from_db()
assert file.upload_state == FileUploadStateChoices.PENDING
def test_api_file_upload_ended_mimetype_not_allowed_not_checking_mimetype(settings):
@@ -308,7 +273,6 @@ def test_api_upload_ended_file_size_exceeded(settings, caplog):
assert not models.File.objects.filter(id=file.id).exists()
assert not default_storage.exists(file.file_key)
assert not default_storage.exists(file.temporary_file_key)
@pytest.mark.django_db(transaction=True)
@@ -9,7 +9,6 @@ from unittest import mock
from django.core.cache import cache
import pytest
from freezegun import freeze_time
from rest_framework.test import APIClient
from ... import utils
@@ -25,7 +24,6 @@ pytestmark = pytest.mark.django_db
# Tests for request_entry endpoint
@freeze_time("2025-01-01 10:00:00")
def test_request_entry_anonymous(settings):
"""Anonymous users should be allowed to request entry to a room."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
@@ -61,7 +59,6 @@ def test_request_entry_anonymous(settings):
"username": "test_user",
"status": "waiting",
"color": "mocked-color",
"entered_at": "2025-01-01T10:00:00+00:00",
"livekit": None,
}
@@ -74,7 +71,6 @@ def test_request_entry_anonymous(settings):
assert participant_data.get("username") == "test_user"
@freeze_time("2025-01-01 10:00:00")
def test_request_entry_authenticated_user(settings):
"""Authenticated users should be allowed to request entry."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
@@ -112,7 +108,6 @@ def test_request_entry_authenticated_user(settings):
"username": "test_user",
"status": "waiting",
"color": "mocked-color",
"entered_at": "2025-01-01T10:00:00+00:00",
"livekit": None,
}
@@ -125,7 +120,6 @@ def test_request_entry_authenticated_user(settings):
assert participant_data.get("username") == "test_user"
@freeze_time("2025-01-01 10:00:00")
def test_request_entry_with_existing_participants(settings):
"""Anonymous users should be allowed to request entry to a room with existing participants."""
# Create a restricted access room
@@ -144,7 +138,6 @@ def test_request_entry_with_existing_participants(settings):
"username": "user1",
"status": "waiting",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
},
)
cache.set(
@@ -154,7 +147,6 @@ def test_request_entry_with_existing_participants(settings):
"username": "user2",
"status": "accepted",
"color": "#654321",
"entered_at": "2025-01-01T10:00:00+00:00",
},
)
@@ -186,7 +178,6 @@ def test_request_entry_with_existing_participants(settings):
assert response.json() == {
"id": participant_id,
"username": "test_user",
"entered_at": "2025-01-01T10:00:00+00:00",
"status": "waiting",
"color": "mocked-color",
"livekit": None,
@@ -201,7 +192,6 @@ def test_request_entry_with_existing_participants(settings):
assert participant_data.get("username") == "test_user"
@freeze_time("2025-01-01 10:00:00")
def test_request_entry_public_room(settings):
"""Entry requests to public rooms should return ACCEPTED status with LiveKit config."""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
@@ -240,7 +230,6 @@ def test_request_entry_public_room(settings):
assert response.json() == {
"id": "123",
"username": "test_user",
"entered_at": "2025-01-01T10:00:00+00:00",
"status": "accepted",
"color": "mocked-color",
"livekit": {"token": "test-token"},
@@ -251,7 +240,6 @@ def test_request_entry_public_room(settings):
assert not lobby_keys
@freeze_time("2025-01-01 10:00:00")
def test_request_entry_authenticated_user_public_room(settings):
"""While authenticated, entry request to public rooms should get accepted."""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
@@ -294,7 +282,6 @@ def test_request_entry_authenticated_user_public_room(settings):
assert response.json() == {
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"username": "test_user",
"entered_at": "2025-01-01T10:00:00+00:00",
"status": "accepted",
"color": "mocked-color",
"livekit": {"token": "test-token"},
@@ -305,7 +292,6 @@ def test_request_entry_authenticated_user_public_room(settings):
assert not lobby_keys
@freeze_time("2025-01-01 10:00:00")
def test_request_entry_waiting_participant_public_room(settings):
"""While waiting, entry request to public rooms should get accepted."""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
@@ -322,7 +308,6 @@ def test_request_entry_waiting_participant_public_room(settings):
"username": "user1",
"status": "waiting",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
},
)
@@ -353,7 +338,6 @@ def test_request_entry_waiting_participant_public_room(settings):
"username": "user1",
"status": "accepted",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
"livekit": {"token": "test-token"},
}
@@ -459,7 +443,6 @@ def test_allow_participant_to_enter_success(settings, allow_entry, updated_statu
"status": "waiting",
"username": "foo",
"color": "123",
"entered_at": "2025-01-01T10:00:00+00:00",
},
)
@@ -595,7 +578,6 @@ def test_list_waiting_participants_success(settings):
"username": "user1",
"status": "waiting",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
},
)
cache.set(
@@ -605,7 +587,6 @@ def test_list_waiting_participants_success(settings):
"username": "user2",
"status": "waiting",
"color": "#654321",
"entered_at": "2025-01-01T10:05:00+00:00",
},
)
lobby_service = LobbyService()
@@ -616,24 +597,21 @@ def test_list_waiting_participants_success(settings):
assert response.status_code == 200
assert response.json() == {
"participants": [
{
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
"username": "user2",
"status": "waiting",
"color": "#654321",
"entered_at": "2025-01-01T10:05:00+00:00",
},
{
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"username": "user1",
"status": "waiting",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
},
]
}
participants = response.json().get("participants")
assert sorted(participants, key=lambda p: p["id"]) == [
{
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"username": "user1",
"status": "waiting",
"color": "#123456",
},
{
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
"username": "user2",
"status": "waiting",
"color": "#654321",
},
]
def test_list_waiting_participants_empty(settings):
@@ -372,67 +372,6 @@ def test_rename_participant_unexpected_twirp_error(mock_livekit_client, room, to
mock_livekit_client.aclose.assert_called_once()
@pytest.mark.parametrize("name", ["John Doe", "Admin", "Room Owner"])
def test_rename_participant_forbidden_when_display_name_edit_disabled(
mock_livekit_client, settings, room, token, name
):
"""
Test rename is rejected for authenticated users when the self-hoster
disables AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME.
"""
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url, {"name": name}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.data == {
"error": "Authenticated participants cannot edit their display name"
}
mock_livekit_client.room.update_participant.assert_not_called()
def test_rename_participant_allowed_when_display_name_edit_enabled(
mock_livekit_client, settings, room, token
):
"""Test rename still works for authenticated users when the setting is enabled."""
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = True
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
)
assert response.status_code == status.HTTP_200_OK
mock_livekit_client.room.update_participant.assert_called_once()
def test_rename_participant_anonymous_allowed_when_display_name_edit_disabled(
mock_livekit_client, settings, room, anonymous_token
):
"""
Test the setting only restricts authenticated users: anonymous participants
have no account name to fall back on and can still rename themselves.
"""
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url,
{"name": "Guest User"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
)
assert response.status_code == status.HTTP_200_OK
mock_livekit_client.room.update_participant.assert_called_once()
def test_rename_participant_success_anonymous(
mock_livekit_client, room, anonymous_token
):
@@ -94,7 +94,7 @@ def test_invalid_payload(client, auth_token, mock_livekit_config):
def test_unknown_event_type(client, mock_livekit_config):
"""Should acknowledge (200) an unknown event type rather than reject it."""
"""Should return 422 for unknown event type."""
event_data = json.dumps({"event": "unknown_event_type"})
# Generate auth token for this specific payload
@@ -112,8 +112,10 @@ def test_unknown_event_type(client, mock_livekit_config):
HTTP_AUTHORIZATION=auth_token,
)
assert response.status_code == 200
assert response.json() == {"status": "success"}
assert response.status_code == 422
assert response.json() == {
"status": "error",
}
@mock.patch.object(LiveKitEventsService, "_handle_room_finished")
@@ -16,6 +16,7 @@ from core.services.livekit_events import (
AuthenticationError,
InvalidPayloadError,
LiveKitEventsService,
UnsupportedEventTypeError,
api,
)
from core.services.lobby import LobbyService
@@ -664,27 +665,22 @@ def test_receive_missing_auth(service):
@mock.patch.object(api.WebhookReceiver, "receive")
def test_receive_unknown_event_is_acknowledged(mock_receive, service, caplog):
"""Unknown event types are logged and ignored, not rejected.
LiveKit adds event types over time and does not retry 4xx responses, so
raising here would silently drop the event.
"""
def test_receive_unsupported_event(mock_receive, service):
"""Should raise LiveKitWebhookError for unsupported events."""
mock_request = mock.MagicMock()
mock_request.headers = {"Authorization": "test_token"}
mock_request.body = b"{}"
# Mock returned data with unsupported event type
mock_data = mock.MagicMock()
mock_data.room.name = str(uuid.uuid4())
mock_data.event = "some_future_event"
mock_data.event = "unsupported_event"
mock_receive.return_value = mock_data
with caplog.at_level("WARNING", logger="core.services.livekit_events"):
service.receive(mock_request) # must not raise
assert "Ignoring unknown LiveKit webhook event type 'some_future_event'" in (
caplog.text
)
with pytest.raises(
UnsupportedEventTypeError, match="Unknown webhook type: unsupported_event"
):
service.receive(mock_request)
@mock.patch.object(api.WebhookReceiver, "receive")
+3 -44
View File
@@ -14,7 +14,6 @@ from django.core.cache import cache
from django.http import HttpResponse
import pytest
from freezegun import freeze_time
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
from core.models import RoleChoices, RoomAccessLevel
@@ -56,7 +55,6 @@ def participant_dict():
"username": "test-username",
"id": "test-participant-id",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
}
@@ -68,7 +66,6 @@ def participant_data():
username="test-username",
id="test-participant-id",
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
)
@@ -80,7 +77,6 @@ def test_lobby_participant_to_dict(participant_data):
assert result["username"] == "test-username"
assert result["id"] == "test-participant-id"
assert result["color"] == "#123456"
assert result["entered_at"] == "2025-01-01T10:00:00+00:00"
def test_lobby_participant_from_dict_success(participant_dict):
@@ -91,20 +87,6 @@ def test_lobby_participant_from_dict_success(participant_dict):
assert participant.username == "test-username"
assert participant.id == "test-participant-id"
assert participant.color == "#123456"
assert participant.entered_at == "2025-01-01T10:00:00+00:00"
def test_lobby_participant_from_dict_missing_entered_at():
"""`entered_at` is mandatory; data without it is rejected."""
data = {
"status": "waiting",
"username": "test-username",
"id": "test-participant-id",
"color": "#123456",
}
with pytest.raises(LobbyParticipantParsingError, match="Invalid participant data"):
LobbyParticipant.from_dict(data)
def test_lobby_participant_from_dict_default_status():
@@ -113,7 +95,6 @@ def test_lobby_participant_from_dict_default_status():
"username": "test-username",
"id": "test-participant-id",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
}
participant = LobbyParticipant.from_dict(data_without_status)
@@ -139,7 +120,6 @@ def test_lobby_participant_from_dict_invalid_status():
"username": "test-username",
"id": "test-participant-id",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
}
with pytest.raises(LobbyParticipantParsingError, match="Invalid participant data"):
@@ -284,7 +264,6 @@ def test_request_entry_public_room(
username=username,
id=participant_id,
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
)
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
@@ -323,7 +302,6 @@ def test_request_entry_trusted_room(
username=username,
id=participant_id,
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
)
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
@@ -366,7 +344,6 @@ def test_request_entry_new_participant(
username=username,
id=participant_id,
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
)
mock_enter.return_value = participant_data
@@ -394,7 +371,6 @@ def test_request_entry_waiting_participant(
username=username,
id=participant_id,
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
)
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
@@ -423,7 +399,6 @@ def test_request_entry_accepted_participant(
username=username,
id=participant_id,
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
)
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
@@ -464,7 +439,6 @@ def test_request_entry_participant_with_role(
username=username,
id=participant_id,
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
)
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
@@ -505,7 +479,6 @@ def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
@mock.patch("core.utils.generate_color")
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.lobby.LobbyService._index_add")
@freeze_time("2025-01-01 10:00:00")
def test_enter_success(
mock_index_add,
mock_notify,
@@ -527,7 +500,6 @@ def test_enter_success(
assert participant.username == username
assert participant.id == participant_id
assert participant.color == "#123456"
assert participant.entered_at == "2025-01-01T10:00:00+00:00"
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
@@ -657,7 +629,6 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
"username": "user1",
"id": "participant1",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
}
participant2 = {
@@ -665,7 +636,6 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
"username": "user2",
"id": "participant2",
"color": "#654321",
"entered_at": "2025-01-01T10:05:00+00:00",
}
lobby_service._index_members = mock.Mock(
@@ -681,10 +651,9 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
assert len(result) == 2
# Most recent entry comes first
assert [p["id"] for p in result] == ["participant2", "participant1"]
assert result[0]["username"] == "user2"
assert result[1]["username"] == "user1"
# Verify both participants are in the result
assert any(p["id"] == "participant1" and p["username"] == "user1" for p in result)
assert any(p["id"] == "participant2" and p["username"] == "user2" for p in result)
# Verify all participants have waiting status
assert all(p["status"] == "waiting" for p in result)
@@ -720,7 +689,6 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
"username": "user2",
"id": "participant2",
"color": "#654321",
"entered_at": "2025-01-01T10:00:00+00:00",
}
corrupted_participant = {"invalid": "data"}
@@ -761,14 +729,12 @@ def test_list_waiting_participants_non_waiting(mock_cache, lobby_service):
"username": "user1",
"id": "participant1",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
}
participant2 = {
"status": "accepted",
"username": "user2",
"id": "participant2",
"color": "#654321",
"entered_at": "2025-01-01T10:00:00+00:00",
}
lobby_service._index_members = mock.Mock(
@@ -866,7 +832,6 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
"username": "test-username",
"id": participant_id,
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
}
mock_cache.get.return_value = participant_dict
@@ -885,7 +850,6 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
"username": "test-username",
"id": participant_id,
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
}
mock_cache.set.assert_called_once_with(
"mocked_cache_key", expected_data, timeout=60
@@ -911,7 +875,6 @@ def test_clear_room_cache(settings, lobby_service):
username="participant1",
id="participant1",
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
),
timeout=settings.LOBBY_WAITING_TIMEOUT,
)
@@ -922,7 +885,6 @@ def test_clear_room_cache(settings, lobby_service):
username="participant2",
id="participant2",
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
),
timeout=settings.LOBBY_ACCEPTED_TIMEOUT,
)
@@ -933,7 +895,6 @@ def test_clear_room_cache(settings, lobby_service):
username="participant3",
id="participant3",
color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
),
timeout=settings.LOBBY_DENIED_TIMEOUT,
)
@@ -969,7 +930,6 @@ def test_clear_participant_cache(lobby_service):
"username": "test-username",
"id": participant_id,
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
}
cache.set(cache_key, participant_data, timeout=settings.LOBBY_WAITING_TIMEOUT)
lobby_service._index_add(room_id, participant_id)
@@ -1040,7 +1000,6 @@ def test_list_waiting_participants_prunes_stale_index_ids(settings, lobby_servic
"username": "user1",
"status": "waiting",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
},
timeout=100,
)
@@ -1,96 +0,0 @@
"""Tests for the external API ResourceServerBackend."""
from django.core.exceptions import SuspiciousOperation
import pytest
import responses
from rest_framework.test import APIClient
from core.external_api.authentication import ResourceServerBackend
from core.factories import UserFactory
from core.models import User
pytestmark = pytest.mark.django_db
def _payload(sub):
return {"sub": sub, "active": True, "scope": "lasuite_meet", "client_id": "app"}
def test_resource_server_backend_get_or_create_user_active():
"""An existing active user matching the sub should be returned."""
user = UserFactory()
result = ResourceServerBackend().get_or_create_user(
access_token="token", id_token=None, payload=_payload(user.sub)
)
assert result == user
def test_resource_server_backend_get_or_create_user_inactive():
"""An inactive user should be rejected even with a valid token."""
user = UserFactory(is_active=False)
with pytest.raises(SuspiciousOperation, match="User account is disabled."):
ResourceServerBackend().get_or_create_user(
access_token="token", id_token=None, payload=_payload(user.sub)
)
def test_resource_server_backend_get_or_create_user_creates(settings):
"""An unknown sub should create an active user when OIDC_CREATE_USER is set."""
settings.OIDC_CREATE_USER = True
result = ResourceServerBackend().get_or_create_user(
access_token="token", id_token=None, payload=_payload("new-sub")
)
assert result.sub == "new-sub"
assert result.is_active is True
assert User.objects.filter(sub="new-sub").exists()
def test_resource_server_backend_get_or_create_user_no_creation(settings):
"""An unknown sub should return None when OIDC_CREATE_USER is unset."""
settings.OIDC_CREATE_USER = False
result = ResourceServerBackend().get_or_create_user(
access_token="token", id_token=None, payload=_payload("new-sub")
)
assert result is None
assert not User.objects.filter(sub="new-sub").exists()
@responses.activate
def test_api_rooms_list_resource_server_inactive_user(settings):
"""End to end: a valid introspected token for an inactive user should get 401."""
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
settings.OIDC_OP_URL = "https://oidc.example.com"
user = UserFactory(is_active=False)
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"active": True,
"sub": user.sub,
"scope": "openid lasuite_meet rooms:list",
"client_id": "app",
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer rs-token")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "login failed" in str(response.data).lower()
@@ -1,75 +0,0 @@
"""
Unit tests for the File model deletion flow
"""
from io import BytesIO
from unittest import mock
from django.core.files.storage import default_storage
from django.utils import timezone
import pytest
from core.factories import FileFactory
from core.models import File
pytestmark = pytest.mark.django_db
def test_models_files_soft_delete():
"""Soft deleting should only set the deletion timestamp."""
file = FileFactory()
assert file.is_deleted is False
file.soft_delete()
file.refresh_from_db()
assert file.is_deleted is True
assert FileFactory(deleted_at=timezone.now()).is_deleted is True
def test_models_files_soft_delete_twice():
"""Soft deleting an already soft deleted file should fail."""
file = FileFactory()
file.soft_delete()
with pytest.raises(RuntimeError, match="already deleted"):
file.soft_delete()
def test_models_files_delete():
"""Deleting should remove the row and both the final and temporary objects."""
file = FileFactory(upload_bytes=b"hello")
default_storage.save(file.temporary_file_key, BytesIO(b"hello"))
# Captured up front: Django nulls the pk after delete, and the keys depend on it
pk, key, temporary_key = file.pk, file.file_key, file.temporary_file_key
file.delete()
assert not File.objects.filter(pk=pk).exists()
assert not default_storage.exists(key)
assert not default_storage.exists(temporary_key)
def test_models_files_delete_without_storage_object():
"""Deleting a file that has nothing in storage should still remove the row."""
file = FileFactory()
pk = file.pk
file.delete()
assert not File.objects.filter(pk=pk).exists()
def test_models_files_delete_storage_failure_keeps_row():
"""A storage failure must leave the row in place so the deletion can be retried."""
file = FileFactory(upload_bytes=b"hello")
with (
mock.patch.object(default_storage, "delete", side_effect=OSError("boom")),
pytest.raises(OSError, match="boom"),
):
file.delete()
assert File.objects.filter(pk=file.pk).exists()
assert default_storage.exists(file.file_key)
@@ -1,22 +0,0 @@
"""Unit tests for the LIVEKIT_DEFAULT_VIDEO_CODEC setting value."""
import pytest
from meet.settings import VideoCodecValue
@pytest.mark.parametrize(
"raw,expected",
[("vp9", "vp9"), ("AV1", "av1"), (" h264 ", "h264")],
)
def test_video_codec_value_normalizes(raw, expected):
"""Whitespace is trimmed and the name is lowercased before it is checked."""
# environ=False keeps __new__ from resolving the value, so the instance survives.
assert VideoCodecValue(environ=False).to_python(raw) == expected
@pytest.mark.parametrize("raw", ["vp10", "", "h.264"])
def test_video_codec_value_rejects_unsupported(raw):
"""A name outside the accepted list stops the settings module loading."""
with pytest.raises(ValueError, match="Unsupported video codec"):
VideoCodecValue(environ=False).to_python(raw)
+1 -53
View File
@@ -53,26 +53,6 @@ def get_release():
return "NA" # Default: not available
class VideoCodecValue(values.Value):
"""
A video codec name, normalized to lowercase and validated against the codecs
supported by the LiveKit client, so that a typo fails at startup instead of
silently downgrading every publisher to another codec.
"""
codecs = frozenset(("vp8", "h264", "vp9", "av1"))
def to_python(self, value):
"""Normalize the codec name and ensure it is a supported one."""
codec = super().to_python(value).strip().lower()
if codec not in self.codecs:
raise ValueError(
f"Unsupported video codec {value!r}, "
f"expected one of: {', '.join(sorted(self.codecs))}."
)
return codec
class Base(Configuration):
"""
This is the base configuration every configuration (aka environment) should inherit from. It
@@ -489,9 +469,6 @@ class Base(Configuration):
# Sentry
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN")
SENTRY_TRACES_SAMPLE_RATE = values.FloatValue(
0.0, environ_name="SENTRY_TRACES_SAMPLE_RATE", environ_prefix=None
)
# Easy thumbnails
THUMBNAIL_EXTENSION = "webp"
@@ -698,9 +675,6 @@ class Base(Configuration):
environ_prefix=None,
default=False,
)
LIVEKIT_DEFAULT_VIDEO_CODEC = VideoCodecValue(
"vp9", environ_name="LIVEKIT_DEFAULT_VIDEO_CODEC", environ_prefix=None
)
CONNECTION_TEST_ENABLED = values.BooleanValue(
environ_name="CONNECTION_TEST_ENABLED",
environ_prefix=None,
@@ -1136,12 +1110,6 @@ class Base(Configuration):
environ_prefix=None,
)
LOGGING_SILENCED_401_PATHS = values.ListValue(
default=["/api/v1.0/users/me/"],
environ_name="LOGGING_SILENCED_401_PATHS",
environ_prefix=None,
)
# Logging
# We want to make it easy to log to console but by default we log production
# to Sentry and don't want to log to console.
@@ -1154,16 +1122,10 @@ class Base(Configuration):
"style": "{",
},
},
"filters": {
"silence_expected_401": {
"()": "core.logging_filters.SilenceExpected401",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "simple",
"filters": ["silence_expected_401"],
},
},
# Override root logger to send it to console
@@ -1174,13 +1136,6 @@ class Base(Configuration):
),
},
"loggers": {
"request.summary": {
"level": values.Value(
"WARNING",
environ_name="LOGGING_LEVEL_REQUEST_SUMMARY",
environ_prefix="",
)
},
"core": {
"handlers": ["console"],
"level": values.Value(
@@ -1256,14 +1211,7 @@ class Base(Configuration):
dsn=cls.SENTRY_DSN,
environment=cls.__name__.lower(), # build, test, development, production
release=get_release(),
traces_sample_rate=cls.SENTRY_TRACES_SAMPLE_RATE,
integrations=[
DjangoIntegration(
transaction_style="url",
middleware_spans=True,
cache_spans=True,
)
],
integrations=[DjangoIntegration()],
)
sentry_sdk.set_tag("application", "backend")
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project]
name = "meet"
version = "1.31.0"
version = "1.30.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
+1 -1
View File
@@ -1187,7 +1187,7 @@ wheels = [
[[package]]
name = "meet"
version = "1.31.0"
version = "1.30.0"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+3 -3
View File
@@ -4,12 +4,12 @@ USER node
WORKDIR /home/frontend/
COPY --chown=node:node ./src/frontend/package.json ./package.json
COPY --chown=node:node ./src/frontend/package-lock.json ./package-lock.json
COPY ./src/frontend/package.json ./package.json
COPY ./src/frontend/package-lock.json ./package-lock.json
RUN npm ci
COPY --chown=node:node .dockerignore ./.dockerignore
COPY .dockerignore ./.dockerignore
COPY --chown=node:node ./src/frontend/ .
### ---- Front-end builder image ----
+45 -38
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "1.31.0",
"version": "1.30.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "1.31.0",
"version": "1.30.0",
"dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
"@fontsource-variable/lexend": "5.3.0",
@@ -23,18 +23,18 @@
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.1.2",
"hoofd": "1.7.3",
"humanize-duration": "3.34.1",
"humanize-duration": "3.33.2",
"i18next": "26.3.6",
"i18next-browser-languagedetector": "8.2.1",
"i18next-parser": "9.4.0",
"i18next-resources-to-backend": "1.2.3",
"livekit-client": "2.21.0",
"posthog-js": "1.418.10",
"posthog-js": "1.414.0",
"react": "18.3.1",
"react-aria": "3.50.0",
"react-aria-components": "1.19.0",
"react-dom": "18.3.1",
"react-i18next": "17.0.12",
"react-i18next": "17.0.10",
"react-stately": "3.48.0",
"use-sound": "5.0.0",
"valtio": "2.3.2",
@@ -1720,28 +1720,28 @@
"license": "MIT"
},
"node_modules/@posthog/browser-common": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.5.2.tgz",
"integrity": "sha512-8GvfEshFdeKIccuy3kpp6mDBxawQtRamMYRCwzy1r1ixLKQVLAGs3afhOf6r75yp59ZQBi5mtXQgUJ2Jz8eHuw==",
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.4.0.tgz",
"integrity": "sha512-W9DCGVks15docUMPvJ2nd8NS16Gn74bsGWuaeg31beEKFSjdW8wvnQ1ETY6WSql5pYxZb3GdJmEUZVVstKSrBQ==",
"license": "MIT",
"dependencies": {
"@posthog/core": "^1.48.11",
"@posthog/types": "^1.405.3"
"@posthog/core": "^1.46.8",
"@posthog/types": "^1.402.0"
}
},
"node_modules/@posthog/core": {
"version": "1.53.2",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.53.2.tgz",
"integrity": "sha512-Knx8442G2LyPVIzLvhcBX/TIdeHpZhrYrtZou3Uw8FWfbr9gQtQeOZDCog3MWcDxJ+4vAnMAAOzf5cYpc7Gxyw==",
"version": "1.49.1",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.49.1.tgz",
"integrity": "sha512-jdZh85tG56OXLH881CVwBZyiXCPPaZasfYeWwm9kVUvxC/Rb+lz7wYN9GuqSEmNPJgVnK4v8wS0bCaFc3OmVEA==",
"license": "MIT",
"dependencies": {
"@posthog/types": "^1.411.1"
"@posthog/types": "^1.407.0"
}
},
"node_modules/@posthog/types": {
"version": "1.411.1",
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.411.1.tgz",
"integrity": "sha512-Gd7tnSYctcSXup3naVlAgavvenByRao6rsSYdMgWgP35KE9jFn+rMHNRJeI5LNjJuLzMXHEVcgzAN7xcATefdw==",
"version": "1.407.1",
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.407.1.tgz",
"integrity": "sha512-WhbkXPC2rgylXqmxHqv70ffI3k+KxyR6s7DBIfr5NvIqHkxp6v0pk31D/jbz0DNVbzwkLjyll2pxr4FNbJiYzg==",
"license": "MIT"
},
"node_modules/@react-aria/overlays": {
@@ -6354,12 +6354,11 @@
"integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w=="
},
"node_modules/html-parse-stringify": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz",
"integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==",
"license": "MIT",
"funding": {
"url": "https://locize.com"
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/htmlparser2": {
@@ -6402,9 +6401,9 @@
}
},
"node_modules/humanize-duration": {
"version": "3.34.1",
"resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.34.1.tgz",
"integrity": "sha512-YIiigjQ+O31rvcyDJPK1ptTZtvSErjBtmHy93VhbwxB/VwMG0FszKs2IB667tFxmOhNapePQQxa24luQRDkKFQ==",
"version": "3.33.2",
"resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.33.2.tgz",
"integrity": "sha512-K7Ny/ULO1hDm2nnhvAY+SJV1skxFb61fd073SG1IWJl+D44ULrruCuTyjHKjBVVcSuTlnY99DKtgEG39CM5QOQ==",
"license": "Unlicense",
"funding": {
"url": "https://github.com/sponsors/EvanHahn"
@@ -9132,16 +9131,16 @@
"license": "MIT"
},
"node_modules/posthog-js": {
"version": "1.418.10",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.418.10.tgz",
"integrity": "sha512-XMvmmnuFoesSPjFo+wvzXkvuzYqIho8CVqxugG1Wt768offFARYNZDd1/xWfm9vk/pJTJ4RhUEHRzLpggmGx9Q==",
"version": "1.414.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.414.0.tgz",
"integrity": "sha512-dtZd4asdskr8lNyltAEX6zyn48uO1pO0EMvx6AXJU65PFhu6yn2LPbKtQcyLysjcN57FJPNT9QYL6St5SBJHqw==",
"license": "(Apache-2.0 AND MIT)",
"dependencies": {
"@posthog/browser-common": "^0.5.0",
"@posthog/core": "^1.48.8",
"@posthog/types": "^1.405.1",
"@posthog/browser-common": "^0.4.0",
"@posthog/core": "^1.46.9",
"@posthog/types": "^1.402.2",
"core-js": "^3.49.0",
"dompurify": "^3.4.13",
"dompurify": "^3.4.12",
"fflate": "^0.4.8",
"preact": "^10.29.3",
"query-selector-shadow-dom": "^1.0.1",
@@ -9436,13 +9435,13 @@
}
},
"node_modules/react-i18next": {
"version": "17.0.12",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.12.tgz",
"integrity": "sha512-lFWPEGkxQ6RhusdUkysFBD58VHfSSzvHBzqMgN0SvfVpdQGfwtNkStTqdy08/sJd7s807qqutgx93fRpD0DJ3Q==",
"version": "17.0.10",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz",
"integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.7",
"html-parse-stringify": "^4.0.1",
"@babel/runtime": "^7.29.2",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
@@ -11609,6 +11608,14 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/walk-sync": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/walk-sync/-/walk-sync-2.2.0.tgz",
+4 -4
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.31.0",
"version": "1.30.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -30,18 +30,18 @@
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.1.2",
"hoofd": "1.7.3",
"humanize-duration": "3.34.1",
"humanize-duration": "3.33.2",
"i18next": "26.3.6",
"i18next-browser-languagedetector": "8.2.1",
"i18next-parser": "9.4.0",
"i18next-resources-to-backend": "1.2.3",
"livekit-client": "2.21.0",
"posthog-js": "1.418.10",
"posthog-js": "1.414.0",
"react": "18.3.1",
"react-aria": "3.50.0",
"react-aria-components": "1.19.0",
"react-dom": "18.3.1",
"react-i18next": "17.0.12",
"react-i18next": "17.0.10",
"react-stately": "3.48.0",
"use-sound": "5.0.0",
"valtio": "2.3.2",
+1 -2
View File
@@ -3,7 +3,7 @@ import { keys } from './queryKeys'
import { useQuery } from '@tanstack/react-query'
import { RecordingMode } from '@/features/recording'
import type { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
import type { Track, VideoCodec } from 'livekit-client'
import type { Track } from 'livekit-client'
type Source = Track.Source
export interface ApiConfig {
@@ -62,7 +62,6 @@ export interface ApiConfig {
force_wss_protocol: boolean
enable_firefox_proxy_workaround: boolean
default_sources: Source[]
default_video_codec: VideoCodec
}
transcription_destination?: string
max_participants_for_sound: number
@@ -15,6 +15,7 @@ export type ApiFileItem = {
type: ApiFileType
creator: ApiFileCreator
deleted_at: string | null
hard_deleted_at: string | null
filename: string
upload_state: ApiFileUploadState
mimetype: string // e.g. "image/png"
@@ -8,7 +8,6 @@ export type WaitingParticipant = {
status: string
username: string
color: string
entered_at: string
}
export type WaitingParticipantsResponse = {
@@ -9,14 +9,6 @@ import {
} from '../../participants/api/listWaitingParticipants'
import { reportError } from '@/features/analytics/telemetry'
const toTimestamp = (participant: WaitingParticipant): number =>
Date.parse(participant.entered_at)
export const sortWaitingParticipants = (
participants: WaitingParticipant[]
): WaitingParticipant[] =>
[...participants].sort((a, b) => toTimestamp(a) - toTimestamp(b))
export const useWaitingParticipants = () => {
const roomData = useRoomData()
const roomId = roomData?.id || '' // FIXME - bad practice
@@ -30,10 +22,7 @@ export const useWaitingParticipants = () => {
})
const waitingParticipants = useMemo(
() =>
canManageLobby
? sortWaitingParticipants(waitingData?.participants || [])
: [],
() => (canManageLobby ? waitingData?.participants || [] : []),
[waitingData, canManageLobby]
)
@@ -107,7 +107,7 @@ export const Conference = ({
adaptiveStream: true,
dynacast: true,
publishDefaults: {
videoCodec: apiConfig?.livekit.default_video_codec ?? 'vp9',
videoCodec: 'vp9',
},
videoCaptureDefaults: {
deviceId: userConfig.videoDeviceId ?? undefined,
@@ -129,7 +129,6 @@ export const Conference = ({
userConfig.videoPublishResolution,
userConfig.audioDeviceId,
userConfig.audioOutputDeviceId,
apiConfig?.livekit.default_video_codec,
])
const room = useMemo(() => new Room(roomOptions), [roomOptions])
@@ -79,7 +79,7 @@ export const LobbyProvider = () => {
// 3. Rights regained.
const prevCanManageLobby = usePrevious(canManageLobby)
useEffect(() => {
if (prevCanManageLobby != canManageLobby && isConnected) {
if (!prevCanManageLobby && canManageLobby && isConnected) {
fetchIfManager()
}
}, [
@@ -16,7 +16,7 @@ const Card = styled('div', {
borderRadius: '0.25rem',
boxShadow: '',
width: '100%',
maxWidth: '410px',
maxWidth: '380px',
minHeight: '196px',
},
})
@@ -229,7 +229,7 @@ const ConfirmationMessage = ({ onNext }: { onNext: () => void }) => {
return (
<Card
style={{
maxWidth: '410px',
maxWidth: '380px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
@@ -71,30 +71,20 @@ export const ConnectionObserver = () => {
useEffect(() => {
if (!isAnalyticsEnabled) return
const handleConnection = async () => {
const handleConnection = () => {
// Preserve original connection timestamp across reconnections to measure
// total session duration from first connect to final disconnect.
if (connectionStartTimeRef.current != null) return
connectionStartTimeRef.current = Date.now()
const participantSid = room.localParticipant.sid
const roomSid = await room.getSid().catch(() => undefined)
void captureMediaEvent('connection-event', {
livekit_room_sid: roomSid,
livekit_participant_sid: participantSid,
})
void captureMediaEvent('connection-event', {})
}
const handleReconnect = () => {
captureEvent('reconnect-event')
}
const handleReconnected = async () => {
const participantSid = room.localParticipant.sid
const roomSid = await room.getSid().catch(() => undefined)
captureEvent('reconnected-event', {
livekit_room_sid: roomSid,
livekit_participant_sid: participantSid,
})
const handleReconnected = () => {
captureEvent('reconnected-event')
}
const handleSignalingConnect = () => {
@@ -2,23 +2,23 @@ import { RiQuestionLine } from '@remixicon/react'
import { MenuItem } from 'react-aria-components'
import { useTranslation } from 'react-i18next'
import { menuRecipe } from '@/primitives/menuRecipe'
import {
useIsSupportEnabled,
openSupportChat,
} from '@/features/support/hooks/useSupport'
import { Crisp } from 'crisp-sdk-web'
import { useIsSupportEnabled } from '@/features/support/hooks/useSupport'
export const SupportMenuItem = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
const isSupportEnabled = useIsSupportEnabled()
if (!isSupportEnabled) {
if (!isSupportEnabled || !Crisp) {
return
}
return (
<MenuItem
className={menuRecipe({ icon: true, variant: 'dark' }).item}
onAction={openSupportChat}
onAction={() => {
Crisp?.chat.open()
}}
>
<RiQuestionLine size={20} />
{t('support')}
@@ -24,8 +24,7 @@ const Heading = styled('h1', {
})
const buttonClass = css({
width: '100%',
flex: 1,
width: { base: '100%', xsm: 'auto' },
})
enum DisconnectReasonKey {
@@ -65,7 +64,7 @@ const FeedbackRoute = () => {
<Heading>{t(`feedback.heading.${reasonKey || 'normal'}`)}</Heading>
<Stack
direction={{ base: 'column', xsm: 'row' }}
width="100%"
width={{ base: '100%', xsm: 'auto' }}
maxWidth="410px"
>
{showBackButton && (
@@ -1,45 +1,20 @@
import { useEffect, useState } from 'react'
import { useEffect } from 'react'
import { Crisp } from 'crisp-sdk-web'
import { type ApiUser } from '@/features/auth/api/ApiUser'
import { useUser } from '@/features/auth/api/useUser'
import { useConfig } from '@/api/useConfig'
type CrispSdk = (typeof import('crisp-sdk-web'))['Crisp']
let crisp: CrispSdk | undefined
let crispPromise: Promise<CrispSdk> | undefined
const loadCrisp = (): Promise<CrispSdk> => {
crispPromise ??= import('crisp-sdk-web')
.then((module) => {
crisp = module.Crisp
return module.Crisp
})
.catch((error) => {
crispPromise = undefined
throw error
})
return crispPromise
}
export const openSupportChat = () => {
if (!crisp?.isCrispInjected()) return
crisp.chat.open()
}
export const initializeSupportSession = (user: ApiUser) => {
if (!crisp?.isCrispInjected()) return
if (!Crisp.isCrispInjected()) return
const { id, email } = user
crisp.setTokenId(`meet-${id}`)
if (email) crisp.user.setEmail(email)
Crisp.setTokenId(`meet-${id}`)
if (email) Crisp.user.setEmail(email)
}
export const terminateSupportSession = () => {
if (!crisp?.isCrispInjected()) return
crisp.setTokenId()
crisp.session.reset()
if (!Crisp.isCrispInjected()) return
Crisp.setTokenId()
Crisp.session.reset()
}
export type useSupportProps = {
@@ -47,70 +22,26 @@ export type useSupportProps = {
isDisabled?: boolean
}
const IDLE_TIMEOUT_MS = 10_000
const scheduleWhenIdle = (callback: () => void): (() => void) => {
if (typeof window.requestIdleCallback === 'function') {
const handle = window.requestIdleCallback(callback, {
timeout: IDLE_TIMEOUT_MS,
})
return () => window.cancelIdleCallback(handle)
}
const handle = window.setTimeout(callback, 1)
return () => window.clearTimeout(handle)
}
// Configure Crisp chat for real-time support across all pages.
export const useSupport = ({ id, isDisabled }: useSupportProps) => {
const { user } = useUser()
const [isInjected, setIsInjected] = useState(
() => crisp?.isCrispInjected() ?? false
)
useEffect(() => {
if (!id || isDisabled) return
if (crisp?.isCrispInjected()) {
setIsInjected(true)
return
}
let cancelled = false
const cancelIdle = scheduleWhenIdle(() => {
void loadCrisp()
.then((sdk) => {
if (cancelled) return
if (!sdk.isCrispInjected()) {
sdk.configure(id)
sdk.setHideOnMobile(true)
}
setIsInjected(true)
})
.catch((error) => {
if (!cancelled) {
console.error('Failed to initialize support chat', error)
}
})
})
return () => {
cancelled = true
cancelIdle()
}
if (!id || Crisp.isCrispInjected() || isDisabled) return
Crisp.configure(id)
Crisp.setHideOnMobile(true)
}, [id, isDisabled])
useEffect(() => {
if (!user || !isInjected || isDisabled) return
if (!user) return
initializeSupportSession(user)
}, [user, isInjected, isDisabled])
}, [user])
return null
}
// Some users block the chat widget, so check its availability safely.
// Some users may block Crisp chat widget with browser ad blockers or anti-tracking plugins
// So we need to safely check if Crisp is available and not blocked
const isCrispAvailable = () => {
try {
return !!window?.$crisp?.is
+1 -1
View File
@@ -480,7 +480,7 @@
"destination": "Ein neues Dokument wird erstellt auf",
"destinationUnknown": "Ein neues Dokument wird erstellt",
"language": "Meeting-Sprache:",
"recording": "Auch eine Videoaufzeichnung starten"
"recording": "Auch eine Aufzeichnung starten"
},
"button": {
"start": "Meeting-Transkription starten",
+1 -1
View File
@@ -480,7 +480,7 @@
"destination": "A new document will be created on",
"destinationUnknown": "A new document will be created",
"language": "Meeting language:",
"recording": "Also start a video recording"
"recording": "Also start a recording"
},
"button": {
"start": "Start transcribing the meeting",
+1 -1
View File
@@ -479,7 +479,7 @@
"destination": "Se creará un nuevo documento en",
"destinationUnknown": "Se creará un nuevo documento",
"language": "Idioma de la reunión:",
"recording": "Iniciar también una grabación de vídeo"
"recording": "Iniciar también una grabación"
},
"button": {
"start": "Empezar a transcribir la reunión",
+1 -1
View File
@@ -480,7 +480,7 @@
"destination": "Un nouveau document sera créé sur",
"destinationUnknown": "Un nouveau document sera créé",
"language": "Langue de la réunion :",
"recording": "Démarrer aussi un enregistrement vidéo"
"recording": "Démarrer aussi un enregistrement"
},
"button": {
"start": "Commencer à transcrire la réunion",
+1 -1
View File
@@ -480,7 +480,7 @@
"destination": "Er wordt een nieuw document aangemaakt op",
"destinationUnknown": "Een nieuw document wordt aangemaakt",
"language": "Vergadertalen:",
"recording": "Start ook een video-opname"
"recording": "Start ook een opname"
},
"button": {
"start": "Begin met het transcriberen van de vergadering",
-1
View File
@@ -150,7 +150,6 @@ backend:
# Frontend
LIVEKIT_FORCE_WSS_PROTOCOL: True
LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND: True
LIVEKIT_DEFAULT_VIDEO_CODEC: vp9
FRONTEND_IDLE_DISCONNECT_WARNING_DELAY: 9000
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041', 'help_article_transcript': 'https://lasuite.crisp.help/fr/article/visio-transcript-1sjq43x', 'help_article_recording': 'https://lasuite.crisp.help/fr/article/visio-enregistrement-wgc8o0', 'help_article_more_tools': 'https://lasuite.crisp.help/fr/article/visio-tools-bvxj23'}"
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "1.31.0",
"version": "1.30.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "1.31.0",
"version": "1.30.0",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.6.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "1.31.0",
"version": "1.30.0",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sdk",
"version": "1.31.0",
"version": "1.30.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "1.31.0",
"version": "1.30.0",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "1.31.0",
"version": "1.30.0",
"author": "",
"license": "ISC",
"description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "1.31.0"
version = "1.30.0"
requires-python = ">=3.13"
dependencies = [
"fastapi[standard]>=0.105.0",
+1 -1
View File
@@ -1507,7 +1507,7 @@ wheels = [
[[package]]
name = "summary"
version = "1.31.0"
version = "1.30.0"
source = { editable = "." }
dependencies = [
{ name = "celery" },