mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-13 20:27:01 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| acd7c51758 | |||
| 7e286fcb4b |
@@ -305,7 +305,6 @@ jobs:
|
|||||||
working-directory: src/summary
|
working-directory: src/summary
|
||||||
|
|
||||||
env:
|
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"}]'
|
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_STORAGE_BUCKET_NAME: "http://meet-media-storage"
|
||||||
AWS_S3_ENDPOINT_URL: "minio:9000"
|
AWS_S3_ENDPOINT_URL: "minio:9000"
|
||||||
|
|||||||
@@ -64,8 +64,9 @@ ALLOW_UNREGISTERED_ROOMS=False
|
|||||||
RECORDING_ENABLE=True
|
RECORDING_ENABLE=True
|
||||||
RECORDING_STORAGE_EVENT_ENABLE=True
|
RECORDING_STORAGE_EVENT_ENABLE=True
|
||||||
RECORDING_STORAGE_EVENT_TOKEN=password
|
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_API_TOKEN=password
|
||||||
|
SUMMARY_SERVICE_WEBHOOK_API_TOKEN=webhook-password
|
||||||
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
|
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
|
||||||
|
|
||||||
# Recording encoding (LiveKit Egress advanced options).
|
# Recording encoding (LiveKit Egress advanced options).
|
||||||
|
|||||||
@@ -39,7 +39,10 @@ from core import enums, models, utils
|
|||||||
from core.api.filters import ListFileFilter
|
from core.api.filters import ListFileFilter
|
||||||
from core.enums import MEDIA_STORAGE_URL_PATTERN
|
from core.enums import MEDIA_STORAGE_URL_PATTERN
|
||||||
from core.recording.enums import FileExtension
|
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 (
|
from core.recording.event.exceptions import (
|
||||||
InvalidBucketError,
|
InvalidBucketError,
|
||||||
InvalidFilepathError,
|
InvalidFilepathError,
|
||||||
@@ -995,6 +998,47 @@ class RecordingViewSet(
|
|||||||
{"message": "Event processed."},
|
{"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):
|
def _auth_get_original_url(self, request):
|
||||||
"""
|
"""
|
||||||
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
|
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
|
||||||
|
|||||||
+23
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -60,6 +60,11 @@ class RecordingStatusChoices(models.TextChoices):
|
|||||||
FAILED_TO_START = "failed_to_start", _("Failed to Start")
|
FAILED_TO_START = "failed_to_start", _("Failed to Start")
|
||||||
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
|
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
|
||||||
NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded")
|
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
|
@classmethod
|
||||||
def is_final(cls, status):
|
def is_final(cls, status):
|
||||||
@@ -73,6 +78,8 @@ class RecordingStatusChoices(models.TextChoices):
|
|||||||
cls.STOPPED,
|
cls.STOPPED,
|
||||||
cls.SAVED,
|
cls.SAVED,
|
||||||
cls.ABORTED,
|
cls.ABORTED,
|
||||||
|
cls.EXTERNAL_PROCESS_SUCCESSFUL,
|
||||||
|
cls.EXTERNAL_PROCESS_FAILED,
|
||||||
cls.FAILED_TO_START,
|
cls.FAILED_TO_START,
|
||||||
cls.FAILED_TO_STOP,
|
cls.FAILED_TO_STOP,
|
||||||
}
|
}
|
||||||
@@ -591,6 +598,14 @@ class Recording(BaseModel):
|
|||||||
verbose_name=_("Recording options"),
|
verbose_name=_("Recording options"),
|
||||||
help_text=_("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:
|
class Meta:
|
||||||
db_table = "meet_recording"
|
db_table = "meet_recording"
|
||||||
@@ -646,6 +661,8 @@ class Recording(BaseModel):
|
|||||||
return self.status in {
|
return self.status in {
|
||||||
RecordingStatusChoices.NOTIFICATION_SUCCEEDED,
|
RecordingStatusChoices.NOTIFICATION_SUCCEEDED,
|
||||||
RecordingStatusChoices.SAVED,
|
RecordingStatusChoices.SAVED,
|
||||||
|
RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL,
|
||||||
|
RecordingStatusChoices.EXTERNAL_PROCESS_FAILED,
|
||||||
}
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ logger = logging.getLogger(__name__)
|
|||||||
class MachineUser:
|
class MachineUser:
|
||||||
"""Represent a non-interactive system user for automated storage operations."""
|
"""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.pk = None
|
||||||
self.username = "storage_event_user"
|
self.username = username
|
||||||
self.is_active = True
|
self.is_active = True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -91,3 +91,29 @@ class StorageEventAuthentication(BaseAuthentication):
|
|||||||
def authenticate_header(self, request):
|
def authenticate_header(self, request):
|
||||||
"""Return the WWW-Authenticate header value."""
|
"""Return the WWW-Authenticate header value."""
|
||||||
return f"{self.TOKEN_TYPE} realm='Storage event API'"
|
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."""
|
"""Service to notify external services when a new recording is ready."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import smtplib
|
import smtplib
|
||||||
|
import warnings
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -17,6 +19,7 @@ from asgiref.sync import async_to_sync
|
|||||||
from livekit import api as livekit_api
|
from livekit import api as livekit_api
|
||||||
|
|
||||||
from core import models, utils
|
from core import models, utils
|
||||||
|
from core.utils import generate_download_s3_file_url
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -215,18 +218,45 @@ class NotificationService:
|
|||||||
)(recording.worker_id)
|
)(recording.worker_id)
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
|
# Legacy V1 params to avoid a breaking change
|
||||||
"owner_id": str(owner_access.user.id),
|
"owner_id": str(owner_access.user.id),
|
||||||
"recording_filename": recording.key,
|
"recording_filename": recording.key,
|
||||||
"metadata_filename": metadata_filename,
|
"metadata_filename": metadata_filename,
|
||||||
"email": owner_access.user.email,
|
"email": owner_access.user.email,
|
||||||
"sub": owner_access.user.sub,
|
"sub": owner_access.user.sub,
|
||||||
"room": recording.room.name,
|
"room": recording.room.name,
|
||||||
"language": recording.options.get("language"),
|
|
||||||
"owner_timezone": str(owner_access.user.timezone),
|
"owner_timezone": str(owner_access.user.timezone),
|
||||||
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
|
"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_start_at": (started_at.isoformat() if started_at else None),
|
||||||
"recording_end_at": (ended_at.isoformat() if ended_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 = {
|
headers = {
|
||||||
@@ -242,6 +272,31 @@ class NotificationService:
|
|||||||
timeout=30,
|
timeout=30,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
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:
|
except requests.RequestException as exc:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Summary service error for recording %s. URL: %s. Exception: %s",
|
"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
|
||||||
@@ -455,3 +455,38 @@ def generate_upload_policy(file):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return policy
|
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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \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"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -88,17 +88,17 @@ msgstr "Scopes"
|
|||||||
msgid "Creator is me"
|
msgid "Creator is me"
|
||||||
msgstr "Ersteller bin ich"
|
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."
|
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
|
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
|
||||||
"hinzuzufügen."
|
"hinzuzufügen."
|
||||||
|
|
||||||
#: core/api/serializers.py:509
|
#: core/api/serializers.py:534
|
||||||
msgid "This file extension is not allowed."
|
msgid "This file extension is not allowed."
|
||||||
msgstr "Diese Dateiendung ist nicht erlaubt."
|
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."
|
msgid "You have reached the maximum number of files for this type."
|
||||||
msgstr "Sie haben die maximale Anzahl an Dateien dieses Typs erreicht."
|
msgstr "Sie haben die maximale Anzahl an Dateien dieses Typs erreicht."
|
||||||
|
|
||||||
@@ -146,51 +146,59 @@ msgstr "Stopp fehlgeschlagen"
|
|||||||
msgid "Notification succeeded"
|
msgid "Notification succeeded"
|
||||||
msgstr "Benachrichtigung erfolgreich"
|
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"
|
msgid "SCREEN_RECORDING"
|
||||||
msgstr "BILDSCHIRMAUFZEICHNUNG"
|
msgstr "BILDSCHIRMAUFZEICHNUNG"
|
||||||
|
|
||||||
#: core/models.py:90
|
#: core/models.py:97
|
||||||
msgid "TRANSCRIPT"
|
msgid "TRANSCRIPT"
|
||||||
msgstr "TRANSKRIPT"
|
msgstr "TRANSKRIPT"
|
||||||
|
|
||||||
#: core/models.py:96
|
#: core/models.py:103
|
||||||
msgid "Public Access"
|
msgid "Public Access"
|
||||||
msgstr "Öffentlicher Zugriff"
|
msgstr "Öffentlicher Zugriff"
|
||||||
|
|
||||||
#: core/models.py:97
|
#: core/models.py:104
|
||||||
msgid "Trusted Access"
|
msgid "Trusted Access"
|
||||||
msgstr "Vertrauenswürdiger Zugriff"
|
msgstr "Vertrauenswürdiger Zugriff"
|
||||||
|
|
||||||
#: core/models.py:98
|
#: core/models.py:105
|
||||||
msgid "Restricted Access"
|
msgid "Restricted Access"
|
||||||
msgstr "Eingeschränkter Zugriff"
|
msgstr "Eingeschränkter Zugriff"
|
||||||
|
|
||||||
#: core/models.py:110
|
#: core/models.py:117
|
||||||
msgid "id"
|
msgid "id"
|
||||||
msgstr "ID"
|
msgstr "ID"
|
||||||
|
|
||||||
#: core/models.py:111
|
#: core/models.py:118
|
||||||
msgid "primary key for the record as UUID"
|
msgid "primary key for the record as UUID"
|
||||||
msgstr "Primärschlüssel des Eintrags als UUID"
|
msgstr "Primärschlüssel des Eintrags als UUID"
|
||||||
|
|
||||||
#: core/models.py:117
|
#: core/models.py:124
|
||||||
msgid "created on"
|
msgid "created on"
|
||||||
msgstr "erstellt am"
|
msgstr "erstellt am"
|
||||||
|
|
||||||
#: core/models.py:118
|
#: core/models.py:125
|
||||||
msgid "date and time at which a record was created"
|
msgid "date and time at which a record was created"
|
||||||
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
|
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
|
||||||
|
|
||||||
#: core/models.py:123
|
#: core/models.py:130
|
||||||
msgid "updated on"
|
msgid "updated on"
|
||||||
msgstr "aktualisiert am"
|
msgstr "aktualisiert am"
|
||||||
|
|
||||||
#: core/models.py:124
|
#: core/models.py:131
|
||||||
msgid "date and time at which a record was last updated"
|
msgid "date and time at which a record was last updated"
|
||||||
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
|
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
|
||||||
|
|
||||||
#: core/models.py:144
|
#: core/models.py:151
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||||
"_ characters."
|
"_ characters."
|
||||||
@@ -198,11 +206,11 @@ msgstr ""
|
|||||||
"Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen "
|
"Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen "
|
||||||
"und die Zeichen @/./+/-/_ enthalten."
|
"und die Zeichen @/./+/-/_ enthalten."
|
||||||
|
|
||||||
#: core/models.py:150
|
#: core/models.py:157
|
||||||
msgid "sub"
|
msgid "sub"
|
||||||
msgstr "Sub"
|
msgstr "Sub"
|
||||||
|
|
||||||
#: core/models.py:152
|
#: core/models.py:159
|
||||||
msgid ""
|
msgid ""
|
||||||
"Optional for pending users; required upon account activation. 255 characters "
|
"Optional for pending users; required upon account activation. 255 characters "
|
||||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||||
@@ -210,55 +218,55 @@ msgstr ""
|
|||||||
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
|
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
|
||||||
"Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt."
|
"Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt."
|
||||||
|
|
||||||
#: core/models.py:161
|
#: core/models.py:168
|
||||||
msgid "identity email address"
|
msgid "identity email address"
|
||||||
msgstr "Identitäts-E-Mail-Adresse"
|
msgstr "Identitäts-E-Mail-Adresse"
|
||||||
|
|
||||||
#: core/models.py:166
|
#: core/models.py:173
|
||||||
msgid "admin email address"
|
msgid "admin email address"
|
||||||
msgstr "Administrator-E-Mail-Adresse"
|
msgstr "Administrator-E-Mail-Adresse"
|
||||||
|
|
||||||
#: core/models.py:168
|
#: core/models.py:175
|
||||||
msgid "full name"
|
msgid "full name"
|
||||||
msgstr "Vollständiger Name"
|
msgstr "Vollständiger Name"
|
||||||
|
|
||||||
#: core/models.py:170
|
#: core/models.py:177
|
||||||
msgid "short name"
|
msgid "short name"
|
||||||
msgstr "Kurzname"
|
msgstr "Kurzname"
|
||||||
|
|
||||||
#: core/models.py:176
|
#: core/models.py:183
|
||||||
msgid "language"
|
msgid "language"
|
||||||
msgstr "Sprache"
|
msgstr "Sprache"
|
||||||
|
|
||||||
#: core/models.py:177
|
#: core/models.py:184
|
||||||
msgid "The language in which the user wants to see the interface."
|
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."
|
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."
|
msgid "The timezone in which the user wants to see times."
|
||||||
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
|
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
|
||||||
|
|
||||||
#: core/models.py:186
|
#: core/models.py:193
|
||||||
msgid "device"
|
msgid "device"
|
||||||
msgstr "Gerät"
|
msgstr "Gerät"
|
||||||
|
|
||||||
#: core/models.py:188
|
#: core/models.py:195
|
||||||
msgid "Whether the user is a device or a real user."
|
msgid "Whether the user is a device or a real user."
|
||||||
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
|
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
|
||||||
|
|
||||||
#: core/models.py:191
|
#: core/models.py:198
|
||||||
msgid "staff status"
|
msgid "staff status"
|
||||||
msgstr "Mitarbeiterstatus"
|
msgstr "Mitarbeiterstatus"
|
||||||
|
|
||||||
#: core/models.py:193
|
#: core/models.py:200
|
||||||
msgid "Whether the user can log into this admin site."
|
msgid "Whether the user can log into this admin site."
|
||||||
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
|
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
|
||||||
|
|
||||||
#: core/models.py:196
|
#: core/models.py:203
|
||||||
msgid "active"
|
msgid "active"
|
||||||
msgstr "aktiv"
|
msgstr "aktiv"
|
||||||
|
|
||||||
#: core/models.py:199
|
#: core/models.py:206
|
||||||
msgid ""
|
msgid ""
|
||||||
"Whether this user should be treated as active. Unselect this instead of "
|
"Whether this user should be treated as active. Unselect this instead of "
|
||||||
"deleting accounts."
|
"deleting accounts."
|
||||||
@@ -266,66 +274,66 @@ msgstr ""
|
|||||||
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
|
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
|
||||||
"anstelle des Löschens des Kontos."
|
"anstelle des Löschens des Kontos."
|
||||||
|
|
||||||
#: core/models.py:212
|
#: core/models.py:219
|
||||||
msgid "user"
|
msgid "user"
|
||||||
msgstr "Benutzer"
|
msgstr "Benutzer"
|
||||||
|
|
||||||
#: core/models.py:213
|
#: core/models.py:220
|
||||||
msgid "users"
|
msgid "users"
|
||||||
msgstr "Benutzer"
|
msgstr "Benutzer"
|
||||||
|
|
||||||
#: core/models.py:272
|
#: core/models.py:279
|
||||||
msgid "Resource"
|
msgid "Resource"
|
||||||
msgstr "Ressource"
|
msgstr "Ressource"
|
||||||
|
|
||||||
#: core/models.py:273
|
#: core/models.py:280
|
||||||
msgid "Resources"
|
msgid "Resources"
|
||||||
msgstr "Ressourcen"
|
msgstr "Ressourcen"
|
||||||
|
|
||||||
#: core/models.py:331
|
#: core/models.py:338
|
||||||
msgid "Resource access"
|
msgid "Resource access"
|
||||||
msgstr "Ressourcenzugriff"
|
msgstr "Ressourcenzugriff"
|
||||||
|
|
||||||
#: core/models.py:332
|
#: core/models.py:339
|
||||||
msgid "Resource accesses"
|
msgid "Resource accesses"
|
||||||
msgstr "Ressourcenzugriffe"
|
msgstr "Ressourcenzugriffe"
|
||||||
|
|
||||||
#: core/models.py:338
|
#: core/models.py:345
|
||||||
msgid "Resource access with this User and Resource already exists."
|
msgid "Resource access with this User and Resource already exists."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
|
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
|
||||||
"bereits."
|
"bereits."
|
||||||
|
|
||||||
#: core/models.py:394
|
#: core/models.py:402
|
||||||
msgid "Visio room configuration"
|
msgid "Visio room configuration"
|
||||||
msgstr "Visio-Raumkonfiguration"
|
msgstr "Visio-Raumkonfiguration"
|
||||||
|
|
||||||
#: core/models.py:395
|
#: core/models.py:403
|
||||||
msgid "Values for Visio parameters to configure the room."
|
msgid "Values for Visio parameters to configure the room."
|
||||||
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
|
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
|
||||||
|
|
||||||
#: core/models.py:402
|
#: core/models.py:410
|
||||||
msgid "Room PIN code"
|
msgid "Room PIN code"
|
||||||
msgstr "PIN-Code für den Raum"
|
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."
|
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
|
"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"
|
msgid "Room"
|
||||||
msgstr "Raum"
|
msgstr "Raum"
|
||||||
|
|
||||||
#: core/models.py:410
|
#: core/models.py:418
|
||||||
msgid "Rooms"
|
msgid "Rooms"
|
||||||
msgstr "Räume"
|
msgstr "Räume"
|
||||||
|
|
||||||
#: core/models.py:574
|
#: core/models.py:582
|
||||||
msgid "Worker ID"
|
msgid "Worker ID"
|
||||||
msgstr "Worker-ID"
|
msgstr "Worker-ID"
|
||||||
|
|
||||||
#: core/models.py:576
|
#: core/models.py:584
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||||
"the worker stops, allowing for easy tracking."
|
"the worker stops, allowing for easy tracking."
|
||||||
@@ -334,141 +342,149 @@ msgstr ""
|
|||||||
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
|
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
|
||||||
"ermöglicht."
|
"ermöglicht."
|
||||||
|
|
||||||
#: core/models.py:584
|
#: core/models.py:592
|
||||||
msgid "Recording mode"
|
msgid "Recording mode"
|
||||||
msgstr "Aufzeichnungsmodus"
|
msgstr "Aufzeichnungsmodus"
|
||||||
|
|
||||||
#: core/models.py:585
|
#: core/models.py:593
|
||||||
msgid "Defines the mode of recording being called."
|
msgid "Defines the mode of recording being called."
|
||||||
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
|
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
|
||||||
|
|
||||||
#: core/models.py:590 core/models.py:591
|
#: core/models.py:598 core/models.py:599
|
||||||
msgid "Recording options"
|
msgid "Recording options"
|
||||||
msgstr "Aufnahmeoptionen"
|
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"
|
msgid "Recording"
|
||||||
msgstr "Aufzeichnung"
|
msgstr "Aufzeichnung"
|
||||||
|
|
||||||
#: core/models.py:598
|
#: core/models.py:614
|
||||||
msgid "Recordings"
|
msgid "Recordings"
|
||||||
msgstr "Aufzeichnungen"
|
msgstr "Aufzeichnungen"
|
||||||
|
|
||||||
#: core/models.py:706
|
#: core/models.py:724
|
||||||
msgid "Recording/user relation"
|
msgid "Recording/user relation"
|
||||||
msgstr "Beziehung Aufzeichnung/Benutzer"
|
msgstr "Beziehung Aufzeichnung/Benutzer"
|
||||||
|
|
||||||
#: core/models.py:707
|
#: core/models.py:725
|
||||||
msgid "Recording/user relations"
|
msgid "Recording/user relations"
|
||||||
msgstr "Beziehungen Aufzeichnung/Benutzer"
|
msgstr "Beziehungen Aufzeichnung/Benutzer"
|
||||||
|
|
||||||
#: core/models.py:713
|
#: core/models.py:731
|
||||||
msgid "This user is already in this recording."
|
msgid "This user is already in this recording."
|
||||||
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
|
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
|
||||||
|
|
||||||
#: core/models.py:719
|
#: core/models.py:737
|
||||||
msgid "This team is already in this recording."
|
msgid "This team is already in this recording."
|
||||||
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
|
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."
|
msgid "Either user or team must be set, not both."
|
||||||
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
|
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
|
||||||
|
|
||||||
#: core/models.py:742
|
#: core/models.py:760
|
||||||
msgid "Create rooms"
|
msgid "Create rooms"
|
||||||
msgstr "Räume erstellen"
|
msgstr "Räume erstellen"
|
||||||
|
|
||||||
#: core/models.py:743
|
#: core/models.py:761
|
||||||
msgid "List rooms"
|
msgid "List rooms"
|
||||||
msgstr "Räume auflisten"
|
msgstr "Räume auflisten"
|
||||||
|
|
||||||
#: core/models.py:744
|
#: core/models.py:762
|
||||||
msgid "Retrieve room details"
|
msgid "Retrieve room details"
|
||||||
msgstr "Raumdetails abrufen"
|
msgstr "Raumdetails abrufen"
|
||||||
|
|
||||||
#: core/models.py:745
|
#: core/models.py:763
|
||||||
msgid "Update rooms"
|
msgid "Update rooms"
|
||||||
msgstr "Räume aktualisieren"
|
msgstr "Räume aktualisieren"
|
||||||
|
|
||||||
#: core/models.py:746
|
#: core/models.py:764
|
||||||
msgid "Delete rooms"
|
msgid "Delete rooms"
|
||||||
msgstr "Räume löschen"
|
msgstr "Räume löschen"
|
||||||
|
|
||||||
#: core/models.py:759
|
#: core/models.py:777
|
||||||
msgid "Application name"
|
msgid "Application name"
|
||||||
msgstr "Anwendungsname"
|
msgstr "Anwendungsname"
|
||||||
|
|
||||||
#: core/models.py:760
|
#: core/models.py:778
|
||||||
msgid "Descriptive name for this application."
|
msgid "Descriptive name for this application."
|
||||||
msgstr "Beschreibender Name für diese Anwendung."
|
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."
|
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
|
"Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
|
||||||
|
|
||||||
#: core/models.py:781
|
#: core/models.py:799
|
||||||
msgid "Application"
|
msgid "Application"
|
||||||
msgstr "Anwendung"
|
msgstr "Anwendung"
|
||||||
|
|
||||||
#: core/models.py:782
|
#: core/models.py:800
|
||||||
msgid "Applications"
|
msgid "Applications"
|
||||||
msgstr "Anwendungen"
|
msgstr "Anwendungen"
|
||||||
|
|
||||||
#: core/models.py:805
|
#: core/models.py:823
|
||||||
msgid "Enter a valid domain"
|
msgid "Enter a valid domain"
|
||||||
msgstr "Geben Sie eine gültige Domain ein"
|
msgstr "Geben Sie eine gültige Domain ein"
|
||||||
|
|
||||||
#: core/models.py:808
|
#: core/models.py:826
|
||||||
msgid "Domain"
|
msgid "Domain"
|
||||||
msgstr "Domain"
|
msgstr "Domain"
|
||||||
|
|
||||||
#: core/models.py:809
|
#: core/models.py:827
|
||||||
msgid "Email domain this application can act on behalf of."
|
msgid "Email domain this application can act on behalf of."
|
||||||
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
|
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
|
||||||
|
|
||||||
#: core/models.py:821
|
#: core/models.py:839
|
||||||
msgid "Application domain"
|
msgid "Application domain"
|
||||||
msgstr "Anwendungsdomain"
|
msgstr "Anwendungsdomain"
|
||||||
|
|
||||||
#: core/models.py:822
|
#: core/models.py:840
|
||||||
msgid "Application domains"
|
msgid "Application domains"
|
||||||
msgstr "Anwendungsdomains"
|
msgstr "Anwendungsdomains"
|
||||||
|
|
||||||
#: core/models.py:840
|
#: core/models.py:858
|
||||||
msgid "Pending"
|
msgid "Pending"
|
||||||
msgstr "Ausstehend"
|
msgstr "Ausstehend"
|
||||||
|
|
||||||
#: core/models.py:848
|
#: core/models.py:866
|
||||||
msgid "Ready"
|
msgid "Ready"
|
||||||
msgstr "Bereit"
|
msgstr "Bereit"
|
||||||
|
|
||||||
#: core/models.py:854
|
#: core/models.py:872
|
||||||
msgid "Background image"
|
msgid "Background image"
|
||||||
msgstr "Hintergrundbild"
|
msgstr "Hintergrundbild"
|
||||||
|
|
||||||
#: core/models.py:866
|
#: core/models.py:884
|
||||||
msgid "title"
|
msgid "title"
|
||||||
msgstr "Titel"
|
msgstr "Titel"
|
||||||
|
|
||||||
#: core/models.py:890
|
#: core/models.py:908
|
||||||
msgid "Malware detection info when the analysis status is unsafe."
|
msgid "Malware detection info when the analysis status is unsafe."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Informationen zur Malware-Erkennung, wenn der Analyse-Status unsicher ist."
|
"Informationen zur Malware-Erkennung, wenn der Analyse-Status unsicher ist."
|
||||||
|
|
||||||
#: core/models.py:895
|
#: core/models.py:913
|
||||||
msgid "File"
|
msgid "File"
|
||||||
msgstr "Datei"
|
msgstr "Datei"
|
||||||
|
|
||||||
#: core/models.py:896
|
#: core/models.py:914
|
||||||
msgid "Files"
|
msgid "Files"
|
||||||
msgstr "Dateien"
|
msgstr "Dateien"
|
||||||
|
|
||||||
#: core/models.py:1000
|
#: core/models.py:1018
|
||||||
msgid "This file is already hard deleted."
|
msgid "This file is already hard deleted."
|
||||||
msgstr "Diese Datei wurde bereits endgültig gelöscht."
|
msgstr "Diese Datei wurde bereits endgültig gelöscht."
|
||||||
|
|
||||||
#: core/models.py:1010
|
#: core/models.py:1028
|
||||||
#, fuzzy
|
#, 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."
|
||||||
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 "
|
"Um eine Datei endgültig zu löschen, muss sie zuvor weich gelöscht worden "
|
||||||
"sein."
|
"sein."
|
||||||
|
|
||||||
#: core/recording/event/notification.py:116
|
#: core/recording/event/notification.py:124
|
||||||
msgid "Your recording is ready"
|
msgid "Your recording is ready"
|
||||||
msgstr "Ihre Aufzeichnung ist bereit"
|
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/html/screen_recording.html:208
|
||||||
#: core/templates/mail/text/screen_recording.txt:13
|
#: 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 "
|
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:209
|
#: core/templates/mail/html/screen_recording.html:209
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \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"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -88,15 +88,15 @@ msgstr "Scopes"
|
|||||||
msgid "Creator is me"
|
msgid "Creator is me"
|
||||||
msgstr "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."
|
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."
|
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."
|
msgid "This file extension is not allowed."
|
||||||
msgstr "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."
|
msgid "You have reached the maximum number of files for this type."
|
||||||
msgstr "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"
|
msgid "Notification succeeded"
|
||||||
msgstr "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"
|
msgid "SCREEN_RECORDING"
|
||||||
msgstr "SCREEN_RECORDING"
|
msgstr "SCREEN_RECORDING"
|
||||||
|
|
||||||
#: core/models.py:90
|
#: core/models.py:97
|
||||||
msgid "TRANSCRIPT"
|
msgid "TRANSCRIPT"
|
||||||
msgstr "TRANSCRIPT"
|
msgstr "TRANSCRIPT"
|
||||||
|
|
||||||
#: core/models.py:96
|
#: core/models.py:103
|
||||||
msgid "Public Access"
|
msgid "Public Access"
|
||||||
msgstr "Public Access"
|
msgstr "Public Access"
|
||||||
|
|
||||||
#: core/models.py:97
|
#: core/models.py:104
|
||||||
msgid "Trusted Access"
|
msgid "Trusted Access"
|
||||||
msgstr "Trusted Access"
|
msgstr "Trusted Access"
|
||||||
|
|
||||||
#: core/models.py:98
|
#: core/models.py:105
|
||||||
msgid "Restricted Access"
|
msgid "Restricted Access"
|
||||||
msgstr "Restricted Access"
|
msgstr "Restricted Access"
|
||||||
|
|
||||||
#: core/models.py:110
|
#: core/models.py:117
|
||||||
msgid "id"
|
msgid "id"
|
||||||
msgstr "id"
|
msgstr "id"
|
||||||
|
|
||||||
#: core/models.py:111
|
#: core/models.py:118
|
||||||
msgid "primary key for the record as UUID"
|
msgid "primary key for the record as UUID"
|
||||||
msgstr "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"
|
msgid "created on"
|
||||||
msgstr "created on"
|
msgstr "created on"
|
||||||
|
|
||||||
#: core/models.py:118
|
#: core/models.py:125
|
||||||
msgid "date and time at which a record was created"
|
msgid "date and time at which a record was created"
|
||||||
msgstr "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"
|
msgid "updated on"
|
||||||
msgstr "updated on"
|
msgstr "updated on"
|
||||||
|
|
||||||
#: core/models.py:124
|
#: core/models.py:131
|
||||||
msgid "date and time at which a record was last updated"
|
msgid "date and time at which a record was last updated"
|
||||||
msgstr "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 ""
|
msgid ""
|
||||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||||
"_ characters."
|
"_ characters."
|
||||||
@@ -196,11 +204,11 @@ msgstr ""
|
|||||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||||
"_ characters."
|
"_ characters."
|
||||||
|
|
||||||
#: core/models.py:150
|
#: core/models.py:157
|
||||||
msgid "sub"
|
msgid "sub"
|
||||||
msgstr "sub"
|
msgstr "sub"
|
||||||
|
|
||||||
#: core/models.py:152
|
#: core/models.py:159
|
||||||
msgid ""
|
msgid ""
|
||||||
"Optional for pending users; required upon account activation. 255 characters "
|
"Optional for pending users; required upon account activation. 255 characters "
|
||||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||||
@@ -208,55 +216,55 @@ msgstr ""
|
|||||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||||
"characters only."
|
"characters only."
|
||||||
|
|
||||||
#: core/models.py:161
|
#: core/models.py:168
|
||||||
msgid "identity email address"
|
msgid "identity email address"
|
||||||
msgstr "identity email address"
|
msgstr "identity email address"
|
||||||
|
|
||||||
#: core/models.py:166
|
#: core/models.py:173
|
||||||
msgid "admin email address"
|
msgid "admin email address"
|
||||||
msgstr "admin email address"
|
msgstr "admin email address"
|
||||||
|
|
||||||
#: core/models.py:168
|
#: core/models.py:175
|
||||||
msgid "full name"
|
msgid "full name"
|
||||||
msgstr "full name"
|
msgstr "full name"
|
||||||
|
|
||||||
#: core/models.py:170
|
#: core/models.py:177
|
||||||
msgid "short name"
|
msgid "short name"
|
||||||
msgstr "short name"
|
msgstr "short name"
|
||||||
|
|
||||||
#: core/models.py:176
|
#: core/models.py:183
|
||||||
msgid "language"
|
msgid "language"
|
||||||
msgstr "language"
|
msgstr "language"
|
||||||
|
|
||||||
#: core/models.py:177
|
#: core/models.py:184
|
||||||
msgid "The language in which the user wants to see the interface."
|
msgid "The language in which the user wants to see the interface."
|
||||||
msgstr "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."
|
msgid "The timezone in which the user wants to see times."
|
||||||
msgstr "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"
|
msgid "device"
|
||||||
msgstr "device"
|
msgstr "device"
|
||||||
|
|
||||||
#: core/models.py:188
|
#: core/models.py:195
|
||||||
msgid "Whether the user is a device or a real user."
|
msgid "Whether the user is a device or a real user."
|
||||||
msgstr "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"
|
msgid "staff status"
|
||||||
msgstr "staff status"
|
msgstr "staff status"
|
||||||
|
|
||||||
#: core/models.py:193
|
#: core/models.py:200
|
||||||
msgid "Whether the user can log into this admin site."
|
msgid "Whether the user can log into this admin site."
|
||||||
msgstr "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"
|
msgid "active"
|
||||||
msgstr "active"
|
msgstr "active"
|
||||||
|
|
||||||
#: core/models.py:199
|
#: core/models.py:206
|
||||||
msgid ""
|
msgid ""
|
||||||
"Whether this user should be treated as active. Unselect this instead of "
|
"Whether this user should be treated as active. Unselect this instead of "
|
||||||
"deleting accounts."
|
"deleting accounts."
|
||||||
@@ -264,63 +272,63 @@ msgstr ""
|
|||||||
"Whether this user should be treated as active. Unselect this instead of "
|
"Whether this user should be treated as active. Unselect this instead of "
|
||||||
"deleting accounts."
|
"deleting accounts."
|
||||||
|
|
||||||
#: core/models.py:212
|
#: core/models.py:219
|
||||||
msgid "user"
|
msgid "user"
|
||||||
msgstr "user"
|
msgstr "user"
|
||||||
|
|
||||||
#: core/models.py:213
|
#: core/models.py:220
|
||||||
msgid "users"
|
msgid "users"
|
||||||
msgstr "users"
|
msgstr "users"
|
||||||
|
|
||||||
#: core/models.py:272
|
#: core/models.py:279
|
||||||
msgid "Resource"
|
msgid "Resource"
|
||||||
msgstr "Resource"
|
msgstr "Resource"
|
||||||
|
|
||||||
#: core/models.py:273
|
#: core/models.py:280
|
||||||
msgid "Resources"
|
msgid "Resources"
|
||||||
msgstr "Resources"
|
msgstr "Resources"
|
||||||
|
|
||||||
#: core/models.py:331
|
#: core/models.py:338
|
||||||
msgid "Resource access"
|
msgid "Resource access"
|
||||||
msgstr "Resource access"
|
msgstr "Resource access"
|
||||||
|
|
||||||
#: core/models.py:332
|
#: core/models.py:339
|
||||||
msgid "Resource accesses"
|
msgid "Resource accesses"
|
||||||
msgstr "Resource accesses"
|
msgstr "Resource accesses"
|
||||||
|
|
||||||
#: core/models.py:338
|
#: core/models.py:345
|
||||||
msgid "Resource access with this User and Resource already exists."
|
msgid "Resource access with this User and Resource already exists."
|
||||||
msgstr "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"
|
msgid "Visio room configuration"
|
||||||
msgstr "Visio room configuration"
|
msgstr "Visio room configuration"
|
||||||
|
|
||||||
#: core/models.py:395
|
#: core/models.py:403
|
||||||
msgid "Values for Visio parameters to configure the room."
|
msgid "Values for Visio parameters to configure the room."
|
||||||
msgstr "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"
|
msgid "Room PIN code"
|
||||||
msgstr "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."
|
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||||
msgstr "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"
|
msgid "Room"
|
||||||
msgstr "Room"
|
msgstr "Room"
|
||||||
|
|
||||||
#: core/models.py:410
|
#: core/models.py:418
|
||||||
msgid "Rooms"
|
msgid "Rooms"
|
||||||
msgstr "Rooms"
|
msgstr "Rooms"
|
||||||
|
|
||||||
#: core/models.py:574
|
#: core/models.py:582
|
||||||
msgid "Worker ID"
|
msgid "Worker ID"
|
||||||
msgstr "Worker ID"
|
msgstr "Worker ID"
|
||||||
|
|
||||||
#: core/models.py:576
|
#: core/models.py:584
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||||
"the worker stops, allowing for easy tracking."
|
"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 "
|
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||||
"the worker stops, allowing for easy tracking."
|
"the worker stops, allowing for easy tracking."
|
||||||
|
|
||||||
#: core/models.py:584
|
#: core/models.py:592
|
||||||
msgid "Recording mode"
|
msgid "Recording mode"
|
||||||
msgstr "Recording mode"
|
msgstr "Recording mode"
|
||||||
|
|
||||||
#: core/models.py:585
|
#: core/models.py:593
|
||||||
msgid "Defines the mode of recording being called."
|
msgid "Defines the mode of recording being called."
|
||||||
msgstr "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"
|
msgid "Recording options"
|
||||||
msgstr "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"
|
msgid "Recording"
|
||||||
msgstr "Recording"
|
msgstr "Recording"
|
||||||
|
|
||||||
#: core/models.py:598
|
#: core/models.py:614
|
||||||
msgid "Recordings"
|
msgid "Recordings"
|
||||||
msgstr "Recordings"
|
msgstr "Recordings"
|
||||||
|
|
||||||
#: core/models.py:706
|
#: core/models.py:724
|
||||||
msgid "Recording/user relation"
|
msgid "Recording/user relation"
|
||||||
msgstr "Recording/user relation"
|
msgstr "Recording/user relation"
|
||||||
|
|
||||||
#: core/models.py:707
|
#: core/models.py:725
|
||||||
msgid "Recording/user relations"
|
msgid "Recording/user relations"
|
||||||
msgstr "Recording/user relations"
|
msgstr "Recording/user relations"
|
||||||
|
|
||||||
#: core/models.py:713
|
#: core/models.py:731
|
||||||
msgid "This user is already in this recording."
|
msgid "This user is already in this recording."
|
||||||
msgstr "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."
|
msgid "This team is already in this recording."
|
||||||
msgstr "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."
|
msgid "Either user or team must be set, not both."
|
||||||
msgstr "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
|
#, fuzzy
|
||||||
#| msgid "created on"
|
#| msgid "created on"
|
||||||
msgid "Create rooms"
|
msgid "Create rooms"
|
||||||
msgstr "Create rooms"
|
msgstr "Create rooms"
|
||||||
|
|
||||||
#: core/models.py:743
|
#: core/models.py:761
|
||||||
msgid "List rooms"
|
msgid "List rooms"
|
||||||
msgstr "List rooms"
|
msgstr "List rooms"
|
||||||
|
|
||||||
#: core/models.py:744
|
#: core/models.py:762
|
||||||
msgid "Retrieve room details"
|
msgid "Retrieve room details"
|
||||||
msgstr "Retrieve room details"
|
msgstr "Retrieve room details"
|
||||||
|
|
||||||
#: core/models.py:745
|
#: core/models.py:763
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
#| msgid "updated on"
|
#| msgid "updated on"
|
||||||
msgid "Update rooms"
|
msgid "Update rooms"
|
||||||
msgstr "Update rooms"
|
msgstr "Update rooms"
|
||||||
|
|
||||||
#: core/models.py:746
|
#: core/models.py:764
|
||||||
msgid "Delete rooms"
|
msgid "Delete rooms"
|
||||||
msgstr "Delete rooms"
|
msgstr "Delete rooms"
|
||||||
|
|
||||||
#: core/models.py:759
|
#: core/models.py:777
|
||||||
msgid "Application name"
|
msgid "Application name"
|
||||||
msgstr "Application name"
|
msgstr "Application name"
|
||||||
|
|
||||||
#: core/models.py:760
|
#: core/models.py:778
|
||||||
msgid "Descriptive name for this application."
|
msgid "Descriptive name for this application."
|
||||||
msgstr "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."
|
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."
|
msgstr "Hashed on Save. Copy it now if this is a new secret."
|
||||||
|
|
||||||
#: core/models.py:781
|
#: core/models.py:799
|
||||||
msgid "Application"
|
msgid "Application"
|
||||||
msgstr "Application"
|
msgstr "Application"
|
||||||
|
|
||||||
#: core/models.py:782
|
#: core/models.py:800
|
||||||
msgid "Applications"
|
msgid "Applications"
|
||||||
msgstr "Applications"
|
msgstr "Applications"
|
||||||
|
|
||||||
#: core/models.py:805
|
#: core/models.py:823
|
||||||
msgid "Enter a valid domain"
|
msgid "Enter a valid domain"
|
||||||
msgstr "Enter a valid domain"
|
msgstr "Enter a valid domain"
|
||||||
|
|
||||||
#: core/models.py:808
|
#: core/models.py:826
|
||||||
msgid "Domain"
|
msgid "Domain"
|
||||||
msgstr "Domain"
|
msgstr "Domain"
|
||||||
|
|
||||||
#: core/models.py:809
|
#: core/models.py:827
|
||||||
msgid "Email domain this application can act on behalf of."
|
msgid "Email domain this application can act on behalf of."
|
||||||
msgstr "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"
|
msgid "Application domain"
|
||||||
msgstr "Application domain"
|
msgstr "Application domain"
|
||||||
|
|
||||||
#: core/models.py:822
|
#: core/models.py:840
|
||||||
msgid "Application domains"
|
msgid "Application domains"
|
||||||
msgstr "Application domains"
|
msgstr "Application domains"
|
||||||
|
|
||||||
#: core/models.py:840
|
#: core/models.py:858
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
#| msgid "Recording"
|
#| msgid "Recording"
|
||||||
msgid "Pending"
|
msgid "Pending"
|
||||||
msgstr "Pending"
|
msgstr "Pending"
|
||||||
|
|
||||||
#: core/models.py:848
|
#: core/models.py:866
|
||||||
msgid "Ready"
|
msgid "Ready"
|
||||||
msgstr "Ready"
|
msgstr "Ready"
|
||||||
|
|
||||||
#: core/models.py:854
|
#: core/models.py:872
|
||||||
msgid "Background image"
|
msgid "Background image"
|
||||||
msgstr "Background image"
|
msgstr "Background image"
|
||||||
|
|
||||||
#: core/models.py:866
|
#: core/models.py:884
|
||||||
msgid "title"
|
msgid "title"
|
||||||
msgstr "title"
|
msgstr "title"
|
||||||
|
|
||||||
#: core/models.py:890
|
#: core/models.py:908
|
||||||
msgid "Malware detection info when the analysis status is unsafe."
|
msgid "Malware detection info when the analysis status is unsafe."
|
||||||
msgstr "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"
|
msgid "File"
|
||||||
msgstr "File"
|
msgstr "File"
|
||||||
|
|
||||||
#: core/models.py:896
|
#: core/models.py:914
|
||||||
msgid "Files"
|
msgid "Files"
|
||||||
msgstr "Files"
|
msgstr "Files"
|
||||||
|
|
||||||
#: core/models.py:1000
|
#: core/models.py:1018
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
#| msgid "This user is already in this recording."
|
#| msgid "This user is already in this recording."
|
||||||
msgid "This file is already hard deleted."
|
msgid "This file is already hard deleted."
|
||||||
msgstr "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."
|
msgid "To hard delete a file, it must first be soft deleted."
|
||||||
msgstr "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"
|
msgid "Your recording is ready"
|
||||||
msgstr "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/html/screen_recording.html:208
|
||||||
#: core/templates/mail/text/screen_recording.txt:13
|
#: 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 "
|
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:209
|
#: core/templates/mail/html/screen_recording.html:209
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \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"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
|
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -89,17 +89,17 @@ msgstr "Scopes"
|
|||||||
msgid "Creator is me"
|
msgid "Creator is me"
|
||||||
msgstr "Je suis le créateur"
|
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."
|
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
|
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
|
||||||
"des accès."
|
"des accès."
|
||||||
|
|
||||||
#: core/api/serializers.py:509
|
#: core/api/serializers.py:534
|
||||||
msgid "This file extension is not allowed."
|
msgid "This file extension is not allowed."
|
||||||
msgstr "Cette extension n'est pas autorisée"
|
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."
|
msgid "You have reached the maximum number of files for this type."
|
||||||
msgstr "Vous avez atteint le nombre maximum de fichiers de ce 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"
|
msgid "Notification succeeded"
|
||||||
msgstr "Notification réussie"
|
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"
|
msgid "SCREEN_RECORDING"
|
||||||
msgstr "ENREGISTREMENT_ÉCRAN"
|
msgstr "ENREGISTREMENT_ÉCRAN"
|
||||||
|
|
||||||
#: core/models.py:90
|
#: core/models.py:97
|
||||||
msgid "TRANSCRIPT"
|
msgid "TRANSCRIPT"
|
||||||
msgstr "TRANSCRIPTION"
|
msgstr "TRANSCRIPTION"
|
||||||
|
|
||||||
#: core/models.py:96
|
#: core/models.py:103
|
||||||
msgid "Public Access"
|
msgid "Public Access"
|
||||||
msgstr "Accès public"
|
msgstr "Accès public"
|
||||||
|
|
||||||
#: core/models.py:97
|
#: core/models.py:104
|
||||||
msgid "Trusted Access"
|
msgid "Trusted Access"
|
||||||
msgstr "Accès de confiance"
|
msgstr "Accès de confiance"
|
||||||
|
|
||||||
#: core/models.py:98
|
#: core/models.py:105
|
||||||
msgid "Restricted Access"
|
msgid "Restricted Access"
|
||||||
msgstr "Accès restreint"
|
msgstr "Accès restreint"
|
||||||
|
|
||||||
#: core/models.py:110
|
#: core/models.py:117
|
||||||
msgid "id"
|
msgid "id"
|
||||||
msgstr "id"
|
msgstr "id"
|
||||||
|
|
||||||
#: core/models.py:111
|
#: core/models.py:118
|
||||||
msgid "primary key for the record as UUID"
|
msgid "primary key for the record as UUID"
|
||||||
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
|
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
|
||||||
|
|
||||||
#: core/models.py:117
|
#: core/models.py:124
|
||||||
msgid "created on"
|
msgid "created on"
|
||||||
msgstr "créé le"
|
msgstr "créé le"
|
||||||
|
|
||||||
#: core/models.py:118
|
#: core/models.py:125
|
||||||
msgid "date and time at which a record was created"
|
msgid "date and time at which a record was created"
|
||||||
msgstr "date et heure auxquelles un enregistrement a été créé"
|
msgstr "date et heure auxquelles un enregistrement a été créé"
|
||||||
|
|
||||||
#: core/models.py:123
|
#: core/models.py:130
|
||||||
msgid "updated on"
|
msgid "updated on"
|
||||||
msgstr "mis à jour le"
|
msgstr "mis à jour le"
|
||||||
|
|
||||||
#: core/models.py:124
|
#: core/models.py:131
|
||||||
msgid "date and time at which a record was last updated"
|
msgid "date and time at which a record was last updated"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
|
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
|
||||||
"fois"
|
"fois"
|
||||||
|
|
||||||
#: core/models.py:144
|
#: core/models.py:151
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||||
"_ characters."
|
"_ characters."
|
||||||
@@ -201,11 +209,11 @@ msgstr ""
|
|||||||
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des "
|
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des "
|
||||||
"chiffres et les caractères @/./+/-/_."
|
"chiffres et les caractères @/./+/-/_."
|
||||||
|
|
||||||
#: core/models.py:150
|
#: core/models.py:157
|
||||||
msgid "sub"
|
msgid "sub"
|
||||||
msgstr "sub"
|
msgstr "sub"
|
||||||
|
|
||||||
#: core/models.py:152
|
#: core/models.py:159
|
||||||
msgid ""
|
msgid ""
|
||||||
"Optional for pending users; required upon account activation. 255 characters "
|
"Optional for pending users; required upon account activation. 255 characters "
|
||||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||||
@@ -213,55 +221,55 @@ msgstr ""
|
|||||||
"Optionnel pour les utilisateurs en attente ; requis lors de l'activation du "
|
"Optionnel pour les utilisateurs en attente ; requis lors de l'activation du "
|
||||||
"compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement."
|
"compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement."
|
||||||
|
|
||||||
#: core/models.py:161
|
#: core/models.py:168
|
||||||
msgid "identity email address"
|
msgid "identity email address"
|
||||||
msgstr "adresse e-mail d'identité"
|
msgstr "adresse e-mail d'identité"
|
||||||
|
|
||||||
#: core/models.py:166
|
#: core/models.py:173
|
||||||
msgid "admin email address"
|
msgid "admin email address"
|
||||||
msgstr "adresse e-mail d'administrateur"
|
msgstr "adresse e-mail d'administrateur"
|
||||||
|
|
||||||
#: core/models.py:168
|
#: core/models.py:175
|
||||||
msgid "full name"
|
msgid "full name"
|
||||||
msgstr "nom complet"
|
msgstr "nom complet"
|
||||||
|
|
||||||
#: core/models.py:170
|
#: core/models.py:177
|
||||||
msgid "short name"
|
msgid "short name"
|
||||||
msgstr "nom court"
|
msgstr "nom court"
|
||||||
|
|
||||||
#: core/models.py:176
|
#: core/models.py:183
|
||||||
msgid "language"
|
msgid "language"
|
||||||
msgstr "langue"
|
msgstr "langue"
|
||||||
|
|
||||||
#: core/models.py:177
|
#: core/models.py:184
|
||||||
msgid "The language in which the user wants to see the interface."
|
msgid "The language in which the user wants to see the interface."
|
||||||
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'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."
|
msgid "The timezone in which the user wants to see times."
|
||||||
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
|
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
|
||||||
|
|
||||||
#: core/models.py:186
|
#: core/models.py:193
|
||||||
msgid "device"
|
msgid "device"
|
||||||
msgstr "appareil"
|
msgstr "appareil"
|
||||||
|
|
||||||
#: core/models.py:188
|
#: core/models.py:195
|
||||||
msgid "Whether the user is a device or a real user."
|
msgid "Whether the user is a device or a real user."
|
||||||
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
|
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
|
||||||
|
|
||||||
#: core/models.py:191
|
#: core/models.py:198
|
||||||
msgid "staff status"
|
msgid "staff status"
|
||||||
msgstr "statut du personnel"
|
msgstr "statut du personnel"
|
||||||
|
|
||||||
#: core/models.py:193
|
#: core/models.py:200
|
||||||
msgid "Whether the user can log into this admin site."
|
msgid "Whether the user can log into this admin site."
|
||||||
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
|
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
|
||||||
|
|
||||||
#: core/models.py:196
|
#: core/models.py:203
|
||||||
msgid "active"
|
msgid "active"
|
||||||
msgstr "actif"
|
msgstr "actif"
|
||||||
|
|
||||||
#: core/models.py:199
|
#: core/models.py:206
|
||||||
msgid ""
|
msgid ""
|
||||||
"Whether this user should be treated as active. Unselect this instead of "
|
"Whether this user should be treated as active. Unselect this instead of "
|
||||||
"deleting accounts."
|
"deleting accounts."
|
||||||
@@ -269,65 +277,65 @@ msgstr ""
|
|||||||
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
|
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
|
||||||
"au lieu de supprimer des comptes."
|
"au lieu de supprimer des comptes."
|
||||||
|
|
||||||
#: core/models.py:212
|
#: core/models.py:219
|
||||||
msgid "user"
|
msgid "user"
|
||||||
msgstr "utilisateur"
|
msgstr "utilisateur"
|
||||||
|
|
||||||
#: core/models.py:213
|
#: core/models.py:220
|
||||||
msgid "users"
|
msgid "users"
|
||||||
msgstr "utilisateurs"
|
msgstr "utilisateurs"
|
||||||
|
|
||||||
#: core/models.py:272
|
#: core/models.py:279
|
||||||
msgid "Resource"
|
msgid "Resource"
|
||||||
msgstr "Ressource"
|
msgstr "Ressource"
|
||||||
|
|
||||||
#: core/models.py:273
|
#: core/models.py:280
|
||||||
msgid "Resources"
|
msgid "Resources"
|
||||||
msgstr "Ressources"
|
msgstr "Ressources"
|
||||||
|
|
||||||
#: core/models.py:331
|
#: core/models.py:338
|
||||||
msgid "Resource access"
|
msgid "Resource access"
|
||||||
msgstr "Accès aux ressources"
|
msgstr "Accès aux ressources"
|
||||||
|
|
||||||
#: core/models.py:332
|
#: core/models.py:339
|
||||||
msgid "Resource accesses"
|
msgid "Resource accesses"
|
||||||
msgstr "Accès aux ressources"
|
msgstr "Accès aux ressources"
|
||||||
|
|
||||||
#: core/models.py:338
|
#: core/models.py:345
|
||||||
msgid "Resource access with this User and Resource already exists."
|
msgid "Resource access with this User and Resource already exists."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
|
"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"
|
msgid "Visio room configuration"
|
||||||
msgstr "Configuration de la salle de visioconférence"
|
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."
|
msgid "Values for Visio parameters to configure the room."
|
||||||
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
|
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
|
||||||
|
|
||||||
#: core/models.py:402
|
#: core/models.py:410
|
||||||
msgid "Room PIN code"
|
msgid "Room PIN code"
|
||||||
msgstr "Code PIN de la salle"
|
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."
|
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Code unique à n chiffres qui identifie cette salle en mode téléphonique."
|
"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"
|
msgid "Room"
|
||||||
msgstr "Salle"
|
msgstr "Salle"
|
||||||
|
|
||||||
#: core/models.py:410
|
#: core/models.py:418
|
||||||
msgid "Rooms"
|
msgid "Rooms"
|
||||||
msgstr "Salles"
|
msgstr "Salles"
|
||||||
|
|
||||||
#: core/models.py:574
|
#: core/models.py:582
|
||||||
msgid "Worker ID"
|
msgid "Worker ID"
|
||||||
msgstr "ID du Worker"
|
msgstr "ID du Worker"
|
||||||
|
|
||||||
#: core/models.py:576
|
#: core/models.py:584
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||||
"the worker stops, allowing for easy tracking."
|
"the worker stops, allowing for easy tracking."
|
||||||
@@ -335,150 +343,158 @@ msgstr ""
|
|||||||
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
|
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
|
||||||
"conservé même lorsque le Worker s'arrête, permettant un suivi facile."
|
"conservé même lorsque le Worker s'arrête, permettant un suivi facile."
|
||||||
|
|
||||||
#: core/models.py:584
|
#: core/models.py:592
|
||||||
msgid "Recording mode"
|
msgid "Recording mode"
|
||||||
msgstr "Mode d'enregistrement"
|
msgstr "Mode d'enregistrement"
|
||||||
|
|
||||||
#: core/models.py:585
|
#: core/models.py:593
|
||||||
msgid "Defines the mode of recording being called."
|
msgid "Defines the mode of recording being called."
|
||||||
msgstr "Définit le mode d'enregistrement appelé."
|
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"
|
msgid "Recording options"
|
||||||
msgstr "Options d'enregistrement"
|
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"
|
msgid "Recording"
|
||||||
msgstr "Enregistrement"
|
msgstr "Enregistrement"
|
||||||
|
|
||||||
#: core/models.py:598
|
#: core/models.py:614
|
||||||
msgid "Recordings"
|
msgid "Recordings"
|
||||||
msgstr "Enregistrements"
|
msgstr "Enregistrements"
|
||||||
|
|
||||||
#: core/models.py:706
|
#: core/models.py:724
|
||||||
msgid "Recording/user relation"
|
msgid "Recording/user relation"
|
||||||
msgstr "Relation enregistrement/utilisateur"
|
msgstr "Relation enregistrement/utilisateur"
|
||||||
|
|
||||||
#: core/models.py:707
|
#: core/models.py:725
|
||||||
msgid "Recording/user relations"
|
msgid "Recording/user relations"
|
||||||
msgstr "Relations enregistrement/utilisateur"
|
msgstr "Relations enregistrement/utilisateur"
|
||||||
|
|
||||||
#: core/models.py:713
|
#: core/models.py:731
|
||||||
msgid "This user is already in this recording."
|
msgid "This user is already in this recording."
|
||||||
msgstr "Cet utilisateur est déjà dans cet enregistrement."
|
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."
|
msgid "This team is already in this recording."
|
||||||
msgstr "Cette équipe est déjà dans cet enregistrement."
|
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."
|
msgid "Either user or team must be set, not both."
|
||||||
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
|
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"
|
msgid "Create rooms"
|
||||||
msgstr "Créer des salles"
|
msgstr "Créer des salles"
|
||||||
|
|
||||||
#: core/models.py:743
|
#: core/models.py:761
|
||||||
msgid "List rooms"
|
msgid "List rooms"
|
||||||
msgstr "Lister les salles"
|
msgstr "Lister les salles"
|
||||||
|
|
||||||
#: core/models.py:744
|
#: core/models.py:762
|
||||||
msgid "Retrieve room details"
|
msgid "Retrieve room details"
|
||||||
msgstr "Afficher les détails d’une salle"
|
msgstr "Afficher les détails d’une salle"
|
||||||
|
|
||||||
#: core/models.py:745
|
#: core/models.py:763
|
||||||
msgid "Update rooms"
|
msgid "Update rooms"
|
||||||
msgstr "Mettre à jour les salles"
|
msgstr "Mettre à jour les salles"
|
||||||
|
|
||||||
#: core/models.py:746
|
#: core/models.py:764
|
||||||
msgid "Delete rooms"
|
msgid "Delete rooms"
|
||||||
msgstr "Supprimer les salles"
|
msgstr "Supprimer les salles"
|
||||||
|
|
||||||
#: core/models.py:759
|
#: core/models.py:777
|
||||||
msgid "Application name"
|
msgid "Application name"
|
||||||
msgstr "Nom de l’application"
|
msgstr "Nom de l’application"
|
||||||
|
|
||||||
#: core/models.py:760
|
#: core/models.py:778
|
||||||
msgid "Descriptive name for this application."
|
msgid "Descriptive name for this application."
|
||||||
msgstr "Nom descriptif de cette 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."
|
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Haché lors de l’enregistrement. Copiez-le maintenant s’il s’agit d’un "
|
"Haché lors de l’enregistrement. Copiez-le maintenant s’il s’agit d’un "
|
||||||
"nouveau secret."
|
"nouveau secret."
|
||||||
|
|
||||||
#: core/models.py:781
|
#: core/models.py:799
|
||||||
msgid "Application"
|
msgid "Application"
|
||||||
msgstr "Application"
|
msgstr "Application"
|
||||||
|
|
||||||
#: core/models.py:782
|
#: core/models.py:800
|
||||||
msgid "Applications"
|
msgid "Applications"
|
||||||
msgstr "Applications"
|
msgstr "Applications"
|
||||||
|
|
||||||
#: core/models.py:805
|
#: core/models.py:823
|
||||||
msgid "Enter a valid domain"
|
msgid "Enter a valid domain"
|
||||||
msgstr "Saisissez un domaine valide"
|
msgstr "Saisissez un domaine valide"
|
||||||
|
|
||||||
#: core/models.py:808
|
#: core/models.py:826
|
||||||
msgid "Domain"
|
msgid "Domain"
|
||||||
msgstr "Domaine"
|
msgstr "Domaine"
|
||||||
|
|
||||||
#: core/models.py:809
|
#: core/models.py:827
|
||||||
msgid "Email domain this application can act on behalf of."
|
msgid "Email domain this application can act on behalf of."
|
||||||
msgstr "Domaine de messagerie au nom duquel cette application peut agir."
|
msgstr "Domaine de messagerie au nom duquel cette application peut agir."
|
||||||
|
|
||||||
#: core/models.py:821
|
#: core/models.py:839
|
||||||
msgid "Application domain"
|
msgid "Application domain"
|
||||||
msgstr "Domaine d’application"
|
msgstr "Domaine d’application"
|
||||||
|
|
||||||
#: core/models.py:822
|
#: core/models.py:840
|
||||||
msgid "Application domains"
|
msgid "Application domains"
|
||||||
msgstr "Domaines d’application"
|
msgstr "Domaines d’application"
|
||||||
|
|
||||||
#: core/models.py:840
|
#: core/models.py:858
|
||||||
msgid "Pending"
|
msgid "Pending"
|
||||||
msgstr "En attente"
|
msgstr "En attente"
|
||||||
|
|
||||||
#: core/models.py:848
|
#: core/models.py:866
|
||||||
msgid "Ready"
|
msgid "Ready"
|
||||||
msgstr "Prêt"
|
msgstr "Prêt"
|
||||||
|
|
||||||
#: core/models.py:854
|
#: core/models.py:872
|
||||||
msgid "Background image"
|
msgid "Background image"
|
||||||
msgstr "Image de fond"
|
msgstr "Image de fond"
|
||||||
|
|
||||||
#: core/models.py:866
|
#: core/models.py:884
|
||||||
msgid "title"
|
msgid "title"
|
||||||
msgstr "Titre"
|
msgstr "Titre"
|
||||||
|
|
||||||
#: core/models.py:890
|
#: core/models.py:908
|
||||||
msgid "Malware detection info when the analysis status is unsafe."
|
msgid "Malware detection info when the analysis status is unsafe."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Information concernant la détection de Malware cand le statut n'est pas sain"
|
"Information concernant la détection de Malware cand le statut n'est pas sain"
|
||||||
|
|
||||||
#: core/models.py:895
|
#: core/models.py:913
|
||||||
msgid "File"
|
msgid "File"
|
||||||
msgstr "Fichier"
|
msgstr "Fichier"
|
||||||
|
|
||||||
#: core/models.py:896
|
#: core/models.py:914
|
||||||
msgid "Files"
|
msgid "Files"
|
||||||
msgstr "Fichiers"
|
msgstr "Fichiers"
|
||||||
|
|
||||||
#: core/models.py:1000
|
#: core/models.py:1018
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
#| msgid "This user is already in this recording."
|
#| msgid "This user is already in this recording."
|
||||||
msgid "This file is already hard deleted."
|
msgid "This file is already hard deleted."
|
||||||
msgstr "Ce fichier a été supprimé."
|
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."
|
msgid "To hard delete a file, it must first be soft deleted."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Pour supprimer définitivement un fichier il doit d'abord avoir été marqué "
|
"Pour supprimer définitivement un fichier il doit d'abord avoir été marqué "
|
||||||
"comme supprimé (soft delete)"
|
"comme supprimé (soft delete)"
|
||||||
|
|
||||||
#: core/recording/event/notification.py:116
|
#: core/recording/event/notification.py:124
|
||||||
msgid "Your recording is ready"
|
msgid "Your recording is ready"
|
||||||
msgstr "Votre enregistrement est prêt"
|
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/html/screen_recording.html:208
|
||||||
#: core/templates/mail/text/screen_recording.txt:13
|
#: 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 "
|
msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" ci-dessous "
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:209
|
#: core/templates/mail/html/screen_recording.html:209
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \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"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -88,16 +88,16 @@ msgstr "Scopes"
|
|||||||
msgid "Creator is me"
|
msgid "Creator is me"
|
||||||
msgstr "Maker ben ik"
|
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."
|
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
|
"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."
|
msgid "This file extension is not allowed."
|
||||||
msgstr "Deze bestandsextensie is niet toegestaan."
|
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."
|
msgid "You have reached the maximum number of files for this type."
|
||||||
msgstr "Het maximale aantal bestanden voor dit type is bereikt."
|
msgstr "Het maximale aantal bestanden voor dit type is bereikt."
|
||||||
|
|
||||||
@@ -145,51 +145,59 @@ msgstr "Stoppen mislukt"
|
|||||||
msgid "Notification succeeded"
|
msgid "Notification succeeded"
|
||||||
msgstr "Notificatie geslaagd"
|
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"
|
msgid "SCREEN_RECORDING"
|
||||||
msgstr "SCHERM_OPNAME"
|
msgstr "SCHERM_OPNAME"
|
||||||
|
|
||||||
#: core/models.py:90
|
#: core/models.py:97
|
||||||
msgid "TRANSCRIPT"
|
msgid "TRANSCRIPT"
|
||||||
msgstr "TRANSCRIPT"
|
msgstr "TRANSCRIPT"
|
||||||
|
|
||||||
#: core/models.py:96
|
#: core/models.py:103
|
||||||
msgid "Public Access"
|
msgid "Public Access"
|
||||||
msgstr "Openbare toegang"
|
msgstr "Openbare toegang"
|
||||||
|
|
||||||
#: core/models.py:97
|
#: core/models.py:104
|
||||||
msgid "Trusted Access"
|
msgid "Trusted Access"
|
||||||
msgstr "Vertrouwde toegang"
|
msgstr "Vertrouwde toegang"
|
||||||
|
|
||||||
#: core/models.py:98
|
#: core/models.py:105
|
||||||
msgid "Restricted Access"
|
msgid "Restricted Access"
|
||||||
msgstr "Beperkte toegang"
|
msgstr "Beperkte toegang"
|
||||||
|
|
||||||
#: core/models.py:110
|
#: core/models.py:117
|
||||||
msgid "id"
|
msgid "id"
|
||||||
msgstr "id"
|
msgstr "id"
|
||||||
|
|
||||||
#: core/models.py:111
|
#: core/models.py:118
|
||||||
msgid "primary key for the record as UUID"
|
msgid "primary key for the record as UUID"
|
||||||
msgstr "primaire sleutel voor het record als UUID"
|
msgstr "primaire sleutel voor het record als UUID"
|
||||||
|
|
||||||
#: core/models.py:117
|
#: core/models.py:124
|
||||||
msgid "created on"
|
msgid "created on"
|
||||||
msgstr "aangemaakt op"
|
msgstr "aangemaakt op"
|
||||||
|
|
||||||
#: core/models.py:118
|
#: core/models.py:125
|
||||||
msgid "date and time at which a record was created"
|
msgid "date and time at which a record was created"
|
||||||
msgstr "datum en tijd waarop een record werd aangemaakt"
|
msgstr "datum en tijd waarop een record werd aangemaakt"
|
||||||
|
|
||||||
#: core/models.py:123
|
#: core/models.py:130
|
||||||
msgid "updated on"
|
msgid "updated on"
|
||||||
msgstr "bijgewerkt op"
|
msgstr "bijgewerkt op"
|
||||||
|
|
||||||
#: core/models.py:124
|
#: core/models.py:131
|
||||||
msgid "date and time at which a record was last updated"
|
msgid "date and time at which a record was last updated"
|
||||||
msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt"
|
msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt"
|
||||||
|
|
||||||
#: core/models.py:144
|
#: core/models.py:151
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||||
"_ characters."
|
"_ characters."
|
||||||
@@ -197,11 +205,11 @@ msgstr ""
|
|||||||
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
|
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
|
||||||
"_ tekens bevatten."
|
"_ tekens bevatten."
|
||||||
|
|
||||||
#: core/models.py:150
|
#: core/models.py:157
|
||||||
msgid "sub"
|
msgid "sub"
|
||||||
msgstr "sub"
|
msgstr "sub"
|
||||||
|
|
||||||
#: core/models.py:152
|
#: core/models.py:159
|
||||||
msgid ""
|
msgid ""
|
||||||
"Optional for pending users; required upon account activation. 255 characters "
|
"Optional for pending users; required upon account activation. 255 characters "
|
||||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||||
@@ -209,55 +217,55 @@ msgstr ""
|
|||||||
"Optioneel voor gebruikers in afwachting; vereist bij accountactivering. "
|
"Optioneel voor gebruikers in afwachting; vereist bij accountactivering. "
|
||||||
"Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan."
|
"Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan."
|
||||||
|
|
||||||
#: core/models.py:161
|
#: core/models.py:168
|
||||||
msgid "identity email address"
|
msgid "identity email address"
|
||||||
msgstr "identiteit e-mailadres"
|
msgstr "identiteit e-mailadres"
|
||||||
|
|
||||||
#: core/models.py:166
|
#: core/models.py:173
|
||||||
msgid "admin email address"
|
msgid "admin email address"
|
||||||
msgstr "beheerder e-mailadres"
|
msgstr "beheerder e-mailadres"
|
||||||
|
|
||||||
#: core/models.py:168
|
#: core/models.py:175
|
||||||
msgid "full name"
|
msgid "full name"
|
||||||
msgstr "volledige naam"
|
msgstr "volledige naam"
|
||||||
|
|
||||||
#: core/models.py:170
|
#: core/models.py:177
|
||||||
msgid "short name"
|
msgid "short name"
|
||||||
msgstr "korte naam"
|
msgstr "korte naam"
|
||||||
|
|
||||||
#: core/models.py:176
|
#: core/models.py:183
|
||||||
msgid "language"
|
msgid "language"
|
||||||
msgstr "taal"
|
msgstr "taal"
|
||||||
|
|
||||||
#: core/models.py:177
|
#: core/models.py:184
|
||||||
msgid "The language in which the user wants to see the interface."
|
msgid "The language in which the user wants to see the interface."
|
||||||
msgstr "De taal waarin de gebruiker de interface wil zien."
|
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."
|
msgid "The timezone in which the user wants to see times."
|
||||||
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
|
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
|
||||||
|
|
||||||
#: core/models.py:186
|
#: core/models.py:193
|
||||||
msgid "device"
|
msgid "device"
|
||||||
msgstr "apparaat"
|
msgstr "apparaat"
|
||||||
|
|
||||||
#: core/models.py:188
|
#: core/models.py:195
|
||||||
msgid "Whether the user is a device or a real user."
|
msgid "Whether the user is a device or a real user."
|
||||||
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
|
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
|
||||||
|
|
||||||
#: core/models.py:191
|
#: core/models.py:198
|
||||||
msgid "staff status"
|
msgid "staff status"
|
||||||
msgstr "personeelsstatus"
|
msgstr "personeelsstatus"
|
||||||
|
|
||||||
#: core/models.py:193
|
#: core/models.py:200
|
||||||
msgid "Whether the user can log into this admin site."
|
msgid "Whether the user can log into this admin site."
|
||||||
msgstr "Of de gebruiker kan inloggen op deze beheersite."
|
msgstr "Of de gebruiker kan inloggen op deze beheersite."
|
||||||
|
|
||||||
#: core/models.py:196
|
#: core/models.py:203
|
||||||
msgid "active"
|
msgid "active"
|
||||||
msgstr "actief"
|
msgstr "actief"
|
||||||
|
|
||||||
#: core/models.py:199
|
#: core/models.py:206
|
||||||
msgid ""
|
msgid ""
|
||||||
"Whether this user should be treated as active. Unselect this instead of "
|
"Whether this user should be treated as active. Unselect this instead of "
|
||||||
"deleting accounts."
|
"deleting accounts."
|
||||||
@@ -265,64 +273,64 @@ msgstr ""
|
|||||||
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
|
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
|
||||||
"plaats van accounts te verwijderen."
|
"plaats van accounts te verwijderen."
|
||||||
|
|
||||||
#: core/models.py:212
|
#: core/models.py:219
|
||||||
msgid "user"
|
msgid "user"
|
||||||
msgstr "gebruiker"
|
msgstr "gebruiker"
|
||||||
|
|
||||||
#: core/models.py:213
|
#: core/models.py:220
|
||||||
msgid "users"
|
msgid "users"
|
||||||
msgstr "gebruikers"
|
msgstr "gebruikers"
|
||||||
|
|
||||||
#: core/models.py:272
|
#: core/models.py:279
|
||||||
msgid "Resource"
|
msgid "Resource"
|
||||||
msgstr "Bron"
|
msgstr "Bron"
|
||||||
|
|
||||||
#: core/models.py:273
|
#: core/models.py:280
|
||||||
msgid "Resources"
|
msgid "Resources"
|
||||||
msgstr "Bronnen"
|
msgstr "Bronnen"
|
||||||
|
|
||||||
#: core/models.py:331
|
#: core/models.py:338
|
||||||
msgid "Resource access"
|
msgid "Resource access"
|
||||||
msgstr "Brontoegang"
|
msgstr "Brontoegang"
|
||||||
|
|
||||||
#: core/models.py:332
|
#: core/models.py:339
|
||||||
msgid "Resource accesses"
|
msgid "Resource accesses"
|
||||||
msgstr "Brontoegangsrechten"
|
msgstr "Brontoegangsrechten"
|
||||||
|
|
||||||
#: core/models.py:338
|
#: core/models.py:345
|
||||||
msgid "Resource access with this User and Resource already exists."
|
msgid "Resource access with this User and Resource already exists."
|
||||||
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
|
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
|
||||||
|
|
||||||
#: core/models.py:394
|
#: core/models.py:402
|
||||||
msgid "Visio room configuration"
|
msgid "Visio room configuration"
|
||||||
msgstr "Visio-ruimteconfiguratie"
|
msgstr "Visio-ruimteconfiguratie"
|
||||||
|
|
||||||
#: core/models.py:395
|
#: core/models.py:403
|
||||||
msgid "Values for Visio parameters to configure the room."
|
msgid "Values for Visio parameters to configure the room."
|
||||||
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
|
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
|
||||||
|
|
||||||
#: core/models.py:402
|
#: core/models.py:410
|
||||||
msgid "Room PIN code"
|
msgid "Room PIN code"
|
||||||
msgstr "Pincode van de kamer"
|
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."
|
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
|
"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"
|
msgid "Room"
|
||||||
msgstr "Ruimte"
|
msgstr "Ruimte"
|
||||||
|
|
||||||
#: core/models.py:410
|
#: core/models.py:418
|
||||||
msgid "Rooms"
|
msgid "Rooms"
|
||||||
msgstr "Ruimtes"
|
msgstr "Ruimtes"
|
||||||
|
|
||||||
#: core/models.py:574
|
#: core/models.py:582
|
||||||
msgid "Worker ID"
|
msgid "Worker ID"
|
||||||
msgstr "Worker ID"
|
msgstr "Worker ID"
|
||||||
|
|
||||||
#: core/models.py:576
|
#: core/models.py:584
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||||
"the worker stops, allowing for easy tracking."
|
"the worker stops, allowing for easy tracking."
|
||||||
@@ -330,140 +338,148 @@ msgstr ""
|
|||||||
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
|
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
|
||||||
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
|
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
|
||||||
|
|
||||||
#: core/models.py:584
|
#: core/models.py:592
|
||||||
msgid "Recording mode"
|
msgid "Recording mode"
|
||||||
msgstr "Opnamemodus"
|
msgstr "Opnamemodus"
|
||||||
|
|
||||||
#: core/models.py:585
|
#: core/models.py:593
|
||||||
msgid "Defines the mode of recording being called."
|
msgid "Defines the mode of recording being called."
|
||||||
msgstr "Definieert de modus van opname die wordt aangeroepen."
|
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"
|
msgid "Recording options"
|
||||||
msgstr "Opnameopties"
|
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"
|
msgid "Recording"
|
||||||
msgstr "Opname"
|
msgstr "Opname"
|
||||||
|
|
||||||
#: core/models.py:598
|
#: core/models.py:614
|
||||||
msgid "Recordings"
|
msgid "Recordings"
|
||||||
msgstr "Opnames"
|
msgstr "Opnames"
|
||||||
|
|
||||||
#: core/models.py:706
|
#: core/models.py:724
|
||||||
msgid "Recording/user relation"
|
msgid "Recording/user relation"
|
||||||
msgstr "Opname/gebruiker-relatie"
|
msgstr "Opname/gebruiker-relatie"
|
||||||
|
|
||||||
#: core/models.py:707
|
#: core/models.py:725
|
||||||
msgid "Recording/user relations"
|
msgid "Recording/user relations"
|
||||||
msgstr "Opname/gebruiker-relaties"
|
msgstr "Opname/gebruiker-relaties"
|
||||||
|
|
||||||
#: core/models.py:713
|
#: core/models.py:731
|
||||||
msgid "This user is already in this recording."
|
msgid "This user is already in this recording."
|
||||||
msgstr "Deze gebruiker is al in deze opname."
|
msgstr "Deze gebruiker is al in deze opname."
|
||||||
|
|
||||||
#: core/models.py:719
|
#: core/models.py:737
|
||||||
msgid "This team is already in this recording."
|
msgid "This team is already in this recording."
|
||||||
msgstr "Dit team is al in deze opname."
|
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."
|
msgid "Either user or team must be set, not both."
|
||||||
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
|
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
|
||||||
|
|
||||||
#: core/models.py:742
|
#: core/models.py:760
|
||||||
msgid "Create rooms"
|
msgid "Create rooms"
|
||||||
msgstr "Ruimtes aanmaken"
|
msgstr "Ruimtes aanmaken"
|
||||||
|
|
||||||
#: core/models.py:743
|
#: core/models.py:761
|
||||||
msgid "List rooms"
|
msgid "List rooms"
|
||||||
msgstr "Ruimtes weergeven"
|
msgstr "Ruimtes weergeven"
|
||||||
|
|
||||||
#: core/models.py:744
|
#: core/models.py:762
|
||||||
msgid "Retrieve room details"
|
msgid "Retrieve room details"
|
||||||
msgstr "Details van een ruimte ophalen"
|
msgstr "Details van een ruimte ophalen"
|
||||||
|
|
||||||
#: core/models.py:745
|
#: core/models.py:763
|
||||||
msgid "Update rooms"
|
msgid "Update rooms"
|
||||||
msgstr "Ruimtes bijwerken"
|
msgstr "Ruimtes bijwerken"
|
||||||
|
|
||||||
#: core/models.py:746
|
#: core/models.py:764
|
||||||
msgid "Delete rooms"
|
msgid "Delete rooms"
|
||||||
msgstr "Ruimtes verwijderen"
|
msgstr "Ruimtes verwijderen"
|
||||||
|
|
||||||
#: core/models.py:759
|
#: core/models.py:777
|
||||||
msgid "Application name"
|
msgid "Application name"
|
||||||
msgstr "Naam van de applicatie"
|
msgstr "Naam van de applicatie"
|
||||||
|
|
||||||
#: core/models.py:760
|
#: core/models.py:778
|
||||||
msgid "Descriptive name for this application."
|
msgid "Descriptive name for this application."
|
||||||
msgstr "Beschrijvende naam voor deze applicatie."
|
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."
|
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
|
"Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
|
||||||
|
|
||||||
#: core/models.py:781
|
#: core/models.py:799
|
||||||
msgid "Application"
|
msgid "Application"
|
||||||
msgstr "Applicatie"
|
msgstr "Applicatie"
|
||||||
|
|
||||||
#: core/models.py:782
|
#: core/models.py:800
|
||||||
msgid "Applications"
|
msgid "Applications"
|
||||||
msgstr "Applicaties"
|
msgstr "Applicaties"
|
||||||
|
|
||||||
#: core/models.py:805
|
#: core/models.py:823
|
||||||
msgid "Enter a valid domain"
|
msgid "Enter a valid domain"
|
||||||
msgstr "Voer een geldig domein in"
|
msgstr "Voer een geldig domein in"
|
||||||
|
|
||||||
#: core/models.py:808
|
#: core/models.py:826
|
||||||
msgid "Domain"
|
msgid "Domain"
|
||||||
msgstr "Domein"
|
msgstr "Domein"
|
||||||
|
|
||||||
#: core/models.py:809
|
#: core/models.py:827
|
||||||
msgid "Email domain this application can act on behalf of."
|
msgid "Email domain this application can act on behalf of."
|
||||||
msgstr "E-maildomein namens welke deze applicatie kan handelen."
|
msgstr "E-maildomein namens welke deze applicatie kan handelen."
|
||||||
|
|
||||||
#: core/models.py:821
|
#: core/models.py:839
|
||||||
msgid "Application domain"
|
msgid "Application domain"
|
||||||
msgstr "Applicatiedomein"
|
msgstr "Applicatiedomein"
|
||||||
|
|
||||||
#: core/models.py:822
|
#: core/models.py:840
|
||||||
msgid "Application domains"
|
msgid "Application domains"
|
||||||
msgstr "Applicatiedomeinen"
|
msgstr "Applicatiedomeinen"
|
||||||
|
|
||||||
#: core/models.py:840
|
#: core/models.py:858
|
||||||
msgid "Pending"
|
msgid "Pending"
|
||||||
msgstr "In afwachting"
|
msgstr "In afwachting"
|
||||||
|
|
||||||
#: core/models.py:848
|
#: core/models.py:866
|
||||||
msgid "Ready"
|
msgid "Ready"
|
||||||
msgstr "Klaar"
|
msgstr "Klaar"
|
||||||
|
|
||||||
#: core/models.py:854
|
#: core/models.py:872
|
||||||
msgid "Background image"
|
msgid "Background image"
|
||||||
msgstr "Achtergrondafbeelding"
|
msgstr "Achtergrondafbeelding"
|
||||||
|
|
||||||
#: core/models.py:866
|
#: core/models.py:884
|
||||||
msgid "title"
|
msgid "title"
|
||||||
msgstr "Titel"
|
msgstr "Titel"
|
||||||
|
|
||||||
#: core/models.py:890
|
#: core/models.py:908
|
||||||
msgid "Malware detection info when the analysis status is unsafe."
|
msgid "Malware detection info when the analysis status is unsafe."
|
||||||
msgstr "Informatie over malwaredetectie wanneer de analysestatus onveilig is."
|
msgstr "Informatie over malwaredetectie wanneer de analysestatus onveilig is."
|
||||||
|
|
||||||
#: core/models.py:895
|
#: core/models.py:913
|
||||||
msgid "File"
|
msgid "File"
|
||||||
msgstr "Bestand"
|
msgstr "Bestand"
|
||||||
|
|
||||||
#: core/models.py:896
|
#: core/models.py:914
|
||||||
msgid "Files"
|
msgid "Files"
|
||||||
msgstr "Bestanden"
|
msgstr "Bestanden"
|
||||||
|
|
||||||
#: core/models.py:1000
|
#: core/models.py:1018
|
||||||
msgid "This file is already hard deleted."
|
msgid "This file is already hard deleted."
|
||||||
msgstr "Dit bestand is al definitief verwijderd."
|
msgstr "Dit bestand is al definitief verwijderd."
|
||||||
|
|
||||||
#: core/models.py:1010
|
#: core/models.py:1028
|
||||||
#, fuzzy
|
#, 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."
|
||||||
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 "
|
"Om een bestand definitief te verwijderen, moet het eerst zacht verwijderd "
|
||||||
"zijn."
|
"zijn."
|
||||||
|
|
||||||
#: core/recording/event/notification.py:116
|
#: core/recording/event/notification.py:124
|
||||||
msgid "Your recording is ready"
|
msgid "Your recording is ready"
|
||||||
msgstr "Je opname is klaar"
|
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/html/screen_recording.html:208
|
||||||
#: core/templates/mail/text/screen_recording.txt:13
|
#: 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 "
|
msgstr "Klik op de \"<a href=\"%(link)s\">Openen</a>\"-link hieronder "
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:209
|
#: core/templates/mail/html/screen_recording.html:209
|
||||||
|
|||||||
@@ -744,6 +744,9 @@ class Base(Configuration):
|
|||||||
SUMMARY_SERVICE_API_TOKEN = SecretFileValue(
|
SUMMARY_SERVICE_API_TOKEN = SecretFileValue(
|
||||||
None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None
|
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(
|
SCREEN_RECORDING_BASE_URL = values.Value(
|
||||||
None, environ_name="SCREEN_RECORDING_BASE_URL", environ_prefix=None
|
None, environ_name="SCREEN_RECORDING_BASE_URL", environ_prefix=None
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -105,8 +105,9 @@ backend:
|
|||||||
RECORDING_ENABLE: True
|
RECORDING_ENABLE: True
|
||||||
RECORDING_STORAGE_EVENT_ENABLE: True
|
RECORDING_STORAGE_EVENT_ENABLE: True
|
||||||
RECORDING_STORAGE_EVENT_TOKEN: password
|
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_API_TOKEN: password
|
||||||
|
SUMMARY_SERVICE_WEBHOOK_API_TOKEN: webhook-password
|
||||||
RECORDING_DOWNLOAD_BASE_URL: https://meet.127.0.0.1.nip.io/recording
|
RECORDING_DOWNLOAD_BASE_URL: https://meet.127.0.0.1.nip.io/recording
|
||||||
ROOM_TELEPHONY_ENABLED: True
|
ROOM_TELEPHONY_ENABLED: True
|
||||||
SSL_CERT_FILE: /app/.venv/lib/python3.13/site-packages/certifi/cacert.pem
|
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_ACCESS_KEY_ID: meet
|
||||||
AWS_S3_SECRET_ACCESS_KEY: password
|
AWS_S3_SECRET_ACCESS_KEY: password
|
||||||
AWS_S3_SECURE_ACCESS: False
|
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:
|
WHISPERX_API_KEY:
|
||||||
secretKeyRef:
|
secretKeyRef:
|
||||||
name: secret-dev
|
name: secret-dev
|
||||||
@@ -109,8 +128,9 @@ backend:
|
|||||||
RECORDING_ENABLE: True
|
RECORDING_ENABLE: True
|
||||||
RECORDING_STORAGE_EVENT_ENABLE: True
|
RECORDING_STORAGE_EVENT_ENABLE: True
|
||||||
RECORDING_STORAGE_EVENT_TOKEN: password
|
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_API_TOKEN: password
|
||||||
|
SUMMARY_SERVICE_WEBHOOK_API_TOKEN: webhook-password
|
||||||
RECORDING_DOWNLOAD_BASE_URL: https://meet.127.0.0.1.nip.io/recording
|
RECORDING_DOWNLOAD_BASE_URL: https://meet.127.0.0.1.nip.io/recording
|
||||||
ROOM_TELEPHONY_ENABLED: True
|
ROOM_TELEPHONY_ENABLED: True
|
||||||
ROOM_TELEPHONY_DEFAULT_COUNTRY: 'FR'
|
ROOM_TELEPHONY_DEFAULT_COUNTRY: 'FR'
|
||||||
@@ -258,6 +278,23 @@ celerySummaryBackend:
|
|||||||
- "-Q"
|
- "-Q"
|
||||||
- "call-webhook-queue-v2"
|
- "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:
|
agentMetadata:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
envVars:
|
envVars:
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ dependencies = [
|
|||||||
"posthog==7.9.12",
|
"posthog==7.9.12",
|
||||||
"requests==2.33.0",
|
"requests==2.33.0",
|
||||||
"sentry-sdk[fastapi, celery]==2.54.0",
|
"sentry-sdk[fastapi, celery]==2.54.0",
|
||||||
"langfuse==4.0.0"
|
"langfuse==4.0.0",
|
||||||
|
"ruff>=0.15.6",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -2,11 +2,8 @@
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
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
|
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 = APIRouter(dependencies=[Depends(verify_tenant_api_key)])
|
||||||
api_router_v2.include_router(tasks_v2.router_tasks_v2, tags=["tasks"])
|
api_router_v2.include_router(tasks_v2.router_tasks_v2, tags=["tasks"])
|
||||||
|
|||||||
@@ -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}
|
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
"""API routes related to application tasks (V2 / tenant friendly)."""
|
"""API routes related to application tasks (V2 / tenant friendly)."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from celery.result import AsyncResult
|
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 (
|
from summary.core.celery_worker import (
|
||||||
celery,
|
celery,
|
||||||
@@ -20,6 +23,7 @@ from summary.core.shared_models import (
|
|||||||
TranscribeWebhookSuccessPayload,
|
TranscribeWebhookSuccessPayload,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
router_tasks_v2 = APIRouter()
|
router_tasks_v2 = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@@ -29,8 +33,20 @@ async def create_transcribe_task_v2(
|
|||||||
request_tenant: AuthorizedTenant = Depends(verify_tenant_api_key_v2),
|
request_tenant: AuthorizedTenant = Depends(verify_tenant_api_key_v2),
|
||||||
):
|
):
|
||||||
"""Create a transcription task."""
|
"""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(
|
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()
|
return TranscribeWebhookPendingPayload(job_id=task.id).model_dump()
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import json
|
|||||||
import time
|
import time
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
from urllib.parse import urlsplit, urlunsplit
|
||||||
|
|
||||||
import redis
|
import redis
|
||||||
from celery.utils.log import get_task_logger
|
from celery.utils.log import get_task_logger
|
||||||
from posthog import Posthog
|
from posthog import Posthog
|
||||||
|
|
||||||
from summary.core.config import get_settings
|
from summary.core.config import get_settings
|
||||||
|
from summary.core.models import TranscribeTaskV2Payload
|
||||||
|
|
||||||
logger = get_task_logger(__name__)
|
logger = get_task_logger(__name__)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -107,23 +109,23 @@ class MetadataManager:
|
|||||||
"""Check if task_id exists in tasks metadata cache."""
|
"""Check if task_id exists in tasks metadata cache."""
|
||||||
return self._redis.exists(self._get_redis_key(task_id))
|
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."""
|
"""Create initial metadata entry for a new task."""
|
||||||
if self._is_disabled or self.has_task_id(task_id):
|
if self._is_disabled or self.has_task_id(task_id):
|
||||||
return
|
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()
|
start_time = time.time()
|
||||||
|
parts = urlsplit(task_payload.cloud_storage_url)
|
||||||
|
clean_url = urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
|
||||||
initial_metadata = {
|
initial_metadata = {
|
||||||
"start_time": start_time,
|
"start_time": start_time,
|
||||||
"asr_model": settings.whisperx_asr_model,
|
"asr_model": settings.whisperx_asr_model,
|
||||||
"retries": 0,
|
"retries": 0,
|
||||||
"filename": filename,
|
"filename": clean_url,
|
||||||
"email": email,
|
"sub": task_payload.user_sub,
|
||||||
"queuing_time": round(start_time - received_at, 2),
|
"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)
|
self._save_metadata(task_id, initial_metadata)
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
import openai
|
import openai
|
||||||
import sentry_sdk
|
import sentry_sdk
|
||||||
@@ -14,10 +13,12 @@ from requests import exceptions
|
|||||||
|
|
||||||
from summary.core.analytics import MetadataManager, get_analytics
|
from summary.core.analytics import MetadataManager, get_analytics
|
||||||
from summary.core.config import get_settings
|
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.file_service import FileService, FileServiceException
|
||||||
from summary.core.llm_service import LLMException, LLMObservability, LLMService
|
from summary.core.llm_service import LLMException, LLMObservability, LLMService
|
||||||
from summary.core.locales import get_locale
|
from summary.core.locales import get_locale
|
||||||
from summary.core.models import (
|
from summary.core.models import (
|
||||||
|
RecordingMetadata,
|
||||||
SummarizeTaskV2Payload,
|
SummarizeTaskV2Payload,
|
||||||
TranscribeTaskV2Payload,
|
TranscribeTaskV2Payload,
|
||||||
)
|
)
|
||||||
@@ -43,7 +44,6 @@ from summary.core.transcript_formatter import TranscriptFormatter
|
|||||||
from summary.core.user_assign import resolve_speaker_identities
|
from summary.core.user_assign import resolve_speaker_identities
|
||||||
from summary.core.webhook_service import (
|
from summary.core.webhook_service import (
|
||||||
call_webhook_v2,
|
call_webhook_v2,
|
||||||
submit_content,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -79,23 +79,17 @@ file_service = FileService()
|
|||||||
def transcribe_audio(
|
def transcribe_audio(
|
||||||
*,
|
*,
|
||||||
task_id: str,
|
task_id: str,
|
||||||
recording_filename: str | None = None,
|
|
||||||
language: str,
|
language: str,
|
||||||
cloud_storage_url=None,
|
cloud_storage_url: str,
|
||||||
raises: bool = False,
|
raises: bool = False,
|
||||||
):
|
):
|
||||||
"""Transcribe an audio file using WhisperX.
|
"""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.
|
WhisperX for transcription, and tracks metadata throughout the process.
|
||||||
|
|
||||||
Returns the transcription object, or None if the file could not be retrieved.
|
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")
|
logger.info("Initiating WhisperX client")
|
||||||
whisperx_client = openai.OpenAI(
|
whisperx_client = openai.OpenAI(
|
||||||
api_key=settings.whisperx_api_key.get_secret_value(),
|
api_key=settings.whisperx_api_key.get_secret_value(),
|
||||||
@@ -106,7 +100,6 @@ def transcribe_audio(
|
|||||||
# Transcription
|
# Transcription
|
||||||
try:
|
try:
|
||||||
with file_service.prepare_audio_file(
|
with file_service.prepare_audio_file(
|
||||||
remote_object_key=recording_filename,
|
|
||||||
cloud_storage_url=cloud_storage_url,
|
cloud_storage_url=cloud_storage_url,
|
||||||
) as (audio_file, metadata):
|
) as (audio_file, metadata):
|
||||||
metadata_manager.track(task_id, {"audio_length": metadata["duration"]})
|
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
|
cloud_storage_url.split("?", 1)[0] if cloud_storage_url else None
|
||||||
)
|
)
|
||||||
logger.exception(
|
logger.exception(
|
||||||
(
|
("Unexpected error while preparing file %s "),
|
||||||
"Unexpected error while preparing file | filename: %s "
|
|
||||||
"| cloud_storage_url: %s"
|
|
||||||
),
|
|
||||||
recording_filename,
|
|
||||||
redacted_cloud_storage_url,
|
redacted_cloud_storage_url,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
@@ -163,41 +152,31 @@ def transcribe_audio(
|
|||||||
|
|
||||||
|
|
||||||
def resolve_speaker_identities_and_apply_to(
|
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.
|
"""Assign users to detected speakers and rewrite the transcriptions.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
transcription: output of meet-whisperx after transcription and diarization
|
transcription: output of meet-whisperx after transcription and diarization
|
||||||
recording_start_at: sourced from LiveKit FileInfo via the egress_ended webhook
|
recording_metadata: Metadata of the recording
|
||||||
recording_end_at: sourced from LiveKit FileInfo via the egress_ended webhook
|
|
||||||
metadata_filename: name of metadata file containing VAD information in S3
|
|
||||||
task_id: current task id, for logging purposes
|
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(
|
logger.debug(
|
||||||
"recording_start_dt: %s ; recording_end_dt: %s",
|
"recording_start_dt: %s ; recording_end_dt: %s",
|
||||||
recording_start_dt,
|
recording_metadata.started_at,
|
||||||
recording_end_dt,
|
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")
|
logger.debug("Running resolve_speaker_identities")
|
||||||
try:
|
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(
|
speaker_mapping = resolve_speaker_identities(
|
||||||
metadata,
|
metadata,
|
||||||
transcription,
|
transcription,
|
||||||
recording_start_dt,
|
recording_metadata.started_at,
|
||||||
recording_end_dt,
|
recording_metadata.ended_at,
|
||||||
)
|
)
|
||||||
new_transcription = speaker_mapping.apply_to(transcription.model_dump())
|
new_transcription = speaker_mapping.apply_to(transcription.model_dump())
|
||||||
return new_transcription
|
return new_transcription
|
||||||
@@ -225,11 +204,8 @@ def format_transcript(
|
|||||||
transcription,
|
transcription,
|
||||||
context_language: str | None,
|
context_language: str | None,
|
||||||
language: str,
|
language: str,
|
||||||
room: str | None,
|
|
||||||
recording_datetime: str | None,
|
|
||||||
owner_timezone: str | None,
|
|
||||||
download_link: str | None,
|
download_link: str | None,
|
||||||
) -> tuple[str, str]:
|
) -> str:
|
||||||
"""Format a transcription into readable content with a title.
|
"""Format a transcription into readable content with a title.
|
||||||
|
|
||||||
Resolves the locale from context_language / language, then uses
|
Resolves the locale from context_language / language, then uses
|
||||||
@@ -242,9 +218,6 @@ def format_transcript(
|
|||||||
|
|
||||||
return formatter.format(
|
return formatter.format(
|
||||||
transcription,
|
transcription,
|
||||||
room=room,
|
|
||||||
recording_datetime=recording_datetime,
|
|
||||||
owner_timezone=owner_timezone,
|
|
||||||
download_link=download_link,
|
download_link=download_link,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -267,130 +240,9 @@ def format_actions(llm_output: dict) -> str:
|
|||||||
return ""
|
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(
|
def summarize_transcription_internals(
|
||||||
*, owner_id: str, transcript: str, session_id: str
|
*, user_sub: str, transcript: str, session_id: str
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Generate a summary from the provided transcription text.
|
"""Generate a summary from the provided transcription text.
|
||||||
|
|
||||||
@@ -401,11 +253,11 @@ def summarize_transcription_internals(
|
|||||||
"""
|
"""
|
||||||
logger.info(
|
logger.info(
|
||||||
"Starting summarization task | Owner: %s",
|
"Starting summarization task | Owner: %s",
|
||||||
owner_id,
|
user_sub,
|
||||||
)
|
)
|
||||||
|
|
||||||
user_has_tracing_consent = analytics.is_feature_enabled(
|
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
|
# NOTE: We must instantiate a new LLMObservability client for each task invocation
|
||||||
@@ -416,7 +268,7 @@ def summarize_transcription_internals(
|
|||||||
llm_observability = LLMObservability(
|
llm_observability = LLMObservability(
|
||||||
user_has_tracing_consent=user_has_tracing_consent,
|
user_has_tracing_consent=user_has_tracing_consent,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
user_id=owner_id,
|
user_id=user_sub,
|
||||||
)
|
)
|
||||||
llm_service = LLMService(llm_observability=llm_observability)
|
llm_service = LLMService(llm_observability=llm_observability)
|
||||||
|
|
||||||
@@ -468,29 +320,6 @@ def summarize_transcription_internals(
|
|||||||
return summary
|
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
|
# Tasks v2
|
||||||
##################################################################################
|
##################################################################################
|
||||||
@@ -549,6 +378,64 @@ def process_audio_transcribe_v2_task(
|
|||||||
).model_dump()
|
).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(
|
file_service.store_transcript(
|
||||||
transcript=transcription_res,
|
transcript=transcription_res,
|
||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
@@ -561,9 +448,30 @@ def process_audio_transcribe_v2_task(
|
|||||||
call_webhook_v2_task.apply_async(
|
call_webhook_v2_task.apply_async(
|
||||||
args=[success_payload.model_dump(), payload.tenant_id]
|
args=[success_payload.model_dump(), payload.tenant_id]
|
||||||
)
|
)
|
||||||
|
metadata_manager.capture(job_id, settings.posthog_event_success)
|
||||||
|
|
||||||
return success_payload.model_dump()
|
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)
|
@signals.task_failure.connect(sender=process_audio_transcribe_v2_task)
|
||||||
def handle_transcribe_v2_failed(
|
def handle_transcribe_v2_failed(
|
||||||
sender,
|
sender,
|
||||||
@@ -621,7 +529,7 @@ def summarize_v2_task(
|
|||||||
"""
|
"""
|
||||||
payload = SummarizeTaskV2Payload.model_validate(payload)
|
payload = SummarizeTaskV2Payload.model_validate(payload)
|
||||||
summary = summarize_transcription_internals(
|
summary = summarize_transcription_internals(
|
||||||
owner_id=payload.user_sub,
|
user_sub=payload.user_sub,
|
||||||
transcript=payload.content,
|
transcript=payload.content,
|
||||||
session_id=self.request.id,
|
session_id=self.request.id,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"""Application configuration and settings."""
|
"""Application configuration and settings."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
from functools import cached_property, lru_cache
|
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 fastapi import Depends
|
||||||
from pydantic import (
|
from pydantic import (
|
||||||
@@ -34,9 +33,12 @@ class AuthorizedTenant(BaseModel):
|
|||||||
title="Webhook API Key",
|
title="Webhook API Key",
|
||||||
description="The api_key to authenticate the webhook request.",
|
description="The api_key to authenticate the webhook request.",
|
||||||
)
|
)
|
||||||
|
allowed_push_to_docs: bool = Field(
|
||||||
|
title="Allow Push to Docs",
|
||||||
V1_DEFAULT_TENANT_ID = "__deprecated_meet_tenant__"
|
description="Whether to allow pushing transcript"
|
||||||
|
" and summaries to docs for this tenant.",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
@@ -45,14 +47,12 @@ class Settings(BaseSettings):
|
|||||||
model_config = SettingsConfigDict(env_file=".env", frozen=True)
|
model_config = SettingsConfigDict(env_file=".env", frozen=True)
|
||||||
|
|
||||||
app_name: str = "summary"
|
app_name: str = "summary"
|
||||||
app_api_v1_str: str = "/api/v1"
|
|
||||||
app_api_v2_str: str = "/api/v2"
|
app_api_v2_str: str = "/api/v2"
|
||||||
|
|
||||||
# Authorized Tenants
|
# Authorized Tenants
|
||||||
# Using env variables to store authorized tenants for now
|
# Using env variables to store authorized tenants for now
|
||||||
# to avoid any other external dependency (DB)
|
# to avoid any other external dependency (DB)
|
||||||
authorized_tenants: tuple[AuthorizedTenant, ...] = Field(default_factory=tuple)
|
authorized_tenants: tuple[AuthorizedTenant, ...] = Field(default_factory=tuple)
|
||||||
v1_tenant_id: str = V1_DEFAULT_TENANT_ID
|
|
||||||
|
|
||||||
# Audio recordings
|
# Audio recordings
|
||||||
recording_max_duration: Optional[int] = None
|
recording_max_duration: Optional[int] = None
|
||||||
@@ -72,8 +72,6 @@ class Settings(BaseSettings):
|
|||||||
celery_result_backend: str = "redis://redis/0"
|
celery_result_backend: str = "redis://redis/0"
|
||||||
celery_max_retries: int = 1
|
celery_max_retries: int = 1
|
||||||
|
|
||||||
transcribe_queue: str = "transcribe-queue"
|
|
||||||
summarize_queue: str = "summarize-queue"
|
|
||||||
# v2 tasks
|
# v2 tasks
|
||||||
transcribe_queue_v2: str = "transcribe-queue-v2"
|
transcribe_queue_v2: str = "transcribe-queue-v2"
|
||||||
summarize_queue_v2: str = "summarize-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_status_forcelist: List[int] = [502, 503, 504]
|
||||||
webhook_backoff_factor: float = 0.1
|
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
|
# Summary related settings
|
||||||
is_summary_enabled: bool = True
|
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
|
||||||
sentry_is_enabled: bool = False
|
sentry_is_enabled: bool = False
|
||||||
sentry_dsn: Optional[str] = None
|
sentry_dsn: Optional[str] = None
|
||||||
@@ -145,33 +144,6 @@ class Settings(BaseSettings):
|
|||||||
task_tracker_redis_url: str = "redis://redis/0"
|
task_tracker_redis_url: str = "redis://redis/0"
|
||||||
task_tracker_prefix: str = "task_metadata:"
|
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")
|
@model_validator(mode="after")
|
||||||
def validate_authorized_tenants(self):
|
def validate_authorized_tenants(self):
|
||||||
"""Validate authorized tenants configuration."""
|
"""Validate authorized tenants configuration."""
|
||||||
@@ -190,16 +162,6 @@ class Settings(BaseSettings):
|
|||||||
raise ValueError("Duplicate application API api_keys are not allowed")
|
raise ValueError("Duplicate application API api_keys are not allowed")
|
||||||
return self
|
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
|
@cached_property
|
||||||
def authorized_tenant_api_keys(self) -> frozenset[str]:
|
def authorized_tenant_api_keys(self) -> frozenset[str]:
|
||||||
"""Return a frozenset of authorized tenant API api_keys."""
|
"""Return a frozenset of authorized tenant API api_keys."""
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -13,7 +13,6 @@ from urllib.parse import urlparse
|
|||||||
|
|
||||||
import requests
|
import requests
|
||||||
from minio import Minio
|
from minio import Minio
|
||||||
from minio.error import MinioException, S3Error
|
|
||||||
|
|
||||||
from summary.core.config import get_settings
|
from summary.core.config import get_settings
|
||||||
from summary.core.shared_models import WhisperXResponse
|
from summary.core.shared_models import WhisperXResponse
|
||||||
@@ -144,54 +143,6 @@ class FileService:
|
|||||||
self._allowed_extensions = settings.recording_allowed_extensions
|
self._allowed_extensions = settings.recording_allowed_extensions
|
||||||
self._max_duration = settings.recording_max_duration
|
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:
|
def _download_from_cloud_storage_url(self, cloud_storage_url: str) -> Path:
|
||||||
"""Download file from a cloud storage URL to local temporary file."""
|
"""Download file from a cloud storage URL to local temporary file."""
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -204,12 +155,6 @@ class FileService:
|
|||||||
raise ValueError("Invalid cloud_storage_url")
|
raise ValueError("Invalid cloud_storage_url")
|
||||||
|
|
||||||
extension = Path(urlparse(cloud_storage_url).path).suffix.lower()
|
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:
|
try:
|
||||||
with requests.get(
|
with requests.get(
|
||||||
cloud_storage_url,
|
cloud_storage_url,
|
||||||
@@ -302,33 +247,19 @@ class FileService:
|
|||||||
os.remove(output_path)
|
os.remove(output_path)
|
||||||
raise RuntimeError("Failed to extract audio.") from e
|
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."""
|
"""Read and parse a JSON file from MinIO storage."""
|
||||||
logger.info("Reading JSON: %s", object_name)
|
logger.info("Reading JSON: %s", cloud_storage_url)
|
||||||
|
local_path = self._download_from_cloud_storage_url(cloud_storage_url)
|
||||||
if not object_name:
|
|
||||||
raise ValueError("Invalid object_name")
|
|
||||||
|
|
||||||
response = None
|
|
||||||
try:
|
try:
|
||||||
response = self._minio_client.get_object(self._bucket_name, object_name)
|
return json.load(local_path.open("r"))
|
||||||
return json.loads(response.read())
|
|
||||||
except (MinioException, S3Error) as e:
|
|
||||||
raise FileServiceException(
|
|
||||||
"Unexpected error while reading JSON object."
|
|
||||||
) from e
|
|
||||||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||||
raise FileServiceException("Invalid JSON content.") from e
|
raise FileServiceException("Invalid JSON content.") from e
|
||||||
finally:
|
|
||||||
if response:
|
|
||||||
response.close()
|
|
||||||
response.release_conn()
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def prepare_audio_file(
|
def prepare_audio_file(
|
||||||
self,
|
self,
|
||||||
remote_object_key: str | None = None,
|
cloud_storage_url: str,
|
||||||
cloud_storage_url: str | None = None,
|
|
||||||
):
|
):
|
||||||
"""Download and prepare audio file for processing.
|
"""Download and prepare audio file for processing.
|
||||||
|
|
||||||
@@ -341,20 +272,7 @@ class FileService:
|
|||||||
file_handle = None
|
file_handle = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if bool(remote_object_key) == bool(cloud_storage_url):
|
downloaded_path = self._download_from_cloud_storage_url(cloud_storage_url)
|
||||||
raise ValueError(
|
|
||||||
(
|
|
||||||
"Exactly one of 'remote_object_key' or "
|
|
||||||
"'cloud_storage_url' must be provided."
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if cloud_storage_url:
|
|
||||||
downloaded_path = self._download_from_cloud_storage_url(
|
|
||||||
cloud_storage_url
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
downloaded_path = self._download_from_minio(remote_object_key)
|
|
||||||
|
|
||||||
duration = self._validate_duration(downloaded_path)
|
duration = self._validate_duration(downloaded_path)
|
||||||
|
|
||||||
|
|||||||
@@ -30,4 +30,5 @@ Einige Punkte, die wir Ihnen empfehlen zu überprüfen:
|
|||||||
document_title_template=(
|
document_title_template=(
|
||||||
'Besprechung "{room}" am {room_recording_date} um {room_recording_time}'
|
'Besprechung "{room}" am {room_recording_date} um {room_recording_time}'
|
||||||
),
|
),
|
||||||
|
summary_title_template="Zusammenfassung von {title}",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,4 +30,5 @@ A few things we recommend you check:
|
|||||||
document_title_template=(
|
document_title_template=(
|
||||||
'Meeting "{room}" on {room_recording_date} at {room_recording_time}'
|
'Meeting "{room}" on {room_recording_date} at {room_recording_time}'
|
||||||
),
|
),
|
||||||
|
summary_title_template="Summary of {title}",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,4 +30,5 @@ Quelques points que nous vous conseillons de vérifier :
|
|||||||
document_title_template=(
|
document_title_template=(
|
||||||
'Réunion "{room}" du {room_recording_date} à {room_recording_time}'
|
'Réunion "{room}" du {room_recording_date} à {room_recording_time}'
|
||||||
),
|
),
|
||||||
|
summary_title_template="Résumé de {title}",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,4 +30,5 @@ Een paar punten die wij u aanraden te controleren:
|
|||||||
document_title_template=(
|
document_title_template=(
|
||||||
'Vergadering "{room}" op {room_recording_date} om {room_recording_time}'
|
'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
|
hallucination_replacement_text: str
|
||||||
document_default_title: str
|
document_default_title: str
|
||||||
document_title_template: str
|
document_title_template: str
|
||||||
|
summary_title_template: str
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Models for the API & Celery tasks creation."""
|
"""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.config import get_settings
|
||||||
from summary.core.types import Url
|
from summary.core.types import Url
|
||||||
@@ -12,6 +13,47 @@ class SharedV2TaskCreation(BaseModel):
|
|||||||
"""Model that holds basic information for task creation."""
|
"""Model that holds basic information for task creation."""
|
||||||
|
|
||||||
user_sub: str = Field(title="User Sub", description="The user's sub.")
|
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):
|
class TranscribeTaskV2Request(SharedV2TaskCreation):
|
||||||
@@ -27,7 +69,17 @@ class TranscribeTaskV2Request(SharedV2TaskCreation):
|
|||||||
description="The language of the context text.",
|
description="The language of the context text.",
|
||||||
)
|
)
|
||||||
language: str = Field(
|
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")
|
@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
|
"""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.")
|
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):
|
class SummarizeTaskV2Request(SharedV2TaskCreation):
|
||||||
@@ -58,3 +111,4 @@ class SummarizeTaskV2Payload(SummarizeTaskV2Request):
|
|||||||
"""Model for creating a summarize task (used for actual task creation)."""
|
"""Model for creating a summarize task (used for actual task creation)."""
|
||||||
|
|
||||||
tenant_id: str = Field(title="Tenant ID", description="The ID of the tenant.")
|
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",
|
"SummarizeWebhookPayloads",
|
||||||
"WebhookPayloads",
|
"WebhookPayloads",
|
||||||
"WhisperXResponse",
|
"WhisperXResponse",
|
||||||
|
"webhook_payload_adapter",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
"""Transcript formatting into readable conversation format with speaker labels."""
|
"""Transcript formatting into readable conversation format with speaker labels."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
|
||||||
from typing import Tuple
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
from summary.core.config import get_settings
|
from summary.core.config import get_settings
|
||||||
from summary.core.locales import LocaleStrings
|
from summary.core.locales import LocaleStrings
|
||||||
@@ -41,12 +38,9 @@ class TranscriptFormatter:
|
|||||||
def format(
|
def format(
|
||||||
self,
|
self,
|
||||||
transcription,
|
transcription,
|
||||||
room: str | None = None,
|
|
||||||
recording_datetime: str | None = None,
|
|
||||||
owner_timezone: str | None = None,
|
|
||||||
download_link: str | None = None,
|
download_link: str | None = None,
|
||||||
) -> Tuple[str, str]:
|
) -> str:
|
||||||
"""Format transcription into the final document and its title."""
|
"""Format transcription into the final document."""
|
||||||
segments = self._get_segments(transcription)
|
segments = self._get_segments(transcription)
|
||||||
|
|
||||||
if not segments:
|
if not segments:
|
||||||
@@ -56,9 +50,7 @@ class TranscriptFormatter:
|
|||||||
content = self._remove_hallucinations(content)
|
content = self._remove_hallucinations(content)
|
||||||
content = self._add_header(content, download_link)
|
content = self._add_header(content, download_link)
|
||||||
|
|
||||||
title = self._generate_title(room, recording_datetime, owner_timezone)
|
return content
|
||||||
|
|
||||||
return content, title
|
|
||||||
|
|
||||||
def _remove_hallucinations(self, content: str) -> str:
|
def _remove_hallucinations(self, content: str) -> str:
|
||||||
"""Remove hallucination patterns from content."""
|
"""Remove hallucination patterns from content."""
|
||||||
@@ -96,23 +88,3 @@ class TranscriptFormatter:
|
|||||||
content = header + content
|
content = header + content
|
||||||
|
|
||||||
return 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 logging
|
||||||
|
|
||||||
import requests
|
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.config import get_settings
|
||||||
from summary.core.shared_models import (
|
from summary.core.shared_models import (
|
||||||
@@ -18,83 +15,6 @@ settings = get_settings()
|
|||||||
logger = logging.getLogger(__name__)
|
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(
|
def call_webhook_v2(
|
||||||
*,
|
*,
|
||||||
tenant_id: str,
|
tenant_id: str,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import sentry_sdk
|
|||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from summary.api import health
|
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
|
from summary.core.config import get_settings
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -17,6 +17,5 @@ app = FastAPI(
|
|||||||
title=settings.app_name,
|
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(api_router_v2, prefix=settings.app_api_v2_str)
|
||||||
app.include_router(health.router)
|
app.include_router(health.router)
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ from summary.main import app
|
|||||||
def get_settings_override():
|
def get_settings_override():
|
||||||
"""Return settings for tests."""
|
"""Return settings for tests."""
|
||||||
return Settings(
|
return Settings(
|
||||||
v1_tenant_id="test-tenant",
|
|
||||||
authorized_tenants=(
|
authorized_tenants=(
|
||||||
AuthorizedTenant(
|
AuthorizedTenant(
|
||||||
webhook_url="https://example.com/webhook",
|
webhook_url="https://example.com/webhook",
|
||||||
|
|||||||
Generated
+1475
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user