mirror of
https://github.com/suitenumerique/meet.git
synced 2026-10-04 21:02:07 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d08a360572 | |||
| 9187173cae | |||
| 364bbf4f0b | |||
| a6a12ef586 | |||
| 2622d89f63 | |||
| f2d50770cf | |||
| 11e8470aa5 | |||
| 3bf78f0f5b | |||
| ddd5e3fce1 | |||
| 5a9e1cb012 | |||
| c49cee3ab4 | |||
| bbc30de490 | |||
| 14b3395e1c |
+10
-4
@@ -12,12 +12,14 @@ and this project adheres to
|
||||
|
||||
- 🔒(backend) throttle meeting link generation
|
||||
- 🔒️(backend) add a daily cap on room creation
|
||||
- ✨(backend) add room soft-deletion to the external API
|
||||
- ✨(backend) purge soft-deleted rooms after a retention period
|
||||
- 🔧(summary) add setting to control Sentry traces sampling rate
|
||||
- ✨(frontend) let signed-out visitors start a meeting
|
||||
- ✨(backend) expose `allow_unregistered_rooms` in the frontend configuration
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(backend) soft delete rooms instead of hard-delete
|
||||
- ✨(frontend) warn users when the connection falls back to TURN
|
||||
- 🔧(backend) configure the technical documentation url
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -25,6 +27,11 @@ and this project adheres to
|
||||
- 🔒️(agents) fix util-linux CVEs reported by Cyberwatch
|
||||
- 🔒️(backend) fix HIGH CVEs in Django and urllib3
|
||||
- 🔒️(agents) upgrade libpcre2-8-0 to fix CVE-2026-103111
|
||||
- 🔒️(frontend) upgrade pcre2 to fix CVE-2026-103111
|
||||
- 🐛(summary) disable default S3 checksums for GCS-compatible storage
|
||||
- 🔒️(summary) redact meeting content from Sentry events
|
||||
- 🐛(frontend) hide tooltips until they have a computed placement
|
||||
- 🐛(brevo) use django-lasuite for marketing management
|
||||
|
||||
## [1.33.0] - 2026-09-30
|
||||
|
||||
@@ -155,7 +162,6 @@ and this project adheres to
|
||||
### Added
|
||||
|
||||
- ✨(any) let any authenticated user manage the lobby on trusted rooms
|
||||
|
||||
### Changed
|
||||
|
||||
- 📱(frontend) collapse mobile control bar items on narrow viewports
|
||||
|
||||
+2
-3
@@ -37,14 +37,13 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked --no-dev
|
||||
|
||||
# ---- mails ----
|
||||
FROM node:22 AS mail-builder
|
||||
FROM node:22-alpine AS mail-builder
|
||||
|
||||
COPY ./src/mail /mail/app
|
||||
|
||||
WORKDIR /mail/app
|
||||
|
||||
RUN yarn install --frozen-lockfile && \
|
||||
yarn build
|
||||
RUN npm ci --ignore-scripts && npm run build
|
||||
|
||||
|
||||
# ---- static link collector ----
|
||||
|
||||
@@ -29,14 +29,6 @@ Rooms now keep track of the last time they were started (`last_started_at`), fed
|
||||
* 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.
|
||||
* With `ALLOW_UNREGISTERED_ROOMS=true`, any user can reopen the purged room by navigating to the same URL. In that case, the room is created dynamically and no corresponding room entry is persisted in the database.
|
||||
|
||||
### 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.
|
||||
|
||||
### Local development: MinIO replaced by Garage
|
||||
|
||||
The development stacks now use [Garage](https://garagehq.deuxfleurs.fr/) instead of MinIO as S3 storage. Garage keeps its own format in `data/media/meta` and `data/media/data` and cannot read what MinIO left there, so local recordings and files will be lost.
|
||||
|
||||
+3
-2
@@ -153,6 +153,7 @@ services:
|
||||
target: frontend-production
|
||||
args:
|
||||
VITE_API_BASE_URL: "http://localhost:8071"
|
||||
VITE_MEDIA_BASE_URL: "http://localhost:8083"
|
||||
VITE_APP_TITLE: "LaSuite Meet"
|
||||
image: meet:frontend-development
|
||||
ports:
|
||||
@@ -172,7 +173,7 @@ services:
|
||||
working_dir: /app
|
||||
|
||||
node:
|
||||
image: node:22
|
||||
image: node:22-alpine
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
environment:
|
||||
HOME: /tmp
|
||||
@@ -271,7 +272,7 @@ services:
|
||||
- /app/.venv
|
||||
|
||||
redis-summary:
|
||||
image: redis
|
||||
image: redis:5
|
||||
ports:
|
||||
- "6379:6379"
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ FROM nginxinc/nginx-unprivileged:1.30.4-alpine3.24 AS frontend-production
|
||||
|
||||
USER root
|
||||
RUN apk upgrade --no-cache libexpat && \
|
||||
apk add --no-cache --upgrade 'pcre2>=10.49-r0' && \
|
||||
apk del curl
|
||||
USER nginx
|
||||
|
||||
|
||||
@@ -4,6 +4,36 @@ server {
|
||||
server_name localhost;
|
||||
charset utf-8;
|
||||
|
||||
# Proxy auth for recordings (authorized by the recordings viewset)
|
||||
location /media/recordings/ {
|
||||
auth_request /media-auth-recordings;
|
||||
auth_request_set $authHeader $upstream_http_authorization;
|
||||
auth_request_set $authDate $upstream_http_x_amz_date;
|
||||
auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
|
||||
|
||||
proxy_set_header Authorization $authHeader;
|
||||
proxy_set_header X-Amz-Date $authDate;
|
||||
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
|
||||
|
||||
proxy_pass http://garage:9000/meet-media-storage/recordings/;
|
||||
proxy_set_header Host garage:9000;
|
||||
proxy_hide_header Content-Disposition;
|
||||
add_header Content-Disposition "attachment";
|
||||
}
|
||||
|
||||
location = /media-auth-recordings {
|
||||
internal;
|
||||
proxy_pass http://app-dev:8000/api/v1.0/recordings/media-auth/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Original-URL $request_uri;
|
||||
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_set_header X-Original-Method $request_method;
|
||||
}
|
||||
|
||||
# Proxy auth for media
|
||||
location /media/ {
|
||||
# Auth request configuration
|
||||
|
||||
@@ -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, and the rooms deleted by their owner for longer than a configurable retention period. 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. It is disabled by default.
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -12,21 +12,16 @@ 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.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
```bash
|
||||
python manage.py purge_inactive_rooms # delete the inactive and deleted rooms
|
||||
python manage.py purge_inactive_rooms # delete the inactive 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. A room is kept, past its inactivity or retention period, as long as it holds a saved recording its users may still access:
|
||||
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:
|
||||
|
||||
- with `RECORDING_EXPIRATION_DAYS` set, a saved recording created less than that many days ago,
|
||||
- with `RECORDING_EXPIRATION_DAYS` unset, any saved recording.
|
||||
@@ -38,8 +33,7 @@ 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. 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.
|
||||
and can be reused when a meeting is created from that same URL.
|
||||
|
||||
* 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.
|
||||
|
||||
|
||||
@@ -347,6 +347,7 @@ These are the environmental options available on meet backend.
|
||||
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
|
||||
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
|
||||
| FRONTEND_DOCUMENTATION_URL | URL of the documentation opened from the room options menu. If unset, the documentation menu item is hidden | |
|
||||
| FRONTEND_TECHNICAL_DOCUMENTATION_URL | URL of the technical documentation (network prerequisites) linked from the footer and the connection test. If unset, both links are hidden | |
|
||||
| FRONTEND_USE_FRENCH_GOV_FOOTER | Show the French government footer in the homepage | false |
|
||||
| FRONTEND_USE_PROCONNECT_BUTTON | Show a "Login with ProConnect" button in the homepage instead of a "Login" button | false |
|
||||
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
|
||||
@@ -406,7 +407,6 @@ 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"} |
|
||||
|
||||
@@ -88,7 +88,7 @@ RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
|
||||
# RECORDING_ENCODING_DEFAULT_RESOLUTION=720p
|
||||
# RECORDING_ENCODING_DEFAULT_PROFILE=full
|
||||
|
||||
# Default encoding values independant of resolution/profile
|
||||
# Default encoding values independent of resolution/profile
|
||||
# RECORDING_ENCODING_AUDIO_BITRATE_KBPS=128
|
||||
# RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ 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"
|
||||
|
||||
@@ -73,6 +73,7 @@ def get_frontend_configuration(request):
|
||||
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
||||
"default_video_codec": settings.LIVEKIT_DEFAULT_VIDEO_CODEC,
|
||||
},
|
||||
"allow_unregistered_rooms": settings.ALLOW_UNREGISTERED_ROOMS,
|
||||
"authenticated_users_can_edit_display_name": (
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
|
||||
),
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
"""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()
|
||||
@@ -25,7 +25,6 @@ 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,7 +113,6 @@ class ResourceAccessSerializerMixin:
|
||||
_("You must be administrator or owner of a room to add accesses to it.")
|
||||
)
|
||||
|
||||
ensure_room_not_deleted(resource)
|
||||
return resource
|
||||
|
||||
|
||||
|
||||
@@ -42,7 +42,6 @@ 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.enums import MEDIA_STORAGE_URL_PATTERN
|
||||
from core.recording.enums import FileExtension
|
||||
@@ -77,7 +76,7 @@ from core.services.participants_management import (
|
||||
ParticipantsManagementException,
|
||||
)
|
||||
from core.services.room_creation import RoomCreation
|
||||
from core.services.room_management import RoomManagement, RoomManagementException
|
||||
from core.services.room_management import RoomManagement
|
||||
from core.services.room_roles import (
|
||||
RoomRoleError,
|
||||
RoomRoleService,
|
||||
@@ -168,7 +167,7 @@ class UserViewSet(
|
||||
)
|
||||
|
||||
|
||||
class RoomViewSet( # pylint: disable=too-many-public-methods
|
||||
class RoomViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
@@ -194,9 +193,10 @@ class RoomViewSet( # pylint: disable=too-many-public-methods
|
||||
filter_kwargs = {"pk": self.kwargs["pk"]}
|
||||
except ValueError:
|
||||
filter_kwargs = {"slug": slugify(self.kwargs["pk"])}
|
||||
obj = get_object_or_404(models.Room.all_objects, **filter_kwargs)
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
obj = get_object_or_404(queryset, **filter_kwargs)
|
||||
# May raise a permission denied
|
||||
self.check_object_permissions(self.request, obj)
|
||||
ensure_room_not_deleted(obj)
|
||||
return obj
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
@@ -248,27 +248,6 @@ class RoomViewSet( # pylint: disable=too-many-public-methods
|
||||
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.
|
||||
|
||||
@@ -961,13 +940,6 @@ 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,
|
||||
|
||||
@@ -1,26 +1,19 @@
|
||||
"""Authentication Backends for the Meet core app."""
|
||||
|
||||
import contextlib
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import (
|
||||
ImproperlyConfigured,
|
||||
SuspiciousOperation,
|
||||
ValidationError,
|
||||
)
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from lasuite.marketing.tasks import create_or_update_contact
|
||||
from lasuite.oidc_login.backends import (
|
||||
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
|
||||
)
|
||||
from rest_framework.authentication import SessionAuthentication
|
||||
|
||||
from core.models import User
|
||||
from core.services.marketing import (
|
||||
ContactCreationError,
|
||||
ContactData,
|
||||
get_marketing_service,
|
||||
)
|
||||
from core.validators import sub_validator
|
||||
|
||||
|
||||
@@ -67,25 +60,15 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
|
||||
@staticmethod
|
||||
def signup_to_marketing_email(email):
|
||||
"""Pragmatic approach to newsletter signup during authentication flow.
|
||||
"""Add the user to the newsletter list on sign-in.
|
||||
|
||||
Details:
|
||||
1. Uses a very short timeout (1s) to prevent blocking the auth process
|
||||
2. Silently fails if the marketing service is down/slow to prioritize user experience
|
||||
3. Trade-off: May miss some signups but ensures auth flow remains fast
|
||||
|
||||
Note: For a more robust solution, consider using Async task processing (Celery/Django-Q)
|
||||
Uses the team's standard Brevo integration, dispatching the contact
|
||||
creation/update as an asynchronous task to keep authentication fast.
|
||||
"""
|
||||
with contextlib.suppress(
|
||||
ContactCreationError, ImproperlyConfigured, ImportError
|
||||
):
|
||||
marketing_service = get_marketing_service()
|
||||
contact_data = ContactData(
|
||||
email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
|
||||
)
|
||||
marketing_service.create_contact(
|
||||
contact_data, timeout=settings.BREVO_API_TIMEOUT
|
||||
)
|
||||
create_or_update_contact.delay(
|
||||
email=email,
|
||||
attributes={"VISIO_SOURCE": ["SIGNIN"]},
|
||||
)
|
||||
|
||||
def get_existing_user(self, sub, email):
|
||||
"""Fetch existing user by sub or email."""
|
||||
|
||||
@@ -22,13 +22,11 @@ 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.services.jwt_token import JwtTokenService
|
||||
from core.services.room_management import RoomManagement, RoomManagementException
|
||||
from core.services.room_management import RoomManagement
|
||||
|
||||
from ..services.provisional_user_service import (
|
||||
ProvisionalUserCreationDisabledError,
|
||||
@@ -144,7 +142,6 @@ class ApplicationViewSet(viewsets.ViewSet):
|
||||
|
||||
class RoomViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
@@ -162,13 +159,9 @@ 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", "delete", "head", "options"]
|
||||
http_method_names = ["get", "post", "patch", "head", "options"]
|
||||
|
||||
authentication_classes = [
|
||||
authentication.ApplicationJWTAuthentication,
|
||||
@@ -183,17 +176,6 @@ 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."""
|
||||
|
||||
@@ -257,16 +239,6 @@ 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."""
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"""Purge inactive and soft-deleted rooms."""
|
||||
"""Purge inactive rooms."""
|
||||
|
||||
import operator
|
||||
from datetime import timedelta
|
||||
from functools import reduce
|
||||
from itertools import batched
|
||||
from logging import getLogger
|
||||
|
||||
@@ -24,12 +22,10 @@ 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 and soft-deleted rooms"
|
||||
help = "Purge inactive rooms"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
@@ -39,68 +35,53 @@ class Command(BaseCommand):
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Browse purgeable rooms and delete them chunk by chunk."""
|
||||
"""Browse inactive rooms and delete them chunk by chunk."""
|
||||
|
||||
if not (
|
||||
settings.ROOM_INACTIVITY_DELETION_DAYS
|
||||
or settings.ROOM_DELETED_RETENTION_DAYS
|
||||
):
|
||||
if not settings.ROOM_INACTIVITY_DELETION_DAYS:
|
||||
self.stdout.write(
|
||||
"Purging rooms is disabled (neither ROOM_INACTIVITY_DELETION_DAYS "
|
||||
"nor ROOM_DELETED_RETENTION_DAYS is set)."
|
||||
"Purging inactive rooms is disabled "
|
||||
"(ROOM_INACTIVITY_DELETION_DAYS is not set)."
|
||||
)
|
||||
return
|
||||
|
||||
now = timezone.now()
|
||||
purgeable_rooms = self.get_purgeable_rooms(now)
|
||||
inactive_rooms = self.get_inactive_rooms(now)
|
||||
|
||||
purgeable_count = purgeable_rooms.count()
|
||||
if not purgeable_count:
|
||||
self.stdout.write("No room to purge.")
|
||||
inactive_count = inactive_rooms.count()
|
||||
if not inactive_count:
|
||||
self.stdout.write("No inactive room to purge.")
|
||||
return
|
||||
|
||||
if options["dry_run"]:
|
||||
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 ''}")
|
||||
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}")
|
||||
return
|
||||
|
||||
purged_count = 0
|
||||
rooms = purgeable_rooms.values_list("pk", "slug", "deleted_at").iterator(
|
||||
chunk_size=CHUNK_SIZE
|
||||
)
|
||||
rooms = inactive_rooms.values_list("pk", "slug").iterator(chunk_size=CHUNK_SIZE)
|
||||
for chunk in batched(rooms, CHUNK_SIZE, strict=False):
|
||||
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)
|
||||
for room_id, slug in chunk:
|
||||
logger.info("Purging inactive room %s (%s)", room_id, slug)
|
||||
|
||||
_, deleted_by_model = purgeable_rooms.filter(
|
||||
pk__in=[room_id for room_id, _, _ in chunk]
|
||||
_, deleted_by_model = inactive_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} room(s).")
|
||||
self.stdout.write(f"Purged {purged_count} inactive room(s).")
|
||||
|
||||
@staticmethod
|
||||
def get_purgeable_rooms(now):
|
||||
"""Return the inactive or long-deleted rooms that no recording protects.
|
||||
def get_inactive_rooms(now):
|
||||
"""Return the rooms inactive for too long that no recording protects."""
|
||||
|
||||
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()
|
||||
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
|
||||
)
|
||||
|
||||
protected_recordings = Recording.objects.filter(
|
||||
room=OuterRef("pk"), status__in=RecordingStatusChoices.saved_statuses()
|
||||
@@ -110,6 +91,4 @@ class Command(BaseCommand):
|
||||
created_at__gte=now - timedelta(days=settings.RECORDING_EXPIRATION_DAYS)
|
||||
)
|
||||
|
||||
return Room.all_objects.filter(
|
||||
reduce(operator.or_, conditions), ~Exists(protected_recordings)
|
||||
)
|
||||
return Room.objects.filter(is_inactive, ~Exists(protected_recordings))
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# 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', '0024_room_last_started_at'),
|
||||
]
|
||||
|
||||
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()),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -406,33 +406,6 @@ 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"""
|
||||
|
||||
@@ -456,7 +429,6 @@ 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,
|
||||
@@ -473,13 +445,8 @@ 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")
|
||||
@@ -502,23 +469,6 @@ 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.
|
||||
"""
|
||||
if self.deleted_at:
|
||||
raise RuntimeError("This room is already deleted.")
|
||||
|
||||
self.deleted_at = timezone.now()
|
||||
self.save(update_fields=["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.
|
||||
@@ -554,7 +504,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.all_objects.filter(pin_code=pin_code).exists():
|
||||
if not Room.objects.filter(pin_code=pin_code).exists():
|
||||
return pin_code
|
||||
|
||||
# Log a warning as a temporary measure until backend observability is implemented.
|
||||
|
||||
@@ -271,24 +271,20 @@ class LiveKitEventsService:
|
||||
)
|
||||
raise ActionFailedError("Failed to process room started event") from e
|
||||
|
||||
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(
|
||||
room_updated_count = models.Room.objects.filter(pk=room_id).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:
|
||||
@@ -296,25 +292,6 @@ 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."""
|
||||
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
"""Marketing service in charge of pushing data for marketing automation."""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import Dict, List, Optional, Protocol
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
import brevo_python
|
||||
import urllib3
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ContactCreationError(Exception):
|
||||
"""Raised when the contact creation fails."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContactData:
|
||||
"""Contact data for marketing service integration."""
|
||||
|
||||
email: str
|
||||
attributes: Optional[Dict[str, str]] = None
|
||||
list_ids: Optional[List[int]] = None
|
||||
update_enabled: bool = True
|
||||
|
||||
|
||||
class MarketingServiceProtocol(Protocol):
|
||||
"""Interface for marketing automation service integrations."""
|
||||
|
||||
def create_contact(
|
||||
self, contact_data: ContactData, timeout: Optional[int] = None
|
||||
) -> dict:
|
||||
"""Create or update a contact.
|
||||
|
||||
Args:
|
||||
contact_data: Contact information and attributes
|
||||
timeout: API request timeout in seconds
|
||||
|
||||
Returns:
|
||||
dict: Service response
|
||||
|
||||
Raises:
|
||||
ContactCreationError: If contact creation fails
|
||||
"""
|
||||
|
||||
|
||||
class BrevoMarketingService:
|
||||
"""Brevo marketing automation integration.
|
||||
|
||||
Handles:
|
||||
- Contact management and segmentation
|
||||
- Marketing campaigns and automation
|
||||
- Email communications
|
||||
|
||||
Configuration via Django settings:
|
||||
- BREVO_API_KEY: API authentication
|
||||
- BREVO_API_CONTACT_LIST_IDS: Default contact lists
|
||||
- BREVO_API_CONTACT_ATTRIBUTES: Default contact attributes
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize Brevo (ex-sendinblue) marketing service."""
|
||||
|
||||
if not settings.BREVO_API_KEY:
|
||||
raise ImproperlyConfigured("Brevo API key is required")
|
||||
|
||||
configuration = brevo_python.Configuration()
|
||||
configuration.api_key["api-key"] = settings.BREVO_API_KEY
|
||||
|
||||
self._api_client = brevo_python.ApiClient(configuration)
|
||||
|
||||
def create_contact(self, contact_data: ContactData, timeout=None) -> dict:
|
||||
"""Create or update a Brevo contact.
|
||||
|
||||
Args:
|
||||
contact_data: Contact information and attributes
|
||||
timeout: API request timeout in seconds
|
||||
|
||||
Returns:
|
||||
dict: Brevo API response
|
||||
|
||||
Raises:
|
||||
ContactCreationError: If contact creation fails
|
||||
ImproperlyConfigured: If required settings are missing
|
||||
|
||||
Note:
|
||||
Contact attributes must be pre-configured in Brevo.
|
||||
Changes to attributes can impact existing workflows.
|
||||
"""
|
||||
|
||||
if not settings.BREVO_API_CONTACT_LIST_IDS:
|
||||
raise ImproperlyConfigured(
|
||||
"Default Brevo List IDs must be configured in settings."
|
||||
)
|
||||
|
||||
contact_api = brevo_python.ContactsApi(self._api_client)
|
||||
|
||||
attributes = {
|
||||
**settings.BREVO_API_CONTACT_ATTRIBUTES,
|
||||
**(contact_data.attributes or {}),
|
||||
}
|
||||
|
||||
list_ids = (contact_data.list_ids or []) + settings.BREVO_API_CONTACT_LIST_IDS
|
||||
|
||||
contact = brevo_python.CreateContact(
|
||||
email=contact_data.email,
|
||||
attributes=attributes,
|
||||
list_ids=list_ids,
|
||||
update_enabled=contact_data.update_enabled,
|
||||
)
|
||||
|
||||
api_configurations = {}
|
||||
|
||||
if timeout is not None:
|
||||
api_configurations["_request_timeout"] = timeout
|
||||
|
||||
try:
|
||||
response = contact_api.create_contact(contact, **api_configurations)
|
||||
except (
|
||||
brevo_python.rest.ApiException,
|
||||
urllib3.exceptions.ReadTimeoutError,
|
||||
) as err:
|
||||
logger.warning("Failed to create contact in Brevo", exc_info=True)
|
||||
raise ContactCreationError("Failed to create contact in Brevo") from err
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_marketing_service() -> MarketingServiceProtocol:
|
||||
"""Return cached instance of configured marketing service."""
|
||||
marketing_service_cls = import_string(settings.MARKETING_SERVICE_CLASS)
|
||||
return marketing_service_cls()
|
||||
@@ -2,13 +2,10 @@
|
||||
|
||||
# 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,
|
||||
@@ -18,9 +15,6 @@ from livekit.api import (
|
||||
)
|
||||
|
||||
from core import utils
|
||||
from core.models import Room
|
||||
|
||||
from .sip_management import SIPException, SIPManagement
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
@@ -121,43 +115,6 @@ class RoomManagement:
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
@classmethod
|
||||
def soft_delete(cls, room: Room):
|
||||
"""Soft delete a room, then close its LiveKit room and its SIP routing.
|
||||
|
||||
Raises:
|
||||
RoomManagementException: the LiveKit room could not be closed, or its
|
||||
SIP dispatch rule could not be deleted.
|
||||
"""
|
||||
|
||||
room.soft_delete()
|
||||
|
||||
try:
|
||||
with contextlib.suppress(RoomNotFoundException):
|
||||
cls.delete_room(str(room.id))
|
||||
|
||||
cls._delete_dispatch_rule(room)
|
||||
except Exception:
|
||||
Room.all_objects.filter(pk=room.pk, deleted_at=room.deleted_at).update(
|
||||
deleted_at=None
|
||||
)
|
||||
room.deleted_at = None
|
||||
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.
|
||||
"""
|
||||
|
||||
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
|
||||
try:
|
||||
SIPManagement().delete_dispatch_rule(room.id)
|
||||
except SIPException as e:
|
||||
raise RoomManagementException("Could not delete dispatch rule") from e
|
||||
|
||||
@classmethod
|
||||
def sync_room_metadata(cls, room):
|
||||
"""Push a room's configuration and access level to its LiveKit room metadata.
|
||||
|
||||
@@ -124,11 +124,7 @@ class SIPManagement:
|
||||
|
||||
@async_to_sync
|
||||
async def delete_dispatch_rule(self, room_id):
|
||||
"""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.
|
||||
"""
|
||||
"""Delete all SIP inbound dispatch rules associated with a specific room."""
|
||||
|
||||
rules_ids = await self._list_dispatch_rules_ids(room_id)
|
||||
|
||||
@@ -142,16 +138,9 @@ class SIPManagement:
|
||||
lkapi = utils.create_livekit_client()
|
||||
try:
|
||||
for rule_id in rules_ids:
|
||||
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)
|
||||
await lkapi.sip.delete_sip_dispatch_rule(
|
||||
delete=DeleteSIPDispatchRuleRequest(sip_dispatch_rule_id=rule_id)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
|
||||
import pytest
|
||||
from lasuite.marketing.tasks import create_or_update_contact
|
||||
|
||||
from core import models
|
||||
from core.authentication.backends import OIDCAuthenticationBackend
|
||||
from core.factories import UserFactory
|
||||
from core.services import marketing
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -606,8 +606,8 @@ def test_marketing_signup_existing_user(
|
||||
mock_signup.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.authentication.backends.get_marketing_service")
|
||||
def test_signup_to_marketing_email_success(mock_marketing):
|
||||
@mock.patch.object(create_or_update_contact, "delay")
|
||||
def test_signup_to_marketing_email_success(mock_create_or_update_contact):
|
||||
"""Test successful marketing signup."""
|
||||
|
||||
email = "test@example.com"
|
||||
@@ -616,46 +616,6 @@ def test_signup_to_marketing_email_success(mock_marketing):
|
||||
OIDCAuthenticationBackend.signup_to_marketing_email(email)
|
||||
|
||||
# Verify service interaction
|
||||
mock_service = mock_marketing.return_value
|
||||
mock_service.create_contact.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
ImportError,
|
||||
ImproperlyConfigured,
|
||||
],
|
||||
)
|
||||
@mock.patch("core.authentication.backends.get_marketing_service")
|
||||
def test_marketing_signup_handles_service_initialization_errors(
|
||||
mock_marketing, error, settings
|
||||
):
|
||||
"""Tests errors that occur when trying to get/initialize the marketing service."""
|
||||
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
|
||||
|
||||
mock_marketing.side_effect = error
|
||||
|
||||
# Should not raise any exception
|
||||
OIDCAuthenticationBackend.signup_to_marketing_email("test@example.com")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
marketing.ContactCreationError,
|
||||
ImproperlyConfigured,
|
||||
ImportError,
|
||||
],
|
||||
)
|
||||
@mock.patch("core.authentication.backends.get_marketing_service")
|
||||
def test_marketing_signup_handles_contact_creation_errors(
|
||||
mock_marketing, error, settings
|
||||
):
|
||||
"""Tests errors that occur during the contact creation process."""
|
||||
|
||||
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
|
||||
mock_marketing.return_value.create_contact.side_effect = error
|
||||
|
||||
# Should not raise any exception
|
||||
OIDCAuthenticationBackend.signup_to_marketing_email("test@example.com")
|
||||
mock_create_or_update_contact.assert_called_once_with(
|
||||
email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
|
||||
)
|
||||
|
||||
@@ -9,7 +9,6 @@ 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
|
||||
|
||||
@@ -20,25 +19,14 @@ 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):
|
||||
@@ -53,12 +41,12 @@ def call_purge(*args):
|
||||
|
||||
|
||||
def room_exists(room):
|
||||
"""Tell whether the room is still in database, soft deleted or not."""
|
||||
return models.Room.all_objects.filter(pk=room.pk).exists()
|
||||
"""Tell whether the room is still in database."""
|
||||
return models.Room.objects.filter(pk=room.pk).exists()
|
||||
|
||||
|
||||
def test_purge_inactive_rooms_disabled(settings):
|
||||
"""Should delete nothing when neither period is configured."""
|
||||
"""Should delete nothing when no inactivity period is configured."""
|
||||
settings.ROOM_INACTIVITY_DELETION_DAYS = None
|
||||
room = create_at(timezone.now() - BEFORE_PERIOD, factories.RoomFactory)
|
||||
|
||||
@@ -80,7 +68,7 @@ def test_purge_inactive_rooms_without_recording_expiration(settings):
|
||||
status=models.RecordingStatusChoices.SAVED,
|
||||
)
|
||||
|
||||
assert call_purge() == "Purged 1 room(s).\n"
|
||||
assert call_purge() == "Purged 1 inactive room(s).\n"
|
||||
|
||||
assert not room_exists(room)
|
||||
assert room_exists(room_with_recording)
|
||||
@@ -109,7 +97,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 room(s).\n"
|
||||
assert output == "Purged 1 inactive room(s).\n"
|
||||
assert not room_exists(room)
|
||||
assert f"Purging inactive room {room.pk} ({room.slug})" in caplog.text
|
||||
|
||||
@@ -132,7 +120,7 @@ def test_purge_inactive_rooms_started_within_period():
|
||||
last_started_at=now - WITHIN_PERIOD,
|
||||
)
|
||||
|
||||
assert call_purge() == "No room to purge.\n"
|
||||
assert call_purge() == "No inactive room to purge.\n"
|
||||
|
||||
assert room_exists(room)
|
||||
|
||||
@@ -141,7 +129,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 room to purge.\n"
|
||||
assert call_purge() == "No inactive room to purge.\n"
|
||||
|
||||
assert room_exists(room)
|
||||
|
||||
@@ -233,7 +221,7 @@ def test_purge_inactive_rooms_dry_run():
|
||||
factories.RoomFactory(name="Recent room")
|
||||
|
||||
assert call_purge("--dry-run") == (
|
||||
"[dry-run] 2 room(s) would be purged:\n- Alpha room\n- Beta room\n"
|
||||
"[dry-run] 2 inactive room(s) would be purged:\n- Alpha room\n- Beta room\n"
|
||||
)
|
||||
|
||||
assert all(room_exists(room) for room in rooms)
|
||||
@@ -247,134 +235,5 @@ def test_purge_inactive_rooms_several_chunks():
|
||||
with mock.patch(f"{COMMAND_MODULE}.CHUNK_SIZE", 2):
|
||||
output = call_purge()
|
||||
|
||||
assert output == "Purged 5 room(s).\n"
|
||||
assert output == "Purged 5 inactive 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"
|
||||
|
||||
@@ -116,29 +116,6 @@ 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,16 +2,11 @@
|
||||
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, RoomAccessLevel
|
||||
from ...services.room_management import RoomManagement, RoomNotFoundException
|
||||
from ...services.sip_management import SIPManagement
|
||||
from ...models import Room
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -88,52 +83,12 @@ def test_api_rooms_delete_administrators():
|
||||
assert Room.objects.count() == 1
|
||||
|
||||
|
||||
@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):
|
||||
def test_api_rooms_delete_owners():
|
||||
"""
|
||||
Authenticated users should be able to delete a room for which they are directly
|
||||
owner. The room is soft deleted, its LiveKit room is closed and a ROOM_DELETED
|
||||
analytics event is emitted.
|
||||
owner.
|
||||
"""
|
||||
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()
|
||||
@@ -144,47 +99,4 @@ def test_api_rooms_delete_owners_room_not_live(
|
||||
)
|
||||
|
||||
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()
|
||||
|
||||
@@ -188,28 +188,6 @@ 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={
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Test LiveKitEvents service.
|
||||
"""
|
||||
# pylint: disable=W0621,W0613, W0212, E0611, C0302
|
||||
# pylint: disable=W0621,W0613, W0212, E0611
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
@@ -27,10 +27,7 @@ from core.services.livekit_events import (
|
||||
to_recording_event,
|
||||
)
|
||||
from core.services.lobby import LobbyService
|
||||
from core.services.room_management import (
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
from core.services.room_management import RoomManagementException
|
||||
from core.services.sip_management import (
|
||||
SIPException,
|
||||
SIPManagement,
|
||||
@@ -799,61 +796,6 @@ 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")
|
||||
)
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
"""
|
||||
Test marketing services.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621,W0613
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
import brevo_python
|
||||
import pytest
|
||||
import urllib3
|
||||
|
||||
from core.services.marketing import (
|
||||
BrevoMarketingService,
|
||||
ContactCreationError,
|
||||
ContactData,
|
||||
get_marketing_service,
|
||||
)
|
||||
|
||||
|
||||
def test_init_missing_api_key(settings):
|
||||
"""Test initialization with missing API key."""
|
||||
settings.BREVO_API_KEY = None
|
||||
with pytest.raises(ImproperlyConfigured, match="Brevo API key is required"):
|
||||
BrevoMarketingService()
|
||||
|
||||
|
||||
def test_create_contact_missing_list_ids(settings):
|
||||
"""Test contact creation with missing list IDs."""
|
||||
|
||||
settings.BREVO_API_KEY = "test-api-key"
|
||||
settings.BREVO_API_CONTACT_LIST_IDS = None
|
||||
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
|
||||
|
||||
valid_contact_data = ContactData(
|
||||
email="test@example.com",
|
||||
attributes={"first_name": "Test"},
|
||||
list_ids=[1, 2],
|
||||
update_enabled=True,
|
||||
)
|
||||
|
||||
brevo_service = BrevoMarketingService()
|
||||
|
||||
with pytest.raises(
|
||||
ImproperlyConfigured, match="Default Brevo List IDs must be configured"
|
||||
):
|
||||
brevo_service.create_contact(valid_contact_data)
|
||||
|
||||
|
||||
@mock.patch("brevo_python.ContactsApi")
|
||||
def test_create_contact_success(mock_contact_api):
|
||||
"""Test successful contact creation."""
|
||||
|
||||
mock_api = mock_contact_api.return_value
|
||||
|
||||
settings.BREVO_API_KEY = "test-api-key"
|
||||
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
|
||||
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
|
||||
|
||||
valid_contact_data = ContactData(
|
||||
email="test@example.com",
|
||||
attributes={"first_name": "Test"},
|
||||
list_ids=[1, 2],
|
||||
update_enabled=True,
|
||||
)
|
||||
|
||||
brevo_service = BrevoMarketingService()
|
||||
|
||||
mock_api.create_contact.return_value = {"id": "test-id"}
|
||||
response = brevo_service.create_contact(valid_contact_data)
|
||||
|
||||
assert response == {"id": "test-id"}
|
||||
|
||||
mock_api.create_contact.assert_called_once()
|
||||
contact_arg = mock_api.create_contact.call_args[0][0]
|
||||
assert contact_arg.email == "test@example.com"
|
||||
assert contact_arg.attributes == {
|
||||
**settings.BREVO_API_CONTACT_ATTRIBUTES,
|
||||
**valid_contact_data.attributes,
|
||||
}
|
||||
assert set(contact_arg.list_ids) == {1, 2, 3, 4}
|
||||
assert contact_arg.update_enabled is True
|
||||
|
||||
|
||||
@mock.patch("brevo_python.ContactsApi")
|
||||
def test_create_contact_with_timeout(mock_contact_api):
|
||||
"""Test contact creation with timeout."""
|
||||
|
||||
mock_api = mock_contact_api.return_value
|
||||
|
||||
settings.BREVO_API_KEY = "test-api-key"
|
||||
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
|
||||
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
|
||||
|
||||
valid_contact_data = ContactData(
|
||||
email="test@example.com",
|
||||
attributes={"first_name": "Test"},
|
||||
list_ids=[1, 2],
|
||||
update_enabled=True,
|
||||
)
|
||||
|
||||
brevo_service = BrevoMarketingService()
|
||||
brevo_service.create_contact(valid_contact_data, timeout=30)
|
||||
|
||||
mock_api.create_contact.assert_called_once()
|
||||
assert mock_api.create_contact.call_args[1]["_request_timeout"] == 30
|
||||
|
||||
|
||||
@mock.patch("brevo_python.ContactsApi")
|
||||
def test_create_contact_api_error(mock_contact_api):
|
||||
"""Test contact creation API error handling."""
|
||||
|
||||
mock_api = mock_contact_api.return_value
|
||||
|
||||
settings.BREVO_API_KEY = "test-api-key"
|
||||
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
|
||||
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
|
||||
|
||||
valid_contact_data = ContactData(
|
||||
email="test@example.com",
|
||||
attributes={"first_name": "Test"},
|
||||
list_ids=[1, 2],
|
||||
update_enabled=True,
|
||||
)
|
||||
|
||||
brevo_service = BrevoMarketingService()
|
||||
|
||||
mock_api.create_contact.side_effect = brevo_python.rest.ApiException()
|
||||
|
||||
with pytest.raises(ContactCreationError, match="Failed to create contact in Brevo"):
|
||||
brevo_service.create_contact(valid_contact_data)
|
||||
|
||||
|
||||
@mock.patch("brevo_python.ContactsApi")
|
||||
def test_create_contact_timeout_error(mock_contact_api):
|
||||
"""Test contact creation timeout error handling."""
|
||||
|
||||
mock_api = mock_contact_api.return_value
|
||||
settings.BREVO_API_KEY = "test-api-key"
|
||||
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
|
||||
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
|
||||
|
||||
valid_contact_data = ContactData(
|
||||
email="test@example.com",
|
||||
attributes={"first_name": "Test"},
|
||||
list_ids=[1, 2],
|
||||
update_enabled=True,
|
||||
)
|
||||
|
||||
brevo_service = BrevoMarketingService()
|
||||
|
||||
mock_api.create_contact.side_effect = urllib3.exceptions.ReadTimeoutError(
|
||||
pool=mock.Mock(),
|
||||
url="https://api.brevo.com/v3/endpoint",
|
||||
message="HTTPSConnectionPool(host='api.brevo.com', port=443): Read timed out.",
|
||||
)
|
||||
|
||||
with pytest.raises(ContactCreationError, match="Failed to create contact in Brevo"):
|
||||
brevo_service.create_contact(valid_contact_data)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clear_marketing_cache():
|
||||
"""Clear marketing service cache between tests."""
|
||||
get_marketing_service.cache_clear()
|
||||
yield
|
||||
get_marketing_service.cache_clear()
|
||||
|
||||
|
||||
def test_get_marketing_service_caching(clear_marketing_cache):
|
||||
"""Test marketing service caching behavior."""
|
||||
settings.BREVO_API_KEY = "test-api-key"
|
||||
settings.MARKETING_SERVICE_CLASS = "core.services.marketing.BrevoMarketingService"
|
||||
|
||||
service1 = get_marketing_service()
|
||||
service2 = get_marketing_service()
|
||||
|
||||
assert service1 is service2
|
||||
assert isinstance(service1, BrevoMarketingService)
|
||||
|
||||
|
||||
def test_get_marketing_service_invalid_class(clear_marketing_cache):
|
||||
"""Test handling of invalid service class."""
|
||||
settings.MARKETING_SERVICE_CLASS = "invalid.service.path"
|
||||
|
||||
with pytest.raises(ImportError):
|
||||
get_marketing_service()
|
||||
|
||||
|
||||
@mock.patch("core.services.marketing.import_string")
|
||||
def test_service_instantiation_called_once(mock_import_string, clear_marketing_cache):
|
||||
"""Test service class is instantiated only once."""
|
||||
|
||||
settings.BREVO_API_KEY = "test-api-key"
|
||||
settings.MARKETING_SERVICE_CLASS = "core.services.marketing.BrevoMarketingService"
|
||||
get_marketing_service.cache_clear()
|
||||
|
||||
mock_service_cls = mock.Mock()
|
||||
mock_service_instance = mock.Mock()
|
||||
mock_service_cls.return_value = mock_service_instance
|
||||
mock_import_string.return_value = mock_service_cls
|
||||
|
||||
service1 = get_marketing_service()
|
||||
service2 = get_marketing_service()
|
||||
|
||||
mock_import_string.assert_called_once_with(settings.MARKETING_SERVICE_CLASS)
|
||||
mock_service_cls.assert_called_once()
|
||||
assert service1 is service2
|
||||
assert service1 is mock_service_instance
|
||||
@@ -2,19 +2,16 @@
|
||||
|
||||
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 Room, RoomAccessLevel
|
||||
from core.models import 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")
|
||||
@@ -65,151 +62,6 @@ 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")
|
||||
@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,
|
||||
so no room_finished webhook deletes it: the soft delete must, or the
|
||||
retained PIN would still route SIP calls to the deleted room."""
|
||||
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_after_closing_it(settings):
|
||||
"""The dispatch rule of a live room is deleted only once the LiveKit room is
|
||||
closed, so it keeps routing calls if the closing fails and the deletion is
|
||||
rolled back."""
|
||||
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_room(str(room.id)),
|
||||
mock.call.delete_dispatch_rule(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,
|
||||
"delete_dispatch_rule",
|
||||
side_effect=SIPException("Could not delete dispatch rules"),
|
||||
)
|
||||
@mock.patch.object(
|
||||
RoomManagement,
|
||||
"delete_room",
|
||||
side_effect=RoomNotFoundException("Room does not exist"),
|
||||
)
|
||||
def test_soft_delete_dispatch_rule_failure_rolls_back(
|
||||
mock_delete_room, mock_delete_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."""
|
||||
settings.ROOMKIT_ENABLED = True
|
||||
room = RoomFactory()
|
||||
|
||||
with pytest.raises(RoomManagementException):
|
||||
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.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,31 +257,6 @@ 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):
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
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
|
||||
@@ -958,99 +956,3 @@ 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
|
||||
|
||||
@@ -26,12 +26,7 @@ from core.models import (
|
||||
RoomAccessLevel,
|
||||
User,
|
||||
)
|
||||
from core.services.room_management import (
|
||||
RoomManagement,
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
from core.services.sip_management import SIPManagement
|
||||
from core.services.room_management import RoomManagement
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -568,53 +563,6 @@ 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."""
|
||||
|
||||
@@ -1444,211 +1392,6 @@ def test_api_rooms_update_tracks_analytics(mock_update_metadata, mock_capture):
|
||||
mock_update_metadata.assert_called_once()
|
||||
|
||||
|
||||
@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(
|
||||
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
|
||||
|
||||
@@ -466,6 +466,11 @@ class Base(Configuration):
|
||||
"documentation_url": values.Value(
|
||||
None, environ_name="FRONTEND_DOCUMENTATION_URL", environ_prefix=None
|
||||
),
|
||||
"technical_documentation_url": values.Value(
|
||||
None,
|
||||
environ_name="FRONTEND_TECHNICAL_DOCUMENTATION_URL",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"external_home_url": values.Value(
|
||||
None, environ_name="FRONTEND_EXTERNAL_HOME_URL", environ_prefix=None
|
||||
),
|
||||
@@ -766,9 +771,6 @@ 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"],
|
||||
@@ -936,24 +938,18 @@ class Base(Configuration):
|
||||
environ_name="SIGNUP_NEW_USER_TO_MARKETING_EMAIL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
MARKETING_SERVICE_CLASS = values.Value(
|
||||
"core.services.marketing.BrevoMarketingService",
|
||||
environ_name="MARKETING_SERVICE_CLASS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
BREVO_API_KEY = SecretFileValue(
|
||||
None, environ_name="BREVO_API_KEY", environ_prefix=None
|
||||
)
|
||||
BREVO_API_CONTACT_LIST_IDS = values.ListValue(
|
||||
[],
|
||||
environ_name="BREVO_API_CONTACT_LIST_IDS",
|
||||
environ_prefix=None,
|
||||
converter=int,
|
||||
)
|
||||
BREVO_API_CONTACT_ATTRIBUTES = values.DictValue({"VISIO_USER": True})
|
||||
BREVO_API_TIMEOUT = values.PositiveIntegerValue(
|
||||
1, environ_name="BREVO_API_TIMEOUT", environ_prefix=None
|
||||
)
|
||||
LASUITE_MARKETING = {
|
||||
"BACKEND": values.Value(
|
||||
"lasuite.marketing.backends.dummy.DummyBackend",
|
||||
environ_name="LASUITE_MARKETING_BACKEND",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"PARAMETERS": values.DictValue(
|
||||
default={},
|
||||
environ_name="LASUITE_MARKETING_PARAMETERS",
|
||||
environ_prefix=None,
|
||||
),
|
||||
}
|
||||
|
||||
# Lobby configurations
|
||||
PRESENCE_KEY_PREFIX = values.Value(
|
||||
@@ -1417,26 +1413,21 @@ class Base(Configuration):
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
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 cls.ROOM_INACTIVITY_DELETION_DAYS:
|
||||
if not cls.RECORDING_EXPIRATION_DAYS:
|
||||
warnings.warn(
|
||||
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.",
|
||||
"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.",
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
elif cls.RECORDING_EXPIRATION_DAYS >= purge_days:
|
||||
elif cls.RECORDING_EXPIRATION_DAYS >= cls.ROOM_INACTIVITY_DELETION_DAYS:
|
||||
warnings.warn(
|
||||
"RECORDING_EXPIRATION_DAYS is greater than or equal to "
|
||||
f"{setting_name}. {rooms} holding a saved recording will be "
|
||||
f"kept past the {period}, until their recordings expire.",
|
||||
"ROOM_INACTIVITY_DELETION_DAYS. Inactive rooms holding a saved "
|
||||
"recording will be kept past the inactivity period, until their "
|
||||
"recordings expire.",
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
@@ -39,6 +39,9 @@ ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||
ARG VITE_APP_TITLE
|
||||
ENV VITE_APP_TITLE=${VITE_APP_TITLE}
|
||||
|
||||
ARG VITE_MEDIA_BASE_URL
|
||||
ENV VITE_MEDIA_BASE_URL=${VITE_MEDIA_BASE_URL}
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# ---- Front-end image ----
|
||||
@@ -46,6 +49,7 @@ FROM nginxinc/nginx-unprivileged:1.30.4-alpine3.24 AS frontend-production
|
||||
|
||||
USER root
|
||||
RUN apk upgrade --no-cache libexpat && \
|
||||
apk add --no-cache --upgrade 'pcre2>=10.49-r0' && \
|
||||
apk del curl
|
||||
USER nginx
|
||||
|
||||
|
||||
Generated
+33
-32
@@ -31,11 +31,11 @@
|
||||
"livekit-client": "2.21.0",
|
||||
"posthog-js": "1.418.10",
|
||||
"react": "18.3.1",
|
||||
"react-aria": "3.50.0",
|
||||
"react-aria-components": "1.19.0",
|
||||
"react-aria": "3.51.0",
|
||||
"react-aria-components": "1.20.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "17.0.12",
|
||||
"react-stately": "3.48.0",
|
||||
"react-stately": "3.49.0",
|
||||
"use-sound": "5.0.0",
|
||||
"valtio": "2.3.2",
|
||||
"wouter": "3.10.0"
|
||||
@@ -883,9 +883,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@internationalized/date": {
|
||||
"version": "3.12.2",
|
||||
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.2.tgz",
|
||||
"integrity": "sha512-FY1Y+H64NDs+HAF6omlnWxm3mEpfgaCSWtL5l551ZZfImA+kGjPFgrnJrGjH6lfmLL0g8Z/mBu1R3kufeCp6Jw==",
|
||||
"version": "3.12.3",
|
||||
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.3.tgz",
|
||||
"integrity": "sha512-fuLX+3ZKLsxI73y8b01EG/WjHb6gE6weCqlfawPO27kBWGMh9G1yH6Csv1uU7/cac9H2GHmOMt6CjmuQ1aia4Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
@@ -901,9 +901,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@internationalized/string": {
|
||||
"version": "3.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@internationalized/string/-/string-3.2.9.tgz",
|
||||
"integrity": "sha512-kzP/M/mbQxODlmOt4bIQZ2SBVUWUSqMLXooXixnX7noche8WHaQcA+nwFN1K2KCF/cp+LDUhcJsCicwkvhD1pg==",
|
||||
"version": "3.2.10",
|
||||
"resolved": "https://registry.npmjs.org/@internationalized/string/-/string-3.2.10.tgz",
|
||||
"integrity": "sha512-PDx6//vHSpRnHfxqMqto11zQvhsaU74O3mKv2F/0eicGZcl9NLjQmGlbHz/LsJh5tLKp4A4L7ZVTzN1/MmMTvA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.0"
|
||||
@@ -1819,9 +1819,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-types/shared": {
|
||||
"version": "3.36.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.36.0.tgz",
|
||||
"integrity": "sha512-DkP/H0C2YjjS7gZWKNqOmU8a16qHPjQNdzMwmTq9SzplM6Iw0kVMTZ0OIoe6FOgGqa+FwMsE2QbPjh/n3g/jXQ==",
|
||||
"version": "3.36.1",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.36.1.tgz",
|
||||
"integrity": "sha512-AzsuD9OfxTOZMMvTRhlN3oHBwOmFN7tDh27LzqmHt4+uOgPhJT7ZM7/kVs/8/o0WxayMUIk3hBmCFRHv1FUoag==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
@@ -9384,19 +9384,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-aria": {
|
||||
"version": "3.50.0",
|
||||
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.50.0.tgz",
|
||||
"integrity": "sha512-S0Os6QZk33fzUAKu1QLT9afoUaCBt1ZNdoiq0n2YMVgKIdNIQS8zxiZ8O9hYE6QyDkHKjD6q39LQZ+qaSAIgjw==",
|
||||
"version": "3.51.0",
|
||||
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.51.0.tgz",
|
||||
"integrity": "sha512-AyWLw0XR38cFPwBu/ErgGaVrc5dupLEKmRlMXTGvFKOtbaGRQ2+yQJkjVhpdHhoRhU4+G+tJDFeHDTS8tK3bfQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@internationalized/date": "^3.12.2",
|
||||
"@internationalized/date": "^3.12.3",
|
||||
"@internationalized/number": "^3.6.7",
|
||||
"@internationalized/string": "^3.2.9",
|
||||
"@react-types/shared": "^3.36.0",
|
||||
"@internationalized/string": "^3.2.10",
|
||||
"@react-types/shared": "^3.36.1",
|
||||
"@swc/helpers": "^0.5.0",
|
||||
"aria-hidden": "^1.2.3",
|
||||
"clsx": "^2.0.0",
|
||||
"react-stately": "3.48.0",
|
||||
"react-stately": "3.49.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -9405,17 +9405,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-aria-components": {
|
||||
"version": "1.19.0",
|
||||
"resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.19.0.tgz",
|
||||
"integrity": "sha512-2smSS5nqJ8cGYMQezuUXveZm7eMyHCqTN6mDpylQBYLYbdF5dxCCuW1DHn1VKLe1DybSfPvX/cZtJlDmvFfn8A==",
|
||||
"version": "1.20.0",
|
||||
"resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.20.0.tgz",
|
||||
"integrity": "sha512-BMbpIgoV9aELeBrB0Y120NgoigHb5OdcJwc+4e7uSnbTbamea6lo+gqcc4LAxzMaK3Jf+7LI1oCDE6yANsmxIQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@internationalized/date": "^3.12.2",
|
||||
"@react-types/shared": "^3.36.0",
|
||||
"@internationalized/date": "^3.12.3",
|
||||
"@internationalized/string": "^3.2.10",
|
||||
"@react-types/shared": "^3.36.1",
|
||||
"@swc/helpers": "^0.5.0",
|
||||
"client-only": "^0.0.1",
|
||||
"react-aria": "3.50.0",
|
||||
"react-stately": "3.48.0"
|
||||
"react-aria": "3.51.0",
|
||||
"react-stately": "3.49.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||
@@ -9469,15 +9470,15 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react-stately": {
|
||||
"version": "3.48.0",
|
||||
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.48.0.tgz",
|
||||
"integrity": "sha512-ImicSAG+lTotAe5izcs1fz49Zk48w7pDusqYg04WaPhCoej8BJ24soMu3iLXIrsi273s4P1gZrYGrqReMfgEEA==",
|
||||
"version": "3.49.0",
|
||||
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.49.0.tgz",
|
||||
"integrity": "sha512-13iNq2KzBrRAzxRc+n53hgROfIistiYY/sPtIhCw1qUB7/kmo+X1xEU2uiS5zcCIrc55AUPwoHqOIIpKWSwB9A==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@internationalized/date": "^3.12.2",
|
||||
"@internationalized/date": "^3.12.3",
|
||||
"@internationalized/number": "^3.6.7",
|
||||
"@internationalized/string": "^3.2.9",
|
||||
"@react-types/shared": "^3.36.0",
|
||||
"@internationalized/string": "^3.2.10",
|
||||
"@react-types/shared": "^3.36.1",
|
||||
"@swc/helpers": "^0.5.0",
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
|
||||
@@ -38,11 +38,11 @@
|
||||
"livekit-client": "2.21.0",
|
||||
"posthog-js": "1.418.10",
|
||||
"react": "18.3.1",
|
||||
"react-aria": "3.50.0",
|
||||
"react-aria-components": "1.19.0",
|
||||
"react-aria": "3.51.0",
|
||||
"react-aria-components": "1.20.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "17.0.12",
|
||||
"react-stately": "3.48.0",
|
||||
"react-stately": "3.49.0",
|
||||
"use-sound": "5.0.0",
|
||||
"valtio": "2.3.2",
|
||||
"wouter": "3.10.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const mediaUrl = (path: string) => {
|
||||
const origin =
|
||||
import.meta.env.VITE_API_BASE_URL ||
|
||||
import.meta.env.VITE_MEDIA_BASE_URL ||
|
||||
(typeof window !== 'undefined' ? window.location.origin : '')
|
||||
|
||||
// Remove leading/trailing slashes from origin/path if it exists
|
||||
|
||||
@@ -22,12 +22,14 @@ export interface ApiConfig {
|
||||
url: string
|
||||
}
|
||||
documentation_url?: string
|
||||
technical_documentation_url?: string
|
||||
external_home_url?: string
|
||||
silence_livekit_debug_logs?: boolean
|
||||
is_silent_login_enabled?: boolean
|
||||
custom_css_url?: string
|
||||
use_french_gov_footer?: boolean
|
||||
use_proconnect_button?: boolean
|
||||
allow_unregistered_rooms?: boolean
|
||||
idle_disconnect_warning_delay?: number
|
||||
recording?: {
|
||||
is_enabled?: boolean
|
||||
|
||||
@@ -22,6 +22,12 @@ export type IceCandidateInfo = {
|
||||
port?: number
|
||||
/** Local candidates only, and not reported by every browser. */
|
||||
networkType?: string
|
||||
/**
|
||||
* For a local relay candidate, the TURN URL it was gathered from
|
||||
* (e.g. `turns:turn.example.com:443?transport=tcp`). Used as a fallback
|
||||
* when the browser does not report `relayProtocol`.
|
||||
*/
|
||||
url?: string
|
||||
}
|
||||
|
||||
export type IceCandidatePair = {
|
||||
@@ -42,6 +48,57 @@ export type IceCandidateReport = {
|
||||
working: IceCandidatePair[]
|
||||
}
|
||||
|
||||
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null
|
||||
|
||||
/** Narrows the loosely typed `data` stored on a step result. */
|
||||
export const isIceCandidateReport = (
|
||||
data: unknown
|
||||
): data is IceCandidateReport =>
|
||||
isObject(data) &&
|
||||
Array.isArray(data.working) &&
|
||||
(data.selected === null ||
|
||||
(isObject(data.selected) && isObject(data.selected.local)))
|
||||
|
||||
/**
|
||||
* Transport between the browser and the TURN server for a local relay
|
||||
* candidate: udp, tcp or tls, or undefined when it cannot be determined.
|
||||
*
|
||||
* `protocol` is deliberately not used here: on a relay candidate it describes
|
||||
* the TURN allocation (server to peer), which is UDP even when the client
|
||||
* reaches the TURN server over TLS.
|
||||
*/
|
||||
export const getRelayTransport = (
|
||||
candidate: IceCandidateInfo
|
||||
): string | undefined => {
|
||||
if (candidate.relayProtocol) return candidate.relayProtocol.toLowerCase()
|
||||
if (!candidate.url) return undefined
|
||||
|
||||
const url = candidate.url.toLowerCase()
|
||||
if (url.startsWith('turns:')) return 'tls'
|
||||
if (!url.startsWith('turn:')) return undefined
|
||||
const transport = /[?&]transport=(udp|tcp)\b/.exec(url)?.[1]
|
||||
// RFC 7065: a turn: URI without a transport parameter defaults to UDP.
|
||||
return transport ?? 'udp'
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the selected pair goes through a TURN relay reached over TCP or
|
||||
* TLS. Media still flows, but TCP head-of-line blocking usually degrades
|
||||
* audio and video under packet loss.
|
||||
*
|
||||
* Direct routes (host, srflx, prflx), including ICE-TCP to the SFU, are out of
|
||||
* scope: the warning and its documentation are about TURN fallbacks.
|
||||
* An undetermined transport is not evidence of a bad route.
|
||||
*/
|
||||
export const isRelayedOverTcp = (data: unknown): boolean => {
|
||||
if (!isIceCandidateReport(data) || !data.selected) return false
|
||||
const { local } = data.selected
|
||||
if (local.type !== 'relay') return false
|
||||
const transport = getRelayTransport(local)
|
||||
return transport === 'tcp' || transport === 'tls'
|
||||
}
|
||||
|
||||
const PROBE_WIDTH = 320
|
||||
const PROBE_HEIGHT = 180
|
||||
const PROBE_FPS = 15
|
||||
@@ -57,6 +114,7 @@ const readCandidate = (stats?: Stats): IceCandidateInfo => {
|
||||
protocol: stats.protocol as string | undefined,
|
||||
relayProtocol: stats.relayProtocol as string | undefined,
|
||||
networkType: stats.networkType as string | undefined,
|
||||
url: stats.url as string | undefined,
|
||||
...(INCLUDE_CANDIDATE_ADDRESSES
|
||||
? {
|
||||
address: stats.address as string | undefined,
|
||||
@@ -67,7 +125,10 @@ const readCandidate = (stats?: Stats): IceCandidateInfo => {
|
||||
}
|
||||
|
||||
const describeCandidate = (candidate: IceCandidateInfo) => {
|
||||
const transport = candidate.relayProtocol ?? candidate.protocol ?? 'unknown'
|
||||
const transport =
|
||||
(candidate.type === 'relay' ? getRelayTransport(candidate) : undefined) ??
|
||||
candidate.protocol ??
|
||||
'unknown'
|
||||
const endpoint =
|
||||
candidate.address === undefined
|
||||
? ''
|
||||
|
||||
@@ -2,18 +2,44 @@ import type { ReactNode } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ProgressBar } from 'react-aria-components'
|
||||
import { css, cx } from '@/styled-system/css'
|
||||
import { A } from '@/primitives'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
import type { ConnectionTestStats } from '../types'
|
||||
import { statusSquareClass } from './stepAppearance'
|
||||
|
||||
type SummaryState = 'idle' | 'running' | 'passed' | 'partial' | 'failed'
|
||||
type SummaryState =
|
||||
| 'idle'
|
||||
| 'running'
|
||||
| 'passed'
|
||||
| 'partial'
|
||||
| 'failed'
|
||||
| 'warning'
|
||||
|
||||
/** Only a failure earns a colour: everything else stays near-black. */
|
||||
/** Only a failure or a degraded route earns a colour: everything else stays near-black. */
|
||||
const stateColorClass: Record<SummaryState, string> = {
|
||||
idle: css({ color: 'greyscale.1000' }),
|
||||
running: css({ color: 'greyscale.1000' }),
|
||||
passed: css({ color: 'greyscale.1000' }),
|
||||
partial: css({ color: 'greyscale.1000' }),
|
||||
failed: css({ color: 'danger.600' }),
|
||||
warning: css({ color: 'warning' }),
|
||||
}
|
||||
|
||||
/**
|
||||
* A hard failure still outranks a warning step; a warning outranks 'partial'
|
||||
* because a measured degraded route matters more than skipped camera or
|
||||
* microphone checks.
|
||||
*/
|
||||
const getSummaryState = (
|
||||
stats: ConnectionTestStats,
|
||||
isRunning: boolean
|
||||
): SummaryState => {
|
||||
if (isRunning) return 'running'
|
||||
if (!stats.hasStarted) return 'idle'
|
||||
if (stats.failed > 0) return 'failed'
|
||||
if (stats.warnings > 0) return 'warning'
|
||||
if (stats.skipped > 0) return 'partial'
|
||||
return 'passed'
|
||||
}
|
||||
|
||||
const cardClass = css({
|
||||
@@ -183,16 +209,15 @@ export const ConnectionTestSummary = ({
|
||||
children?: ReactNode
|
||||
}) => {
|
||||
const { t } = useTranslation('connectionTest')
|
||||
const { data: config } = useConfig()
|
||||
|
||||
const state: SummaryState = isRunning
|
||||
? 'running'
|
||||
: !stats.hasStarted
|
||||
? 'idle'
|
||||
: stats.failed > 0
|
||||
? 'failed'
|
||||
: stats.skipped > 0
|
||||
? 'partial'
|
||||
: 'passed'
|
||||
// Network prerequisites for the reader's IT department. Instance specific,
|
||||
// so it comes from the backend; without it the warning shows no link.
|
||||
const networkDocUrl = config?.technical_documentation_url
|
||||
|
||||
const state = getSummaryState(stats, isRunning)
|
||||
// Skipped device checks still deserve their hint under a route warning.
|
||||
const showPartialHint = state === 'warning' && stats.skipped > 0
|
||||
|
||||
return (
|
||||
<section className={cardClass}>
|
||||
@@ -206,7 +231,27 @@ export const ConnectionTestSummary = ({
|
||||
: t(`summary.${state}`)}
|
||||
</p>
|
||||
|
||||
<p className={hintClass}>{t(`summary.${state}Hint`)}</p>
|
||||
<p className={hintClass}>
|
||||
{t(`summary.${state}Hint`)}
|
||||
{state === 'warning' && networkDocUrl && (
|
||||
<>
|
||||
{' '}
|
||||
<A
|
||||
href={networkDocUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
size="sm"
|
||||
externalIcon
|
||||
aria-label={t('summary.warningDocLinkAriaLabel')}
|
||||
>
|
||||
{t('summary.warningDocLink')}
|
||||
</A>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{showPartialHint && (
|
||||
<p className={hintClass}>{t('summary.partialHint')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{stats.hasStarted && (
|
||||
@@ -237,6 +282,13 @@ export const ConnectionTestSummary = ({
|
||||
value={stats.passed}
|
||||
label={t('counts.passed')}
|
||||
/>
|
||||
{stats.warnings > 0 && (
|
||||
<Counter
|
||||
squareClass={statusSquareClass.warning}
|
||||
value={stats.warnings}
|
||||
label={t('counts.warnings')}
|
||||
/>
|
||||
)}
|
||||
<Counter
|
||||
squareClass={statusSquareClass.skipped}
|
||||
value={stats.skipped}
|
||||
|
||||
@@ -15,15 +15,20 @@ export const statusSquareClass: Record<ConnectionTestStepStatus, string> = {
|
||||
animation: 'pulse_background 1.2s ease-in-out infinite',
|
||||
}),
|
||||
success: css({ backgroundColor: 'success.600' }),
|
||||
warning: css({ backgroundColor: 'warning' }),
|
||||
failed: css({ backgroundColor: 'danger.600' }),
|
||||
skipped: css({ backgroundColor: 'greyscale.300' }),
|
||||
}
|
||||
|
||||
/** Colour is carried by the square; the label stays near-black except on failure. */
|
||||
/**
|
||||
* Colour is carried by the square; the label stays near-black except on
|
||||
* failure and warning.
|
||||
*/
|
||||
export const statusTextClass: Record<ConnectionTestStepStatus, string> = {
|
||||
pending: css({ color: 'greyscale.500' }),
|
||||
running: css({ color: 'greyscale.700' }),
|
||||
success: css({ color: 'greyscale.1000' }),
|
||||
warning: css({ color: 'warning', fontWeight: 'medium' }),
|
||||
failed: css({ color: 'danger.600', fontWeight: 'medium' }),
|
||||
skipped: css({ color: 'greyscale.500' }),
|
||||
}
|
||||
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
type CheckInfo,
|
||||
} from 'livekit-client'
|
||||
import { fetchConnectionTestDetails } from '../api/fetchConnectionTestDetails'
|
||||
import { SelectedCandidateCheck } from '../checks/selectedCandidate'
|
||||
import {
|
||||
isRelayedOverTcp,
|
||||
SelectedCandidateCheck,
|
||||
} from '../checks/selectedCandidate'
|
||||
import {
|
||||
createInitialSteps,
|
||||
type ConnectionTestLog,
|
||||
@@ -49,10 +52,23 @@ const getErrorMessage = (error: unknown, fallback = 'Unknown error') =>
|
||||
const isPermissionError = (error: unknown) =>
|
||||
error instanceof Error && PERMISSION_ERROR_NAMES.has(error.name)
|
||||
|
||||
const toStepStatus = (info: CheckInfo): ConnectionTestStepStatus => {
|
||||
const status = CHECK_STATUS_TO_STEP[info.status] ?? 'failed'
|
||||
return status === 'success' && isRelayedOverTcp(info.data)
|
||||
? 'warning'
|
||||
: status
|
||||
}
|
||||
|
||||
const fromCheckInfo = (info: CheckInfo): Partial<ConnectionTestStepResult> => ({
|
||||
status: CHECK_STATUS_TO_STEP[info.status] ?? 'failed',
|
||||
status: toStepStatus(info),
|
||||
summary: info.description,
|
||||
logs: info.logs,
|
||||
// Only SelectedCandidateCheck sets `data` (the ICE candidate report).
|
||||
// Consumers narrow it with a type guard (see isIceCandidateReport).
|
||||
data:
|
||||
typeof info.data === 'object' && info.data !== null
|
||||
? (info.data as Record<string, unknown>)
|
||||
: undefined,
|
||||
})
|
||||
|
||||
const groupDevicesByKind = (devices: MediaDeviceInfo[]) => {
|
||||
|
||||
@@ -15,6 +15,7 @@ export type ConnectionTestStepStatus =
|
||||
| 'pending'
|
||||
| 'running'
|
||||
| 'success'
|
||||
| 'warning'
|
||||
| 'failed'
|
||||
| 'skipped'
|
||||
|
||||
@@ -63,6 +64,7 @@ export type ConnectionTestStats = {
|
||||
total: number
|
||||
settled: number
|
||||
passed: number
|
||||
warnings: number
|
||||
failed: number
|
||||
skipped: number
|
||||
hasStarted: boolean
|
||||
@@ -77,24 +79,27 @@ export const summarizeSteps = (
|
||||
steps: ConnectionTestStepResult[]
|
||||
): ConnectionTestStats => {
|
||||
let passed = 0
|
||||
let warnings = 0
|
||||
let failed = 0
|
||||
let skipped = 0
|
||||
let pending = 0
|
||||
|
||||
for (const step of steps) {
|
||||
if (step.status === 'success') passed += 1
|
||||
else if (step.status === 'warning') warnings += 1
|
||||
else if (step.status === 'failed') failed += 1
|
||||
else if (step.status === 'skipped') skipped += 1
|
||||
else if (step.status === 'pending') pending += 1
|
||||
}
|
||||
|
||||
const total = steps.length
|
||||
const settled = passed + failed + skipped
|
||||
const settled = passed + warnings + failed + skipped
|
||||
|
||||
return {
|
||||
total,
|
||||
settled,
|
||||
passed,
|
||||
warnings,
|
||||
failed,
|
||||
skipped,
|
||||
hasStarted: pending < total,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/primitives'
|
||||
import { navigateTo } from '@/navigation/navigateTo'
|
||||
import { generateRoomId } from '@/features/rooms'
|
||||
|
||||
export const CreateUnregisteredMeetingButton = () => {
|
||||
const { t } = useTranslation('home')
|
||||
return (
|
||||
<Button
|
||||
variant="primary"
|
||||
data-attr="create-unregistered-meeting"
|
||||
onPress={() =>
|
||||
navigateTo('room', generateRoomId(), { state: { create: true } })
|
||||
}
|
||||
>
|
||||
{t('createMeeting')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { JoinMeetingDialog } from '../components/JoinMeetingDialog'
|
||||
import { IntroSlider } from '../components/IntroSlider'
|
||||
import { MoreLink } from '../components/MoreLink'
|
||||
import { CreateMeetingMenu } from '../components/CreateMeetingMenu'
|
||||
import { CreateUnregisteredMeetingButton } from '../components/CreateUnregisteredMeetingButton'
|
||||
import { ReactNode, useEffect, useState } from 'react'
|
||||
|
||||
import { css } from '@/styled-system/css'
|
||||
@@ -189,13 +190,19 @@ const Home = () => {
|
||||
display: 'flex',
|
||||
gap: 0.5,
|
||||
flexDirection: { base: 'column', xsm: 'row' },
|
||||
flexWrap: 'wrap',
|
||||
alignItems: { base: 'center', xsm: 'items-start' },
|
||||
})}
|
||||
>
|
||||
{isLoggedIn ? (
|
||||
<CreateMeetingMenu />
|
||||
) : (
|
||||
<LoginButton proConnectHint={false} />
|
||||
<>
|
||||
{data?.allow_unregistered_rooms && (
|
||||
<CreateUnregisteredMeetingButton />
|
||||
)}
|
||||
<LoginButton proConnectHint={false} />
|
||||
</>
|
||||
)}
|
||||
<DialogTrigger>
|
||||
<Button
|
||||
|
||||
@@ -12,8 +12,6 @@ export enum ApiLobbyStatus {
|
||||
DENIED = 'denied',
|
||||
TIMEOUT = 'timeout',
|
||||
ACCEPTED = 'accepted',
|
||||
// Client-side only: the room was deleted while waiting
|
||||
DELETED = 'deleted',
|
||||
}
|
||||
|
||||
export interface ApiRequestEntry {
|
||||
|
||||
@@ -85,7 +85,6 @@ export const Conference = ({
|
||||
const {
|
||||
status: fetchStatus,
|
||||
isError: isFetchError,
|
||||
error: fetchError,
|
||||
data,
|
||||
} = useQuery({
|
||||
queryKey: fetchKey,
|
||||
@@ -99,8 +98,6 @@ 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,
|
||||
})
|
||||
@@ -201,15 +198,6 @@ 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 (
|
||||
@@ -294,7 +282,6 @@ export const Conference = ({
|
||||
return
|
||||
case DisconnectReason.DUPLICATE_IDENTITY:
|
||||
case DisconnectReason.PARTICIPANT_REMOVED:
|
||||
case DisconnectReason.ROOM_DELETED:
|
||||
navigateTo(
|
||||
'feedback',
|
||||
{},
|
||||
|
||||
@@ -40,11 +40,15 @@ const StyledRACDialog = styled(Dialog, {
|
||||
})
|
||||
|
||||
export const InviteDialog = ({ mode }: { mode: 'join' | 'create' }) => {
|
||||
const [showInviteDialog, setShowInviteDialog] = useState(mode === 'create')
|
||||
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'shareDialog' })
|
||||
|
||||
const roomData = useRoomData()
|
||||
|
||||
const isCreatingUnregisteredRoom =
|
||||
roomData?.id === null && !!history.state?.create
|
||||
const [isDismissed, setIsDismissed] = useState(false)
|
||||
const showInviteDialog =
|
||||
!isDismissed && (mode === 'create' || isCreatingUnregisteredRoom)
|
||||
const roomUrl = roomData?.slug ? getRouteUrl('room', roomData.slug) : ''
|
||||
|
||||
const telephony = useTelephony()
|
||||
@@ -78,7 +82,7 @@ export const InviteDialog = ({ mode }: { mode: 'join' | 'create' }) => {
|
||||
variant="tertiaryText"
|
||||
size="xs"
|
||||
onPress={() => {
|
||||
setShowInviteDialog(false)
|
||||
setIsDismissed(true)
|
||||
}}
|
||||
aria-label={t('closeDialog')}
|
||||
>
|
||||
|
||||
@@ -74,9 +74,7 @@ export const Lobby = ({
|
||||
const { openLoginHint } = useLoginHint()
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const { data, error } = await refetchRoom()
|
||||
|
||||
if (error?.statusCode == 410) return
|
||||
const { data } = await refetchRoom()
|
||||
|
||||
if (!data?.livekit) {
|
||||
// Display a message to inform the user that by logging in, they won't have to wait for room entry approval.
|
||||
@@ -90,22 +88,7 @@ export const Lobby = ({
|
||||
enterRoom()
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
|
||||
switch (status) {
|
||||
case ApiLobbyStatus.TIMEOUT:
|
||||
return (
|
||||
<VStack alignItems="center" textAlign="center">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
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,21 +39,10 @@ export const useLobby = ({
|
||||
const { data: waitingData } = useQuery({
|
||||
queryKey: [keys.requestEntry, roomId],
|
||||
queryFn: async () => {
|
||||
let response: ApiRequestEntry
|
||||
try {
|
||||
response = await requestEntry({
|
||||
roomId,
|
||||
username,
|
||||
})
|
||||
} 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
|
||||
}
|
||||
const response = await requestEntry({
|
||||
roomId,
|
||||
username,
|
||||
})
|
||||
if (response.status === ApiLobbyStatus.ACCEPTED) {
|
||||
clearWaitingTimeout()
|
||||
setStatus(ApiLobbyStatus.ACCEPTED)
|
||||
|
||||
@@ -31,7 +31,6 @@ const buttonClass = css({
|
||||
enum DisconnectReasonKey {
|
||||
DuplicateIdentity = 'duplicateIdentity',
|
||||
ParticipantRemoved = 'participantRemoved',
|
||||
RoomDeleted = 'roomDeleted',
|
||||
}
|
||||
|
||||
const FeedbackRoute = () => {
|
||||
@@ -47,8 +46,6 @@ const FeedbackRoute = () => {
|
||||
return DisconnectReasonKey.DuplicateIdentity
|
||||
case DisconnectReason.PARTICIPANT_REMOVED:
|
||||
return DisconnectReasonKey.ParticipantRemoved
|
||||
case DisconnectReason.ROOM_DELETED:
|
||||
return DisconnectReasonKey.RoomDeleted
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -59,10 +56,7 @@ const FeedbackRoute = () => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Rejoining is not possible once removed or once the room is deleted
|
||||
const showBackButton =
|
||||
reasonKey !== DisconnectReasonKey.ParticipantRemoved &&
|
||||
reasonKey !== DisconnectReasonKey.RoomDeleted
|
||||
const showBackButton = reasonKey !== DisconnectReasonKey.ParticipantRemoved
|
||||
|
||||
return (
|
||||
<Screen layout="centered" footer={false}>
|
||||
|
||||
@@ -126,6 +126,9 @@ export const Footer = () => {
|
||||
return null
|
||||
}
|
||||
|
||||
const isConnectionTestEnabled = !!data.diagnostics?.connection_test_enabled
|
||||
const technicalDocumentationUrl = data.technical_documentation_url
|
||||
|
||||
return (
|
||||
<footer
|
||||
className={css({
|
||||
@@ -256,7 +259,9 @@ export const Footer = () => {
|
||||
{t('links.data')}
|
||||
</A>
|
||||
</StyledLi>
|
||||
<StyledLi divider>
|
||||
<StyledLi
|
||||
divider={isConnectionTestEnabled || !!technicalDocumentationUrl}
|
||||
>
|
||||
<Link
|
||||
underline={false}
|
||||
footer="minor"
|
||||
@@ -266,8 +271,8 @@ export const Footer = () => {
|
||||
{t('links.accessibility')}
|
||||
</Link>
|
||||
</StyledLi>
|
||||
{data?.diagnostics?.connection_test_enabled && (
|
||||
<StyledLi divider>
|
||||
{isConnectionTestEnabled && (
|
||||
<StyledLi divider={!!technicalDocumentationUrl}>
|
||||
<Link
|
||||
underline={false}
|
||||
footer="minor"
|
||||
@@ -278,19 +283,21 @@ export const Footer = () => {
|
||||
</Link>
|
||||
</StyledLi>
|
||||
)}
|
||||
<StyledLi>
|
||||
<A
|
||||
externalIcon
|
||||
underline={false}
|
||||
footer="minor"
|
||||
href="https://docs.numerique.gouv.fr/docs/f2baa1b9-f29e-4d58-959d-65d4376fc6b8/"
|
||||
aria-label={
|
||||
t('links.technicalDetails') + ' - ' + t('links.ariaLabel')
|
||||
}
|
||||
>
|
||||
{t('links.technicalDetails')}
|
||||
</A>
|
||||
</StyledLi>
|
||||
{technicalDocumentationUrl && (
|
||||
<StyledLi>
|
||||
<A
|
||||
externalIcon
|
||||
underline={false}
|
||||
footer="minor"
|
||||
href={technicalDocumentationUrl}
|
||||
aria-label={
|
||||
t('links.technicalDetails') + ' - ' + t('links.ariaLabel')
|
||||
}
|
||||
>
|
||||
{t('links.technicalDetails')}
|
||||
</A>
|
||||
</StyledLi>
|
||||
)}
|
||||
</SecondRow>
|
||||
<ThirdRow>
|
||||
{t('mentions')}{' '}
|
||||
|
||||
@@ -29,11 +29,13 @@
|
||||
"pending": "Ausstehend",
|
||||
"running": "Läuft…",
|
||||
"success": "Erfolgreich",
|
||||
"warning": "Nicht optimal",
|
||||
"failed": "Fehlgeschlagen",
|
||||
"skipped": "Übersprungen"
|
||||
},
|
||||
"counts": {
|
||||
"passed": "erfolgreich",
|
||||
"warnings": "nicht optimal",
|
||||
"failed": "fehlgeschlagen",
|
||||
"skipped": "übersprungen"
|
||||
},
|
||||
@@ -46,6 +48,10 @@
|
||||
"passedHint": "Ihr Browser, Ihre Geräte und Ihr Netzwerk sind für eine Besprechung bereit.",
|
||||
"partial": "Teilweiser Test",
|
||||
"partialHint": "Einige Prüfungen wurden übersprungen. Erlauben Sie den Zugriff auf Ihre Kamera und Ihr Mikrofon, um diese zu testen.",
|
||||
"warning": "Verbindung nicht optimal",
|
||||
"warningHint": "Sie können an Ihren Besprechungen teilnehmen, aber die Bild- und Tonqualität kann aufgrund Ihrer Netzwerkeinstellungen beeinträchtigt sein. Ihre IT-Abteilung kann hier Abhilfe schaffen.",
|
||||
"warningDocLink": "Netzwerkanforderungen für Ihre IT-Abteilung",
|
||||
"warningDocLinkAriaLabel": "Netzwerkanforderungen für Ihre IT-Abteilung öffnen – öffnet in neuem Tab",
|
||||
"failed_one": "{{count}} Prüfung fehlgeschlagen",
|
||||
"failed_other": "{{count}} Prüfungen fehlgeschlagen",
|
||||
"failedHint": "Öffnen Sie die fehlgeschlagenen Prüfungen für weitere Details und senden Sie den Bericht an Ihre IT-Abteilung."
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
"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",
|
||||
"roomDeleted": "Dieses Meeting wurde gelöscht"
|
||||
"participantRemoved": "Du wurdest vom Host aus dem Meeting entfernt"
|
||||
},
|
||||
"home": "Zur Startseite zurückkehren",
|
||||
"back": "Dem Meeting erneut beitreten"
|
||||
@@ -95,10 +94,6 @@
|
||||
"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.",
|
||||
@@ -220,10 +215,6 @@
|
||||
"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",
|
||||
|
||||
@@ -29,11 +29,13 @@
|
||||
"pending": "Pending",
|
||||
"running": "Running…",
|
||||
"success": "Passed",
|
||||
"warning": "Not optimal",
|
||||
"failed": "Failed",
|
||||
"skipped": "Skipped"
|
||||
},
|
||||
"counts": {
|
||||
"passed": "passed",
|
||||
"warnings": "not optimal",
|
||||
"failed": "failed",
|
||||
"skipped": "skipped"
|
||||
},
|
||||
@@ -46,6 +48,10 @@
|
||||
"passedHint": "Your browser, your devices and your network are ready for a meeting.",
|
||||
"partial": "Partially tested",
|
||||
"partialHint": "Some checks were skipped. Allow access to your camera and microphone to test them.",
|
||||
"warning": "Suboptimal connection",
|
||||
"warningHint": "You can join your meetings, but video and audio quality may be reduced because of your network settings. Your IT department can improve this.",
|
||||
"warningDocLink": "Network requirements for your IT department",
|
||||
"warningDocLinkAriaLabel": "Open the network requirements for your IT department - opens in new window",
|
||||
"failed_one": "{{count}} check failed",
|
||||
"failed_other": "{{count}} checks failed",
|
||||
"failedHint": "Open the failed checks below for details, then send the report to your IT department."
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
"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",
|
||||
"roomDeleted": "This meeting has been deleted"
|
||||
"participantRemoved": "You have been removed from the meeting by a host"
|
||||
},
|
||||
"home": "Return to home",
|
||||
"back": "Rejoin the meeting"
|
||||
@@ -95,10 +94,6 @@
|
||||
"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.",
|
||||
@@ -220,10 +215,6 @@
|
||||
"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",
|
||||
|
||||
@@ -29,11 +29,13 @@
|
||||
"pending": "En espera",
|
||||
"running": "En curso…",
|
||||
"success": "Correcto",
|
||||
"warning": "No óptimo",
|
||||
"failed": "Error",
|
||||
"skipped": "Omitido"
|
||||
},
|
||||
"counts": {
|
||||
"passed": "correctas",
|
||||
"warnings": "no óptimas",
|
||||
"failed": "con errores",
|
||||
"skipped": "omitidas"
|
||||
},
|
||||
@@ -46,6 +48,10 @@
|
||||
"passedHint": "Tu navegador, tus dispositivos y tu red están listos para una reunión.",
|
||||
"partial": "Prueba parcial",
|
||||
"partialHint": "Se han omitido algunas comprobaciones. Autoriza el acceso a tu cámara y a tu micrófono para probarlos.",
|
||||
"warning": "Conexión no óptima",
|
||||
"warningHint": "Puedes participar en tus reuniones, pero la calidad de la imagen y del sonido puede verse reducida por la configuración de tu red. Tu servicio informático puede mejorar la situación.",
|
||||
"warningDocLink": "Requisitos de red para tu servicio informático",
|
||||
"warningDocLinkAriaLabel": "Abrir los requisitos de red para tu servicio informático - se abre en una nueva ventana",
|
||||
"failed_one": "{{count}} verificación en error",
|
||||
"failed_other": "{{count}} verificaciones en error",
|
||||
"failedHint": "Abre las verificaciones en error para ver el detalle y transmite después el informe a tu servicio informático."
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
"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",
|
||||
"roomDeleted": "Esta reunión ha sido eliminada"
|
||||
"participantRemoved": "Un administrador te ha expulsado de la llamada"
|
||||
},
|
||||
"home": "Volver al inicio",
|
||||
"back": "Volver a la reunión"
|
||||
@@ -95,10 +94,6 @@
|
||||
"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.",
|
||||
@@ -220,10 +215,6 @@
|
||||
"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",
|
||||
|
||||
@@ -29,11 +29,13 @@
|
||||
"pending": "En attente",
|
||||
"running": "En cours…",
|
||||
"success": "Réussi",
|
||||
"warning": "Non optimal",
|
||||
"failed": "Échec",
|
||||
"skipped": "Ignoré"
|
||||
},
|
||||
"counts": {
|
||||
"passed": "réussis",
|
||||
"warnings": "non optimaux",
|
||||
"failed": "en échec",
|
||||
"skipped": "ignorés"
|
||||
},
|
||||
@@ -46,6 +48,10 @@
|
||||
"passedHint": "Votre navigateur, vos périphériques et votre réseau sont prêts pour une réunion.",
|
||||
"partial": "Test partiel",
|
||||
"partialHint": "Certaines vérifications ont été ignorées. Autorisez l'accès à votre caméra et à votre microphone pour les tester.",
|
||||
"warning": "Connexion non optimale",
|
||||
"warningHint": "Vous pouvez participer à vos réunions, mais la qualité de l'image et du son risque d'être réduite à cause des réglages de votre réseau. Votre service informatique peut améliorer la situation.",
|
||||
"warningDocLink": "Prérequis réseau à transmettre à votre service informatique",
|
||||
"warningDocLinkAriaLabel": "Ouvrir les prérequis réseau à transmettre à votre service informatique - ouvre dans une nouvelle fenêtre",
|
||||
"failed_one": "{{count}} vérification en échec",
|
||||
"failed_other": "{{count}} vérifications en échec",
|
||||
"failedHint": "Ouvrez les vérifications en échec pour voir le détail, puis transmettez le rapport à votre service informatique."
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
"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",
|
||||
"roomDeleted": "Cette réunion a été supprimée"
|
||||
"participantRemoved": "Vous avez été exclu de l'appel par un administrateur"
|
||||
},
|
||||
"home": "Retourner à l'accueil",
|
||||
"back": "Réintégrer la réunion"
|
||||
@@ -95,10 +94,6 @@
|
||||
"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.",
|
||||
@@ -220,10 +215,6 @@
|
||||
"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",
|
||||
|
||||
@@ -29,11 +29,13 @@
|
||||
"pending": "In afwachting",
|
||||
"running": "Bezig…",
|
||||
"success": "Geslaagd",
|
||||
"warning": "Niet optimaal",
|
||||
"failed": "Mislukt",
|
||||
"skipped": "Overgeslagen"
|
||||
},
|
||||
"counts": {
|
||||
"passed": "geslaagd",
|
||||
"warnings": "niet optimaal",
|
||||
"failed": "mislukt",
|
||||
"skipped": "overgeslagen"
|
||||
},
|
||||
@@ -46,6 +48,10 @@
|
||||
"passedHint": "Je browser, apparaten en netwerk zijn klaar voor een vergadering.",
|
||||
"partial": "Gedeeltelijke test",
|
||||
"partialHint": "Sommige controles zijn overgeslagen. Geef toegang tot je camera en microfoon om deze te testen.",
|
||||
"warning": "Verbinding niet optimaal",
|
||||
"warningHint": "Je kunt deelnemen aan je vergaderingen, maar de beeld- en geluidskwaliteit kan minder zijn door de instellingen van je netwerk. Je IT-afdeling kan dit verbeteren.",
|
||||
"warningDocLink": "Netwerkvereisten voor je IT-afdeling",
|
||||
"warningDocLinkAriaLabel": "Netwerkvereisten voor je IT-afdeling openen - opent in nieuw venster",
|
||||
"failed_one": "{{count}} controle mislukt",
|
||||
"failed_other": "{{count}} controles mislukt",
|
||||
"failedHint": "Open de mislukte controles voor meer details en stuur het rapport door naar je IT-afdeling."
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
"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",
|
||||
"roomDeleted": "Deze vergadering is verwijderd"
|
||||
"participantRemoved": "U bent door een beheerder uit het gesprek verwijderd"
|
||||
},
|
||||
"home": "Keer terug naar het hoofdscherm",
|
||||
"back": "Sluit weer bij de vergadering aan"
|
||||
@@ -95,10 +94,6 @@
|
||||
"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.",
|
||||
@@ -220,10 +215,6 @@
|
||||
"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",
|
||||
|
||||
@@ -103,3 +103,8 @@ html:has(.lk-video-conference) {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Same workaround as above, see adobe/react-spectrum#10680 */
|
||||
[role='tooltip'][data-rac]:not([data-placement]) {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -6,6 +6,7 @@ declare const __MEDIAPIPE_VERSION__: string
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL: string
|
||||
readonly VITE_APP_TITLE: string
|
||||
readonly VITE_MEDIA_BASE_URL?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
@@ -157,6 +157,7 @@ backend:
|
||||
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'}"
|
||||
FRONTEND_FEEDBACK: "{'url': 'https://grist.numerique.gouv.fr/o/docs/cbMv4G7pLY3Z/USER-RESEARCH-or-LA-SUITE/f/26'}"
|
||||
FRONTEND_DOCUMENTATION_URL: "https://docs.numerique.gouv.fr/docs/7c5bd65d-3c21-486f-bce1-26e0a921d642/"
|
||||
FRONTEND_TECHNICAL_DOCUMENTATION_URL: "https://docs.numerique.gouv.fr/docs/f2baa1b9-f29e-4d58-959d-65d4376fc6b8/"
|
||||
FRONTEND_MANIFEST_LINK: "https://docs.numerique.gouv.fr/docs/1ef86abf-f7e0-46ce-b6c7-8be8b8af4c3d/"
|
||||
FRONTEND_IDLE_DISCONNECT_WARNING_DELAY: 9000
|
||||
FRONTEND_TRANSCRIPTION_DESTINATION: "https://docs.numerique.gouv.fr"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
#!/bin/sh
|
||||
set -e
|
||||
# Run html-to-text to convert all html files to text files
|
||||
DIR_MAILS="../backend/core/templates/mail/"
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
#!/bin/sh
|
||||
|
||||
# Run mjml command to convert all mjml templates to html files
|
||||
DIR_MAILS="../backend/core/templates/mail/html/"
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build-mjml-to-html": "bash ./bin/mjml-to-html",
|
||||
"build-html-to-plain-text": "bash ./bin/html-to-plain-text",
|
||||
"build-mjml-to-html": "sh ./bin/mjml-to-html",
|
||||
"build-html-to-plain-text": "sh ./bin/html-to-plain-text",
|
||||
"build": "npm run build-mjml-to-html && npm run build-html-to-plain-text"
|
||||
},
|
||||
"volta": {
|
||||
|
||||
@@ -9,7 +9,6 @@ from typing import Any
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
import sentry_sdk
|
||||
from celery import Celery, signals
|
||||
from celery.utils.log import get_task_logger
|
||||
from openai.types.audio import Transcription
|
||||
@@ -42,6 +41,7 @@ from summary.core.prompt import (
|
||||
PROMPT_SYSTEM_TLDR,
|
||||
PROMPT_USER_PART,
|
||||
)
|
||||
from summary.core.sentry import init_sentry
|
||||
from summary.core.shared_models import (
|
||||
SummarizeWebhookFailurePayload,
|
||||
SummarizeWebhookSuccessPayload,
|
||||
@@ -75,12 +75,11 @@ celery = Celery(
|
||||
|
||||
celery.config_from_object("summary.core.celery_config")
|
||||
|
||||
if settings.sentry_dsn and settings.sentry_is_enabled:
|
||||
|
||||
@signals.celeryd_init.connect
|
||||
def init_sentry(**_kwargs):
|
||||
"""Initialize sentry."""
|
||||
sentry_sdk.init(dsn=settings.sentry_dsn, enable_tracing=True)
|
||||
@signals.celeryd_init.connect
|
||||
def init_celery_sentry(**_kwargs):
|
||||
"""Initialize Sentry in the Celery worker."""
|
||||
init_sentry()
|
||||
|
||||
|
||||
file_service = FileService()
|
||||
|
||||
@@ -84,6 +84,8 @@ class Settings(BaseSettings):
|
||||
aws_s3_secret_access_key: SecretStr
|
||||
aws_s3_secure_access: bool = True
|
||||
aws_s3_region_name: str | None = None
|
||||
aws_s3_request_checksum_calculation: str | None = None
|
||||
aws_s3_response_checksum_validation: str | None = None
|
||||
aws_transcript_path: str = "transcripts"
|
||||
aws_summary_path: str = "summaries"
|
||||
|
||||
@@ -126,6 +128,7 @@ class Settings(BaseSettings):
|
||||
# Sentry
|
||||
sentry_is_enabled: bool = False
|
||||
sentry_dsn: Optional[str] = None
|
||||
sentry_traces_sample_rate: float = Field(default=0.1, ge=0.0, le=1.0)
|
||||
|
||||
# Posthog (analytics)
|
||||
posthog_enabled: bool = False
|
||||
|
||||
@@ -286,7 +286,12 @@ def _build_s3_client():
|
||||
aws_access_key_id=settings.aws_s3_access_key_id,
|
||||
aws_secret_access_key=settings.aws_s3_secret_access_key.get_secret_value(),
|
||||
region_name=settings.aws_s3_region_name,
|
||||
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
|
||||
config=Config(
|
||||
signature_version="s3v4",
|
||||
s3={"addressing_style": "path"},
|
||||
request_checksum_calculation=settings.aws_s3_request_checksum_calculation,
|
||||
response_checksum_validation=settings.aws_s3_response_checksum_validation,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Sentry configuration."""
|
||||
|
||||
import sentry_sdk
|
||||
from sentry_sdk.scrubber import DEFAULT_DENYLIST, DEFAULT_PII_DENYLIST, EventScrubber
|
||||
|
||||
from summary.core.config import get_settings
|
||||
|
||||
# Exact names (case-insensitive) of variables and dict keys to redact.
|
||||
SENSITIVE_DATA_DENYLIST = [
|
||||
# Raw payloads and serialized bodies
|
||||
"data",
|
||||
"body",
|
||||
"payload",
|
||||
"args",
|
||||
"kwargs",
|
||||
"response",
|
||||
"res",
|
||||
# Transcripts
|
||||
"transcript",
|
||||
"transcription",
|
||||
"transcription_json",
|
||||
"transcription_res",
|
||||
"new_transcription",
|
||||
"segments",
|
||||
"word_segments",
|
||||
"words",
|
||||
"text",
|
||||
"content",
|
||||
"formatted_output",
|
||||
# Summaries and LLM exchanges
|
||||
"summary",
|
||||
"raw_summary",
|
||||
"cleaned_summary",
|
||||
"tldr",
|
||||
"part",
|
||||
"parts",
|
||||
"parts_summarized",
|
||||
"next_steps",
|
||||
"title",
|
||||
"titles",
|
||||
"action",
|
||||
"line",
|
||||
"lines",
|
||||
"user_prompt",
|
||||
"prompt_user_part",
|
||||
"messages",
|
||||
"json_data", # OpenAI client internals
|
||||
"opts",
|
||||
"options",
|
||||
"input_options",
|
||||
# Participants' personal data
|
||||
"email",
|
||||
"user_email",
|
||||
"assignees",
|
||||
"participant_name",
|
||||
"participant_names",
|
||||
"participants_info",
|
||||
"speaker_to_name",
|
||||
# Signed / pre-authenticated URLs
|
||||
"cloud_storage_url",
|
||||
"transcription_data_url",
|
||||
"summary_data_url",
|
||||
]
|
||||
|
||||
|
||||
def build_event_scrubber() -> EventScrubber:
|
||||
"""Build the scrubber redacting meeting content and personal data."""
|
||||
return EventScrubber(
|
||||
denylist=DEFAULT_DENYLIST + SENSITIVE_DATA_DENYLIST,
|
||||
pii_denylist=DEFAULT_PII_DENYLIST,
|
||||
recursive=True,
|
||||
)
|
||||
|
||||
|
||||
def init_sentry() -> None:
|
||||
"""Initialize Sentry if enabled in the settings."""
|
||||
settings = get_settings()
|
||||
|
||||
if not settings.sentry_is_enabled:
|
||||
return
|
||||
|
||||
if not settings.sentry_dsn:
|
||||
return
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn=settings.sentry_dsn,
|
||||
traces_sample_rate=settings.sentry_traces_sample_rate,
|
||||
# Never attach request bodies, Celery task arguments or user data.
|
||||
send_default_pii=False,
|
||||
# Task creation requests carry the content to summarize.
|
||||
max_request_body_size="never",
|
||||
include_local_variables=True,
|
||||
event_scrubber=build_event_scrubber(),
|
||||
)
|
||||
@@ -1,18 +1,17 @@
|
||||
"""Application."""
|
||||
|
||||
import sentry_sdk
|
||||
from dockerflow.fastapi import router as dockerflow_router
|
||||
from fastapi import FastAPI
|
||||
|
||||
from summary.api.main import api_router_v2
|
||||
from summary.core import checks # noqa: F401 -- registers the Dockerflow checks
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.sentry import init_sentry
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
if settings.sentry_dsn and settings.sentry_is_enabled:
|
||||
sentry_sdk.init(dsn=settings.sentry_dsn, enable_tracing=True)
|
||||
init_sentry()
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
"""Tests for the Sentry configuration.
|
||||
|
||||
Each test raises an error from code handling meeting content and inspects the
|
||||
event Sentry would send: the content must be redacted, while harmless local
|
||||
variables are kept for debugging.
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Callable, Iterator
|
||||
from unittest.mock import Mock
|
||||
|
||||
import httpx
|
||||
import openai
|
||||
import pytest
|
||||
import sentry_sdk
|
||||
from botocore.exceptions import ClientError
|
||||
from botocore.stub import Stubber
|
||||
from sentry_sdk.transport import Transport
|
||||
|
||||
from summary.core import file_service
|
||||
from summary.core import sentry as sentry_module
|
||||
from summary.core.file_service import FileService
|
||||
from summary.core.llm_service import LLMException, LLMService
|
||||
from summary.core.shared_models import WhisperXResponse
|
||||
|
||||
CANARY = "CANARY-MEETING-CONTENT"
|
||||
|
||||
SentryEvents = Callable[[], list[str]]
|
||||
|
||||
|
||||
class _CapturingTransport(Transport):
|
||||
"""Keep serialized events in memory instead of sending them."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.events: list[str] = []
|
||||
|
||||
def capture_envelope(self, envelope):
|
||||
"""Store each serialized event of the envelope."""
|
||||
for item in envelope.items:
|
||||
if item.type == "event":
|
||||
self.events.append(item.payload.get_bytes().decode())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sentry_events() -> Iterator[SentryEvents]:
|
||||
"""Initialize Sentry as in production, with an in-memory transport."""
|
||||
transport = _CapturingTransport()
|
||||
sentry_sdk.init(
|
||||
dsn="https://public@sentry.example.com/1",
|
||||
transport=transport,
|
||||
send_default_pii=False,
|
||||
include_local_variables=True,
|
||||
event_scrubber=sentry_module.build_event_scrubber(),
|
||||
default_integrations=False,
|
||||
)
|
||||
|
||||
def flush() -> list[str]:
|
||||
sentry_sdk.flush()
|
||||
return transport.events
|
||||
|
||||
yield flush
|
||||
sentry_sdk.init() # Disable Sentry for the following tests
|
||||
|
||||
|
||||
def _transcript() -> WhisperXResponse:
|
||||
return WhisperXResponse.model_validate(
|
||||
{
|
||||
"segments": [
|
||||
{
|
||||
"start": 0.0,
|
||||
"end": 1.0,
|
||||
"text": f"I don't know {CANARY}",
|
||||
"speaker": "SPEAKER_01",
|
||||
"words": [
|
||||
{
|
||||
"word": CANARY,
|
||||
"start": 0.0,
|
||||
"end": 1.0,
|
||||
"score": 0.9,
|
||||
"speaker": "SPEAKER_01",
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _local_vars(event: str) -> list[dict]:
|
||||
"""Return the local variables of every frame of the event."""
|
||||
return [
|
||||
frame.get("vars", {})
|
||||
for exception in json.loads(event)["exception"]["values"]
|
||||
for frame in exception["stacktrace"]["frames"]
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def s3_stubber(monkeypatch: pytest.MonkeyPatch) -> Iterator[Stubber]:
|
||||
"""Stub the S3 client built by the file service."""
|
||||
monkeypatch.setattr(
|
||||
file_service,
|
||||
"settings",
|
||||
file_service.settings.model_copy(
|
||||
update={
|
||||
"aws_s3_endpoint_url": "garage:9000",
|
||||
"aws_s3_secure_access": False,
|
||||
"aws_s3_region_name": "fr-par",
|
||||
"aws_storage_bucket_name": "meet-media-storage",
|
||||
}
|
||||
),
|
||||
)
|
||||
stubber = Stubber(file_service._build_s3_client())
|
||||
stubber.activate()
|
||||
monkeypatch.setattr(file_service, "_build_s3_client", lambda: stubber.client)
|
||||
yield stubber
|
||||
stubber.assert_no_pending_responses()
|
||||
|
||||
|
||||
def test_sentry_store_transcript_failure_redacts_transcript(
|
||||
sentry_events: SentryEvents, s3_stubber: Stubber
|
||||
) -> None:
|
||||
"""A failed S3 upload does not send the transcript, but keeps the job id."""
|
||||
s3_stubber.add_client_error("put_object", service_error_code="InvalidDigest")
|
||||
|
||||
try:
|
||||
FileService().store_transcript(transcript=_transcript(), job_id="job-1")
|
||||
except ClientError:
|
||||
sentry_sdk.capture_exception()
|
||||
else:
|
||||
pytest.fail("store_transcript should have failed")
|
||||
|
||||
[event] = sentry_events()
|
||||
assert CANARY not in event
|
||||
|
||||
store_transcript_vars = next(
|
||||
frame_vars
|
||||
for frame_vars in _local_vars(event)
|
||||
if "transcript_path" in frame_vars
|
||||
)
|
||||
assert store_transcript_vars["job_id"] == "'job-1'"
|
||||
assert store_transcript_vars["transcript_path"] == "'transcripts/job-1.json'"
|
||||
assert store_transcript_vars["data"] == "[Filtered]"
|
||||
assert store_transcript_vars["transcript"] == "[Filtered]"
|
||||
|
||||
|
||||
def test_sentry_llm_failure_redacts_prompts(sentry_events: SentryEvents) -> None:
|
||||
"""A failed LLM call does not send the prompts, even from OpenAI internals."""
|
||||
client = openai.OpenAI(
|
||||
api_key="test-key",
|
||||
base_url="https://llm.example.com/v1",
|
||||
max_retries=0,
|
||||
http_client=httpx.Client(
|
||||
transport=httpx.MockTransport(lambda request: httpx.Response(500))
|
||||
),
|
||||
)
|
||||
observability = Mock(is_enabled=False)
|
||||
observability.get_openai_client.return_value = client
|
||||
|
||||
try:
|
||||
LLMService(observability).call(
|
||||
system_prompt="Summarize this meeting.",
|
||||
user_prompt=f"Transcript: {CANARY}",
|
||||
name="tldr",
|
||||
)
|
||||
except LLMException:
|
||||
sentry_sdk.capture_exception()
|
||||
else:
|
||||
pytest.fail("the LLM call should have failed")
|
||||
|
||||
[event] = sentry_events()
|
||||
assert CANARY not in event
|
||||
|
||||
|
||||
def test_init_sentry_uses_the_event_scrubber(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Sentry is initialized with local variables and the content scrubber."""
|
||||
settings = sentry_module.get_settings().model_copy(
|
||||
update={"sentry_is_enabled": True, "sentry_dsn": "https://k@example.com/1"}
|
||||
)
|
||||
monkeypatch.setattr(sentry_module, "get_settings", lambda: settings)
|
||||
init = Mock()
|
||||
monkeypatch.setattr(sentry_module.sentry_sdk, "init", init)
|
||||
|
||||
sentry_module.init_sentry()
|
||||
|
||||
init.assert_called_once()
|
||||
kwargs = init.call_args.kwargs
|
||||
assert kwargs["send_default_pii"] is False
|
||||
assert kwargs["max_request_body_size"] == "never"
|
||||
assert kwargs["include_local_variables"] is True
|
||||
denylist = kwargs["event_scrubber"].denylist
|
||||
assert {"data", "transcript", "content", "summary", "password"} <= set(denylist)
|
||||
|
||||
|
||||
def test_init_sentry_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Sentry is not initialized when disabled."""
|
||||
settings = sentry_module.get_settings().model_copy(
|
||||
update={"sentry_is_enabled": False, "sentry_dsn": "https://k@example.com/1"}
|
||||
)
|
||||
monkeypatch.setattr(sentry_module, "get_settings", lambda: settings)
|
||||
init = Mock()
|
||||
monkeypatch.setattr(sentry_module.sentry_sdk, "init", init)
|
||||
|
||||
sentry_module.init_sentry()
|
||||
|
||||
init.assert_not_called()
|
||||
Reference in New Issue
Block a user