Compare commits

...

2 Commits

Author SHA1 Message Date
Florent Chehab acd7c51758 (backend) compatible with summary v2 routes
* Track external process (transcribe) status through a webhook and
new Enum choices,
* Send v2 and v1 summary routes params so that both endpoint can still be used,
* Updated dev setup to make it work.
2026-05-26 18:28:08 +02:00
Florent Chehab 7e286fcb4b 💥(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-26 16:59:43 +02:00
37 changed files with 2555 additions and 880 deletions
-1
View File
@@ -305,7 +305,6 @@ jobs:
working-directory: src/summary
env:
V1_TENANT_ID: 'test-tenant'
AUTHORIZED_TENANTS: '[{"id": "test-tenant", "api_key": "test-api-token", "webhook_url": "https://example.com/webhook", "webhook_api_key": "test-webhook-api-key"}]'
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
AWS_S3_ENDPOINT_URL: "minio:9000"
+2 -1
View File
@@ -64,8 +64,9 @@ ALLOW_UNREGISTERED_ROOMS=False
RECORDING_ENABLE=True
RECORDING_STORAGE_EVENT_ENABLE=True
RECORDING_STORAGE_EVENT_TOKEN=password
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v1/tasks/
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe/
SUMMARY_SERVICE_API_TOKEN=password
SUMMARY_SERVICE_WEBHOOK_API_TOKEN=webhook-password
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
# Recording encoding (LiveKit Egress advanced options).
+45 -1
View File
@@ -39,7 +39,10 @@ from core import enums, models, utils
from core.api.filters import ListFileFilter
from core.enums import MEDIA_STORAGE_URL_PATTERN
from core.recording.enums import FileExtension
from core.recording.event.authentication import StorageEventAuthentication
from core.recording.event.authentication import (
RecordingProcessWebhookAuthentication,
StorageEventAuthentication,
)
from core.recording.event.exceptions import (
InvalidBucketError,
InvalidFilepathError,
@@ -995,6 +998,47 @@ class RecordingViewSet(
{"message": "Event processed."},
)
@decorators.action(
detail=False,
methods=["post"],
url_path="external-process-hook",
authentication_classes=[RecordingProcessWebhookAuthentication],
)
def on_external_process_event_received(self, request, pk=None): # pylint: disable=unused-argument
"""Handle incoming external process events for recordings."""
logger.debug("Processing external process event %s", request.data)
data = request.data
if not data.get("job_id"):
raise drf_exceptions.ValidationError(detail="No job_id provided")
job_id = data["job_id"]
try:
recording = models.Recording.objects.get(external_process_id=job_id)
except models.Recording.DoesNotExist as e:
logger.warning("No recording found for job_id %s: %s", job_id, e)
recording = None
changed = False
if recording and data.get("type") == "transcript":
if data.get("status") == "success":
logger.info("External process received for recording %s", job_id)
recording.status = (
models.RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL
)
changed = True
if data.get("status") == "failure":
recording.status = models.RecordingStatusChoices.EXTERNAL_PROCESS_FAILED
changed = True
if changed and recording:
recording.save()
else:
logger.info("No changes to save for external process id %s", job_id)
return drf_response.Response(
{"message": "Event processed."},
)
def _auth_get_original_url(self, request):
"""
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
@@ -0,0 +1,23 @@
# Generated by Django 5.2.14 on 2026-05-26 14:30
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0018_rename_active_application_is_active'),
]
operations = [
migrations.AddField(
model_name='recording',
name='external_process_id',
field=models.CharField(blank=True, help_text='ID of the external process associated with the recording.', max_length=255, null=True, unique=True, verbose_name='External Process ID'),
),
migrations.AlterField(
model_name='recording',
name='status',
field=models.CharField(choices=[('initiated', 'Initiated'), ('active', 'Active'), ('stopped', 'Stopped'), ('saved', 'Saved'), ('aborted', 'Aborted'), ('failed_to_start', 'Failed to Start'), ('failed_to_stop', 'Failed to Stop'), ('notification_succeeded', 'Notification succeeded'), ('external_process_successful', 'External process successful'), ('external_process_failed', 'External process failed')], default='initiated', max_length=50),
),
]
+17
View File
@@ -60,6 +60,11 @@ class RecordingStatusChoices(models.TextChoices):
FAILED_TO_START = "failed_to_start", _("Failed to Start")
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded")
EXTERNAL_PROCESS_SUCCESSFUL = (
"external_process_successful",
_("External process successful"),
)
EXTERNAL_PROCESS_FAILED = "external_process_failed", _("External process failed")
@classmethod
def is_final(cls, status):
@@ -73,6 +78,8 @@ class RecordingStatusChoices(models.TextChoices):
cls.STOPPED,
cls.SAVED,
cls.ABORTED,
cls.EXTERNAL_PROCESS_SUCCESSFUL,
cls.EXTERNAL_PROCESS_FAILED,
cls.FAILED_TO_START,
cls.FAILED_TO_STOP,
}
@@ -591,6 +598,14 @@ class Recording(BaseModel):
verbose_name=_("Recording options"),
help_text=_("Recording options"),
)
external_process_id = models.CharField(
max_length=255,
null=True,
blank=True,
unique=True,
verbose_name=_("External Process ID"),
help_text=_("ID of the external process associated with the recording."),
)
class Meta:
db_table = "meet_recording"
@@ -646,6 +661,8 @@ class Recording(BaseModel):
return self.status in {
RecordingStatusChoices.NOTIFICATION_SUCCEEDED,
RecordingStatusChoices.SAVED,
RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL,
RecordingStatusChoices.EXTERNAL_PROCESS_FAILED,
}
@property
@@ -14,9 +14,9 @@ logger = logging.getLogger(__name__)
class MachineUser:
"""Represent a non-interactive system user for automated storage operations."""
def __init__(self) -> None:
def __init__(self, username: str = "storage_event_user") -> None:
self.pk = None
self.username = "storage_event_user"
self.username = username
self.is_active = True
@property
@@ -91,3 +91,29 @@ class StorageEventAuthentication(BaseAuthentication):
def authenticate_header(self, request):
"""Return the WWW-Authenticate header value."""
return f"{self.TOKEN_TYPE} realm='Storage event API'"
class RecordingProcessWebhookAuthentication(BaseAuthentication):
"""
Custom authentication class for recording process 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).
"""
logger.info("Authentificating")
authorization_header: str = request.headers.get("Authorization") or ""
if not secrets.compare_digest(
authorization_header.removeprefix("Bearer "),
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN,
):
logger.warning(
"Authentication failed: Bad Authorization header (ip: %s)",
request.META.get("REMOTE_ADDR"),
)
raise AuthenticationFailed()
return MachineUser("external_process_user"), None
@@ -1,8 +1,10 @@
"""Service to notify external services when a new recording is ready."""
import asyncio
import json
import logging
import smtplib
import warnings
from datetime import datetime, timezone
from django.conf import settings
@@ -17,6 +19,7 @@ from asgiref.sync import async_to_sync
from livekit import api as livekit_api
from core import models, utils
from core.utils import generate_download_s3_file_url
logger = logging.getLogger(__name__)
@@ -215,18 +218,45 @@ class NotificationService:
)(recording.worker_id)
payload = {
# Legacy V1 params to avoid a breaking change
"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),
# V2 params
"user_sub": owner_access.user.sub,
"user_email": owner_access.user.email,
"cloud_storage_url": generate_download_s3_file_url(
recording.key, expires_in=60 * 60 * 24, override_domain=False
),
"language": recording.options.get("language", "fr"),
"context_language": owner_access.user.language,
"push_to_docs_config": {
"user_email": owner_access.user.email,
"title": "TODO",
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
# For now the feature flag logic is handled on summary side
"auto_create_summary": True,
},
"metadata": (
{
"cloud_storage_url": generate_download_s3_file_url(
metadata_filename,
expires_in=60 * 60 * 24,
override_domain=False,
),
"started_at": started_at.isoformat(),
"ended_at": ended_at.isoformat(),
}
if (started_at and ended_at)
else None
),
}
headers = {
@@ -242,6 +272,31 @@ class NotificationService:
timeout=30,
)
response.raise_for_status()
is_v2_implementation = False
try:
response_json = response.json()
# We do not require a job_id to avoid a breaking change
if job_id := response_json.get("job_id"):
recording.external_process_id = job_id
recording.save()
is_v2_implementation = True
except json.JSONDecodeError:
pass
if not is_v2_implementation:
warnings.warn(
"You are likely using your own implementation for the summary / "
"transcribe service."
"We have released a new version and API contract for that service, "
"we recommend checking it out"
# pylint: disable=line-too-long
"https://github.com/suitenumerique/meet/blob/19c2a378e7e66652afbfa3e749badd697e3917ac/src/summary/summary/api/route/tasks_v2.py "
"and mimicking it's behavior. We will remove the legacy "
"handling in a future version.",
DeprecationWarning,
stacklevel=2,
)
except requests.RequestException as exc:
logger.exception(
"Summary service error for recording %s. URL: %s. Exception: %s",
@@ -0,0 +1,134 @@
"""
Test recordings API endpoints: external process hook.
"""
# pylint: disable=redefined-outer-name,unused-argument
import pytest
from ...factories import RecordingFactory
from ...models import RecordingStatusChoices
pytestmark = pytest.mark.django_db
@pytest.fixture
def external_process_settings(settings):
"""Configure authentication token for the external process webhook."""
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "testWebhookToken"
return settings
def test_external_process_event_missing_authorization_header(
external_process_settings, client
):
"""Requests without authorization must be rejected."""
response = client.post(
"/api/v1.0/recordings/external-process-hook/",
{"job_id": "job-1", "type": "transcript", "status": "success"},
)
assert response.status_code == 403
def test_external_process_event_wrong_bearer_token(external_process_settings, client):
"""Requests with invalid bearer token must be rejected."""
response = client.post(
"/api/v1.0/recordings/external-process-hook/",
{"job_id": "job-1", "type": "transcript", "status": "success"},
HTTP_AUTHORIZATION="Bearer wrongToken",
)
assert response.status_code == 403
def test_external_process_event_missing_job_id(external_process_settings, client):
"""Payload without job_id must fail validation."""
response = client.post(
"/api/v1.0/recordings/external-process-hook/",
{"type": "transcript", "status": "success"},
HTTP_AUTHORIZATION="Bearer testWebhookToken",
)
assert response.status_code == 400
assert "No job_id provided" in str(response.json())
def test_external_process_event_success_updates_recording_status(
external_process_settings, client
):
"""A successful transcript process should update recording status."""
recording = RecordingFactory(
status=RecordingStatusChoices.SAVED,
external_process_id="job-123",
)
response = client.post(
"/api/v1.0/recordings/external-process-hook/",
{"job_id": "job-123", "type": "transcript", "status": "success"},
HTTP_AUTHORIZATION="Bearer testWebhookToken",
)
assert response.status_code == 200
assert response.json() == {"message": "Event processed."}
recording.refresh_from_db()
assert recording.status == RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL
def test_external_process_event_failure_updates_recording_status(
external_process_settings, client
):
"""A failing transcript process should update recording status."""
recording = RecordingFactory(
status=RecordingStatusChoices.SAVED,
external_process_id="job-456",
)
response = client.post(
"/api/v1.0/recordings/external-process-hook/",
{"job_id": "job-456", "type": "transcript", "status": "failure"},
HTTP_AUTHORIZATION="Bearer testWebhookToken",
)
assert response.status_code == 200
assert response.json() == {"message": "Event processed."}
recording.refresh_from_db()
assert recording.status == RecordingStatusChoices.EXTERNAL_PROCESS_FAILED
def test_external_process_event_unknown_recording_is_ignored(
external_process_settings, client
):
"""Unknown job_id should not fail the webhook processing."""
response = client.post(
"/api/v1.0/recordings/external-process-hook/",
{"job_id": "missing-job", "type": "transcript", "status": "success"},
HTTP_AUTHORIZATION="Bearer testWebhookToken",
)
assert response.status_code == 200
assert response.json() == {"message": "Event processed."}
def test_external_process_event_non_transcript_event_does_not_change_status(
external_process_settings, client
):
"""Only transcript events should update recording status."""
recording = RecordingFactory(
status=RecordingStatusChoices.SAVED,
external_process_id="job-789",
)
response = client.post(
"/api/v1.0/recordings/external-process-hook/",
{"job_id": "job-789", "type": "thumbnail", "status": "success"},
HTTP_AUTHORIZATION="Bearer testWebhookToken",
)
assert response.status_code == 200
assert response.json() == {"message": "Event processed."}
recording.refresh_from_db()
assert recording.status == RecordingStatusChoices.SAVED
+35
View File
@@ -455,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,
)
+100 -82
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-21 14:09+0000\n"
"POT-Creation-Date: 2026-05-26 16:20+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -88,17 +88,17 @@ msgstr "Scopes"
msgid "Creator is me"
msgstr "Ersteller bin ich"
#: core/api/serializers.py:88
#: core/api/serializers.py:89
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
"hinzuzufügen."
#: core/api/serializers.py:509
#: core/api/serializers.py:534
msgid "This file extension is not allowed."
msgstr "Diese Dateiendung ist nicht erlaubt."
#: core/api/viewsets.py:1090
#: core/api/viewsets.py:1228
msgid "You have reached the maximum number of files for this type."
msgstr "Sie haben die maximale Anzahl an Dateien dieses Typs erreicht."
@@ -146,51 +146,59 @@ msgstr "Stopp fehlgeschlagen"
msgid "Notification succeeded"
msgstr "Benachrichtigung erfolgreich"
#: core/models.py:89
#: core/models.py:65
msgid "External process successful"
msgstr ""
#: core/models.py:67
msgid "External process failed"
msgstr ""
#: core/models.py:96
msgid "SCREEN_RECORDING"
msgstr "BILDSCHIRMAUFZEICHNUNG"
#: core/models.py:90
#: core/models.py:97
msgid "TRANSCRIPT"
msgstr "TRANSKRIPT"
#: core/models.py:96
#: core/models.py:103
msgid "Public Access"
msgstr "Öffentlicher Zugriff"
#: core/models.py:97
#: core/models.py:104
msgid "Trusted Access"
msgstr "Vertrauenswürdiger Zugriff"
#: core/models.py:98
#: core/models.py:105
msgid "Restricted Access"
msgstr "Eingeschränkter Zugriff"
#: core/models.py:110
#: core/models.py:117
msgid "id"
msgstr "ID"
#: core/models.py:111
#: core/models.py:118
msgid "primary key for the record as UUID"
msgstr "Primärschlüssel des Eintrags als UUID"
#: core/models.py:117
#: core/models.py:124
msgid "created on"
msgstr "erstellt am"
#: core/models.py:118
#: core/models.py:125
msgid "date and time at which a record was created"
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
#: core/models.py:123
#: core/models.py:130
msgid "updated on"
msgstr "aktualisiert am"
#: core/models.py:124
#: core/models.py:131
msgid "date and time at which a record was last updated"
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
#: core/models.py:144
#: core/models.py:151
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -198,11 +206,11 @@ msgstr ""
"Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen "
"und die Zeichen @/./+/-/_ enthalten."
#: core/models.py:150
#: core/models.py:157
msgid "sub"
msgstr "Sub"
#: core/models.py:152
#: core/models.py:159
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -210,55 +218,55 @@ msgstr ""
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
"Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt."
#: core/models.py:161
#: core/models.py:168
msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse"
#: core/models.py:166
#: core/models.py:173
msgid "admin email address"
msgstr "Administrator-E-Mail-Adresse"
#: core/models.py:168
#: core/models.py:175
msgid "full name"
msgstr "Vollständiger Name"
#: core/models.py:170
#: core/models.py:177
msgid "short name"
msgstr "Kurzname"
#: core/models.py:176
#: core/models.py:183
msgid "language"
msgstr "Sprache"
#: core/models.py:177
#: core/models.py:184
msgid "The language in which the user wants to see the interface."
msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
#: core/models.py:183
#: core/models.py:190
msgid "The timezone in which the user wants to see times."
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
#: core/models.py:186
#: core/models.py:193
msgid "device"
msgstr "Gerät"
#: core/models.py:188
#: core/models.py:195
msgid "Whether the user is a device or a real user."
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
#: core/models.py:191
#: core/models.py:198
msgid "staff status"
msgstr "Mitarbeiterstatus"
#: core/models.py:193
#: core/models.py:200
msgid "Whether the user can log into this admin site."
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
#: core/models.py:196
#: core/models.py:203
msgid "active"
msgstr "aktiv"
#: core/models.py:199
#: core/models.py:206
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -266,66 +274,66 @@ msgstr ""
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
"anstelle des Löschens des Kontos."
#: core/models.py:212
#: core/models.py:219
msgid "user"
msgstr "Benutzer"
#: core/models.py:213
#: core/models.py:220
msgid "users"
msgstr "Benutzer"
#: core/models.py:272
#: core/models.py:279
msgid "Resource"
msgstr "Ressource"
#: core/models.py:273
#: core/models.py:280
msgid "Resources"
msgstr "Ressourcen"
#: core/models.py:331
#: core/models.py:338
msgid "Resource access"
msgstr "Ressourcenzugriff"
#: core/models.py:332
#: core/models.py:339
msgid "Resource accesses"
msgstr "Ressourcenzugriffe"
#: core/models.py:338
#: core/models.py:345
msgid "Resource access with this User and Resource already exists."
msgstr ""
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
"bereits."
#: core/models.py:394
#: core/models.py:402
msgid "Visio room configuration"
msgstr "Visio-Raumkonfiguration"
#: core/models.py:395
#: core/models.py:403
msgid "Values for Visio parameters to configure the room."
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
#: core/models.py:402
#: core/models.py:410
msgid "Room PIN code"
msgstr "PIN-Code für den Raum"
#: core/models.py:403
#: core/models.py:411
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
#: core/models.py:409 core/models.py:563
#: core/models.py:417 core/models.py:571
msgid "Room"
msgstr "Raum"
#: core/models.py:410
#: core/models.py:418
msgid "Rooms"
msgstr "Räume"
#: core/models.py:574
#: core/models.py:582
msgid "Worker ID"
msgstr "Worker-ID"
#: core/models.py:576
#: core/models.py:584
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -334,141 +342,149 @@ msgstr ""
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
"ermöglicht."
#: core/models.py:584
#: core/models.py:592
msgid "Recording mode"
msgstr "Aufzeichnungsmodus"
#: core/models.py:585
#: core/models.py:593
msgid "Defines the mode of recording being called."
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
#: core/models.py:590 core/models.py:591
#: core/models.py:598 core/models.py:599
msgid "Recording options"
msgstr "Aufnahmeoptionen"
#: core/models.py:597
#: core/models.py:606
msgid "External Process ID"
msgstr ""
#: core/models.py:607
msgid "ID of the external process associated with the recording."
msgstr ""
#: core/models.py:613
msgid "Recording"
msgstr "Aufzeichnung"
#: core/models.py:598
#: core/models.py:614
msgid "Recordings"
msgstr "Aufzeichnungen"
#: core/models.py:706
#: core/models.py:724
msgid "Recording/user relation"
msgstr "Beziehung Aufzeichnung/Benutzer"
#: core/models.py:707
#: core/models.py:725
msgid "Recording/user relations"
msgstr "Beziehungen Aufzeichnung/Benutzer"
#: core/models.py:713
#: core/models.py:731
msgid "This user is already in this recording."
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
#: core/models.py:719
#: core/models.py:737
msgid "This team is already in this recording."
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
#: core/models.py:725
#: core/models.py:743
msgid "Either user or team must be set, not both."
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
#: core/models.py:742
#: core/models.py:760
msgid "Create rooms"
msgstr "Räume erstellen"
#: core/models.py:743
#: core/models.py:761
msgid "List rooms"
msgstr "Räume auflisten"
#: core/models.py:744
#: core/models.py:762
msgid "Retrieve room details"
msgstr "Raumdetails abrufen"
#: core/models.py:745
#: core/models.py:763
msgid "Update rooms"
msgstr "Räume aktualisieren"
#: core/models.py:746
#: core/models.py:764
msgid "Delete rooms"
msgstr "Räume löschen"
#: core/models.py:759
#: core/models.py:777
msgid "Application name"
msgstr "Anwendungsname"
#: core/models.py:760
#: core/models.py:778
msgid "Descriptive name for this application."
msgstr "Beschreibender Name für diese Anwendung."
#: core/models.py:770
#: core/models.py:788
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr ""
"Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
#: core/models.py:781
#: core/models.py:799
msgid "Application"
msgstr "Anwendung"
#: core/models.py:782
#: core/models.py:800
msgid "Applications"
msgstr "Anwendungen"
#: core/models.py:805
#: core/models.py:823
msgid "Enter a valid domain"
msgstr "Geben Sie eine gültige Domain ein"
#: core/models.py:808
#: core/models.py:826
msgid "Domain"
msgstr "Domain"
#: core/models.py:809
#: core/models.py:827
msgid "Email domain this application can act on behalf of."
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
#: core/models.py:821
#: core/models.py:839
msgid "Application domain"
msgstr "Anwendungsdomain"
#: core/models.py:822
#: core/models.py:840
msgid "Application domains"
msgstr "Anwendungsdomains"
#: core/models.py:840
#: core/models.py:858
msgid "Pending"
msgstr "Ausstehend"
#: core/models.py:848
#: core/models.py:866
msgid "Ready"
msgstr "Bereit"
#: core/models.py:854
#: core/models.py:872
msgid "Background image"
msgstr "Hintergrundbild"
#: core/models.py:866
#: core/models.py:884
msgid "title"
msgstr "Titel"
#: core/models.py:890
#: core/models.py:908
msgid "Malware detection info when the analysis status is unsafe."
msgstr ""
"Informationen zur Malware-Erkennung, wenn der Analyse-Status unsicher ist."
#: core/models.py:895
#: core/models.py:913
msgid "File"
msgstr "Datei"
#: core/models.py:896
#: core/models.py:914
msgid "Files"
msgstr "Dateien"
#: core/models.py:1000
#: core/models.py:1018
msgid "This file is already hard deleted."
msgstr "Diese Datei wurde bereits endgültig gelöscht."
#: core/models.py:1010
#: core/models.py:1028
#, fuzzy
#| msgid "To hard delete a file, it must first be soft deleted."
msgid "To hard delete a file, it must first be soft deleted."
@@ -476,7 +492,7 @@ msgstr ""
"Um eine Datei endgültig zu löschen, muss sie zuvor weich gelöscht worden "
"sein."
#: core/recording/event/notification.py:116
#: core/recording/event/notification.py:124
msgid "Your recording is ready"
msgstr "Ihre Aufzeichnung ist bereit"
@@ -576,7 +592,9 @@ msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
#, fuzzy
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgid "Click the \"Open\" button below "
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
#: core/templates/mail/html/screen_recording.html:209
+100 -82
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-21 14:09+0000\n"
"POT-Creation-Date: 2026-05-26 16:20+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -88,15 +88,15 @@ msgstr "Scopes"
msgid "Creator is me"
msgstr "Creator is me"
#: core/api/serializers.py:88
#: core/api/serializers.py:89
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr "You must be administrator or owner of a room to add accesses to it."
#: core/api/serializers.py:509
#: core/api/serializers.py:534
msgid "This file extension is not allowed."
msgstr "This file extension is not allowed."
#: core/api/viewsets.py:1090
#: core/api/viewsets.py:1228
msgid "You have reached the maximum number of files for this type."
msgstr "You have reached the maximum number of files for this type."
@@ -144,51 +144,59 @@ msgstr "Failed to Stop"
msgid "Notification succeeded"
msgstr "Notification succeeded"
#: core/models.py:89
#: core/models.py:65
msgid "External process successful"
msgstr ""
#: core/models.py:67
msgid "External process failed"
msgstr ""
#: core/models.py:96
msgid "SCREEN_RECORDING"
msgstr "SCREEN_RECORDING"
#: core/models.py:90
#: core/models.py:97
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:96
#: core/models.py:103
msgid "Public Access"
msgstr "Public Access"
#: core/models.py:97
#: core/models.py:104
msgid "Trusted Access"
msgstr "Trusted Access"
#: core/models.py:98
#: core/models.py:105
msgid "Restricted Access"
msgstr "Restricted Access"
#: core/models.py:110
#: core/models.py:117
msgid "id"
msgstr "id"
#: core/models.py:111
#: core/models.py:118
msgid "primary key for the record as UUID"
msgstr "primary key for the record as UUID"
#: core/models.py:117
#: core/models.py:124
msgid "created on"
msgstr "created on"
#: core/models.py:118
#: core/models.py:125
msgid "date and time at which a record was created"
msgstr "date and time at which a record was created"
#: core/models.py:123
#: core/models.py:130
msgid "updated on"
msgstr "updated on"
#: core/models.py:124
#: core/models.py:131
msgid "date and time at which a record was last updated"
msgstr "date and time at which a record was last updated"
#: core/models.py:144
#: core/models.py:151
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -196,11 +204,11 @@ msgstr ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
#: core/models.py:150
#: core/models.py:157
msgid "sub"
msgstr "sub"
#: core/models.py:152
#: core/models.py:159
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -208,55 +216,55 @@ msgstr ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
#: core/models.py:161
#: core/models.py:168
msgid "identity email address"
msgstr "identity email address"
#: core/models.py:166
#: core/models.py:173
msgid "admin email address"
msgstr "admin email address"
#: core/models.py:168
#: core/models.py:175
msgid "full name"
msgstr "full name"
#: core/models.py:170
#: core/models.py:177
msgid "short name"
msgstr "short name"
#: core/models.py:176
#: core/models.py:183
msgid "language"
msgstr "language"
#: core/models.py:177
#: core/models.py:184
msgid "The language in which the user wants to see the interface."
msgstr "The language in which the user wants to see the interface."
#: core/models.py:183
#: core/models.py:190
msgid "The timezone in which the user wants to see times."
msgstr "The timezone in which the user wants to see times."
#: core/models.py:186
#: core/models.py:193
msgid "device"
msgstr "device"
#: core/models.py:188
#: core/models.py:195
msgid "Whether the user is a device or a real user."
msgstr "Whether the user is a device or a real user."
#: core/models.py:191
#: core/models.py:198
msgid "staff status"
msgstr "staff status"
#: core/models.py:193
#: core/models.py:200
msgid "Whether the user can log into this admin site."
msgstr "Whether the user can log into this admin site."
#: core/models.py:196
#: core/models.py:203
msgid "active"
msgstr "active"
#: core/models.py:199
#: core/models.py:206
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -264,63 +272,63 @@ msgstr ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
#: core/models.py:212
#: core/models.py:219
msgid "user"
msgstr "user"
#: core/models.py:213
#: core/models.py:220
msgid "users"
msgstr "users"
#: core/models.py:272
#: core/models.py:279
msgid "Resource"
msgstr "Resource"
#: core/models.py:273
#: core/models.py:280
msgid "Resources"
msgstr "Resources"
#: core/models.py:331
#: core/models.py:338
msgid "Resource access"
msgstr "Resource access"
#: core/models.py:332
#: core/models.py:339
msgid "Resource accesses"
msgstr "Resource accesses"
#: core/models.py:338
#: core/models.py:345
msgid "Resource access with this User and Resource already exists."
msgstr "Resource access with this User and Resource already exists."
#: core/models.py:394
#: core/models.py:402
msgid "Visio room configuration"
msgstr "Visio room configuration"
#: core/models.py:395
#: core/models.py:403
msgid "Values for Visio parameters to configure the room."
msgstr "Values for Visio parameters to configure the room."
#: core/models.py:402
#: core/models.py:410
msgid "Room PIN code"
msgstr "Room PIN code"
#: core/models.py:403
#: core/models.py:411
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Unique n-digit code that identifies this room in telephony mode."
#: core/models.py:409 core/models.py:563
#: core/models.py:417 core/models.py:571
msgid "Room"
msgstr "Room"
#: core/models.py:410
#: core/models.py:418
msgid "Rooms"
msgstr "Rooms"
#: core/models.py:574
#: core/models.py:582
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:576
#: core/models.py:584
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -328,151 +336,159 @@ msgstr ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
#: core/models.py:584
#: core/models.py:592
msgid "Recording mode"
msgstr "Recording mode"
#: core/models.py:585
#: core/models.py:593
msgid "Defines the mode of recording being called."
msgstr "Defines the mode of recording being called."
#: core/models.py:590 core/models.py:591
#: core/models.py:598 core/models.py:599
msgid "Recording options"
msgstr "Recording options"
#: core/models.py:597
#: core/models.py:606
msgid "External Process ID"
msgstr ""
#: core/models.py:607
msgid "ID of the external process associated with the recording."
msgstr ""
#: core/models.py:613
msgid "Recording"
msgstr "Recording"
#: core/models.py:598
#: core/models.py:614
msgid "Recordings"
msgstr "Recordings"
#: core/models.py:706
#: core/models.py:724
msgid "Recording/user relation"
msgstr "Recording/user relation"
#: core/models.py:707
#: core/models.py:725
msgid "Recording/user relations"
msgstr "Recording/user relations"
#: core/models.py:713
#: core/models.py:731
msgid "This user is already in this recording."
msgstr "This user is already in this recording."
#: core/models.py:719
#: core/models.py:737
msgid "This team is already in this recording."
msgstr "This team is already in this recording."
#: core/models.py:725
#: core/models.py:743
msgid "Either user or team must be set, not both."
msgstr "Either user or team must be set, not both."
#: core/models.py:742
#: core/models.py:760
#, fuzzy
#| msgid "created on"
msgid "Create rooms"
msgstr "Create rooms"
#: core/models.py:743
#: core/models.py:761
msgid "List rooms"
msgstr "List rooms"
#: core/models.py:744
#: core/models.py:762
msgid "Retrieve room details"
msgstr "Retrieve room details"
#: core/models.py:745
#: core/models.py:763
#, fuzzy
#| msgid "updated on"
msgid "Update rooms"
msgstr "Update rooms"
#: core/models.py:746
#: core/models.py:764
msgid "Delete rooms"
msgstr "Delete rooms"
#: core/models.py:759
#: core/models.py:777
msgid "Application name"
msgstr "Application name"
#: core/models.py:760
#: core/models.py:778
msgid "Descriptive name for this application."
msgstr "Descriptive name for this application."
#: core/models.py:770
#: core/models.py:788
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "Hashed on Save. Copy it now if this is a new secret."
#: core/models.py:781
#: core/models.py:799
msgid "Application"
msgstr "Application"
#: core/models.py:782
#: core/models.py:800
msgid "Applications"
msgstr "Applications"
#: core/models.py:805
#: core/models.py:823
msgid "Enter a valid domain"
msgstr "Enter a valid domain"
#: core/models.py:808
#: core/models.py:826
msgid "Domain"
msgstr "Domain"
#: core/models.py:809
#: core/models.py:827
msgid "Email domain this application can act on behalf of."
msgstr "Email domain this application can act on behalf of."
#: core/models.py:821
#: core/models.py:839
msgid "Application domain"
msgstr "Application domain"
#: core/models.py:822
#: core/models.py:840
msgid "Application domains"
msgstr "Application domains"
#: core/models.py:840
#: core/models.py:858
#, fuzzy
#| msgid "Recording"
msgid "Pending"
msgstr "Pending"
#: core/models.py:848
#: core/models.py:866
msgid "Ready"
msgstr "Ready"
#: core/models.py:854
#: core/models.py:872
msgid "Background image"
msgstr "Background image"
#: core/models.py:866
#: core/models.py:884
msgid "title"
msgstr "title"
#: core/models.py:890
#: core/models.py:908
msgid "Malware detection info when the analysis status is unsafe."
msgstr "Malware detection info when the analysis status is unsafe."
#: core/models.py:895
#: core/models.py:913
msgid "File"
msgstr "File"
#: core/models.py:896
#: core/models.py:914
msgid "Files"
msgstr "Files"
#: core/models.py:1000
#: core/models.py:1018
#, fuzzy
#| msgid "This user is already in this recording."
msgid "This file is already hard deleted."
msgstr "This file is already hard deleted."
#: core/models.py:1010
#: core/models.py:1028
msgid "To hard delete a file, it must first be soft deleted."
msgstr "To hard delete a file, it must first be soft deleted."
#: core/recording/event/notification.py:116
#: core/recording/event/notification.py:124
msgid "Your recording is ready"
msgstr "Your recording is ready"
@@ -572,7 +588,9 @@ msgstr "To keep this recording permanently:"
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
#, fuzzy
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgid "Click the \"Open\" button below "
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
#: core/templates/mail/html/screen_recording.html:209
+100 -82
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-21 14:09+0000\n"
"POT-Creation-Date: 2026-05-26 16:20+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -89,17 +89,17 @@ msgstr "Scopes"
msgid "Creator is me"
msgstr "Je suis le créateur"
#: core/api/serializers.py:88
#: core/api/serializers.py:89
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
"des accès."
#: core/api/serializers.py:509
#: core/api/serializers.py:534
msgid "This file extension is not allowed."
msgstr "Cette extension n'est pas autorisée"
#: core/api/viewsets.py:1090
#: core/api/viewsets.py:1228
msgid "You have reached the maximum number of files for this type."
msgstr "Vous avez atteint le nombre maximum de fichiers de ce type"
@@ -147,53 +147,61 @@ msgstr "Échec à l'arrêt"
msgid "Notification succeeded"
msgstr "Notification réussie"
#: core/models.py:89
#: core/models.py:65
msgid "External process successful"
msgstr ""
#: core/models.py:67
msgid "External process failed"
msgstr ""
#: core/models.py:96
msgid "SCREEN_RECORDING"
msgstr "ENREGISTREMENT_ÉCRAN"
#: core/models.py:90
#: core/models.py:97
msgid "TRANSCRIPT"
msgstr "TRANSCRIPTION"
#: core/models.py:96
#: core/models.py:103
msgid "Public Access"
msgstr "Accès public"
#: core/models.py:97
#: core/models.py:104
msgid "Trusted Access"
msgstr "Accès de confiance"
#: core/models.py:98
#: core/models.py:105
msgid "Restricted Access"
msgstr "Accès restreint"
#: core/models.py:110
#: core/models.py:117
msgid "id"
msgstr "id"
#: core/models.py:111
#: core/models.py:118
msgid "primary key for the record as UUID"
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
#: core/models.py:117
#: core/models.py:124
msgid "created on"
msgstr "créé le"
#: core/models.py:118
#: core/models.py:125
msgid "date and time at which a record was created"
msgstr "date et heure auxquelles un enregistrement a été créé"
#: core/models.py:123
#: core/models.py:130
msgid "updated on"
msgstr "mis à jour le"
#: core/models.py:124
#: core/models.py:131
msgid "date and time at which a record was last updated"
msgstr ""
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
"fois"
#: core/models.py:144
#: core/models.py:151
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -201,11 +209,11 @@ msgstr ""
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des "
"chiffres et les caractères @/./+/-/_."
#: core/models.py:150
#: core/models.py:157
msgid "sub"
msgstr "sub"
#: core/models.py:152
#: core/models.py:159
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -213,55 +221,55 @@ msgstr ""
"Optionnel pour les utilisateurs en attente ; requis lors de l'activation du "
"compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement."
#: core/models.py:161
#: core/models.py:168
msgid "identity email address"
msgstr "adresse e-mail d'identité"
#: core/models.py:166
#: core/models.py:173
msgid "admin email address"
msgstr "adresse e-mail d'administrateur"
#: core/models.py:168
#: core/models.py:175
msgid "full name"
msgstr "nom complet"
#: core/models.py:170
#: core/models.py:177
msgid "short name"
msgstr "nom court"
#: core/models.py:176
#: core/models.py:183
msgid "language"
msgstr "langue"
#: core/models.py:177
#: core/models.py:184
msgid "The language in which the user wants to see the interface."
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
#: core/models.py:183
#: core/models.py:190
msgid "The timezone in which the user wants to see times."
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: core/models.py:186
#: core/models.py:193
msgid "device"
msgstr "appareil"
#: core/models.py:188
#: core/models.py:195
msgid "Whether the user is a device or a real user."
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: core/models.py:191
#: core/models.py:198
msgid "staff status"
msgstr "statut du personnel"
#: core/models.py:193
#: core/models.py:200
msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: core/models.py:196
#: core/models.py:203
msgid "active"
msgstr "actif"
#: core/models.py:199
#: core/models.py:206
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -269,65 +277,65 @@ msgstr ""
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
"au lieu de supprimer des comptes."
#: core/models.py:212
#: core/models.py:219
msgid "user"
msgstr "utilisateur"
#: core/models.py:213
#: core/models.py:220
msgid "users"
msgstr "utilisateurs"
#: core/models.py:272
#: core/models.py:279
msgid "Resource"
msgstr "Ressource"
#: core/models.py:273
#: core/models.py:280
msgid "Resources"
msgstr "Ressources"
#: core/models.py:331
#: core/models.py:338
msgid "Resource access"
msgstr "Accès aux ressources"
#: core/models.py:332
#: core/models.py:339
msgid "Resource accesses"
msgstr "Accès aux ressources"
#: core/models.py:338
#: core/models.py:345
msgid "Resource access with this User and Resource already exists."
msgstr ""
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
#: core/models.py:394
#: core/models.py:402
msgid "Visio room configuration"
msgstr "Configuration de la salle de visioconférence"
#: core/models.py:395
#: core/models.py:403
msgid "Values for Visio parameters to configure the room."
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
#: core/models.py:402
#: core/models.py:410
msgid "Room PIN code"
msgstr "Code PIN de la salle"
#: core/models.py:403
#: core/models.py:411
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Code unique à n chiffres qui identifie cette salle en mode téléphonique."
#: core/models.py:409 core/models.py:563
#: core/models.py:417 core/models.py:571
msgid "Room"
msgstr "Salle"
#: core/models.py:410
#: core/models.py:418
msgid "Rooms"
msgstr "Salles"
#: core/models.py:574
#: core/models.py:582
msgid "Worker ID"
msgstr "ID du Worker"
#: core/models.py:576
#: core/models.py:584
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -335,150 +343,158 @@ msgstr ""
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
"conservé même lorsque le Worker s'arrête, permettant un suivi facile."
#: core/models.py:584
#: core/models.py:592
msgid "Recording mode"
msgstr "Mode d'enregistrement"
#: core/models.py:585
#: core/models.py:593
msgid "Defines the mode of recording being called."
msgstr "Définit le mode d'enregistrement appelé."
#: core/models.py:590 core/models.py:591
#: core/models.py:598 core/models.py:599
msgid "Recording options"
msgstr "Options d'enregistrement"
#: core/models.py:597
#: core/models.py:606
msgid "External Process ID"
msgstr ""
#: core/models.py:607
msgid "ID of the external process associated with the recording."
msgstr ""
#: core/models.py:613
msgid "Recording"
msgstr "Enregistrement"
#: core/models.py:598
#: core/models.py:614
msgid "Recordings"
msgstr "Enregistrements"
#: core/models.py:706
#: core/models.py:724
msgid "Recording/user relation"
msgstr "Relation enregistrement/utilisateur"
#: core/models.py:707
#: core/models.py:725
msgid "Recording/user relations"
msgstr "Relations enregistrement/utilisateur"
#: core/models.py:713
#: core/models.py:731
msgid "This user is already in this recording."
msgstr "Cet utilisateur est déjà dans cet enregistrement."
#: core/models.py:719
#: core/models.py:737
msgid "This team is already in this recording."
msgstr "Cette équipe est déjà dans cet enregistrement."
#: core/models.py:725
#: core/models.py:743
msgid "Either user or team must be set, not both."
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
#: core/models.py:742
#: core/models.py:760
msgid "Create rooms"
msgstr "Créer des salles"
#: core/models.py:743
#: core/models.py:761
msgid "List rooms"
msgstr "Lister les salles"
#: core/models.py:744
#: core/models.py:762
msgid "Retrieve room details"
msgstr "Afficher les détails dune salle"
#: core/models.py:745
#: core/models.py:763
msgid "Update rooms"
msgstr "Mettre à jour les salles"
#: core/models.py:746
#: core/models.py:764
msgid "Delete rooms"
msgstr "Supprimer les salles"
#: core/models.py:759
#: core/models.py:777
msgid "Application name"
msgstr "Nom de lapplication"
#: core/models.py:760
#: core/models.py:778
msgid "Descriptive name for this application."
msgstr "Nom descriptif de cette application."
#: core/models.py:770
#: core/models.py:788
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr ""
"Haché lors de lenregistrement. Copiez-le maintenant sil sagit dun "
"nouveau secret."
#: core/models.py:781
#: core/models.py:799
msgid "Application"
msgstr "Application"
#: core/models.py:782
#: core/models.py:800
msgid "Applications"
msgstr "Applications"
#: core/models.py:805
#: core/models.py:823
msgid "Enter a valid domain"
msgstr "Saisissez un domaine valide"
#: core/models.py:808
#: core/models.py:826
msgid "Domain"
msgstr "Domaine"
#: core/models.py:809
#: core/models.py:827
msgid "Email domain this application can act on behalf of."
msgstr "Domaine de messagerie au nom duquel cette application peut agir."
#: core/models.py:821
#: core/models.py:839
msgid "Application domain"
msgstr "Domaine dapplication"
#: core/models.py:822
#: core/models.py:840
msgid "Application domains"
msgstr "Domaines dapplication"
#: core/models.py:840
#: core/models.py:858
msgid "Pending"
msgstr "En attente"
#: core/models.py:848
#: core/models.py:866
msgid "Ready"
msgstr "Prêt"
#: core/models.py:854
#: core/models.py:872
msgid "Background image"
msgstr "Image de fond"
#: core/models.py:866
#: core/models.py:884
msgid "title"
msgstr "Titre"
#: core/models.py:890
#: core/models.py:908
msgid "Malware detection info when the analysis status is unsafe."
msgstr ""
"Information concernant la détection de Malware cand le statut n'est pas sain"
#: core/models.py:895
#: core/models.py:913
msgid "File"
msgstr "Fichier"
#: core/models.py:896
#: core/models.py:914
msgid "Files"
msgstr "Fichiers"
#: core/models.py:1000
#: core/models.py:1018
#, fuzzy
#| msgid "This user is already in this recording."
msgid "This file is already hard deleted."
msgstr "Ce fichier a été supprimé."
#: core/models.py:1010
#: core/models.py:1028
msgid "To hard delete a file, it must first be soft deleted."
msgstr ""
"Pour supprimer définitivement un fichier il doit d'abord avoir été marqué "
"comme supprimé (soft delete)"
#: core/recording/event/notification.py:116
#: core/recording/event/notification.py:124
msgid "Your recording is ready"
msgstr "Votre enregistrement est prêt"
@@ -578,7 +594,9 @@ msgstr "Pour conserver cet enregistrement de façon permanente :"
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
#, fuzzy
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgid "Click the \"Open\" button below "
msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" ci-dessous "
#: core/templates/mail/html/screen_recording.html:209
+100 -82
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-21 14:09+0000\n"
"POT-Creation-Date: 2026-05-26 16:20+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -88,16 +88,16 @@ msgstr "Scopes"
msgid "Creator is me"
msgstr "Maker ben ik"
#: core/api/serializers.py:88
#: core/api/serializers.py:89
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
#: core/api/serializers.py:509
#: core/api/serializers.py:534
msgid "This file extension is not allowed."
msgstr "Deze bestandsextensie is niet toegestaan."
#: core/api/viewsets.py:1090
#: core/api/viewsets.py:1228
msgid "You have reached the maximum number of files for this type."
msgstr "Het maximale aantal bestanden voor dit type is bereikt."
@@ -145,51 +145,59 @@ msgstr "Stoppen mislukt"
msgid "Notification succeeded"
msgstr "Notificatie geslaagd"
#: core/models.py:89
#: core/models.py:65
msgid "External process successful"
msgstr ""
#: core/models.py:67
msgid "External process failed"
msgstr ""
#: core/models.py:96
msgid "SCREEN_RECORDING"
msgstr "SCHERM_OPNAME"
#: core/models.py:90
#: core/models.py:97
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:96
#: core/models.py:103
msgid "Public Access"
msgstr "Openbare toegang"
#: core/models.py:97
#: core/models.py:104
msgid "Trusted Access"
msgstr "Vertrouwde toegang"
#: core/models.py:98
#: core/models.py:105
msgid "Restricted Access"
msgstr "Beperkte toegang"
#: core/models.py:110
#: core/models.py:117
msgid "id"
msgstr "id"
#: core/models.py:111
#: core/models.py:118
msgid "primary key for the record as UUID"
msgstr "primaire sleutel voor het record als UUID"
#: core/models.py:117
#: core/models.py:124
msgid "created on"
msgstr "aangemaakt op"
#: core/models.py:118
#: core/models.py:125
msgid "date and time at which a record was created"
msgstr "datum en tijd waarop een record werd aangemaakt"
#: core/models.py:123
#: core/models.py:130
msgid "updated on"
msgstr "bijgewerkt op"
#: core/models.py:124
#: core/models.py:131
msgid "date and time at which a record was last updated"
msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt"
#: core/models.py:144
#: core/models.py:151
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -197,11 +205,11 @@ msgstr ""
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
"_ tekens bevatten."
#: core/models.py:150
#: core/models.py:157
msgid "sub"
msgstr "sub"
#: core/models.py:152
#: core/models.py:159
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -209,55 +217,55 @@ msgstr ""
"Optioneel voor gebruikers in afwachting; vereist bij accountactivering. "
"Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan."
#: core/models.py:161
#: core/models.py:168
msgid "identity email address"
msgstr "identiteit e-mailadres"
#: core/models.py:166
#: core/models.py:173
msgid "admin email address"
msgstr "beheerder e-mailadres"
#: core/models.py:168
#: core/models.py:175
msgid "full name"
msgstr "volledige naam"
#: core/models.py:170
#: core/models.py:177
msgid "short name"
msgstr "korte naam"
#: core/models.py:176
#: core/models.py:183
msgid "language"
msgstr "taal"
#: core/models.py:177
#: core/models.py:184
msgid "The language in which the user wants to see the interface."
msgstr "De taal waarin de gebruiker de interface wil zien."
#: core/models.py:183
#: core/models.py:190
msgid "The timezone in which the user wants to see times."
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
#: core/models.py:186
#: core/models.py:193
msgid "device"
msgstr "apparaat"
#: core/models.py:188
#: core/models.py:195
msgid "Whether the user is a device or a real user."
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
#: core/models.py:191
#: core/models.py:198
msgid "staff status"
msgstr "personeelsstatus"
#: core/models.py:193
#: core/models.py:200
msgid "Whether the user can log into this admin site."
msgstr "Of de gebruiker kan inloggen op deze beheersite."
#: core/models.py:196
#: core/models.py:203
msgid "active"
msgstr "actief"
#: core/models.py:199
#: core/models.py:206
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -265,64 +273,64 @@ msgstr ""
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
"plaats van accounts te verwijderen."
#: core/models.py:212
#: core/models.py:219
msgid "user"
msgstr "gebruiker"
#: core/models.py:213
#: core/models.py:220
msgid "users"
msgstr "gebruikers"
#: core/models.py:272
#: core/models.py:279
msgid "Resource"
msgstr "Bron"
#: core/models.py:273
#: core/models.py:280
msgid "Resources"
msgstr "Bronnen"
#: core/models.py:331
#: core/models.py:338
msgid "Resource access"
msgstr "Brontoegang"
#: core/models.py:332
#: core/models.py:339
msgid "Resource accesses"
msgstr "Brontoegangsrechten"
#: core/models.py:338
#: core/models.py:345
msgid "Resource access with this User and Resource already exists."
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
#: core/models.py:394
#: core/models.py:402
msgid "Visio room configuration"
msgstr "Visio-ruimteconfiguratie"
#: core/models.py:395
#: core/models.py:403
msgid "Values for Visio parameters to configure the room."
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
#: core/models.py:402
#: core/models.py:410
msgid "Room PIN code"
msgstr "Pincode van de kamer"
#: core/models.py:403
#: core/models.py:411
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
#: core/models.py:409 core/models.py:563
#: core/models.py:417 core/models.py:571
msgid "Room"
msgstr "Ruimte"
#: core/models.py:410
#: core/models.py:418
msgid "Rooms"
msgstr "Ruimtes"
#: core/models.py:574
#: core/models.py:582
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:576
#: core/models.py:584
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -330,140 +338,148 @@ msgstr ""
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
#: core/models.py:584
#: core/models.py:592
msgid "Recording mode"
msgstr "Opnamemodus"
#: core/models.py:585
#: core/models.py:593
msgid "Defines the mode of recording being called."
msgstr "Definieert de modus van opname die wordt aangeroepen."
#: core/models.py:590 core/models.py:591
#: core/models.py:598 core/models.py:599
msgid "Recording options"
msgstr "Opnameopties"
#: core/models.py:597
#: core/models.py:606
msgid "External Process ID"
msgstr ""
#: core/models.py:607
msgid "ID of the external process associated with the recording."
msgstr ""
#: core/models.py:613
msgid "Recording"
msgstr "Opname"
#: core/models.py:598
#: core/models.py:614
msgid "Recordings"
msgstr "Opnames"
#: core/models.py:706
#: core/models.py:724
msgid "Recording/user relation"
msgstr "Opname/gebruiker-relatie"
#: core/models.py:707
#: core/models.py:725
msgid "Recording/user relations"
msgstr "Opname/gebruiker-relaties"
#: core/models.py:713
#: core/models.py:731
msgid "This user is already in this recording."
msgstr "Deze gebruiker is al in deze opname."
#: core/models.py:719
#: core/models.py:737
msgid "This team is already in this recording."
msgstr "Dit team is al in deze opname."
#: core/models.py:725
#: core/models.py:743
msgid "Either user or team must be set, not both."
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
#: core/models.py:742
#: core/models.py:760
msgid "Create rooms"
msgstr "Ruimtes aanmaken"
#: core/models.py:743
#: core/models.py:761
msgid "List rooms"
msgstr "Ruimtes weergeven"
#: core/models.py:744
#: core/models.py:762
msgid "Retrieve room details"
msgstr "Details van een ruimte ophalen"
#: core/models.py:745
#: core/models.py:763
msgid "Update rooms"
msgstr "Ruimtes bijwerken"
#: core/models.py:746
#: core/models.py:764
msgid "Delete rooms"
msgstr "Ruimtes verwijderen"
#: core/models.py:759
#: core/models.py:777
msgid "Application name"
msgstr "Naam van de applicatie"
#: core/models.py:760
#: core/models.py:778
msgid "Descriptive name for this application."
msgstr "Beschrijvende naam voor deze applicatie."
#: core/models.py:770
#: core/models.py:788
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr ""
"Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
#: core/models.py:781
#: core/models.py:799
msgid "Application"
msgstr "Applicatie"
#: core/models.py:782
#: core/models.py:800
msgid "Applications"
msgstr "Applicaties"
#: core/models.py:805
#: core/models.py:823
msgid "Enter a valid domain"
msgstr "Voer een geldig domein in"
#: core/models.py:808
#: core/models.py:826
msgid "Domain"
msgstr "Domein"
#: core/models.py:809
#: core/models.py:827
msgid "Email domain this application can act on behalf of."
msgstr "E-maildomein namens welke deze applicatie kan handelen."
#: core/models.py:821
#: core/models.py:839
msgid "Application domain"
msgstr "Applicatiedomein"
#: core/models.py:822
#: core/models.py:840
msgid "Application domains"
msgstr "Applicatiedomeinen"
#: core/models.py:840
#: core/models.py:858
msgid "Pending"
msgstr "In afwachting"
#: core/models.py:848
#: core/models.py:866
msgid "Ready"
msgstr "Klaar"
#: core/models.py:854
#: core/models.py:872
msgid "Background image"
msgstr "Achtergrondafbeelding"
#: core/models.py:866
#: core/models.py:884
msgid "title"
msgstr "Titel"
#: core/models.py:890
#: core/models.py:908
msgid "Malware detection info when the analysis status is unsafe."
msgstr "Informatie over malwaredetectie wanneer de analysestatus onveilig is."
#: core/models.py:895
#: core/models.py:913
msgid "File"
msgstr "Bestand"
#: core/models.py:896
#: core/models.py:914
msgid "Files"
msgstr "Bestanden"
#: core/models.py:1000
#: core/models.py:1018
msgid "This file is already hard deleted."
msgstr "Dit bestand is al definitief verwijderd."
#: core/models.py:1010
#: core/models.py:1028
#, fuzzy
#| msgid "To hard delete a file, it must first be soft deleted."
msgid "To hard delete a file, it must first be soft deleted."
@@ -471,7 +487,7 @@ msgstr ""
"Om een bestand definitief te verwijderen, moet het eerst zacht verwijderd "
"zijn."
#: core/recording/event/notification.py:116
#: core/recording/event/notification.py:124
msgid "Your recording is ready"
msgstr "Je opname is klaar"
@@ -571,7 +587,9 @@ msgstr "Om deze opname permanent te bewaren:"
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
#, fuzzy
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgid "Click the \"Open\" button below "
msgstr "Klik op de \"<a href=\"%(link)s\">Openen</a>\"-link hieronder "
#: core/templates/mail/html/screen_recording.html:209
+3
View File
@@ -744,6 +744,9 @@ class Base(Configuration):
SUMMARY_SERVICE_API_TOKEN = SecretFileValue(
None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None
)
SUMMARY_SERVICE_WEBHOOK_API_TOKEN = SecretFileValue(
None, environ_name="SUMMARY_SERVICE_WEBHOOK_API_TOKEN", environ_prefix=None
)
SCREEN_RECORDING_BASE_URL = values.Value(
None, environ_name="SCREEN_RECORDING_BASE_URL", environ_prefix=None
)
@@ -105,8 +105,9 @@ backend:
RECORDING_ENABLE: True
RECORDING_STORAGE_EVENT_ENABLE: True
RECORDING_STORAGE_EVENT_TOKEN: password
SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v1/tasks/
SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v2/async-jobs/transcribe/
SUMMARY_SERVICE_API_TOKEN: password
SUMMARY_SERVICE_WEBHOOK_API_TOKEN: webhook-password
RECORDING_DOWNLOAD_BASE_URL: https://meet.127.0.0.1.nip.io/recording
ROOM_TELEPHONY_ENABLED: True
SSL_CERT_FILE: /app/.venv/lib/python3.13/site-packages/certifi/cacert.pem
@@ -6,7 +6,26 @@ _summaryEnvVars: &summaryEnvVars
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
AWS_S3_SECURE_ACCESS: False
AUTHORIZED_TENANTS: '[{"id": "dictaphone", "api_key": "dictaphone_token", "webhook_url": "http://dictaphone-backend.dictaphone.svc.cluster.local/api/v1.0/ai-jobs/webhook/", "webhook_api_key": "token_summary"}]'
# Note: setting allowed_push_to_docs false because it won't work in dev mode
AUTHORIZED_TENANTS: >
[
{
"id": "dictaphone",
"api_key": "dictaphone_token",
"webhook_url": "http://dictaphone-backend.dictaphone.svc.cluster.local/api/v1.0/ai-jobs/webhook/",
"webhook_api_key": "token_summary",
"allowed_push_to_docs": false
},
{
"id": "visio",
"api_key": "password",
"webhook_url": "https://meet.127.0.0.1.nip.io/api/v1.0/recordings/external-process-hook/",
"webhook_api_key": "webhook-password",
"allowed_push_to_docs": false
}
]
SSL_CERT_FILE: /usr/local/lib/python3.13/site-packages/certifi/cacert.pem
IS_DOCS_INTEGRATION_ENABLED: false
WHISPERX_API_KEY:
secretKeyRef:
name: secret-dev
@@ -109,8 +128,9 @@ backend:
RECORDING_ENABLE: True
RECORDING_STORAGE_EVENT_ENABLE: True
RECORDING_STORAGE_EVENT_TOKEN: password
SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v1/tasks/
SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v2/async-jobs/transcribe/
SUMMARY_SERVICE_API_TOKEN: password
SUMMARY_SERVICE_WEBHOOK_API_TOKEN: webhook-password
RECORDING_DOWNLOAD_BASE_URL: https://meet.127.0.0.1.nip.io/recording
ROOM_TELEPHONY_ENABLED: True
ROOM_TELEPHONY_DEFAULT_COUNTRY: 'FR'
@@ -258,6 +278,23 @@ celerySummaryBackend:
- "-Q"
- "call-webhook-queue-v2"
# Extra volume mounts to manage our local custom CA and avoid to set ssl_verify: false
extraVolumeMounts:
- name: certs
mountPath: /usr/local/lib/python3.13/site-packages/certifi/cacert.pem
subPath: cacert.pem
# Extra volumes to manage our local custom CA and avoid to set ssl_verify: false
extraVolumes:
- name: certs
configMap:
name: certifi
items:
- key: cacert.pem
path: cacert.pem
agentMetadata:
replicas: 1
envVars:
+2 -1
View File
@@ -14,7 +14,8 @@ dependencies = [
"posthog==7.9.12",
"requests==2.33.0",
"sentry-sdk[fastapi, celery]==2.54.0",
"langfuse==4.0.0"
"langfuse==4.0.0",
"ruff>=0.15.6",
]
[project.optional-dependencies]
+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}
+18 -2
View File
@@ -1,7 +1,10 @@
"""API routes related to application tasks (V2 / tenant friendly)."""
import logging
from datetime import datetime, timezone
from celery.result import AsyncResult
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi import APIRouter, Depends, HTTPException, Request, status
from summary.core.celery_worker import (
celery,
@@ -20,6 +23,7 @@ from summary.core.shared_models import (
TranscribeWebhookSuccessPayload,
)
logger = logging.getLogger(__name__)
router_tasks_v2 = APIRouter()
@@ -29,8 +33,20 @@ async def create_transcribe_task_v2(
request_tenant: AuthorizedTenant = Depends(verify_tenant_api_key_v2),
):
"""Create a transcription task."""
if (
request.push_to_docs_config is not None
and not request_tenant.allowed_push_to_docs
):
logger.error(
f"Push to docs is not allowed for this tenant ({request_tenant.id})."
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Push to docs is not allowed for this tenant.",
)
task = process_audio_transcribe_v2_task.apply_async(
args=[{**request.model_dump(), "tenant_id": request_tenant.id}]
args=[{**request.model_dump(), "tenant_id": request_tenant.id, "received_at": datetime.now(timezone.utc)}]
)
return TranscribeWebhookPendingPayload(job_id=task.id).model_dump()
+10 -8
View File
@@ -4,12 +4,14 @@ import json
import time
from collections import Counter
from functools import lru_cache
from urllib.parse import urlsplit, urlunsplit
import redis
from celery.utils.log import get_task_logger
from posthog import Posthog
from summary.core.config import get_settings
from summary.core.models import TranscribeTaskV2Payload
logger = get_task_logger(__name__)
settings = get_settings()
@@ -107,23 +109,23 @@ class MetadataManager:
"""Check if task_id exists in tasks metadata cache."""
return self._redis.exists(self._get_redis_key(task_id))
def create(self, task_id, task_args):
def create(self, task_id: str, task_payload: TranscribeTaskV2Payload):
"""Create initial metadata entry for a new task."""
if self._is_disabled or self.has_task_id(task_id):
return
# Positional args mirror process_audio_transcribe_summarize_v2 signature:
# owner_id, recording_filename, metadata_filename, email, sub, received_at, ...
_, filename, _, email, _, received_at, *_ = task_args
start_time = time.time()
parts = urlsplit(task_payload.cloud_storage_url)
clean_url = urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
initial_metadata = {
"start_time": start_time,
"asr_model": settings.whisperx_asr_model,
"retries": 0,
"filename": filename,
"email": email,
"queuing_time": round(start_time - received_at, 2),
"filename": clean_url,
"sub": task_payload.user_sub,
"email": task_payload.user_email,
"tenant_id": task_payload.tenant_id,
"queuing_time": round(start_time - task_payload.received_at.timestamp(), 2),
}
self._save_metadata(task_id, initial_metadata)
+100 -192
View File
@@ -4,7 +4,6 @@
import json
import time
from datetime import datetime
import openai
import sentry_sdk
@@ -14,10 +13,12 @@ from requests import exceptions
from summary.core.analytics import MetadataManager, get_analytics
from summary.core.config import get_settings
from summary.core.docs_service import create_document_in_docs
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,
)
@@ -43,7 +44,6 @@ 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 +79,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 +100,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"]})
@@ -149,11 +142,7 @@ def transcribe_audio(
cloud_storage_url.split("?", 1)[0] if cloud_storage_url else None
)
logger.exception(
(
"Unexpected error while preparing file | filename: %s "
"| cloud_storage_url: %s"
),
recording_filename,
("Unexpected error while preparing file %s "),
redacted_cloud_storage_url,
)
return None
@@ -163,41 +152,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.started_at,
recording_metadata.ended_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.started_at,
recording_metadata.ended_at,
)
new_transcription = speaker_mapping.apply_to(transcription.model_dump())
return new_transcription
@@ -225,11 +204,8 @@ 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]:
) -> str:
"""Format a transcription into readable content with a title.
Resolves the locale from context_language / language, then uses
@@ -242,9 +218,6 @@ def format_transcript(
return formatter.format(
transcription,
room=room,
recording_datetime=recording_datetime,
owner_timezone=owner_timezone,
download_link=download_link,
)
@@ -267,130 +240,9 @@ 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)
def summarize_transcription_internals(
*, owner_id: str, transcript: str, session_id: str
*, user_sub: str, transcript: str, session_id: str
) -> str:
"""Generate a summary from the provided transcription text.
@@ -401,11 +253,11 @@ def summarize_transcription_internals(
"""
logger.info(
"Starting summarization task | Owner: %s",
owner_id,
user_sub,
)
user_has_tracing_consent = analytics.is_feature_enabled(
"summary-tracing-consent", distinct_id=owner_id
"summary-tracing-consent", distinct_id=user_sub
)
# NOTE: We must instantiate a new LLMObservability client for each task invocation
@@ -416,7 +268,7 @@ def summarize_transcription_internals(
llm_observability = LLMObservability(
user_has_tracing_consent=user_has_tracing_consent,
session_id=session_id,
user_id=owner_id,
user_id=user_sub,
)
llm_service = LLMService(llm_observability=llm_observability)
@@ -468,29 +320,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 +378,64 @@ 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}")
# We do it synchronously for now
if (
payload.push_to_docs_config
and settings.is_docs_integration_enabled
and settings.get_authorized_tenant(
tenant_id=payload.tenant_id
).allowed_push_to_docs
):
# Format output
content = format_transcript(
transcription_res,
payload.context_language,
payload.language,
payload.push_to_docs_config.download_link,
)
create_document_in_docs(
content=content,
title=payload.push_to_docs_config.title,
email=payload.push_to_docs_config.user_email,
sub=payload.user_sub,
)
if (
payload.push_to_docs_config.auto_create_summary
and analytics.is_feature_enabled(
"summary-enabled", distinct_id=payload.user_sub
)
and settings.is_summary_enabled
):
summary = summarize_transcription_internals(
user_sub=payload.user_sub,
transcript=content,
session_id=self.request.id,
)
locale = get_locale(payload.context_language, payload.language)
create_document_in_docs(
content=summary,
title=locale.summary_title_template.format(
title=payload.push_to_docs_config.title
),
email=payload.push_to_docs_config.user_email,
sub=payload.user_sub,
)
metadata_manager.capture(job_id, settings.posthog_event_success)
file_service.store_transcript(
transcript=transcription_res,
job_id=job_id,
@@ -561,9 +448,30 @@ 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()
@signals.task_prerun.connect(sender=process_audio_transcribe_v2_task)
def task_started(task_id=None, task=None, args=None, **kwargs):
"""Signal handler called before task execution begins."""
if args:
metadata_manager.create(task_id, TranscribeTaskV2Payload.model_validate(args[0]))
@signals.task_retry.connect(sender=process_audio_transcribe_v2_task)
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_v2_task)
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_failure.connect(sender=process_audio_transcribe_v2_task)
def handle_transcribe_v2_failed(
sender,
@@ -621,7 +529,7 @@ def summarize_v2_task(
"""
payload = SummarizeTaskV2Payload.model_validate(payload)
summary = summarize_transcription_internals(
owner_id=payload.user_sub,
user_sub=payload.user_sub,
transcript=payload.content,
session_id=self.request.id,
)
+14 -52
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, Mapping, Optional, Set
from fastapi import Depends
from pydantic import (
@@ -34,9 +33,12 @@ class AuthorizedTenant(BaseModel):
title="Webhook API Key",
description="The api_key to authenticate the webhook request.",
)
V1_DEFAULT_TENANT_ID = "__deprecated_meet_tenant__"
allowed_push_to_docs: bool = Field(
title="Allow Push to Docs",
description="Whether to allow pushing transcript"
" and summaries to docs for this tenant.",
default=False,
)
class Settings(BaseSettings):
@@ -45,14 +47,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 +72,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,15 +112,16 @@ 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}"
# Summary related settings
is_summary_enabled: bool = True
# Docs service configuration
is_docs_integration_enabled: bool = True
docs_base_url: str = "https://example.com"
docs_server_to_server_api_key: SecretStr = Field(
title="API key for using docs server to server api", default="NO_API_KEY"
)
# Sentry
sentry_is_enabled: bool = False
sentry_dsn: Optional[str] = None
@@ -145,33 +144,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 +162,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."""
+78
View File
@@ -0,0 +1,78 @@
"""Service for delivering content to external destinations."""
import json
import logging
from requests import Session
from requests.adapters import HTTPAdapter
from urllib3.util import Retry
from summary.core.config import get_settings
settings = get_settings()
logger = logging.getLogger(__name__)
def _create_retry_session(api_key: str | None = None):
"""Create an HTTP session configured with retry logic."""
session = Session()
retries = Retry(
total=settings.webhook_max_retries,
backoff_factor=settings.webhook_backoff_factor,
status_forcelist=settings.webhook_status_forcelist,
allowed_methods={"POST"},
)
session.mount("https://", HTTPAdapter(max_retries=retries))
if api_key:
session.headers.update({"Authorization": f"Bearer {api_key}"})
return session
def _post_with_retries(*, url, data, api_key: str | None = None):
"""Send POST request with automatic retries."""
session = _create_retry_session(api_key=api_key)
try:
response = session.post(url, json=data, timeout=(20, 3 * 60))
response.raise_for_status()
return response
finally:
session.close()
def create_document_in_docs(*, content: str, title: str, email: str, sub: str) -> None:
"""Call the Docs API to create a document on behalf of the user there.
Builds the payload, sends it with retries, and logs the outcome.
"""
data = {
"title": title,
"content": content,
"email": email,
"sub": sub,
}
logger.debug("Submitting to %s", settings.docs_base_url)
logger.debug("Request payload: %s", json.dumps(data, indent=2))
response = _post_with_retries(
url=settings.docs_base_url,
api_key=settings.docs_server_to_server_api_key.get_secret_value(),
data=data,
)
try:
response_data = response.json()
document_id = response_data.get("id", "N/A")
except (json.JSONDecodeError, AttributeError):
document_id = "Unable to parse response"
response_data = response.text
logger.info(
"Delivery success | Document %s submitted (HTTP %s)",
document_id,
response.status_code,
)
logger.debug("Full response: %s", response_data)
+6 -88
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(
@@ -204,12 +155,6 @@ class FileService:
raise ValueError("Invalid cloud_storage_url")
extension = Path(urlparse(cloud_storage_url).path).suffix.lower()
if extension not in self._allowed_extensions:
logger.warning(
"Invalid file extension '%s' from cloud_storage_url", extension
)
raise ValueError(f"Invalid file extension '{extension}'")
try:
with requests.get(
cloud_storage_url,
@@ -302,33 +247,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 +272,7 @@ 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)
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+56 -2
View File
@@ -1,6 +1,7 @@
"""Models for the API & Celery tasks creation."""
from datetime import datetime
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
@@ -12,6 +13,47 @@ class SharedV2TaskCreation(BaseModel):
"""Model that holds basic information for task creation."""
user_sub: str = Field(title="User Sub", description="The user's sub.")
user_email: str | None = Field(
title="User Email", description="The user's email for analytics purposes."
)
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.",
)
started_at: AwareDatetime = Field(title="Start time of the recording to transcribe")
ended_at: AwareDatetime = Field(title="End time of the recording to transcribe")
class PushToDocsBaseConfig(BaseModel):
"""Model containing information for pushing transcript and summaries to docs."""
user_email: str = Field(
title="User Email", description="The user's email, future owner of the docs."
)
title: str = Field(title="Title", description="The title for the created document.")
class PushToDocsTranscriptConfig(PushToDocsBaseConfig):
"""Model for push to docs information for transcripts."""
download_link: str | None = Field(
title="Download Link", description="The link to download the recording."
)
auto_create_summary: bool = Field(
title="Auto Create Summary Docs",
description="Whether to automatically create a summary "
"for the transcription task and push it to docs.",
default=False,
)
class PushToDocsSummaryConfig(PushToDocsBaseConfig):
"""Model for push to docs information for summaries."""
class TranscribeTaskV2Request(SharedV2TaskCreation):
@@ -27,7 +69,17 @@ 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,
)
push_to_docs_config: PushToDocsTranscriptConfig | None = Field(
title="Push to Docs info",
description="If set, configuration for pushing to docs",
default=None,
)
@field_validator("language")
@@ -46,6 +98,7 @@ class TranscribeTaskV2Payload(TranscribeTaskV2Request):
"""Model for creating a transcribe and summarize task (used for actual task creation).""" # noqa: E501
tenant_id: str = Field(title="Tenant ID", description="The ID of the tenant.")
received_at: datetime = Field(title="Received At", description="The time the task was received.")
class SummarizeTaskV2Request(SharedV2TaskCreation):
@@ -58,3 +111,4 @@ class SummarizeTaskV2Payload(SummarizeTaskV2Request):
"""Model for creating a summarize task (used for actual task creation)."""
tenant_id: str = Field(title="Tenant ID", description="The ID of the tenant.")
received_at: datetime = Field(title="Received At", description="The time the task was received.")
@@ -159,4 +159,5 @@ __all__ = [
"SummarizeWebhookPayloads",
"WebhookPayloads",
"WhisperXResponse",
"webhook_payload_adapter",
]
@@ -1,9 +1,6 @@
"""Transcript formatting into readable conversation format with speaker labels."""
import logging
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
@@ -41,12 +38,9 @@ class TranscriptFormatter:
def format(
self,
transcription,
room: str | None = None,
recording_datetime: str | None = None,
owner_timezone: str | None = None,
download_link: str | None = None,
) -> Tuple[str, str]:
"""Format transcription into the final document and its title."""
) -> str:
"""Format transcription into the final document."""
segments = self._get_segments(transcription)
if not segments:
@@ -56,9 +50,7 @@ class TranscriptFormatter:
content = self._remove_hallucinations(content)
content = self._add_header(content, download_link)
title = self._generate_title(room, recording_datetime, owner_timezone)
return content, title
return content
def _remove_hallucinations(self, content: str) -> str:
"""Remove hallucination patterns from content."""
@@ -96,23 +88,3 @@ class TranscriptFormatter:
content = header + content
return content
def _generate_title(
self,
room: str | None = None,
recording_datetime: str | None = None,
owner_timezone: str | None = None,
) -> str:
"""Generate title from context or return default."""
if not room or not recording_datetime:
return self._locale.document_default_title
dt = datetime.fromisoformat(recording_datetime)
if owner_timezone:
dt = dt.astimezone(ZoneInfo(owner_timezone))
return self._locale.document_title_template.format(
room=room,
room_recording_date=dt.strftime("%Y-%m-%d"),
room_recording_time=dt.strftime("%H:%M"),
)
@@ -4,9 +4,6 @@ import json
import logging
import requests
from requests import Session
from requests.adapters import HTTPAdapter
from urllib3.util import Retry
from summary.core.config import get_settings
from summary.core.shared_models import (
@@ -18,83 +15,6 @@ settings = get_settings()
logger = logging.getLogger(__name__)
def _create_retry_session(api_key: str | None = None):
"""Create an HTTP session configured with retry logic."""
session = Session()
retries = Retry(
total=settings.webhook_max_retries,
backoff_factor=settings.webhook_backoff_factor,
status_forcelist=settings.webhook_status_forcelist,
allowed_methods={"POST"},
)
session.mount("https://", HTTPAdapter(max_retries=retries))
if api_key:
session.headers.update({"Authorization": f"Bearer {api_key}"})
return session
def _post_with_retries(*, url, data, api_key: str | None = None):
"""Send POST request with automatic retries."""
session = _create_retry_session(api_key=api_key)
try:
response = session.post(url, json=data)
response.raise_for_status()
return response
finally:
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)
-1
View File
@@ -11,7 +11,6 @@ from summary.main import app
def get_settings_override():
"""Return settings for tests."""
return Settings(
v1_tenant_id="test-tenant",
authorized_tenants=(
AuthorizedTenant(
webhook_url="https://example.com/webhook",
+1475
View File
File diff suppressed because it is too large Load Diff