Compare commits

..

2 Commits

Author SHA1 Message Date
Florent Chehab 01ff215901 wip backend meet 2026-05-14 17:42:44 +02:00
Florent Chehab 217c74e830 💥(summary) removed summary v1 related code
We are in the process of moving meet to summary v2 routes and tasks.
This commits removes code from summary and moves some code from summary to meet,
which should have the responsability of this code.
2026-05-14 14:04:42 +02:00
55 changed files with 1085 additions and 1774 deletions
-10
View File
@@ -8,16 +8,6 @@ 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
+52
View File
@@ -0,0 +1,52 @@
"""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"]
+6 -24
View File
@@ -138,31 +138,13 @@ class FilePermission(IsAuthenticated):
return obj.get_abilities(request.user).get(view.action, False)
class CanMuteParticipant(permissions.BasePermission):
class TranscribeWebhookPermission(permissions.BasePermission):
"""
Permissions applying to the summary webhook endpoint.
"""
Grant muting rights based on role or room configuration.
- 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_permission(self, request, view):
return request.method == "POST"
def has_object_permission(self, request, view, obj):
"""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)
return False
+14 -9
View File
@@ -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, field_serializer
from pydantic import BaseModel, Field
from pydantic import ValidationError as PydanticValidationError
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
@@ -166,6 +166,11 @@ 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
@@ -182,7 +187,7 @@ class RoomSerializer(serializers.ModelSerializer):
room_id=room_id,
user=request.user,
username=username,
configuration=output["configuration"],
configuration=configuration,
is_admin_or_owner=is_admin_or_owner,
)
else:
@@ -312,7 +317,9 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
)
TrackSource = Literal["camera", "microphone", "screen_share", "screen_share_audio"]
RoomConfigurationTrackSource = Literal[
"camera", "microphone", "screen_share", "screen_share_audio"
]
class RoomConfiguration(BaseModel):
@@ -321,12 +328,14 @@ class RoomConfiguration(BaseModel):
Unknown fields are rejected.
"""
can_publish_sources: list[TrackSource] | None = None
everyone_can_mute: bool | None = None
can_publish_sources: list[RoomConfigurationTrackSource] | None = None
model_config = {"extra": "forbid"}
TrackSource = Literal["SCREEN_SHARE", "SCREEN_SHARE_AUDIO", "CAMERA", "MICROPHONE"]
class ParticipantPermission(BaseModel):
"""Mirror the LiveKit ParticipantPermission protobuf.
@@ -346,10 +355,6 @@ 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."""
+98 -75
View File
@@ -17,6 +17,7 @@ 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,
@@ -33,9 +34,8 @@ from rest_framework import (
from rest_framework import (
status as drf_status,
)
from rest_framework.settings import api_settings
from core import enums, models, utils
from core import analytics, enums, models, utils
from core.api.filters import ListFileFilter
from core.enums import MEDIA_STORAGE_URL_PATTERN
from core.recording.enums import FileExtension
@@ -77,15 +77,14 @@ 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
@@ -305,41 +304,6 @@ 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"],
@@ -655,11 +619,7 @@ class RoomViewSet(
methods=["post"],
url_path="mute-participant",
url_name="mute-participant",
permission_classes=[permissions.CanMuteParticipant],
authentication_classes=[
LiveKitTokenAuthentication,
*api_settings.DEFAULT_AUTHENTICATION_CLASSES,
],
permission_classes=[permissions.HasPrivilegesOnRoom],
)
def mute_participant(self, request, pk=None): # pylint: disable=unused-argument
"""Mute a specific track for a participant in the room."""
@@ -668,26 +628,6 @@ 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),
@@ -980,15 +920,9 @@ 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_succeeded = notification_service.notify_external_services(
recording
)
notification_service.notify_external_services(recording)
recording.status = (
models.RecordingStatusChoices.NOTIFICATION_SUCCEEDED
if notification_succeeded
else models.RecordingStatusChoices.SAVED
)
recording.status = models.RecordingStatusChoices.SAVED
recording.save()
return drf_response.Response(
@@ -1397,3 +1331,92 @@ 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."},
)
@@ -0,0 +1,34 @@
"""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
@@ -0,0 +1,36 @@
# 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')],
},
),
]
+70 -1
View File
@@ -388,7 +388,6 @@ 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,
@@ -591,6 +590,16 @@ 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"
@@ -737,6 +746,66 @@ 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,22 +45,17 @@ class NotificationService:
"""Process a recording based on its mode."""
if recording.mode == models.RecordingModeChoices.TRANSCRIPT:
return self._notify_summary_service(recording)
if recording.mode == models.RecordingModeChoices.SCREEN_RECORDING:
summary_success = True
self._notify_summary_service(recording)
elif recording.mode == models.RecordingModeChoices.SCREEN_RECORDING:
if recording.options.get("transcribe", False):
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
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,
)
@staticmethod
def _notify_user_by_email(recording) -> bool:
@@ -187,71 +182,17 @@ class NotificationService:
or not settings.SUMMARY_SERVICE_API_TOKEN
):
logger.error("Summary service not configured")
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
return
started_at, ended_at = async_to_sync(
NotificationService._get_recording_timestamps
)(recording.worker_id)
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),
}
recording.started_at = started_at
recording.ended_at = ended_at
recording.save()
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
call_transcribe_service.apply_async(args=[recording.id])
notification_service = NotificationService()
@@ -15,7 +15,6 @@ from livekit.api import (
TwirpError,
UpdateParticipantRequest,
)
from livekit.protocol.models import ParticipantInfo
from core import utils
@@ -155,44 +154,3 @@ 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
)
@@ -1,64 +0,0 @@
"""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()
View File
+319
View File
@@ -0,0 +1,319 @@
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,23 +2,20 @@
Test rooms API endpoints in the Meet core app: participants management.
"""
# pylint: disable=redefined-outer-name,unused-argument,protected-access,no-name-in-module,too-many-lines
# pylint: disable=redefined-outer-name,unused-argument,protected-access
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, UpdateParticipantRequest
from livekit.protocol.models import ParticipantInfo
from livekit.api import TwirpError
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
@@ -34,8 +31,8 @@ def mock_livekit_client():
yield mock_client
def test_mute_participant_success_as_admin(mock_livekit_client):
"""Admins and owners should be able to mute without a LiveKit token."""
def test_mute_participant_success(mock_livekit_client):
"""Test successful participant muting."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
@@ -44,12 +41,10 @@ def test_mute_participant_success_as_admin(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,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
@@ -58,131 +53,23 @@ def test_mute_participant_success_as_admin(mock_livekit_client):
mock_livekit_client.aclose.assert_called_once()
def test_mute_participant_anonymous_no_token_forbidden(mock_livekit_client):
"""Should forbid muting when user is anonymous and no LiveKit token."""
def test_mute_participant_forbidden_without_access():
"""Test mute participant returns 403 when user lacks room privileges."""
client = APIClient()
room = RoomFactory()
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_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
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,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
response = client.post(url, payload, 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():
"""Should reject muting when the payload is invalid."""
"""Test mute participant with invalid payload."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
@@ -191,16 +78,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, {"participant_identity": "invalid-uuid", "track_sid": ""}, format="json"
)
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_mute_participant_unexpected_twirp_error(mock_livekit_client):
"""Should return 500 when the LiveKit API raises a TwirpError."""
"""Test mute participant when LiveKit API raises TwirpError."""
client = APIClient()
mock_livekit_client.room.mute_published_track.side_effect = TwirpError(
@@ -214,12 +101,10 @@ 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,
{"participant_identity": str(uuid4()), "track_sid": "test-track-sid"},
format="json",
)
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert response.data == {"error": "Failed to mute participant"}
@@ -227,282 +112,6 @@ 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()
@@ -521,8 +130,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,
@@ -549,8 +158,8 @@ def test_update_participant_success(mock_livekit_client):
{"can_publish_data": True},
{
"can_publish_sources": [
"camera",
"microphone",
"CAMERA",
"MICROPHONE",
]
},
{"can_update_metadata": True},
@@ -581,41 +190,9 @@ 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,7 +28,6 @@ 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,
@@ -48,7 +47,6 @@ 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,
@@ -67,7 +65,6 @@ 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,
@@ -84,7 +81,6 @@ 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,
@@ -101,7 +97,6 @@ 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,
@@ -205,7 +200,6 @@ 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,
@@ -252,7 +246,6 @@ 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,
@@ -304,7 +297,6 @@ 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,
@@ -346,7 +338,6 @@ 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,
@@ -392,7 +383,6 @@ 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,18 +3,12 @@ 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
@@ -85,14 +79,12 @@ def test_api_rooms_update_members():
assert room.configuration == {}
@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."""
def test_api_rooms_update_administrators():
"""Administrators or owners of a room should be allowed to update it."""
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)
@@ -114,120 +106,11 @@ def test_api_rooms_update_administrators(mock_update_metadata):
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": [
@@ -239,17 +122,12 @@ def test_api_rooms_update_administrators_name_only(mock_update_metadata):
},
{"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"},
],
)
@patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_configuration_valid(mock_update_metadata, configuration):
def test_api_rooms_update_configuration_valid(configuration):
"""Administrators should be allowed to set valid configurations."""
user = UserFactory()
room = RoomFactory(users=[(user, "owner")], configuration={})
room = RoomFactory(users=[(user, "owner")])
client = APIClient()
client.force_login(user)
@@ -262,28 +140,6 @@ def test_api_rooms_update_configuration_valid(mock_update_metadata, configuratio
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."""
@@ -342,24 +198,6 @@ 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
@@ -379,61 +217,3 @@ 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"]},
},
)
@@ -2,9 +2,10 @@
from typing import Optional
from summary.core.config import get_settings
from summary.core.locales import de, en, fr, nl
from summary.core.locales.strings import LocaleStrings
from django.conf import settings
from core.transcription.locales import de, en, fr, nl
from core.transcription.locales.strings import LocaleStrings
_LOCALES = {"fr": fr, "en": en, "de": de, "nl": nl}
@@ -27,4 +28,4 @@ def get_locale(*languages: Optional[str]) -> LocaleStrings:
if base_lang in _LOCALES:
return _LOCALES[base_lang].STRINGS
return _LOCALES[get_settings().default_context_language].STRINGS
return _LOCALES[settings.TRANSCRIPTION_DEFAULT_LANGUAGE].STRINGS
@@ -1,6 +1,6 @@
"""German locale strings."""
from summary.core.locales.strings import LocaleStrings
from core.transcription.locales.strings import LocaleStrings
STRINGS = LocaleStrings(
empty_transcription="""
@@ -30,4 +30,5 @@ 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,6 +1,6 @@
"""English locale strings."""
from summary.core.locales.strings import LocaleStrings
from core.transcription.locales.strings import LocaleStrings
STRINGS = LocaleStrings(
empty_transcription="""
@@ -30,4 +30,5 @@ 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,6 +1,6 @@
"""French locale strings (default)."""
from summary.core.locales.strings import LocaleStrings
from core.transcription.locales.strings import LocaleStrings
STRINGS = LocaleStrings(
empty_transcription="""
@@ -30,4 +30,5 @@ 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,6 +1,6 @@
"""Dutch locale strings."""
from summary.core.locales.strings import LocaleStrings
from core.transcription.locales.strings import LocaleStrings
STRINGS = LocaleStrings(
empty_transcription="""
@@ -30,4 +30,5 @@ 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}",
)
@@ -13,3 +13,4 @@ class LocaleStrings:
hallucination_replacement_text: str
document_default_title: str
document_title_template: str
summary_title_template: str
@@ -5,10 +5,9 @@ from datetime import datetime
from typing import Tuple
from zoneinfo import ZoneInfo
from summary.core.config import get_settings
from summary.core.locales import LocaleStrings
from django.conf import settings
settings = get_settings()
from core.transcription.locales.strings import LocaleStrings
logger = logging.getLogger(__name__)
@@ -38,11 +37,11 @@ class TranscriptFormatter:
return None
def format(
def format( # pylint: disable=too-many-arguments,too-many-positional-arguments
self,
transcription,
room: str | None = None,
recording_datetime: str | None = None,
recording_datetime: datetime | None = None,
owner_timezone: str | None = None,
download_link: str | None = None,
) -> Tuple[str, str]:
@@ -100,14 +99,14 @@ class TranscriptFormatter:
def _generate_title(
self,
room: str | None = None,
recording_datetime: str | None = None,
recording_datetime: datetime | 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 = datetime.fromisoformat(recording_datetime)
dt = recording_datetime
if owner_timezone:
dt = dt.astimezone(ZoneInfo(owner_timezone))
@@ -0,0 +1,163 @@
"""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",
]
+1
View File
@@ -16,6 +16,7 @@ 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"
)
+36 -5
View File
@@ -121,11 +121,7 @@ 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",
"is_authenticated": not user.is_anonymous,
}
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
)
)
@@ -459,3 +455,38 @@ 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,
)
+20
View File
@@ -19,6 +19,7 @@ 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
@@ -451,6 +452,11 @@ 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",
@@ -744,6 +750,20 @@ 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
)
+1
View File
@@ -62,6 +62,7 @@ dependencies = [
"livekit-api==1.1.0",
"aiohttp==3.13.4",
"urllib3==2.7.0",
"posthog>=7.14.2",
]
[project.urls]
+35
View File
@@ -148,6 +148,15 @@ 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"
@@ -559,6 +568,15 @@ 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"
@@ -1204,6 +1222,7 @@ dependencies = [
{ name = "markdown" },
{ name = "mozilla-django-oidc" },
{ name = "nested-multipart-parser" },
{ name = "posthog" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pydantic" },
{ name = "pyjwt" },
@@ -1266,6 +1285,7 @@ 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" },
@@ -1506,6 +1526,21 @@ 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"
+1 -3
View File
@@ -2,8 +2,6 @@ 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?: {
@@ -52,7 +50,7 @@ export interface ApiConfig {
url: string
force_wss_protocol: boolean
enable_firefox_proxy_workaround: boolean
default_sources: Source[]
default_sources: string[]
}
transcription_destination?: string
}
@@ -1,6 +1,3 @@
import { Track } from 'livekit-client'
import Source = Track.Source
export type ApiLiveKit = {
url: string
room: string
@@ -13,11 +10,6 @@ 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
@@ -26,5 +18,7 @@ export type ApiRoom = {
is_administrable: boolean
access_level: ApiAccessLevel
livekit?: ApiLiveKit
configuration?: RoomConfiguration
configuration?: {
[key: string]: string | number | boolean | string[]
}
}
@@ -6,74 +6,44 @@ import {
NotificationType,
} from '@/features/notifications'
import { fetchApi } from '@/api/fetchApi'
import { useIsAdminOrOwner } from '../livekit/hooks/useIsAdminOrOwner'
import { useCallback } from 'react'
export const useMuteParticipant = () => {
const apiRoomData = useRoomData()
const data = useRoomData()
const { notifyParticipants } = useNotifyParticipants()
const isAdminOrOwner = useIsAdminOrOwner()
const muteParticipant = useCallback(
async (participant: Participant) => {
if (!apiRoomData?.livekit?.room) {
throw new Error('Room id is not available')
}
const muteParticipant = async (participant: Participant) => {
if (!data?.id) {
throw new Error('Room id is not available')
}
const trackSid = participant.getTrackPublication(
Source.Microphone
)?.trackSid
const trackSid = participant.getTrackPublication(
Source.Microphone
)?.trackSid
if (!trackSid) {
return
}
if (!trackSid) {
return
}
try {
const response = await fetchApi(`rooms/${data.id}/mute-participant/`, {
method: 'POST',
body: JSON.stringify({
participant_identity: participant.identity,
track_sid: trackSid,
}),
})
// 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}`
)
}
await notifyParticipants({
type: NotificationType.ParticipantMuted,
destinationIdentities: [participant.identity],
})
return response
},
[apiRoomData, isAdminOrOwner, notifyParticipants]
)
} catch (error) {
console.error(
`Failed to mute participant ${participant.identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
)
}
}
return { muteParticipant }
}
@@ -8,7 +8,7 @@ export const useParticipantPermissions = () => {
const updateParticipantPermissions = async (
participant: Participant,
sources: Source[]
sources: Array<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,
can_publish_sources: sources.map((source) => source.toUpperCase()),
}
try {
@@ -9,8 +9,7 @@ import { queryClient } from '@/api/queryClient'
import { keys } from '@/api/queryKeys'
import { useQuery } from '@tanstack/react-query'
import { useParams } from 'wouter'
import { usePublishSourcesManager } from '../hooks/usePublishSourcesManager'
import { usePermissionsManager } from '../hooks/usePermissionsManager'
import { usePublishSourcesManager } from '@/features/rooms/livekit/hooks/usePublishSourcesManager'
export const Admin = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
@@ -39,8 +38,6 @@ export const Admin = () => {
isScreenShareEnabled,
} = usePublishSourcesManager()
const { toggleMuting, isMutingEnabled } = usePermissionsManager()
return (
<Div
display="flex"
@@ -133,17 +130,6 @@ 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,13 +1,7 @@
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 ||
apiRoomData?.configuration?.everyone_can_mute !== false
)
return participant.isLocal || isAdminOrOwner
}
@@ -1,46 +0,0 @@
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,6 +39,10 @@ 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
@@ -50,16 +54,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
}, [configData, configuration?.can_publish_sources])
return configuration.can_publish_sources.map((source) => {
return source as Source
})
}, [defaultSources, configuration?.can_publish_sources])
const updateSource = useCallback(
async (sources: Source[], enabled: boolean) => {
@@ -74,7 +78,7 @@ export const usePublishSourcesManager = () => {
const newConfiguration = {
...configuration,
can_publish_sources: newSources,
can_publish_sources: newSources as string[],
}
const room = await patchRoom({
@@ -1,86 +0,0 @@
// 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,7 +32,6 @@ 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'
@@ -91,7 +90,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
useConnectionObserver()
useRoomPageTitle()
useVideoResolutionSubscription()
useSyncLiveKitMetadata()
useRegisterKeyboardShortcut({
id: 'open-shortcuts',
-4
View File
@@ -528,10 +528,6 @@
"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."
}
}
},
-4
View File
@@ -527,10 +527,6 @@
"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."
}
}
},
-4
View File
@@ -527,10 +527,6 @@
"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."
}
}
},
-4
View File
@@ -527,10 +527,6 @@
"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."
}
}
},
+1 -1
View File
@@ -59,7 +59,7 @@ export const CAPTION_FONT_COLOR_VALUES: Record<CaptionColor, string> = {
}
export const CAPTION_BACKGROUND_COLOR_VALUES: Record<CaptionColor, string> = {
default: 'transparent',
default: 'rgba(0, 0, 0, 0.75)',
black: 'rgba(0, 0, 0, 0.75)',
white: 'rgba(255, 255, 255, 0.75)',
blue: 'rgba(0, 0, 255, 0.75)',
+1 -4
View File
@@ -2,11 +2,8 @@
from fastapi import APIRouter, Depends
from summary.api.route import tasks, tasks_v2
from summary.api.route import 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"])
-79
View File
@@ -1,79 +0,0 @@
"""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}
+44 -207
View File
@@ -4,7 +4,6 @@
import json
import time
from datetime import datetime
import openai
import sentry_sdk
@@ -16,8 +15,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,
)
@@ -39,11 +38,9 @@ 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()
@@ -79,23 +76,17 @@ file_service = FileService()
def transcribe_audio(
*,
task_id: str,
recording_filename: str | None = None,
language: str,
cloud_storage_url=None,
cloud_storage_url: str,
raises: bool = False,
):
"""Transcribe an audio file using WhisperX.
Downloads the audio from MinIO or a cloud storage URL, sends it to
Downloads the audio from 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(),
@@ -106,7 +97,6 @@ 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"]})
@@ -150,10 +140,8 @@ def transcribe_audio(
)
logger.exception(
(
"Unexpected error while preparing file | filename: %s "
"| cloud_storage_url: %s"
"Unexpected error while preparing file %s "
),
recording_filename,
redacted_cloud_storage_url,
)
return None
@@ -163,41 +151,31 @@ def transcribe_audio(
def resolve_speaker_identities_and_apply_to(
transcription, recording_start_at, recording_end_at, metadata_filename, task_id
):
*, transcription: WhisperXResponse, recording_metadata: RecordingMetadata, task_id
) -> WhisperXResponse:
"""Assign users to detected speakers and rewrite the transcriptions.
Args:
transcription: output of meet-whisperx after transcription and diarization
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
recording_metadata: Metadata of the recording
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_start_dt,
recording_end_dt,
recording_metadata.start_at,
recording_metadata.end_at,
)
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_json(metadata_filename)
metadata = file_service.read_cloud_storage_json(
recording_metadata.cloud_storage_url
)
speaker_mapping = resolve_speaker_identities(
metadata,
transcription,
recording_start_dt,
recording_end_dt,
recording_metadata.start_at,
recording_metadata.end_at,
)
new_transcription = speaker_mapping.apply_to(transcription.model_dump())
return new_transcription
@@ -221,34 +199,6 @@ 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.
@@ -267,126 +217,23 @@ def format_actions(llm_output: dict) -> str:
return ""
@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)
# @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(
@@ -468,29 +315,6 @@ 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
##################################################################################
@@ -549,6 +373,17 @@ 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,
@@ -561,6 +396,8 @@ 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 -48
View File
@@ -1,9 +1,8 @@
"""Application configuration and settings."""
import logging
import os
from functools import cached_property, lru_cache
from typing import Annotated, Any, List, Literal, Mapping, Optional, Set
from typing import Annotated, List, Literal, Mapping, Optional, Set
from fastapi import Depends
from pydantic import (
@@ -36,8 +35,6 @@ class AuthorizedTenant(BaseModel):
)
V1_DEFAULT_TENANT_ID = "__deprecated_meet_tenant__"
class Settings(BaseSettings):
"""Configuration settings loaded from environment variables and .env file."""
@@ -45,14 +42,12 @@ 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
@@ -72,8 +67,6 @@ 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"
@@ -114,9 +107,6 @@ 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}"
@@ -145,33 +135,6 @@ 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."""
@@ -190,16 +153,6 @@ 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."""
+8 -82
View File
@@ -13,7 +13,6 @@ 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
@@ -144,54 +143,6 @@ 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(
@@ -302,33 +253,19 @@ class FileService:
os.remove(output_path)
raise RuntimeError("Failed to extract audio.") from e
def read_json(self, object_name: str) -> dict:
def read_cloud_storage_json(self, cloud_storage_url: str) -> dict:
"""Read and parse a JSON file from MinIO storage."""
logger.info("Reading JSON: %s", object_name)
if not object_name:
raise ValueError("Invalid object_name")
response = None
logger.info("Reading JSON: %s", cloud_storage_url)
local_path = self._download_from_cloud_storage_url(cloud_storage_url)
try:
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
return json.load(local_path.open("r"))
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,
remote_object_key: str | None = None,
cloud_storage_url: str | None = None,
cloud_storage_url: str,
):
"""Download and prepare audio file for processing.
@@ -341,20 +278,9 @@ class FileService:
file_handle = None
try:
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)
downloaded_path = self._download_from_cloud_storage_url(
cloud_storage_url
)
duration = self._validate_duration(downloaded_path)
+18 -2
View File
@@ -1,6 +1,6 @@
"""Models for the API & Celery tasks creation."""
from pydantic import BaseModel, Field, field_validator
from pydantic import AwareDatetime, BaseModel, Field, field_validator
from summary.core.config import get_settings
from summary.core.types import Url
@@ -14,6 +14,17 @@ 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)."""
@@ -27,7 +38,12 @@ class TranscribeTaskV2Request(SharedV2TaskCreation):
description="The language of the context text.",
)
language: str = Field(
title="Language", description="The language of the content to summarize."
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,
)
@field_validator("language")
@@ -159,4 +159,5 @@ __all__ = [
"SummarizeWebhookPayloads",
"WebhookPayloads",
"WhisperXResponse",
"webhook_payload_adapter",
]
+12 -46
View File
@@ -8,10 +8,9 @@ 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 asdict, dataclass, field, is_dataclass
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
@@ -319,24 +318,6 @@ 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,
@@ -363,6 +344,17 @@ 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():
@@ -405,30 +397,4 @@ 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,55 +46,6 @@ 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,
+1 -2
View File
@@ -4,7 +4,7 @@ import sentry_sdk
from fastapi import FastAPI
from summary.api import health
from summary.api.main import api_router_v1, api_router_v2
from summary.api.main import api_router_v2
from summary.core.config import get_settings
settings = get_settings()
@@ -17,6 +17,5 @@ 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)