mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-29 22:19:08 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e7e5d1a4c | |||
| d85123d0c5 | |||
| b2abf814fa | |||
| cfdf1c2f92 | |||
| 4139f542d3 | |||
| eda66640df | |||
| 3fa05ea785 | |||
| 30b68052e1 | |||
| 04fd79b56b | |||
| e336122cfa | |||
| 172dc70649 | |||
| e0ab7f191f | |||
| 455b315dbb | |||
| 3089b03062 | |||
| 60febb3b57 | |||
| bf76ab1ddf | |||
| 7565ede0a7 | |||
| 1a15e9f44e | |||
| 7838d8acfe | |||
| 3bb388b937 | |||
| e1cc8105db | |||
| 7844dfcc12 | |||
| ef71003721 | |||
| f74d23c57e | |||
| acedb21045 | |||
| 67e7d382e3 | |||
| 164ac8d948 |
@@ -252,13 +252,13 @@ jobs:
|
|||||||
|
|
||||||
- name: Start MinIO
|
- name: Start MinIO
|
||||||
run: |
|
run: |
|
||||||
docker pull minio/minio
|
docker pull quay.io/minio/minio
|
||||||
docker run -d --name minio \
|
docker run -d --name minio \
|
||||||
-p 9000:9000 \
|
-p 9000:9000 \
|
||||||
-e "MINIO_ACCESS_KEY=meet" \
|
-e "MINIO_ACCESS_KEY=meet" \
|
||||||
-e "MINIO_SECRET_KEY=password" \
|
-e "MINIO_SECRET_KEY=password" \
|
||||||
-v /data/media:/data \
|
-v /data/media:/data \
|
||||||
minio/minio server --console-address :9001 /data
|
quay.io/minio/minio server --console-address :9001 /data
|
||||||
|
|
||||||
# Tool to wait for a service to be ready
|
# Tool to wait for a service to be ready
|
||||||
- name: Install Dockerize
|
- name: Install Dockerize
|
||||||
|
|||||||
@@ -10,9 +10,35 @@ and this project adheres to
|
|||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- ✨(backend) make the LiveKit default video codec configurable
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 📈(frontend) include LiveKit SIDs in the connection analytics event
|
||||||
|
- 🔇(backend) silence expected 401 warnings on /me
|
||||||
|
- 🔇(backend) silence noisy request summary info logs
|
||||||
|
- ⚡️(frontend) defer loading the Crisp script until idle
|
||||||
|
- ⬆️(frontend) upgrade react-i18next from 17.0.10 to 17.0.12
|
||||||
|
- ⬆️(frontend) upgrade posthog-js from 1.414.0 to 1.418.10
|
||||||
|
- ⬆️(addons) upgrade i18next from 26.3.6 to 26.4.0
|
||||||
|
- ⬆️(frontend) upgrade humanize-duration from 3.33.2 to 3.34.1
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🐛(backend) acknowledge unknown LiveKit webhook events instead of 422
|
||||||
|
- 🔒️(backend) enforce display name setting on rename API
|
||||||
|
- 🔒️(backend) reject inactive users in resource server backend
|
||||||
|
|
||||||
|
## [1.31.0] - 2026-09-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
- ✨(frontend) add 1080p sending resolution option #1660
|
- ✨(frontend) add 1080p sending resolution option #1660
|
||||||
- ✨(backend) add Traefik support via configurable media-auth url header #1649
|
- ✨(backend) add Traefik support via configurable media-auth url header #1649
|
||||||
- ✨(backend) update a room's attributes from the external API
|
- ✨(backend) update a room's attributes from the external API
|
||||||
|
- 🔊(backend) log request duration in Gunicorn workers
|
||||||
|
- 📈(frontend) track missing lobby participant on accept/reject
|
||||||
|
- ✨(backend) sort waiting participants by their arrival time
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@@ -26,6 +52,11 @@ and this project adheres to
|
|||||||
- 🐛(frontend) restore automatic lower-hand on speaking
|
- 🐛(frontend) restore automatic lower-hand on speaking
|
||||||
- 🐛(frontend) center Avatar initials with a font-aware cap-height ratio
|
- 🐛(frontend) center Avatar initials with a font-aware cap-height ratio
|
||||||
- 🐛(frontend) keep feedback buttons on one line for fr/es/en
|
- 🐛(frontend) keep feedback buttons on one line for fr/es/en
|
||||||
|
- ⚡️(frontend) increase lobby polling interval on both sides
|
||||||
|
- ⚡️(frontend) add trailing slash on the /me endpoint call
|
||||||
|
- ⚡️(backend) refactor lobby storage to bound key lookups per room
|
||||||
|
- ⚡️(backend) refactor presence cache to bound key lookups per room
|
||||||
|
- 💄(frontend) position the login hint dynamically next to the button
|
||||||
|
|
||||||
## [1.30.0] - 2026-09-01
|
## [1.30.0] - 2026-09-01
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -17,7 +17,7 @@ services:
|
|||||||
|
|
||||||
minio:
|
minio:
|
||||||
user: ${DOCKER_USER:-1000}
|
user: ${DOCKER_USER:-1000}
|
||||||
image: minio/minio
|
image: quay.io/minio/minio
|
||||||
environment:
|
environment:
|
||||||
- MINIO_ROOT_USER=meet
|
- MINIO_ROOT_USER=meet
|
||||||
- MINIO_ROOT_PASSWORD=password
|
- MINIO_ROOT_PASSWORD=password
|
||||||
@@ -35,7 +35,7 @@ services:
|
|||||||
- ./data/media:/data
|
- ./data/media:/data
|
||||||
|
|
||||||
createbuckets:
|
createbuckets:
|
||||||
image: minio/mc
|
image: quay.io/minio/mc
|
||||||
depends_on:
|
depends_on:
|
||||||
minio:
|
minio:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -14,3 +14,4 @@ accesslog = "-"
|
|||||||
# Using '-' for the error log file makes gunicorn log errors to stderr
|
# Using '-' for the error log file makes gunicorn log errors to stderr
|
||||||
errorlog = "-"
|
errorlog = "-"
|
||||||
loglevel = "info"
|
loglevel = "info"
|
||||||
|
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(M)s'
|
||||||
|
|||||||
Generated
+4
-4
@@ -10,7 +10,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "3.50.0",
|
"core-js": "3.50.0",
|
||||||
"i18next": "26.3.6",
|
"i18next": "26.4.0",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"regenerator-runtime": "0.14.1"
|
"regenerator-runtime": "0.14.1"
|
||||||
},
|
},
|
||||||
@@ -9367,9 +9367,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next": {
|
"node_modules/i18next": {
|
||||||
"version": "26.3.6",
|
"version": "26.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.0.tgz",
|
||||||
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
|
"integrity": "sha512-rsmK5bFqsD1AetSFSIa43wtNR4WpvvH4p0tLEsTxkC7QTrfdFm06nbQ95bh8Og4wwaCnUEcm9DVYL2cgxitiQg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "3.50.0",
|
"core-js": "3.50.0",
|
||||||
"i18next": "26.3.6",
|
"i18next": "26.4.0",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"regenerator-runtime": "0.14.1"
|
"regenerator-runtime": "0.14.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.29.0"
|
version = "1.31.0"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"livekit-agents==1.6.7",
|
"livekit-agents==1.6.7",
|
||||||
|
|||||||
Generated
+1
-1
@@ -9,7 +9,7 @@ resolution-markers = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.29.0"
|
version = "1.31.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ def get_frontend_configuration(request):
|
|||||||
"force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL,
|
"force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL,
|
||||||
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
|
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
|
||||||
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
||||||
|
"default_video_codec": settings.LIVEKIT_DEFAULT_VIDEO_CODEC,
|
||||||
},
|
},
|
||||||
"authenticated_users_can_edit_display_name": (
|
"authenticated_users_can_edit_display_name": (
|
||||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
|
||||||
|
|||||||
@@ -909,6 +909,15 @@ class RoomViewSet(
|
|||||||
"""Rename the current participant in the room."""
|
"""Rename the current participant in the room."""
|
||||||
room = self.get_object()
|
room = self.get_object()
|
||||||
|
|
||||||
|
if (
|
||||||
|
not settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
|
||||||
|
and request.user.is_authenticated
|
||||||
|
):
|
||||||
|
return drf_response.Response(
|
||||||
|
{"error": "Authenticated participants cannot edit their display name"},
|
||||||
|
status=drf_status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
|
||||||
serializer = serializers.RenameParticipantSerializer(data=request.data)
|
serializer = serializers.RenameParticipantSerializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
|||||||
@@ -286,6 +286,10 @@ class ResourceServerBackend(LaSuiteBackend):
|
|||||||
if user is None and settings.OIDC_CREATE_USER:
|
if user is None and settings.OIDC_CREATE_USER:
|
||||||
user = self.create_user(sub)
|
user = self.create_user(sub)
|
||||||
|
|
||||||
|
if user is not None and not user.is_active:
|
||||||
|
logger.warning("Inactive user attempted authentication: %s", user.pk)
|
||||||
|
raise SuspiciousOperation("User account is disabled.")
|
||||||
|
|
||||||
return user
|
return user
|
||||||
|
|
||||||
def create_user(self, sub):
|
def create_user(self, sub):
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Logging filters for the core application."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
|
||||||
|
class SilenceExpected401(logging.Filter):
|
||||||
|
"""Drop the expected 401 from anonymous hits on the /me endpoint.
|
||||||
|
|
||||||
|
The frontend probes `/users/me/` to check authentication; a 401 for
|
||||||
|
anonymous users is normal, not a warning worth logging.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def filter(self, record):
|
||||||
|
"""Return False for a 401 on a silenced path, True otherwise."""
|
||||||
|
if getattr(record, "status_code", None) != 401:
|
||||||
|
return True
|
||||||
|
|
||||||
|
request = getattr(record, "request", None)
|
||||||
|
path = getattr(request, "path", None)
|
||||||
|
if not path:
|
||||||
|
return True
|
||||||
|
|
||||||
|
return path not in settings.LOGGING_SILENCED_401_PATHS
|
||||||
@@ -52,12 +52,6 @@ class InvalidPayloadError(LiveKitWebhookError):
|
|||||||
status_code = 400
|
status_code = 400
|
||||||
|
|
||||||
|
|
||||||
class UnsupportedEventTypeError(LiveKitWebhookError):
|
|
||||||
"""Unsupported event type."""
|
|
||||||
|
|
||||||
status_code = 422
|
|
||||||
|
|
||||||
|
|
||||||
class ActionFailedError(LiveKitWebhookError):
|
class ActionFailedError(LiveKitWebhookError):
|
||||||
"""Webhook action fails to process or complete."""
|
"""Webhook action fails to process or complete."""
|
||||||
|
|
||||||
@@ -74,6 +68,7 @@ class LiveKitWebhookEventType(Enum):
|
|||||||
# Participant events
|
# Participant events
|
||||||
PARTICIPANT_JOINED = "participant_joined"
|
PARTICIPANT_JOINED = "participant_joined"
|
||||||
PARTICIPANT_LEFT = "participant_left"
|
PARTICIPANT_LEFT = "participant_left"
|
||||||
|
PARTICIPANT_CONNECTION_ABORTED = "participant_connection_aborted"
|
||||||
|
|
||||||
# Track events
|
# Track events
|
||||||
TRACK_PUBLISHED = "track_published"
|
TRACK_PUBLISHED = "track_published"
|
||||||
@@ -153,10 +148,13 @@ class LiveKitEventsService:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
webhook_type = LiveKitWebhookEventType(data.event)
|
webhook_type = LiveKitWebhookEventType(data.event)
|
||||||
except ValueError as e:
|
except ValueError:
|
||||||
raise UnsupportedEventTypeError(
|
logger.warning(
|
||||||
f"Unknown webhook type: {data.event}"
|
"Ignoring unknown LiveKit webhook event type '%s' for room '%s'",
|
||||||
) from e
|
data.event,
|
||||||
|
room_name,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
# Handle according to received webhook type
|
# Handle according to received webhook type
|
||||||
handler = self._webhook_handlers.get(webhook_type.value)
|
handler = self._webhook_handlers.get(webhook_type.value)
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import logging
|
|||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, FrozenSet, Optional, Sequence, Tuple
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from core import models, utils
|
from core import models, utils
|
||||||
|
|
||||||
@@ -46,6 +47,7 @@ class LobbyParticipant:
|
|||||||
username: str
|
username: str
|
||||||
color: str
|
color: str
|
||||||
id: str
|
id: str
|
||||||
|
entered_at: str
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, str]:
|
def to_dict(self) -> Dict[str, str]:
|
||||||
"""Serialize the participant object to a dict representation."""
|
"""Serialize the participant object to a dict representation."""
|
||||||
@@ -54,6 +56,7 @@ class LobbyParticipant:
|
|||||||
"username": self.username,
|
"username": self.username,
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
"color": self.color,
|
"color": self.color,
|
||||||
|
"entered_at": self.entered_at,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -68,6 +71,7 @@ class LobbyParticipant:
|
|||||||
username=data["username"],
|
username=data["username"],
|
||||||
id=data["id"],
|
id=data["id"],
|
||||||
color=data["color"],
|
color=data["color"],
|
||||||
|
entered_at=data["entered_at"],
|
||||||
)
|
)
|
||||||
except (KeyError, ValueError) as e:
|
except (KeyError, ValueError) as e:
|
||||||
logger.exception("Error creating Participant from dict:")
|
logger.exception("Error creating Participant from dict:")
|
||||||
@@ -86,6 +90,47 @@ class LobbyService:
|
|||||||
"""Generate cache key for participant(s) data."""
|
"""Generate cache key for participant(s) data."""
|
||||||
return f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
|
return f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_index_key(room_id: UUID) -> str:
|
||||||
|
"""Raw Redis key of the per-room participant index (a native SET)."""
|
||||||
|
return cache.client.make_key(f"{settings.LOBBY_KEY_PREFIX}-index_{room_id!s}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _redis(write: bool = True):
|
||||||
|
"""Raw redis-py client.
|
||||||
|
|
||||||
|
SADD/SREM/SMEMBERS are not exposed by the Django cache API; this is
|
||||||
|
the documented django-redis escape hatch.
|
||||||
|
"""
|
||||||
|
return cache.client.get_client(write=write)
|
||||||
|
|
||||||
|
def _index_add(self, room_id: UUID, participant_id: str) -> None:
|
||||||
|
"""Record a participant id in the room index."""
|
||||||
|
index_key = self._get_index_key(room_id)
|
||||||
|
pipe = self._redis().pipeline(transaction=False)
|
||||||
|
pipe.sadd(index_key, participant_id)
|
||||||
|
pipe.expire(index_key, settings.LOBBY_ACCEPTED_TIMEOUT)
|
||||||
|
pipe.execute()
|
||||||
|
|
||||||
|
def _index_members(self, room_id: UUID) -> FrozenSet[str]:
|
||||||
|
"""All participant ids currently indexed for the room."""
|
||||||
|
members = self._redis(write=False).smembers(self._get_index_key(room_id))
|
||||||
|
return frozenset(
|
||||||
|
member.decode() if isinstance(member, bytes) else member
|
||||||
|
for member in members
|
||||||
|
)
|
||||||
|
|
||||||
|
def _index_touch(self, room_id: UUID) -> None:
|
||||||
|
"""Re-arm the room index backstop TTL."""
|
||||||
|
self._redis().expire(
|
||||||
|
self._get_index_key(room_id), settings.LOBBY_ACCEPTED_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
def _index_remove(self, room_id: UUID, *participant_ids: str) -> None:
|
||||||
|
"""Drop participant ids from the room index."""
|
||||||
|
if participant_ids:
|
||||||
|
self._redis().srem(self._get_index_key(room_id), *participant_ids)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_or_create_participant_id(request) -> str:
|
def _get_or_create_participant_id(request) -> str:
|
||||||
"""Extract unique participant identifier from the request."""
|
"""Extract unique participant identifier from the request."""
|
||||||
@@ -162,6 +207,7 @@ class LobbyService:
|
|||||||
username=username,
|
username=username,
|
||||||
id=participant_id,
|
id=participant_id,
|
||||||
color=utils.generate_color(participant_id),
|
color=utils.generate_color(participant_id),
|
||||||
|
entered_at=timezone.now().isoformat(),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
participant.status = LobbyParticipantStatus.ACCEPTED
|
participant.status = LobbyParticipantStatus.ACCEPTED
|
||||||
@@ -209,15 +255,12 @@ class LobbyService:
|
|||||||
cache.touch(
|
cache.touch(
|
||||||
self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT
|
self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT
|
||||||
)
|
)
|
||||||
|
self._index_touch(room_id)
|
||||||
|
|
||||||
def enter(
|
def enter(
|
||||||
self, room_id: UUID, participant_id: str, username: str
|
self, room_id: UUID, participant_id: str, username: str
|
||||||
) -> LobbyParticipant:
|
) -> LobbyParticipant:
|
||||||
"""Add participant to waiting lobby.
|
"""Add participant to waiting lobby."""
|
||||||
|
|
||||||
Create a new participant entry in waiting status and notify room
|
|
||||||
participants of the new entry request.
|
|
||||||
"""
|
|
||||||
|
|
||||||
color = utils.generate_color(participant_id)
|
color = utils.generate_color(participant_id)
|
||||||
|
|
||||||
@@ -226,6 +269,7 @@ class LobbyService:
|
|||||||
username=username,
|
username=username,
|
||||||
id=participant_id,
|
id=participant_id,
|
||||||
color=color,
|
color=color,
|
||||||
|
entered_at=timezone.now().isoformat(),
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -245,6 +289,7 @@ class LobbyService:
|
|||||||
participant.to_dict(),
|
participant.to_dict(),
|
||||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
self._index_add(room_id, participant_id)
|
||||||
|
|
||||||
return participant
|
return participant
|
||||||
|
|
||||||
@@ -266,28 +311,42 @@ class LobbyService:
|
|||||||
cache.delete(cache_key)
|
cache.delete(cache_key)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def list_waiting_participants(self, room_id: UUID) -> List[dict]:
|
def list_waiting_participants(self, room_id: UUID) -> Sequence[dict]:
|
||||||
"""List all waiting participants for a room."""
|
"""List all waiting participants for a room."""
|
||||||
|
|
||||||
pattern = self._get_cache_key(room_id, "*")
|
member_ids = self._index_members(room_id)
|
||||||
keys = list(cache.iter_keys(pattern, itersize=utils.CACHE_SCAN_ITERSIZE))
|
|
||||||
|
|
||||||
if not keys:
|
if not member_ids:
|
||||||
return []
|
return ()
|
||||||
|
|
||||||
data = cache.get_many(keys)
|
keys_by_id = {
|
||||||
|
participant_id: self._get_cache_key(room_id, participant_id)
|
||||||
|
for participant_id in member_ids
|
||||||
|
}
|
||||||
|
data = cache.get_many(list(keys_by_id.values()))
|
||||||
|
|
||||||
|
dead_ids = []
|
||||||
waiting_participants = []
|
waiting_participants = []
|
||||||
for cache_key, raw_participant in data.items():
|
|
||||||
|
for participant_id, cache_key in keys_by_id.items():
|
||||||
|
raw_participant = data.get(cache_key)
|
||||||
|
if raw_participant is None:
|
||||||
|
dead_ids.append(participant_id)
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
participant = LobbyParticipant.from_dict(raw_participant)
|
participant = LobbyParticipant.from_dict(raw_participant)
|
||||||
except LobbyParticipantParsingError:
|
except LobbyParticipantParsingError:
|
||||||
cache.delete(cache_key)
|
cache.delete(cache_key)
|
||||||
|
dead_ids.append(participant_id)
|
||||||
continue
|
continue
|
||||||
if participant.status == LobbyParticipantStatus.WAITING:
|
if participant.status == LobbyParticipantStatus.WAITING:
|
||||||
waiting_participants.append(participant.to_dict())
|
waiting_participants.append(participant.to_dict())
|
||||||
|
|
||||||
return waiting_participants
|
self._index_remove(room_id, *dead_ids)
|
||||||
|
|
||||||
|
waiting_participants.sort(key=lambda p: p["entered_at"], reverse=True)
|
||||||
|
|
||||||
|
return tuple(waiting_participants)
|
||||||
|
|
||||||
def handle_participant_entry(
|
def handle_participant_entry(
|
||||||
self,
|
self,
|
||||||
@@ -341,16 +400,24 @@ class LobbyService:
|
|||||||
|
|
||||||
participant.status = status
|
participant.status = status
|
||||||
cache.set(cache_key, participant.to_dict(), timeout=timeout)
|
cache.set(cache_key, participant.to_dict(), timeout=timeout)
|
||||||
|
self._index_touch(room_id)
|
||||||
|
|
||||||
def clear_room_cache(self, room_id: UUID) -> None:
|
def clear_room_cache(self, room_id: UUID) -> None:
|
||||||
"""Clear all participant entries from the cache for a specific room."""
|
"""Clear all participant entries from the cache for a specific room."""
|
||||||
|
|
||||||
cache.delete_pattern(
|
member_ids = self._index_members(room_id)
|
||||||
self._get_cache_key(room_id, "*"), itersize=utils.CACHE_SCAN_ITERSIZE
|
if member_ids:
|
||||||
)
|
cache.delete_many(
|
||||||
|
[
|
||||||
|
self._get_cache_key(room_id, participant_id)
|
||||||
|
for participant_id in member_ids
|
||||||
|
]
|
||||||
|
)
|
||||||
|
self._redis().delete(self._get_index_key(room_id))
|
||||||
|
|
||||||
def clear_participant_cache(self, room_id: UUID, participant_id: str) -> None:
|
def clear_participant_cache(self, room_id: UUID, participant_id: str) -> None:
|
||||||
"""Clear a given participant entry from the cache for a specific room."""
|
"""Clear a given participant entry from the cache for a specific room."""
|
||||||
|
|
||||||
cache_key = self._get_cache_key(room_id, participant_id)
|
cache_key = self._get_cache_key(room_id, participant_id)
|
||||||
cache.delete(cache_key)
|
cache.delete(cache_key)
|
||||||
|
self._index_remove(room_id, participant_id)
|
||||||
|
|||||||
@@ -1,25 +1,11 @@
|
|||||||
"""Presence cache.
|
"""Presence cache."""
|
||||||
|
|
||||||
Redis-backed memo of "this identity is currently connected to this room".
|
|
||||||
|
|
||||||
This module is intentionally a *pure cache store* with no dependency on other
|
|
||||||
services, so that `participants_management` (which talks to LiveKit) can
|
|
||||||
import it without creating an import cycle. The composition of "check cache,
|
|
||||||
fall back to LiveKit" lives in
|
|
||||||
`ParticipantsManagement.check_if_in_meeting_cached`.
|
|
||||||
|
|
||||||
Only positive answers are stored: a sticky negative would lock out someone
|
|
||||||
who joins right after a miss for the whole TTL. The TTL is a safety net in
|
|
||||||
case an invalidation webhook is lost.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
from typing import FrozenSet
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
|
||||||
from core.utils import CACHE_SCAN_ITERSIZE
|
|
||||||
|
|
||||||
|
|
||||||
class PresenceCache:
|
class PresenceCache:
|
||||||
"""Store and invalidate (room, identity) presence entries."""
|
"""Store and invalidate (room, identity) presence entries."""
|
||||||
@@ -29,24 +15,65 @@ class PresenceCache:
|
|||||||
"""Cache key for a (room, identity) presence entry."""
|
"""Cache key for a (room, identity) presence entry."""
|
||||||
return f"{settings.PRESENCE_KEY_PREFIX}_{room_id!s}_{identity}"
|
return f"{settings.PRESENCE_KEY_PREFIX}_{room_id!s}_{identity}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_index_key(room_id: UUID | str) -> str:
|
||||||
|
"""Raw Redis key of the per-room identity index (a native SET).
|
||||||
|
|
||||||
|
Built through django-redis' make_key so it lives under the same
|
||||||
|
KEY_PREFIX/version namespace as the presence entries.
|
||||||
|
"""
|
||||||
|
return cache.client.make_key(
|
||||||
|
f"{settings.PRESENCE_KEY_PREFIX}-index_{room_id!s}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _redis(write: bool = True):
|
||||||
|
"""Raw redis-py client.
|
||||||
|
|
||||||
|
SADD/SREM/SMEMBERS are not exposed by the Django cache API; this is
|
||||||
|
the documented django-redis escape hatch.
|
||||||
|
"""
|
||||||
|
return cache.client.get_client(write=write)
|
||||||
|
|
||||||
|
def _index_members(self, room_id: UUID | str) -> FrozenSet[str]:
|
||||||
|
"""All identities currently indexed for the room."""
|
||||||
|
members = self._redis(write=False).smembers(self._get_index_key(room_id))
|
||||||
|
return frozenset(
|
||||||
|
member.decode() if isinstance(member, bytes) else member
|
||||||
|
for member in members
|
||||||
|
)
|
||||||
|
|
||||||
def is_marked_present(self, room_id: UUID | str, identity: str) -> bool:
|
def is_marked_present(self, room_id: UUID | str, identity: str) -> bool:
|
||||||
"""Return True if a positive presence entry exists in cache."""
|
"""Return True if a positive presence entry exists in cache."""
|
||||||
return bool(cache.get(self._get_cache_key(room_id, identity)))
|
return bool(cache.get(self._get_cache_key(room_id, identity)))
|
||||||
|
|
||||||
def mark_present(self, room_id: UUID | str, identity: str) -> None:
|
def mark_present(self, room_id: UUID | str, identity: str) -> None:
|
||||||
"""Record that `identity` is in `room_id`."""
|
"""Record that `identity` is in `room_id` and index it for the room."""
|
||||||
cache.set(
|
cache.set(
|
||||||
self._get_cache_key(room_id, identity),
|
self._get_cache_key(room_id, identity),
|
||||||
True,
|
True,
|
||||||
timeout=settings.PRESENCE_CACHE_TIMEOUT,
|
timeout=settings.PRESENCE_CACHE_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
index_key = self._get_index_key(room_id)
|
||||||
|
pipe = self._redis().pipeline(transaction=False)
|
||||||
|
pipe.sadd(index_key, identity)
|
||||||
|
pipe.expire(index_key, settings.PRESENCE_CACHE_TIMEOUT)
|
||||||
|
pipe.execute()
|
||||||
|
|
||||||
def clear(self, room_id: UUID | str, identity: str) -> None:
|
def clear(self, room_id: UUID | str, identity: str) -> None:
|
||||||
"""Forget presence for one participant (e.g. on participant_left)."""
|
"""Forget presence for one participant (e.g. on participant_left)."""
|
||||||
cache.delete(self._get_cache_key(room_id, identity))
|
cache.delete(self._get_cache_key(room_id, identity))
|
||||||
|
self._redis().srem(self._get_index_key(room_id), identity)
|
||||||
|
|
||||||
def clear_room(self, room_id: UUID | str) -> None:
|
def clear_room(self, room_id: UUID | str) -> None:
|
||||||
"""Forget presence for every participant of a room (on room_finished)."""
|
"""Forget presence for every participant of a room (on room_finished).
|
||||||
cache.delete_pattern(
|
|
||||||
self._get_cache_key(room_id, "*"), itersize=CACHE_SCAN_ITERSIZE
|
Deletes the indexed entries and the index itself with targeted
|
||||||
)
|
commands instead of a full-keyspace pattern scan.
|
||||||
|
"""
|
||||||
|
identities = self._index_members(room_id)
|
||||||
|
if identities:
|
||||||
|
cache.delete_many(
|
||||||
|
[self._get_cache_key(room_id, identity) for identity in identities]
|
||||||
|
)
|
||||||
|
self._redis().delete(self._get_index_key(room_id))
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from unittest import mock
|
|||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from freezegun import freeze_time
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from ... import utils
|
from ... import utils
|
||||||
@@ -24,6 +25,7 @@ pytestmark = pytest.mark.django_db
|
|||||||
# Tests for request_entry endpoint
|
# Tests for request_entry endpoint
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2025-01-01 10:00:00")
|
||||||
def test_request_entry_anonymous(settings):
|
def test_request_entry_anonymous(settings):
|
||||||
"""Anonymous users should be allowed to request entry to a room."""
|
"""Anonymous users should be allowed to request entry to a room."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
@@ -59,6 +61,7 @@ def test_request_entry_anonymous(settings):
|
|||||||
"username": "test_user",
|
"username": "test_user",
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
"color": "mocked-color",
|
"color": "mocked-color",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
"livekit": None,
|
"livekit": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +74,7 @@ def test_request_entry_anonymous(settings):
|
|||||||
assert participant_data.get("username") == "test_user"
|
assert participant_data.get("username") == "test_user"
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2025-01-01 10:00:00")
|
||||||
def test_request_entry_authenticated_user(settings):
|
def test_request_entry_authenticated_user(settings):
|
||||||
"""Authenticated users should be allowed to request entry."""
|
"""Authenticated users should be allowed to request entry."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
@@ -108,6 +112,7 @@ def test_request_entry_authenticated_user(settings):
|
|||||||
"username": "test_user",
|
"username": "test_user",
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
"color": "mocked-color",
|
"color": "mocked-color",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
"livekit": None,
|
"livekit": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +125,7 @@ def test_request_entry_authenticated_user(settings):
|
|||||||
assert participant_data.get("username") == "test_user"
|
assert participant_data.get("username") == "test_user"
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2025-01-01 10:00:00")
|
||||||
def test_request_entry_with_existing_participants(settings):
|
def test_request_entry_with_existing_participants(settings):
|
||||||
"""Anonymous users should be allowed to request entry to a room with existing participants."""
|
"""Anonymous users should be allowed to request entry to a room with existing participants."""
|
||||||
# Create a restricted access room
|
# Create a restricted access room
|
||||||
@@ -138,6 +144,7 @@ def test_request_entry_with_existing_participants(settings):
|
|||||||
"username": "user1",
|
"username": "user1",
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
cache.set(
|
cache.set(
|
||||||
@@ -147,6 +154,7 @@ def test_request_entry_with_existing_participants(settings):
|
|||||||
"username": "user2",
|
"username": "user2",
|
||||||
"status": "accepted",
|
"status": "accepted",
|
||||||
"color": "#654321",
|
"color": "#654321",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -178,6 +186,7 @@ def test_request_entry_with_existing_participants(settings):
|
|||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": participant_id,
|
"id": participant_id,
|
||||||
"username": "test_user",
|
"username": "test_user",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
"color": "mocked-color",
|
"color": "mocked-color",
|
||||||
"livekit": None,
|
"livekit": None,
|
||||||
@@ -192,6 +201,7 @@ def test_request_entry_with_existing_participants(settings):
|
|||||||
assert participant_data.get("username") == "test_user"
|
assert participant_data.get("username") == "test_user"
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2025-01-01 10:00:00")
|
||||||
def test_request_entry_public_room(settings):
|
def test_request_entry_public_room(settings):
|
||||||
"""Entry requests to public rooms should return ACCEPTED status with LiveKit config."""
|
"""Entry requests to public rooms should return ACCEPTED status with LiveKit config."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||||
@@ -230,6 +240,7 @@ def test_request_entry_public_room(settings):
|
|||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": "123",
|
"id": "123",
|
||||||
"username": "test_user",
|
"username": "test_user",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
"status": "accepted",
|
"status": "accepted",
|
||||||
"color": "mocked-color",
|
"color": "mocked-color",
|
||||||
"livekit": {"token": "test-token"},
|
"livekit": {"token": "test-token"},
|
||||||
@@ -240,6 +251,7 @@ def test_request_entry_public_room(settings):
|
|||||||
assert not lobby_keys
|
assert not lobby_keys
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2025-01-01 10:00:00")
|
||||||
def test_request_entry_authenticated_user_public_room(settings):
|
def test_request_entry_authenticated_user_public_room(settings):
|
||||||
"""While authenticated, entry request to public rooms should get accepted."""
|
"""While authenticated, entry request to public rooms should get accepted."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||||
@@ -282,6 +294,7 @@ def test_request_entry_authenticated_user_public_room(settings):
|
|||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||||
"username": "test_user",
|
"username": "test_user",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
"status": "accepted",
|
"status": "accepted",
|
||||||
"color": "mocked-color",
|
"color": "mocked-color",
|
||||||
"livekit": {"token": "test-token"},
|
"livekit": {"token": "test-token"},
|
||||||
@@ -292,6 +305,7 @@ def test_request_entry_authenticated_user_public_room(settings):
|
|||||||
assert not lobby_keys
|
assert not lobby_keys
|
||||||
|
|
||||||
|
|
||||||
|
@freeze_time("2025-01-01 10:00:00")
|
||||||
def test_request_entry_waiting_participant_public_room(settings):
|
def test_request_entry_waiting_participant_public_room(settings):
|
||||||
"""While waiting, entry request to public rooms should get accepted."""
|
"""While waiting, entry request to public rooms should get accepted."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||||
@@ -308,6 +322,7 @@ def test_request_entry_waiting_participant_public_room(settings):
|
|||||||
"username": "user1",
|
"username": "user1",
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -338,6 +353,7 @@ def test_request_entry_waiting_participant_public_room(settings):
|
|||||||
"username": "user1",
|
"username": "user1",
|
||||||
"status": "accepted",
|
"status": "accepted",
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
"livekit": {"token": "test-token"},
|
"livekit": {"token": "test-token"},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -443,6 +459,7 @@ def test_allow_participant_to_enter_success(settings, allow_entry, updated_statu
|
|||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
"username": "foo",
|
"username": "foo",
|
||||||
"color": "123",
|
"color": "123",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -578,6 +595,7 @@ def test_list_waiting_participants_success(settings):
|
|||||||
"username": "user1",
|
"username": "user1",
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
cache.set(
|
cache.set(
|
||||||
@@ -587,28 +605,35 @@ def test_list_waiting_participants_success(settings):
|
|||||||
"username": "user2",
|
"username": "user2",
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
"color": "#654321",
|
"color": "#654321",
|
||||||
|
"entered_at": "2025-01-01T10:05:00+00:00",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
lobby_service = LobbyService()
|
||||||
|
lobby_service._index_add(room.id, "2f7f162f-e7d1-421b-90e7-02bfbfbf8def")
|
||||||
|
lobby_service._index_add(room.id, "f4ca3ab8a6c04ad88097b8da33f60f10")
|
||||||
|
|
||||||
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
|
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
participants = response.json().get("participants")
|
assert response.json() == {
|
||||||
assert sorted(participants, key=lambda p: p["id"]) == [
|
"participants": [
|
||||||
{
|
{
|
||||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
|
||||||
"username": "user1",
|
"username": "user2",
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
"color": "#123456",
|
"color": "#654321",
|
||||||
},
|
"entered_at": "2025-01-01T10:05:00+00:00",
|
||||||
{
|
},
|
||||||
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
|
{
|
||||||
"username": "user2",
|
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||||
"status": "waiting",
|
"username": "user1",
|
||||||
"color": "#654321",
|
"status": "waiting",
|
||||||
},
|
"color": "#123456",
|
||||||
]
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_list_waiting_participants_empty(settings):
|
def test_list_waiting_participants_empty(settings):
|
||||||
|
|||||||
@@ -372,6 +372,67 @@ def test_rename_participant_unexpected_twirp_error(mock_livekit_client, room, to
|
|||||||
mock_livekit_client.aclose.assert_called_once()
|
mock_livekit_client.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("name", ["John Doe", "Admin", "Room Owner"])
|
||||||
|
def test_rename_participant_forbidden_when_display_name_edit_disabled(
|
||||||
|
mock_livekit_client, settings, room, token, name
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Test rename is rejected for authenticated users when the self-hoster
|
||||||
|
disables AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME.
|
||||||
|
"""
|
||||||
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url, {"name": name}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||||
|
assert response.data == {
|
||||||
|
"error": "Authenticated participants cannot edit their display name"
|
||||||
|
}
|
||||||
|
mock_livekit_client.room.update_participant.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_participant_allowed_when_display_name_edit_enabled(
|
||||||
|
mock_livekit_client, settings, room, token
|
||||||
|
):
|
||||||
|
"""Test rename still works for authenticated users when the setting is enabled."""
|
||||||
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = True
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
mock_livekit_client.room.update_participant.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_rename_participant_anonymous_allowed_when_display_name_edit_disabled(
|
||||||
|
mock_livekit_client, settings, room, anonymous_token
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Test the setting only restricts authenticated users: anonymous participants
|
||||||
|
have no account name to fall back on and can still rename themselves.
|
||||||
|
"""
|
||||||
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||||
|
response = client.post(
|
||||||
|
url,
|
||||||
|
{"name": "Guest User"},
|
||||||
|
format="json",
|
||||||
|
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == status.HTTP_200_OK
|
||||||
|
mock_livekit_client.room.update_participant.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
def test_rename_participant_success_anonymous(
|
def test_rename_participant_success_anonymous(
|
||||||
mock_livekit_client, room, anonymous_token
|
mock_livekit_client, room, anonymous_token
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ def test_invalid_payload(client, auth_token, mock_livekit_config):
|
|||||||
|
|
||||||
|
|
||||||
def test_unknown_event_type(client, mock_livekit_config):
|
def test_unknown_event_type(client, mock_livekit_config):
|
||||||
"""Should return 422 for unknown event type."""
|
"""Should acknowledge (200) an unknown event type rather than reject it."""
|
||||||
event_data = json.dumps({"event": "unknown_event_type"})
|
event_data = json.dumps({"event": "unknown_event_type"})
|
||||||
|
|
||||||
# Generate auth token for this specific payload
|
# Generate auth token for this specific payload
|
||||||
@@ -112,10 +112,8 @@ def test_unknown_event_type(client, mock_livekit_config):
|
|||||||
HTTP_AUTHORIZATION=auth_token,
|
HTTP_AUTHORIZATION=auth_token,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 422
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {"status": "success"}
|
||||||
"status": "error",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(LiveKitEventsService, "_handle_room_finished")
|
@mock.patch.object(LiveKitEventsService, "_handle_room_finished")
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ from core.services.livekit_events import (
|
|||||||
AuthenticationError,
|
AuthenticationError,
|
||||||
InvalidPayloadError,
|
InvalidPayloadError,
|
||||||
LiveKitEventsService,
|
LiveKitEventsService,
|
||||||
UnsupportedEventTypeError,
|
|
||||||
api,
|
api,
|
||||||
)
|
)
|
||||||
from core.services.lobby import LobbyService
|
from core.services.lobby import LobbyService
|
||||||
@@ -665,22 +664,27 @@ def test_receive_missing_auth(service):
|
|||||||
|
|
||||||
|
|
||||||
@mock.patch.object(api.WebhookReceiver, "receive")
|
@mock.patch.object(api.WebhookReceiver, "receive")
|
||||||
def test_receive_unsupported_event(mock_receive, service):
|
def test_receive_unknown_event_is_acknowledged(mock_receive, service, caplog):
|
||||||
"""Should raise LiveKitWebhookError for unsupported events."""
|
"""Unknown event types are logged and ignored, not rejected.
|
||||||
|
|
||||||
|
LiveKit adds event types over time and does not retry 4xx responses, so
|
||||||
|
raising here would silently drop the event.
|
||||||
|
"""
|
||||||
mock_request = mock.MagicMock()
|
mock_request = mock.MagicMock()
|
||||||
mock_request.headers = {"Authorization": "test_token"}
|
mock_request.headers = {"Authorization": "test_token"}
|
||||||
mock_request.body = b"{}"
|
mock_request.body = b"{}"
|
||||||
|
|
||||||
# Mock returned data with unsupported event type
|
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
mock_data.room.name = str(uuid.uuid4())
|
mock_data.room.name = str(uuid.uuid4())
|
||||||
mock_data.event = "unsupported_event"
|
mock_data.event = "some_future_event"
|
||||||
mock_receive.return_value = mock_data
|
mock_receive.return_value = mock_data
|
||||||
|
|
||||||
with pytest.raises(
|
with caplog.at_level("WARNING", logger="core.services.livekit_events"):
|
||||||
UnsupportedEventTypeError, match="Unknown webhook type: unsupported_event"
|
service.receive(mock_request) # must not raise
|
||||||
):
|
|
||||||
service.receive(mock_request)
|
assert "Ignoring unknown LiveKit webhook event type 'some_future_event'" in (
|
||||||
|
caplog.text
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(api.WebhookReceiver, "receive")
|
@mock.patch.object(api.WebhookReceiver, "receive")
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
Test lobby service.
|
Test lobby service.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# pylint: disable=W0621,W0613, W0212, R0913
|
# pylint: disable=W0621,W0613, W0212, R0913, C0302
|
||||||
# ruff: noqa: PLR0913, PLR0917
|
# ruff: noqa: PLR0913, PLR0917
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
@@ -14,6 +14,7 @@ from django.core.cache import cache
|
|||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from freezegun import freeze_time
|
||||||
|
|
||||||
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||||
from core.models import RoleChoices, RoomAccessLevel
|
from core.models import RoleChoices, RoomAccessLevel
|
||||||
@@ -24,7 +25,6 @@ from core.services.lobby import (
|
|||||||
LobbyParticipantStatus,
|
LobbyParticipantStatus,
|
||||||
LobbyService,
|
LobbyService,
|
||||||
)
|
)
|
||||||
from core.services.presence import CACHE_SCAN_ITERSIZE
|
|
||||||
from core.utils import NotificationError
|
from core.utils import NotificationError
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
@@ -56,6 +56,7 @@ def participant_dict():
|
|||||||
"username": "test-username",
|
"username": "test-username",
|
||||||
"id": "test-participant-id",
|
"id": "test-participant-id",
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -67,6 +68,7 @@ def participant_data():
|
|||||||
username="test-username",
|
username="test-username",
|
||||||
id="test-participant-id",
|
id="test-participant-id",
|
||||||
color="#123456",
|
color="#123456",
|
||||||
|
entered_at="2025-01-01T10:00:00+00:00",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -78,6 +80,7 @@ def test_lobby_participant_to_dict(participant_data):
|
|||||||
assert result["username"] == "test-username"
|
assert result["username"] == "test-username"
|
||||||
assert result["id"] == "test-participant-id"
|
assert result["id"] == "test-participant-id"
|
||||||
assert result["color"] == "#123456"
|
assert result["color"] == "#123456"
|
||||||
|
assert result["entered_at"] == "2025-01-01T10:00:00+00:00"
|
||||||
|
|
||||||
|
|
||||||
def test_lobby_participant_from_dict_success(participant_dict):
|
def test_lobby_participant_from_dict_success(participant_dict):
|
||||||
@@ -88,6 +91,20 @@ def test_lobby_participant_from_dict_success(participant_dict):
|
|||||||
assert participant.username == "test-username"
|
assert participant.username == "test-username"
|
||||||
assert participant.id == "test-participant-id"
|
assert participant.id == "test-participant-id"
|
||||||
assert participant.color == "#123456"
|
assert participant.color == "#123456"
|
||||||
|
assert participant.entered_at == "2025-01-01T10:00:00+00:00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lobby_participant_from_dict_missing_entered_at():
|
||||||
|
"""`entered_at` is mandatory; data without it is rejected."""
|
||||||
|
data = {
|
||||||
|
"status": "waiting",
|
||||||
|
"username": "test-username",
|
||||||
|
"id": "test-participant-id",
|
||||||
|
"color": "#123456",
|
||||||
|
}
|
||||||
|
|
||||||
|
with pytest.raises(LobbyParticipantParsingError, match="Invalid participant data"):
|
||||||
|
LobbyParticipant.from_dict(data)
|
||||||
|
|
||||||
|
|
||||||
def test_lobby_participant_from_dict_default_status():
|
def test_lobby_participant_from_dict_default_status():
|
||||||
@@ -96,6 +113,7 @@ def test_lobby_participant_from_dict_default_status():
|
|||||||
"username": "test-username",
|
"username": "test-username",
|
||||||
"id": "test-participant-id",
|
"id": "test-participant-id",
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
}
|
}
|
||||||
|
|
||||||
participant = LobbyParticipant.from_dict(data_without_status)
|
participant = LobbyParticipant.from_dict(data_without_status)
|
||||||
@@ -121,6 +139,7 @@ def test_lobby_participant_from_dict_invalid_status():
|
|||||||
"username": "test-username",
|
"username": "test-username",
|
||||||
"id": "test-participant-id",
|
"id": "test-participant-id",
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
}
|
}
|
||||||
|
|
||||||
with pytest.raises(LobbyParticipantParsingError, match="Invalid participant data"):
|
with pytest.raises(LobbyParticipantParsingError, match="Invalid participant data"):
|
||||||
@@ -265,6 +284,7 @@ def test_request_entry_public_room(
|
|||||||
username=username,
|
username=username,
|
||||||
id=participant_id,
|
id=participant_id,
|
||||||
color="#123456",
|
color="#123456",
|
||||||
|
entered_at="2025-01-01T10:00:00+00:00",
|
||||||
)
|
)
|
||||||
|
|
||||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||||
@@ -303,6 +323,7 @@ def test_request_entry_trusted_room(
|
|||||||
username=username,
|
username=username,
|
||||||
id=participant_id,
|
id=participant_id,
|
||||||
color="#123456",
|
color="#123456",
|
||||||
|
entered_at="2025-01-01T10:00:00+00:00",
|
||||||
)
|
)
|
||||||
|
|
||||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||||
@@ -345,6 +366,7 @@ def test_request_entry_new_participant(
|
|||||||
username=username,
|
username=username,
|
||||||
id=participant_id,
|
id=participant_id,
|
||||||
color="#123456",
|
color="#123456",
|
||||||
|
entered_at="2025-01-01T10:00:00+00:00",
|
||||||
)
|
)
|
||||||
mock_enter.return_value = participant_data
|
mock_enter.return_value = participant_data
|
||||||
|
|
||||||
@@ -372,6 +394,7 @@ def test_request_entry_waiting_participant(
|
|||||||
username=username,
|
username=username,
|
||||||
id=participant_id,
|
id=participant_id,
|
||||||
color="#123456",
|
color="#123456",
|
||||||
|
entered_at="2025-01-01T10:00:00+00:00",
|
||||||
)
|
)
|
||||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||||
@@ -400,6 +423,7 @@ def test_request_entry_accepted_participant(
|
|||||||
username=username,
|
username=username,
|
||||||
id=participant_id,
|
id=participant_id,
|
||||||
color="#123456",
|
color="#123456",
|
||||||
|
entered_at="2025-01-01T10:00:00+00:00",
|
||||||
)
|
)
|
||||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||||
@@ -440,6 +464,7 @@ def test_request_entry_participant_with_role(
|
|||||||
username=username,
|
username=username,
|
||||||
id=participant_id,
|
id=participant_id,
|
||||||
color="#123456",
|
color="#123456",
|
||||||
|
entered_at="2025-01-01T10:00:00+00:00",
|
||||||
)
|
)
|
||||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||||
@@ -466,18 +491,23 @@ def test_request_entry_participant_with_role(
|
|||||||
def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
|
def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
|
||||||
"""Test refreshing waiting status for a participant."""
|
"""Test refreshing waiting status for a participant."""
|
||||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||||
|
lobby_service._index_touch = mock.Mock()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
lobby_service.refresh_waiting_status(room.id, participant_id)
|
lobby_service.refresh_waiting_status(room.id, participant_id)
|
||||||
mock_cache.touch.assert_called_once_with(
|
mock_cache.touch.assert_called_once_with(
|
||||||
"mocked_cache_key", settings.LOBBY_WAITING_TIMEOUT
|
"mocked_cache_key", settings.LOBBY_WAITING_TIMEOUT
|
||||||
)
|
)
|
||||||
|
lobby_service._index_touch.assert_called_once_with(room.id)
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=R0917
|
# pylint: disable=R0917
|
||||||
@mock.patch("core.services.lobby.cache")
|
@mock.patch("core.services.lobby.cache")
|
||||||
@mock.patch("core.utils.generate_color")
|
@mock.patch("core.utils.generate_color")
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
|
@mock.patch("core.services.lobby.LobbyService._index_add")
|
||||||
|
@freeze_time("2025-01-01 10:00:00")
|
||||||
def test_enter_success(
|
def test_enter_success(
|
||||||
|
mock_index_add,
|
||||||
mock_notify,
|
mock_notify,
|
||||||
mock_generate_color,
|
mock_generate_color,
|
||||||
mock_cache,
|
mock_cache,
|
||||||
@@ -497,6 +527,7 @@ def test_enter_success(
|
|||||||
assert participant.username == username
|
assert participant.username == username
|
||||||
assert participant.id == participant_id
|
assert participant.id == participant_id
|
||||||
assert participant.color == "#123456"
|
assert participant.color == "#123456"
|
||||||
|
assert participant.entered_at == "2025-01-01T10:00:00+00:00"
|
||||||
|
|
||||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||||
|
|
||||||
@@ -508,13 +539,16 @@ def test_enter_success(
|
|||||||
mock_notify.assert_called_once_with(
|
mock_notify.assert_called_once_with(
|
||||||
room_name=str(room.pk), notification_data={"type": "participantWaiting"}
|
room_name=str(room.pk), notification_data={"type": "participantWaiting"}
|
||||||
)
|
)
|
||||||
|
mock_index_add.assert_called_once_with(room.id, participant_id)
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=R0917
|
# pylint: disable=R0917
|
||||||
@mock.patch("core.services.lobby.cache")
|
@mock.patch("core.services.lobby.cache")
|
||||||
@mock.patch("core.utils.generate_color")
|
@mock.patch("core.utils.generate_color")
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
|
@mock.patch("core.services.lobby.LobbyService._index_add")
|
||||||
def test_enter_with_notification_error(
|
def test_enter_with_notification_error(
|
||||||
|
mock_index_add,
|
||||||
mock_notify,
|
mock_notify,
|
||||||
mock_generate_color,
|
mock_generate_color,
|
||||||
mock_cache,
|
mock_cache,
|
||||||
@@ -541,6 +575,7 @@ def test_enter_with_notification_error(
|
|||||||
participant.to_dict(),
|
participant.to_dict(),
|
||||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
mock_index_add.assert_called_once_with(room.id, participant_id)
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.lobby.cache")
|
@mock.patch("core.services.lobby.cache")
|
||||||
@@ -579,14 +614,15 @@ def test_get_participant_parsing_error(
|
|||||||
@mock.patch("core.services.lobby.cache")
|
@mock.patch("core.services.lobby.cache")
|
||||||
def test_list_waiting_participants_empty(mock_cache, lobby_service):
|
def test_list_waiting_participants_empty(mock_cache, lobby_service):
|
||||||
"""Test listing waiting participants when none exist."""
|
"""Test listing waiting participants when none exist."""
|
||||||
mock_cache.iter_keys.return_value = []
|
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
lobby_service._index_members = mock.Mock(return_value=[])
|
||||||
|
lobby_service._index_remove = mock.Mock()
|
||||||
|
|
||||||
result = lobby_service.list_waiting_participants(room.id)
|
result = lobby_service.list_waiting_participants(room.id)
|
||||||
|
|
||||||
assert result == []
|
assert result == ()
|
||||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
lobby_service._index_members.assert_called_once_with(room.id)
|
||||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
lobby_service._index_remove.assert_not_called()
|
||||||
mock_cache.get_many.assert_not_called()
|
mock_cache.get_many.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@@ -595,7 +631,8 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
|
|||||||
"""Test listing waiting participants with valid data."""
|
"""Test listing waiting participants with valid data."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||||
mock_cache.iter_keys.return_value = [cache_key]
|
lobby_service._index_members = mock.Mock(return_value=["participant1"])
|
||||||
|
lobby_service._index_remove = mock.Mock()
|
||||||
mock_cache.get_many.return_value = {cache_key: participant_dict}
|
mock_cache.get_many.return_value = {cache_key: participant_dict}
|
||||||
|
|
||||||
result = lobby_service.list_waiting_participants(room.id)
|
result = lobby_service.list_waiting_participants(room.id)
|
||||||
@@ -603,8 +640,8 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
|
|||||||
assert len(result) == 1
|
assert len(result) == 1
|
||||||
assert result[0]["status"] == "waiting"
|
assert result[0]["status"] == "waiting"
|
||||||
assert result[0]["username"] == "test-username"
|
assert result[0]["username"] == "test-username"
|
||||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
lobby_service._index_members.assert_called_once_with(room.id)
|
||||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
lobby_service._index_remove.assert_called_once_with(room.id)
|
||||||
mock_cache.get_many.assert_called_once_with([cache_key])
|
mock_cache.get_many.assert_called_once_with([cache_key])
|
||||||
|
|
||||||
|
|
||||||
@@ -620,6 +657,7 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
|||||||
"username": "user1",
|
"username": "user1",
|
||||||
"id": "participant1",
|
"id": "participant1",
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
}
|
}
|
||||||
|
|
||||||
participant2 = {
|
participant2 = {
|
||||||
@@ -627,9 +665,13 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
|||||||
"username": "user2",
|
"username": "user2",
|
||||||
"id": "participant2",
|
"id": "participant2",
|
||||||
"color": "#654321",
|
"color": "#654321",
|
||||||
|
"entered_at": "2025-01-01T10:05:00+00:00",
|
||||||
}
|
}
|
||||||
|
|
||||||
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
|
lobby_service._index_members = mock.Mock(
|
||||||
|
return_value=["participant1", "participant2"]
|
||||||
|
)
|
||||||
|
lobby_service._index_remove = mock.Mock()
|
||||||
mock_cache.get_many.return_value = {
|
mock_cache.get_many.return_value = {
|
||||||
cache_key1: participant1,
|
cache_key1: participant1,
|
||||||
cache_key2: participant2,
|
cache_key2: participant2,
|
||||||
@@ -639,15 +681,15 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
|||||||
|
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
|
|
||||||
# Verify both participants are in the result
|
# Most recent entry comes first
|
||||||
assert any(p["id"] == "participant1" and p["username"] == "user1" for p in result)
|
assert [p["id"] for p in result] == ["participant2", "participant1"]
|
||||||
assert any(p["id"] == "participant2" and p["username"] == "user2" for p in result)
|
assert result[0]["username"] == "user2"
|
||||||
|
assert result[1]["username"] == "user1"
|
||||||
|
|
||||||
# Verify all participants have waiting status
|
# Verify all participants have waiting status
|
||||||
assert all(p["status"] == "waiting" for p in result)
|
assert all(p["status"] == "waiting" for p in result)
|
||||||
|
|
||||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
lobby_service._index_members.assert_called_once_with(room.id)
|
||||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
|
||||||
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
|
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
|
||||||
|
|
||||||
|
|
||||||
@@ -656,12 +698,13 @@ def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service):
|
|||||||
"""Test listing waiting participants with corrupted data."""
|
"""Test listing waiting participants with corrupted data."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||||
mock_cache.iter_keys.return_value = [cache_key]
|
lobby_service._index_members = mock.Mock(return_value=["participant1"])
|
||||||
|
lobby_service._index_remove = mock.Mock()
|
||||||
mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}}
|
mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}}
|
||||||
|
|
||||||
result = lobby_service.list_waiting_participants(room.id)
|
result = lobby_service.list_waiting_participants(room.id)
|
||||||
|
|
||||||
assert result == []
|
assert result == ()
|
||||||
mock_cache.delete.assert_called_once_with(cache_key)
|
mock_cache.delete.assert_called_once_with(cache_key)
|
||||||
|
|
||||||
|
|
||||||
@@ -677,11 +720,15 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
|
|||||||
"username": "user2",
|
"username": "user2",
|
||||||
"id": "participant2",
|
"id": "participant2",
|
||||||
"color": "#654321",
|
"color": "#654321",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
}
|
}
|
||||||
|
|
||||||
corrupted_participant = {"invalid": "data"}
|
corrupted_participant = {"invalid": "data"}
|
||||||
|
|
||||||
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
|
lobby_service._index_members = mock.Mock(
|
||||||
|
return_value=["participant1", "participant2"]
|
||||||
|
)
|
||||||
|
lobby_service._index_remove = mock.Mock()
|
||||||
mock_cache.get_many.return_value = {
|
mock_cache.get_many.return_value = {
|
||||||
cache_key1: corrupted_participant,
|
cache_key1: corrupted_participant,
|
||||||
cache_key2: valid_participant,
|
cache_key2: valid_participant,
|
||||||
@@ -699,8 +746,6 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
|
|||||||
mock_cache.delete.assert_called_once_with(cache_key1)
|
mock_cache.delete.assert_called_once_with(cache_key1)
|
||||||
|
|
||||||
# Verify both cache keys were queried
|
# Verify both cache keys were queried
|
||||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
|
||||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
|
||||||
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
|
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
|
||||||
|
|
||||||
|
|
||||||
@@ -716,15 +761,20 @@ def test_list_waiting_participants_non_waiting(mock_cache, lobby_service):
|
|||||||
"username": "user1",
|
"username": "user1",
|
||||||
"id": "participant1",
|
"id": "participant1",
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
}
|
}
|
||||||
participant2 = {
|
participant2 = {
|
||||||
"status": "accepted",
|
"status": "accepted",
|
||||||
"username": "user2",
|
"username": "user2",
|
||||||
"id": "participant2",
|
"id": "participant2",
|
||||||
"color": "#654321",
|
"color": "#654321",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
}
|
}
|
||||||
|
|
||||||
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
|
lobby_service._index_members = mock.Mock(
|
||||||
|
return_value=["participant1", "participant2"]
|
||||||
|
)
|
||||||
|
lobby_service._index_remove = mock.Mock()
|
||||||
mock_cache.get_many.return_value = {
|
mock_cache.get_many.return_value = {
|
||||||
cache_key1: participant1,
|
cache_key1: participant1,
|
||||||
cache_key2: participant2,
|
cache_key2: participant2,
|
||||||
@@ -816,10 +866,12 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
|
|||||||
"username": "test-username",
|
"username": "test-username",
|
||||||
"id": participant_id,
|
"id": participant_id,
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
}
|
}
|
||||||
|
|
||||||
mock_cache.get.return_value = participant_dict
|
mock_cache.get.return_value = participant_dict
|
||||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||||
|
lobby_service._index_touch = mock.Mock()
|
||||||
|
|
||||||
lobby_service._update_participant_status(
|
lobby_service._update_participant_status(
|
||||||
room.id,
|
room.id,
|
||||||
@@ -833,10 +885,12 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
|
|||||||
"username": "test-username",
|
"username": "test-username",
|
||||||
"id": participant_id,
|
"id": participant_id,
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
}
|
}
|
||||||
mock_cache.set.assert_called_once_with(
|
mock_cache.set.assert_called_once_with(
|
||||||
"mocked_cache_key", expected_data, timeout=60
|
"mocked_cache_key", expected_data, timeout=60
|
||||||
)
|
)
|
||||||
|
lobby_service._index_touch.assert_called_once_with(room.id)
|
||||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||||
|
|
||||||
|
|
||||||
@@ -857,6 +911,7 @@ def test_clear_room_cache(settings, lobby_service):
|
|||||||
username="participant1",
|
username="participant1",
|
||||||
id="participant1",
|
id="participant1",
|
||||||
color="#123456",
|
color="#123456",
|
||||||
|
entered_at="2025-01-01T10:00:00+00:00",
|
||||||
),
|
),
|
||||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||||
)
|
)
|
||||||
@@ -867,6 +922,7 @@ def test_clear_room_cache(settings, lobby_service):
|
|||||||
username="participant2",
|
username="participant2",
|
||||||
id="participant2",
|
id="participant2",
|
||||||
color="#123456",
|
color="#123456",
|
||||||
|
entered_at="2025-01-01T10:00:00+00:00",
|
||||||
),
|
),
|
||||||
timeout=settings.LOBBY_ACCEPTED_TIMEOUT,
|
timeout=settings.LOBBY_ACCEPTED_TIMEOUT,
|
||||||
)
|
)
|
||||||
@@ -877,13 +933,18 @@ def test_clear_room_cache(settings, lobby_service):
|
|||||||
username="participant3",
|
username="participant3",
|
||||||
id="participant3",
|
id="participant3",
|
||||||
color="#123456",
|
color="#123456",
|
||||||
|
entered_at="2025-01-01T10:00:00+00:00",
|
||||||
),
|
),
|
||||||
timeout=settings.LOBBY_DENIED_TIMEOUT,
|
timeout=settings.LOBBY_DENIED_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
for participant_id in ("participant1", "participant2", "participant3"):
|
||||||
|
lobby_service._index_add(room_id, participant_id)
|
||||||
|
|
||||||
lobby_service.clear_room_cache(room_id)
|
lobby_service.clear_room_cache(room_id)
|
||||||
|
|
||||||
assert cache.keys(f"test-lobby_{room_id!s}_*") == []
|
assert cache.keys(f"test-lobby_{room_id!s}_*") == []
|
||||||
|
assert lobby_service._index_members(room_id) == frozenset()
|
||||||
|
|
||||||
|
|
||||||
def test_clear_room_empty(settings, lobby_service):
|
def test_clear_room_empty(settings, lobby_service):
|
||||||
@@ -908,12 +969,16 @@ def test_clear_participant_cache(lobby_service):
|
|||||||
"username": "test-username",
|
"username": "test-username",
|
||||||
"id": participant_id,
|
"id": participant_id,
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
}
|
}
|
||||||
cache.set(cache_key, participant_data, timeout=settings.LOBBY_WAITING_TIMEOUT)
|
cache.set(cache_key, participant_data, timeout=settings.LOBBY_WAITING_TIMEOUT)
|
||||||
|
lobby_service._index_add(room_id, participant_id)
|
||||||
assert cache.get(cache_key) is not None
|
assert cache.get(cache_key) is not None
|
||||||
|
assert participant_id in lobby_service._index_members(room_id)
|
||||||
|
|
||||||
lobby_service.clear_participant_cache(room_id, participant_id)
|
lobby_service.clear_participant_cache(room_id, participant_id)
|
||||||
assert cache.get(cache_key) is None
|
assert cache.get(cache_key) is None
|
||||||
|
assert participant_id not in lobby_service._index_members(room_id)
|
||||||
|
|
||||||
|
|
||||||
def test_clear_participant_cache_nonexistent(lobby_service):
|
def test_clear_participant_cache_nonexistent(lobby_service):
|
||||||
@@ -927,3 +992,85 @@ def test_clear_participant_cache_nonexistent(lobby_service):
|
|||||||
lobby_service.clear_participant_cache(room_id, participant_id)
|
lobby_service.clear_participant_cache(room_id, participant_id)
|
||||||
|
|
||||||
assert cache.get(cache_key) is None
|
assert cache.get(cache_key) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_index_add_members_remove_roundtrip(lobby_service):
|
||||||
|
"""The room index records, lists and forgets participant ids."""
|
||||||
|
room_id = uuid.uuid4()
|
||||||
|
|
||||||
|
assert lobby_service._index_members(room_id) == frozenset()
|
||||||
|
|
||||||
|
lobby_service._index_add(room_id, "participant1")
|
||||||
|
lobby_service._index_add(room_id, "participant2")
|
||||||
|
|
||||||
|
assert sorted(lobby_service._index_members(room_id)) == [
|
||||||
|
"participant1",
|
||||||
|
"participant2",
|
||||||
|
]
|
||||||
|
|
||||||
|
# The index carries a backstop TTL so abandoned rooms cannot leak it.
|
||||||
|
ttl = lobby_service._redis().ttl(lobby_service._get_index_key(room_id))
|
||||||
|
assert 0 < ttl <= settings.LOBBY_ACCEPTED_TIMEOUT
|
||||||
|
|
||||||
|
lobby_service._index_remove(room_id, "participant1")
|
||||||
|
assert lobby_service._index_members(room_id) == frozenset(["participant2"])
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.utils.notify_participants")
|
||||||
|
def test_enter_registers_participant_in_room_index(
|
||||||
|
mock_notify, lobby_service, participant_id, username
|
||||||
|
):
|
||||||
|
"""Entering the lobby must index the participant id for the room."""
|
||||||
|
room_id = uuid.uuid4()
|
||||||
|
|
||||||
|
lobby_service.enter(room_id, participant_id, username)
|
||||||
|
|
||||||
|
assert lobby_service._index_members(room_id) == frozenset([participant_id])
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_waiting_participants_prunes_stale_index_ids(settings, lobby_service):
|
||||||
|
"""Indexed ids whose cache entry expired are pruned and not listed."""
|
||||||
|
settings.LOBBY_KEY_PREFIX = "test-lobby-prune"
|
||||||
|
room_id = uuid.uuid4()
|
||||||
|
|
||||||
|
cache.set(
|
||||||
|
f"test-lobby-prune_{room_id!s}_participant1",
|
||||||
|
{
|
||||||
|
"id": "participant1",
|
||||||
|
"username": "user1",
|
||||||
|
"status": "waiting",
|
||||||
|
"color": "#123456",
|
||||||
|
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||||
|
},
|
||||||
|
timeout=100,
|
||||||
|
)
|
||||||
|
lobby_service._index_add(room_id, "participant1")
|
||||||
|
# participant2 is indexed but its cache entry has expired.
|
||||||
|
lobby_service._index_add(room_id, "participant2")
|
||||||
|
|
||||||
|
result = lobby_service.list_waiting_participants(room_id)
|
||||||
|
|
||||||
|
assert [participant["id"] for participant in result] == ["participant1"]
|
||||||
|
assert lobby_service._index_members(room_id) == frozenset(["participant1"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_waiting_status_rearms_room_index_ttl(lobby_service, participant_id):
|
||||||
|
"""A lone waiter's polling must keep the room index alive.
|
||||||
|
|
||||||
|
Regression test: the index backstop TTL is only armed at enter() time,
|
||||||
|
so a participant whose rolling WAITING refreshes outlast it would keep
|
||||||
|
their entry alive while silently vanishing from the moderator list.
|
||||||
|
Refreshing the waiting status must therefore re-arm the index TTL.
|
||||||
|
"""
|
||||||
|
room_id = uuid.uuid4()
|
||||||
|
lobby_service._index_add(room_id, participant_id)
|
||||||
|
|
||||||
|
index_key = lobby_service._get_index_key(room_id)
|
||||||
|
redis_client = lobby_service._redis()
|
||||||
|
redis_client.expire(index_key, 10)
|
||||||
|
assert redis_client.ttl(index_key) <= 10
|
||||||
|
|
||||||
|
lobby_service.refresh_waiting_status(room_id, participant_id)
|
||||||
|
|
||||||
|
assert redis_client.ttl(index_key) > 10
|
||||||
|
assert lobby_service._index_members(room_id) == frozenset([participant_id])
|
||||||
|
|||||||
@@ -90,19 +90,18 @@ def test_presence_clear_and_clear_room():
|
|||||||
assert presence.is_marked_present(other_room, "a") is True
|
assert presence.is_marked_present(other_room, "a") is True
|
||||||
|
|
||||||
|
|
||||||
def test_presence_clear_room_scans_in_pages():
|
def test_presence_clear_room_removes_many_entries_and_the_index():
|
||||||
"""clear_room removes every match, even across several SCAN pages,
|
"""clear_room removes every entry of the room through the index — never
|
||||||
and only within the room."""
|
a keyspace scan — and leaves other rooms untouched."""
|
||||||
room_id, other_room = str(uuid4()), str(uuid4())
|
room_id, other_room = str(uuid4()), str(uuid4())
|
||||||
presence = PresenceCache()
|
presence = PresenceCache()
|
||||||
for i in range(7):
|
for i in range(7):
|
||||||
presence.mark_present(room_id, f"user-{i}")
|
presence.mark_present(room_id, f"user-{i}")
|
||||||
presence.mark_present(other_room, "user-0")
|
presence.mark_present(other_room, "user-0")
|
||||||
|
|
||||||
# An itersize smaller than the match count forces delete_pattern to
|
presence.clear_room(room_id)
|
||||||
# page through several SCAN cursors rather than finish in one pass.
|
|
||||||
with mock.patch("core.utils.CACHE_SCAN_ITERSIZE", 3):
|
|
||||||
presence.clear_room(room_id)
|
|
||||||
|
|
||||||
assert all(not presence.is_marked_present(room_id, f"user-{i}") for i in range(7))
|
assert all(not presence.is_marked_present(room_id, f"user-{i}") for i in range(7))
|
||||||
assert presence.is_marked_present(other_room, "user-0") is True
|
assert presence.is_marked_present(other_room, "user-0") is True
|
||||||
|
assert presence._index_members(room_id) == frozenset([])
|
||||||
|
assert presence._index_members(other_room) == frozenset(["user-0"])
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Tests for the external API ResourceServerBackend."""
|
||||||
|
|
||||||
|
from django.core.exceptions import SuspiciousOperation
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import responses
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core.external_api.authentication import ResourceServerBackend
|
||||||
|
from core.factories import UserFactory
|
||||||
|
from core.models import User
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def _payload(sub):
|
||||||
|
return {"sub": sub, "active": True, "scope": "lasuite_meet", "client_id": "app"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_server_backend_get_or_create_user_active():
|
||||||
|
"""An existing active user matching the sub should be returned."""
|
||||||
|
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
result = ResourceServerBackend().get_or_create_user(
|
||||||
|
access_token="token", id_token=None, payload=_payload(user.sub)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == user
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_server_backend_get_or_create_user_inactive():
|
||||||
|
"""An inactive user should be rejected even with a valid token."""
|
||||||
|
|
||||||
|
user = UserFactory(is_active=False)
|
||||||
|
|
||||||
|
with pytest.raises(SuspiciousOperation, match="User account is disabled."):
|
||||||
|
ResourceServerBackend().get_or_create_user(
|
||||||
|
access_token="token", id_token=None, payload=_payload(user.sub)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_server_backend_get_or_create_user_creates(settings):
|
||||||
|
"""An unknown sub should create an active user when OIDC_CREATE_USER is set."""
|
||||||
|
|
||||||
|
settings.OIDC_CREATE_USER = True
|
||||||
|
|
||||||
|
result = ResourceServerBackend().get_or_create_user(
|
||||||
|
access_token="token", id_token=None, payload=_payload("new-sub")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.sub == "new-sub"
|
||||||
|
assert result.is_active is True
|
||||||
|
assert User.objects.filter(sub="new-sub").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_resource_server_backend_get_or_create_user_no_creation(settings):
|
||||||
|
"""An unknown sub should return None when OIDC_CREATE_USER is unset."""
|
||||||
|
|
||||||
|
settings.OIDC_CREATE_USER = False
|
||||||
|
|
||||||
|
result = ResourceServerBackend().get_or_create_user(
|
||||||
|
access_token="token", id_token=None, payload=_payload("new-sub")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
assert not User.objects.filter(sub="new-sub").exists()
|
||||||
|
|
||||||
|
|
||||||
|
@responses.activate
|
||||||
|
def test_api_rooms_list_resource_server_inactive_user(settings):
|
||||||
|
"""End to end: a valid introspected token for an inactive user should get 401."""
|
||||||
|
|
||||||
|
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||||
|
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||||
|
|
||||||
|
user = UserFactory(is_active=False)
|
||||||
|
|
||||||
|
responses.add(
|
||||||
|
responses.POST,
|
||||||
|
"https://oidc.example.com/introspect",
|
||||||
|
json={
|
||||||
|
"iss": "https://oidc.example.com",
|
||||||
|
"active": True,
|
||||||
|
"sub": user.sub,
|
||||||
|
"scope": "openid lasuite_meet rooms:list",
|
||||||
|
"client_id": "app",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
client.credentials(HTTP_AUTHORIZATION="Bearer rs-token")
|
||||||
|
response = client.get("/external-api/v1.0/rooms/")
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
assert "login failed" in str(response.data).lower()
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"""Unit tests for the LIVEKIT_DEFAULT_VIDEO_CODEC setting value."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from meet.settings import VideoCodecValue
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"raw,expected",
|
||||||
|
[("vp9", "vp9"), ("AV1", "av1"), (" h264 ", "h264")],
|
||||||
|
)
|
||||||
|
def test_video_codec_value_normalizes(raw, expected):
|
||||||
|
"""Whitespace is trimmed and the name is lowercased before it is checked."""
|
||||||
|
# environ=False keeps __new__ from resolving the value, so the instance survives.
|
||||||
|
assert VideoCodecValue(environ=False).to_python(raw) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("raw", ["vp10", "", "h.264"])
|
||||||
|
def test_video_codec_value_rejects_unsupported(raw):
|
||||||
|
"""A name outside the accepted list stops the settings module loading."""
|
||||||
|
with pytest.raises(ValueError, match="Unsupported video codec"):
|
||||||
|
VideoCodecValue(environ=False).to_python(raw)
|
||||||
@@ -499,6 +499,3 @@ def build_telephony_config():
|
|||||||
"default_country": country,
|
"default_country": country,
|
||||||
"international_phone_number": international,
|
"international_phone_number": international,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
CACHE_SCAN_ITERSIZE = 500
|
|
||||||
|
|||||||
@@ -53,6 +53,26 @@ def get_release():
|
|||||||
return "NA" # Default: not available
|
return "NA" # Default: not available
|
||||||
|
|
||||||
|
|
||||||
|
class VideoCodecValue(values.Value):
|
||||||
|
"""
|
||||||
|
A video codec name, normalized to lowercase and validated against the codecs
|
||||||
|
supported by the LiveKit client, so that a typo fails at startup instead of
|
||||||
|
silently downgrading every publisher to another codec.
|
||||||
|
"""
|
||||||
|
|
||||||
|
codecs = frozenset(("vp8", "h264", "vp9", "av1"))
|
||||||
|
|
||||||
|
def to_python(self, value):
|
||||||
|
"""Normalize the codec name and ensure it is a supported one."""
|
||||||
|
codec = super().to_python(value).strip().lower()
|
||||||
|
if codec not in self.codecs:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unsupported video codec {value!r}, "
|
||||||
|
f"expected one of: {', '.join(sorted(self.codecs))}."
|
||||||
|
)
|
||||||
|
return codec
|
||||||
|
|
||||||
|
|
||||||
class Base(Configuration):
|
class Base(Configuration):
|
||||||
"""
|
"""
|
||||||
This is the base configuration every configuration (aka environment) should inherit from. It
|
This is the base configuration every configuration (aka environment) should inherit from. It
|
||||||
@@ -469,6 +489,9 @@ class Base(Configuration):
|
|||||||
|
|
||||||
# Sentry
|
# Sentry
|
||||||
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN")
|
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN")
|
||||||
|
SENTRY_TRACES_SAMPLE_RATE = values.FloatValue(
|
||||||
|
0.0, environ_name="SENTRY_TRACES_SAMPLE_RATE", environ_prefix=None
|
||||||
|
)
|
||||||
|
|
||||||
# Easy thumbnails
|
# Easy thumbnails
|
||||||
THUMBNAIL_EXTENSION = "webp"
|
THUMBNAIL_EXTENSION = "webp"
|
||||||
@@ -675,6 +698,9 @@ class Base(Configuration):
|
|||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
|
LIVEKIT_DEFAULT_VIDEO_CODEC = VideoCodecValue(
|
||||||
|
"vp9", environ_name="LIVEKIT_DEFAULT_VIDEO_CODEC", environ_prefix=None
|
||||||
|
)
|
||||||
CONNECTION_TEST_ENABLED = values.BooleanValue(
|
CONNECTION_TEST_ENABLED = values.BooleanValue(
|
||||||
environ_name="CONNECTION_TEST_ENABLED",
|
environ_name="CONNECTION_TEST_ENABLED",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
@@ -865,7 +891,7 @@ class Base(Configuration):
|
|||||||
"room_lobby", environ_name="LOBBY_KEY_PREFIX", environ_prefix=None
|
"room_lobby", environ_name="LOBBY_KEY_PREFIX", environ_prefix=None
|
||||||
)
|
)
|
||||||
LOBBY_WAITING_TIMEOUT = values.PositiveIntegerValue(
|
LOBBY_WAITING_TIMEOUT = values.PositiveIntegerValue(
|
||||||
3, environ_name="LOBBY_WAITING_TIMEOUT", environ_prefix=None
|
6, environ_name="LOBBY_WAITING_TIMEOUT", environ_prefix=None
|
||||||
)
|
)
|
||||||
LOBBY_DENIED_TIMEOUT = values.PositiveIntegerValue(
|
LOBBY_DENIED_TIMEOUT = values.PositiveIntegerValue(
|
||||||
5, environ_name="LOBBY_DENIED_TIMEOUT", environ_prefix=None
|
5, environ_name="LOBBY_DENIED_TIMEOUT", environ_prefix=None
|
||||||
@@ -1110,6 +1136,12 @@ class Base(Configuration):
|
|||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
LOGGING_SILENCED_401_PATHS = values.ListValue(
|
||||||
|
default=["/api/v1.0/users/me/"],
|
||||||
|
environ_name="LOGGING_SILENCED_401_PATHS",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
# We want to make it easy to log to console but by default we log production
|
# We want to make it easy to log to console but by default we log production
|
||||||
# to Sentry and don't want to log to console.
|
# to Sentry and don't want to log to console.
|
||||||
@@ -1122,10 +1154,16 @@ class Base(Configuration):
|
|||||||
"style": "{",
|
"style": "{",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"filters": {
|
||||||
|
"silence_expected_401": {
|
||||||
|
"()": "core.logging_filters.SilenceExpected401",
|
||||||
|
},
|
||||||
|
},
|
||||||
"handlers": {
|
"handlers": {
|
||||||
"console": {
|
"console": {
|
||||||
"class": "logging.StreamHandler",
|
"class": "logging.StreamHandler",
|
||||||
"formatter": "simple",
|
"formatter": "simple",
|
||||||
|
"filters": ["silence_expected_401"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
# Override root logger to send it to console
|
# Override root logger to send it to console
|
||||||
@@ -1136,6 +1174,13 @@ class Base(Configuration):
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
"loggers": {
|
"loggers": {
|
||||||
|
"request.summary": {
|
||||||
|
"level": values.Value(
|
||||||
|
"WARNING",
|
||||||
|
environ_name="LOGGING_LEVEL_REQUEST_SUMMARY",
|
||||||
|
environ_prefix="",
|
||||||
|
)
|
||||||
|
},
|
||||||
"core": {
|
"core": {
|
||||||
"handlers": ["console"],
|
"handlers": ["console"],
|
||||||
"level": values.Value(
|
"level": values.Value(
|
||||||
@@ -1211,7 +1256,14 @@ class Base(Configuration):
|
|||||||
dsn=cls.SENTRY_DSN,
|
dsn=cls.SENTRY_DSN,
|
||||||
environment=cls.__name__.lower(), # build, test, development, production
|
environment=cls.__name__.lower(), # build, test, development, production
|
||||||
release=get_release(),
|
release=get_release(),
|
||||||
integrations=[DjangoIntegration()],
|
traces_sample_rate=cls.SENTRY_TRACES_SAMPLE_RATE,
|
||||||
|
integrations=[
|
||||||
|
DjangoIntegration(
|
||||||
|
transaction_style="url",
|
||||||
|
middleware_spans=True,
|
||||||
|
cache_spans=True,
|
||||||
|
)
|
||||||
|
],
|
||||||
)
|
)
|
||||||
sentry_sdk.set_tag("application", "backend")
|
sentry_sdk.set_tag("application", "backend")
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.30.0"
|
version = "1.31.0"
|
||||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
|
|||||||
Generated
+1
-1
@@ -1187,7 +1187,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.30.0"
|
version = "1.31.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
|
|||||||
Generated
+38
-45
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.30.0",
|
"version": "1.31.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.30.0",
|
"version": "1.31.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
|
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
|
||||||
"@fontsource-variable/lexend": "5.3.0",
|
"@fontsource-variable/lexend": "5.3.0",
|
||||||
@@ -23,18 +23,18 @@
|
|||||||
"@timephy/rnnoise-wasm": "1.0.0",
|
"@timephy/rnnoise-wasm": "1.0.0",
|
||||||
"crisp-sdk-web": "1.1.2",
|
"crisp-sdk-web": "1.1.2",
|
||||||
"hoofd": "1.7.3",
|
"hoofd": "1.7.3",
|
||||||
"humanize-duration": "3.33.2",
|
"humanize-duration": "3.34.1",
|
||||||
"i18next": "26.3.6",
|
"i18next": "26.3.6",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"i18next-parser": "9.4.0",
|
"i18next-parser": "9.4.0",
|
||||||
"i18next-resources-to-backend": "1.2.3",
|
"i18next-resources-to-backend": "1.2.3",
|
||||||
"livekit-client": "2.21.0",
|
"livekit-client": "2.21.0",
|
||||||
"posthog-js": "1.414.0",
|
"posthog-js": "1.418.10",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria": "3.50.0",
|
"react-aria": "3.50.0",
|
||||||
"react-aria-components": "1.19.0",
|
"react-aria-components": "1.19.0",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-i18next": "17.0.10",
|
"react-i18next": "17.0.12",
|
||||||
"react-stately": "3.48.0",
|
"react-stately": "3.48.0",
|
||||||
"use-sound": "5.0.0",
|
"use-sound": "5.0.0",
|
||||||
"valtio": "2.3.2",
|
"valtio": "2.3.2",
|
||||||
@@ -1720,28 +1720,28 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@posthog/browser-common": {
|
"node_modules/@posthog/browser-common": {
|
||||||
"version": "0.4.0",
|
"version": "0.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.5.2.tgz",
|
||||||
"integrity": "sha512-W9DCGVks15docUMPvJ2nd8NS16Gn74bsGWuaeg31beEKFSjdW8wvnQ1ETY6WSql5pYxZb3GdJmEUZVVstKSrBQ==",
|
"integrity": "sha512-8GvfEshFdeKIccuy3kpp6mDBxawQtRamMYRCwzy1r1ixLKQVLAGs3afhOf6r75yp59ZQBi5mtXQgUJ2Jz8eHuw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@posthog/core": "^1.46.8",
|
"@posthog/core": "^1.48.11",
|
||||||
"@posthog/types": "^1.402.0"
|
"@posthog/types": "^1.405.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@posthog/core": {
|
"node_modules/@posthog/core": {
|
||||||
"version": "1.49.1",
|
"version": "1.53.2",
|
||||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.49.1.tgz",
|
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.53.2.tgz",
|
||||||
"integrity": "sha512-jdZh85tG56OXLH881CVwBZyiXCPPaZasfYeWwm9kVUvxC/Rb+lz7wYN9GuqSEmNPJgVnK4v8wS0bCaFc3OmVEA==",
|
"integrity": "sha512-Knx8442G2LyPVIzLvhcBX/TIdeHpZhrYrtZou3Uw8FWfbr9gQtQeOZDCog3MWcDxJ+4vAnMAAOzf5cYpc7Gxyw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@posthog/types": "^1.407.0"
|
"@posthog/types": "^1.411.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@posthog/types": {
|
"node_modules/@posthog/types": {
|
||||||
"version": "1.407.1",
|
"version": "1.411.1",
|
||||||
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.407.1.tgz",
|
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.411.1.tgz",
|
||||||
"integrity": "sha512-WhbkXPC2rgylXqmxHqv70ffI3k+KxyR6s7DBIfr5NvIqHkxp6v0pk31D/jbz0DNVbzwkLjyll2pxr4FNbJiYzg==",
|
"integrity": "sha512-Gd7tnSYctcSXup3naVlAgavvenByRao6rsSYdMgWgP35KE9jFn+rMHNRJeI5LNjJuLzMXHEVcgzAN7xcATefdw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@react-aria/overlays": {
|
"node_modules/@react-aria/overlays": {
|
||||||
@@ -6354,11 +6354,12 @@
|
|||||||
"integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w=="
|
"integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w=="
|
||||||
},
|
},
|
||||||
"node_modules/html-parse-stringify": {
|
"node_modules/html-parse-stringify": {
|
||||||
"version": "3.0.1",
|
"version": "4.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz",
|
||||||
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
|
"integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==",
|
||||||
"dependencies": {
|
"license": "MIT",
|
||||||
"void-elements": "3.1.0"
|
"funding": {
|
||||||
|
"url": "https://locize.com"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/htmlparser2": {
|
"node_modules/htmlparser2": {
|
||||||
@@ -6401,9 +6402,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/humanize-duration": {
|
"node_modules/humanize-duration": {
|
||||||
"version": "3.33.2",
|
"version": "3.34.1",
|
||||||
"resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.33.2.tgz",
|
"resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.34.1.tgz",
|
||||||
"integrity": "sha512-K7Ny/ULO1hDm2nnhvAY+SJV1skxFb61fd073SG1IWJl+D44ULrruCuTyjHKjBVVcSuTlnY99DKtgEG39CM5QOQ==",
|
"integrity": "sha512-YIiigjQ+O31rvcyDJPK1ptTZtvSErjBtmHy93VhbwxB/VwMG0FszKs2IB667tFxmOhNapePQQxa24luQRDkKFQ==",
|
||||||
"license": "Unlicense",
|
"license": "Unlicense",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/EvanHahn"
|
"url": "https://github.com/sponsors/EvanHahn"
|
||||||
@@ -9131,16 +9132,16 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/posthog-js": {
|
"node_modules/posthog-js": {
|
||||||
"version": "1.414.0",
|
"version": "1.418.10",
|
||||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.414.0.tgz",
|
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.418.10.tgz",
|
||||||
"integrity": "sha512-dtZd4asdskr8lNyltAEX6zyn48uO1pO0EMvx6AXJU65PFhu6yn2LPbKtQcyLysjcN57FJPNT9QYL6St5SBJHqw==",
|
"integrity": "sha512-XMvmmnuFoesSPjFo+wvzXkvuzYqIho8CVqxugG1Wt768offFARYNZDd1/xWfm9vk/pJTJ4RhUEHRzLpggmGx9Q==",
|
||||||
"license": "(Apache-2.0 AND MIT)",
|
"license": "(Apache-2.0 AND MIT)",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@posthog/browser-common": "^0.4.0",
|
"@posthog/browser-common": "^0.5.0",
|
||||||
"@posthog/core": "^1.46.9",
|
"@posthog/core": "^1.48.8",
|
||||||
"@posthog/types": "^1.402.2",
|
"@posthog/types": "^1.405.1",
|
||||||
"core-js": "^3.49.0",
|
"core-js": "^3.49.0",
|
||||||
"dompurify": "^3.4.12",
|
"dompurify": "^3.4.13",
|
||||||
"fflate": "^0.4.8",
|
"fflate": "^0.4.8",
|
||||||
"preact": "^10.29.3",
|
"preact": "^10.29.3",
|
||||||
"query-selector-shadow-dom": "^1.0.1",
|
"query-selector-shadow-dom": "^1.0.1",
|
||||||
@@ -9435,13 +9436,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-i18next": {
|
"node_modules/react-i18next": {
|
||||||
"version": "17.0.10",
|
"version": "17.0.12",
|
||||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.12.tgz",
|
||||||
"integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==",
|
"integrity": "sha512-lFWPEGkxQ6RhusdUkysFBD58VHfSSzvHBzqMgN0SvfVpdQGfwtNkStTqdy08/sJd7s807qqutgx93fRpD0DJ3Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.29.2",
|
"@babel/runtime": "^7.29.7",
|
||||||
"html-parse-stringify": "^3.0.1",
|
"html-parse-stringify": "^4.0.1",
|
||||||
"use-sync-external-store": "^1.6.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -11608,14 +11609,6 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/void-elements": {
|
|
||||||
"version": "3.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
|
|
||||||
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=0.10.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/walk-sync": {
|
"node_modules/walk-sync": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/walk-sync/-/walk-sync-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/walk-sync/-/walk-sync-2.2.0.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.30.0",
|
"version": "1.31.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "panda codegen && vite",
|
"dev": "panda codegen && vite",
|
||||||
@@ -30,18 +30,18 @@
|
|||||||
"@timephy/rnnoise-wasm": "1.0.0",
|
"@timephy/rnnoise-wasm": "1.0.0",
|
||||||
"crisp-sdk-web": "1.1.2",
|
"crisp-sdk-web": "1.1.2",
|
||||||
"hoofd": "1.7.3",
|
"hoofd": "1.7.3",
|
||||||
"humanize-duration": "3.33.2",
|
"humanize-duration": "3.34.1",
|
||||||
"i18next": "26.3.6",
|
"i18next": "26.3.6",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"i18next-parser": "9.4.0",
|
"i18next-parser": "9.4.0",
|
||||||
"i18next-resources-to-backend": "1.2.3",
|
"i18next-resources-to-backend": "1.2.3",
|
||||||
"livekit-client": "2.21.0",
|
"livekit-client": "2.21.0",
|
||||||
"posthog-js": "1.414.0",
|
"posthog-js": "1.418.10",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria": "3.50.0",
|
"react-aria": "3.50.0",
|
||||||
"react-aria-components": "1.19.0",
|
"react-aria-components": "1.19.0",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-i18next": "17.0.10",
|
"react-i18next": "17.0.12",
|
||||||
"react-stately": "3.48.0",
|
"react-stately": "3.48.0",
|
||||||
"use-sound": "5.0.0",
|
"use-sound": "5.0.0",
|
||||||
"valtio": "2.3.2",
|
"valtio": "2.3.2",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { keys } from './queryKeys'
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { RecordingMode } from '@/features/recording'
|
import { RecordingMode } from '@/features/recording'
|
||||||
import type { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
import type { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
||||||
import type { Track } from 'livekit-client'
|
import type { Track, VideoCodec } from 'livekit-client'
|
||||||
type Source = Track.Source
|
type Source = Track.Source
|
||||||
|
|
||||||
export interface ApiConfig {
|
export interface ApiConfig {
|
||||||
@@ -62,6 +62,7 @@ export interface ApiConfig {
|
|||||||
force_wss_protocol: boolean
|
force_wss_protocol: boolean
|
||||||
enable_firefox_proxy_workaround: boolean
|
enable_firefox_proxy_workaround: boolean
|
||||||
default_sources: Source[]
|
default_sources: Source[]
|
||||||
|
default_video_codec: VideoCodec
|
||||||
}
|
}
|
||||||
transcription_destination?: string
|
transcription_destination?: string
|
||||||
max_participants_for_sound: number
|
max_participants_for_sound: number
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const fetchUser = (
|
|||||||
}
|
}
|
||||||
): Promise<ApiUser | false> => {
|
): Promise<ApiUser | false> => {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
fetchApi<ApiUser>('/users/me')
|
fetchApi<ApiUser>('/users/me/')
|
||||||
.then(resolve)
|
.then(resolve)
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
// we assume that a 401 means the user is not logged in
|
// we assume that a 401 means the user is not logged in
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ApiError } from '@/api/ApiError'
|
import { ApiError } from '@/api/ApiError'
|
||||||
import { fetchApi } from '@/api/fetchApi'
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
|
import { captureEvent } from '@/features/analytics/telemetry'
|
||||||
import { useMutation, type UseMutationOptions } from '@tanstack/react-query'
|
import { useMutation, type UseMutationOptions } from '@tanstack/react-query'
|
||||||
|
|
||||||
export interface EnterRoomParams {
|
export interface EnterRoomParams {
|
||||||
@@ -17,13 +18,23 @@ export const enterRoom = async ({
|
|||||||
allowEntry,
|
allowEntry,
|
||||||
participantId,
|
participantId,
|
||||||
}: EnterRoomParams): Promise<EnterRoomResponse> => {
|
}: EnterRoomParams): Promise<EnterRoomResponse> => {
|
||||||
return await fetchApi<EnterRoomResponse>(`/rooms/${roomId}/enter/`, {
|
try {
|
||||||
method: 'POST',
|
return await fetchApi<EnterRoomResponse>(`/rooms/${roomId}/enter/`, {
|
||||||
body: JSON.stringify({
|
method: 'POST',
|
||||||
participant_id: participantId,
|
body: JSON.stringify({
|
||||||
allow_entry: allowEntry,
|
participant_id: participantId,
|
||||||
}),
|
allow_entry: allowEntry,
|
||||||
})
|
}),
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ApiError && error.statusCode === 404) {
|
||||||
|
captureEvent('lobby_entry_participant_gone', {
|
||||||
|
room_id: roomId,
|
||||||
|
allow_entry: allowEntry,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useEnterRoom(
|
export function useEnterRoom(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type WaitingParticipant = {
|
|||||||
status: string
|
status: string
|
||||||
username: string
|
username: string
|
||||||
color: string
|
color: string
|
||||||
|
entered_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WaitingParticipantsResponse = {
|
export type WaitingParticipantsResponse = {
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ import {
|
|||||||
} from '../../participants/api/listWaitingParticipants'
|
} from '../../participants/api/listWaitingParticipants'
|
||||||
import { reportError } from '@/features/analytics/telemetry'
|
import { reportError } from '@/features/analytics/telemetry'
|
||||||
|
|
||||||
|
const toTimestamp = (participant: WaitingParticipant): number =>
|
||||||
|
Date.parse(participant.entered_at)
|
||||||
|
|
||||||
|
export const sortWaitingParticipants = (
|
||||||
|
participants: WaitingParticipant[]
|
||||||
|
): WaitingParticipant[] =>
|
||||||
|
[...participants].sort((a, b) => toTimestamp(a) - toTimestamp(b))
|
||||||
|
|
||||||
export const useWaitingParticipants = () => {
|
export const useWaitingParticipants = () => {
|
||||||
const roomData = useRoomData()
|
const roomData = useRoomData()
|
||||||
const roomId = roomData?.id || '' // FIXME - bad practice
|
const roomId = roomData?.id || '' // FIXME - bad practice
|
||||||
@@ -22,7 +30,10 @@ export const useWaitingParticipants = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const waitingParticipants = useMemo(
|
const waitingParticipants = useMemo(
|
||||||
() => (canManageLobby ? waitingData?.participants || [] : []),
|
() =>
|
||||||
|
canManageLobby
|
||||||
|
? sortWaitingParticipants(waitingData?.participants || [])
|
||||||
|
: [],
|
||||||
[waitingData, canManageLobby]
|
[waitingData, canManageLobby]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ export const Conference = ({
|
|||||||
adaptiveStream: true,
|
adaptiveStream: true,
|
||||||
dynacast: true,
|
dynacast: true,
|
||||||
publishDefaults: {
|
publishDefaults: {
|
||||||
videoCodec: 'vp9',
|
videoCodec: apiConfig?.livekit.default_video_codec ?? 'vp9',
|
||||||
},
|
},
|
||||||
videoCaptureDefaults: {
|
videoCaptureDefaults: {
|
||||||
deviceId: userConfig.videoDeviceId ?? undefined,
|
deviceId: userConfig.videoDeviceId ?? undefined,
|
||||||
@@ -129,6 +129,7 @@ export const Conference = ({
|
|||||||
userConfig.videoPublishResolution,
|
userConfig.videoPublishResolution,
|
||||||
userConfig.audioDeviceId,
|
userConfig.audioDeviceId,
|
||||||
userConfig.audioOutputDeviceId,
|
userConfig.audioOutputDeviceId,
|
||||||
|
apiConfig?.livekit.default_video_codec,
|
||||||
])
|
])
|
||||||
|
|
||||||
const room = useMemo(() => new Room(roomOptions), [roomOptions])
|
const room = useMemo(() => new Room(roomOptions), [roomOptions])
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import { keys } from '@/api/queryKeys'
|
|||||||
import { queryClient } from '@/api/queryClient'
|
import { queryClient } from '@/api/queryClient'
|
||||||
import { ApiError } from '@/api/ApiError'
|
import { ApiError } from '@/api/ApiError'
|
||||||
|
|
||||||
export const POLL_INTERVAL_MS = 1000
|
export const POLL_INTERVAL_MS = 4_000
|
||||||
export const LAZY_POLL_INTERVAL_MS = 10_000
|
export const LAZY_POLL_INTERVAL_MS = 15_000
|
||||||
|
|
||||||
export const LobbyProvider = () => {
|
export const LobbyProvider = () => {
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
@@ -79,7 +79,7 @@ export const LobbyProvider = () => {
|
|||||||
// 3. Rights regained.
|
// 3. Rights regained.
|
||||||
const prevCanManageLobby = usePrevious(canManageLobby)
|
const prevCanManageLobby = usePrevious(canManageLobby)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!prevCanManageLobby && canManageLobby && isConnected) {
|
if (prevCanManageLobby != canManageLobby && isConnected) {
|
||||||
fetchIfManager()
|
fetchIfManager()
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const Card = styled('div', {
|
|||||||
borderRadius: '0.25rem',
|
borderRadius: '0.25rem',
|
||||||
boxShadow: '',
|
boxShadow: '',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
maxWidth: '380px',
|
maxWidth: '410px',
|
||||||
minHeight: '196px',
|
minHeight: '196px',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -229,7 +229,7 @@ const ConfirmationMessage = ({ onNext }: { onNext: () => void }) => {
|
|||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
style={{
|
style={{
|
||||||
maxWidth: '380px',
|
maxWidth: '410px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
} from '../api/requestEntry'
|
} from '../api/requestEntry'
|
||||||
|
|
||||||
export const WAIT_TIMEOUT_MS = 600000 // 10 minutes
|
export const WAIT_TIMEOUT_MS = 600000 // 10 minutes
|
||||||
export const POLL_INTERVAL_MS = 1000
|
export const POLL_INTERVAL_MS = 3_000
|
||||||
|
|
||||||
export const useLobby = ({
|
export const useLobby = ({
|
||||||
roomId,
|
roomId,
|
||||||
|
|||||||
@@ -71,20 +71,30 @@ export const ConnectionObserver = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAnalyticsEnabled) return
|
if (!isAnalyticsEnabled) return
|
||||||
|
|
||||||
const handleConnection = () => {
|
const handleConnection = async () => {
|
||||||
// Preserve original connection timestamp across reconnections to measure
|
// Preserve original connection timestamp across reconnections to measure
|
||||||
// total session duration from first connect to final disconnect.
|
// total session duration from first connect to final disconnect.
|
||||||
if (connectionStartTimeRef.current != null) return
|
if (connectionStartTimeRef.current != null) return
|
||||||
connectionStartTimeRef.current = Date.now()
|
connectionStartTimeRef.current = Date.now()
|
||||||
void captureMediaEvent('connection-event', {})
|
const participantSid = room.localParticipant.sid
|
||||||
|
const roomSid = await room.getSid().catch(() => undefined)
|
||||||
|
void captureMediaEvent('connection-event', {
|
||||||
|
livekit_room_sid: roomSid,
|
||||||
|
livekit_participant_sid: participantSid,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReconnect = () => {
|
const handleReconnect = () => {
|
||||||
captureEvent('reconnect-event')
|
captureEvent('reconnect-event')
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleReconnected = () => {
|
const handleReconnected = async () => {
|
||||||
captureEvent('reconnected-event')
|
const participantSid = room.localParticipant.sid
|
||||||
|
const roomSid = await room.getSid().catch(() => undefined)
|
||||||
|
captureEvent('reconnected-event', {
|
||||||
|
livekit_room_sid: roomSid,
|
||||||
|
livekit_participant_sid: participantSid,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSignalingConnect = () => {
|
const handleSignalingConnect = () => {
|
||||||
|
|||||||
+6
-6
@@ -2,23 +2,23 @@ import { RiQuestionLine } from '@remixicon/react'
|
|||||||
import { MenuItem } from 'react-aria-components'
|
import { MenuItem } from 'react-aria-components'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||||
import { Crisp } from 'crisp-sdk-web'
|
import {
|
||||||
import { useIsSupportEnabled } from '@/features/support/hooks/useSupport'
|
useIsSupportEnabled,
|
||||||
|
openSupportChat,
|
||||||
|
} from '@/features/support/hooks/useSupport'
|
||||||
|
|
||||||
export const SupportMenuItem = () => {
|
export const SupportMenuItem = () => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'options.items' })
|
||||||
const isSupportEnabled = useIsSupportEnabled()
|
const isSupportEnabled = useIsSupportEnabled()
|
||||||
|
|
||||||
if (!isSupportEnabled || !Crisp) {
|
if (!isSupportEnabled) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
className={menuRecipe({ icon: true, variant: 'dark' }).item}
|
||||||
onAction={() => {
|
onAction={openSupportChat}
|
||||||
Crisp?.chat.open()
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<RiQuestionLine size={20} />
|
<RiQuestionLine size={20} />
|
||||||
{t('support')}
|
{t('support')}
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ const Heading = styled('h1', {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const buttonClass = css({
|
const buttonClass = css({
|
||||||
width: { base: '100%', xsm: 'auto' },
|
width: '100%',
|
||||||
|
flex: 1,
|
||||||
})
|
})
|
||||||
|
|
||||||
enum DisconnectReasonKey {
|
enum DisconnectReasonKey {
|
||||||
@@ -64,7 +65,7 @@ const FeedbackRoute = () => {
|
|||||||
<Heading>{t(`feedback.heading.${reasonKey || 'normal'}`)}</Heading>
|
<Heading>{t(`feedback.heading.${reasonKey || 'normal'}`)}</Heading>
|
||||||
<Stack
|
<Stack
|
||||||
direction={{ base: 'column', xsm: 'row' }}
|
direction={{ base: 'column', xsm: 'row' }}
|
||||||
width={{ base: '100%', xsm: 'auto' }}
|
width="100%"
|
||||||
maxWidth="410px"
|
maxWidth="410px"
|
||||||
>
|
>
|
||||||
{showBackButton && (
|
{showBackButton && (
|
||||||
|
|||||||
@@ -1,20 +1,45 @@
|
|||||||
import { useEffect } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Crisp } from 'crisp-sdk-web'
|
|
||||||
import { type ApiUser } from '@/features/auth/api/ApiUser'
|
import { type ApiUser } from '@/features/auth/api/ApiUser'
|
||||||
import { useUser } from '@/features/auth/api/useUser'
|
import { useUser } from '@/features/auth/api/useUser'
|
||||||
import { useConfig } from '@/api/useConfig'
|
import { useConfig } from '@/api/useConfig'
|
||||||
|
|
||||||
|
type CrispSdk = (typeof import('crisp-sdk-web'))['Crisp']
|
||||||
|
|
||||||
|
let crisp: CrispSdk | undefined
|
||||||
|
let crispPromise: Promise<CrispSdk> | undefined
|
||||||
|
|
||||||
|
const loadCrisp = (): Promise<CrispSdk> => {
|
||||||
|
crispPromise ??= import('crisp-sdk-web')
|
||||||
|
.then((module) => {
|
||||||
|
crisp = module.Crisp
|
||||||
|
return module.Crisp
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
crispPromise = undefined
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
|
||||||
|
return crispPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
export const openSupportChat = () => {
|
||||||
|
if (!crisp?.isCrispInjected()) return
|
||||||
|
crisp.chat.open()
|
||||||
|
}
|
||||||
|
|
||||||
export const initializeSupportSession = (user: ApiUser) => {
|
export const initializeSupportSession = (user: ApiUser) => {
|
||||||
if (!Crisp.isCrispInjected()) return
|
if (!crisp?.isCrispInjected()) return
|
||||||
|
|
||||||
const { id, email } = user
|
const { id, email } = user
|
||||||
Crisp.setTokenId(`meet-${id}`)
|
crisp.setTokenId(`meet-${id}`)
|
||||||
if (email) Crisp.user.setEmail(email)
|
if (email) crisp.user.setEmail(email)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const terminateSupportSession = () => {
|
export const terminateSupportSession = () => {
|
||||||
if (!Crisp.isCrispInjected()) return
|
if (!crisp?.isCrispInjected()) return
|
||||||
Crisp.setTokenId()
|
|
||||||
Crisp.session.reset()
|
crisp.setTokenId()
|
||||||
|
crisp.session.reset()
|
||||||
}
|
}
|
||||||
|
|
||||||
export type useSupportProps = {
|
export type useSupportProps = {
|
||||||
@@ -22,26 +47,70 @@ export type useSupportProps = {
|
|||||||
isDisabled?: boolean
|
isDisabled?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configure Crisp chat for real-time support across all pages.
|
const IDLE_TIMEOUT_MS = 10_000
|
||||||
|
|
||||||
|
const scheduleWhenIdle = (callback: () => void): (() => void) => {
|
||||||
|
if (typeof window.requestIdleCallback === 'function') {
|
||||||
|
const handle = window.requestIdleCallback(callback, {
|
||||||
|
timeout: IDLE_TIMEOUT_MS,
|
||||||
|
})
|
||||||
|
return () => window.cancelIdleCallback(handle)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handle = window.setTimeout(callback, 1)
|
||||||
|
return () => window.clearTimeout(handle)
|
||||||
|
}
|
||||||
|
|
||||||
export const useSupport = ({ id, isDisabled }: useSupportProps) => {
|
export const useSupport = ({ id, isDisabled }: useSupportProps) => {
|
||||||
const { user } = useUser()
|
const { user } = useUser()
|
||||||
|
const [isInjected, setIsInjected] = useState(
|
||||||
|
() => crisp?.isCrispInjected() ?? false
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!id || Crisp.isCrispInjected() || isDisabled) return
|
if (!id || isDisabled) return
|
||||||
Crisp.configure(id)
|
|
||||||
Crisp.setHideOnMobile(true)
|
if (crisp?.isCrispInjected()) {
|
||||||
|
setIsInjected(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
const cancelIdle = scheduleWhenIdle(() => {
|
||||||
|
void loadCrisp()
|
||||||
|
.then((sdk) => {
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
if (!sdk.isCrispInjected()) {
|
||||||
|
sdk.configure(id)
|
||||||
|
sdk.setHideOnMobile(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsInjected(true)
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
console.error('Failed to initialize support chat', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
cancelIdle()
|
||||||
|
}
|
||||||
}, [id, isDisabled])
|
}, [id, isDisabled])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return
|
if (!user || !isInjected || isDisabled) return
|
||||||
initializeSupportSession(user)
|
initializeSupportSession(user)
|
||||||
}, [user])
|
}, [user, isInjected, isDisabled])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
// Some users may block Crisp chat widget with browser ad blockers or anti-tracking plugins
|
// Some users block the chat widget, so check its availability safely.
|
||||||
// So we need to safely check if Crisp is available and not blocked
|
|
||||||
const isCrispAvailable = () => {
|
const isCrispAvailable = () => {
|
||||||
try {
|
try {
|
||||||
return !!window?.$crisp?.is
|
return !!window?.$crisp?.is
|
||||||
|
|||||||
@@ -35,30 +35,21 @@ const LoginHint = () => {
|
|||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: '103px',
|
top: 'calc(100% + 12px)',
|
||||||
right: '110px',
|
right: 0,
|
||||||
zIndex: '100',
|
zIndex: '100',
|
||||||
outline: 'none',
|
outline: 'none',
|
||||||
padding: '1.25rem',
|
padding: '1.25rem',
|
||||||
maxWidth: '350px',
|
width: 'max-content',
|
||||||
|
maxWidth: 'min(350px, calc(100vw - 2rem))',
|
||||||
boxShadow: '0 2px 5px rgba(0 0 0 / 0.1)',
|
boxShadow: '0 2px 5px rgba(0 0 0 / 0.1)',
|
||||||
borderRadius: '1rem',
|
borderRadius: '1rem',
|
||||||
backgroundColor: 'primary.200',
|
backgroundColor: 'primary.200',
|
||||||
display: 'none',
|
|
||||||
xsm: {
|
|
||||||
display: 'block',
|
|
||||||
},
|
|
||||||
sm: {
|
|
||||||
top: '131px',
|
|
||||||
right: '100px',
|
|
||||||
zIndex: '100',
|
|
||||||
},
|
|
||||||
_after: {
|
_after: {
|
||||||
content: '""',
|
content: '""',
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: '-10px',
|
top: '-10px',
|
||||||
right: '20%',
|
right: '1.5rem',
|
||||||
marginLeft: '-10px',
|
|
||||||
borderWidth: '0 10px 10px 10px',
|
borderWidth: '0 10px 10px 10px',
|
||||||
borderStyle: 'solid',
|
borderStyle: 'solid',
|
||||||
borderColor: 'transparent transparent #E3E3FB transparent',
|
borderColor: 'transparent transparent #E3E3FB transparent',
|
||||||
@@ -171,12 +162,13 @@ export const Header = () => {
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={css({
|
className={css({
|
||||||
|
position: 'relative',
|
||||||
display: { base: 'none', xsm: 'block' },
|
display: { base: 'none', xsm: 'block' },
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<LoginButton proConnectHint={false} />
|
<LoginButton proConnectHint={false} />
|
||||||
|
<LoginHint />
|
||||||
</div>
|
</div>
|
||||||
<LoginHint />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{!!user && (
|
{!!user && (
|
||||||
|
|||||||
@@ -480,7 +480,7 @@
|
|||||||
"destination": "Ein neues Dokument wird erstellt auf",
|
"destination": "Ein neues Dokument wird erstellt auf",
|
||||||
"destinationUnknown": "Ein neues Dokument wird erstellt",
|
"destinationUnknown": "Ein neues Dokument wird erstellt",
|
||||||
"language": "Meeting-Sprache:",
|
"language": "Meeting-Sprache:",
|
||||||
"recording": "Auch eine Aufzeichnung starten"
|
"recording": "Auch eine Videoaufzeichnung starten"
|
||||||
},
|
},
|
||||||
"button": {
|
"button": {
|
||||||
"start": "Meeting-Transkription starten",
|
"start": "Meeting-Transkription starten",
|
||||||
|
|||||||
@@ -480,7 +480,7 @@
|
|||||||
"destination": "A new document will be created on",
|
"destination": "A new document will be created on",
|
||||||
"destinationUnknown": "A new document will be created",
|
"destinationUnknown": "A new document will be created",
|
||||||
"language": "Meeting language:",
|
"language": "Meeting language:",
|
||||||
"recording": "Also start a recording"
|
"recording": "Also start a video recording"
|
||||||
},
|
},
|
||||||
"button": {
|
"button": {
|
||||||
"start": "Start transcribing the meeting",
|
"start": "Start transcribing the meeting",
|
||||||
|
|||||||
@@ -479,7 +479,7 @@
|
|||||||
"destination": "Se creará un nuevo documento en",
|
"destination": "Se creará un nuevo documento en",
|
||||||
"destinationUnknown": "Se creará un nuevo documento",
|
"destinationUnknown": "Se creará un nuevo documento",
|
||||||
"language": "Idioma de la reunión:",
|
"language": "Idioma de la reunión:",
|
||||||
"recording": "Iniciar también una grabación"
|
"recording": "Iniciar también una grabación de vídeo"
|
||||||
},
|
},
|
||||||
"button": {
|
"button": {
|
||||||
"start": "Empezar a transcribir la reunión",
|
"start": "Empezar a transcribir la reunión",
|
||||||
|
|||||||
@@ -480,7 +480,7 @@
|
|||||||
"destination": "Un nouveau document sera créé sur",
|
"destination": "Un nouveau document sera créé sur",
|
||||||
"destinationUnknown": "Un nouveau document sera créé",
|
"destinationUnknown": "Un nouveau document sera créé",
|
||||||
"language": "Langue de la réunion :",
|
"language": "Langue de la réunion :",
|
||||||
"recording": "Démarrer aussi un enregistrement"
|
"recording": "Démarrer aussi un enregistrement vidéo"
|
||||||
},
|
},
|
||||||
"button": {
|
"button": {
|
||||||
"start": "Commencer à transcrire la réunion",
|
"start": "Commencer à transcrire la réunion",
|
||||||
|
|||||||
@@ -480,7 +480,7 @@
|
|||||||
"destination": "Er wordt een nieuw document aangemaakt op",
|
"destination": "Er wordt een nieuw document aangemaakt op",
|
||||||
"destinationUnknown": "Een nieuw document wordt aangemaakt",
|
"destinationUnknown": "Een nieuw document wordt aangemaakt",
|
||||||
"language": "Vergadertalen:",
|
"language": "Vergadertalen:",
|
||||||
"recording": "Start ook een opname"
|
"recording": "Start ook een video-opname"
|
||||||
},
|
},
|
||||||
"button": {
|
"button": {
|
||||||
"start": "Begin met het transcriberen van de vergadering",
|
"start": "Begin met het transcriberen van de vergadering",
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ backend:
|
|||||||
# Frontend
|
# Frontend
|
||||||
LIVEKIT_FORCE_WSS_PROTOCOL: True
|
LIVEKIT_FORCE_WSS_PROTOCOL: True
|
||||||
LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND: True
|
LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND: True
|
||||||
|
LIVEKIT_DEFAULT_VIDEO_CODEC: vp9
|
||||||
FRONTEND_IDLE_DISCONNECT_WARNING_DELAY: 9000
|
FRONTEND_IDLE_DISCONNECT_WARNING_DELAY: 9000
|
||||||
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
|
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
|
||||||
FRONTEND_SUPPORT: "{'id': '58ea6697-8eba-4492-bc59-ad6562585041', 'help_article_transcript': 'https://lasuite.crisp.help/fr/article/visio-transcript-1sjq43x', 'help_article_recording': 'https://lasuite.crisp.help/fr/article/visio-enregistrement-wgc8o0', 'help_article_more_tools': 'https://lasuite.crisp.help/fr/article/visio-tools-bvxj23'}"
|
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'}"
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "mail_mjml",
|
"name": "mail_mjml",
|
||||||
"version": "1.30.0",
|
"version": "1.31.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "mail_mjml",
|
"name": "mail_mjml",
|
||||||
"version": "1.30.0",
|
"version": "1.31.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@html-to/text-cli": "0.6.1",
|
"@html-to/text-cli": "0.6.1",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mail_mjml",
|
"name": "mail_mjml",
|
||||||
"version": "1.30.0",
|
"version": "1.31.0",
|
||||||
"description": "An util to generate html and text django's templates from mjml templates",
|
"description": "An util to generate html and text django's templates from mjml templates",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sdk",
|
"name": "sdk",
|
||||||
"version": "1.30.0",
|
"version": "1.31.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sdk",
|
"name": "sdk",
|
||||||
"version": "1.30.0",
|
"version": "1.31.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"./library",
|
"./library",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sdk",
|
"name": "sdk",
|
||||||
"version": "1.30.0",
|
"version": "1.31.0",
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"description": "",
|
"description": "",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "summary"
|
name = "summary"
|
||||||
version = "1.30.0"
|
version = "1.31.0"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"fastapi[standard]>=0.105.0",
|
"fastapi[standard]>=0.105.0",
|
||||||
|
|||||||
Generated
+1
-1
@@ -1507,7 +1507,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "summary"
|
name = "summary"
|
||||||
version = "1.30.0"
|
version = "1.31.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "celery" },
|
{ name = "celery" },
|
||||||
|
|||||||
Reference in New Issue
Block a user