mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 04:09:26 +00:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de50aeb4fe | |||
| 71f76a81e9 | |||
| 385da86759 | |||
| 81e3483f28 | |||
| 5e030c2a07 | |||
| 32fbedd358 | |||
| aab90650f1 | |||
| 534cf000b2 | |||
| 5bac1668fe | |||
| 5a7a0da923 | |||
| c20daafd81 | |||
| 9846a61bd0 | |||
| 388b7d172d | |||
| 288562cc0e | |||
| 79400188d8 | |||
| dcaa45ccfe |
@@ -8,6 +8,16 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(fullstack) allow participants to mute others based on room configuration
|
||||
- ✨(frontend) add synchronizer for room metadata updates
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(fullstack) simplify source serialization
|
||||
- ✨(backend) expose room configuration to all API consumers
|
||||
|
||||
## [1.16.0] - 2026-05-13
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
"""Analytics module."""
|
||||
|
||||
import logging
|
||||
from enum import StrEnum
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import posthog
|
||||
|
||||
from core.models import User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EventName(StrEnum):
|
||||
"""Analytics event names."""
|
||||
|
||||
TRANSCRIPT_GENERATION_SUCCESS = "transcript_generation_success"
|
||||
TRANSCRIPT_GENERATION_FAILURE = "transcript_generation_failure"
|
||||
SUMMARY_GENERATION_SUCCESS = "summary_generation_success"
|
||||
SUMMARY_GENERATION_FAILURE = "summary_generation_failure"
|
||||
|
||||
|
||||
def capture_event(event_name: EventName, *, user: User, properties=None) -> None:
|
||||
"""
|
||||
Capture an analytics event with user properties.
|
||||
"""
|
||||
if not settings.POSTHOG_ENABLED:
|
||||
return
|
||||
|
||||
properties = properties or {}
|
||||
properties["$set"] = {
|
||||
"name": user.full_name,
|
||||
"email": user.email,
|
||||
"sub": user.sub,
|
||||
}
|
||||
posthog.capture(event_name, distinct_id=user.id, properties=properties)
|
||||
|
||||
|
||||
def is_feature_enabled(feature_name: str, distinct_id: str) -> bool:
|
||||
"""Check if a feature flag is enabled for a user."""
|
||||
if not settings.POSTHOG_ENABLED:
|
||||
return False
|
||||
|
||||
try:
|
||||
return posthog.feature_enabled(feature_name, distinct_id)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.error("Error checking feature flag %s: %s", feature_name, e)
|
||||
return False
|
||||
|
||||
|
||||
__all__ = ["EventName", "capture_event", "is_feature_enabled"]
|
||||
@@ -138,13 +138,31 @@ class FilePermission(IsAuthenticated):
|
||||
return obj.get_abilities(request.user).get(view.action, False)
|
||||
|
||||
|
||||
class TranscribeWebhookPermission(permissions.BasePermission):
|
||||
"""
|
||||
Permissions applying to the summary webhook endpoint.
|
||||
class CanMuteParticipant(permissions.BasePermission):
|
||||
"""
|
||||
Grant muting rights based on role or room configuration.
|
||||
|
||||
def has_permission(self, request, view):
|
||||
return request.method == "POST"
|
||||
- Admins and owners can always mute.
|
||||
- When `everyone_can_mute` is enabled on the room, any participant
|
||||
currently in the room (proven by a valid LiveKit token for that room)
|
||||
can mute.
|
||||
"""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
return False
|
||||
"""Check if the requesting user is allowed to mute a participant in the given room."""
|
||||
|
||||
is_livekit_token_auth = request.auth and hasattr(request.auth, "video")
|
||||
|
||||
# Always allow admins/owners when authenticated with session cookie
|
||||
if not is_livekit_token_auth and obj.is_administrator_or_owner(request.user):
|
||||
return True
|
||||
|
||||
everyone_can_mute = obj.configuration.get("everyone_can_mute", True)
|
||||
if not everyone_can_mute:
|
||||
return False
|
||||
|
||||
if not is_livekit_token_auth:
|
||||
return False
|
||||
|
||||
# LiveKit token scoped to this room
|
||||
return request.auth.video.room == str(obj.id)
|
||||
|
||||
@@ -13,7 +13,7 @@ from django.core.exceptions import SuspiciousOperation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from django_pydantic_field.rest_framework import SchemaField
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, field_serializer
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
@@ -166,11 +166,6 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
output["accesses"] = access_serializer.data
|
||||
|
||||
configuration = output["configuration"]
|
||||
|
||||
if not is_admin_or_owner:
|
||||
del output["configuration"]
|
||||
|
||||
should_access_room = (
|
||||
(
|
||||
instance.access_level == models.RoomAccessLevel.TRUSTED
|
||||
@@ -187,7 +182,7 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
room_id=room_id,
|
||||
user=request.user,
|
||||
username=username,
|
||||
configuration=configuration,
|
||||
configuration=output["configuration"],
|
||||
is_admin_or_owner=is_admin_or_owner,
|
||||
)
|
||||
else:
|
||||
@@ -317,9 +312,7 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
|
||||
)
|
||||
|
||||
|
||||
RoomConfigurationTrackSource = Literal[
|
||||
"camera", "microphone", "screen_share", "screen_share_audio"
|
||||
]
|
||||
TrackSource = Literal["camera", "microphone", "screen_share", "screen_share_audio"]
|
||||
|
||||
|
||||
class RoomConfiguration(BaseModel):
|
||||
@@ -328,14 +321,12 @@ class RoomConfiguration(BaseModel):
|
||||
Unknown fields are rejected.
|
||||
"""
|
||||
|
||||
can_publish_sources: list[RoomConfigurationTrackSource] | None = None
|
||||
can_publish_sources: list[TrackSource] | None = None
|
||||
everyone_can_mute: bool | None = None
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
|
||||
TrackSource = Literal["SCREEN_SHARE", "SCREEN_SHARE_AUDIO", "CAMERA", "MICROPHONE"]
|
||||
|
||||
|
||||
class ParticipantPermission(BaseModel):
|
||||
"""Mirror the LiveKit ParticipantPermission protobuf.
|
||||
|
||||
@@ -355,6 +346,10 @@ class ParticipantPermission(BaseModel):
|
||||
|
||||
model_config = {"extra": "forbid"}
|
||||
|
||||
@field_serializer("can_publish_sources")
|
||||
def _serialize_sources(self, sources: list[str]) -> list[str]:
|
||||
return [s.upper() for s in sources]
|
||||
|
||||
|
||||
class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
||||
"""Validate participant update data."""
|
||||
|
||||
@@ -17,7 +17,6 @@ from django.utils.text import slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from django_filters import rest_framework as django_filters
|
||||
from pydantic import ValidationError
|
||||
from rest_framework import (
|
||||
decorators,
|
||||
filters,
|
||||
@@ -34,8 +33,9 @@ from rest_framework import (
|
||||
from rest_framework import (
|
||||
status as drf_status,
|
||||
)
|
||||
from rest_framework.settings import api_settings
|
||||
|
||||
from core import analytics, enums, models, utils
|
||||
from core import enums, models, utils
|
||||
from core.api.filters import ListFileFilter
|
||||
from core.enums import MEDIA_STORAGE_URL_PATTERN
|
||||
from core.recording.enums import FileExtension
|
||||
@@ -77,14 +77,15 @@ from core.services.participants_management import (
|
||||
ParticipantsManagementException,
|
||||
)
|
||||
from core.services.room_creation import RoomCreation
|
||||
from core.services.room_management import (
|
||||
RoomManagement,
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
from core.services.subtitle import SubtitleException, SubtitleService
|
||||
from core.tasks.file import process_file_deletion
|
||||
|
||||
from ..authentication.livekit import LiveKitTokenAuthentication
|
||||
from ..authentication.webhooks import AiWebhookAuthentication
|
||||
from ..models import AiJobStatusChoices, AiRecordingJob
|
||||
from ..tasks.ai_job import handle_summary_received, handle_transcript_received
|
||||
from ..transcription import webhook_schemas
|
||||
from . import permissions, serializers, throttling
|
||||
from .feature_flag import FeatureFlag
|
||||
|
||||
@@ -304,6 +305,41 @@ class RoomViewSet(
|
||||
if callback_id := self.request.data.get("callback_id"):
|
||||
RoomCreation().persist_callback_state(callback_id, room)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""Persist the room update, then sync metadata to LiveKit."""
|
||||
|
||||
old_configuration = serializer.instance.configuration
|
||||
old_access_level = serializer.instance.access_level
|
||||
|
||||
room = serializer.save()
|
||||
|
||||
if (
|
||||
room.configuration == old_configuration
|
||||
and room.access_level == old_access_level
|
||||
):
|
||||
return
|
||||
|
||||
metadata = {
|
||||
"configuration": room.configuration,
|
||||
"access_level": room.access_level,
|
||||
}
|
||||
|
||||
try:
|
||||
RoomManagement().update_metadata(
|
||||
room_name=str(room.id),
|
||||
metadata=metadata,
|
||||
)
|
||||
except RoomNotFoundException:
|
||||
logger.info(
|
||||
"LiveKit room %s does not exist yet, skipping metadata sync",
|
||||
room.id,
|
||||
)
|
||||
except RoomManagementException:
|
||||
logger.warning(
|
||||
"Failed to sync metadata to LiveKit for room %s",
|
||||
room.id,
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
@@ -619,7 +655,11 @@ class RoomViewSet(
|
||||
methods=["post"],
|
||||
url_path="mute-participant",
|
||||
url_name="mute-participant",
|
||||
permission_classes=[permissions.HasPrivilegesOnRoom],
|
||||
permission_classes=[permissions.CanMuteParticipant],
|
||||
authentication_classes=[
|
||||
LiveKitTokenAuthentication,
|
||||
*api_settings.DEFAULT_AUTHENTICATION_CLASSES,
|
||||
],
|
||||
)
|
||||
def mute_participant(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Mute a specific track for a participant in the room."""
|
||||
@@ -628,6 +668,26 @@ class RoomViewSet(
|
||||
serializer = serializers.MuteParticipantSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
# TEMPORARY: a LiveKit token proves access was granted, not that the caller
|
||||
# joined. Cross-check identity against the live participant list until auth
|
||||
# is hardened. Skipped for non-LiveKit auth backends.
|
||||
caller_identity = getattr(request.auth, "identity", None)
|
||||
if caller_identity is not None:
|
||||
try:
|
||||
ParticipantsManagement().check_if_in_meeting(
|
||||
room_name=str(room.pk),
|
||||
identity=caller_identity,
|
||||
)
|
||||
except (ParticipantNotFoundException, ParticipantsManagementException):
|
||||
logger.warning(
|
||||
"Failed to verify caller presence for mute in room %s; denying",
|
||||
room.pk,
|
||||
)
|
||||
return drf_response.Response(
|
||||
{"error": "Could not verify caller presence"},
|
||||
status=drf_status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
try:
|
||||
ParticipantsManagement().mute(
|
||||
room_name=str(room.pk),
|
||||
@@ -920,9 +980,15 @@ class RecordingViewSet(
|
||||
|
||||
# Attempt to notify external services about the recording
|
||||
# This is a non-blocking operation - failures are logged but don't interrupt the flow
|
||||
notification_service.notify_external_services(recording)
|
||||
notification_succeeded = notification_service.notify_external_services(
|
||||
recording
|
||||
)
|
||||
|
||||
recording.status = models.RecordingStatusChoices.SAVED
|
||||
recording.status = (
|
||||
models.RecordingStatusChoices.NOTIFICATION_SUCCEEDED
|
||||
if notification_succeeded
|
||||
else models.RecordingStatusChoices.SAVED
|
||||
)
|
||||
recording.save()
|
||||
|
||||
return drf_response.Response(
|
||||
@@ -1331,92 +1397,3 @@ class FileViewSet(
|
||||
request = utils.generate_s3_authorization_headers(f"{url_params.get('key'):s}")
|
||||
|
||||
return drf_response.Response("authorized", headers=request.headers, status=200)
|
||||
|
||||
|
||||
class AiJobViewSet(
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""AI jobs API."""
|
||||
|
||||
permission_classes = []
|
||||
serializer_class = None
|
||||
|
||||
def get_queryset(self):
|
||||
"""Restrict AI jobs to current user except webhook endpoint."""
|
||||
|
||||
raise NotImplementedError()
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["post"],
|
||||
url_path="webhook",
|
||||
authentication_classes=[AiWebhookAuthentication],
|
||||
permission_classes=[permissions.TranscribeWebhookPermission],
|
||||
)
|
||||
def on_ai_event(self, request):
|
||||
"""Handle incoming hook events for recordings."""
|
||||
logger.debug("Received transcribe webhook event: %s", request.data)
|
||||
|
||||
try:
|
||||
payload = webhook_schemas.webhook_payload_adapter.validate_python(
|
||||
request.data
|
||||
)
|
||||
except ValidationError as exc:
|
||||
logger.error("Invalid webhook payload: %s", exc)
|
||||
raise drf_exceptions.ValidationError(detail=exc) from exc
|
||||
|
||||
ai_recording_job = AiRecordingJob.objects.filter(
|
||||
remote_job_id=payload.job_id
|
||||
).first()
|
||||
|
||||
if not ai_recording_job:
|
||||
logger.warning("No AI recording job found for job ID: %s", payload.job_id)
|
||||
return drf_response.Response(
|
||||
{"message": "No AI recording job found for job ID, ignoring."},
|
||||
)
|
||||
|
||||
if ai_recording_job.status == AiJobStatusChoices.SUCCESS:
|
||||
logger.warning(
|
||||
"AI recording job already in success state for job ID: %s",
|
||||
payload.job_id,
|
||||
)
|
||||
return drf_response.Response(
|
||||
{"message": "AI recording job already in success state, ignoring."},
|
||||
)
|
||||
|
||||
if isinstance(payload, webhook_schemas.TranscribeWebhookSuccessPayload):
|
||||
handle_transcript_received.apply_async(
|
||||
args=[payload.job_id, payload.transcription_data_url]
|
||||
)
|
||||
elif isinstance(payload, webhook_schemas.SummarizeWebhookSuccessPayload):
|
||||
handle_summary_received.apply_async(
|
||||
args=[payload.job_id, payload.summary_data_url]
|
||||
)
|
||||
elif isinstance(
|
||||
payload,
|
||||
(
|
||||
webhook_schemas.SummarizeWebhookFailurePayload,
|
||||
webhook_schemas.TranscribeWebhookFailurePayload,
|
||||
),
|
||||
):
|
||||
ai_recording_job.status = AiJobStatusChoices.FAILED
|
||||
ai_recording_job.save()
|
||||
analytics.capture_event(
|
||||
analytics.EventName.TRANSCRIPT_GENERATION_FAILURE
|
||||
if isinstance(payload, webhook_schemas.TranscribeWebhookFailurePayload)
|
||||
else analytics.EventName.SUMMARY_GENERATION_FAILURE,
|
||||
user=ai_recording_job.user,
|
||||
properties={
|
||||
"generation_time_seconds": (
|
||||
timezone.now() - ai_recording_job.created_at
|
||||
).total_seconds(),
|
||||
"ai_recording_job_id": ai_recording_job.id,
|
||||
"recording_id": ai_recording_job.recording.id,
|
||||
},
|
||||
)
|
||||
else:
|
||||
raise NotImplementedError()
|
||||
|
||||
return drf_response.Response(
|
||||
{"message": "Event processed."},
|
||||
)
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
"""Webhooks authentication."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
|
||||
from rest_framework.authentication import BaseAuthentication
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AiWebhookAuthentication(BaseAuthentication):
|
||||
"""
|
||||
Custom authentication class for AI webhook requests.
|
||||
Validates the API key in the Authorization header.
|
||||
"""
|
||||
|
||||
def authenticate(self, request):
|
||||
"""
|
||||
Authenticate the request and return a two-tuple of (user, token).
|
||||
"""
|
||||
|
||||
authorization_header: str = request.headers.get("Authorization") or ""
|
||||
if authorization_header.removeprefix("Bearer ") != settings.AI_WEBHOOK_API_KEY:
|
||||
logger.warning(
|
||||
"Authentication failed: Bad Authorization header (ip: %s)",
|
||||
request.META.get("REMOTE_ADDR"),
|
||||
)
|
||||
raise AuthenticationFailed()
|
||||
|
||||
# No users are associated with the transcribe webhooks
|
||||
return AnonymousUser(), None
|
||||
@@ -1,36 +0,0 @@
|
||||
# Generated by Django 5.2.14 on 2026-05-14 12:40
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0018_rename_active_application_is_active'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AiRecordingJob',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('remote_job_id', models.CharField(blank=True, max_length=255, null=True, unique=True)),
|
||||
('type', models.CharField(choices=[('transcript', 'Transcript'), ('summary', 'Summary')], max_length=25)),
|
||||
('status', models.CharField(choices=[('pending', 'Pending'), ('success', 'Success'), ('failed', 'Failed')], max_length=25)),
|
||||
('language', models.CharField(choices=[('fr', 'fr'), ('en', 'en'), ('de', 'de'), ('nl', 'nl')], default='fr', max_length=2)),
|
||||
('docs_app_id', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('recording', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ai_jobs', to='core.recording')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'AiJob',
|
||||
'verbose_name_plural': 'AiJobs',
|
||||
'db_table': 'ai_job',
|
||||
'ordering': ('created_at',),
|
||||
'indexes': [models.Index(fields=['recording', 'type', '-created_at'], name='ai_job_recordi_ca452e_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -388,6 +388,7 @@ class Room(Resource):
|
||||
choices=RoomAccessLevel.choices,
|
||||
default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
|
||||
)
|
||||
# Public configuration exposed to any room participant via the API
|
||||
configuration = models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
@@ -590,16 +591,6 @@ class Recording(BaseModel):
|
||||
verbose_name=_("Recording options"),
|
||||
help_text=_("Recording options"),
|
||||
)
|
||||
started_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_("Recording start timestamp as recorded by livekit."),
|
||||
)
|
||||
ended_at = models.DateTimeField(
|
||||
null=True,
|
||||
blank=True,
|
||||
help_text=_("Recording end timestamp as recorded by livekit."),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "meet_recording"
|
||||
@@ -746,66 +737,6 @@ class RecordingAccess(BaseAccess):
|
||||
return self._get_abilities(self.recording, user)
|
||||
|
||||
|
||||
class AiJobStatusChoices(models.TextChoices):
|
||||
"""Possible states of a file."""
|
||||
|
||||
PENDING = "pending", _("Pending")
|
||||
SUCCESS = "success", _("Success")
|
||||
FAILED = "failed", _("Failed")
|
||||
|
||||
|
||||
class AiJobTypeChoices(models.TextChoices):
|
||||
"""Possible types of Ai Jobs."""
|
||||
|
||||
TRANSCRIPT = "transcript", _("Transcript")
|
||||
SUMMARIZE = "summary", _("Summary")
|
||||
|
||||
|
||||
class AiRecordingJob(BaseModel):
|
||||
"""
|
||||
A job that is run to process an audio file.
|
||||
"""
|
||||
|
||||
remote_job_id = models.CharField(max_length=255, unique=True, null=True, blank=True)
|
||||
type = models.CharField(
|
||||
max_length=25,
|
||||
choices=AiJobTypeChoices.choices,
|
||||
)
|
||||
recording = models.ForeignKey(
|
||||
Recording, on_delete=models.CASCADE, related_name="ai_jobs"
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=25,
|
||||
choices=AiJobStatusChoices.choices,
|
||||
)
|
||||
language = models.CharField(
|
||||
max_length=2,
|
||||
choices=(("fr", "fr"), ("en", "en"), ("de", "de"), ("nl", "nl")),
|
||||
default="fr",
|
||||
)
|
||||
docs_app_id = models.CharField(max_length=255, null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
db_table = "ai_job"
|
||||
verbose_name = _("AiJob")
|
||||
verbose_name_plural = _("AiJobs")
|
||||
ordering = ("created_at",)
|
||||
indexes = [
|
||||
models.Index(fields=["recording", "type", "-created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.recording.id} - {self.type} - {self.status}"
|
||||
|
||||
@property
|
||||
def user(self):
|
||||
return (
|
||||
RecordingAccess.objects.select_related("user")
|
||||
.filter(role=RoleChoices.OWNER, recording_id=self.recording.id)
|
||||
.first()
|
||||
).user
|
||||
|
||||
|
||||
class ApplicationScope(models.TextChoices):
|
||||
"""Available permission scopes for application operations."""
|
||||
|
||||
|
||||
@@ -12,11 +12,11 @@ from django.utils.translation import get_language, override
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import aiohttp
|
||||
import requests
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit import api as livekit_api
|
||||
|
||||
from core import models, utils
|
||||
from core.tasks.ai_job import call_transcribe_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -45,17 +45,22 @@ class NotificationService:
|
||||
"""Process a recording based on its mode."""
|
||||
|
||||
if recording.mode == models.RecordingModeChoices.TRANSCRIPT:
|
||||
self._notify_summary_service(recording)
|
||||
elif recording.mode == models.RecordingModeChoices.SCREEN_RECORDING:
|
||||
return self._notify_summary_service(recording)
|
||||
|
||||
if recording.mode == models.RecordingModeChoices.SCREEN_RECORDING:
|
||||
summary_success = True
|
||||
if recording.options.get("transcribe", False):
|
||||
self._notify_summary_service(recording)
|
||||
self._notify_user_by_email(recording)
|
||||
else:
|
||||
logger.error(
|
||||
"Unknown recording mode %s for recording %s",
|
||||
recording.mode,
|
||||
recording.id,
|
||||
)
|
||||
summary_success = self._notify_summary_service(recording)
|
||||
|
||||
email_success = self._notify_user_by_email(recording)
|
||||
return email_success and summary_success
|
||||
|
||||
logger.error(
|
||||
"Unknown recording mode %s for recording %s",
|
||||
recording.mode,
|
||||
recording.id,
|
||||
)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _notify_user_by_email(recording) -> bool:
|
||||
@@ -182,17 +187,71 @@ class NotificationService:
|
||||
or not settings.SUMMARY_SERVICE_API_TOKEN
|
||||
):
|
||||
logger.error("Summary service not configured")
|
||||
return
|
||||
return False
|
||||
|
||||
owner_access = (
|
||||
models.RecordingAccess.objects.select_related("user")
|
||||
.filter(
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording_id=recording.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if settings.METADATA_COLLECTOR_ENABLED and recording.options.get(
|
||||
"collect_metadata", False
|
||||
):
|
||||
output_folder = settings.METADATA_COLLECTOR_OUTPUT_FOLDER
|
||||
metadata_filename = f"{output_folder}/{recording.id}-metadata.json"
|
||||
else:
|
||||
metadata_filename = None
|
||||
|
||||
if not owner_access:
|
||||
logger.error("No owner found for recording %s", recording.id)
|
||||
return False
|
||||
|
||||
started_at, ended_at = async_to_sync(
|
||||
NotificationService._get_recording_timestamps
|
||||
)(recording.worker_id)
|
||||
|
||||
recording.started_at = started_at
|
||||
recording.ended_at = ended_at
|
||||
recording.save()
|
||||
payload = {
|
||||
"owner_id": str(owner_access.user.id),
|
||||
"recording_filename": recording.key,
|
||||
"metadata_filename": metadata_filename,
|
||||
"email": owner_access.user.email,
|
||||
"sub": owner_access.user.sub,
|
||||
"room": recording.room.name,
|
||||
"language": recording.options.get("language"),
|
||||
"owner_timezone": str(owner_access.user.timezone),
|
||||
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
|
||||
"context_language": owner_access.user.language,
|
||||
"recording_start_at": (started_at.isoformat() if started_at else None),
|
||||
"recording_end_at": (ended_at.isoformat() if ended_at else None),
|
||||
}
|
||||
|
||||
call_transcribe_service.apply_async(args=[recording.id])
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {settings.SUMMARY_SERVICE_API_TOKEN}",
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
settings.SUMMARY_SERVICE_ENDPOINT,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.RequestException as exc:
|
||||
logger.exception(
|
||||
"Summary service error for recording %s. URL: %s. Exception: %s",
|
||||
recording.id,
|
||||
settings.SUMMARY_SERVICE_ENDPOINT,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
notification_service = NotificationService()
|
||||
|
||||
@@ -15,6 +15,7 @@ from livekit.api import (
|
||||
TwirpError,
|
||||
UpdateParticipantRequest,
|
||||
)
|
||||
from livekit.protocol.models import ParticipantInfo
|
||||
|
||||
from core import utils
|
||||
|
||||
@@ -154,3 +155,44 @@ class ParticipantsManagement:
|
||||
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
@async_to_sync
|
||||
async def check_if_in_meeting(self, room_name: str, identity: str) -> bool:
|
||||
"""Check whether `identity` is currently a participant in `room_name`.
|
||||
|
||||
Raises ParticipantsManagementException for unexpected LiveKit errors
|
||||
so callers can fail closed rather than silently allowing the action.
|
||||
"""
|
||||
|
||||
if not room_name or not identity:
|
||||
return False
|
||||
|
||||
lkapi = utils.create_livekit_client()
|
||||
|
||||
try:
|
||||
participant = await lkapi.room.get_participant(
|
||||
RoomParticipantIdentity(
|
||||
room=room_name,
|
||||
identity=identity,
|
||||
)
|
||||
)
|
||||
except TwirpError as e:
|
||||
if e.code == "not_found":
|
||||
raise ParticipantNotFoundException("Participant does not exist") from e
|
||||
|
||||
logger.exception(
|
||||
"Unexpected error checking participant %s in room %s",
|
||||
identity,
|
||||
room_name,
|
||||
)
|
||||
raise ParticipantsManagementException(
|
||||
"Could not verify participant presence"
|
||||
) from e
|
||||
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
return (
|
||||
participant is not None
|
||||
and participant.state != ParticipantInfo.State.DISCONNECTED
|
||||
)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Room management service for LiveKit rooms."""
|
||||
|
||||
# pylint: disable=no-name-in-module
|
||||
|
||||
import json
|
||||
from logging import getLogger
|
||||
from typing import Dict, Optional
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit.api import (
|
||||
TwirpError,
|
||||
UpdateRoomMetadataRequest,
|
||||
)
|
||||
|
||||
from core import utils
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class RoomManagementException(Exception):
|
||||
"""Exception raised when a room management operation fails."""
|
||||
|
||||
|
||||
class RoomNotFoundException(RoomManagementException):
|
||||
"""Raised when the target room does not exist in LiveKit."""
|
||||
|
||||
|
||||
class RoomManagement:
|
||||
"""Service for managing LiveKit rooms."""
|
||||
|
||||
@async_to_sync
|
||||
async def update_metadata(self, room_name: str, metadata: Optional[Dict] = None):
|
||||
"""Update a LiveKit room's metadata.
|
||||
|
||||
The `room_name` corresponds to the LiveKit room identifier
|
||||
(i.e. the Room model's UUID as a string).
|
||||
"""
|
||||
|
||||
lkapi = utils.create_livekit_client()
|
||||
|
||||
try:
|
||||
await lkapi.room.update_room_metadata(
|
||||
UpdateRoomMetadataRequest(
|
||||
room=room_name,
|
||||
metadata=json.dumps(metadata) if metadata is not None else "",
|
||||
)
|
||||
)
|
||||
|
||||
except TwirpError as e:
|
||||
if e.code == "not_found":
|
||||
logger.warning(
|
||||
"Room %s not found in LiveKit, skipping metadata update",
|
||||
room_name,
|
||||
)
|
||||
raise RoomNotFoundException("Room does not exist") from e
|
||||
|
||||
logger.exception(
|
||||
"Unexpected error updating metadata for room %s",
|
||||
room_name,
|
||||
)
|
||||
raise RoomManagementException("Could not update room metadata") from e
|
||||
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
@@ -1,319 +0,0 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
import requests
|
||||
|
||||
from core import analytics, models
|
||||
from core.models import (
|
||||
AiJobStatusChoices,
|
||||
AiJobTypeChoices,
|
||||
AiRecordingJob,
|
||||
Recording,
|
||||
)
|
||||
from core.tasks._task import task
|
||||
from core.transcription.locales import get_locale
|
||||
from core.transcription.transcript_formatter import TranscriptFormatter
|
||||
from core.transcription.webhook_schemas import WhisperXResponse
|
||||
from core.utils import generate_download_s3_file_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@task
|
||||
def call_transcribe_service(recording_id):
|
||||
"""
|
||||
Call the transcribe service for a given recording.
|
||||
"""
|
||||
try:
|
||||
recording = Recording.objects.get(id=recording_id)
|
||||
except Recording.DoesNotExist:
|
||||
logger.error("Recoding %s does not exist", recording_id)
|
||||
return None
|
||||
|
||||
owner_access = (
|
||||
models.RecordingAccess.objects.select_related("user")
|
||||
.filter(
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording_id=recording.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not owner_access:
|
||||
logger.error("No owner found for recording %s", recording.id)
|
||||
return False
|
||||
|
||||
metadata = None
|
||||
if (
|
||||
settings.METADATA_COLLECTOR_ENABLED
|
||||
and recording.options.get("collect_metadata", False)
|
||||
and recording.started_at
|
||||
and recording.ended_at
|
||||
):
|
||||
output_folder = settings.METADATA_COLLECTOR_OUTPUT_FOLDER
|
||||
metadata_filename = f"{output_folder}/{recording.id}-metadata.json"
|
||||
metadata = {
|
||||
"cloud_storage_url": generate_download_s3_file_url(metadata_filename),
|
||||
"start_at": recording.started_at,
|
||||
"end_at": recording.ended_at,
|
||||
}
|
||||
|
||||
language = (
|
||||
recording.options.get("language") or settings.TRANSCRIPTION_DEFAULT_LANGUAGE
|
||||
)
|
||||
ai_transcribe_job = AiRecordingJob.objects.create(
|
||||
remote_job_id=None,
|
||||
recording=recording,
|
||||
type=AiJobTypeChoices.TRANSCRIPT,
|
||||
status=AiJobStatusChoices.PENDING,
|
||||
language=language,
|
||||
)
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
settings.AI_SERVICE_URL + "async-jobs/transcribe/",
|
||||
json={
|
||||
"user_sub": owner_access.user.sub,
|
||||
"language": language,
|
||||
"cloud_storage_url": generate_download_s3_file_url(
|
||||
recording.key, expires_in=60 * 60 * 24, override_domain=False
|
||||
),
|
||||
"metadata": metadata,
|
||||
},
|
||||
headers={
|
||||
"Authorization": f"Bearer {settings.AI_SERVICE_API_KEY}",
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Creating transcription job failed for recording %s: %s", recording_id, e
|
||||
)
|
||||
ai_transcribe_job.status = AiJobStatusChoices.FAILED
|
||||
ai_transcribe_job.save()
|
||||
raise e
|
||||
|
||||
data = response.json()
|
||||
|
||||
ai_transcribe_job.remote_job_id = data["job_id"]
|
||||
ai_transcribe_job.save()
|
||||
|
||||
recording.status = models.RecordingStatusChoices.NOTIFICATION_SUCCEEDED
|
||||
recording.save()
|
||||
|
||||
logger.info("Transcription job created for recording %s", recording_id)
|
||||
return ai_transcribe_job.id
|
||||
|
||||
|
||||
def format_transcript( # noqa: PLR0913
|
||||
transcription,
|
||||
*,
|
||||
context_language: str | None,
|
||||
language: str,
|
||||
room: str | None,
|
||||
recording_datetime: datetime | None,
|
||||
owner_timezone: str | None,
|
||||
download_link: str | None,
|
||||
) -> tuple[str, str]:
|
||||
"""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)
|
||||
|
||||
return formatter.format(
|
||||
transcription,
|
||||
room=room,
|
||||
recording_datetime=recording_datetime,
|
||||
owner_timezone=owner_timezone,
|
||||
download_link=download_link,
|
||||
)
|
||||
|
||||
|
||||
@task
|
||||
def handle_transcript_received(remote_job_id, url):
|
||||
"""
|
||||
Store the transcript and call the summarize service for a given recording.
|
||||
"""
|
||||
ai_transcript_job = AiRecordingJob.objects.filter(
|
||||
remote_job_id=remote_job_id, type=AiJobTypeChoices.TRANSCRIPT
|
||||
).first()
|
||||
if not ai_transcript_job:
|
||||
logger.warning("No AI recording job found for job ID: %s", remote_job_id)
|
||||
return
|
||||
|
||||
user = ai_transcript_job.user
|
||||
recording = ai_transcript_job.recording
|
||||
|
||||
response = requests.get(url, timeout=(10, 20))
|
||||
response.raise_for_status()
|
||||
transcript = WhisperXResponse(**response.json())
|
||||
|
||||
# Format output
|
||||
content, title = format_transcript(
|
||||
transcript,
|
||||
context_language=user.language,
|
||||
language=ai_transcript_job.language,
|
||||
room=recording.room.name,
|
||||
recording_datetime=recording.started_at or recording.created_at,
|
||||
owner_timezone=user.timezone,
|
||||
download_link=urljoin(settings.RECORDING_DOWNLOAD_BASE_URL, recording.id),
|
||||
)
|
||||
|
||||
create_document_in_docs(
|
||||
title=title, content=content, email=user.email, sub=user.sub
|
||||
)
|
||||
|
||||
ai_transcript_job.status = AiJobStatusChoices.SUCCESS
|
||||
ai_transcript_job.save()
|
||||
|
||||
analytics.capture_event(
|
||||
analytics.EventName.TRANSCRIPT_GENERATION_SUCCESS,
|
||||
user=ai_transcript_job.user,
|
||||
properties={
|
||||
"generation_time_seconds": (
|
||||
timezone.now() - ai_transcript_job.created_at
|
||||
).total_seconds(),
|
||||
"ai_recording_job_id": ai_transcript_job.id,
|
||||
"language": ai_transcript_job.language,
|
||||
"recording_id": ai_transcript_job.recording.id,
|
||||
"transcript_size": len(response.content),
|
||||
},
|
||||
)
|
||||
|
||||
# LLM Summarization
|
||||
if analytics.is_feature_enabled("summary-enabled", distinct_id=user.sub):
|
||||
ai_summary_job = AiRecordingJob.objects.create(
|
||||
remote_job_id=None,
|
||||
file=recording,
|
||||
type=AiJobTypeChoices.SUMMARIZE,
|
||||
status=AiJobStatusChoices.PENDING,
|
||||
language=ai_transcript_job.language,
|
||||
)
|
||||
|
||||
try:
|
||||
summary_response = requests.post(
|
||||
settings.AI_SERVICE_URL + "async-jobs/summarize/",
|
||||
json={
|
||||
"user_sub": ai_summary_job.user.sub,
|
||||
"language": ai_transcript_job.language,
|
||||
"content": content,
|
||||
},
|
||||
headers={
|
||||
"Authorization": f"Bearer {settings.AI_SERVICE_API_KEY}",
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
summary_response.raise_for_status()
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"Creating summary job failed for recording %s: %s", recording.id, e
|
||||
)
|
||||
ai_summary_job.status = AiJobStatusChoices.FAILED
|
||||
ai_summary_job.save()
|
||||
raise e
|
||||
|
||||
ai_summary_job.remote_job_id = summary_response.json()["job_id"]
|
||||
ai_summary_job.save()
|
||||
|
||||
logger.info("Summary job created for recording %s", recording.id)
|
||||
|
||||
|
||||
@task
|
||||
def handle_summary_received(remote_job_id, url):
|
||||
"""
|
||||
Store the summary of a given file.
|
||||
"""
|
||||
ai_summary_job = AiRecordingJob.objects.filter(
|
||||
remote_job_id=remote_job_id, type=AiJobTypeChoices.SUMMARIZE
|
||||
).first()
|
||||
if not ai_summary_job:
|
||||
logger.warning("No AI file job found for job ID: %s", remote_job_id)
|
||||
return
|
||||
|
||||
recording = ai_summary_job.recording
|
||||
|
||||
logger.info("Storing summary for recording %s & url %s", recording.id, url)
|
||||
response = requests.get(url, timeout=(10, 20))
|
||||
response.raise_for_status()
|
||||
|
||||
user = ai_summary_job.user
|
||||
|
||||
# We dynamically recompute the title for the document since we don't have access to the transcript
|
||||
_, title = format_transcript(
|
||||
None,
|
||||
context_language=user.language,
|
||||
language=ai_summary_job.language,
|
||||
room=recording.room.name,
|
||||
recording_datetime=recording.started_at or recording.created_at,
|
||||
owner_timezone=user.timezone,
|
||||
download_link=urljoin(settings.RECORDING_DOWNLOAD_BASE_URL, recording.id),
|
||||
)
|
||||
|
||||
create_document_in_docs(
|
||||
title=get_locale(user.language).summary_title_template.format(title=title),
|
||||
content=response.text,
|
||||
email=user.email,
|
||||
sub=user.sub,
|
||||
)
|
||||
|
||||
logger.info("Summary created in docs for recording %s & url %s", recording.id, url)
|
||||
ai_summary_job.status = AiJobStatusChoices.SUCCESS
|
||||
ai_summary_job.save()
|
||||
|
||||
analytics.capture_event(
|
||||
analytics.EventName.TRANSCRIPT_GENERATION_SUCCESS,
|
||||
user=ai_summary_job.user,
|
||||
properties={
|
||||
"generation_time_seconds": (
|
||||
timezone.now() - ai_summary_job.created_at
|
||||
).total_seconds(),
|
||||
"ai_recording_job_id": ai_summary_job.id,
|
||||
"language": ai_summary_job.language,
|
||||
"recording_id": ai_summary_job.recording.id,
|
||||
"transcript_size": len(response.content),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def create_document_in_docs(*, title: str, content: str, email: str, sub: str) -> str:
|
||||
"""
|
||||
Create a document in Docs for a given file.
|
||||
"""
|
||||
|
||||
response = requests.post(
|
||||
urljoin(settings.DOCS_BASE_URL, "/api/v1.0/documents/create-for-owner/"),
|
||||
json={
|
||||
"title": title,
|
||||
"content": content,
|
||||
"email": email,
|
||||
"sub": sub,
|
||||
},
|
||||
headers={
|
||||
"Authorization": f"Bearer {settings.DOCS_SERVER_TO_SERVER_API_KEY}",
|
||||
},
|
||||
timeout=20,
|
||||
)
|
||||
|
||||
if response.status_code != 201:
|
||||
logger.error(
|
||||
"Failed to create document in Docs %s",
|
||||
title,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
docs_app_id = response.json()["id"]
|
||||
logger.info(
|
||||
"Document created in Docs => %s (in docs)",
|
||||
docs_app_id,
|
||||
)
|
||||
return docs_app_id
|
||||
@@ -2,20 +2,23 @@
|
||||
Test rooms API endpoints in the Meet core app: participants management.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument,protected-access
|
||||
# pylint: disable=redefined-outer-name,unused-argument,protected-access,no-name-in-module,too-many-lines
|
||||
|
||||
import random
|
||||
from unittest import mock
|
||||
from uuid import uuid4
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.urls import reverse
|
||||
|
||||
import pytest
|
||||
from livekit.api import TwirpError
|
||||
from livekit.api import TwirpError, UpdateParticipantRequest
|
||||
from livekit.protocol.models import ParticipantInfo
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import utils
|
||||
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||
from core.services.lobby import LobbyService
|
||||
|
||||
@@ -31,8 +34,8 @@ def mock_livekit_client():
|
||||
yield mock_client
|
||||
|
||||
|
||||
def test_mute_participant_success(mock_livekit_client):
|
||||
"""Test successful participant muting."""
|
||||
def test_mute_participant_success_as_admin(mock_livekit_client):
|
||||
"""Admins and owners should be able to mute without a LiveKit token."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
@@ -41,10 +44,12 @@ def test_mute_participant_success(mock_livekit_client):
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {"status": "success"}
|
||||
@@ -53,23 +58,131 @@ def test_mute_participant_success(mock_livekit_client):
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
def test_mute_participant_forbidden_without_access():
|
||||
"""Test mute participant returns 403 when user lacks room privileges."""
|
||||
def test_mute_participant_anonymous_no_token_forbidden(mock_livekit_client):
|
||||
"""Should forbid muting when user is anonymous and no LiveKit token."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory() # User without UserResourceAccess
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
mock_livekit_client.room.mute_published_track.assert_not_called()
|
||||
|
||||
|
||||
def test_mute_participant_with_livekit_token_for_this_room(mock_livekit_client):
|
||||
"""Should allow muting when the LiveKit token is scoped to this room."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {"status": "success"}
|
||||
|
||||
mock_livekit_client.room.mute_published_track.assert_called_once()
|
||||
|
||||
|
||||
def test_mute_participant_with_livekit_token_for_another_room_forbidden(
|
||||
mock_livekit_client,
|
||||
):
|
||||
"""Should forbid muting when the LiveKit token is scoped to a different room."""
|
||||
|
||||
client = APIClient()
|
||||
target_room = RoomFactory()
|
||||
other_room = RoomFactory()
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=False)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
mock_livekit_client.room.mute_published_track.assert_not_called()
|
||||
|
||||
|
||||
def test_mute_participant_authenticated_no_role_no_token_forbidden(mock_livekit_client):
|
||||
"""Should forbid muting when user has no room role and no LiveKit token."""
|
||||
client = APIClient()
|
||||
room = RoomFactory() # everyone_can_mute defaults to True
|
||||
user = UserFactory() # no UserResourceAccess for this room
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
mock_livekit_client.room.mute_published_track.assert_not_called()
|
||||
|
||||
|
||||
def test_mute_participant_everyone_can_mute_disabled_blocks_non_admin(
|
||||
mock_livekit_client,
|
||||
):
|
||||
"""Should forbid muting when everyone_can_mute is False, even with a LiveKit token."""
|
||||
client = APIClient()
|
||||
room = RoomFactory(configuration={"everyone_can_mute": False})
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
mock_livekit_client.room.mute_published_track.assert_not_called()
|
||||
|
||||
|
||||
def test_mute_participant_everyone_can_mute_disabled_allows_admin(mock_livekit_client):
|
||||
"""Should allow admins and owners to mute when everyone_can_mute is False."""
|
||||
client = APIClient()
|
||||
room = RoomFactory(configuration={"everyone_can_mute": False})
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
mock_livekit_client.room.mute_published_track.assert_called_once()
|
||||
|
||||
|
||||
def test_mute_participant_invalid_payload():
|
||||
"""Test mute participant with invalid payload."""
|
||||
"""Should reject muting when the payload is invalid."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
@@ -78,16 +191,16 @@ def test_mute_participant_invalid_payload():
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {"participant_identity": "invalid-uuid", "track_sid": ""}
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
response = client.post(
|
||||
url, {"participant_identity": "invalid-uuid", "track_sid": ""}, format="json"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
def test_mute_participant_unexpected_twirp_error(mock_livekit_client):
|
||||
"""Test mute participant when LiveKit API raises TwirpError."""
|
||||
"""Should return 500 when the LiveKit API raises a TwirpError."""
|
||||
client = APIClient()
|
||||
|
||||
mock_livekit_client.room.mute_published_track.side_effect = TwirpError(
|
||||
@@ -101,10 +214,12 @@ def test_mute_participant_unexpected_twirp_error(mock_livekit_client):
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
assert response.data == {"error": "Failed to mute participant"}
|
||||
@@ -112,6 +227,282 @@ def test_mute_participant_unexpected_twirp_error(mock_livekit_client):
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
def test_mute_participant_participant_not_found(mock_livekit_client):
|
||||
"""Should return 404 when the participant does not exist in the room."""
|
||||
client = APIClient()
|
||||
|
||||
mock_livekit_client.room.mute_published_track.side_effect = TwirpError(
|
||||
msg="participant does not exist", code="not_found", status=404
|
||||
)
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_404_NOT_FOUND
|
||||
assert response.data == {"error": "Participant not found"}
|
||||
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
def test_mute_participant_management_exception(mock_livekit_client):
|
||||
"""Should return 500 when ParticipantsManagement raises an unexpected error."""
|
||||
client = APIClient()
|
||||
|
||||
mock_livekit_client.room.mute_published_track.side_effect = TwirpError(
|
||||
msg="boom", code="internal", status=503
|
||||
)
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
assert response.data == {"error": "Failed to mute participant"}
|
||||
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
def test_mute_participant_admin_with_token_for_this_room(mock_livekit_client):
|
||||
"""Should allow muting when user is admin and LiveKit token is scoped to this room."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
# Token identity matches the admin user so LiveKitTokenAuthentication
|
||||
# resolves request.user back to the admin.
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=True)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {"status": "success"}
|
||||
|
||||
mock_livekit_client.room.mute_published_track.assert_called_once()
|
||||
|
||||
|
||||
def test_mute_participant_admin_with_token_for_another_room(mock_livekit_client):
|
||||
"""Should not allow muting when user is admin and the LiveKit token is for another room."""
|
||||
client = APIClient()
|
||||
target_room = RoomFactory()
|
||||
other_room = RoomFactory()
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=target_room,
|
||||
user=user,
|
||||
role=random.choice(["administrator", "owner"]),
|
||||
)
|
||||
# Token is scoped to a DIFFERENT room, and admin status must only be
|
||||
# honored when established via session, never via a LiveKit
|
||||
# token, which can be replayed off-host.
|
||||
token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=True)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.data == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
mock_livekit_client.room.mute_published_track.assert_not_called()
|
||||
|
||||
|
||||
def test_mute_participant_admin_token_replayed_does_not_grant_admin(
|
||||
mock_livekit_client,
|
||||
):
|
||||
"""Should forbid muting when a LiveKit token issued for an admin is passed without a session."""
|
||||
client = APIClient()
|
||||
room = RoomFactory(configuration={"everyone_can_mute": False})
|
||||
admin_user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room,
|
||||
user=admin_user,
|
||||
role=random.choice(["administrator", "owner"]),
|
||||
)
|
||||
# The token is the only credential.
|
||||
token = utils.generate_token(str(room.id), admin_user, is_admin_or_owner=True)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
mock_livekit_client.room.mute_published_track.assert_not_called()
|
||||
|
||||
|
||||
def test_mute_participant_livekit_token_triggers_presence_check(mock_livekit_client):
|
||||
"""Should check participant presence when authenticated via LiveKit token only."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
# Presence is verified against LiveKit before the mute is issued.
|
||||
mock_livekit_client.room.get_participant.assert_called_once()
|
||||
mock_livekit_client.room.mute_published_track.assert_called_once()
|
||||
|
||||
|
||||
def test_mute_participant_livekit_token_presence_check_returns_participant(
|
||||
mock_livekit_client,
|
||||
):
|
||||
"""Should mute when the authentified participant is currently in the room."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
|
||||
# Simulate LiveKit confirming the caller is currently in the room.
|
||||
# State != DISCONNECTED (3) means present.
|
||||
mock_livekit_client.room.get_participant.return_value = ParticipantInfo(
|
||||
identity="caller-identity",
|
||||
state=ParticipantInfo.State.ACTIVE,
|
||||
)
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {"status": "success"}
|
||||
mock_livekit_client.room.get_participant.assert_called_once()
|
||||
mock_livekit_client.room.mute_published_track.assert_called_once()
|
||||
|
||||
|
||||
def test_mute_participant_livekit_token_presence_check_participant_not_found(
|
||||
mock_livekit_client,
|
||||
):
|
||||
"""Should not mute when the authentified participant is not found."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
|
||||
mock_livekit_client.room.get_participant.side_effect = TwirpError(
|
||||
msg="participant does not exist", code="not_found", status=404
|
||||
)
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.data == {"error": "Could not verify caller presence"}
|
||||
mock_livekit_client.room.get_participant.assert_called_once()
|
||||
# The presence check failed, so we never reach the mute call.
|
||||
mock_livekit_client.room.mute_published_track.assert_not_called()
|
||||
|
||||
|
||||
def test_mute_participant_livekit_token_presence_check_twirp_error_forbidden(
|
||||
mock_livekit_client,
|
||||
):
|
||||
"""Should not mute when the presence check fail."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
|
||||
mock_livekit_client.room.get_participant.side_effect = TwirpError(
|
||||
msg="an error occured", code="not_found", status=500
|
||||
)
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.data == {"error": "Could not verify caller presence"}
|
||||
mock_livekit_client.room.get_participant.assert_called_once()
|
||||
# The presence check failed, so we never reach the mute call.
|
||||
mock_livekit_client.room.mute_published_track.assert_not_called()
|
||||
|
||||
|
||||
def test_mute_participant_session_auth_skips_presence_check(mock_livekit_client):
|
||||
"""Should not check presence of the participant when authentified with a session cookie."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
# Session auth has no LiveKit identity to verify against, so the
|
||||
# stop-gap presence check is skipped.
|
||||
mock_livekit_client.room.get_participant.assert_not_called()
|
||||
mock_livekit_client.room.mute_published_track.assert_called_once()
|
||||
|
||||
|
||||
def test_update_participant_success(mock_livekit_client):
|
||||
"""Test successful participant update."""
|
||||
client = APIClient()
|
||||
@@ -130,8 +521,8 @@ def test_update_participant_success(mock_livekit_client):
|
||||
"can_publish": True,
|
||||
"can_publish_data": True,
|
||||
"can_publish_sources": [
|
||||
"CAMERA",
|
||||
"MICROPHONE",
|
||||
"camera",
|
||||
"microphone",
|
||||
],
|
||||
"can_update_metadata": True,
|
||||
"can_subscribe_metrics": True,
|
||||
@@ -158,8 +549,8 @@ def test_update_participant_success(mock_livekit_client):
|
||||
{"can_publish_data": True},
|
||||
{
|
||||
"can_publish_sources": [
|
||||
"CAMERA",
|
||||
"MICROPHONE",
|
||||
"camera",
|
||||
"microphone",
|
||||
]
|
||||
},
|
||||
{"can_update_metadata": True},
|
||||
@@ -190,9 +581,41 @@ def test_update_participant_permission_fields_are_optional(
|
||||
assert response.data == {"status": "success"}
|
||||
|
||||
mock_livekit_client.room.update_participant.assert_called_once()
|
||||
|
||||
(request_arg,), _ = mock_livekit_client.room.update_participant.call_args
|
||||
assert isinstance(request_arg, UpdateParticipantRequest)
|
||||
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
def test_update_participant_permission_fields_invalid_case(mock_livekit_client):
|
||||
"""Should raise bad request when can_publish_sources is uppercase."""
|
||||
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_publish_sources": [
|
||||
"CAMERA",
|
||||
"microphone",
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
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_livekit_client.room.update_participant.assert_not_called()
|
||||
mock_livekit_client.aclose.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value,permission_key",
|
||||
[
|
||||
|
||||
@@ -28,6 +28,7 @@ def test_api_rooms_retrieve_anonymous_private_pk():
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
@@ -47,6 +48,7 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"configuration": {},
|
||||
"access_level": "trusted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
@@ -65,6 +67,7 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
@@ -81,6 +84,7 @@ def test_api_rooms_retrieve_anonymous_private_slug():
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
@@ -97,6 +101,7 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
@@ -200,6 +205,7 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
|
||||
assert response.status_code == 200
|
||||
expected_name = f"{room.id!s}"
|
||||
assert response.json() == {
|
||||
"configuration": {},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
@@ -246,6 +252,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
|
||||
expected_name = f"{room.id!s}"
|
||||
assert response.json() == {
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
@@ -297,6 +304,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
||||
|
||||
expected_name = f"{room.id!s}"
|
||||
assert response.json() == {
|
||||
"configuration": {},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
@@ -338,6 +346,7 @@ def test_api_rooms_retrieve_authenticated():
|
||||
assert response.status_code == 200
|
||||
|
||||
assert response.json() == {
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
@@ -383,6 +392,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
|
||||
expected_name = str(room.id)
|
||||
assert content_dict == {
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
|
||||
@@ -3,12 +3,18 @@ Test rooms API endpoints in the Meet core app: update.
|
||||
"""
|
||||
|
||||
import random
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
from ...models import RoomAccessLevel
|
||||
from ...services.room_management import (
|
||||
RoomManagement,
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -79,12 +85,14 @@ def test_api_rooms_update_members():
|
||||
assert room.configuration == {}
|
||||
|
||||
|
||||
def test_api_rooms_update_administrators():
|
||||
"""Administrators or owners of a room should be allowed to update it."""
|
||||
@patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_administrators(mock_update_metadata):
|
||||
"""Should sync LiveKit metadata when both configuration and access level change."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
access_level=RoomAccessLevel.RESTRICTED,
|
||||
users=[(user, random.choice(["administrator", "owner"]))],
|
||||
configuration={"can_publish_sources": ["camera"]},
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -106,11 +114,120 @@ def test_api_rooms_update_administrators():
|
||||
assert room.access_level == RoomAccessLevel.PUBLIC
|
||||
assert room.configuration == {"can_publish_sources": ["camera", "microphone"]}
|
||||
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
room_name=str(room.id),
|
||||
metadata={
|
||||
"access_level": "public",
|
||||
"configuration": {"can_publish_sources": ["camera", "microphone"]},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_administrators_configuration_only(mock_update_metadata):
|
||||
"""Should sync LiveKit metadata when only configuration changes."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
access_level=RoomAccessLevel.RESTRICTED,
|
||||
users=[(user, random.choice(["administrator", "owner"]))],
|
||||
configuration={},
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
{
|
||||
"name": "New name",
|
||||
"slug": "should-be-ignored",
|
||||
"configuration": {"can_publish_sources": ["camera", "microphone"]},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
room.refresh_from_db()
|
||||
assert room.name == "New name"
|
||||
assert room.slug == "new-name"
|
||||
assert room.access_level == RoomAccessLevel.RESTRICTED
|
||||
assert room.configuration == {"can_publish_sources": ["camera", "microphone"]}
|
||||
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
room_name=str(room.id),
|
||||
metadata={
|
||||
"access_level": "restricted",
|
||||
"configuration": {"can_publish_sources": ["camera", "microphone"]},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_administrators_access_level_only(mock_update_metadata):
|
||||
"""Should sync LiveKit metadata when only access level changes."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
access_level=RoomAccessLevel.RESTRICTED,
|
||||
users=[(user, random.choice(["administrator", "owner"]))],
|
||||
configuration={"can_publish_sources": ["camera"]},
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.put(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
{
|
||||
"name": "New name",
|
||||
"access_level": RoomAccessLevel.PUBLIC,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
room.refresh_from_db()
|
||||
assert room.name == "New name"
|
||||
assert room.slug == "new-name"
|
||||
assert room.access_level == RoomAccessLevel.PUBLIC
|
||||
assert room.configuration == {"can_publish_sources": ["camera"]}
|
||||
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
room_name=str(room.id),
|
||||
metadata={
|
||||
"access_level": "public",
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_administrators_name_only(mock_update_metadata):
|
||||
"""Should not sync LiveKit metadata when neither configuration nor access level changes."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
name="Old name",
|
||||
access_level=RoomAccessLevel.PUBLIC,
|
||||
configuration={"can_publish_sources": ["camera"]},
|
||||
users=[(user, random.choice(["administrator", "owner"]))],
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
{"name": "New name"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
room.refresh_from_db()
|
||||
assert room.name == "New name"
|
||||
assert room.slug == "new-name"
|
||||
# Unrelated fields untouched
|
||||
assert room.access_level == RoomAccessLevel.PUBLIC
|
||||
assert room.configuration == {"can_publish_sources": ["camera"]}
|
||||
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"configuration",
|
||||
[
|
||||
{},
|
||||
{"can_publish_sources": ["camera", "microphone"]},
|
||||
{
|
||||
"can_publish_sources": [
|
||||
@@ -122,12 +239,17 @@ def test_api_rooms_update_administrators():
|
||||
},
|
||||
{"can_publish_sources": []},
|
||||
{"can_publish_sources": None},
|
||||
{"can_publish_sources": None, "everyone_can_mute": True},
|
||||
{"can_publish_sources": None, "everyone_can_mute": False},
|
||||
{"can_publish_sources": None, "everyone_can_mute": "yes"},
|
||||
{"can_publish_sources": None, "everyone_can_mute": "1"},
|
||||
],
|
||||
)
|
||||
def test_api_rooms_update_configuration_valid(configuration):
|
||||
@patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_configuration_valid(mock_update_metadata, configuration):
|
||||
"""Administrators should be allowed to set valid configurations."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
room = RoomFactory(users=[(user, "owner")], configuration={})
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
@@ -140,6 +262,28 @@ def test_api_rooms_update_configuration_valid(configuration):
|
||||
room.refresh_from_db()
|
||||
assert room.configuration == configuration
|
||||
|
||||
mock_update_metadata.assert_called_once()
|
||||
|
||||
|
||||
@patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_configuration_unchanged_empty(mock_update_metadata):
|
||||
"""Should not sync LiveKit metadata when patching an already empty configuration."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")], configuration={})
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
{"configuration": {}},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
room.refresh_from_db()
|
||||
assert room.configuration == {}
|
||||
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
|
||||
def test_api_rooms_update_configuration_extra_keys_rejected():
|
||||
"""Extra keys in configuration should be rejected."""
|
||||
@@ -198,6 +342,24 @@ def test_api_rooms_update_configuration_wrong_type():
|
||||
assert room.configuration == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalid_value", ["test", [], {}])
|
||||
def test_api_rooms_update_configuration_everyone_can_mute_wrong_type(invalid_value):
|
||||
"""everyone_can_mute values with wrong types should be rejected."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
{"configuration": {"everyone_can_mute": invalid_value}},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
room.refresh_from_db()
|
||||
assert room.configuration == {}
|
||||
|
||||
|
||||
def test_api_rooms_update_administrators_of_another():
|
||||
"""
|
||||
Being administrator or owner of a room should not grant authorization to update
|
||||
@@ -217,3 +379,61 @@ def test_api_rooms_update_administrators_of_another():
|
||||
other_room.refresh_from_db()
|
||||
assert other_room.name == "Old name"
|
||||
assert other_room.slug == "old-name"
|
||||
|
||||
|
||||
@patch.object(RoomManagement, "update_metadata", side_effect=RoomNotFoundException)
|
||||
def test_api_rooms_update_livekit_room_not_found(mock_update_metadata):
|
||||
"""Should not fail the API request when the LiveKit room does not exist yet."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
users=[(user, random.choice(["administrator", "owner"]))],
|
||||
configuration={},
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
{"configuration": {"can_publish_sources": ["camera"]}},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
room.refresh_from_db()
|
||||
assert room.configuration == {"can_publish_sources": ["camera"]}
|
||||
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
room_name=str(room.id),
|
||||
metadata={
|
||||
"access_level": room.access_level,
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@patch.object(RoomManagement, "update_metadata", side_effect=RoomManagementException)
|
||||
def test_api_rooms_update_livekit_sync_failure(mock_update_metadata):
|
||||
"""Should not fail the API request when the LiveKit metadata sync fails."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
users=[(user, random.choice(["administrator", "owner"]))],
|
||||
configuration={},
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
{"configuration": {"can_publish_sources": ["camera"]}},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
room.refresh_from_db()
|
||||
assert room.configuration == {"can_publish_sources": ["camera"]}
|
||||
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
room_name=str(room.id),
|
||||
metadata={
|
||||
"access_level": room.access_level,
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
"""Transcribe / summary Shared / Webhook models."""
|
||||
|
||||
from typing import Annotated, Literal, Union
|
||||
|
||||
from pydantic import BaseModel, Field, TypeAdapter
|
||||
|
||||
|
||||
class WordSegment(BaseModel):
|
||||
"""Word segment model for transcription tasks."""
|
||||
|
||||
word: str = Field(title="Word")
|
||||
start: float | None = Field(
|
||||
default=None, title="Start Time", description="Start time in seconds."
|
||||
)
|
||||
end: float | None = Field(
|
||||
default=None, title="End Time", description="End time in seconds."
|
||||
)
|
||||
score: float | None = Field(
|
||||
default=None,
|
||||
title="Confidence Score",
|
||||
description="Confidence score for the word segment.",
|
||||
)
|
||||
speaker: str | None = Field(
|
||||
default=None,
|
||||
title="Speaker",
|
||||
description="Speaker identifier for the word segment.",
|
||||
)
|
||||
|
||||
|
||||
class Segment(BaseModel):
|
||||
"""Segment model for transcription tasks."""
|
||||
|
||||
start: float | None = Field(
|
||||
default=None, title="Start Time", description="Start time in seconds."
|
||||
)
|
||||
end: float | None = Field(
|
||||
default=None, title="End Time", description="End time in seconds."
|
||||
)
|
||||
text: str = Field(
|
||||
title="Segment Text", description="Transcribed text for the segment."
|
||||
)
|
||||
words: tuple[WordSegment, ...] | None = Field(
|
||||
title="Word Segments", description="List of word segments within the segment."
|
||||
)
|
||||
speaker: str | None = Field(
|
||||
default=None, title="Speaker", description="Speaker identifier for the segment."
|
||||
)
|
||||
|
||||
|
||||
class WhisperXResponse(BaseModel):
|
||||
"""Model for WhisperX response."""
|
||||
|
||||
segments: tuple[Segment, ...] = Field(
|
||||
title="Segments", description="List of transcribed segments."
|
||||
)
|
||||
word_segments: tuple[WordSegment, ...] = Field(
|
||||
title="Word Segments", description="List of word segments."
|
||||
)
|
||||
|
||||
|
||||
class BaseWebhook(BaseModel):
|
||||
"""Base webhook payload."""
|
||||
|
||||
job_id: str = Field(
|
||||
title="Job ID",
|
||||
description="The ID of the job document in the receiver system.",
|
||||
)
|
||||
|
||||
|
||||
class TranscribeWebhookSuccessPayload(BaseWebhook):
|
||||
"""Payload for a successful transcription webhook."""
|
||||
|
||||
type: Literal["transcript"] = Field(default="transcript")
|
||||
status: Literal["success"] = Field(default="success")
|
||||
transcription_data_url: str = Field(
|
||||
title="Transcript", description="URL to the raw transcription data."
|
||||
)
|
||||
|
||||
|
||||
class TranscribeWebhookPendingPayload(BaseWebhook):
|
||||
"""Payload for a pending transcription webhook-like response."""
|
||||
|
||||
type: Literal["transcript"] = Field(default="transcript")
|
||||
status: Literal["pending"] = Field(default="pending")
|
||||
|
||||
|
||||
class TranscribeWebhookFailurePayload(BaseWebhook):
|
||||
"""Payload for a failed transcription webhook."""
|
||||
|
||||
type: Literal["transcript"] = Field(default="transcript")
|
||||
status: Literal["failure"] = Field(default="failure")
|
||||
error_code: Literal["unknown_error"] = Field(
|
||||
title="Error code", description="The error code."
|
||||
)
|
||||
|
||||
|
||||
TranscribeWebhookPayloads = Annotated[
|
||||
Union[
|
||||
TranscribeWebhookSuccessPayload,
|
||||
TranscribeWebhookPendingPayload,
|
||||
TranscribeWebhookFailurePayload,
|
||||
],
|
||||
Field(discriminator="status"),
|
||||
]
|
||||
|
||||
|
||||
class SummarizeWebhookSuccessPayload(BaseWebhook):
|
||||
"""Payload for a successful summarization webhook."""
|
||||
|
||||
type: Literal["summary"] = Field(default="summary")
|
||||
status: Literal["success"] = Field(default="success")
|
||||
summary_data_url: str = Field(
|
||||
title="Summary", description="URL to the raw summary data."
|
||||
)
|
||||
|
||||
|
||||
class SummarizeWebhookPendingPayload(BaseWebhook):
|
||||
"""Payload for a pending summarization webhook-like response."""
|
||||
|
||||
type: Literal["summary"] = Field(default="summary")
|
||||
status: Literal["pending"] = Field(default="pending")
|
||||
|
||||
|
||||
class SummarizeWebhookFailurePayload(BaseWebhook):
|
||||
"""Payload for a failed summarization webhook."""
|
||||
|
||||
type: Literal["summary"] = Field(default="summary")
|
||||
status: Literal["failure"] = Field(default="failure")
|
||||
error_code: Literal["unknown_error"] = Field(
|
||||
title="Error code", description="The error code."
|
||||
)
|
||||
|
||||
|
||||
SummarizeWebhookPayloads = Annotated[
|
||||
Union[
|
||||
SummarizeWebhookSuccessPayload,
|
||||
SummarizeWebhookPendingPayload,
|
||||
SummarizeWebhookFailurePayload,
|
||||
],
|
||||
Field(discriminator="status"),
|
||||
]
|
||||
|
||||
WebhookPayloads = Annotated[
|
||||
Union[TranscribeWebhookPayloads, SummarizeWebhookPayloads],
|
||||
Field(discriminator="type"),
|
||||
]
|
||||
|
||||
|
||||
webhook_payload_adapter = TypeAdapter(WebhookPayloads)
|
||||
|
||||
__all__ = [
|
||||
"TranscribeWebhookSuccessPayload",
|
||||
"TranscribeWebhookPendingPayload",
|
||||
"TranscribeWebhookFailurePayload",
|
||||
"SummarizeWebhookSuccessPayload",
|
||||
"SummarizeWebhookPendingPayload",
|
||||
"SummarizeWebhookFailurePayload",
|
||||
"TranscribeWebhookPayloads",
|
||||
"SummarizeWebhookPayloads",
|
||||
"WebhookPayloads",
|
||||
"WhisperXResponse",
|
||||
"webhook_payload_adapter",
|
||||
]
|
||||
@@ -16,7 +16,6 @@ router.register("users", viewsets.UserViewSet, basename="users")
|
||||
router.register("rooms", viewsets.RoomViewSet, basename="rooms")
|
||||
router.register("recordings", viewsets.RecordingViewSet, basename="recordings")
|
||||
router.register("files", viewsets.FileViewSet, basename="files")
|
||||
router.register("ai-jobs", viewsets.AiJobViewSet, basename="ai-jobs")
|
||||
router.register(
|
||||
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
|
||||
)
|
||||
|
||||
@@ -121,7 +121,11 @@ def generate_token(
|
||||
.with_identity(identity)
|
||||
.with_name(username or default_username)
|
||||
.with_attributes(
|
||||
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
|
||||
{
|
||||
"color": color,
|
||||
"room_admin": "true" if is_admin_or_owner else "false",
|
||||
"is_authenticated": not user.is_anonymous,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -455,38 +459,3 @@ def generate_upload_policy(file):
|
||||
)
|
||||
|
||||
return policy
|
||||
|
||||
|
||||
def generate_download_s3_file_url(
|
||||
key, *, expires_in: int, override_domain: bool = True
|
||||
):
|
||||
"""
|
||||
Generate a S3 signed download url for a given key.
|
||||
"""
|
||||
|
||||
# This settings should be used if the backend application and the frontend application
|
||||
# can't connect to the object storage with the same domain. This is the case in the
|
||||
# docker compose stack used in development. The frontend application will use localhost
|
||||
# to connect to the object storage while the backend application will use the object storage
|
||||
# service name declared in the docker compose stack.
|
||||
# This is needed because the domain name is used to compute the signature. So it can't be
|
||||
# changed dynamically by the frontend application.
|
||||
if settings.AWS_S3_DOMAIN_REPLACE and override_domain:
|
||||
s3_client = boto3.client(
|
||||
"s3",
|
||||
aws_access_key_id=settings.AWS_S3_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=settings.AWS_S3_SECRET_ACCESS_KEY,
|
||||
endpoint_url=settings.AWS_S3_DOMAIN_REPLACE,
|
||||
config=botocore.client.Config(
|
||||
region_name=settings.AWS_S3_REGION_NAME,
|
||||
signature_version=settings.AWS_S3_SIGNATURE_VERSION,
|
||||
),
|
||||
)
|
||||
else:
|
||||
s3_client = default_storage.connection.meta.client
|
||||
|
||||
return s3_client.generate_presigned_url(
|
||||
ClientMethod="get_object",
|
||||
Params={"Bucket": default_storage.bucket_name, "Key": key},
|
||||
ExpiresIn=expires_in,
|
||||
)
|
||||
|
||||
@@ -19,7 +19,6 @@ from socket import gethostbyname, gethostname
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import dj_database_url
|
||||
import posthog
|
||||
import sentry_sdk
|
||||
from configurations import Configuration, values
|
||||
from lasuite.configuration.values import SecretFileValue
|
||||
@@ -452,11 +451,6 @@ class Base(Configuration):
|
||||
CELERY_BROKER_URL = values.Value("redis://redis:6379/0", environ_prefix=None)
|
||||
CELERY_BROKER_TRANSPORT_OPTIONS = values.DictValue({}, environ_prefix=None)
|
||||
|
||||
# Analytics
|
||||
POSTHOG_ENABLED = values.BooleanValue(False, environ_prefix=None)
|
||||
POSTHOG_API_KEY = values.Value(None, environ_prefix=None)
|
||||
POSTHOG_API_HOST = values.Value(None, environ_prefix=None)
|
||||
|
||||
# Session
|
||||
SESSION_ENGINE = values.Value(
|
||||
default="django.contrib.sessions.backends.cache",
|
||||
@@ -750,20 +744,6 @@ class Base(Configuration):
|
||||
SUMMARY_SERVICE_API_TOKEN = SecretFileValue(
|
||||
None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None
|
||||
)
|
||||
DOCS_BASE_URL = values.Value(
|
||||
"https://example.com",
|
||||
environ_name="DOCS_BASE_URL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
DOCS_SERVER_TO_SERVER_API_KEY = SecretFileValue(
|
||||
None,
|
||||
environ_name="DOCS_SERVER_TO_SERVER_API_KEY",
|
||||
environ_prefix=None,
|
||||
)
|
||||
TRANSCRIPTION_DEFAULT_LANGUAGE = values.Value(
|
||||
default="fr", environ_name="TRANSCRIPTION_DEFAULT_LANGUAGE", environ_prefix=None
|
||||
)
|
||||
|
||||
SCREEN_RECORDING_BASE_URL = values.Value(
|
||||
None, environ_name="SCREEN_RECORDING_BASE_URL", environ_prefix=None
|
||||
)
|
||||
|
||||
@@ -62,7 +62,6 @@ dependencies = [
|
||||
"livekit-api==1.1.0",
|
||||
"aiohttp==3.13.4",
|
||||
"urllib3==2.7.0",
|
||||
"posthog>=7.14.2",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
Generated
-35
@@ -148,15 +148,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "backoff"
|
||||
version = "2.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "billiard"
|
||||
version = "4.2.4"
|
||||
@@ -568,15 +559,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/77/dc8c558f7593132cf8fefec57c4f60c83b16941c574ac5f619abb3ae7933/dill-0.4.1-py3-none-any.whl", hash = "sha256:1e1ce33e978ae97fcfcff5638477032b801c46c7c65cf717f95fbc2248f79a9d", size = 120019, upload-time = "2026-01-19T02:36:55.663Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "distro"
|
||||
version = "1.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dj-database-url"
|
||||
version = "3.1.2"
|
||||
@@ -1222,7 +1204,6 @@ dependencies = [
|
||||
{ name = "markdown" },
|
||||
{ name = "mozilla-django-oidc" },
|
||||
{ name = "nested-multipart-parser" },
|
||||
{ name = "posthog" },
|
||||
{ name = "psycopg", extra = ["binary"] },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyjwt" },
|
||||
@@ -1285,7 +1266,6 @@ requires-dist = [
|
||||
{ name = "markdown", specifier = "==3.10.2" },
|
||||
{ name = "mozilla-django-oidc", specifier = "==5.0.2" },
|
||||
{ name = "nested-multipart-parser", specifier = "==1.6.0" },
|
||||
{ name = "posthog", specifier = ">=7.14.2" },
|
||||
{ name = "psycopg", extras = ["binary"], specifier = "==3.3.3" },
|
||||
{ name = "pydantic", specifier = "==2.12.5" },
|
||||
{ name = "pyjwt", specifier = "==2.12.1" },
|
||||
@@ -1526,21 +1506,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "posthog"
|
||||
version = "7.14.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "backoff" },
|
||||
{ name = "distro" },
|
||||
{ name = "requests" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ca/e6/fe25f9eaeb5b4b66aa738554ba1ef9feece8b1d5b6a9ea431782b6c6c58f/posthog-7.14.2.tar.gz", hash = "sha256:b913dc23acc301a95ca9b851c193b261932d01a66a9af91eb6e9883cd05d5b6b", size = 205633, upload-time = "2026-05-13T16:36:27.153Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/b8/75f9eed446c1d48405871542b36fdf1dba3333756a477bcb0d9ef70d17a4/posthog-7.14.2-py3-none-any.whl", hash = "sha256:f78b45a5ad5c72e55bf1cadebe8cf46bae2a6094e7fe01cbc7b5ec0531aab4d8", size = 240750, upload-time = "2026-05-13T16:36:25.544Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pprintpp"
|
||||
version = "0.4.0"
|
||||
|
||||
@@ -2,6 +2,8 @@ import { fetchApi } from './fetchApi'
|
||||
import { keys } from './queryKeys'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { RecordingMode } from '@/features/recording'
|
||||
import { Track } from 'livekit-client'
|
||||
import Source = Track.Source
|
||||
|
||||
export interface ApiConfig {
|
||||
analytics?: {
|
||||
@@ -50,7 +52,7 @@ export interface ApiConfig {
|
||||
url: string
|
||||
force_wss_protocol: boolean
|
||||
enable_firefox_proxy_workaround: boolean
|
||||
default_sources: string[]
|
||||
default_sources: Source[]
|
||||
}
|
||||
transcription_destination?: string
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { Track } from 'livekit-client'
|
||||
import Source = Track.Source
|
||||
|
||||
export type ApiLiveKit = {
|
||||
url: string
|
||||
room: string
|
||||
@@ -10,6 +13,11 @@ export enum ApiAccessLevel {
|
||||
RESTRICTED = 'restricted',
|
||||
}
|
||||
|
||||
export type RoomConfiguration = {
|
||||
can_publish_sources?: Source[] | null
|
||||
everyone_can_mute?: boolean | null
|
||||
}
|
||||
|
||||
export type ApiRoom = {
|
||||
id: string
|
||||
name: string
|
||||
@@ -18,7 +26,5 @@ export type ApiRoom = {
|
||||
is_administrable: boolean
|
||||
access_level: ApiAccessLevel
|
||||
livekit?: ApiLiveKit
|
||||
configuration?: {
|
||||
[key: string]: string | number | boolean | string[]
|
||||
}
|
||||
configuration?: RoomConfiguration
|
||||
}
|
||||
|
||||
@@ -6,44 +6,74 @@ import {
|
||||
NotificationType,
|
||||
} from '@/features/notifications'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { useIsAdminOrOwner } from '../livekit/hooks/useIsAdminOrOwner'
|
||||
|
||||
import { useCallback } from 'react'
|
||||
|
||||
export const useMuteParticipant = () => {
|
||||
const data = useRoomData()
|
||||
|
||||
const apiRoomData = useRoomData()
|
||||
const { notifyParticipants } = useNotifyParticipants()
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
|
||||
const muteParticipant = async (participant: Participant) => {
|
||||
if (!data?.id) {
|
||||
throw new Error('Room id is not available')
|
||||
}
|
||||
const trackSid = participant.getTrackPublication(
|
||||
Source.Microphone
|
||||
)?.trackSid
|
||||
const muteParticipant = useCallback(
|
||||
async (participant: Participant) => {
|
||||
if (!apiRoomData?.livekit?.room) {
|
||||
throw new Error('Room id is not available')
|
||||
}
|
||||
|
||||
if (!trackSid) {
|
||||
return
|
||||
}
|
||||
const trackSid = participant.getTrackPublication(
|
||||
Source.Microphone
|
||||
)?.trackSid
|
||||
|
||||
try {
|
||||
const response = await fetchApi(`rooms/${data.id}/mute-participant/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
participant_identity: participant.identity,
|
||||
track_sid: trackSid,
|
||||
}),
|
||||
})
|
||||
if (!trackSid) {
|
||||
return
|
||||
}
|
||||
|
||||
await notifyParticipants({
|
||||
type: NotificationType.ParticipantMuted,
|
||||
destinationIdentities: [participant.identity],
|
||||
})
|
||||
// Guard against undefined token for non-admin users
|
||||
if (!isAdminOrOwner && !apiRoomData.livekit.token) {
|
||||
console.error('Cannot mute participant: missing auth token')
|
||||
return
|
||||
}
|
||||
|
||||
const headers = !isAdminOrOwner
|
||||
? { Authorization: `Bearer ${apiRoomData.livekit.token}` }
|
||||
: undefined
|
||||
|
||||
let response
|
||||
try {
|
||||
response = await fetchApi(
|
||||
`rooms/${apiRoomData.livekit.room}/mute-participant/`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
participant_identity: participant.identity,
|
||||
track_sid: trackSid,
|
||||
}),
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await notifyParticipants({
|
||||
type: NotificationType.ParticipantMuted,
|
||||
destinationIdentities: [participant.identity],
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Failed to notify muted participant ${participant.identity}: ${e}`
|
||||
)
|
||||
}
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
[apiRoomData, isAdminOrOwner, notifyParticipants]
|
||||
)
|
||||
|
||||
return { muteParticipant }
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ export const useParticipantPermissions = () => {
|
||||
|
||||
const updateParticipantPermissions = async (
|
||||
participant: Participant,
|
||||
sources: Array<Source>
|
||||
sources: Source[]
|
||||
) => {
|
||||
if (!data?.id) {
|
||||
throw new Error('Room id is not available')
|
||||
@@ -20,7 +20,7 @@ export const useParticipantPermissions = () => {
|
||||
can_update_metadata: participant.permissions?.canUpdateMetadata,
|
||||
can_subscribe_metrics: participant.permissions?.canSubscribeMetrics,
|
||||
can_publish: sources.length > 0,
|
||||
can_publish_sources: sources.map((source) => source.toUpperCase()),
|
||||
can_publish_sources: sources,
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -9,7 +9,8 @@ import { queryClient } from '@/api/queryClient'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useParams } from 'wouter'
|
||||
import { usePublishSourcesManager } from '@/features/rooms/livekit/hooks/usePublishSourcesManager'
|
||||
import { usePublishSourcesManager } from '../hooks/usePublishSourcesManager'
|
||||
import { usePermissionsManager } from '../hooks/usePermissionsManager'
|
||||
|
||||
export const Admin = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
|
||||
@@ -38,6 +39,8 @@ export const Admin = () => {
|
||||
isScreenShareEnabled,
|
||||
} = usePublishSourcesManager()
|
||||
|
||||
const { toggleMuting, isMutingEnabled } = usePermissionsManager()
|
||||
|
||||
return (
|
||||
<Div
|
||||
display="flex"
|
||||
@@ -130,6 +133,17 @@ export const Admin = () => {
|
||||
fullWidth: true,
|
||||
}}
|
||||
/>
|
||||
<Field
|
||||
type="switch"
|
||||
label={t('moderation.mute.label')}
|
||||
description={t('moderation.mute.description')}
|
||||
isSelected={isMutingEnabled}
|
||||
onChange={toggleMuting}
|
||||
wrapperProps={{
|
||||
noMargin: true,
|
||||
fullWidth: true,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { useIsAdminOrOwner } from './useIsAdminOrOwner'
|
||||
import { Participant } from 'livekit-client'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
|
||||
export const useCanMute = (participant: Participant) => {
|
||||
const apiRoomData = useRoomData()
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
return participant.isLocal || isAdminOrOwner
|
||||
return (
|
||||
participant.isLocal ||
|
||||
isAdminOrOwner ||
|
||||
apiRoomData?.configuration?.everyone_can_mute !== false
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { usePatchRoom } from '@/features/rooms/api/patchRoom'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { useCallback } from 'react'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
|
||||
export const usePermissionsManager = () => {
|
||||
const { mutateAsync: patchRoom } = usePatchRoom()
|
||||
|
||||
const data = useRoomData()
|
||||
const configuration = data?.configuration
|
||||
const roomId = data?.slug
|
||||
|
||||
const isMutingEnabled = configuration?.everyone_can_mute ?? true
|
||||
|
||||
const toggleMuting = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
if (!roomId) return
|
||||
|
||||
try {
|
||||
const newConfiguration = {
|
||||
...configuration,
|
||||
everyone_can_mute: enabled,
|
||||
}
|
||||
|
||||
const room = await patchRoom({
|
||||
roomId,
|
||||
room: { configuration: newConfiguration },
|
||||
})
|
||||
|
||||
queryClient.setQueryData([keys.room, roomId], room)
|
||||
|
||||
return { configuration: newConfiguration }
|
||||
} catch (error) {
|
||||
console.error('Failed to update muting permission:', error)
|
||||
return { success: false, error }
|
||||
}
|
||||
},
|
||||
[configuration, roomId, patchRoom]
|
||||
)
|
||||
|
||||
return {
|
||||
toggleMuting,
|
||||
isMutingEnabled,
|
||||
}
|
||||
}
|
||||
@@ -39,10 +39,6 @@ export const usePublishSourcesManager = () => {
|
||||
|
||||
const { notifyParticipants } = useNotifyParticipants()
|
||||
|
||||
const defaultSources = configData?.livekit?.default_sources?.map((source) => {
|
||||
return source as Source
|
||||
})
|
||||
|
||||
// The name can be misleading—use the slug instead to ensure the correct React Query key is updated.
|
||||
const roomId = data?.slug
|
||||
|
||||
@@ -54,16 +50,16 @@ export const usePublishSourcesManager = () => {
|
||||
)
|
||||
|
||||
const currentSources = useMemo(() => {
|
||||
const defaultSources = configData?.livekit?.default_sources ?? []
|
||||
|
||||
if (
|
||||
configuration?.can_publish_sources == undefined ||
|
||||
!Array.isArray(configuration?.can_publish_sources)
|
||||
) {
|
||||
return defaultSources
|
||||
}
|
||||
return configuration.can_publish_sources.map((source) => {
|
||||
return source as Source
|
||||
})
|
||||
}, [defaultSources, configuration?.can_publish_sources])
|
||||
return configuration.can_publish_sources
|
||||
}, [configData, configuration?.can_publish_sources])
|
||||
|
||||
const updateSource = useCallback(
|
||||
async (sources: Source[], enabled: boolean) => {
|
||||
@@ -78,7 +74,7 @@ export const usePublishSourcesManager = () => {
|
||||
|
||||
const newConfiguration = {
|
||||
...configuration,
|
||||
can_publish_sources: newSources as string[],
|
||||
can_publish_sources: newSources,
|
||||
}
|
||||
|
||||
const room = await patchRoom({
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// features/rooms/hooks/useSyncLiveKitMetadata.ts
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
import { queryClient } from '@/api/queryClient'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import {
|
||||
ApiAccessLevel,
|
||||
ApiRoom,
|
||||
RoomConfiguration,
|
||||
} from '@/features/rooms/api/ApiRoom'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { useRoomData } from './useRoomData'
|
||||
|
||||
/**
|
||||
* Shape of the LiveKit room metadata blob pushed by the backend.
|
||||
* Matches RoomManagement.update_metadata → {"configuration": room.configuration}
|
||||
*/
|
||||
type RoomLiveKitMetadata = {
|
||||
configuration?: RoomConfiguration
|
||||
access_level?: ApiAccessLevel
|
||||
}
|
||||
|
||||
const parseMetadata = (raw: string | undefined): RoomLiveKitMetadata | null => {
|
||||
if (!raw) return null
|
||||
try {
|
||||
return JSON.parse(raw) as RoomLiveKitMetadata
|
||||
} catch {
|
||||
console.warn('useSyncLiveKitMetadata: failed to parse room metadata')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync LiveKit room metadata into the React Query cache.
|
||||
*
|
||||
* The backend pushes room configuration into LiveKit's room metadata
|
||||
* whenever it changes. This hook listens for those changes and patches
|
||||
* the ApiRoom cache so every `useRoomData()`
|
||||
* consumer sees the fresh value automatically.
|
||||
*
|
||||
* Mount once, at the level where the LiveKit Room instance lives.
|
||||
*/
|
||||
export const useSyncLiveKitMetadata = () => {
|
||||
const room = useRoomContext()
|
||||
const roomData = useRoomData()
|
||||
const roomSlug = roomData?.slug
|
||||
|
||||
useEffect(() => {
|
||||
if (!room || !roomSlug) return
|
||||
|
||||
const applyMetadata = (raw: string | undefined) => {
|
||||
const parsed = parseMetadata(raw)
|
||||
if (!parsed) return
|
||||
|
||||
queryClient.setQueryData<ApiRoom>([keys.room, roomSlug], (prev) => {
|
||||
if (!prev) return prev
|
||||
const nextConfiguration = parsed.configuration ?? prev.configuration
|
||||
const nextAccessLevel = parsed.access_level ?? prev.access_level
|
||||
if (
|
||||
nextConfiguration === prev.configuration &&
|
||||
nextAccessLevel === prev.access_level
|
||||
) {
|
||||
return prev
|
||||
}
|
||||
|
||||
return {
|
||||
...prev,
|
||||
configuration: nextConfiguration,
|
||||
access_level: nextAccessLevel,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Apply whatever metadata is currently set (covers the case where we
|
||||
// joined the room AFTER the last metadata change, so no event will fire).
|
||||
applyMetadata(room.metadata)
|
||||
|
||||
const handler = (raw: string) => applyMetadata(raw)
|
||||
room.on(RoomEvent.RoomMetadataChanged, handler)
|
||||
|
||||
return () => {
|
||||
room.off(RoomEvent.RoomMetadataChanged, handler)
|
||||
}
|
||||
}, [room, roomSlug])
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKey
|
||||
import { useSettingsDialog } from '@/features/settings'
|
||||
import { SettingsDialogExtendedKey } from '@/features/settings/type'
|
||||
import { useVideoResolutionSubscription } from '../hooks/useVideoResolutionSubscription'
|
||||
import { useSyncLiveKitMetadata } from '../hooks/useSyncLiveKitMetadata'
|
||||
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
|
||||
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
|
||||
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
||||
@@ -90,6 +91,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
useConnectionObserver()
|
||||
useRoomPageTitle()
|
||||
useVideoResolutionSubscription()
|
||||
useSyncLiveKitMetadata()
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
id: 'open-shortcuts',
|
||||
|
||||
@@ -528,6 +528,10 @@
|
||||
"screenshare": {
|
||||
"label": "Bildschirm teilen",
|
||||
"description": "Wenn du diese Option deaktivierst, können Teilnehmende ihren Bildschirm nicht mehr teilen. Laufende Bildschirmfreigaben werden sofort beendet."
|
||||
},
|
||||
"mute": {
|
||||
"label": "Andere stummschalten",
|
||||
"description": "Wenn deaktiviert, können Teilnehmer andere Teilnehmer nicht mehr stummschalten."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -527,6 +527,10 @@
|
||||
"screenshare": {
|
||||
"label": "Share their screen",
|
||||
"description": "Disabling this option will prevent participants from sharing their screen, and any ongoing screen sharing will be stopped immediately."
|
||||
},
|
||||
"mute": {
|
||||
"label": "Mute others",
|
||||
"description": "When disabled, participants will no longer be able to mute other participants."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -527,6 +527,10 @@
|
||||
"screenshare": {
|
||||
"label": "Partager leur écran",
|
||||
"description": "En désactivant cette option, les participants ne pourront plus partager leur écran et tout partage en cours sera immédiatement interrompu."
|
||||
},
|
||||
"mute": {
|
||||
"label": "Muter les autres",
|
||||
"description": "En désactivant cette option, les participants ne pourront plus muter d'autres participants."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -527,6 +527,10 @@
|
||||
"screenshare": {
|
||||
"label": "Hun scherm delen",
|
||||
"description": "Als u deze optie uitschakelt, kunnen deelnemers hun scherm niet meer delen en wordt elke lopende schermdeling onmiddellijk gestopt."
|
||||
},
|
||||
"mute": {
|
||||
"label": "Anderen dempen",
|
||||
"description": "Wanneer uitgeschakeld, kunnen deelnemers andere deelnemers niet meer dempen."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -59,7 +59,7 @@ export const CAPTION_FONT_COLOR_VALUES: Record<CaptionColor, string> = {
|
||||
}
|
||||
|
||||
export const CAPTION_BACKGROUND_COLOR_VALUES: Record<CaptionColor, string> = {
|
||||
default: 'rgba(0, 0, 0, 0.75)',
|
||||
default: 'transparent',
|
||||
black: 'rgba(0, 0, 0, 0.75)',
|
||||
white: 'rgba(255, 255, 255, 0.75)',
|
||||
blue: 'rgba(0, 0, 255, 0.75)',
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from summary.api.route import tasks_v2
|
||||
from summary.api.route import tasks, tasks_v2
|
||||
from summary.core.security import verify_tenant_api_key
|
||||
|
||||
api_router_v1 = APIRouter(dependencies=[Depends(verify_tenant_api_key)])
|
||||
api_router_v1.include_router(tasks.router_tasks_v1, tags=["tasks"])
|
||||
|
||||
api_router_v2 = APIRouter(dependencies=[Depends(verify_tenant_api_key)])
|
||||
api_router_v2.include_router(tasks_v2.router_tasks_v2, tags=["tasks"])
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""API routes related to application tasks."""
|
||||
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from celery.result import AsyncResult
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
from summary.core.celery_worker import (
|
||||
process_audio_transcribe_summarize_v2,
|
||||
)
|
||||
from summary.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
class TranscribeSummarizeTaskCreation(BaseModel):
|
||||
"""Transcription and summarization parameters."""
|
||||
|
||||
owner_id: str
|
||||
recording_filename: str
|
||||
metadata_filename: Optional[str] = None
|
||||
email: str
|
||||
sub: str
|
||||
version: Optional[int] = 2
|
||||
room: Optional[str]
|
||||
owner_timezone: Optional[str]
|
||||
language: Optional[str]
|
||||
download_link: Optional[str]
|
||||
context_language: Optional[str] = None
|
||||
recording_start_at: Optional[str] = None
|
||||
recording_end_at: Optional[str] = None
|
||||
|
||||
@field_validator("language")
|
||||
@classmethod
|
||||
def validate_language(cls, v):
|
||||
"""Validate 'language' parameter."""
|
||||
if v is not None and v not in settings.whisperx_allowed_languages:
|
||||
raise ValueError(
|
||||
f"Language '{v}' is not allowed. "
|
||||
f"Allowed languages: {', '.join(settings.whisperx_allowed_languages)}"
|
||||
)
|
||||
return v
|
||||
|
||||
|
||||
router_tasks_v1 = APIRouter(prefix="/tasks")
|
||||
|
||||
|
||||
@router_tasks_v1.post("/")
|
||||
async def create_transcribe_summarize_task(request: TranscribeSummarizeTaskCreation):
|
||||
"""Create a transcription and summarization task."""
|
||||
task = process_audio_transcribe_summarize_v2.apply_async(
|
||||
args=[
|
||||
request.owner_id,
|
||||
request.recording_filename,
|
||||
request.metadata_filename,
|
||||
request.email,
|
||||
request.sub,
|
||||
time.time(),
|
||||
request.room,
|
||||
request.owner_timezone,
|
||||
request.language,
|
||||
request.download_link,
|
||||
request.context_language,
|
||||
request.recording_start_at,
|
||||
request.recording_end_at,
|
||||
],
|
||||
queue=settings.transcribe_queue,
|
||||
)
|
||||
|
||||
return {"id": task.id, "message": "Task created"}
|
||||
|
||||
|
||||
@router_tasks_v1.get("/{task_id}")
|
||||
async def get_task_status(task_id: str):
|
||||
"""Check task status by ID."""
|
||||
task = AsyncResult(task_id)
|
||||
return {"id": task_id, "status": task.status}
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import openai
|
||||
import sentry_sdk
|
||||
@@ -15,8 +16,8 @@ from summary.core.analytics import MetadataManager, get_analytics
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.file_service import FileService, FileServiceException
|
||||
from summary.core.llm_service import LLMException, LLMObservability, LLMService
|
||||
from summary.core.locales import get_locale
|
||||
from summary.core.models import (
|
||||
RecordingMetadata,
|
||||
SummarizeTaskV2Payload,
|
||||
TranscribeTaskV2Payload,
|
||||
)
|
||||
@@ -38,9 +39,11 @@ from summary.core.shared_models import (
|
||||
WhisperXResponse,
|
||||
webhook_payload_adapter,
|
||||
)
|
||||
from summary.core.transcript_formatter import TranscriptFormatter
|
||||
from summary.core.user_assign import resolve_speaker_identities
|
||||
from summary.core.webhook_service import (
|
||||
call_webhook_v2,
|
||||
submit_content,
|
||||
)
|
||||
|
||||
settings = get_settings()
|
||||
@@ -76,17 +79,23 @@ file_service = FileService()
|
||||
def transcribe_audio(
|
||||
*,
|
||||
task_id: str,
|
||||
recording_filename: str | None = None,
|
||||
language: str,
|
||||
cloud_storage_url: str,
|
||||
cloud_storage_url=None,
|
||||
raises: bool = False,
|
||||
):
|
||||
"""Transcribe an audio file using WhisperX.
|
||||
|
||||
Downloads the audio from a cloud storage URL, sends it to
|
||||
Downloads the audio from MinIO or a cloud storage URL, sends it to
|
||||
WhisperX for transcription, and tracks metadata throughout the process.
|
||||
|
||||
Returns the transcription object, or None if the file could not be retrieved.
|
||||
"""
|
||||
if bool(recording_filename) == bool(cloud_storage_url):
|
||||
raise ValueError(
|
||||
"Either filename or cloud_storage_url must be provided, but not both."
|
||||
)
|
||||
|
||||
logger.info("Initiating WhisperX client")
|
||||
whisperx_client = openai.OpenAI(
|
||||
api_key=settings.whisperx_api_key.get_secret_value(),
|
||||
@@ -97,6 +106,7 @@ def transcribe_audio(
|
||||
# Transcription
|
||||
try:
|
||||
with file_service.prepare_audio_file(
|
||||
remote_object_key=recording_filename,
|
||||
cloud_storage_url=cloud_storage_url,
|
||||
) as (audio_file, metadata):
|
||||
metadata_manager.track(task_id, {"audio_length": metadata["duration"]})
|
||||
@@ -140,8 +150,10 @@ def transcribe_audio(
|
||||
)
|
||||
logger.exception(
|
||||
(
|
||||
"Unexpected error while preparing file %s "
|
||||
"Unexpected error while preparing file | filename: %s "
|
||||
"| cloud_storage_url: %s"
|
||||
),
|
||||
recording_filename,
|
||||
redacted_cloud_storage_url,
|
||||
)
|
||||
return None
|
||||
@@ -151,31 +163,41 @@ def transcribe_audio(
|
||||
|
||||
|
||||
def resolve_speaker_identities_and_apply_to(
|
||||
*, transcription: WhisperXResponse, recording_metadata: RecordingMetadata, task_id
|
||||
) -> WhisperXResponse:
|
||||
transcription, recording_start_at, recording_end_at, metadata_filename, task_id
|
||||
):
|
||||
"""Assign users to detected speakers and rewrite the transcriptions.
|
||||
|
||||
Args:
|
||||
transcription: output of meet-whisperx after transcription and diarization
|
||||
recording_metadata: Metadata of the recording
|
||||
recording_start_at: sourced from LiveKit FileInfo via the egress_ended webhook
|
||||
recording_end_at: sourced from LiveKit FileInfo via the egress_ended webhook
|
||||
metadata_filename: name of metadata file containing VAD information in S3
|
||||
task_id: current task id, for logging purposes
|
||||
"""
|
||||
recording_start_dt = (
|
||||
datetime.fromisoformat(recording_start_at) if recording_start_at else None
|
||||
)
|
||||
recording_end_dt = (
|
||||
datetime.fromisoformat(recording_end_at) if recording_end_at else None
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
"recording_start_dt: %s ; recording_end_dt: %s",
|
||||
recording_metadata.start_at,
|
||||
recording_metadata.end_at,
|
||||
recording_start_dt,
|
||||
recording_end_dt,
|
||||
)
|
||||
if (recording_start_dt is None) or (recording_end_dt is None):
|
||||
logger.debug("Skipping resolve_speaker_identities")
|
||||
return transcription
|
||||
|
||||
logger.debug("Running resolve_speaker_identities")
|
||||
try:
|
||||
metadata = file_service.read_cloud_storage_json(
|
||||
recording_metadata.cloud_storage_url
|
||||
)
|
||||
metadata = file_service.read_json(metadata_filename)
|
||||
speaker_mapping = resolve_speaker_identities(
|
||||
metadata,
|
||||
transcription,
|
||||
recording_metadata.start_at,
|
||||
recording_metadata.end_at,
|
||||
recording_start_dt,
|
||||
recording_end_dt,
|
||||
)
|
||||
new_transcription = speaker_mapping.apply_to(transcription.model_dump())
|
||||
return new_transcription
|
||||
@@ -199,6 +221,34 @@ def resolve_speaker_identities_and_apply_to(
|
||||
return transcription
|
||||
|
||||
|
||||
def format_transcript(
|
||||
transcription,
|
||||
context_language: str | None,
|
||||
language: str,
|
||||
room: str | None,
|
||||
recording_datetime: str | None,
|
||||
owner_timezone: str | None,
|
||||
download_link: str | None,
|
||||
) -> tuple[str, str]:
|
||||
"""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)
|
||||
|
||||
return formatter.format(
|
||||
transcription,
|
||||
room=room,
|
||||
recording_datetime=recording_datetime,
|
||||
owner_timezone=owner_timezone,
|
||||
download_link=download_link,
|
||||
)
|
||||
|
||||
|
||||
def format_actions(llm_output: dict) -> str:
|
||||
"""Format the actions from the LLM output into a markdown list.
|
||||
|
||||
@@ -217,23 +267,126 @@ def format_actions(llm_output: dict) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
# @signals.task_prerun.connect(sender=process_audio_transcribe_summarize_v2)
|
||||
# def task_started(task_id=None, task=None, args=None, **kwargs):
|
||||
# """Signal handler called before task execution begins."""
|
||||
# task_args = args or []
|
||||
# metadata_manager.create(task_id, task_args)
|
||||
#
|
||||
#
|
||||
# @signals.task_retry.connect(sender=process_audio_transcribe_summarize_v2)
|
||||
# def task_retry_handler(request=None, reason=None, einfo=None, **kwargs):
|
||||
# """Signal handler called when task execution retries."""
|
||||
# metadata_manager.retry(request.id)
|
||||
#
|
||||
#
|
||||
# @signals.task_failure.connect(sender=process_audio_transcribe_summarize_v2)
|
||||
# def task_failure_handler(task_id, exception=None, **kwargs):
|
||||
# """Signal handler called when task execution fails permanently."""
|
||||
# metadata_manager.capture(task_id, settings.posthog_event_failure)
|
||||
@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,
|
||||
recording_filename: str,
|
||||
metadata_filename: str | None,
|
||||
email: str,
|
||||
sub: str,
|
||||
received_at: float,
|
||||
room: str | None,
|
||||
owner_timezone: str | None,
|
||||
language: str | None,
|
||||
download_link: str | None,
|
||||
context_language: str | None = None,
|
||||
recording_start_at: str | None = None,
|
||||
recording_end_at: str | None = 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.
|
||||
recording_filename: Name of the audio file in MinIO storage.
|
||||
metadata_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.
|
||||
owner_timezone: IANA timezone of the recording owner (e.g. "Europe/Paris").
|
||||
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.
|
||||
recording_start_at: ISO 8601 timestamp of when file recording actually started
|
||||
(from LiveKit FileInfo.started_at via the egress_ended webhook).
|
||||
recording_end_at: ISO 8601 timestamp of when file recording ended
|
||||
(from LiveKit FileInfo.ended_at via the egress_ended webhook).
|
||||
"""
|
||||
logger.info(
|
||||
"Notification received | Owner: %s | Room: %s",
|
||||
owner_id,
|
||||
room,
|
||||
)
|
||||
|
||||
task_id = self.request.id
|
||||
|
||||
# Transcribe the audio
|
||||
transcription = transcribe_audio(
|
||||
task_id=task_id, recording_filename=recording_filename, language=language
|
||||
)
|
||||
if transcription is None:
|
||||
return
|
||||
|
||||
# Assign speakers and rewrite transcription/diarization output
|
||||
if settings.is_resolve_speaker_identities_enabled and (
|
||||
metadata_filename is not None
|
||||
):
|
||||
transcription = resolve_speaker_identities_and_apply_to(
|
||||
transcription,
|
||||
recording_start_at,
|
||||
recording_end_at,
|
||||
metadata_filename,
|
||||
task_id,
|
||||
)
|
||||
|
||||
# Format output
|
||||
content, title = format_transcript(
|
||||
transcription,
|
||||
context_language,
|
||||
language,
|
||||
room,
|
||||
recording_start_at,
|
||||
owner_timezone,
|
||||
download_link,
|
||||
)
|
||||
|
||||
submit_content(content, title, email, sub)
|
||||
metadata_manager.capture(task_id, settings.posthog_event_success)
|
||||
|
||||
# LLM Summarization
|
||||
if (
|
||||
analytics.is_feature_enabled("summary-enabled", distinct_id=owner_id)
|
||||
and settings.is_summary_enabled
|
||||
):
|
||||
logger.info("Queuing summary generation task.")
|
||||
summarize_transcription.apply_async(
|
||||
args=[owner_id, content, email, sub, title],
|
||||
queue=settings.summarize_queue,
|
||||
)
|
||||
else:
|
||||
logger.info("Summary generation not enabled for this user. Skipping.")
|
||||
|
||||
|
||||
@signals.task_prerun.connect(sender=process_audio_transcribe_summarize_v2)
|
||||
def task_started(task_id=None, task=None, args=None, **kwargs):
|
||||
"""Signal handler called before task execution begins."""
|
||||
task_args = args or []
|
||||
metadata_manager.create(task_id, task_args)
|
||||
|
||||
|
||||
@signals.task_retry.connect(sender=process_audio_transcribe_summarize_v2)
|
||||
def task_retry_handler(request=None, reason=None, einfo=None, **kwargs):
|
||||
"""Signal handler called when task execution retries."""
|
||||
metadata_manager.retry(request.id)
|
||||
|
||||
|
||||
@signals.task_failure.connect(sender=process_audio_transcribe_summarize_v2)
|
||||
def task_failure_handler(task_id, exception=None, **kwargs):
|
||||
"""Signal handler called when task execution fails permanently."""
|
||||
metadata_manager.capture(task_id, settings.posthog_event_failure)
|
||||
|
||||
|
||||
def summarize_transcription_internals(
|
||||
@@ -315,6 +468,29 @@ def summarize_transcription_internals(
|
||||
return summary
|
||||
|
||||
|
||||
@celery.task(
|
||||
bind=True,
|
||||
autoretry_for=[LLMException, Exception],
|
||||
max_retries=settings.celery_max_retries,
|
||||
queue=settings.summarize_queue,
|
||||
)
|
||||
def summarize_transcription(
|
||||
self, owner_id: str, transcript: str, email: str, sub: str, title: str
|
||||
):
|
||||
"""Generate a summary from the provided transcription text.
|
||||
|
||||
This Celery task performs the following operations:
|
||||
1. Run summary internals
|
||||
2. Sends the final summary via webhook.
|
||||
"""
|
||||
summary = summarize_transcription_internals(
|
||||
owner_id=owner_id, transcript=transcript, session_id=self.request.id
|
||||
)
|
||||
summary_title = settings.summary_title_template.format(title=title)
|
||||
|
||||
submit_content(summary, summary_title, email, sub)
|
||||
|
||||
|
||||
##################################################################################
|
||||
# Tasks v2
|
||||
##################################################################################
|
||||
@@ -373,17 +549,6 @@ def process_audio_transcribe_v2_task(
|
||||
).model_dump()
|
||||
)
|
||||
|
||||
# Assign speakers and rewrite transcription/diarization output
|
||||
if settings.is_resolve_speaker_identities_enabled and payload.metadata is not None:
|
||||
try:
|
||||
transcription_res = resolve_speaker_identities_and_apply_to(
|
||||
transcription=transcription_res,
|
||||
recording_metadata=payload.metadata,
|
||||
task_id=job_id,
|
||||
)
|
||||
except BaseException as e:
|
||||
logger.error(f"Failed to resolve speaker identities, skipping: {e}")
|
||||
|
||||
file_service.store_transcript(
|
||||
transcript=transcription_res,
|
||||
job_id=job_id,
|
||||
@@ -396,8 +561,6 @@ def process_audio_transcribe_v2_task(
|
||||
call_webhook_v2_task.apply_async(
|
||||
args=[success_payload.model_dump(), payload.tenant_id]
|
||||
)
|
||||
metadata_manager.capture(job_id, settings.posthog_event_success)
|
||||
|
||||
return success_payload.model_dump()
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Application configuration and settings."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from functools import cached_property, lru_cache
|
||||
from typing import Annotated, List, Literal, Mapping, Optional, Set
|
||||
from typing import Annotated, Any, List, Literal, Mapping, Optional, Set
|
||||
|
||||
from fastapi import Depends
|
||||
from pydantic import (
|
||||
@@ -35,6 +36,8 @@ class AuthorizedTenant(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
V1_DEFAULT_TENANT_ID = "__deprecated_meet_tenant__"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Configuration settings loaded from environment variables and .env file."""
|
||||
@@ -42,12 +45,14 @@ class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", frozen=True)
|
||||
|
||||
app_name: str = "summary"
|
||||
app_api_v1_str: str = "/api/v1"
|
||||
app_api_v2_str: str = "/api/v2"
|
||||
|
||||
# Authorized Tenants
|
||||
# Using env variables to store authorized tenants for now
|
||||
# to avoid any other external dependency (DB)
|
||||
authorized_tenants: tuple[AuthorizedTenant, ...] = Field(default_factory=tuple)
|
||||
v1_tenant_id: str = V1_DEFAULT_TENANT_ID
|
||||
|
||||
# Audio recordings
|
||||
recording_max_duration: Optional[int] = None
|
||||
@@ -67,6 +72,8 @@ class Settings(BaseSettings):
|
||||
celery_result_backend: str = "redis://redis/0"
|
||||
celery_max_retries: int = 1
|
||||
|
||||
transcribe_queue: str = "transcribe-queue"
|
||||
summarize_queue: str = "summarize-queue"
|
||||
# v2 tasks
|
||||
transcribe_queue_v2: str = "transcribe-queue-v2"
|
||||
summarize_queue_v2: str = "summarize-queue-v2"
|
||||
@@ -107,6 +114,9 @@ class Settings(BaseSettings):
|
||||
webhook_status_forcelist: List[int] = [502, 503, 504]
|
||||
webhook_backoff_factor: float = 0.1
|
||||
|
||||
# Locale
|
||||
default_context_language: Literal["de", "en", "fr", "nl"] = "fr"
|
||||
|
||||
# Output related settings
|
||||
summary_title_template: Optional[str] = "Résumé de {title}"
|
||||
|
||||
@@ -135,6 +145,33 @@ class Settings(BaseSettings):
|
||||
task_tracker_redis_url: str = "redis://redis/0"
|
||||
task_tracker_prefix: str = "task_metadata:"
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def legacy_default_tenant_config(cls, data: Any) -> Any:
|
||||
"""Migrate the legacy default tenant configuration."""
|
||||
if isinstance(data, dict):
|
||||
api_key = os.getenv("APP_API_TOKEN")
|
||||
webhook_api_key = os.getenv("WEBHOOK_API_TOKEN")
|
||||
webhook_url = os.getenv("WEBHOOK_URL")
|
||||
if api_key and webhook_api_key and webhook_url:
|
||||
logger.warning(
|
||||
"Deprecated legacy app configuration detected, "
|
||||
"please use only the new 'authorized_tenants' field instead."
|
||||
)
|
||||
|
||||
authorized_tenants = list(data.get("authorized_tenants", []))
|
||||
authorized_tenants.append(
|
||||
AuthorizedTenant(
|
||||
id=V1_DEFAULT_TENANT_ID,
|
||||
api_key=SecretStr(api_key),
|
||||
webhook_url=webhook_url,
|
||||
webhook_api_key=SecretStr(webhook_api_key),
|
||||
)
|
||||
)
|
||||
data["authorized_tenants"] = tuple(authorized_tenants)
|
||||
|
||||
return data
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_authorized_tenants(self):
|
||||
"""Validate authorized tenants configuration."""
|
||||
@@ -153,6 +190,16 @@ class Settings(BaseSettings):
|
||||
raise ValueError("Duplicate application API api_keys are not allowed")
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_default_v1_tenant(self):
|
||||
"""Validate default v1 tenant configuration."""
|
||||
if not any(
|
||||
tenant.id == self.v1_tenant_id for tenant in self.authorized_tenants
|
||||
):
|
||||
raise ValueError("v1 tenant is not configured in authorized tenants")
|
||||
|
||||
return self
|
||||
|
||||
@cached_property
|
||||
def authorized_tenant_api_keys(self) -> frozenset[str]:
|
||||
"""Return a frozenset of authorized tenant API api_keys."""
|
||||
|
||||
@@ -13,6 +13,7 @@ from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
from minio import Minio
|
||||
from minio.error import MinioException, S3Error
|
||||
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.shared_models import WhisperXResponse
|
||||
@@ -143,6 +144,54 @@ class FileService:
|
||||
self._allowed_extensions = settings.recording_allowed_extensions
|
||||
self._max_duration = settings.recording_max_duration
|
||||
|
||||
def _download_from_minio(self, remote_object_key) -> Path:
|
||||
"""Download file from MinIO to local temporary file.
|
||||
|
||||
The file is downloaded to a temporary location for local manipulation
|
||||
such as validation, conversion, or processing before being used.
|
||||
"""
|
||||
logger.info("Download recording | object_key: %s", remote_object_key)
|
||||
|
||||
if not 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:
|
||||
logger.warning("Invalid file extension '%s'", extension)
|
||||
raise ValueError(f"Invalid file extension '{extension}'")
|
||||
|
||||
response = None
|
||||
|
||||
try:
|
||||
response = self._minio_client.get_object(
|
||||
self._bucket_name, remote_object_key
|
||||
)
|
||||
|
||||
with tempfile.NamedTemporaryFile(
|
||||
suffix=extension, delete=False, prefix="minio_download_"
|
||||
) as tmp:
|
||||
for chunk in response.stream(self._stream_chunk_size):
|
||||
tmp.write(chunk)
|
||||
|
||||
tmp.flush()
|
||||
local_path = Path(tmp.name)
|
||||
|
||||
logger.info("Recording successfully downloaded")
|
||||
logger.debug("Recording local file path: %s", local_path)
|
||||
|
||||
return local_path
|
||||
|
||||
except (MinioException, S3Error) as e:
|
||||
raise FileServiceException(
|
||||
"Unexpected error while downloading object."
|
||||
) from e
|
||||
|
||||
finally:
|
||||
if response:
|
||||
response.close()
|
||||
|
||||
def _download_from_cloud_storage_url(self, cloud_storage_url: str) -> Path:
|
||||
"""Download file from a cloud storage URL to local temporary file."""
|
||||
logger.info(
|
||||
@@ -253,19 +302,33 @@ class FileService:
|
||||
os.remove(output_path)
|
||||
raise RuntimeError("Failed to extract audio.") from e
|
||||
|
||||
def read_cloud_storage_json(self, cloud_storage_url: str) -> dict:
|
||||
def read_json(self, object_name: str) -> dict:
|
||||
"""Read and parse a JSON file from MinIO storage."""
|
||||
logger.info("Reading JSON: %s", cloud_storage_url)
|
||||
local_path = self._download_from_cloud_storage_url(cloud_storage_url)
|
||||
logger.info("Reading JSON: %s", object_name)
|
||||
|
||||
if not object_name:
|
||||
raise ValueError("Invalid object_name")
|
||||
|
||||
response = None
|
||||
try:
|
||||
return json.load(local_path.open("r"))
|
||||
response = self._minio_client.get_object(self._bucket_name, object_name)
|
||||
return json.loads(response.read())
|
||||
except (MinioException, S3Error) as e:
|
||||
raise FileServiceException(
|
||||
"Unexpected error while reading JSON object."
|
||||
) from e
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||
raise FileServiceException("Invalid JSON content.") from e
|
||||
finally:
|
||||
if response:
|
||||
response.close()
|
||||
response.release_conn()
|
||||
|
||||
@contextmanager
|
||||
def prepare_audio_file(
|
||||
self,
|
||||
cloud_storage_url: str,
|
||||
remote_object_key: str | None = None,
|
||||
cloud_storage_url: str | None = None,
|
||||
):
|
||||
"""Download and prepare audio file for processing.
|
||||
|
||||
@@ -278,9 +341,20 @@ class FileService:
|
||||
file_handle = None
|
||||
|
||||
try:
|
||||
downloaded_path = self._download_from_cloud_storage_url(
|
||||
cloud_storage_url
|
||||
)
|
||||
if bool(remote_object_key) == bool(cloud_storage_url):
|
||||
raise ValueError(
|
||||
(
|
||||
"Exactly one of 'remote_object_key' or "
|
||||
"'cloud_storage_url' must be provided."
|
||||
)
|
||||
)
|
||||
|
||||
if cloud_storage_url:
|
||||
downloaded_path = self._download_from_cloud_storage_url(
|
||||
cloud_storage_url
|
||||
)
|
||||
else:
|
||||
downloaded_path = self._download_from_minio(remote_object_key)
|
||||
|
||||
duration = self._validate_duration(downloaded_path)
|
||||
|
||||
|
||||
+4
-5
@@ -2,10 +2,9 @@
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from core.transcription.locales import de, en, fr, nl
|
||||
from core.transcription.locales.strings import LocaleStrings
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.locales import de, en, fr, nl
|
||||
from summary.core.locales.strings import LocaleStrings
|
||||
|
||||
_LOCALES = {"fr": fr, "en": en, "de": de, "nl": nl}
|
||||
|
||||
@@ -28,4 +27,4 @@ def get_locale(*languages: Optional[str]) -> LocaleStrings:
|
||||
if base_lang in _LOCALES:
|
||||
return _LOCALES[base_lang].STRINGS
|
||||
|
||||
return _LOCALES[settings.TRANSCRIPTION_DEFAULT_LANGUAGE].STRINGS
|
||||
return _LOCALES[get_settings().default_context_language].STRINGS
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
"""German locale strings."""
|
||||
|
||||
from core.transcription.locales.strings import LocaleStrings
|
||||
from summary.core.locales.strings import LocaleStrings
|
||||
|
||||
STRINGS = LocaleStrings(
|
||||
empty_transcription="""
|
||||
@@ -30,5 +30,4 @@ Einige Punkte, die wir Ihnen empfehlen zu überprüfen:
|
||||
document_title_template=(
|
||||
'Besprechung "{room}" am {room_recording_date} um {room_recording_time}'
|
||||
),
|
||||
summary_title_template="Zusammenfassung von {title}",
|
||||
)
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
"""English locale strings."""
|
||||
|
||||
from core.transcription.locales.strings import LocaleStrings
|
||||
from summary.core.locales.strings import LocaleStrings
|
||||
|
||||
STRINGS = LocaleStrings(
|
||||
empty_transcription="""
|
||||
@@ -30,5 +30,4 @@ A few things we recommend you check:
|
||||
document_title_template=(
|
||||
'Meeting "{room}" on {room_recording_date} at {room_recording_time}'
|
||||
),
|
||||
summary_title_template="Summary of {title}",
|
||||
)
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
"""French locale strings (default)."""
|
||||
|
||||
from core.transcription.locales.strings import LocaleStrings
|
||||
from summary.core.locales.strings import LocaleStrings
|
||||
|
||||
STRINGS = LocaleStrings(
|
||||
empty_transcription="""
|
||||
@@ -30,5 +30,4 @@ Quelques points que nous vous conseillons de vérifier :
|
||||
document_title_template=(
|
||||
'Réunion "{room}" du {room_recording_date} à {room_recording_time}'
|
||||
),
|
||||
summary_title_template="Résumé de {title}",
|
||||
)
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
"""Dutch locale strings."""
|
||||
|
||||
from core.transcription.locales.strings import LocaleStrings
|
||||
from summary.core.locales.strings import LocaleStrings
|
||||
|
||||
STRINGS = LocaleStrings(
|
||||
empty_transcription="""
|
||||
@@ -30,5 +30,4 @@ Een paar punten die wij u aanraden te controleren:
|
||||
document_title_template=(
|
||||
'Vergadering "{room}" op {room_recording_date} om {room_recording_time}'
|
||||
),
|
||||
summary_title_template="Samenvatting van {title}",
|
||||
)
|
||||
-1
@@ -13,4 +13,3 @@ class LocaleStrings:
|
||||
hallucination_replacement_text: str
|
||||
document_default_title: str
|
||||
document_title_template: str
|
||||
summary_title_template: str
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Models for the API & Celery tasks creation."""
|
||||
|
||||
from pydantic import AwareDatetime, BaseModel, Field, field_validator
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.types import Url
|
||||
@@ -14,17 +14,6 @@ class SharedV2TaskCreation(BaseModel):
|
||||
user_sub: str = Field(title="User Sub", description="The user's sub.")
|
||||
|
||||
|
||||
class RecordingMetadata(BaseModel):
|
||||
"""Model for recording metadata."""
|
||||
|
||||
cloud_storage_url: Url = Field(
|
||||
title="Cloud Storage URL",
|
||||
description="The URL of the metadata file for speaker assignement.",
|
||||
)
|
||||
start_at: AwareDatetime = Field(title="Start time of the recording to transcribe")
|
||||
end_at: AwareDatetime = Field(title="End time of the recording to transcribe")
|
||||
|
||||
|
||||
class TranscribeTaskV2Request(SharedV2TaskCreation):
|
||||
"""Model for creating a transcribe and summarize task (used for API request)."""
|
||||
|
||||
@@ -38,12 +27,7 @@ class TranscribeTaskV2Request(SharedV2TaskCreation):
|
||||
description="The language of the context text.",
|
||||
)
|
||||
language: str = Field(
|
||||
title="Language", description="The language of the content to transcribe."
|
||||
)
|
||||
metadata: RecordingMetadata | None = Field(
|
||||
title="Metadata",
|
||||
description="The metadata for the transcribe task.",
|
||||
default=None,
|
||||
title="Language", description="The language of the content to summarize."
|
||||
)
|
||||
|
||||
@field_validator("language")
|
||||
|
||||
@@ -159,5 +159,4 @@ __all__ = [
|
||||
"SummarizeWebhookPayloads",
|
||||
"WebhookPayloads",
|
||||
"WhisperXResponse",
|
||||
"webhook_payload_adapter",
|
||||
]
|
||||
|
||||
+7
-6
@@ -5,9 +5,10 @@ from datetime import datetime
|
||||
from typing import Tuple
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from django.conf import settings
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.locales import LocaleStrings
|
||||
|
||||
from core.transcription.locales.strings import LocaleStrings
|
||||
settings = get_settings()
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -37,11 +38,11 @@ class TranscriptFormatter:
|
||||
|
||||
return None
|
||||
|
||||
def format( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
def format(
|
||||
self,
|
||||
transcription,
|
||||
room: str | None = None,
|
||||
recording_datetime: datetime | None = None,
|
||||
recording_datetime: str | None = None,
|
||||
owner_timezone: str | None = None,
|
||||
download_link: str | None = None,
|
||||
) -> Tuple[str, str]:
|
||||
@@ -99,14 +100,14 @@ class TranscriptFormatter:
|
||||
def _generate_title(
|
||||
self,
|
||||
room: str | None = None,
|
||||
recording_datetime: datetime | None = None,
|
||||
recording_datetime: str | None = None,
|
||||
owner_timezone: str | None = None,
|
||||
) -> str:
|
||||
"""Generate title from context or return default."""
|
||||
if not room or not recording_datetime:
|
||||
return self._locale.document_default_title
|
||||
|
||||
dt = recording_datetime
|
||||
dt = datetime.fromisoformat(recording_datetime)
|
||||
if owner_timezone:
|
||||
dt = dt.astimezone(ZoneInfo(owner_timezone))
|
||||
|
||||
@@ -8,9 +8,10 @@ Multiple speakers can map to the same participant (e.g. two people sharing
|
||||
one microphone). A participant with no matching speaker gets no assignment.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import asdict, dataclass, field, is_dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -318,6 +319,24 @@ def _build_speaker_timelines(transcription: Any) -> dict[str, list[Interval]]:
|
||||
return intervals
|
||||
|
||||
|
||||
def _json_default(obj: Any) -> Any:
|
||||
"""Encode datetimes, dataclasses, and pydantic models for `json.dumps`.
|
||||
|
||||
Intended to be used for logging of `resolve_speaker_identities` (input
|
||||
and computed variables)
|
||||
"""
|
||||
if isinstance(obj, datetime):
|
||||
return obj.isoformat()
|
||||
if is_dataclass(obj) and not isinstance(obj, type):
|
||||
return asdict(obj)
|
||||
if hasattr(obj, "segments") and hasattr(obj, "word_segments"):
|
||||
return {"segments": obj.segments, "word_segments": obj.word_segments}
|
||||
if hasattr(obj, "model_dump"):
|
||||
return obj.model_dump(mode="json")
|
||||
|
||||
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
||||
|
||||
|
||||
def resolve_speaker_identities(
|
||||
metadata: dict[str, Any],
|
||||
transcription: Any,
|
||||
@@ -344,17 +363,6 @@ def resolve_speaker_identities(
|
||||
)
|
||||
speaker_timelines = _build_speaker_timelines(transcription)
|
||||
|
||||
logger.debug(
|
||||
"Assignment inputs: %d participants, %d speakers\n%s\n%s\n%s",
|
||||
len(participant_timelines),
|
||||
len(speaker_timelines),
|
||||
participant_timelines,
|
||||
speaker_timelines,
|
||||
_format_timelines_debug(
|
||||
participant_timelines, participant_names, speaker_timelines
|
||||
),
|
||||
)
|
||||
|
||||
result = AssignmentResult()
|
||||
|
||||
for speaker, speaker_intervals in speaker_timelines.items():
|
||||
@@ -397,4 +405,30 @@ def resolve_speaker_identities(
|
||||
overlap_threshold,
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
json.dumps(
|
||||
{
|
||||
"input": {
|
||||
"recording_start_datetime": recording_start_datetime.isoformat(),
|
||||
"recording_end_datetime": recording_end_datetime.isoformat(),
|
||||
"metadata": metadata,
|
||||
"transcription": transcription,
|
||||
},
|
||||
"computed": {
|
||||
"speaker_timelines": speaker_timelines,
|
||||
"participant_timelines": participant_timelines,
|
||||
"result": result,
|
||||
},
|
||||
},
|
||||
default=_json_default,
|
||||
indent=2,
|
||||
ensure_ascii=False,
|
||||
),
|
||||
)
|
||||
logger.debug(
|
||||
_format_timelines_debug(
|
||||
participant_timelines, participant_names, speaker_timelines
|
||||
),
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
@@ -46,6 +46,55 @@ def _post_with_retries(*, url, data, api_key: str | None = None):
|
||||
session.close()
|
||||
|
||||
|
||||
def call_webhook_v1(*, tenant_id: str, payload: dict) -> None:
|
||||
"""Call webhook with payload a payload and optional token."""
|
||||
tenant = settings.get_authorized_tenant(tenant_id=tenant_id)
|
||||
|
||||
logger.debug("Submitting to %s", tenant.webhook_url)
|
||||
logger.debug("Request payload: %s", json.dumps(payload, indent=2))
|
||||
|
||||
response = _post_with_retries(
|
||||
url=tenant.webhook_url,
|
||||
api_key=tenant.webhook_api_key.get_secret_value(),
|
||||
data=payload,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def submit_content(content: str, title: str, email: str, sub: str) -> None:
|
||||
"""Submit content to the configured webhook destination.
|
||||
|
||||
Builds the payload, sends it with retries, and logs the outcome.
|
||||
|
||||
Notes:
|
||||
Deprecated: Use call_webhook_v2 directly instead.
|
||||
|
||||
Deprecated:
|
||||
This will route content to the v1 default tenant
|
||||
"""
|
||||
data = {
|
||||
"title": title,
|
||||
"content": content,
|
||||
"email": email,
|
||||
"sub": sub,
|
||||
}
|
||||
|
||||
call_webhook_v1(payload=data, tenant_id=settings.v1_tenant_id)
|
||||
|
||||
|
||||
def call_webhook_v2(
|
||||
*,
|
||||
tenant_id: str,
|
||||
|
||||
@@ -4,7 +4,7 @@ import sentry_sdk
|
||||
from fastapi import FastAPI
|
||||
|
||||
from summary.api import health
|
||||
from summary.api.main import api_router_v2
|
||||
from summary.api.main import api_router_v1, api_router_v2
|
||||
from summary.core.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
@@ -17,5 +17,6 @@ app = FastAPI(
|
||||
title=settings.app_name,
|
||||
)
|
||||
|
||||
app.include_router(api_router_v1, prefix=settings.app_api_v1_str)
|
||||
app.include_router(api_router_v2, prefix=settings.app_api_v2_str)
|
||||
app.include_router(health.router)
|
||||
|
||||
Reference in New Issue
Block a user