Compare commits
26 Commits
pydantic
...
entitlements
| Author | SHA1 | Date | |
|---|---|---|---|
| ca98cf5fac | |||
| e56c0f997e | |||
| 61afd94e3a | |||
| 3d7aec2b4a | |||
| 9c009839f0 | |||
| ec63ddcd47 | |||
| fcde8757e6 | |||
| 9610e606eb | |||
| 8362ac0e24 | |||
| f1ddd7fa2f | |||
| 487340efca | |||
| 7ebc928dd3 | |||
| 85de214ca7 | |||
| e3e34dbf31 | |||
| 555afe4abd | |||
| 78ddb121e3 | |||
| ca9c7fc152 | |||
| 6e3845d0c1 | |||
| 41b171da68 | |||
| 4ad897e756 | |||
| 42647d6d25 | |||
| 14526808ab | |||
| 25167495cc | |||
| 720eb6a93e | |||
| bfbf253033 | |||
| 692e0e359e |
@@ -8,6 +8,25 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🩹(frontend) remove incorrect reference to ProConnect on the prejoin #1080
|
||||
- ✨(frontend) add Ctrl+Shift+/ to open shortcuts settings #1050
|
||||
- ♿(frontend) announce selected state to screen readers #1081
|
||||
|
||||
### Changed
|
||||
|
||||
- 🔒️(backend) enhance API input validation to strengthen security #1053
|
||||
- 🦺(backend) strengthen API validation for recording options #1063
|
||||
- ⚡️(frontend) optimize few performance caveats #1073
|
||||
- 🔒️(helm) introduce a dedicated Kubernetes Ingress for webhook-livekit #1066
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(migrations) use settings in migrations #1058
|
||||
- 💄(frontend) truncate pinned participant name with ellipsis on overflow #1056
|
||||
- ♿(frontend) prevent focus ring clipping on invite dialog #1078
|
||||
|
||||
## [1.9.0] - 2026-03-02
|
||||
|
||||
### Added
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from rest_framework import permissions
|
||||
|
||||
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
|
||||
|
||||
from ..models import RoleChoices
|
||||
|
||||
ACTION_FOR_METHOD_TO_PERMISSION = {
|
||||
@@ -45,11 +47,27 @@ class RoomPermissions(permissions.BasePermission):
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Only allow authenticated users for unsafe methods."""
|
||||
"""Only allow authenticated users for unsafe methods.
|
||||
|
||||
Room creation additionally requires the can_create entitlement.
|
||||
Fail-closed: denies creation when the entitlements service is unavailable.
|
||||
"""
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
|
||||
return request.user.is_authenticated
|
||||
if not request.user.is_authenticated:
|
||||
return False
|
||||
|
||||
if view.action == "create":
|
||||
try:
|
||||
entitlements = get_user_entitlements(
|
||||
request.user.sub, request.user.email
|
||||
)
|
||||
return entitlements.get("can_create", False)
|
||||
except EntitlementsUnavailableError:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Object permissions are only given to administrators of the room."""
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
"""Client serializers for the Meet core app."""
|
||||
|
||||
# pylint: disable=abstract-method,no-name-in-module
|
||||
from typing import Literal
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from livekit.api import ParticipantPermission
|
||||
from django_pydantic_field.rest_framework import SchemaField
|
||||
from pydantic import BaseModel, Field
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from timezone_field.rest_framework import TimeZoneSerializerField
|
||||
|
||||
from core import models, utils
|
||||
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
@@ -23,6 +28,25 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
||||
|
||||
|
||||
class UserMeSerializer(UserSerializer):
|
||||
"""Serialize users for me endpoint."""
|
||||
|
||||
can_create = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = [*UserSerializer.Meta.fields, "can_create"]
|
||||
read_only_fields = [*UserSerializer.Meta.read_only_fields, "can_create"]
|
||||
|
||||
def get_can_create(self, user) -> bool:
|
||||
"""Check entitlements for the current user."""
|
||||
try:
|
||||
entitlements = get_user_entitlements(user.sub, user.email)
|
||||
return entitlements.get("can_create", False)
|
||||
except EntitlementsUnavailableError:
|
||||
return False
|
||||
|
||||
|
||||
class ResourceAccessSerializerMixin:
|
||||
"""
|
||||
A serializer mixin to share controlling that the logged-in user submitting a room access object
|
||||
@@ -201,6 +225,27 @@ class BaseValidationOnlySerializer(serializers.Serializer):
|
||||
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
|
||||
|
||||
|
||||
class RecordingOptions(BaseModel):
|
||||
"""Configuration options for recording.
|
||||
|
||||
Attributes:
|
||||
language: ISO 639-1 language code compatible with whisperX.
|
||||
When `None`, the transcription engine will attempt to
|
||||
auto-detect the spoken language.
|
||||
transcribe: Whether to transcribe the recorded audio.
|
||||
When `None`, falls back to the application default.
|
||||
original_mode: The original recording mode before any override.
|
||||
Must be one of the valid RecordingModeChoices values when provided.
|
||||
|
||||
"""
|
||||
|
||||
language: str | None = None
|
||||
transcribe: bool | None = None
|
||||
original_mode: Literal["screen_recording", "transcript"] | None = None
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
|
||||
class StartRecordingSerializer(BaseValidationOnlySerializer):
|
||||
"""Validate start recording requests."""
|
||||
|
||||
@@ -213,10 +258,11 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
|
||||
"screen_recording or transcript.",
|
||||
},
|
||||
)
|
||||
options = serializers.JSONField(
|
||||
options = SchemaField(
|
||||
schema=RecordingOptions | None,
|
||||
required=False,
|
||||
allow_null=True,
|
||||
default=dict,
|
||||
help_text="Recording options",
|
||||
)
|
||||
|
||||
|
||||
@@ -261,6 +307,28 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
|
||||
)
|
||||
|
||||
|
||||
class ParticipantPermission(BaseModel):
|
||||
"""Mirror the LiveKit ParticipantPermission protobuf.
|
||||
|
||||
Control what a participant is allowed to publish, subscribe, and do within a room.
|
||||
Unknown fields are rejected.
|
||||
"""
|
||||
|
||||
can_subscribe: bool | None = None
|
||||
can_publish: bool | None = None
|
||||
can_publish_data: bool | None = None
|
||||
can_publish_sources: list[int] = Field(
|
||||
default_factory=list
|
||||
) # TrackSource enum values
|
||||
hidden: bool | None = None
|
||||
recorder: bool | None = None
|
||||
can_update_metadata: bool | None = None
|
||||
agent: bool | None = None
|
||||
can_subscribe_metrics: bool | None = None
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
|
||||
class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
||||
"""Validate participant update data."""
|
||||
|
||||
@@ -272,10 +340,11 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
||||
allow_null=True,
|
||||
help_text="Participant attributes as JSON object",
|
||||
)
|
||||
permission = serializers.DictField(
|
||||
permission = SchemaField(
|
||||
schema=ParticipantPermission | None,
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Participant permission as JSON object",
|
||||
help_text="Participant permissions",
|
||||
)
|
||||
name = serializers.CharField(
|
||||
max_length=255,
|
||||
@@ -285,6 +354,33 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
||||
help_text="Display name for the participant",
|
||||
)
|
||||
|
||||
def validate_permission(self, permission):
|
||||
"""Validate that the given permission does not include forbidden or unimplemented fields."""
|
||||
|
||||
if permission is None:
|
||||
return None
|
||||
|
||||
suspicious_fields = [
|
||||
field
|
||||
for field in settings.PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS
|
||||
if getattr(permission, field) is not None
|
||||
]
|
||||
if suspicious_fields:
|
||||
raise SuspiciousOperation(
|
||||
f"Setting the following participant permissions is not allowed: "
|
||||
f"{', '.join(suspicious_fields)}."
|
||||
)
|
||||
if permission.can_subscribe_metrics is not None:
|
||||
raise serializers.ValidationError(
|
||||
{
|
||||
"permission": {
|
||||
"can_subscribe_metrics": "This permission is not implemented."
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return permission
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Ensure at least one update field is provided."""
|
||||
update_fields = ["metadata", "attributes", "permission", "name"]
|
||||
@@ -300,12 +396,4 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
||||
f"{', '.join(update_fields)}."
|
||||
)
|
||||
|
||||
if "permission" in attrs:
|
||||
try:
|
||||
ParticipantPermission(**attrs["permission"])
|
||||
except ValueError as e:
|
||||
raise serializers.ValidationError(
|
||||
{"permission": f"Invalid permission: {str(e)}"}
|
||||
) from e
|
||||
|
||||
return attrs
|
||||
|
||||
@@ -187,7 +187,7 @@ class UserViewSet(
|
||||
"""
|
||||
context = {"request": request}
|
||||
return drf_response.Response(
|
||||
self.serializer_class(request.user, context=context).data
|
||||
serializers.UserMeSerializer(request.user, context=context).data
|
||||
)
|
||||
|
||||
|
||||
@@ -296,12 +296,14 @@ class RoomViewSet(
|
||||
)
|
||||
|
||||
mode = serializer.validated_data["mode"]
|
||||
options = serializer.validated_data["options"]
|
||||
options = serializer.validated_data.get("options")
|
||||
room = self.get_object()
|
||||
|
||||
# May raise exception if an active or initiated recording already exist for the room
|
||||
recording = models.Recording.objects.create(
|
||||
room=room, mode=mode, options=options
|
||||
room=room,
|
||||
mode=mode,
|
||||
options=options.model_dump(exclude_none=True) if options else {},
|
||||
)
|
||||
|
||||
models.RecordingAccess.objects.create(
|
||||
@@ -607,13 +609,15 @@ class RoomViewSet(
|
||||
serializer = serializers.UpdateParticipantSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
permission = serializer.validated_data.get("permission")
|
||||
|
||||
try:
|
||||
ParticipantsManagement().update(
|
||||
room_name=str(room.pk),
|
||||
identity=str(serializer.validated_data["participant_identity"]),
|
||||
metadata=serializer.validated_data.get("metadata"),
|
||||
attributes=serializer.validated_data.get("attributes"),
|
||||
permission=serializer.validated_data.get("permission"),
|
||||
permission=permission.model_dump() if permission else None,
|
||||
name=serializer.validated_data.get("name"),
|
||||
)
|
||||
except ParticipantsManagementException:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Authentication Backends for the Meet core app."""
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
|
||||
@@ -10,6 +11,7 @@ from lasuite.oidc_login.backends import (
|
||||
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
|
||||
)
|
||||
|
||||
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
|
||||
from core.models import User
|
||||
from core.services.marketing import (
|
||||
ContactCreationError,
|
||||
@@ -17,6 +19,8 @@ from core.services.marketing import (
|
||||
get_marketing_service,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
||||
@@ -59,6 +63,21 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
if is_new_user and email and settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
|
||||
self.signup_to_marketing_email(email)
|
||||
|
||||
# Warm the entitlements cache on login (force_refresh)
|
||||
try:
|
||||
get_user_entitlements(
|
||||
user_sub=user.sub,
|
||||
user_email=user.email,
|
||||
user_info=claims,
|
||||
force_refresh=True,
|
||||
)
|
||||
except EntitlementsUnavailableError:
|
||||
email_domain = user.email.split("@")[-1] if "@" in user.email else "?"
|
||||
logger.warning(
|
||||
"Entitlements unavailable for user@%s during login",
|
||||
email_domain,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def signup_to_marketing_email(email):
|
||||
"""Pragmatic approach to newsletter signup during authentication flow.
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Entitlements service layer."""
|
||||
|
||||
from core.entitlements.factory import get_entitlements_backend
|
||||
|
||||
|
||||
class EntitlementsUnavailableError(Exception):
|
||||
"""Raised when the entitlements backend cannot be reached or returns an error."""
|
||||
|
||||
|
||||
def get_user_entitlements(user_sub, user_email, user_info=None, force_refresh=False):
|
||||
"""Get user entitlements, delegating to the configured backend.
|
||||
|
||||
Args:
|
||||
user_sub: The user's OIDC subject identifier.
|
||||
user_email: The user's email address.
|
||||
user_info: The full OIDC user_info dict (forwarded to backend).
|
||||
force_refresh: If True, bypass backend cache and fetch fresh data.
|
||||
|
||||
Returns:
|
||||
dict: {"can_create": bool}
|
||||
|
||||
Raises:
|
||||
EntitlementsUnavailableError: If the backend cannot be reached
|
||||
and no cache exists.
|
||||
"""
|
||||
backend = get_entitlements_backend()
|
||||
return backend.get_user_entitlements(
|
||||
user_sub, user_email, user_info=user_info, force_refresh=force_refresh
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Abstract base class for entitlements backends."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class EntitlementsBackend(ABC):
|
||||
"""Abstract base class that defines the interface for entitlements backends."""
|
||||
|
||||
@abstractmethod
|
||||
def get_user_entitlements(
|
||||
self, user_sub, user_email, user_info=None, force_refresh=False
|
||||
):
|
||||
"""Fetch user entitlements.
|
||||
|
||||
Args:
|
||||
user_sub: The user's OIDC subject identifier.
|
||||
user_email: The user's email address.
|
||||
user_info: The full OIDC user_info dict (backends may
|
||||
extract claims from it).
|
||||
force_refresh: If True, bypass any cache and fetch fresh data.
|
||||
|
||||
Returns:
|
||||
dict: {"can_create": bool}
|
||||
|
||||
Raises:
|
||||
EntitlementsUnavailableError: If the backend cannot be reached.
|
||||
"""
|
||||
@@ -0,0 +1,120 @@
|
||||
"""DeployCenter (Espace Operateur) entitlements backend."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
import requests
|
||||
|
||||
from core.entitlements import EntitlementsUnavailableError
|
||||
from core.entitlements.backends.base import EntitlementsBackend
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DeployCenterEntitlementsBackend(EntitlementsBackend):
|
||||
"""Backend that fetches entitlements from the DeployCenter API.
|
||||
|
||||
Args:
|
||||
base_url: Full URL of the entitlements endpoint
|
||||
(e.g. "https://dc.example.com/api/v1.0/entitlements/").
|
||||
service_id: The service identifier in DeployCenter.
|
||||
api_key: API key for X-Service-Auth header.
|
||||
timeout: HTTP request timeout in seconds.
|
||||
oidc_claims: List of OIDC claim names to extract from user_info
|
||||
and forward as query params (e.g. ["siret"]).
|
||||
"""
|
||||
|
||||
def __init__( # pylint: disable=too-many-arguments
|
||||
self,
|
||||
base_url,
|
||||
service_id,
|
||||
api_key,
|
||||
*,
|
||||
timeout=10,
|
||||
oidc_claims=None,
|
||||
):
|
||||
self.base_url = base_url
|
||||
self.service_id = service_id
|
||||
self.api_key = api_key
|
||||
self.timeout = timeout
|
||||
self.oidc_claims = oidc_claims or []
|
||||
|
||||
def _cache_key(self, user_sub):
|
||||
return f"entitlements:user:{user_sub}"
|
||||
|
||||
def _make_request(self, user_email, user_info=None):
|
||||
"""Make a request to the DeployCenter entitlements API.
|
||||
|
||||
Returns:
|
||||
dict | None: The response data, or None on failure.
|
||||
"""
|
||||
params = {
|
||||
"service_id": self.service_id,
|
||||
"account_type": "user",
|
||||
"account_email": user_email,
|
||||
}
|
||||
|
||||
# Forward configured OIDC claims as query params
|
||||
if user_info:
|
||||
for claim in self.oidc_claims:
|
||||
if claim in user_info:
|
||||
params[claim] = user_info[claim]
|
||||
|
||||
headers = {
|
||||
"X-Service-Auth": f"Bearer {self.api_key}",
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.get(
|
||||
self.base_url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except (requests.RequestException, ValueError):
|
||||
email_domain = user_email.split("@")[-1] if "@" in user_email else "?"
|
||||
logger.warning(
|
||||
"DeployCenter entitlements request failed for user@%s",
|
||||
email_domain,
|
||||
exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
def get_user_entitlements(
|
||||
self, user_sub, user_email, user_info=None, force_refresh=False
|
||||
):
|
||||
"""Fetch user entitlements from DeployCenter with caching.
|
||||
|
||||
On cache miss or force_refresh: fetches from the API.
|
||||
On API failure: falls back to stale cache if available,
|
||||
otherwise raises EntitlementsUnavailableError.
|
||||
"""
|
||||
cache_key = self._cache_key(user_sub)
|
||||
|
||||
if not force_refresh:
|
||||
cached = cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
data = self._make_request(user_email, user_info=user_info)
|
||||
|
||||
if data is None:
|
||||
# API failed — try stale cache as fallback
|
||||
cached = cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
raise EntitlementsUnavailableError(
|
||||
"Failed to fetch user entitlements from DeployCenter"
|
||||
)
|
||||
|
||||
entitlements = data.get("entitlements", {})
|
||||
result = {
|
||||
"can_create": entitlements.get("can_create", False),
|
||||
}
|
||||
|
||||
cache.set(cache_key, result, settings.ENTITLEMENTS_CACHE_TIMEOUT)
|
||||
return result
|
||||
@@ -0,0 +1,12 @@
|
||||
"""Local entitlements backend for development and testing."""
|
||||
|
||||
from core.entitlements.backends.base import EntitlementsBackend
|
||||
|
||||
|
||||
class LocalEntitlementsBackend(EntitlementsBackend):
|
||||
"""Local backend that always grants access."""
|
||||
|
||||
def get_user_entitlements(
|
||||
self, user_sub, user_email, user_info=None, force_refresh=False
|
||||
):
|
||||
return {"can_create": True}
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Factory for creating entitlements backend instances."""
|
||||
|
||||
import functools
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
|
||||
@functools.cache
|
||||
def get_entitlements_backend():
|
||||
"""Return a singleton instance of the configured entitlements backend."""
|
||||
backend_class = import_string(settings.ENTITLEMENTS_BACKEND)
|
||||
return backend_class(**settings.ENTITLEMENTS_BACKEND_PARAMETERS)
|
||||
@@ -44,7 +44,7 @@ class Migration(migrations.Migration):
|
||||
('sub', models.CharField(blank=True, help_text='Optional for pending users; required upon account activation. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
|
||||
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
|
||||
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
|
||||
('language', models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
|
||||
('language', models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
|
||||
('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)),
|
||||
('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')),
|
||||
('is_staff', models.BooleanField(default=False, help_text='Whether the user can log into this admin site.', verbose_name='staff status')),
|
||||
@@ -96,7 +96,7 @@ class Migration(migrations.Migration):
|
||||
migrations.AddField(
|
||||
model_name='resource',
|
||||
name='users',
|
||||
field=models.ManyToManyField(related_name='resources', through='core.ResourceAccess', to=settings.AUTH_USER_MODEL),
|
||||
field=models.ManyToManyField(related_name='resources', through='core.ResourceAccess', through_fields=('resource', 'user'), to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='resourceaccess',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Generated by Django 5.0.7 on 2024-08-07 14:39
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -13,6 +13,6 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
field=models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Generated by Django 5.1.8 on 2025-04-22 14:52
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
@@ -13,6 +13,6 @@ class Migration(migrations.Migration):
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('nl-nl', 'Dutch'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
field=models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -8,6 +8,7 @@ import random
|
||||
from unittest import mock
|
||||
from uuid import uuid4
|
||||
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.urls import reverse
|
||||
|
||||
import pytest
|
||||
@@ -132,11 +133,7 @@ def test_update_participant_success(mock_livekit_client):
|
||||
1,
|
||||
2,
|
||||
], # [TrackSource.CAMERA, TrackSource.MICROPHONE]
|
||||
"hidden": False,
|
||||
"recorder": False,
|
||||
"can_update_metadata": True,
|
||||
"agent": False,
|
||||
"can_subscribe_metrics": False,
|
||||
},
|
||||
"name": "John Doe",
|
||||
}
|
||||
@@ -151,6 +148,151 @@ def test_update_participant_success(mock_livekit_client):
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"permission_payload",
|
||||
[
|
||||
{}, # empty dict is valid
|
||||
{"can_subscribe": True},
|
||||
{"can_publish": True},
|
||||
{"can_publish_data": True},
|
||||
{"can_publish_sources": [1, 2]},
|
||||
{"can_update_metadata": True},
|
||||
],
|
||||
)
|
||||
def test_update_participant_permission_fields_are_optional(
|
||||
mock_livekit_client, permission_payload
|
||||
):
|
||||
"""Test that each required permission field can be passed individually."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {
|
||||
"participant_identity": str(uuid4()),
|
||||
"permission": permission_payload,
|
||||
}
|
||||
|
||||
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {"status": "success"}
|
||||
|
||||
mock_livekit_client.room.update_participant.assert_called_once()
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value,permission_key",
|
||||
[
|
||||
(False, "hidden"),
|
||||
(True, "hidden"),
|
||||
(False, "recorder"),
|
||||
(True, "recorder"),
|
||||
(False, "agent"),
|
||||
(True, "agent"),
|
||||
],
|
||||
)
|
||||
@mock.patch("core.api.serializers.SuspiciousOperation", side_effect=SuspiciousOperation)
|
||||
def test_update_participant_suspicious_permission(
|
||||
mock_suspicious, value, permission_key
|
||||
):
|
||||
"""Test update participant raises 400 when a restricted permission is set."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {
|
||||
"participant_identity": str(uuid4()),
|
||||
"permission": {
|
||||
"can_subscribe": True,
|
||||
"can_publish": True,
|
||||
"can_publish_data": True,
|
||||
"can_update_metadata": False,
|
||||
permission_key: value,
|
||||
},
|
||||
}
|
||||
|
||||
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
mock_suspicious.assert_called_once_with(
|
||||
f"Setting the following participant permissions is not allowed: {permission_key}."
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.api.serializers.SuspiciousOperation", side_effect=SuspiciousOperation)
|
||||
def test_update_participant_suspicious_permission_multiple(mock_suspicious):
|
||||
"""Test update participant raises 400 when multiple suspicious permissions are set."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {
|
||||
"participant_identity": str(uuid4()),
|
||||
"permission": {
|
||||
"can_subscribe": True,
|
||||
"can_publish": True,
|
||||
"can_publish_data": True,
|
||||
"hidden": True,
|
||||
"recorder": False,
|
||||
"can_update_metadata": False,
|
||||
"agent": True,
|
||||
"can_subscribe_metrics": False,
|
||||
},
|
||||
}
|
||||
|
||||
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
mock_suspicious.assert_called_once_with(
|
||||
"Setting the following participant permissions is not allowed: hidden, recorder, agent."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", (False, True))
|
||||
def test_update_participant_unimplemented_can_subscribe_metrics(value):
|
||||
"""Test update participant raises 400 when can_subscribe_metrics is set."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {
|
||||
"participant_identity": str(uuid4()),
|
||||
"permission": {
|
||||
"can_subscribe": True,
|
||||
"can_publish": True,
|
||||
"can_publish_data": True,
|
||||
"can_update_metadata": False,
|
||||
"can_subscribe_metrics": value,
|
||||
},
|
||||
}
|
||||
|
||||
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "can_subscribe_metrics" in str(response.data)
|
||||
|
||||
|
||||
def test_update_participant_forbidden_without_access():
|
||||
"""Test update participant returns 403 when user lacks room privileges."""
|
||||
client = APIClient()
|
||||
@@ -226,7 +368,17 @@ def test_update_participant_invalid_permission():
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "Invalid permission" in str(response.data)
|
||||
assert response.json() == {
|
||||
"permission": [
|
||||
{
|
||||
"type": "extra_forbidden",
|
||||
"loc": ["invalid-attributes"],
|
||||
"msg": "Extra inputs are not permitted",
|
||||
"input": "True",
|
||||
"url": "https://errors.pydantic.dev/2.12/v/extra_forbidden",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_update_participant_wrong_metadata_attributes():
|
||||
|
||||
@@ -199,3 +199,308 @@ def test_start_recording_success(
|
||||
access = recording.accesses.first()
|
||||
assert access.user == user
|
||||
assert access.role == "owner"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["fr", "en", "nl", "de"])
|
||||
def test_start_recording_options_language_valid(
|
||||
settings, mock_worker_service_factory, mock_worker_manager, value
|
||||
):
|
||||
"""Should accept a valid ISO 639-1 language code."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording", "options": {"language": value}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
recording = Recording.objects.get(room=room)
|
||||
assert recording.options == {"language": value}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["invalid-value", "francais", "123"])
|
||||
def test_start_recording_options_language_not_validated(
|
||||
settings, mock_worker_service_factory, mock_worker_manager, value
|
||||
):
|
||||
"""Invalid language codes are currently accepted — no format validation yet.
|
||||
|
||||
TODO: tighten this once language validation is introduced.
|
||||
"""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording", "options": {"language": value}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
|
||||
def test_start_recording_options_language_null(
|
||||
settings, mock_worker_service_factory, mock_worker_manager
|
||||
):
|
||||
"""Should accept null language (triggers auto-detection)."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording", "options": {"language": None}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
recording = Recording.objects.get(room=room)
|
||||
assert recording.options == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [True, 1, "y", "on", "true", "yes", "t"])
|
||||
def test_start_recording_options_transcribe_valid_true(
|
||||
settings, mock_worker_service_factory, mock_worker_manager, value
|
||||
):
|
||||
"""Should accept transcribe with any valid pydantic true values."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording", "options": {"transcribe": value}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
recording = Recording.objects.get(room=room)
|
||||
assert recording.options == {"transcribe": True}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [False, 0, "n", "off", "false", "no", "f"])
|
||||
def test_start_recording_options_transcribe_valid_false(
|
||||
settings, mock_worker_service_factory, mock_worker_manager, value
|
||||
):
|
||||
"""Should accept transcribe with any valid pydantic false values."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording", "options": {"transcribe": value}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
recording = Recording.objects.get(room=room)
|
||||
assert recording.options == {"transcribe": False}
|
||||
|
||||
|
||||
def test_start_recording_options_transcribe_null(
|
||||
settings, mock_worker_service_factory, mock_worker_manager
|
||||
):
|
||||
"""Should accept transcribe=null (falls back to application default)."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording", "options": {"transcribe": None}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
recording = Recording.objects.get(room=room)
|
||||
assert recording.options == {}
|
||||
|
||||
|
||||
def test_start_recording_options_null(
|
||||
settings, mock_worker_service_factory, mock_worker_manager
|
||||
):
|
||||
"""Should accept options=null."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording", "options": None},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
recording = Recording.objects.get(room=room)
|
||||
assert recording.options == {}
|
||||
|
||||
|
||||
def test_start_recording_options_omitted(
|
||||
settings, mock_worker_service_factory, mock_worker_manager
|
||||
):
|
||||
"""Should accept a request with no options field at all."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
recording = Recording.objects.get(room=room)
|
||||
assert recording.options == {}
|
||||
|
||||
|
||||
def test_start_recording_options_unknown_field_rejected(settings):
|
||||
"""Should reject unknown fields in options (extra='forbid')."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording", "options": {"unknown_field": "value"}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["foo", 12])
|
||||
def test_start_recording_options_invalid_transcribe_type(settings, value):
|
||||
"""Should reject non-boolean transcribe values."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording", "options": {"transcribe": value}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["screen_recording", "transcript"])
|
||||
def test_start_recording_options_original_mode_valid(
|
||||
settings, mock_worker_service_factory, mock_worker_manager, value
|
||||
):
|
||||
"""Should accept valid recording mode choices for original_mode."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording", "options": {"original_mode": value}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
recording = Recording.objects.get(room=room)
|
||||
assert recording.options == {"original_mode": value}
|
||||
|
||||
|
||||
def test_start_recording_options_original_mode_null(
|
||||
settings, mock_worker_service_factory, mock_worker_manager
|
||||
):
|
||||
"""Should accept original_mode=null."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording", "options": {"original_mode": None}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
recording = Recording.objects.get(room=room)
|
||||
assert recording.options == {}
|
||||
|
||||
|
||||
def test_start_recording_options_original_mode_omitted(
|
||||
settings, mock_worker_service_factory, mock_worker_manager
|
||||
):
|
||||
"""Should accept a request with original_mode omitted."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording", "options": {}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
recording = Recording.objects.get(room=room)
|
||||
assert recording.options == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["invalid_mode", "foo", 123, "SCREEN_RECORDING"])
|
||||
def test_start_recording_options_original_mode_invalid(settings, value):
|
||||
"""Should reject invalid recording mode values for original_mode."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
room.accesses.create(user=user, role="owner")
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording", "options": {"original_mode": value}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
@@ -125,6 +125,7 @@ def test_api_users_retrieve_me_authenticated(settings):
|
||||
"short_name": user.short_name,
|
||||
"language": user.language,
|
||||
"timezone": "UTC",
|
||||
"can_create": True,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,504 @@
|
||||
"""Tests for the entitlements module."""
|
||||
|
||||
# pylint: disable=redefined-outer-name
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
import responses
|
||||
from rest_framework.status import HTTP_201_CREATED, HTTP_403_FORBIDDEN
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from django.core.cache import cache as django_cache
|
||||
|
||||
from core import factories
|
||||
from core.api.serializers import UserMeSerializer
|
||||
from core.authentication.backends import OIDCAuthenticationBackend
|
||||
from core.entitlements import EntitlementsUnavailableError, get_user_entitlements
|
||||
from core.entitlements.backends.deploycenter import DeployCenterEntitlementsBackend
|
||||
from core.entitlements.backends.local import LocalEntitlementsBackend
|
||||
from core.entitlements.factory import get_entitlements_backend
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
DC_URL = "https://deploy.example.com/api/v1.0/entitlements/"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_cache():
|
||||
"""Clear Django cache between tests to prevent entitlements cache bleed."""
|
||||
django_cache.clear()
|
||||
|
||||
|
||||
# -- LocalEntitlementsBackend --
|
||||
|
||||
|
||||
def test_local_backend_always_grants_access():
|
||||
"""The local backend should always return can_create=True."""
|
||||
backend = LocalEntitlementsBackend()
|
||||
result = backend.get_user_entitlements("sub-123", "user@example.com")
|
||||
assert result == {"can_create": True}
|
||||
|
||||
|
||||
def test_local_backend_ignores_parameters():
|
||||
"""The local backend should work regardless of parameters passed."""
|
||||
backend = LocalEntitlementsBackend()
|
||||
result = backend.get_user_entitlements(
|
||||
"sub-123",
|
||||
"user@example.com",
|
||||
user_info={"some": "claim"},
|
||||
force_refresh=True,
|
||||
)
|
||||
assert result == {"can_create": True}
|
||||
|
||||
|
||||
# -- Factory --
|
||||
|
||||
|
||||
@override_settings(
|
||||
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
|
||||
ENTITLEMENTS_BACKEND_PARAMETERS={},
|
||||
)
|
||||
def test_factory_returns_local_backend():
|
||||
"""The factory should instantiate the configured backend."""
|
||||
get_entitlements_backend.cache_clear()
|
||||
backend = get_entitlements_backend()
|
||||
assert isinstance(backend, LocalEntitlementsBackend)
|
||||
get_entitlements_backend.cache_clear()
|
||||
|
||||
|
||||
@override_settings(
|
||||
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
|
||||
ENTITLEMENTS_BACKEND_PARAMETERS={},
|
||||
)
|
||||
def test_factory_singleton():
|
||||
"""The factory should return the same instance on repeated calls."""
|
||||
get_entitlements_backend.cache_clear()
|
||||
backend1 = get_entitlements_backend()
|
||||
backend2 = get_entitlements_backend()
|
||||
assert backend1 is backend2
|
||||
get_entitlements_backend.cache_clear()
|
||||
|
||||
|
||||
# -- get_user_entitlements public API --
|
||||
|
||||
|
||||
@override_settings(
|
||||
ENTITLEMENTS_BACKEND="core.entitlements.backends.local.LocalEntitlementsBackend",
|
||||
ENTITLEMENTS_BACKEND_PARAMETERS={},
|
||||
)
|
||||
def test_get_user_entitlements_with_local_backend():
|
||||
"""The public API should delegate to the configured backend."""
|
||||
get_entitlements_backend.cache_clear()
|
||||
result = get_user_entitlements("sub-123", "user@example.com")
|
||||
assert result["can_create"] is True
|
||||
get_entitlements_backend.cache_clear()
|
||||
|
||||
|
||||
# -- DeployCenterEntitlementsBackend --
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_deploycenter_backend_grants_access():
|
||||
"""DeployCenter backend should return can_create from API response."""
|
||||
responses.add(
|
||||
responses.GET,
|
||||
DC_URL,
|
||||
json={"entitlements": {"can_create": True}},
|
||||
status=200,
|
||||
)
|
||||
|
||||
backend = DeployCenterEntitlementsBackend(
|
||||
base_url=DC_URL,
|
||||
service_id="meet",
|
||||
api_key="test-key",
|
||||
)
|
||||
result = backend.get_user_entitlements("sub-123", "user@example.com")
|
||||
assert result == {"can_create": True}
|
||||
|
||||
# Verify request was made with correct params and header
|
||||
assert len(responses.calls) == 1
|
||||
request = responses.calls[0].request
|
||||
assert "service_id=meet" in request.url
|
||||
assert "account_email=user%40example.com" in request.url
|
||||
assert request.headers["X-Service-Auth"] == "Bearer test-key"
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_deploycenter_backend_denies_access():
|
||||
"""DeployCenter backend should return can_create=False when API says so."""
|
||||
responses.add(
|
||||
responses.GET,
|
||||
DC_URL,
|
||||
json={"entitlements": {"can_create": False}},
|
||||
status=200,
|
||||
)
|
||||
|
||||
backend = DeployCenterEntitlementsBackend(
|
||||
base_url=DC_URL,
|
||||
service_id="meet",
|
||||
api_key="test-key",
|
||||
)
|
||||
result = backend.get_user_entitlements("sub-123", "user@example.com")
|
||||
assert result == {"can_create": False}
|
||||
|
||||
|
||||
@responses.activate
|
||||
@override_settings(ENTITLEMENTS_CACHE_TIMEOUT=300)
|
||||
def test_deploycenter_backend_uses_cache():
|
||||
"""DeployCenter should use cached results when not force_refresh."""
|
||||
responses.add(
|
||||
responses.GET,
|
||||
DC_URL,
|
||||
json={"entitlements": {"can_create": True}},
|
||||
status=200,
|
||||
)
|
||||
|
||||
backend = DeployCenterEntitlementsBackend(
|
||||
base_url=DC_URL,
|
||||
service_id="meet",
|
||||
api_key="test-key",
|
||||
)
|
||||
|
||||
# First call hits the API
|
||||
result1 = backend.get_user_entitlements("sub-123", "user@example.com")
|
||||
assert result1 == {"can_create": True}
|
||||
assert len(responses.calls) == 1
|
||||
|
||||
# Second call should use cache
|
||||
result2 = backend.get_user_entitlements("sub-123", "user@example.com")
|
||||
assert result2 == {"can_create": True}
|
||||
assert len(responses.calls) == 1 # No additional API call
|
||||
|
||||
|
||||
@responses.activate
|
||||
@override_settings(ENTITLEMENTS_CACHE_TIMEOUT=300)
|
||||
def test_deploycenter_backend_force_refresh_bypasses_cache():
|
||||
"""force_refresh=True should bypass cache and hit the API."""
|
||||
responses.add(
|
||||
responses.GET,
|
||||
DC_URL,
|
||||
json={"entitlements": {"can_create": True}},
|
||||
status=200,
|
||||
)
|
||||
responses.add(
|
||||
responses.GET,
|
||||
DC_URL,
|
||||
json={"entitlements": {"can_create": False}},
|
||||
status=200,
|
||||
)
|
||||
|
||||
backend = DeployCenterEntitlementsBackend(
|
||||
base_url=DC_URL,
|
||||
service_id="meet",
|
||||
api_key="test-key",
|
||||
)
|
||||
|
||||
result1 = backend.get_user_entitlements("sub-123", "user@example.com")
|
||||
assert result1["can_create"] is True
|
||||
|
||||
result2 = backend.get_user_entitlements(
|
||||
"sub-123", "user@example.com", force_refresh=True
|
||||
)
|
||||
assert result2["can_create"] is False
|
||||
assert len(responses.calls) == 2
|
||||
|
||||
|
||||
@responses.activate
|
||||
@override_settings(ENTITLEMENTS_CACHE_TIMEOUT=300)
|
||||
def test_deploycenter_backend_fallback_to_stale_cache():
|
||||
"""When API fails, should return stale cached value if available."""
|
||||
responses.add(
|
||||
responses.GET,
|
||||
DC_URL,
|
||||
json={"entitlements": {"can_create": True}},
|
||||
status=200,
|
||||
)
|
||||
|
||||
backend = DeployCenterEntitlementsBackend(
|
||||
base_url=DC_URL,
|
||||
service_id="meet",
|
||||
api_key="test-key",
|
||||
)
|
||||
|
||||
# Populate cache
|
||||
backend.get_user_entitlements("sub-123", "user@example.com")
|
||||
|
||||
# Now API fails
|
||||
responses.replace(
|
||||
responses.GET,
|
||||
DC_URL,
|
||||
body=requests.ConnectionError("Connection error"),
|
||||
)
|
||||
|
||||
# force_refresh to hit API, but should fall back to cache
|
||||
result = backend.get_user_entitlements(
|
||||
"sub-123", "user@example.com", force_refresh=True
|
||||
)
|
||||
assert result == {"can_create": True}
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_deploycenter_backend_raises_when_no_cache():
|
||||
"""When API fails and no cache exists, should raise."""
|
||||
responses.add(
|
||||
responses.GET,
|
||||
DC_URL,
|
||||
body=requests.ConnectionError("Connection error"),
|
||||
)
|
||||
|
||||
backend = DeployCenterEntitlementsBackend(
|
||||
base_url=DC_URL,
|
||||
service_id="meet",
|
||||
api_key="test-key",
|
||||
)
|
||||
|
||||
with pytest.raises(EntitlementsUnavailableError):
|
||||
backend.get_user_entitlements("sub-123", "user@example.com")
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_deploycenter_backend_sends_oidc_claims():
|
||||
"""DeployCenter should forward configured OIDC claims."""
|
||||
responses.add(
|
||||
responses.GET,
|
||||
DC_URL,
|
||||
json={"entitlements": {"can_create": True}},
|
||||
status=200,
|
||||
)
|
||||
|
||||
backend = DeployCenterEntitlementsBackend(
|
||||
base_url=DC_URL,
|
||||
service_id="meet",
|
||||
api_key="test-key",
|
||||
oidc_claims=["organization"],
|
||||
)
|
||||
|
||||
backend.get_user_entitlements(
|
||||
"sub-123",
|
||||
"user@example.com",
|
||||
user_info={"organization": "org-42", "other": "ignored"},
|
||||
)
|
||||
|
||||
request = responses.calls[0].request
|
||||
assert "organization=org-42" in request.url
|
||||
assert "other" not in request.url
|
||||
|
||||
|
||||
# -- Auth backend integration --
|
||||
|
||||
|
||||
def test_auth_backend_warms_cache_on_login():
|
||||
"""post_get_or_create_user should call get_user_entitlements with force_refresh."""
|
||||
user = factories.UserFactory()
|
||||
backend = OIDCAuthenticationBackend()
|
||||
|
||||
with mock.patch(
|
||||
"core.authentication.backends.get_user_entitlements",
|
||||
return_value={"can_create": True},
|
||||
) as mock_ent:
|
||||
backend.post_get_or_create_user(
|
||||
user, {"email": user.email, "sub": "x"}, is_new_user=False
|
||||
)
|
||||
mock_ent.assert_called_once_with(
|
||||
user_sub=user.sub,
|
||||
user_email=user.email,
|
||||
user_info={"email": user.email, "sub": "x"},
|
||||
force_refresh=True,
|
||||
)
|
||||
|
||||
|
||||
def test_auth_backend_login_succeeds_when_access_denied():
|
||||
"""Login should succeed even when can_create is False (gated in frontend)."""
|
||||
user = factories.UserFactory()
|
||||
backend = OIDCAuthenticationBackend()
|
||||
|
||||
with mock.patch(
|
||||
"core.authentication.backends.get_user_entitlements",
|
||||
return_value={"can_create": False},
|
||||
):
|
||||
# Should not raise — user logs in, frontend gates access
|
||||
backend.post_get_or_create_user(
|
||||
user, {"email": user.email}, is_new_user=False
|
||||
)
|
||||
|
||||
|
||||
def test_auth_backend_login_succeeds_when_entitlements_unavailable():
|
||||
"""Login should succeed when entitlements service is unavailable."""
|
||||
user = factories.UserFactory()
|
||||
backend = OIDCAuthenticationBackend()
|
||||
|
||||
with mock.patch(
|
||||
"core.authentication.backends.get_user_entitlements",
|
||||
side_effect=EntitlementsUnavailableError("unavailable"),
|
||||
):
|
||||
# Should not raise
|
||||
backend.post_get_or_create_user(
|
||||
user, {"email": user.email}, is_new_user=False
|
||||
)
|
||||
|
||||
|
||||
# -- UserMeSerializer (can_create field) --
|
||||
|
||||
|
||||
def test_user_me_serializer_includes_can_create_true():
|
||||
"""UserMeSerializer should include can_create=True when entitled."""
|
||||
user = factories.UserFactory()
|
||||
with mock.patch(
|
||||
"core.api.serializers.get_user_entitlements",
|
||||
return_value={"can_create": True},
|
||||
):
|
||||
data = UserMeSerializer(user).data
|
||||
assert data["can_create"] is True
|
||||
|
||||
|
||||
def test_user_me_serializer_includes_can_create_false():
|
||||
"""UserMeSerializer should include can_create=False when not entitled."""
|
||||
user = factories.UserFactory()
|
||||
with mock.patch(
|
||||
"core.api.serializers.get_user_entitlements",
|
||||
return_value={"can_create": False},
|
||||
):
|
||||
data = UserMeSerializer(user).data
|
||||
assert data["can_create"] is False
|
||||
|
||||
|
||||
def test_user_me_serializer_can_create_fail_closed():
|
||||
"""UserMeSerializer should return can_create=False when entitlements unavailable."""
|
||||
user = factories.UserFactory()
|
||||
with mock.patch(
|
||||
"core.api.serializers.get_user_entitlements",
|
||||
side_effect=EntitlementsUnavailableError("unavailable"),
|
||||
):
|
||||
data = UserMeSerializer(user).data
|
||||
assert data["can_create"] is False
|
||||
|
||||
|
||||
# -- /users/me/ endpoint integration --
|
||||
|
||||
|
||||
def test_api_users_me_includes_can_create():
|
||||
"""GET /users/me/ should include can_create in the response."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "can_create" in response.json()
|
||||
assert response.json()["can_create"] is True
|
||||
|
||||
|
||||
def test_api_users_me_can_create_false():
|
||||
"""GET /users/me/ should return can_create=False when not entitled."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with mock.patch(
|
||||
"core.api.serializers.get_user_entitlements",
|
||||
return_value={"can_create": False},
|
||||
):
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["can_create"] is False
|
||||
|
||||
|
||||
# -- Room creation entitlements enforcement --
|
||||
|
||||
|
||||
def test_room_creation_blocked_when_not_entitled():
|
||||
"""Room creation should return 403 when user has can_create=False."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with mock.patch(
|
||||
"core.api.permissions.get_user_entitlements",
|
||||
return_value={"can_create": False},
|
||||
):
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/",
|
||||
data={"name": "test-room"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_room_creation_blocked_when_entitlements_unavailable():
|
||||
"""Room creation should return 403 when entitlements service
|
||||
is unavailable (fail-closed)."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with mock.patch(
|
||||
"core.api.permissions.get_user_entitlements",
|
||||
side_effect=EntitlementsUnavailableError("unavailable"),
|
||||
):
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/",
|
||||
data={"name": "test-room"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_room_creation_allowed_when_entitled():
|
||||
"""Room creation should succeed when user has can_create=True."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with mock.patch(
|
||||
"core.api.permissions.get_user_entitlements",
|
||||
return_value={"can_create": True},
|
||||
):
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/",
|
||||
data={"name": "test-room"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == HTTP_201_CREATED
|
||||
|
||||
|
||||
# -- Non-create room actions are NOT gated by entitlements --
|
||||
|
||||
|
||||
def test_room_retrieve_allowed_when_not_entitled():
|
||||
"""Room retrieval should work even when user has can_create=False."""
|
||||
user = factories.UserFactory()
|
||||
room = factories.RoomFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with mock.patch(
|
||||
"core.api.permissions.get_user_entitlements",
|
||||
return_value={"can_create": False},
|
||||
):
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_room_list_allowed_when_not_entitled():
|
||||
"""Room listing should work even when user has can_create=False."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with mock.patch(
|
||||
"core.api.permissions.get_user_entitlements",
|
||||
return_value={"can_create": False},
|
||||
):
|
||||
response = client.get("/api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -596,6 +596,12 @@ class Base(Configuration):
|
||||
ALLOW_UNREGISTERED_ROOMS = values.BooleanValue(
|
||||
True, environ_name="ALLOW_UNREGISTERED_ROOMS", environ_prefix=None
|
||||
)
|
||||
# if provided, treat as suspicious (possible privilege escalation attempt).
|
||||
PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS = values.ListValue(
|
||||
["hidden", "recorder", "agent"],
|
||||
environ_name="PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Recording settings
|
||||
RECORDING_ENABLE = values.BooleanValue(
|
||||
@@ -700,6 +706,23 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Entitlements
|
||||
ENTITLEMENTS_BACKEND = values.Value(
|
||||
"core.entitlements.backends.local.LocalEntitlementsBackend",
|
||||
environ_name="ENTITLEMENTS_BACKEND",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ENTITLEMENTS_BACKEND_PARAMETERS = values.DictValue(
|
||||
{},
|
||||
environ_name="ENTITLEMENTS_BACKEND_PARAMETERS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
ENTITLEMENTS_CACHE_TIMEOUT = values.PositiveIntegerValue(
|
||||
300, # 5 minutes
|
||||
environ_name="ENTITLEMENTS_CACHE_TIMEOUT",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Calendar integrations
|
||||
ROOM_CREATION_CALLBACK_CACHE_TIMEOUT = values.PositiveIntegerValue(
|
||||
600, # 10 minutes
|
||||
|
||||
@@ -39,6 +39,7 @@ dependencies = [
|
||||
"django-redis==6.0.0",
|
||||
"django-storages[s3]==1.14.6",
|
||||
"django-timezone-field>=5.1",
|
||||
"django-pydantic-field==0.5.4",
|
||||
"django==5.2.11",
|
||||
"djangorestframework==3.16.1",
|
||||
"drf_spectacular==0.29.0",
|
||||
@@ -50,6 +51,7 @@ dependencies = [
|
||||
"markdown==3.10.2",
|
||||
"nested-multipart-parser==1.6.0",
|
||||
"psycopg[binary]==3.3.2",
|
||||
"pydantic==2.12.4",
|
||||
"PyJWT==2.11.0",
|
||||
"python-frontmatter==1.1.0",
|
||||
"requests==2.32.5",
|
||||
|
||||
Generated
+51
-55
@@ -979,10 +979,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
@@ -1143,10 +1144,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/config-array/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
@@ -4493,33 +4495,10 @@
|
||||
"tinyglobby": "^0.2.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@ts-morph/common/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@ts-morph/common/node_modules/brace-expansion": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
|
||||
"integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@ts-morph/common/node_modules/minimatch": {
|
||||
"version": "10.2.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.2.tgz",
|
||||
"integrity": "sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==",
|
||||
"version": "10.2.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
|
||||
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
@@ -5414,13 +5393,26 @@
|
||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
|
||||
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
|
||||
"integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion/node_modules/balanced-match": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
@@ -6608,9 +6600,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-jsx-a11y/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
@@ -6734,10 +6726,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
@@ -7391,9 +7384,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
@@ -8789,9 +8783,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/matcher-collection/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
@@ -8891,13 +8886,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
|
||||
"version": "9.0.8",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.8.tgz",
|
||||
"integrity": "sha512-reYkDYtj/b19TeqbNZCV4q9t+Yxylf/rYBsLb42SXJatTv4/ylq5lEiAmhA/IToxO7NI2UzNMghHoHuaqDkAjw==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^2.0.1"
|
||||
"brace-expansion": "^5.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16 || 14 >=14.17"
|
||||
@@ -11879,9 +11874,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/walk-sync/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
|
||||
@@ -7,4 +7,5 @@ export type ApiUser = {
|
||||
last_name: string
|
||||
language: BackendLanguage
|
||||
timezone: string
|
||||
can_create?: boolean
|
||||
}
|
||||
|
||||
@@ -148,7 +148,8 @@ const IntroText = styled('div', {
|
||||
|
||||
export const Home = () => {
|
||||
const { t } = useTranslation('home')
|
||||
const { isLoggedIn } = useUser()
|
||||
const { isLoggedIn, user } = useUser()
|
||||
const canCreate = user?.can_create === true
|
||||
|
||||
const {
|
||||
userChoices: { username },
|
||||
@@ -200,45 +201,56 @@ export const Home = () => {
|
||||
})}
|
||||
>
|
||||
{isLoggedIn ? (
|
||||
<Menu>
|
||||
<Button variant="primary" data-attr="create-meeting">
|
||||
{t('createMeeting')}
|
||||
</Button>
|
||||
<RACMenu>
|
||||
<MenuItem
|
||||
className={
|
||||
menuRecipe({ icon: true, variant: 'light' }).item
|
||||
}
|
||||
onAction={async () => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then((data) =>
|
||||
navigateTo('room', data.slug, {
|
||||
state: { create: true, initialRoomData: data },
|
||||
})
|
||||
)
|
||||
}}
|
||||
data-attr="create-option-instant"
|
||||
>
|
||||
<RiAddLine size={18} />
|
||||
{t('createMenu.instantOption')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className={
|
||||
menuRecipe({ icon: true, variant: 'light' }).item
|
||||
}
|
||||
onAction={() => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then((data) =>
|
||||
setLaterRoom(data)
|
||||
)
|
||||
}}
|
||||
data-attr="create-option-later"
|
||||
>
|
||||
<RiLink size={18} />
|
||||
{t('createMenu.laterOption')}
|
||||
</MenuItem>
|
||||
</RACMenu>
|
||||
</Menu>
|
||||
canCreate ? (
|
||||
<Menu>
|
||||
<Button variant="primary" data-attr="create-meeting">
|
||||
{t('createMeeting')}
|
||||
</Button>
|
||||
<RACMenu>
|
||||
<MenuItem
|
||||
className={
|
||||
menuRecipe({ icon: true, variant: 'light' }).item
|
||||
}
|
||||
onAction={async () => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then((data) =>
|
||||
navigateTo('room', data.slug, {
|
||||
state: { create: true, initialRoomData: data },
|
||||
})
|
||||
)
|
||||
}}
|
||||
data-attr="create-option-instant"
|
||||
>
|
||||
<RiAddLine size={18} />
|
||||
{t('createMenu.instantOption')}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
className={
|
||||
menuRecipe({ icon: true, variant: 'light' }).item
|
||||
}
|
||||
onAction={() => {
|
||||
const slug = generateRoomId()
|
||||
createRoom({ slug, username }).then((data) =>
|
||||
setLaterRoom(data)
|
||||
)
|
||||
}}
|
||||
data-attr="create-option-later"
|
||||
>
|
||||
<RiLink size={18} />
|
||||
{t('createMenu.laterOption')}
|
||||
</MenuItem>
|
||||
</RACMenu>
|
||||
</Menu>
|
||||
) : (
|
||||
<p
|
||||
className={css({
|
||||
color: 'greyscale.700',
|
||||
fontSize: '0.95rem',
|
||||
})}
|
||||
>
|
||||
{t('noAccess')}
|
||||
</p>
|
||||
)
|
||||
) : (
|
||||
<LoginButton proConnectHint={false} />
|
||||
)}
|
||||
|
||||
@@ -65,7 +65,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||
alignItems="left"
|
||||
justify="start"
|
||||
gap={0}
|
||||
style={{ maxWidth: '100%', overflow: 'hidden' }}
|
||||
style={{ maxWidth: '100%', overflow: 'visible' }}
|
||||
>
|
||||
<Heading slot="title" level={2} className={text({ variant: 'h2' })}>
|
||||
{t('heading')}
|
||||
@@ -93,7 +93,7 @@ export const InviteDialog = (props: Omit<DialogProps, 'title'>) => {
|
||||
flexDirection: 'column',
|
||||
marginTop: '0.5rem',
|
||||
gap: '1rem',
|
||||
overflow: 'hidden',
|
||||
overflow: 'visible',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -753,7 +753,7 @@ export const Join = ({
|
||||
try {
|
||||
saveVideoInputDeviceId(id)
|
||||
if (videoTrack) {
|
||||
await await videoTrack.setDeviceId({ exact: id })
|
||||
await videoTrack.setDeviceId({ exact: id })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to switch camera device', err)
|
||||
|
||||
@@ -4,7 +4,7 @@ import { cva } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { styled, VStack } from '@/styled-system/jsx'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import { PostHog } from 'posthog-js'
|
||||
import type { PostHog } from 'posthog-js'
|
||||
import { Button as RACButton } from 'react-aria-components'
|
||||
import { useIsAnalyticsEnabled } from '@/features/analytics/hooks/useIsAnalyticsEnabled'
|
||||
|
||||
|
||||
@@ -74,13 +74,17 @@ export const useWaitingParticipants = () => {
|
||||
): Promise<void> => {
|
||||
try {
|
||||
setListEnabled(false)
|
||||
for (const participant of waitingParticipants) {
|
||||
await enterRoom({
|
||||
roomId: roomId,
|
||||
allowEntry,
|
||||
participantId: participant.id,
|
||||
})
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
waitingParticipants.map((participant) =>
|
||||
enterRoom({
|
||||
roomId: roomId,
|
||||
allowEntry,
|
||||
participantId: participant.id,
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
await refetchWaiting()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useSettingsDialog } from '@/features/settings/hook/useSettingsDialog'
|
||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||
|
||||
const IDLE_DISCONNECT_TIMEOUT_MS = 120000 // 2 minutes
|
||||
const COUNTDOWN_ANNOUNCEMENT_SECONDS = [90, 60, 30]
|
||||
const COUNTDOWN_ANNOUNCEMENT_SECONDS = new Set([90, 60, 30])
|
||||
const FINAL_COUNTDOWN_SECONDS = 10
|
||||
|
||||
export const IsIdleDisconnectModal = () => {
|
||||
@@ -58,7 +58,7 @@ export const IsIdleDisconnectModal = () => {
|
||||
if (!connectionObserverSnap.isIdleDisconnectModalOpen) return
|
||||
|
||||
const shouldAnnounce =
|
||||
COUNTDOWN_ANNOUNCEMENT_SECONDS.includes(remainingSeconds) ||
|
||||
COUNTDOWN_ANNOUNCEMENT_SECONDS.has(remainingSeconds) ||
|
||||
remainingSeconds <= FINAL_COUNTDOWN_SECONDS
|
||||
|
||||
if (shouldAnnounce && remainingSeconds !== lastAnnouncementRef.current) {
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
import React, { ReactNode } from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
|
||||
const Hint = styled('div', {
|
||||
base: {
|
||||
position: 'absolute',
|
||||
top: '0.75rem',
|
||||
right: '0.75rem',
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
color: 'white',
|
||||
borderRadius: 'calc(var(--lk-border-radius) / 2)',
|
||||
paddingInline: '0.5rem',
|
||||
paddingBlock: '0.1rem',
|
||||
fontSize: '0.875rem',
|
||||
opacity: 0,
|
||||
visibility: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
transition: 'opacity 150ms ease',
|
||||
'.lk-grid-layout > *:first-child:focus-within &': {
|
||||
opacity: 1,
|
||||
visibility: 'visible',
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export interface KeyboardShortcutHintProps {
|
||||
children: ReactNode
|
||||
@@ -12,21 +35,5 @@ export interface KeyboardShortcutHintProps {
|
||||
export const KeyboardShortcutHint: React.FC<KeyboardShortcutHintProps> = ({
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '0.75rem',
|
||||
right: '0.75rem',
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
color: 'white',
|
||||
borderRadius: 'calc(var(--lk-border-radius) / 2)',
|
||||
paddingInline: '0.5rem',
|
||||
paddingBlock: '0.1rem',
|
||||
fontSize: '0.875rem',
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
return <Hint>{children}</Hint>
|
||||
}
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import type { CSSProperties } from 'react'
|
||||
import { Text } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useParticipantInfo } from '@livekit/components-react'
|
||||
import { Participant } from 'livekit-client'
|
||||
|
||||
const participantNameStyles: CSSProperties = {
|
||||
paddingBottom: '0.1rem',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}
|
||||
|
||||
const participantNameScreenShareStyles: CSSProperties = {
|
||||
...participantNameStyles,
|
||||
marginLeft: '0.4rem',
|
||||
}
|
||||
|
||||
export const ParticipantName = ({
|
||||
participant,
|
||||
isScreenShare = false,
|
||||
@@ -17,26 +30,14 @@ export const ParticipantName = ({
|
||||
|
||||
if (isScreenShare) {
|
||||
return (
|
||||
<Text
|
||||
variant="sm"
|
||||
style={{
|
||||
paddingBottom: '0.1rem',
|
||||
marginLeft: '0.4rem',
|
||||
}}
|
||||
>
|
||||
<Text variant="sm" style={participantNameScreenShareStyles}>
|
||||
{t('screenShare', { name: displayedName })}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Text
|
||||
variant="sm"
|
||||
style={{
|
||||
paddingBottom: '0.1rem',
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Text variant="sm" style={participantNameStyles} aria-hidden="true">
|
||||
{displayedName}
|
||||
</Text>
|
||||
)
|
||||
|
||||
@@ -183,7 +183,7 @@ export const ParticipantTile: (
|
||||
}}
|
||||
>
|
||||
{isHandRaised && !isScreenShare && (
|
||||
<>
|
||||
<span>
|
||||
<span>{positionInQueue}</span>
|
||||
<RiHand
|
||||
color="black"
|
||||
@@ -197,7 +197,7 @@ export const ParticipantTile: (
|
||||
animationIterationCount: '2',
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
</span>
|
||||
)}
|
||||
{isScreenShare && (
|
||||
<ScreenShareIcon
|
||||
@@ -210,10 +210,12 @@ export const ParticipantTile: (
|
||||
{isEncrypted && !isScreenShare && (
|
||||
<LockLockedIcon style={{ marginRight: '0.25rem' }} />
|
||||
)}
|
||||
<ParticipantName
|
||||
isScreenShare={isScreenShare}
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
<div className="lk-participant-name-wrapper">
|
||||
<ParticipantName
|
||||
isScreenShare={isScreenShare}
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</HStack>
|
||||
<ConnectionQualityIndicator className="lk-participant-metadata-item" />
|
||||
@@ -229,9 +231,7 @@ export const ParticipantTile: (
|
||||
)}
|
||||
</ParticipantContextIfNeeded>
|
||||
</TrackRefContextIfNeeded>
|
||||
{hasKeyboardFocus && (
|
||||
<KeyboardShortcutHint>{t('toolbarHint')}</KeyboardShortcutHint>
|
||||
)}
|
||||
<KeyboardShortcutHint>{t('toolbarHint')}</KeyboardShortcutHint>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import { useSidePanel } from '../../hooks/useSidePanel'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { ToggleButtonProps } from '@/primitives/ToggleButton'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
|
||||
export const ToolsToggle = ({
|
||||
variant = 'primaryTextDark',
|
||||
@@ -15,6 +16,11 @@ export const ToolsToggle = ({
|
||||
const { isToolsOpen, toggleTools } = useSidePanel()
|
||||
const tooltipLabel = isToolsOpen ? 'open' : 'closed'
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
id: 'recording',
|
||||
handler: toggleTools,
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
|
||||
@@ -12,6 +12,7 @@ import { StartMediaButton } from '../../components/controls/StartMediaButton'
|
||||
import { MoreOptions } from './MoreOptions'
|
||||
import { useRef } from 'react'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
import { useFullScreen } from '../../hooks/useFullScreen'
|
||||
import { VideoDeviceControl } from '../../components/controls/Device/VideoDeviceControl'
|
||||
import { AudioDevicesControl } from '../../components/controls/Device/AudioDevicesControl'
|
||||
|
||||
@@ -21,6 +22,8 @@ export function DesktopControlBar({
|
||||
const browserSupportsScreenSharing = supportsScreenSharing()
|
||||
const desktopControlBarEl = useRef<HTMLDivElement>(null)
|
||||
|
||||
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({})
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
id: 'focus-toolbar',
|
||||
handler: () => {
|
||||
@@ -32,6 +35,13 @@ export function DesktopControlBar({
|
||||
firstButton?.focus()
|
||||
},
|
||||
})
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
id: 'fullscreen',
|
||||
handler: toggleFullScreen,
|
||||
isDisabled: !isFullscreenAvailable,
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={desktopControlBarEl}
|
||||
|
||||
@@ -31,6 +31,9 @@ import { RecordingProvider } from '@/features/recording'
|
||||
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
||||
import { useConnectionObserver } from '../hooks/useConnectionObserver'
|
||||
import { useNoiseReduction } from '../hooks/useNoiseReduction'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
import { useSettingsDialog } from '@/features/settings'
|
||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||
import { useVideoResolutionSubscription } from '../hooks/useVideoResolutionSubscription'
|
||||
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
|
||||
import { useSubtitles } from '@/features/subtitle/hooks/useSubtitles'
|
||||
@@ -97,6 +100,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
const { t: tRooms } = useTranslation('rooms')
|
||||
const room = useRoomContext()
|
||||
const announce = useScreenReaderAnnounce()
|
||||
const { toggleSettingsDialog } = useSettingsDialog()
|
||||
|
||||
const getAnnouncementName = useCallback(
|
||||
(participant?: Participant | null) => {
|
||||
@@ -111,6 +115,13 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
useConnectionObserver()
|
||||
useVideoResolutionSubscription()
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
id: 'open-shortcuts',
|
||||
handler: useCallback(() => {
|
||||
toggleSettingsDialog(SettingsDialogExtendedKey.SHORTCUTS)
|
||||
}, [toggleSettingsDialog]),
|
||||
})
|
||||
|
||||
const tracks = useTracks(
|
||||
[
|
||||
{ source: Track.Source.Camera, withPlaceholder: true },
|
||||
|
||||
@@ -4,8 +4,10 @@ export const roomIdPattern = '[a-z]{3}-[a-z]{4}-[a-z]{3}'
|
||||
export const flexibleRoomIdPattern =
|
||||
'(?:[a-zA-Z0-9]{3}-?[a-zA-Z0-9]{4}-?[a-zA-Z0-9]{3})'
|
||||
|
||||
const roomRegex = new RegExp(`^${roomIdPattern}$`)
|
||||
|
||||
export const isRoomValid = (roomIdOrUrl: string) =>
|
||||
new RegExp(`^${roomIdPattern}$`).test(roomIdOrUrl) ||
|
||||
roomRegex.test(roomIdOrUrl) ||
|
||||
new RegExp(`^${window.location.origin}/${roomIdPattern}$`).test(roomIdOrUrl)
|
||||
|
||||
export const normalizeRoomId = (roomId: string) => {
|
||||
|
||||
@@ -10,7 +10,10 @@ const callbackIdHandler = new CallbackIdHandler()
|
||||
const popupWindow = new PopupWindow()
|
||||
|
||||
export const CreatePopup = () => {
|
||||
const { isLoggedIn } = useUser({ fetchUserOptions: { attemptSilent: false } })
|
||||
const { isLoggedIn, user } = useUser({
|
||||
fetchUserOptions: { attemptSilent: false },
|
||||
})
|
||||
const canCreate = user?.can_create === true
|
||||
const { mutateAsync: createRoom } = useCreateRoom()
|
||||
|
||||
const callbackId = useMemo(() => callbackIdHandler.getOrCreate(), [])
|
||||
@@ -55,10 +58,10 @@ export const CreatePopup = () => {
|
||||
console.error('Failed to create meeting room:', error)
|
||||
}
|
||||
}
|
||||
if (isLoggedIn && callbackId) {
|
||||
if (isLoggedIn && canCreate && callbackId) {
|
||||
createMeetingRoom()
|
||||
}
|
||||
}, [isLoggedIn, callbackId, createRoom])
|
||||
}, [isLoggedIn, canCreate, callbackId, createRoom])
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -3,15 +3,20 @@ import { ShortcutRow } from '@/features/shortcuts/components/ShortcutRow'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { TabPanel, type TabPanelProps } from '@/primitives/Tabs'
|
||||
import { H } from '@/primitives'
|
||||
|
||||
const tableStyle = css({
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
overflowY: 'auto',
|
||||
'& caption': {
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '0.75rem',
|
||||
textAlign: 'left',
|
||||
},
|
||||
'& th, & td': {
|
||||
padding: '0.65rem 0',
|
||||
textAlign: 'left',
|
||||
fontWeight: 'normal',
|
||||
},
|
||||
'& tbody tr': {
|
||||
borderBottom: '1px solid rgba(255,255,255,0.08)',
|
||||
@@ -29,12 +34,11 @@ export const ShortcutTab = ({ id }: Pick<TabPanelProps, 'id'>) => {
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
})}
|
||||
>
|
||||
<H lvl={2}>{t('shortcuts.listLabel')}</H>
|
||||
<table className={tableStyle}>
|
||||
<thead className="sr-only">
|
||||
<caption>{t('shortcuts.listLabel')}</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{t('shortcuts.columnAction')}</th>
|
||||
<th scope="col">{t('shortcuts.columnShortcut')}</th>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { TabPanel, TabPanelProps } from '@/primitives/Tabs'
|
||||
import { useMediaDeviceSelect, useRoomContext } from '@livekit/components-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { css } from '@/styled-system/css'
|
||||
import {
|
||||
createLocalVideoTrack,
|
||||
@@ -22,6 +22,8 @@ export type VideoTabProps = Pick<DialogProps, 'onOpenChange'> &
|
||||
|
||||
type DeviceItems = Array<{ value: string; label: string }>
|
||||
|
||||
const EMPTY_PROPS = {}
|
||||
|
||||
export const VideoTab = ({ id }: VideoTabProps) => {
|
||||
const { t } = useTranslation('settings', { keyPrefix: 'video' })
|
||||
const { localParticipant, remoteParticipants } = useRoomContext()
|
||||
@@ -59,7 +61,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
||||
const isCamEnabled = devicesIn?.length > 0
|
||||
|
||||
const disabledProps = isCamEnabled
|
||||
? {}
|
||||
? EMPTY_PROPS
|
||||
: {
|
||||
placeholder: t('permissionsRequired'),
|
||||
isDisabled: true,
|
||||
@@ -117,6 +119,40 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
||||
}
|
||||
}, [videoDeviceId, videoElement])
|
||||
|
||||
const resolutionItems = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
value: 'h720',
|
||||
label: `${t('resolution.publish.items.high')} (720p)`,
|
||||
},
|
||||
{
|
||||
value: 'h360',
|
||||
label: `${t('resolution.publish.items.medium')} (360p)`,
|
||||
},
|
||||
{
|
||||
value: 'h180',
|
||||
label: `${t('resolution.publish.items.low')} (180p)`,
|
||||
},
|
||||
]
|
||||
}, [t])
|
||||
|
||||
const videoQualityItems = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
value: VideoQuality.HIGH.toString(),
|
||||
label: t('resolution.subscribe.items.high'),
|
||||
},
|
||||
{
|
||||
value: VideoQuality.MEDIUM.toString(),
|
||||
label: t('resolution.subscribe.items.medium'),
|
||||
},
|
||||
{
|
||||
value: VideoQuality.LOW.toString(),
|
||||
label: t('resolution.subscribe.items.low'),
|
||||
},
|
||||
]
|
||||
}, [t])
|
||||
|
||||
return (
|
||||
<TabPanel padding={'md'} flex id={id}>
|
||||
<RowWrapper heading={t('camera.heading')}>
|
||||
@@ -178,20 +214,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
||||
<Field
|
||||
type="select"
|
||||
label={t('resolution.publish.label')}
|
||||
items={[
|
||||
{
|
||||
value: 'h720',
|
||||
label: `${t('resolution.publish.items.high')} (720p)`,
|
||||
},
|
||||
{
|
||||
value: 'h360',
|
||||
label: `${t('resolution.publish.items.medium')} (360p)`,
|
||||
},
|
||||
{
|
||||
value: 'h180',
|
||||
label: `${t('resolution.publish.items.low')} (180p)`,
|
||||
},
|
||||
]}
|
||||
items={resolutionItems}
|
||||
selectedKey={videoPublishResolution}
|
||||
onSelectionChange={async (key) => {
|
||||
await handleVideoResolutionChange(key as VideoResolution)
|
||||
@@ -206,20 +229,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
||||
<Field
|
||||
type="select"
|
||||
label={t('resolution.subscribe.label')}
|
||||
items={[
|
||||
{
|
||||
value: VideoQuality.HIGH.toString(),
|
||||
label: t('resolution.subscribe.items.high'),
|
||||
},
|
||||
{
|
||||
value: VideoQuality.MEDIUM.toString(),
|
||||
label: t('resolution.subscribe.items.medium'),
|
||||
},
|
||||
{
|
||||
value: VideoQuality.LOW.toString(),
|
||||
label: t('resolution.subscribe.items.low'),
|
||||
},
|
||||
]}
|
||||
items={videoQualityItems}
|
||||
selectedKey={videoSubscribeQuality?.toString()}
|
||||
onSelectionChange={(key) => {
|
||||
if (key == undefined) return
|
||||
|
||||
@@ -14,7 +14,25 @@ export const useSettingsDialog = () => {
|
||||
settingsStore.areSettingsOpen = true
|
||||
}
|
||||
|
||||
const closeSettingsDialog = () => {
|
||||
settingsStore.areSettingsOpen = false
|
||||
}
|
||||
|
||||
const toggleSettingsDialog = (
|
||||
defaultSelectedTab?: SettingsDialogExtendedKey
|
||||
) => {
|
||||
if (areSettingsOpen) {
|
||||
closeSettingsDialog()
|
||||
} else {
|
||||
if (defaultSelectedTab)
|
||||
settingsStore.defaultSelectedTab = defaultSelectedTab
|
||||
settingsStore.areSettingsOpen = true
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
openSettingsDialog,
|
||||
closeSettingsDialog,
|
||||
toggleSettingsDialog,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Shortcut } from './types'
|
||||
export type ShortcutCategory = 'navigation' | 'media' | 'interaction'
|
||||
|
||||
export type ShortcutId =
|
||||
| 'open-shortcuts'
|
||||
| 'focus-toolbar'
|
||||
| 'toggle-microphone'
|
||||
| 'toggle-camera'
|
||||
@@ -29,6 +30,11 @@ export type ShortcutDescriptor = {
|
||||
}
|
||||
|
||||
export const shortcutCatalog: ShortcutDescriptor[] = [
|
||||
{
|
||||
id: 'open-shortcuts',
|
||||
category: 'navigation',
|
||||
shortcut: { key: '/', ctrlKey: true, shiftKey: true },
|
||||
},
|
||||
{
|
||||
id: 'focus-toolbar',
|
||||
category: 'navigation',
|
||||
|
||||
@@ -25,9 +25,9 @@ export const ShortcutBadge: React.FC<ShortcutBadgeProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className={cx(badgeStyle, className)} aria-hidden="true">
|
||||
<span>{visualLabel}</span>
|
||||
</div>
|
||||
<kbd className={cx(badgeStyle, className)} aria-hidden="true">
|
||||
{visualLabel}
|
||||
</kbd>
|
||||
{srLabel && <span className="sr-only">{srLabel}</span>}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -31,9 +31,9 @@ export const ShortcutRow: React.FC<ShortcutRowProps> = ({ descriptor }) => {
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td className={text({ variant: 'body' })}>
|
||||
<th scope="row" className={text({ variant: 'body' })}>
|
||||
{t(`actions.${descriptor.id}`)}
|
||||
</td>
|
||||
</th>
|
||||
<td className={shortcutCellStyle}>
|
||||
<ShortcutBadge visualLabel={visualShortcut} srLabel={srShortcut} />
|
||||
</td>
|
||||
|
||||
@@ -19,7 +19,10 @@ export const useKeyboardShortcuts = () => {
|
||||
shiftKey,
|
||||
altKey,
|
||||
})
|
||||
const shortcut = shortcutsSnap.shortcuts.get(shortcutKey)
|
||||
let shortcut = shortcutsSnap.shortcuts.get(shortcutKey)
|
||||
if (!shortcut && shortcutKey === 'ctrl+shift+?') {
|
||||
shortcut = shortcutsSnap.shortcuts.get('ctrl+shift+/')
|
||||
}
|
||||
if (!shortcut) return
|
||||
e.preventDefault()
|
||||
await shortcut()
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"heading": "Überprüfen Sie Ihren Meeting-Code",
|
||||
"body": "Stellen Sie sicher, dass Sie den richtigen Meeting-Code in der URL eingegeben haben. Beispiel:"
|
||||
},
|
||||
"selected": "ausgewählt",
|
||||
"submit": "OK",
|
||||
"footer": {
|
||||
"links": {
|
||||
@@ -45,7 +46,7 @@
|
||||
"license": "Etalab 2.0 Lizenz"
|
||||
},
|
||||
"loginHint": {
|
||||
"title": "Melden Sie sich mit Ihrem ProConnect-Konto an",
|
||||
"title": "Melden Sie sich mit Ihrem Konto an",
|
||||
"body": "Statt zu warten, melden Sie sich mit Ihrem ProConnect-Konto an.",
|
||||
"button": {
|
||||
"ariaLabel": "Hinweis schließen",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"joinMeetingTipContent": "Sie können einem Meeting beitreten, indem Sie den vollständigen Link in die Adressleiste Ihres Browsers einfügen.",
|
||||
"joinMeetingTipHeading": "Wussten Sie schon?",
|
||||
"loginToCreateMeeting": "Melden Sie sich an, um ein Meeting zu erstellen",
|
||||
"noAccess": "Sie haben keinen Zugang zur Erstellung von Meetings. Bitte kontaktieren Sie Ihren Administrator.",
|
||||
"moreLinkLabel": "Mehr erfahren – neues Tab",
|
||||
"moreLink": "Mehr erfahren",
|
||||
"moreAbout": "über {{appTitle}}",
|
||||
|
||||
@@ -590,7 +590,7 @@
|
||||
},
|
||||
"participantTileFocus": {
|
||||
"containerLabel": "Optionen für {{name}}",
|
||||
"toolbarHint": "F2: zur Symbolleiste unten.",
|
||||
"toolbarHint": "Ctrl+Shift+/: Direkt auf die Tastenkürzel zugreifen.",
|
||||
"pin": {
|
||||
"enable": "Anheften",
|
||||
"disable": "Lösen"
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"heading": "Verify your meeting code",
|
||||
"body": "Check that you have entered the correct meeting code in the URL. Example:"
|
||||
},
|
||||
"selected": "selected",
|
||||
"submit": "OK",
|
||||
"footer": {
|
||||
"links": {
|
||||
@@ -45,7 +46,7 @@
|
||||
"license": "etalab 2.0 license"
|
||||
},
|
||||
"loginHint": {
|
||||
"title": "Log in with your ProConnect account",
|
||||
"title": "Log in with your account",
|
||||
"body": "Instead of waiting, log in with your ProConnect account.",
|
||||
"button": {
|
||||
"ariaLabel": "Close the suggestion",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"joinMeetingTipContent": "You can join a meeting by pasting its full link in the browser's address bar.",
|
||||
"joinMeetingTipHeading": "Did you know?",
|
||||
"loginToCreateMeeting": "Login to create a meeting",
|
||||
"noAccess": "You do not have access to create meetings. Please contact your administrator.",
|
||||
"moreLinkLabel": "Learn more - new tab",
|
||||
"moreLink": "Learn more",
|
||||
"moreAbout": "about {{appTitle}}",
|
||||
|
||||
@@ -590,7 +590,7 @@
|
||||
},
|
||||
"participantTileFocus": {
|
||||
"containerLabel": "Options for {{name}}",
|
||||
"toolbarHint": "F2: go to the bottom toolbar.",
|
||||
"toolbarHint": "Ctrl+Shift+/: access shortcuts directly.",
|
||||
"pin": {
|
||||
"enable": "Pin",
|
||||
"disable": "Unpin"
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"heading": "Vérifier votre code de réunion",
|
||||
"body": "Vérifiez que vous avez saisi le code de réunion correct dans l'URL. Exemple :"
|
||||
},
|
||||
"selected": "sélectionné",
|
||||
"submit": "OK",
|
||||
"footer": {
|
||||
"links": {
|
||||
@@ -45,7 +46,7 @@
|
||||
"license": "licence etalab 2.0"
|
||||
},
|
||||
"loginHint": {
|
||||
"title": "Connectez-vous avec votre compte ProConnect",
|
||||
"title": "Connectez-vous avec votre compte",
|
||||
"body": "Au lieu de patienter, connectez-vous avec votre compte ProConnect.",
|
||||
"button": {
|
||||
"ariaLabel": "Fermer la suggestion",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"joinMeetingTipContent": "Vous pouvez rejoindre une réunion en copiant directement son lien complet dans la barre d'adresse du navigateur.",
|
||||
"joinMeetingTipHeading": "Astuce",
|
||||
"loginToCreateMeeting": "Connectez-vous pour créer une réunion",
|
||||
"noAccess": "Vous n'avez pas accès à la création de réunions. Veuillez contacter votre administrateur.",
|
||||
"moreLinkLabel": "En savoir plus - nouvelle fenêtre",
|
||||
"moreLink": "En savoir plus",
|
||||
"moreAbout": "sur {{appTitle}}",
|
||||
|
||||
@@ -590,7 +590,7 @@
|
||||
},
|
||||
"participantTileFocus": {
|
||||
"containerLabel": "Options pour {{name}}",
|
||||
"toolbarHint": "F2 : raccourci barre d'outils en bas.",
|
||||
"toolbarHint": "Ctrl+Shift+/ : accéder directement aux raccourcis.",
|
||||
"pin": {
|
||||
"enable": "Épingler",
|
||||
"disable": "Annuler l'épinglage"
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"notFound": {
|
||||
"heading": "Pagina niet gevonden"
|
||||
},
|
||||
"selected": "geselecteerd",
|
||||
"submit": "OK",
|
||||
"footer": {
|
||||
"links": {
|
||||
@@ -44,7 +45,7 @@
|
||||
"license": "etalab 2.0 licentie"
|
||||
},
|
||||
"loginHint": {
|
||||
"title": "Log in met je ProConnect-account",
|
||||
"title": "Log in met je account",
|
||||
"body": "In plaats van te wachten, log in met je ProConnect-account.",
|
||||
"button": {
|
||||
"ariaLabel": "Sluit de suggestie",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"joinMeetingTipContent": "U kunt deelnemen aan een vergadering door de volledige link in de adresbalk van de browser te plakken.",
|
||||
"joinMeetingTipHeading": "Wist u dat?",
|
||||
"loginToCreateMeeting": "Log in om een vergadering te maken",
|
||||
"noAccess": "U heeft geen toegang om vergaderingen te maken. Neem contact op met uw beheerder.",
|
||||
"moreLinkLabel": "Meer informatie - nieuw tabblad",
|
||||
"moreLink": "Meer informatie",
|
||||
"moreAbout": "over {{appTitle}}",
|
||||
|
||||
@@ -590,7 +590,7 @@
|
||||
},
|
||||
"participantTileFocus": {
|
||||
"containerLabel": "Opties voor {{name}}",
|
||||
"toolbarHint": "F2: naar de werkbalk onderaan.",
|
||||
"toolbarHint": "Ctrl+Shift+/: direct toegang tot de sneltoetsen.",
|
||||
"pin": {
|
||||
"enable": "Pinnen",
|
||||
"disable": "Losmaken"
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ReactNode } from 'react'
|
||||
import { Menu, MenuProps, MenuItem } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { VisuallyHidden } from '@/styled-system/jsx'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
||||
import type { RecipeVariantProps } from '@/styled-system/types'
|
||||
|
||||
@@ -19,6 +21,7 @@ export const MenuList = <T extends string | number = string>({
|
||||
} & MenuProps<unknown> &
|
||||
RecipeVariantProps<typeof menuRecipe>) => {
|
||||
const [variantProps] = menuRecipe.splitVariantProps(menuProps)
|
||||
const { t } = useTranslation('global')
|
||||
const classes = menuRecipe({
|
||||
extraPadding: true,
|
||||
variant: variant,
|
||||
@@ -39,11 +42,19 @@ export const MenuList = <T extends string | number = string>({
|
||||
className={classes.item}
|
||||
key={value}
|
||||
id={value as string}
|
||||
textValue={typeof label === 'string' ? label : undefined}
|
||||
onAction={() => {
|
||||
onAction(value as T)
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{({ isSelected }) => (
|
||||
<>
|
||||
{label}
|
||||
{isSelected && (
|
||||
<VisuallyHidden>, {t('selected')}</VisuallyHidden>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</MenuItem>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type ReactNode } from 'react'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { styled, VisuallyHidden } from '@/styled-system/jsx'
|
||||
import { RemixiconComponentType, RiArrowDropDownLine } from '@remixicon/react'
|
||||
import {
|
||||
Button,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
SelectProps as RACSelectProps,
|
||||
SelectValue,
|
||||
} from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Box } from './Box'
|
||||
import { StyledPopover } from './Popover'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe.ts'
|
||||
@@ -110,6 +111,7 @@ export const Select = <T extends string | number>({
|
||||
...props
|
||||
}: SelectProps<T>) => {
|
||||
const IconComponent = iconComponent
|
||||
const { t } = useTranslation('global')
|
||||
return (
|
||||
<RACSelect {...props}>
|
||||
{label}
|
||||
@@ -138,8 +140,18 @@ export const Select = <T extends string | number>({
|
||||
}
|
||||
id={item.value}
|
||||
key={item.value}
|
||||
textValue={
|
||||
typeof item.label === 'string' ? item.label : undefined
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
{({ isSelected }) => (
|
||||
<>
|
||||
{item.label}
|
||||
{isSelected && (
|
||||
<VisuallyHidden>, {t('selected')}</VisuallyHidden>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ListBoxItem>
|
||||
))}
|
||||
</ListBox>
|
||||
|
||||
@@ -11,6 +11,8 @@ import { CreatePopup } from '@/features/sdk/routes/CreatePopup'
|
||||
import { CreateMeetingButton } from '@/features/sdk/routes/CreateMeetingButton'
|
||||
import { RecordingDownloadRoute } from '@/features/recording'
|
||||
|
||||
const roomIdRegex = new RegExp(`^[/](?<roomId>${flexibleRoomIdPattern})$`)
|
||||
|
||||
export const routes: Record<
|
||||
| 'home'
|
||||
| 'room'
|
||||
@@ -37,7 +39,7 @@ export const routes: Record<
|
||||
room: {
|
||||
name: 'room',
|
||||
to: (roomId: string) => `/${roomId.trim()}`,
|
||||
path: new RegExp(`^[/](?<roomId>${flexibleRoomIdPattern})$`),
|
||||
path: roomIdRegex,
|
||||
Component: RoomRoute,
|
||||
},
|
||||
feedback: {
|
||||
|
||||
@@ -151,3 +151,24 @@
|
||||
[data-lk-theme] .lk-participant-tile {
|
||||
box-shadow: var(--lk-box-shadow);
|
||||
}
|
||||
|
||||
/* Participant name ellipsis: truncate when overflowing */
|
||||
.lk-participant-metadata {
|
||||
gap: 1rem;
|
||||
}
|
||||
.lk-participant-metadata > *:first-child {
|
||||
min-width: 0;
|
||||
}
|
||||
.lk-participant-metadata > *:first-child {
|
||||
flex: 1;
|
||||
}
|
||||
.lk-participant-metadata > *:first-child .lk-participant-metadata-item,
|
||||
.lk-participant-metadata
|
||||
.lk-participant-metadata-item
|
||||
.lk-participant-name-wrapper {
|
||||
min-width: 0;
|
||||
}
|
||||
.lk-participant-metadata > *:first-child .lk-participant-metadata-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@@ -128,6 +128,10 @@ ingressAdmin:
|
||||
enabled: true
|
||||
host: meet.127.0.0.1.nip.io
|
||||
|
||||
ingressWebhook:
|
||||
enabled: true
|
||||
host: meet.127.0.0.1.nip.io
|
||||
|
||||
posthog:
|
||||
ingress:
|
||||
enabled: false
|
||||
|
||||
@@ -141,6 +141,10 @@ ingressAdmin:
|
||||
enabled: true
|
||||
host: meet.127.0.0.1.nip.io
|
||||
|
||||
ingressWebhook:
|
||||
enabled: true
|
||||
host: meet.127.0.0.1.nip.io
|
||||
|
||||
posthog:
|
||||
ingress:
|
||||
enabled: false
|
||||
|
||||
@@ -156,6 +156,10 @@ ingressAdmin:
|
||||
enabled: true
|
||||
host: meet.127.0.0.1.nip.io
|
||||
|
||||
ingressWebhook:
|
||||
enabled: true
|
||||
host: meet.127.0.0.1.nip.io
|
||||
|
||||
posthog:
|
||||
ingress:
|
||||
enabled: false
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
apiVersion: v2
|
||||
type: application
|
||||
name: meet
|
||||
version: 0.0.15
|
||||
version: 0.0.16
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
{{- if .Values.ingressWebhook.enabled -}}
|
||||
{{- $fullName := include "meet.fullname" . -}}
|
||||
{{- if and .Values.ingressWebhook.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
|
||||
{{- if not (hasKey .Values.ingressWebhook.annotations "kubernetes.io/ingress.class") }}
|
||||
{{- $_ := set .Values.ingressWebhook.annotations "kubernetes.io/ingress.class" .Values.ingressWebhook.className}}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
{{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}}
|
||||
apiVersion: networking.k8s.io/v1beta1
|
||||
{{- else -}}
|
||||
apiVersion: extensions/v1beta1
|
||||
{{- end }}
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: {{ $fullName }}-webhook
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
labels:
|
||||
{{- include "meet.labels" . | nindent 4 }}
|
||||
{{- with .Values.ingressWebhook.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
{{- if and .Values.ingressWebhook.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
|
||||
ingressClassName: {{ .Values.ingressWebhook.className }}
|
||||
{{- end }}
|
||||
{{- if .Values.ingressWebhook.tls.enabled }}
|
||||
tls:
|
||||
{{- if .Values.ingressWebhook.host }}
|
||||
- secretName: {{ .Values.ingressWebhook.tls.secretName | default (printf "%s-tls" $fullName) | quote }}
|
||||
hosts:
|
||||
- {{ .Values.ingressWebhook.host | quote }}
|
||||
{{- end }}
|
||||
{{- range .Values.ingressWebhook.tls.additional }}
|
||||
- hosts:
|
||||
{{- range .hosts }}
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
secretName: {{ .secretName }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
rules:
|
||||
{{- if .Values.ingressWebhook.host }}
|
||||
- host: {{ .Values.ingressWebhook.host | quote }}
|
||||
http:
|
||||
paths:
|
||||
- path: {{ .Values.ingressWebhook.path }}
|
||||
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
pathType: Exact
|
||||
{{- end }}
|
||||
backend:
|
||||
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
service:
|
||||
name: {{ include "meet.backend.fullname" . }}
|
||||
port:
|
||||
number: {{ .Values.backend.service.port }}
|
||||
{{- else }}
|
||||
serviceName: {{ include "meet.backend.fullname" . }}
|
||||
servicePort: {{ .Values.backend.service.port }}
|
||||
{{- end }}
|
||||
{{- with .Values.ingressWebhook.customBackends }}
|
||||
{{- toYaml . | nindent 10 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- range .Values.ingressWebhook.hosts }}
|
||||
- host: {{ . | quote }}
|
||||
http:
|
||||
paths:
|
||||
- path: {{ .Values.ingressWebhook.path }}
|
||||
{{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
pathType: Exact
|
||||
{{- end }}
|
||||
backend:
|
||||
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
|
||||
service:
|
||||
name: {{ include "meet.backend.fullname" $ }}
|
||||
port:
|
||||
number: {{ $.Values.backend.service.port }}
|
||||
{{- else }}
|
||||
serviceName: {{ include "meet.backend.fullname" $ }}
|
||||
servicePort: {{ $.Values.backend.service.port }}
|
||||
{{- end }}
|
||||
{{- with $.Values.ingressWebhook.customBackends }}
|
||||
{{- toYaml . | nindent 10 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -50,6 +50,31 @@ ingress:
|
||||
## @param ingress.customBackends Add custom backends to ingress
|
||||
customBackends: []
|
||||
|
||||
## @param ingressWebhook.enabled whether to enable the Ingress or not
|
||||
## @param ingressWebhook.className IngressClass to use for the Ingress
|
||||
## @param ingressWebhook.host Host for the Ingress
|
||||
## @param ingressWebhook.path Path to use for the Ingress
|
||||
ingressWebhook:
|
||||
enabled: false
|
||||
className: null
|
||||
host: meet.example.com
|
||||
path: /api/v1.0/rooms/webhooks-livekit/
|
||||
## @param ingressWebhook.hosts Additional host to configure for the Ingress
|
||||
hosts: []
|
||||
# - chart-example.local
|
||||
## @param ingressWebhook.tls.enabled Weather to enable TLS for the Ingress
|
||||
## @param ingressWebhook.tls.secretName Secret name for TLS config
|
||||
## @skip ingressWebhook.tls.additional
|
||||
## @extra ingressWebhook.tls.additional[].secretName Secret name for additional TLS config
|
||||
## @extra ingressWebhook.tls.additional[].hosts[] Hosts for additional TLS config
|
||||
tls:
|
||||
secretName: null
|
||||
enabled: true
|
||||
additional: []
|
||||
|
||||
## @param ingressWebhook.customBackends Add custom backends to ingress
|
||||
customBackends: []
|
||||
|
||||
|
||||
## @param ingressAdmin.enabled whether to enable the Ingress or not
|
||||
## @param ingressAdmin.className IngressClass to use for the Ingress
|
||||
|
||||
@@ -112,19 +112,16 @@ class MetadataManager:
|
||||
if self._is_disabled or self.has_task_id(task_id):
|
||||
return
|
||||
|
||||
initial_metadata = {
|
||||
"start_time": time.time(),
|
||||
"asr_model": settings.whisperx_asr_model,
|
||||
"retries": 0,
|
||||
}
|
||||
|
||||
_, filename, email, _, received_at, *_ = task_args
|
||||
|
||||
start_time = time.time()
|
||||
initial_metadata = {
|
||||
**initial_metadata,
|
||||
"start_time": start_time,
|
||||
"asr_model": settings.whisperx_asr_model,
|
||||
"retries": 0,
|
||||
"filename": filename,
|
||||
"email": email,
|
||||
"queuing_time": round(initial_metadata["start_time"] - received_at, 2),
|
||||
"queuing_time": round(start_time - received_at, 2),
|
||||
}
|
||||
|
||||
self._save_metadata(task_id, initial_metadata)
|
||||
|
||||
@@ -10,9 +10,7 @@ import openai
|
||||
import sentry_sdk
|
||||
from celery import Celery, signals
|
||||
from celery.utils.log import get_task_logger
|
||||
from requests import Session, exceptions
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util import Retry
|
||||
from requests import exceptions
|
||||
|
||||
from summary.core.analytics import MetadataManager, get_analytics
|
||||
from summary.core.config import get_settings
|
||||
@@ -30,6 +28,7 @@ from summary.core.prompt import (
|
||||
PROMPT_USER_PART,
|
||||
)
|
||||
from summary.core.transcript_formatter import TranscriptFormatter
|
||||
from summary.core.webhook_service import submit_content
|
||||
|
||||
settings = get_settings()
|
||||
analytics = get_analytics()
|
||||
@@ -56,103 +55,17 @@ if settings.sentry_dsn and settings.sentry_is_enabled:
|
||||
sentry_sdk.init(dsn=settings.sentry_dsn, enable_tracing=True)
|
||||
|
||||
|
||||
file_service = FileService(logger=logger)
|
||||
file_service = FileService()
|
||||
|
||||
|
||||
def create_retry_session():
|
||||
"""Create an HTTP session configured with retry logic."""
|
||||
session = Session()
|
||||
retries = Retry(
|
||||
total=settings.webhook_max_retries,
|
||||
backoff_factor=settings.webhook_backoff_factor,
|
||||
status_forcelist=settings.webhook_status_forcelist,
|
||||
allowed_methods={"POST"},
|
||||
)
|
||||
session.mount("https://", HTTPAdapter(max_retries=retries))
|
||||
return session
|
||||
def transcribe_audio(task_id, filename, language):
|
||||
"""Transcribe an audio file using WhisperX.
|
||||
|
||||
Downloads the audio from MinIO, sends it to WhisperX for transcription,
|
||||
and tracks metadata throughout the process.
|
||||
|
||||
def format_actions(llm_output: dict) -> str:
|
||||
"""Format the actions from the LLM output into a markdown list.
|
||||
|
||||
fomat:
|
||||
- [ ] Action title Assignée à : assignee1, assignee2, Échéance : due_date
|
||||
Returns the transcription object, or None if the file could not be retrieved.
|
||||
"""
|
||||
lines = []
|
||||
for action in llm_output.get("actions", []):
|
||||
title = action.get("title", "").strip()
|
||||
assignees = ", ".join(action.get("assignees", [])) or "-"
|
||||
due_date = action.get("due_date") or "-"
|
||||
line = f"- [ ] {title} Assignée à : {assignees}, Échéance : {due_date}"
|
||||
lines.append(line)
|
||||
if lines:
|
||||
return "### Prochaines étapes\n\n" + "\n".join(lines)
|
||||
return ""
|
||||
|
||||
|
||||
def post_with_retries(url, data):
|
||||
"""Send POST request with automatic retries."""
|
||||
session = create_retry_session()
|
||||
session.headers.update(
|
||||
{"Authorization": f"Bearer {settings.webhook_api_token.get_secret_value()}"}
|
||||
)
|
||||
try:
|
||||
response = session.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
@celery.task(
|
||||
bind=True,
|
||||
autoretry_for=[exceptions.HTTPError],
|
||||
max_retries=settings.celery_max_retries,
|
||||
queue=settings.transcribe_queue,
|
||||
)
|
||||
def process_audio_transcribe_summarize_v2(
|
||||
self,
|
||||
owner_id: str,
|
||||
filename: str,
|
||||
email: str,
|
||||
sub: str,
|
||||
received_at: float,
|
||||
room: Optional[str],
|
||||
recording_date: Optional[str],
|
||||
recording_time: Optional[str],
|
||||
language: Optional[str],
|
||||
download_link: Optional[str],
|
||||
context_language: Optional[str] = None,
|
||||
):
|
||||
"""Process an audio file by transcribing it and generating a summary.
|
||||
|
||||
This Celery task performs the following operations:
|
||||
1. Retrieves the audio file from MinIO storage
|
||||
2. Transcribes the audio using WhisperX model
|
||||
3. Sends the results via webhook
|
||||
|
||||
Args:
|
||||
self: Celery task instance (passed on with bind=True)
|
||||
owner_id: Unique identifier of the recording owner.
|
||||
filename: Name of the audio file in MinIO storage.
|
||||
email: Email address of the recording owner.
|
||||
sub: OIDC subject identifier of the recording owner.
|
||||
received_at: Unix timestamp when the recording was received.
|
||||
room: room name where the recording took place.
|
||||
recording_date: Date of the recording (localized display string).
|
||||
recording_time: Time of the recording (localized display string).
|
||||
language: ISO 639-1 language code for transcription.
|
||||
download_link: URL to download the original recording.
|
||||
context_language: ISO 639-1 language code of the meeting summary context text.
|
||||
"""
|
||||
logger.info(
|
||||
"Notification received | Owner: %s | Room: %s",
|
||||
owner_id,
|
||||
room,
|
||||
)
|
||||
|
||||
task_id = self.request.id
|
||||
|
||||
logger.info("Initiating WhisperX client")
|
||||
whisperx_client = openai.OpenAI(
|
||||
api_key=settings.whisperx_api_key.get_secret_value(),
|
||||
@@ -162,9 +75,7 @@ def process_audio_transcribe_summarize_v2(
|
||||
|
||||
# Transcription
|
||||
try:
|
||||
with (
|
||||
file_service.prepare_audio_file(filename) as (audio_file, metadata),
|
||||
):
|
||||
with file_service.prepare_audio_file(filename) as (audio_file, metadata):
|
||||
metadata_manager.track(task_id, {"audio_length": metadata["duration"]})
|
||||
|
||||
if language is None:
|
||||
@@ -195,16 +106,32 @@ def process_audio_transcribe_summarize_v2(
|
||||
|
||||
except FileServiceException:
|
||||
logger.exception("Unexpected error for filename: %s", filename)
|
||||
return
|
||||
return None
|
||||
|
||||
metadata_manager.track_transcription_metadata(task_id, transcription)
|
||||
return transcription
|
||||
|
||||
# For locale of context, use in decreasing priority context_language,
|
||||
# language (of meeting), default context language
|
||||
|
||||
def format_transcript(
|
||||
transcription,
|
||||
context_language,
|
||||
language,
|
||||
room,
|
||||
recording_date,
|
||||
recording_time,
|
||||
download_link,
|
||||
):
|
||||
"""Format a transcription into readable content with a title.
|
||||
|
||||
Resolves the locale from context_language / language, then uses
|
||||
TranscriptFormatter to produce markdown content and a title.
|
||||
|
||||
Returns a (content, title) tuple.
|
||||
"""
|
||||
locale = get_locale(context_language, language)
|
||||
formatter = TranscriptFormatter(locale)
|
||||
|
||||
content, title = formatter.format(
|
||||
return formatter.format(
|
||||
transcription,
|
||||
room=room,
|
||||
recording_date=recording_date,
|
||||
@@ -212,32 +139,90 @@ def process_audio_transcribe_summarize_v2(
|
||||
download_link=download_link,
|
||||
)
|
||||
|
||||
data = {
|
||||
"title": title,
|
||||
"content": content,
|
||||
"email": email,
|
||||
"sub": sub,
|
||||
}
|
||||
|
||||
logger.debug("Submitting webhook to %s", settings.webhook_url)
|
||||
logger.debug("Request payload: %s", json.dumps(data, indent=2))
|
||||
def format_actions(llm_output: dict) -> str:
|
||||
"""Format the actions from the LLM output into a markdown list.
|
||||
|
||||
response = post_with_retries(settings.webhook_url, data)
|
||||
fomat:
|
||||
- [ ] Action title Assignée à : assignee1, assignee2, Échéance : due_date
|
||||
"""
|
||||
lines = []
|
||||
for action in llm_output.get("actions", []):
|
||||
title = action.get("title", "").strip()
|
||||
assignees = ", ".join(action.get("assignees", [])) or "-"
|
||||
due_date = action.get("due_date") or "-"
|
||||
line = f"- [ ] {title} Assignée à : {assignees}, Échéance : {due_date}"
|
||||
lines.append(line)
|
||||
if lines:
|
||||
return "### Prochaines étapes\n\n" + "\n".join(lines)
|
||||
return ""
|
||||
|
||||
try:
|
||||
response_data = response.json()
|
||||
document_id = response_data.get("id", "N/A")
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
document_id = "Unable to parse response"
|
||||
response_data = response.text
|
||||
|
||||
@celery.task(
|
||||
bind=True,
|
||||
autoretry_for=[exceptions.HTTPError],
|
||||
max_retries=settings.celery_max_retries,
|
||||
queue=settings.transcribe_queue,
|
||||
)
|
||||
def process_audio_transcribe_summarize_v2(
|
||||
self,
|
||||
owner_id: str,
|
||||
filename: str,
|
||||
email: str,
|
||||
sub: str,
|
||||
received_at: float,
|
||||
room: Optional[str],
|
||||
recording_date: Optional[str],
|
||||
recording_time: Optional[str],
|
||||
language: Optional[str],
|
||||
download_link: Optional[str],
|
||||
context_language: Optional[str] = None,
|
||||
):
|
||||
"""Process an audio file by transcribing it and generating a summary.
|
||||
|
||||
This Celery task orchestrates:
|
||||
1. Audio transcription via WhisperX
|
||||
2. Transcript formatting
|
||||
3. Webhook submission
|
||||
4. Conditional summarization queuing
|
||||
|
||||
Args:
|
||||
self: Celery task instance (passed on with bind=True)
|
||||
owner_id: Unique identifier of the recording owner.
|
||||
filename: Name of the audio file in MinIO storage.
|
||||
email: Email address of the recording owner.
|
||||
sub: OIDC subject identifier of the recording owner.
|
||||
received_at: Unix timestamp when the recording was received.
|
||||
room: room name where the recording took place.
|
||||
recording_date: Date of the recording (localized display string).
|
||||
recording_time: Time of the recording (localized display string).
|
||||
language: ISO 639-1 language code for transcription.
|
||||
download_link: URL to download the original recording.
|
||||
context_language: ISO 639-1 language code of the meeting summary context text.
|
||||
"""
|
||||
logger.info(
|
||||
"Webhook success | Document %s submitted (HTTP %s)",
|
||||
document_id,
|
||||
response.status_code,
|
||||
"Notification received | Owner: %s | Room: %s",
|
||||
owner_id,
|
||||
room,
|
||||
)
|
||||
logger.debug("Full response: %s", response_data)
|
||||
|
||||
task_id = self.request.id
|
||||
|
||||
transcription = transcribe_audio(task_id, filename, language)
|
||||
if transcription is None:
|
||||
return
|
||||
|
||||
content, title = format_transcript(
|
||||
transcription,
|
||||
context_language,
|
||||
language,
|
||||
room,
|
||||
recording_date,
|
||||
recording_time,
|
||||
download_link,
|
||||
)
|
||||
|
||||
submit_content(content, title, email, sub)
|
||||
metadata_manager.capture(task_id, settings.posthog_event_success)
|
||||
|
||||
# LLM Summarization
|
||||
@@ -306,12 +291,11 @@ def summarize_transcription(
|
||||
# a singleton client. This is a performance trade-off we accept to ensure per-user
|
||||
# privacy controls in observability traces.
|
||||
llm_observability = LLMObservability(
|
||||
logger=logger,
|
||||
user_has_tracing_consent=user_has_tracing_consent,
|
||||
session_id=self.request.id,
|
||||
user_id=owner_id,
|
||||
)
|
||||
llm_service = LLMService(llm_observability=llm_observability, logger=logger)
|
||||
llm_service = LLMService(llm_observability=llm_observability)
|
||||
|
||||
tldr = llm_service.call(PROMPT_SYSTEM_TLDR, transcript, name="tldr")
|
||||
|
||||
@@ -354,20 +338,9 @@ def summarize_transcription(
|
||||
logger.info("Summary cleaned")
|
||||
|
||||
summary = tldr + "\n\n" + cleaned_summary + "\n\n" + next_steps
|
||||
summary_title = settings.summary_title_template.format(title=title)
|
||||
|
||||
data = {
|
||||
"title": settings.summary_title_template.format(title=title),
|
||||
"content": summary,
|
||||
"email": email,
|
||||
"sub": sub,
|
||||
}
|
||||
|
||||
logger.debug("Submitting webhook to %s", settings.webhook_url)
|
||||
|
||||
response = post_with_retries(settings.webhook_url, data)
|
||||
|
||||
logger.info("Webhook submitted successfully. Status: %s", response.status_code)
|
||||
logger.debug("Response body: %s", response.text)
|
||||
submit_content(summary, summary_title, email, sub)
|
||||
|
||||
llm_observability.flush()
|
||||
logger.debug("LLM observability flushed")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""File service to encapsulate files' manipulations."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
@@ -15,6 +16,9 @@ from summary.core.config import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FileServiceException(Exception):
|
||||
"""Base exception for file service operations."""
|
||||
|
||||
@@ -24,10 +28,8 @@ class FileServiceException(Exception):
|
||||
class FileService:
|
||||
"""Service for downloading and preparing files from MinIO storage."""
|
||||
|
||||
def __init__(self, logger):
|
||||
def __init__(self):
|
||||
"""Initialize FileService with MinIO client and configuration."""
|
||||
self._logger = logger
|
||||
|
||||
endpoint = (
|
||||
settings.aws_s3_endpoint_url.removeprefix("https://")
|
||||
.removeprefix("http://")
|
||||
@@ -53,16 +55,16 @@ class FileService:
|
||||
The file is downloaded to a temporary location for local manipulation
|
||||
such as validation, conversion, or processing before being used.
|
||||
"""
|
||||
self._logger.info("Download recording | object_key: %s", remote_object_key)
|
||||
logger.info("Download recording | object_key: %s", remote_object_key)
|
||||
|
||||
if not remote_object_key:
|
||||
self._logger.warning("Invalid object_key '%s'", remote_object_key)
|
||||
logger.warning("Invalid object_key '%s'", remote_object_key)
|
||||
raise ValueError("Invalid object_key")
|
||||
|
||||
extension = Path(remote_object_key).suffix.lower()
|
||||
|
||||
if extension not in self._allowed_extensions:
|
||||
self._logger.warning("Invalid file extension '%s'", extension)
|
||||
logger.warning("Invalid file extension '%s'", extension)
|
||||
raise ValueError(f"Invalid file extension '{extension}'")
|
||||
|
||||
response = None
|
||||
@@ -81,8 +83,8 @@ class FileService:
|
||||
tmp.flush()
|
||||
local_path = Path(tmp.name)
|
||||
|
||||
self._logger.info("Recording successfully downloaded")
|
||||
self._logger.debug("Recording local file path: %s", local_path)
|
||||
logger.info("Recording successfully downloaded")
|
||||
logger.debug("Recording local file path: %s", local_path)
|
||||
|
||||
return local_path
|
||||
|
||||
@@ -100,7 +102,7 @@ class FileService:
|
||||
file_metadata = mutagen.File(local_path).info
|
||||
duration = file_metadata.length
|
||||
|
||||
self._logger.info(
|
||||
logger.info(
|
||||
"Recording file duration: %.2f seconds",
|
||||
duration,
|
||||
)
|
||||
@@ -109,14 +111,14 @@ class FileService:
|
||||
error_msg = "Recording too long. Limit is %.2fs seconds" % (
|
||||
self._max_duration,
|
||||
)
|
||||
self._logger.error(error_msg)
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
return duration
|
||||
|
||||
def _extract_audio_from_video(self, video_path: Path) -> Path:
|
||||
"""Extract audio from video file (e.g., MP4) and save as audio file."""
|
||||
self._logger.info("Extracting audio from video file: %s", video_path)
|
||||
logger.info("Extracting audio from video file: %s", video_path)
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=".m4a", delete=False, prefix="audio_extract_"
|
||||
@@ -140,16 +142,16 @@ class FileService:
|
||||
command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True
|
||||
)
|
||||
|
||||
self._logger.info("Audio successfully extracted to: %s", output_path)
|
||||
logger.info("Audio successfully extracted to: %s", output_path)
|
||||
return output_path
|
||||
|
||||
except FileNotFoundError as e:
|
||||
self._logger.error("ffmpeg not found. Please install ffmpeg.")
|
||||
logger.error("ffmpeg not found. Please install ffmpeg.")
|
||||
if output_path.exists():
|
||||
os.remove(output_path)
|
||||
raise RuntimeError("ffmpeg is not installed or not in PATH") from e
|
||||
except subprocess.CalledProcessError as e:
|
||||
self._logger.error("Audio extraction failed: %s", e.stderr.decode())
|
||||
logger.error("Audio extraction failed: %s", e.stderr.decode())
|
||||
if output_path.exists():
|
||||
os.remove(output_path)
|
||||
raise RuntimeError("Failed to extract audio.") from e
|
||||
@@ -173,7 +175,7 @@ class FileService:
|
||||
extension = downloaded_path.suffix.lower()
|
||||
|
||||
if extension in settings.recording_video_extensions:
|
||||
self._logger.info("Video file detected, extracting audio...")
|
||||
logger.info("Video file detected, extracting audio...")
|
||||
extracted_audio_path = self._extract_audio_from_video(downloaded_path)
|
||||
processed_path = extracted_audio_path
|
||||
else:
|
||||
@@ -194,8 +196,6 @@ class FileService:
|
||||
|
||||
try:
|
||||
os.remove(path)
|
||||
self._logger.debug("Temporary file removed: %s", path)
|
||||
logger.debug("Temporary file removed: %s", path)
|
||||
except OSError as e:
|
||||
self._logger.warning(
|
||||
"Failed to remove temporary file %s: %s", path, e
|
||||
)
|
||||
logger.warning("Failed to remove temporary file %s: %s", path, e)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""LLM service to encapsulate LLM's calls."""
|
||||
|
||||
import logging
|
||||
from typing import Any, Mapping, Optional
|
||||
|
||||
import openai
|
||||
@@ -10,6 +11,9 @@ from summary.core.config import get_settings
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LLMObservability:
|
||||
"""Manage observability and tracing for LLM calls using Langfuse.
|
||||
|
||||
@@ -21,13 +25,11 @@ class LLMObservability:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
logger,
|
||||
session_id: str,
|
||||
user_id: str,
|
||||
user_has_tracing_consent: bool = False,
|
||||
):
|
||||
"""Initialize the LLMObservability client."""
|
||||
self._logger = logger
|
||||
self._observability_client: Optional[Langfuse] = None
|
||||
self.session_id = session_id
|
||||
self.user_id = user_id
|
||||
@@ -75,7 +77,7 @@ class LLMObservability:
|
||||
}
|
||||
|
||||
if not self.is_enabled:
|
||||
self._logger.debug("Using regular OpenAI client (observability disabled)")
|
||||
logger.debug("Using regular OpenAI client (observability disabled)")
|
||||
return openai.OpenAI(**base_args)
|
||||
|
||||
# Langfuse's OpenAI wrapper is imported here to avoid triggering client
|
||||
@@ -83,7 +85,7 @@ class LLMObservability:
|
||||
# is missing. Conditional import ensures Langfuse only initializes when enabled.
|
||||
from langfuse.openai import openai as langfuse_openai # noqa: PLC0415
|
||||
|
||||
self._logger.debug("Using LangfuseOpenAI client (observability enabled)")
|
||||
logger.debug("Using LangfuseOpenAI client (observability enabled)")
|
||||
return langfuse_openai.OpenAI(**base_args)
|
||||
|
||||
def flush(self):
|
||||
@@ -99,11 +101,10 @@ class LLMException(Exception):
|
||||
class LLMService:
|
||||
"""Service for performing calls to the LLM configured in the settings."""
|
||||
|
||||
def __init__(self, llm_observability, logger):
|
||||
def __init__(self, llm_observability):
|
||||
"""Init the LLMService once."""
|
||||
self._client = llm_observability.get_openai_client()
|
||||
self._observability = llm_observability
|
||||
self._logger = logger
|
||||
|
||||
def call(
|
||||
self,
|
||||
@@ -140,5 +141,5 @@ class LLMService:
|
||||
return response.choices[0].message.content
|
||||
|
||||
except Exception as e:
|
||||
self._logger.exception("LLM call failed: %s", e)
|
||||
logger.exception("LLM call failed: %s", e)
|
||||
raise LLMException(f"LLM call failed: {e}") from e
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Service for delivering content to external destinations."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from requests import Session
|
||||
from requests.adapters import HTTPAdapter
|
||||
from urllib3.util import Retry
|
||||
|
||||
from summary.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _create_retry_session():
|
||||
"""Create an HTTP session configured with retry logic."""
|
||||
session = Session()
|
||||
retries = Retry(
|
||||
total=settings.webhook_max_retries,
|
||||
backoff_factor=settings.webhook_backoff_factor,
|
||||
status_forcelist=settings.webhook_status_forcelist,
|
||||
allowed_methods={"POST"},
|
||||
)
|
||||
session.mount("https://", HTTPAdapter(max_retries=retries))
|
||||
return session
|
||||
|
||||
|
||||
def _post_with_retries(url, data):
|
||||
"""Send POST request with automatic retries."""
|
||||
session = _create_retry_session()
|
||||
session.headers.update(
|
||||
{"Authorization": f"Bearer {settings.webhook_api_token.get_secret_value()}"}
|
||||
)
|
||||
try:
|
||||
response = session.post(url, json=data)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def submit_content(content, title, email, sub):
|
||||
"""Submit content to the configured webhook destination.
|
||||
|
||||
Builds the payload, sends it with retries, and logs the outcome.
|
||||
"""
|
||||
data = {
|
||||
"title": title,
|
||||
"content": content,
|
||||
"email": email,
|
||||
"sub": sub,
|
||||
}
|
||||
|
||||
logger.debug("Submitting to %s", settings.webhook_url)
|
||||
logger.debug("Request payload: %s", json.dumps(data, indent=2))
|
||||
|
||||
response = _post_with_retries(settings.webhook_url, data)
|
||||
|
||||
try:
|
||||
response_data = response.json()
|
||||
document_id = response_data.get("id", "N/A")
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
document_id = "Unable to parse response"
|
||||
response_data = response.text
|
||||
|
||||
logger.info(
|
||||
"Delivery success | Document %s submitted (HTTP %s)",
|
||||
document_id,
|
||||
response.status_code,
|
||||
)
|
||||
logger.debug("Full response: %s", response_data)
|
||||
Reference in New Issue
Block a user