mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-27 20:29:09 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b56bcc38c8 |
@@ -305,6 +305,7 @@ 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"
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ and this project adheres to
|
|||||||
|
|
||||||
- ⬆️(agents) upgrade to python 3.14 slim
|
- ⬆️(agents) upgrade to python 3.14 slim
|
||||||
- ⬆️(dependencies) update python dependencies
|
- ⬆️(dependencies) update python dependencies
|
||||||
- 💥(summary) remove v1 related code #1362
|
|
||||||
- ✨(meet) use compatible with summary v2 #1362
|
|
||||||
- ♻️(backend) refactor analytics backend from Protocol to abstract class
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -301,7 +301,7 @@ services:
|
|||||||
context: ./src/summary
|
context: ./src/summary
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
target: production
|
target: production
|
||||||
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q transcribe-queue-v2
|
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q transcribe-queue
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/summary
|
- env.d/development/summary
|
||||||
volumes:
|
volumes:
|
||||||
@@ -321,7 +321,7 @@ services:
|
|||||||
context: ./src/summary
|
context: ./src/summary
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
target: production
|
target: production
|
||||||
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q summarize-queue-v2
|
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q summarize-queue
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/summary
|
- env.d/development/summary
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -65,9 +65,8 @@ 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/v2/async-jobs/transcribe/
|
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v1/tasks/
|
||||||
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).
|
||||||
|
|||||||
@@ -30,4 +30,3 @@ POSTHOG_ENABLED="False"
|
|||||||
|
|
||||||
# Transcription
|
# Transcription
|
||||||
TRANSCRIPTION_SATISFACTION_FORM_BASE_URL=
|
TRANSCRIPTION_SATISFACTION_FORM_BASE_URL=
|
||||||
AUTHORIZED_TENANTS='[{"id": "meet","api_key": "password","webhook_url": "https://configure-your-url.com/api/v1.0/recordings/external-process-hook/","webhook_api_key": "webhook-password","allowed_push_to_docs": true}]'
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from core.recording.event import notification
|
|||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
from .tasks.file import process_file_deletion
|
from .tasks.file import process_file_deletion
|
||||||
from .utils import generate_download_s3_url
|
from .utils import generate_download_file_url
|
||||||
|
|
||||||
|
|
||||||
def hard_delete_file(file):
|
def hard_delete_file(file):
|
||||||
@@ -242,7 +242,7 @@ class FileAdmin(admin.ModelAdmin):
|
|||||||
"""Return a clickable preview URL for the file."""
|
"""Return a clickable preview URL for the file."""
|
||||||
if not obj.is_ready:
|
if not obj.is_ready:
|
||||||
return "-"
|
return "-"
|
||||||
url = generate_download_s3_url(obj.key, expires_in=60 * 60)
|
url = generate_download_file_url(obj, expires_in=60 * 60)
|
||||||
|
|
||||||
return format_html(
|
return format_html(
|
||||||
'<a href="{}" target="_blank" rel="noopener noreferrer">Open File</a>', url
|
'<a href="{}" target="_blank" rel="noopener noreferrer">Open File</a>', url
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
"""Analytics backend protocol and default no-op implementation."""
|
"""Analytics backend protocol and default no-op implementation."""
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from typing import Any, Protocol
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from ..models import User
|
from ..models import User
|
||||||
from .events import AnalyticsEvent
|
from .events import AnalyticsEvent
|
||||||
|
|
||||||
|
|
||||||
class AnalyticsBackend(ABC):
|
class AnalyticsBackend(Protocol):
|
||||||
"""
|
"""
|
||||||
Interface every analytics backend must implement.
|
Interface every analytics backend must implement.
|
||||||
|
|
||||||
@@ -18,11 +17,11 @@ class AnalyticsBackend(ABC):
|
|||||||
ANALYTICS_BACKEND_SETTINGS = {"api_key": "...", "host": "..."}
|
ANALYTICS_BACKEND_SETTINGS = {"api_key": "...", "host": "..."}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@abstractmethod
|
def __init__(self, **kwargs: Any) -> None: ...
|
||||||
|
|
||||||
def identify(self, user: User, properties: dict[str, Any] | None = None) -> None:
|
def identify(self, user: User, properties: dict[str, Any] | None = None) -> None:
|
||||||
"""Associate traits (email, name, ...) with an identified user."""
|
"""Associate traits (email, name, ...) with an identified user."""
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def capture(
|
def capture(
|
||||||
self,
|
self,
|
||||||
user: User,
|
user: User,
|
||||||
@@ -31,14 +30,16 @@ class AnalyticsBackend(ABC):
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Record an event performed by an identified user."""
|
"""Record an event performed by an identified user."""
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def shutdown(self) -> None:
|
def shutdown(self) -> None:
|
||||||
"""Flush pending events. Called on process exit."""
|
"""Flush pending events. Called on process exit."""
|
||||||
|
|
||||||
|
|
||||||
class NoOpAnalytics(AnalyticsBackend):
|
class NoOpAnalytics:
|
||||||
"""Default backend: silently discards everything."""
|
"""Default backend: silently discards everything."""
|
||||||
|
|
||||||
|
def __init__(self, **kwargs: Any) -> None:
|
||||||
|
"""No-op: accepts and ignores any backend settings kwargs."""
|
||||||
|
|
||||||
def identify(self, user: User, properties=None) -> None:
|
def identify(self, user: User, properties=None) -> None:
|
||||||
"""No-op: discards identify calls."""
|
"""No-op: discards identify calls."""
|
||||||
|
|
||||||
|
|||||||
@@ -6,13 +6,12 @@ from typing import Any
|
|||||||
from posthog import Posthog
|
from posthog import Posthog
|
||||||
|
|
||||||
from ..models import User
|
from ..models import User
|
||||||
from . import AnalyticsBackend
|
|
||||||
from .events import AnalyticsEvent
|
from .events import AnalyticsEvent
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class PostHogAnalytics(AnalyticsBackend):
|
class PostHogAnalytics:
|
||||||
"""Send events to PostHog, keyed on the user's primary key (UUID)."""
|
"""Send events to PostHog, keyed on the user's primary key (UUID)."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
|
|||||||
@@ -563,13 +563,3 @@ class RenameParticipantSerializer(BaseValidationOnlySerializer):
|
|||||||
"""Serializer for renaming a participant in a room."""
|
"""Serializer for renaming a participant in a room."""
|
||||||
|
|
||||||
name = serializers.CharField(min_length=1, max_length=255, allow_blank=False)
|
name = serializers.CharField(min_length=1, max_length=255, allow_blank=False)
|
||||||
|
|
||||||
|
|
||||||
class ExternalProcessEventSerializer(BaseValidationOnlySerializer):
|
|
||||||
"""Validate external process event data."""
|
|
||||||
|
|
||||||
job_id = serializers.CharField(required=True)
|
|
||||||
# We are not strict on purpose on those fields to avoid
|
|
||||||
# useless bad requests
|
|
||||||
type = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
|
||||||
status = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
|
||||||
|
|||||||
@@ -39,10 +39,7 @@ from core import analytics, 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 (
|
from core.recording.event.authentication import StorageEventAuthentication
|
||||||
RecordingProcessWebhookAuthentication,
|
|
||||||
StorageEventAuthentication,
|
|
||||||
)
|
|
||||||
from core.recording.event.exceptions import (
|
from core.recording.event.exceptions import (
|
||||||
InvalidBucketError,
|
InvalidBucketError,
|
||||||
InvalidFilepathError,
|
InvalidFilepathError,
|
||||||
@@ -1002,60 +999,6 @@ class RecordingViewSet(
|
|||||||
{"message": "Event processed."},
|
{"message": "Event processed."},
|
||||||
)
|
)
|
||||||
|
|
||||||
@decorators.action(
|
|
||||||
detail=False,
|
|
||||||
methods=["post"],
|
|
||||||
url_path="external-process-hook",
|
|
||||||
authentication_classes=[RecordingProcessWebhookAuthentication],
|
|
||||||
serializer_class=serializers.ExternalProcessEventSerializer,
|
|
||||||
)
|
|
||||||
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)
|
|
||||||
serializer = self.get_serializer(data=request.data)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
|
|
||||||
ok_response = drf_response.Response(
|
|
||||||
{"message": "Event processed."},
|
|
||||||
)
|
|
||||||
|
|
||||||
validated_data = serializer.validated_data
|
|
||||||
job_id = validated_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)
|
|
||||||
return ok_response
|
|
||||||
|
|
||||||
if validated_data.get("type") == "transcript":
|
|
||||||
if validated_data.get("status") == "success":
|
|
||||||
logger.info(
|
|
||||||
"External process transcript success received for recording %s",
|
|
||||||
job_id,
|
|
||||||
)
|
|
||||||
recording.status = (
|
|
||||||
models.RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL
|
|
||||||
)
|
|
||||||
recording.save()
|
|
||||||
return ok_response
|
|
||||||
|
|
||||||
if validated_data.get("status") == "failure":
|
|
||||||
logger.info(
|
|
||||||
"External process transcript failure received for recording %s",
|
|
||||||
job_id,
|
|
||||||
)
|
|
||||||
recording.status = models.RecordingStatusChoices.EXTERNAL_PROCESS_FAILED
|
|
||||||
recording.save()
|
|
||||||
return ok_response
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"No changes to save for external process id %s and payload %s",
|
|
||||||
job_id,
|
|
||||||
validated_data,
|
|
||||||
)
|
|
||||||
|
|
||||||
return ok_response
|
|
||||||
|
|
||||||
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
@@ -1,23 +0,0 @@
|
|||||||
# Generated by Django 5.2.14 on 2026-06-22 08:26
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('core', '0020_alter_file_upload_state'),
|
|
||||||
]
|
|
||||||
|
|
||||||
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,11 +60,6 @@ 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):
|
||||||
@@ -78,8 +73,6 @@ 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,
|
||||||
}
|
}
|
||||||
@@ -605,14 +598,6 @@ 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"
|
||||||
@@ -668,8 +653,6 @@ 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, username: str = "storage_event_user") -> None:
|
def __init__(self) -> None:
|
||||||
self.pk = None
|
self.pk = None
|
||||||
self.username = username
|
self.username = "storage_event_user"
|
||||||
self.is_active = True
|
self.is_active = True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -34,33 +34,33 @@ class MachineUser:
|
|||||||
return self.username
|
return self.username
|
||||||
|
|
||||||
|
|
||||||
class HeaderBasedAuthentication(BaseAuthentication):
|
class StorageEventAuthentication(BaseAuthentication):
|
||||||
"""Authenticate requests using a header with a secret key."""
|
"""Authenticate requests using a Bearer token for storage event integration.
|
||||||
|
This class validates Bearer tokens for storage events that don't map to database users.
|
||||||
|
It's designed for S3-compatible storage integrations and similar use cases.
|
||||||
|
Events are submitted when a webhook is configured on some bucket's events.
|
||||||
|
"""
|
||||||
|
|
||||||
AUTH_HEADER = "Authorization"
|
AUTH_HEADER = "Authorization"
|
||||||
TOKEN_TYPE = "Bearer" # noqa S105
|
TOKEN_TYPE = "Bearer" # noqa S105
|
||||||
REALM = ""
|
|
||||||
|
|
||||||
IS_ENFORCED_SETTINGS_KEY = None
|
|
||||||
EXPECTED_TOKEN_SETTINGS_KEY = None
|
|
||||||
|
|
||||||
def authenticate(self, request):
|
def authenticate(self, request):
|
||||||
"""Validate the Bearer token from the Authorization header."""
|
"""Validate the Bearer token from the Authorization header."""
|
||||||
|
|
||||||
if self.IS_ENFORCED_SETTINGS_KEY is not None:
|
if not settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
|
||||||
if not getattr(settings, self.IS_ENFORCED_SETTINGS_KEY):
|
|
||||||
return MachineUser(), None
|
return MachineUser(), None
|
||||||
|
|
||||||
if (
|
required_token = settings.RECORDING_STORAGE_EVENT_TOKEN
|
||||||
self.EXPECTED_TOKEN_SETTINGS_KEY is None
|
if not required_token:
|
||||||
or (required_token := getattr(settings, self.EXPECTED_TOKEN_SETTINGS_KEY))
|
if settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
|
||||||
is None
|
|
||||||
):
|
|
||||||
raise AuthenticationFailed(
|
raise AuthenticationFailed(
|
||||||
"Authentication is enabled but token is not configured."
|
"Authentication is enabled but token is not configured."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return MachineUser(), None
|
||||||
|
|
||||||
auth_header = request.headers.get(self.AUTH_HEADER)
|
auth_header = request.headers.get(self.AUTH_HEADER)
|
||||||
|
|
||||||
if not auth_header:
|
if not auth_header:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Authentication failed: Missing Authorization header (ip: %s)",
|
"Authentication failed: Missing Authorization header (ip: %s)",
|
||||||
@@ -68,10 +68,15 @@ class HeaderBasedAuthentication(BaseAuthentication):
|
|||||||
)
|
)
|
||||||
raise AuthenticationFailed("Authorization header is required")
|
raise AuthenticationFailed("Authorization header is required")
|
||||||
|
|
||||||
scheme, _, token = auth_header.partition(" ")
|
auth_parts = auth_header.split(" ")
|
||||||
if scheme.lower() != self.TOKEN_TYPE.lower() or not token.strip():
|
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
|
||||||
raise AuthenticationFailed("Invalid authorization header format.")
|
logger.warning(
|
||||||
token = token.strip()
|
"Authentication failed: Invalid authorization header (ip: %s)",
|
||||||
|
request.META.get("REMOTE_ADDR"),
|
||||||
|
)
|
||||||
|
raise AuthenticationFailed("Invalid authorization header.")
|
||||||
|
|
||||||
|
token = auth_parts[1]
|
||||||
|
|
||||||
# Use constant-time comparison to prevent timing attacks
|
# Use constant-time comparison to prevent timing attacks
|
||||||
if not secrets.compare_digest(token.encode(), required_token.encode()):
|
if not secrets.compare_digest(token.encode(), required_token.encode()):
|
||||||
@@ -85,26 +90,4 @@ class HeaderBasedAuthentication(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='{self.REALM}'"
|
return f"{self.TOKEN_TYPE} realm='Storage event API'"
|
||||||
|
|
||||||
|
|
||||||
class StorageEventAuthentication(HeaderBasedAuthentication):
|
|
||||||
"""Authenticate requests using a Bearer token for storage event integration.
|
|
||||||
This class validates Bearer tokens for storage events that don't map to database users.
|
|
||||||
It's designed for S3-compatible storage integrations and similar use cases.
|
|
||||||
Events are submitted when a webhook is configured on some bucket's events.
|
|
||||||
"""
|
|
||||||
|
|
||||||
REALM = "Storage event API"
|
|
||||||
IS_ENFORCED_SETTINGS_KEY = "RECORDING_ENABLE_STORAGE_EVENT_AUTH"
|
|
||||||
EXPECTED_TOKEN_SETTINGS_KEY = "RECORDING_STORAGE_EVENT_TOKEN" # noqa S105
|
|
||||||
|
|
||||||
|
|
||||||
class RecordingProcessWebhookAuthentication(HeaderBasedAuthentication):
|
|
||||||
"""
|
|
||||||
Custom authentication class for recording process webhook requests.
|
|
||||||
Validates the API key in the Authorization header.
|
|
||||||
"""
|
|
||||||
|
|
||||||
REALM = "External process webhook API"
|
|
||||||
EXPECTED_TOKEN_SETTINGS_KEY = "SUMMARY_SERVICE_WEBHOOK_API_TOKEN" # noqa S105
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import asyncio
|
|||||||
import logging
|
import logging
|
||||||
import smtplib
|
import smtplib
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.mail import send_mail
|
from django.core.mail import send_mail
|
||||||
@@ -18,7 +17,6 @@ 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_url
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -180,49 +178,8 @@ class NotificationService:
|
|||||||
|
|
||||||
return _ns_to_utc(file_result.started_at), _ns_to_utc(file_result.ended_at)
|
return _ns_to_utc(file_result.started_at), _ns_to_utc(file_result.ended_at)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _generate_title(
|
|
||||||
*,
|
|
||||||
locale: str,
|
|
||||||
room: str,
|
|
||||||
recording_datetime: datetime | None,
|
|
||||||
owner_timezone: str | None,
|
|
||||||
) -> str:
|
|
||||||
"""Generate title from context or return default."""
|
|
||||||
if recording_datetime is None:
|
|
||||||
with override(locale):
|
|
||||||
return _("Transcription")
|
|
||||||
|
|
||||||
dt = recording_datetime
|
|
||||||
if owner_timezone:
|
|
||||||
try:
|
|
||||||
dt = recording_datetime.astimezone(ZoneInfo(owner_timezone))
|
|
||||||
except (KeyError, ZoneInfoNotFoundError):
|
|
||||||
pass # Keep the original UTC datetime
|
|
||||||
|
|
||||||
with override(locale):
|
|
||||||
translated_template = _(
|
|
||||||
'Meeting "{room}" on {room_recording_date} at {room_recording_time}'
|
|
||||||
)
|
|
||||||
return translated_template.format(
|
|
||||||
room=room,
|
|
||||||
room_recording_date=dt.strftime("%Y-%m-%d"),
|
|
||||||
room_recording_time=dt.strftime("%H:%M"),
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _notify_summary_service(recording: models.Recording):
|
def _notify_summary_service(recording: models.Recording):
|
||||||
if settings.SUMMARY_SERVICE_VERSION == 1:
|
|
||||||
return NotificationService._notify_summary_service_v1(recording)
|
|
||||||
if settings.SUMMARY_SERVICE_VERSION == 2:
|
|
||||||
return NotificationService._notify_summary_service_v2(recording)
|
|
||||||
|
|
||||||
raise NotImplementedError(
|
|
||||||
f"Unknown summary service version: {settings.SUMMARY_SERVICE_VERSION}"
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _notify_summary_service_v1(recording: models.Recording):
|
|
||||||
"""Notify summary service about a new recording."""
|
"""Notify summary service about a new recording."""
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -296,119 +253,5 @@ class NotificationService:
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _notify_summary_service_v2(recording: models.Recording):
|
|
||||||
"""Notify summary service about a new recording."""
|
|
||||||
|
|
||||||
if (
|
|
||||||
not settings.SUMMARY_SERVICE_ENDPOINT
|
|
||||||
or not settings.SUMMARY_SERVICE_API_TOKEN
|
|
||||||
):
|
|
||||||
logger.error("Summary service not configured")
|
|
||||||
return False
|
|
||||||
|
|
||||||
owner_access = (
|
|
||||||
models.RecordingAccess.objects.select_related("user")
|
|
||||||
.filter(
|
|
||||||
role=models.RoleChoices.OWNER,
|
|
||||||
recording_id=recording.id,
|
|
||||||
)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
metadata_filename: None | str = None
|
|
||||||
if settings.METADATA_COLLECTOR_ENABLED and recording.options.get(
|
|
||||||
"collect_metadata", False
|
|
||||||
):
|
|
||||||
output_folder = settings.METADATA_COLLECTOR_OUTPUT_FOLDER
|
|
||||||
metadata_filename = f"{output_folder}/{recording.id}-metadata.json"
|
|
||||||
|
|
||||||
if not owner_access:
|
|
||||||
logger.error("No owner found for recording %s", recording.id)
|
|
||||||
return False
|
|
||||||
|
|
||||||
started_at, ended_at = async_to_sync(
|
|
||||||
NotificationService._get_recording_timestamps
|
|
||||||
)(recording.worker_id)
|
|
||||||
|
|
||||||
form_base_url = settings.TRANSCRIPTION_SATISFACTION_FORM_BASE_URL
|
|
||||||
form_link = (
|
|
||||||
f"{form_base_url}?room_id={recording.room.id}"
|
|
||||||
if (form_base_url and metadata_filename is not None)
|
|
||||||
else None
|
|
||||||
)
|
|
||||||
metadata_payload = None
|
|
||||||
if started_at and ended_at and metadata_filename:
|
|
||||||
metadata_payload = {
|
|
||||||
"cloud_storage_url": generate_download_s3_url(
|
|
||||||
metadata_filename,
|
|
||||||
expires_in=settings.SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS,
|
|
||||||
override_domain=False,
|
|
||||||
),
|
|
||||||
"started_at": started_at.isoformat(),
|
|
||||||
"ended_at": ended_at.isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"user_sub": owner_access.user.sub,
|
|
||||||
"user_email": owner_access.user.email,
|
|
||||||
"cloud_storage_url": generate_download_s3_url(
|
|
||||||
recording.key,
|
|
||||||
expires_in=settings.SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS,
|
|
||||||
override_domain=False,
|
|
||||||
),
|
|
||||||
"language": recording.options.get(
|
|
||||||
"language", get_language().split("-")[0].lower()
|
|
||||||
),
|
|
||||||
"context_language": owner_access.user.language,
|
|
||||||
"push_to_docs_config": {
|
|
||||||
"user_email": owner_access.user.email,
|
|
||||||
"title": NotificationService._generate_title(
|
|
||||||
locale=owner_access.user.language
|
|
||||||
or recording.options.get("language", get_language()),
|
|
||||||
room=recording.room.name,
|
|
||||||
recording_datetime=started_at,
|
|
||||||
owner_timezone=str(owner_access.user.timezone),
|
|
||||||
),
|
|
||||||
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
|
|
||||||
"form_link": form_link,
|
|
||||||
# For now the feature flag logic is handled on summary side
|
|
||||||
"auto_create_summary": True,
|
|
||||||
},
|
|
||||||
"metadata": metadata_payload,
|
|
||||||
}
|
|
||||||
|
|
||||||
headers = {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": f"Bearer {settings.SUMMARY_SERVICE_API_TOKEN}",
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = requests.post(
|
|
||||||
settings.SUMMARY_SERVICE_ENDPOINT,
|
|
||||||
json=payload,
|
|
||||||
headers=headers,
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
response_json = response.json()
|
|
||||||
# We do not require a job_id to avoid a breaking change
|
|
||||||
job_id = response_json.get("job_id")
|
|
||||||
if not isinstance(job_id, str):
|
|
||||||
raise ValueError("job_id is not a string")
|
|
||||||
|
|
||||||
recording.external_process_id = job_id
|
|
||||||
recording.save()
|
|
||||||
|
|
||||||
except requests.RequestException as exc:
|
|
||||||
logger.exception(
|
|
||||||
"Summary service error for recording %s. URL: %s. Exception: %s",
|
|
||||||
recording.id,
|
|
||||||
settings.SUMMARY_SERVICE_ENDPOINT,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
notification_service = NotificationService()
|
notification_service = NotificationService()
|
||||||
|
|||||||
@@ -136,10 +136,10 @@ def test_authenticate_header():
|
|||||||
|
|
||||||
|
|
||||||
def test_multiple_spaces_in_auth_header(settings):
|
def test_multiple_spaces_in_auth_header(settings):
|
||||||
"""Test success when Authorization header contains multiple spaces."""
|
"""Test failure when Authorization header contains multiple spaces."""
|
||||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
||||||
request = RequestFactory().get("/")
|
request = RequestFactory().get("/")
|
||||||
request.headers = {"Authorization": "Bearer extra-spaces-token"}
|
request.headers = {"Authorization": "Bearer extra-spaces-token"}
|
||||||
|
|
||||||
header = StorageEventAuthentication().authenticate_header(request)
|
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
|
||||||
assert header == "Bearer realm='Storage event API'"
|
StorageEventAuthentication().authenticate(request)
|
||||||
|
|||||||
@@ -243,168 +243,3 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
|
|||||||
assert result is False
|
assert result is False
|
||||||
assert mock_send_mail.call_count == 2
|
assert mock_send_mail.call_count == 2
|
||||||
assert "notification could not be sent:" in caplog.text
|
assert "notification could not be sent:" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.recording.event.notification.requests.post")
|
|
||||||
@mock.patch("core.recording.event.notification.generate_download_s3_url")
|
|
||||||
@mock.patch.object(
|
|
||||||
NotificationService, "_get_recording_timestamps", new_callable=mock.AsyncMock
|
|
||||||
)
|
|
||||||
def test_notify_summary_service_post_args_with_metadata(
|
|
||||||
mock_get_recording_timestamps,
|
|
||||||
mock_generate_download_s3_url,
|
|
||||||
mock_post,
|
|
||||||
settings,
|
|
||||||
):
|
|
||||||
"""Test summary notification computed request args when metadata is enabled."""
|
|
||||||
settings.SUMMARY_SERVICE_VERSION = 2
|
|
||||||
settings.SUMMARY_SERVICE_ENDPOINT = "https://summary.test/api/v2/tasks"
|
|
||||||
settings.SUMMARY_SERVICE_API_TOKEN = "summary-token"
|
|
||||||
settings.RECORDING_DOWNLOAD_BASE_URL = "https://app.test/recordings"
|
|
||||||
settings.SCREEN_RECORDING_BASE_URL = None
|
|
||||||
settings.METADATA_COLLECTOR_ENABLED = True
|
|
||||||
settings.METADATA_COLLECTOR_OUTPUT_FOLDER = "recordings-metadata"
|
|
||||||
|
|
||||||
recording = factories.RecordingFactory(
|
|
||||||
room__name="Engineering Sync",
|
|
||||||
worker_id="egress-1",
|
|
||||||
options={"collect_metadata": True, "language": "en-us"},
|
|
||||||
)
|
|
||||||
owner = factories.UserFactory(
|
|
||||||
email="owner@test.com",
|
|
||||||
sub="owner-sub",
|
|
||||||
language="fr-fr",
|
|
||||||
timezone="Europe/Paris",
|
|
||||||
)
|
|
||||||
factories.UserRecordingAccessFactory(
|
|
||||||
recording=recording, role=models.RoleChoices.OWNER, user=owner
|
|
||||||
)
|
|
||||||
|
|
||||||
started_at = datetime.datetime(2026, 1, 2, 10, 30, tzinfo=datetime.timezone.utc)
|
|
||||||
ended_at = datetime.datetime(2026, 1, 2, 11, 45, tzinfo=datetime.timezone.utc)
|
|
||||||
mock_get_recording_timestamps.return_value = (started_at, ended_at)
|
|
||||||
mock_generate_download_s3_url.side_effect = [
|
|
||||||
"https://storage.test/metadata.json",
|
|
||||||
"https://storage.test/recording.ogg",
|
|
||||||
]
|
|
||||||
|
|
||||||
mock_response = mock.Mock()
|
|
||||||
mock_response.raise_for_status.return_value = None
|
|
||||||
mock_response.json.return_value = {"job_id": "job-42"}
|
|
||||||
mock_post.return_value = mock_response
|
|
||||||
|
|
||||||
result = NotificationService._notify_summary_service(recording)
|
|
||||||
recording.refresh_from_db()
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
assert recording.external_process_id == "job-42"
|
|
||||||
|
|
||||||
metadata_filename = (
|
|
||||||
f"{settings.METADATA_COLLECTOR_OUTPUT_FOLDER}/{recording.id}-metadata.json"
|
|
||||||
)
|
|
||||||
expected_payload = {
|
|
||||||
"user_sub": owner.sub,
|
|
||||||
"user_email": owner.email,
|
|
||||||
"cloud_storage_url": "https://storage.test/recording.ogg",
|
|
||||||
"language": "en-us",
|
|
||||||
"context_language": owner.language,
|
|
||||||
"push_to_docs_config": {
|
|
||||||
"user_email": owner.email,
|
|
||||||
"title": 'Réunion "Engineering Sync" du 2026-01-02 à 11:30',
|
|
||||||
"download_link": f"{settings.RECORDING_DOWNLOAD_BASE_URL}/{recording.id}",
|
|
||||||
"auto_create_summary": True,
|
|
||||||
"form_link": None,
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"cloud_storage_url": "https://storage.test/metadata.json",
|
|
||||||
"started_at": started_at.isoformat(),
|
|
||||||
"ended_at": ended_at.isoformat(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
expected_headers = {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": "Bearer summary-token",
|
|
||||||
}
|
|
||||||
mock_post.assert_called_once_with(
|
|
||||||
"https://summary.test/api/v2/tasks",
|
|
||||||
json=expected_payload,
|
|
||||||
headers=expected_headers,
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
assert mock_generate_download_s3_url.call_args_list == [
|
|
||||||
mock.call(metadata_filename, expires_in=60 * 60 * 24, override_domain=False),
|
|
||||||
mock.call(recording.key, expires_in=60 * 60 * 24, override_domain=False),
|
|
||||||
]
|
|
||||||
mock_get_recording_timestamps.assert_awaited_once_with("egress-1")
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.recording.event.notification.requests.post")
|
|
||||||
@mock.patch("core.recording.event.notification.generate_download_s3_url")
|
|
||||||
@mock.patch.object(
|
|
||||||
NotificationService, "_get_recording_timestamps", new_callable=mock.AsyncMock
|
|
||||||
)
|
|
||||||
def test_notify_summary_service_post_args_without_metadata(
|
|
||||||
mock_get_recording_timestamps,
|
|
||||||
mock_generate_download_s3_url,
|
|
||||||
mock_post,
|
|
||||||
settings,
|
|
||||||
):
|
|
||||||
"""Test summary notification computed request args when metadata is not available."""
|
|
||||||
settings.SUMMARY_SERVICE_VERSION = 2
|
|
||||||
settings.SUMMARY_SERVICE_ENDPOINT = "https://summary.test/api/v2/tasks"
|
|
||||||
settings.SUMMARY_SERVICE_API_TOKEN = "summary-token"
|
|
||||||
settings.RECORDING_DOWNLOAD_BASE_URL = "https://app.test/recordings"
|
|
||||||
settings.SCREEN_RECORDING_BASE_URL = None
|
|
||||||
settings.METADATA_COLLECTOR_ENABLED = False
|
|
||||||
|
|
||||||
recording = factories.RecordingFactory(room__name="Daily")
|
|
||||||
owner = factories.UserFactory(
|
|
||||||
email="owner@test.com",
|
|
||||||
sub="owner-sub",
|
|
||||||
language="en-us",
|
|
||||||
timezone="UTC",
|
|
||||||
)
|
|
||||||
factories.UserRecordingAccessFactory(
|
|
||||||
recording=recording, role=models.RoleChoices.OWNER, user=owner
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_get_recording_timestamps.return_value = (None, None)
|
|
||||||
mock_generate_download_s3_url.return_value = "https://storage.test/recording.mp4"
|
|
||||||
|
|
||||||
mock_response = mock.Mock()
|
|
||||||
mock_response.raise_for_status.return_value = None
|
|
||||||
mock_response.json.return_value = {"job_id": "job-51"}
|
|
||||||
mock_post.return_value = mock_response
|
|
||||||
|
|
||||||
result = NotificationService._notify_summary_service(recording)
|
|
||||||
|
|
||||||
assert result is True
|
|
||||||
expected_payload = {
|
|
||||||
"user_sub": owner.sub,
|
|
||||||
"user_email": owner.email,
|
|
||||||
"cloud_storage_url": "https://storage.test/recording.mp4",
|
|
||||||
"language": "en",
|
|
||||||
"context_language": owner.language,
|
|
||||||
"push_to_docs_config": {
|
|
||||||
"user_email": owner.email,
|
|
||||||
"title": "Transcription",
|
|
||||||
"download_link": f"{settings.RECORDING_DOWNLOAD_BASE_URL}/{recording.id}",
|
|
||||||
"auto_create_summary": True,
|
|
||||||
"form_link": None,
|
|
||||||
},
|
|
||||||
"metadata": None,
|
|
||||||
}
|
|
||||||
expected_headers = {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
"Authorization": "Bearer summary-token",
|
|
||||||
}
|
|
||||||
mock_post.assert_called_once_with(
|
|
||||||
"https://summary.test/api/v2/tasks",
|
|
||||||
json=expected_payload,
|
|
||||||
headers=expected_headers,
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
mock_generate_download_s3_url.assert_called_once_with(
|
|
||||||
recording.key, expires_in=60 * 60 * 24, override_domain=False
|
|
||||||
)
|
|
||||||
mock_get_recording_timestamps.assert_awaited_once_with(recording.worker_id)
|
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
"""
|
|
||||||
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 == 401
|
|
||||||
|
|
||||||
|
|
||||||
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 == 401
|
|
||||||
|
|
||||||
|
|
||||||
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 response.json() == {"job_id": ["This field is required."]}
|
|
||||||
|
|
||||||
|
|
||||||
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
|
|
||||||
@@ -459,14 +459,12 @@ def generate_upload_policy(file):
|
|||||||
return policy
|
return policy
|
||||||
|
|
||||||
|
|
||||||
def generate_download_s3_url(
|
def generate_download_file_url(file, *, expires_in: int, override_domain: bool = True):
|
||||||
key: str, *, expires_in: int, override_domain: bool = True
|
|
||||||
):
|
|
||||||
"""
|
"""
|
||||||
Generate a S3 signed download url for a given key.
|
Generate a S3 signed download url for a given file.
|
||||||
"""
|
"""
|
||||||
if not key:
|
|
||||||
raise ValueError("key cannot be empty")
|
key = file.file_key
|
||||||
|
|
||||||
# This setting should be used if the backend application and the frontend application
|
# This setting 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
|
# can't connect to the object storage with the same domain. This is the case in the
|
||||||
|
|||||||
@@ -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-07-02 10:47+0000\n"
|
"POT-Creation-Date: 2026-04-21 14:09+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"
|
||||||
@@ -17,92 +17,70 @@ msgstr ""
|
|||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
|
||||||
#: core/admin.py:67
|
#: core/admin.py:29
|
||||||
msgid "Personal info"
|
msgid "Personal info"
|
||||||
msgstr "Persönliche Informationen"
|
msgstr "Persönliche Informationen"
|
||||||
|
|
||||||
#: core/admin.py:80
|
#: core/admin.py:42
|
||||||
msgid "Permissions"
|
msgid "Permissions"
|
||||||
msgstr "Berechtigungen"
|
msgstr "Berechtigungen"
|
||||||
|
|
||||||
#: core/admin.py:92
|
#: core/admin.py:54
|
||||||
msgid "Important dates"
|
msgid "Important dates"
|
||||||
msgstr "Wichtige Daten"
|
msgstr "Wichtige Daten"
|
||||||
|
|
||||||
#: core/admin.py:206
|
#: core/admin.py:132 core/admin.py:275
|
||||||
msgid "Content"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: core/admin.py:217
|
|
||||||
#, fuzzy
|
|
||||||
#| msgid "Delete rooms"
|
|
||||||
msgid "Deletion"
|
|
||||||
msgstr "Räume löschen"
|
|
||||||
|
|
||||||
#: core/admin.py:226
|
|
||||||
msgid "Derived info"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: core/admin.py:237
|
|
||||||
msgid "Timestamps"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: core/admin.py:240
|
|
||||||
msgid "File preview"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: core/admin.py:300 core/admin.py:443
|
|
||||||
msgid "No owner"
|
msgid "No owner"
|
||||||
msgstr "Kein Eigentümer"
|
msgstr "Kein Eigentümer"
|
||||||
|
|
||||||
#: core/admin.py:303 core/admin.py:446
|
#: core/admin.py:135 core/admin.py:278
|
||||||
msgid "Multiple owners"
|
msgid "Multiple owners"
|
||||||
msgstr "Mehrere Eigentümer"
|
msgstr "Mehrere Eigentümer"
|
||||||
|
|
||||||
#: core/admin.py:316
|
#: core/admin.py:148
|
||||||
msgid "Resend notification to external service"
|
msgid "Resend notification to external service"
|
||||||
msgstr "Benachrichtigung erneut an externen Dienst senden"
|
msgstr "Benachrichtigung erneut an externen Dienst senden"
|
||||||
|
|
||||||
#: core/admin.py:339
|
#: core/admin.py:171
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s"
|
msgid "Failed to notify for recording %(id)s"
|
||||||
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen"
|
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen"
|
||||||
|
|
||||||
#: core/admin.py:347
|
#: core/admin.py:179
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||||
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen: %(error)s"
|
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen: %(error)s"
|
||||||
|
|
||||||
#: core/admin.py:355
|
#: core/admin.py:187
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||||
msgstr "Benachrichtigungen für %(count)s Aufnahme(n) erfolgreich gesendet."
|
msgstr "Benachrichtigungen für %(count)s Aufnahme(n) erfolgreich gesendet."
|
||||||
|
|
||||||
#: core/admin.py:363
|
#: core/admin.py:195
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Skipped %(count)s expired recording(s)."
|
msgid "Skipped %(count)s expired recording(s)."
|
||||||
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
|
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
|
||||||
|
|
||||||
#: core/admin.py:368
|
#: core/admin.py:200
|
||||||
msgid "Mark selected recordings as 'Failed to Stop'"
|
msgid "Mark selected recordings as 'Failed to Stop'"
|
||||||
msgstr "Ausgewählte Aufnahmen als ‚Fehler beim Stoppen‘ markieren"
|
msgstr "Ausgewählte Aufnahmen als ‚Fehler beim Stoppen‘ markieren"
|
||||||
|
|
||||||
#: core/admin.py:386
|
#: core/admin.py:218
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
||||||
msgstr "%(count)s Aufnahme(n) erfolgreich als ‚Fehler beim Stoppen‘ markiert."
|
msgstr "%(count)s Aufnahme(n) erfolgreich als ‚Fehler beim Stoppen‘ markiert."
|
||||||
|
|
||||||
#: core/admin.py:394
|
#: core/admin.py:226
|
||||||
#, fuzzy, python-format
|
#, fuzzy, python-format
|
||||||
#| msgid "Skipped %(count)s expired recording(s)."
|
#| msgid "Skipped %(count)s expired recording(s)."
|
||||||
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
||||||
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
|
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
|
||||||
|
|
||||||
#: core/admin.py:510
|
#: core/admin.py:342
|
||||||
msgid "No scopes"
|
msgid "No scopes"
|
||||||
msgstr "Keine Scopes"
|
msgstr "Keine Scopes"
|
||||||
|
|
||||||
#: core/admin.py:512
|
#: core/admin.py:344
|
||||||
msgid "Scopes"
|
msgid "Scopes"
|
||||||
msgstr "Scopes"
|
msgstr "Scopes"
|
||||||
|
|
||||||
@@ -110,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:89
|
#: core/api/serializers.py:88
|
||||||
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:534
|
#: core/api/serializers.py:509
|
||||||
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:1222
|
#: core/api/viewsets.py:1090
|
||||||
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."
|
||||||
|
|
||||||
@@ -168,59 +146,51 @@ msgstr "Stopp fehlgeschlagen"
|
|||||||
msgid "Notification succeeded"
|
msgid "Notification succeeded"
|
||||||
msgstr "Benachrichtigung erfolgreich"
|
msgstr "Benachrichtigung erfolgreich"
|
||||||
|
|
||||||
#: core/models.py:65
|
#: core/models.py:89
|
||||||
msgid "External process successful"
|
|
||||||
msgstr "Externer Prozess erfolgreich"
|
|
||||||
|
|
||||||
#: core/models.py:67
|
|
||||||
msgid "External process failed"
|
|
||||||
msgstr "Externer Prozess fehlgeschlagen"
|
|
||||||
|
|
||||||
#: core/models.py:96
|
|
||||||
msgid "SCREEN_RECORDING"
|
msgid "SCREEN_RECORDING"
|
||||||
msgstr "BILDSCHIRMAUFZEICHNUNG"
|
msgstr "BILDSCHIRMAUFZEICHNUNG"
|
||||||
|
|
||||||
#: core/models.py:97
|
#: core/models.py:90
|
||||||
msgid "TRANSCRIPT"
|
msgid "TRANSCRIPT"
|
||||||
msgstr "TRANSKRIPT"
|
msgstr "TRANSKRIPT"
|
||||||
|
|
||||||
#: core/models.py:103
|
#: core/models.py:96
|
||||||
msgid "Public Access"
|
msgid "Public Access"
|
||||||
msgstr "Öffentlicher Zugriff"
|
msgstr "Öffentlicher Zugriff"
|
||||||
|
|
||||||
#: core/models.py:104
|
#: core/models.py:97
|
||||||
msgid "Trusted Access"
|
msgid "Trusted Access"
|
||||||
msgstr "Vertrauenswürdiger Zugriff"
|
msgstr "Vertrauenswürdiger Zugriff"
|
||||||
|
|
||||||
#: core/models.py:105
|
#: core/models.py:98
|
||||||
msgid "Restricted Access"
|
msgid "Restricted Access"
|
||||||
msgstr "Eingeschränkter Zugriff"
|
msgstr "Eingeschränkter Zugriff"
|
||||||
|
|
||||||
#: core/models.py:117
|
#: core/models.py:110
|
||||||
msgid "id"
|
msgid "id"
|
||||||
msgstr "ID"
|
msgstr "ID"
|
||||||
|
|
||||||
#: core/models.py:118
|
#: core/models.py:111
|
||||||
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:124
|
#: core/models.py:117
|
||||||
msgid "created on"
|
msgid "created on"
|
||||||
msgstr "erstellt am"
|
msgstr "erstellt am"
|
||||||
|
|
||||||
#: core/models.py:125
|
#: core/models.py:118
|
||||||
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:130
|
#: core/models.py:123
|
||||||
msgid "updated on"
|
msgid "updated on"
|
||||||
msgstr "aktualisiert am"
|
msgstr "aktualisiert am"
|
||||||
|
|
||||||
#: core/models.py:131
|
#: core/models.py:124
|
||||||
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:151
|
#: core/models.py:144
|
||||||
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."
|
||||||
@@ -228,11 +198,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:157
|
#: core/models.py:150
|
||||||
msgid "sub"
|
msgid "sub"
|
||||||
msgstr "Sub"
|
msgstr "Sub"
|
||||||
|
|
||||||
#: core/models.py:159
|
#: core/models.py:152
|
||||||
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."
|
||||||
@@ -240,55 +210,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:168
|
#: core/models.py:161
|
||||||
msgid "identity email address"
|
msgid "identity email address"
|
||||||
msgstr "Identitäts-E-Mail-Adresse"
|
msgstr "Identitäts-E-Mail-Adresse"
|
||||||
|
|
||||||
#: core/models.py:173
|
#: core/models.py:166
|
||||||
msgid "admin email address"
|
msgid "admin email address"
|
||||||
msgstr "Administrator-E-Mail-Adresse"
|
msgstr "Administrator-E-Mail-Adresse"
|
||||||
|
|
||||||
#: core/models.py:175
|
#: core/models.py:168
|
||||||
msgid "full name"
|
msgid "full name"
|
||||||
msgstr "Vollständiger Name"
|
msgstr "Vollständiger Name"
|
||||||
|
|
||||||
#: core/models.py:177
|
#: core/models.py:170
|
||||||
msgid "short name"
|
msgid "short name"
|
||||||
msgstr "Kurzname"
|
msgstr "Kurzname"
|
||||||
|
|
||||||
#: core/models.py:183
|
#: core/models.py:176
|
||||||
msgid "language"
|
msgid "language"
|
||||||
msgstr "Sprache"
|
msgstr "Sprache"
|
||||||
|
|
||||||
#: core/models.py:184
|
#: core/models.py:177
|
||||||
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:190
|
#: core/models.py:183
|
||||||
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:193
|
#: core/models.py:186
|
||||||
msgid "device"
|
msgid "device"
|
||||||
msgstr "Gerät"
|
msgstr "Gerät"
|
||||||
|
|
||||||
#: core/models.py:195
|
#: core/models.py:188
|
||||||
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:198
|
#: core/models.py:191
|
||||||
msgid "staff status"
|
msgid "staff status"
|
||||||
msgstr "Mitarbeiterstatus"
|
msgstr "Mitarbeiterstatus"
|
||||||
|
|
||||||
#: core/models.py:200
|
#: core/models.py:193
|
||||||
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:203
|
#: core/models.py:196
|
||||||
msgid "active"
|
msgid "active"
|
||||||
msgstr "aktiv"
|
msgstr "aktiv"
|
||||||
|
|
||||||
#: core/models.py:206
|
#: core/models.py:199
|
||||||
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."
|
||||||
@@ -296,66 +266,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:219
|
#: core/models.py:212
|
||||||
msgid "user"
|
msgid "user"
|
||||||
msgstr "Benutzer"
|
msgstr "Benutzer"
|
||||||
|
|
||||||
#: core/models.py:220
|
#: core/models.py:213
|
||||||
msgid "users"
|
msgid "users"
|
||||||
msgstr "Benutzer"
|
msgstr "Benutzer"
|
||||||
|
|
||||||
#: core/models.py:286
|
#: core/models.py:272
|
||||||
msgid "Resource"
|
msgid "Resource"
|
||||||
msgstr "Ressource"
|
msgstr "Ressource"
|
||||||
|
|
||||||
#: core/models.py:287
|
#: core/models.py:273
|
||||||
msgid "Resources"
|
msgid "Resources"
|
||||||
msgstr "Ressourcen"
|
msgstr "Ressourcen"
|
||||||
|
|
||||||
#: core/models.py:345
|
#: core/models.py:331
|
||||||
msgid "Resource access"
|
msgid "Resource access"
|
||||||
msgstr "Ressourcenzugriff"
|
msgstr "Ressourcenzugriff"
|
||||||
|
|
||||||
#: core/models.py:346
|
#: core/models.py:332
|
||||||
msgid "Resource accesses"
|
msgid "Resource accesses"
|
||||||
msgstr "Ressourcenzugriffe"
|
msgstr "Ressourcenzugriffe"
|
||||||
|
|
||||||
#: core/models.py:352
|
#: core/models.py:338
|
||||||
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:409
|
#: core/models.py:394
|
||||||
msgid "Visio room configuration"
|
msgid "Visio room configuration"
|
||||||
msgstr "Visio-Raumkonfiguration"
|
msgstr "Visio-Raumkonfiguration"
|
||||||
|
|
||||||
#: core/models.py:410
|
#: core/models.py:395
|
||||||
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:417
|
#: core/models.py:402
|
||||||
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:418
|
#: core/models.py:403
|
||||||
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:424 core/models.py:578
|
#: core/models.py:409 core/models.py:563
|
||||||
msgid "Room"
|
msgid "Room"
|
||||||
msgstr "Raum"
|
msgstr "Raum"
|
||||||
|
|
||||||
#: core/models.py:425
|
#: core/models.py:410
|
||||||
msgid "Rooms"
|
msgid "Rooms"
|
||||||
msgstr "Räume"
|
msgstr "Räume"
|
||||||
|
|
||||||
#: core/models.py:589
|
#: core/models.py:574
|
||||||
msgid "Worker ID"
|
msgid "Worker ID"
|
||||||
msgstr "Worker-ID"
|
msgstr "Worker-ID"
|
||||||
|
|
||||||
#: core/models.py:591
|
#: core/models.py:576
|
||||||
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."
|
||||||
@@ -364,153 +334,141 @@ 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:599
|
#: core/models.py:584
|
||||||
msgid "Recording mode"
|
msgid "Recording mode"
|
||||||
msgstr "Aufzeichnungsmodus"
|
msgstr "Aufzeichnungsmodus"
|
||||||
|
|
||||||
#: core/models.py:600
|
#: core/models.py:585
|
||||||
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:605 core/models.py:606
|
#: core/models.py:590 core/models.py:591
|
||||||
msgid "Recording options"
|
msgid "Recording options"
|
||||||
msgstr "Aufnahmeoptionen"
|
msgstr "Aufnahmeoptionen"
|
||||||
|
|
||||||
#: core/models.py:613
|
#: core/models.py:597
|
||||||
msgid "External Process ID"
|
|
||||||
msgstr "External Process ID"
|
|
||||||
|
|
||||||
#: core/models.py:614
|
|
||||||
msgid "ID of the external process associated with the recording."
|
|
||||||
msgstr "ID des externen Prozesses, der mit der Aufzeichnung verknüpft ist"
|
|
||||||
|
|
||||||
#: core/models.py:620
|
|
||||||
msgid "Recording"
|
msgid "Recording"
|
||||||
msgstr "Aufzeichnung"
|
msgstr "Aufzeichnung"
|
||||||
|
|
||||||
#: core/models.py:621
|
#: core/models.py:598
|
||||||
msgid "Recordings"
|
msgid "Recordings"
|
||||||
msgstr "Aufzeichnungen"
|
msgstr "Aufzeichnungen"
|
||||||
|
|
||||||
#: core/models.py:731
|
#: core/models.py:706
|
||||||
msgid "Recording/user relation"
|
msgid "Recording/user relation"
|
||||||
msgstr "Beziehung Aufzeichnung/Benutzer"
|
msgstr "Beziehung Aufzeichnung/Benutzer"
|
||||||
|
|
||||||
#: core/models.py:732
|
#: core/models.py:707
|
||||||
msgid "Recording/user relations"
|
msgid "Recording/user relations"
|
||||||
msgstr "Beziehungen Aufzeichnung/Benutzer"
|
msgstr "Beziehungen Aufzeichnung/Benutzer"
|
||||||
|
|
||||||
#: core/models.py:738
|
#: core/models.py:713
|
||||||
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:744
|
#: core/models.py:719
|
||||||
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:750
|
#: core/models.py:725
|
||||||
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:767
|
#: core/models.py:742
|
||||||
msgid "Create rooms"
|
msgid "Create rooms"
|
||||||
msgstr "Räume erstellen"
|
msgstr "Räume erstellen"
|
||||||
|
|
||||||
#: core/models.py:768
|
#: core/models.py:743
|
||||||
msgid "List rooms"
|
msgid "List rooms"
|
||||||
msgstr "Räume auflisten"
|
msgstr "Räume auflisten"
|
||||||
|
|
||||||
#: core/models.py:769
|
#: core/models.py:744
|
||||||
msgid "Retrieve room details"
|
msgid "Retrieve room details"
|
||||||
msgstr "Raumdetails abrufen"
|
msgstr "Raumdetails abrufen"
|
||||||
|
|
||||||
#: core/models.py:770
|
#: core/models.py:745
|
||||||
msgid "Update rooms"
|
msgid "Update rooms"
|
||||||
msgstr "Räume aktualisieren"
|
msgstr "Räume aktualisieren"
|
||||||
|
|
||||||
#: core/models.py:771
|
#: core/models.py:746
|
||||||
msgid "Delete rooms"
|
msgid "Delete rooms"
|
||||||
msgstr "Räume löschen"
|
msgstr "Räume löschen"
|
||||||
|
|
||||||
#: core/models.py:784
|
#: core/models.py:759
|
||||||
msgid "Application name"
|
msgid "Application name"
|
||||||
msgstr "Anwendungsname"
|
msgstr "Anwendungsname"
|
||||||
|
|
||||||
#: core/models.py:785
|
#: core/models.py:760
|
||||||
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:795
|
#: core/models.py:770
|
||||||
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:806
|
#: core/models.py:781
|
||||||
msgid "Application"
|
msgid "Application"
|
||||||
msgstr "Anwendung"
|
msgstr "Anwendung"
|
||||||
|
|
||||||
#: core/models.py:807
|
#: core/models.py:782
|
||||||
msgid "Applications"
|
msgid "Applications"
|
||||||
msgstr "Anwendungen"
|
msgstr "Anwendungen"
|
||||||
|
|
||||||
#: core/models.py:830
|
#: core/models.py:805
|
||||||
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:833
|
#: core/models.py:808
|
||||||
msgid "Domain"
|
msgid "Domain"
|
||||||
msgstr "Domain"
|
msgstr "Domain"
|
||||||
|
|
||||||
#: core/models.py:834
|
#: core/models.py:809
|
||||||
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:846
|
#: core/models.py:821
|
||||||
msgid "Application domain"
|
msgid "Application domain"
|
||||||
msgstr "Anwendungsdomain"
|
msgstr "Anwendungsdomain"
|
||||||
|
|
||||||
#: core/models.py:847
|
#: core/models.py:822
|
||||||
msgid "Application domains"
|
msgid "Application domains"
|
||||||
msgstr "Anwendungsdomains"
|
msgstr "Anwendungsdomains"
|
||||||
|
|
||||||
#: core/models.py:865
|
#: core/models.py:840
|
||||||
msgid "Pending"
|
msgid "Pending"
|
||||||
msgstr "Ausstehend"
|
msgstr "Ausstehend"
|
||||||
|
|
||||||
#: core/models.py:866
|
#: core/models.py:848
|
||||||
msgid "Analyzing"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: core/models.py:873
|
|
||||||
msgid "Ready"
|
msgid "Ready"
|
||||||
msgstr "Bereit"
|
msgstr "Bereit"
|
||||||
|
|
||||||
#: core/models.py:879
|
#: core/models.py:854
|
||||||
msgid "Background image"
|
msgid "Background image"
|
||||||
msgstr "Hintergrundbild"
|
msgstr "Hintergrundbild"
|
||||||
|
|
||||||
#: core/models.py:891
|
#: core/models.py:866
|
||||||
msgid "title"
|
msgid "title"
|
||||||
msgstr "Titel"
|
msgstr "Titel"
|
||||||
|
|
||||||
#: core/models.py:915
|
#: core/models.py:890
|
||||||
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:920
|
#: core/models.py:895
|
||||||
msgid "File"
|
msgid "File"
|
||||||
msgstr "Datei"
|
msgstr "Datei"
|
||||||
|
|
||||||
#: core/models.py:921
|
#: core/models.py:896
|
||||||
msgid "Files"
|
msgid "Files"
|
||||||
msgstr "Dateien"
|
msgstr "Dateien"
|
||||||
|
|
||||||
#: core/models.py:1041
|
#: core/models.py:1000
|
||||||
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:1051
|
#: core/models.py:1010
|
||||||
#, 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."
|
||||||
@@ -518,20 +476,10 @@ 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:123
|
#: core/recording/event/notification.py:116
|
||||||
msgid "Your recording is ready"
|
msgid "Your recording is ready"
|
||||||
msgstr "Ihre Aufzeichnung ist bereit"
|
msgstr "Ihre Aufzeichnung ist bereit"
|
||||||
|
|
||||||
#: core/recording/event/notification.py:194
|
|
||||||
msgid "Transcription"
|
|
||||||
msgstr "Transkription"
|
|
||||||
|
|
||||||
#: core/recording/event/notification.py:204
|
|
||||||
#, python-brace-format
|
|
||||||
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
|
||||||
msgstr ""
|
|
||||||
"Besprechung \"{room}\" am {room_recording_date} um {room_recording_time}"
|
|
||||||
|
|
||||||
#: core/services/invitation.py:44
|
#: core/services/invitation.py:44
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||||
@@ -627,7 +575,7 @@ msgid "To keep this recording permanently:"
|
|||||||
msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
|
msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:208
|
#: core/templates/mail/html/screen_recording.html:208
|
||||||
#, python-format
|
#: core/templates/mail/text/screen_recording.txt:13
|
||||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link 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 "
|
||||||
|
|
||||||
@@ -656,24 +604,18 @@ msgstr ""
|
|||||||
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
|
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
|
||||||
"an unser Support-Team unter %(support_email)s. "
|
"an unser Support-Team unter %(support_email)s. "
|
||||||
|
|
||||||
#: core/templates/mail/text/screen_recording.txt:13
|
#: meet/settings.py:223
|
||||||
#, fuzzy, python-format
|
|
||||||
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
|
||||||
msgid "Click the \"Open [%(link)s]\" link below "
|
|
||||||
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
|
|
||||||
|
|
||||||
#: meet/settings.py:228
|
|
||||||
msgid "English"
|
msgid "English"
|
||||||
msgstr "Englisch"
|
msgstr "Englisch"
|
||||||
|
|
||||||
#: meet/settings.py:229
|
#: meet/settings.py:224
|
||||||
msgid "French"
|
msgid "French"
|
||||||
msgstr "Französisch"
|
msgstr "Französisch"
|
||||||
|
|
||||||
#: meet/settings.py:230
|
#: meet/settings.py:225
|
||||||
msgid "Dutch"
|
msgid "Dutch"
|
||||||
msgstr "Niederländisch"
|
msgstr "Niederländisch"
|
||||||
|
|
||||||
#: meet/settings.py:231
|
#: meet/settings.py:226
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr "Deutsch"
|
msgstr "Deutsch"
|
||||||
|
|||||||
@@ -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-07-02 10:47+0000\n"
|
"POT-Creation-Date: 2026-04-21 14:09+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"
|
||||||
@@ -17,92 +17,70 @@ msgstr ""
|
|||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
|
||||||
#: core/admin.py:67
|
#: core/admin.py:29
|
||||||
msgid "Personal info"
|
msgid "Personal info"
|
||||||
msgstr "Personal info"
|
msgstr "Personal info"
|
||||||
|
|
||||||
#: core/admin.py:80
|
#: core/admin.py:42
|
||||||
msgid "Permissions"
|
msgid "Permissions"
|
||||||
msgstr "Permissions"
|
msgstr "Permissions"
|
||||||
|
|
||||||
#: core/admin.py:92
|
#: core/admin.py:54
|
||||||
msgid "Important dates"
|
msgid "Important dates"
|
||||||
msgstr "Important dates"
|
msgstr "Important dates"
|
||||||
|
|
||||||
#: core/admin.py:206
|
#: core/admin.py:132 core/admin.py:275
|
||||||
msgid "Content"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: core/admin.py:217
|
|
||||||
#, fuzzy
|
|
||||||
#| msgid "Delete rooms"
|
|
||||||
msgid "Deletion"
|
|
||||||
msgstr "Delete rooms"
|
|
||||||
|
|
||||||
#: core/admin.py:226
|
|
||||||
msgid "Derived info"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: core/admin.py:237
|
|
||||||
msgid "Timestamps"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: core/admin.py:240
|
|
||||||
msgid "File preview"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: core/admin.py:300 core/admin.py:443
|
|
||||||
msgid "No owner"
|
msgid "No owner"
|
||||||
msgstr "No owner"
|
msgstr "No owner"
|
||||||
|
|
||||||
#: core/admin.py:303 core/admin.py:446
|
#: core/admin.py:135 core/admin.py:278
|
||||||
msgid "Multiple owners"
|
msgid "Multiple owners"
|
||||||
msgstr "Multiple owners"
|
msgstr "Multiple owners"
|
||||||
|
|
||||||
#: core/admin.py:316
|
#: core/admin.py:148
|
||||||
msgid "Resend notification to external service"
|
msgid "Resend notification to external service"
|
||||||
msgstr "Resend notification to external service"
|
msgstr "Resend notification to external service"
|
||||||
|
|
||||||
#: core/admin.py:339
|
#: core/admin.py:171
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s"
|
msgid "Failed to notify for recording %(id)s"
|
||||||
msgstr "Failed to notify for recording %(id)s"
|
msgstr "Failed to notify for recording %(id)s"
|
||||||
|
|
||||||
#: core/admin.py:347
|
#: core/admin.py:179
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||||
msgstr "Failed to notify for recording %(id)s: %(error)s"
|
msgstr "Failed to notify for recording %(id)s: %(error)s"
|
||||||
|
|
||||||
#: core/admin.py:355
|
#: core/admin.py:187
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||||
msgstr "Successfully sent notifications for %(count)s recording(s)."
|
msgstr "Successfully sent notifications for %(count)s recording(s)."
|
||||||
|
|
||||||
#: core/admin.py:363
|
#: core/admin.py:195
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Skipped %(count)s expired recording(s)."
|
msgid "Skipped %(count)s expired recording(s)."
|
||||||
msgstr "Skipped %(count)s expired recording(s)."
|
msgstr "Skipped %(count)s expired recording(s)."
|
||||||
|
|
||||||
#: core/admin.py:368
|
#: core/admin.py:200
|
||||||
msgid "Mark selected recordings as 'Failed to Stop'"
|
msgid "Mark selected recordings as 'Failed to Stop'"
|
||||||
msgstr "Mark selected recordings as 'Failed to Stop'"
|
msgstr "Mark selected recordings as 'Failed to Stop'"
|
||||||
|
|
||||||
#: core/admin.py:386
|
#: core/admin.py:218
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
||||||
msgstr "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
msgstr "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
||||||
|
|
||||||
#: core/admin.py:394
|
#: core/admin.py:226
|
||||||
#, fuzzy, python-format
|
#, fuzzy, python-format
|
||||||
#| msgid "Skipped %(count)s expired recording(s)."
|
#| msgid "Skipped %(count)s expired recording(s)."
|
||||||
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
||||||
msgstr "Skipped %(count)s expired recording(s)."
|
msgstr "Skipped %(count)s expired recording(s)."
|
||||||
|
|
||||||
#: core/admin.py:510
|
#: core/admin.py:342
|
||||||
msgid "No scopes"
|
msgid "No scopes"
|
||||||
msgstr "No scopes"
|
msgstr "No scopes"
|
||||||
|
|
||||||
#: core/admin.py:512
|
#: core/admin.py:344
|
||||||
msgid "Scopes"
|
msgid "Scopes"
|
||||||
msgstr "Scopes"
|
msgstr "Scopes"
|
||||||
|
|
||||||
@@ -110,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:89
|
#: core/api/serializers.py:88
|
||||||
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:534
|
#: core/api/serializers.py:509
|
||||||
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:1222
|
#: core/api/viewsets.py:1090
|
||||||
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."
|
||||||
|
|
||||||
@@ -166,59 +144,51 @@ msgstr "Failed to Stop"
|
|||||||
msgid "Notification succeeded"
|
msgid "Notification succeeded"
|
||||||
msgstr "Notification succeeded"
|
msgstr "Notification succeeded"
|
||||||
|
|
||||||
#: core/models.py:65
|
#: core/models.py:89
|
||||||
msgid "External process successful"
|
|
||||||
msgstr "External process successful"
|
|
||||||
|
|
||||||
#: core/models.py:67
|
|
||||||
msgid "External process failed"
|
|
||||||
msgstr "External process failed"
|
|
||||||
|
|
||||||
#: core/models.py:96
|
|
||||||
msgid "SCREEN_RECORDING"
|
msgid "SCREEN_RECORDING"
|
||||||
msgstr "SCREEN_RECORDING"
|
msgstr "SCREEN_RECORDING"
|
||||||
|
|
||||||
#: core/models.py:97
|
#: core/models.py:90
|
||||||
msgid "TRANSCRIPT"
|
msgid "TRANSCRIPT"
|
||||||
msgstr "TRANSCRIPT"
|
msgstr "TRANSCRIPT"
|
||||||
|
|
||||||
#: core/models.py:103
|
#: core/models.py:96
|
||||||
msgid "Public Access"
|
msgid "Public Access"
|
||||||
msgstr "Public Access"
|
msgstr "Public Access"
|
||||||
|
|
||||||
#: core/models.py:104
|
#: core/models.py:97
|
||||||
msgid "Trusted Access"
|
msgid "Trusted Access"
|
||||||
msgstr "Trusted Access"
|
msgstr "Trusted Access"
|
||||||
|
|
||||||
#: core/models.py:105
|
#: core/models.py:98
|
||||||
msgid "Restricted Access"
|
msgid "Restricted Access"
|
||||||
msgstr "Restricted Access"
|
msgstr "Restricted Access"
|
||||||
|
|
||||||
#: core/models.py:117
|
#: core/models.py:110
|
||||||
msgid "id"
|
msgid "id"
|
||||||
msgstr "id"
|
msgstr "id"
|
||||||
|
|
||||||
#: core/models.py:118
|
#: core/models.py:111
|
||||||
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:124
|
#: core/models.py:117
|
||||||
msgid "created on"
|
msgid "created on"
|
||||||
msgstr "created on"
|
msgstr "created on"
|
||||||
|
|
||||||
#: core/models.py:125
|
#: core/models.py:118
|
||||||
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:130
|
#: core/models.py:123
|
||||||
msgid "updated on"
|
msgid "updated on"
|
||||||
msgstr "updated on"
|
msgstr "updated on"
|
||||||
|
|
||||||
#: core/models.py:131
|
#: core/models.py:124
|
||||||
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:151
|
#: core/models.py:144
|
||||||
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."
|
||||||
@@ -226,11 +196,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:157
|
#: core/models.py:150
|
||||||
msgid "sub"
|
msgid "sub"
|
||||||
msgstr "sub"
|
msgstr "sub"
|
||||||
|
|
||||||
#: core/models.py:159
|
#: core/models.py:152
|
||||||
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."
|
||||||
@@ -238,55 +208,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:168
|
#: core/models.py:161
|
||||||
msgid "identity email address"
|
msgid "identity email address"
|
||||||
msgstr "identity email address"
|
msgstr "identity email address"
|
||||||
|
|
||||||
#: core/models.py:173
|
#: core/models.py:166
|
||||||
msgid "admin email address"
|
msgid "admin email address"
|
||||||
msgstr "admin email address"
|
msgstr "admin email address"
|
||||||
|
|
||||||
#: core/models.py:175
|
#: core/models.py:168
|
||||||
msgid "full name"
|
msgid "full name"
|
||||||
msgstr "full name"
|
msgstr "full name"
|
||||||
|
|
||||||
#: core/models.py:177
|
#: core/models.py:170
|
||||||
msgid "short name"
|
msgid "short name"
|
||||||
msgstr "short name"
|
msgstr "short name"
|
||||||
|
|
||||||
#: core/models.py:183
|
#: core/models.py:176
|
||||||
msgid "language"
|
msgid "language"
|
||||||
msgstr "language"
|
msgstr "language"
|
||||||
|
|
||||||
#: core/models.py:184
|
#: core/models.py:177
|
||||||
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:190
|
#: core/models.py:183
|
||||||
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:193
|
#: core/models.py:186
|
||||||
msgid "device"
|
msgid "device"
|
||||||
msgstr "device"
|
msgstr "device"
|
||||||
|
|
||||||
#: core/models.py:195
|
#: core/models.py:188
|
||||||
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:198
|
#: core/models.py:191
|
||||||
msgid "staff status"
|
msgid "staff status"
|
||||||
msgstr "staff status"
|
msgstr "staff status"
|
||||||
|
|
||||||
#: core/models.py:200
|
#: core/models.py:193
|
||||||
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:203
|
#: core/models.py:196
|
||||||
msgid "active"
|
msgid "active"
|
||||||
msgstr "active"
|
msgstr "active"
|
||||||
|
|
||||||
#: core/models.py:206
|
#: core/models.py:199
|
||||||
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."
|
||||||
@@ -294,63 +264,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:219
|
#: core/models.py:212
|
||||||
msgid "user"
|
msgid "user"
|
||||||
msgstr "user"
|
msgstr "user"
|
||||||
|
|
||||||
#: core/models.py:220
|
#: core/models.py:213
|
||||||
msgid "users"
|
msgid "users"
|
||||||
msgstr "users"
|
msgstr "users"
|
||||||
|
|
||||||
#: core/models.py:286
|
#: core/models.py:272
|
||||||
msgid "Resource"
|
msgid "Resource"
|
||||||
msgstr "Resource"
|
msgstr "Resource"
|
||||||
|
|
||||||
#: core/models.py:287
|
#: core/models.py:273
|
||||||
msgid "Resources"
|
msgid "Resources"
|
||||||
msgstr "Resources"
|
msgstr "Resources"
|
||||||
|
|
||||||
#: core/models.py:345
|
#: core/models.py:331
|
||||||
msgid "Resource access"
|
msgid "Resource access"
|
||||||
msgstr "Resource access"
|
msgstr "Resource access"
|
||||||
|
|
||||||
#: core/models.py:346
|
#: core/models.py:332
|
||||||
msgid "Resource accesses"
|
msgid "Resource accesses"
|
||||||
msgstr "Resource accesses"
|
msgstr "Resource accesses"
|
||||||
|
|
||||||
#: core/models.py:352
|
#: core/models.py:338
|
||||||
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:409
|
#: core/models.py:394
|
||||||
msgid "Visio room configuration"
|
msgid "Visio room configuration"
|
||||||
msgstr "Visio room configuration"
|
msgstr "Visio room configuration"
|
||||||
|
|
||||||
#: core/models.py:410
|
#: core/models.py:395
|
||||||
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:417
|
#: core/models.py:402
|
||||||
msgid "Room PIN code"
|
msgid "Room PIN code"
|
||||||
msgstr "Room PIN code"
|
msgstr "Room PIN code"
|
||||||
|
|
||||||
#: core/models.py:418
|
#: core/models.py:403
|
||||||
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:424 core/models.py:578
|
#: core/models.py:409 core/models.py:563
|
||||||
msgid "Room"
|
msgid "Room"
|
||||||
msgstr "Room"
|
msgstr "Room"
|
||||||
|
|
||||||
#: core/models.py:425
|
#: core/models.py:410
|
||||||
msgid "Rooms"
|
msgid "Rooms"
|
||||||
msgstr "Rooms"
|
msgstr "Rooms"
|
||||||
|
|
||||||
#: core/models.py:589
|
#: core/models.py:574
|
||||||
msgid "Worker ID"
|
msgid "Worker ID"
|
||||||
msgstr "Worker ID"
|
msgstr "Worker ID"
|
||||||
|
|
||||||
#: core/models.py:591
|
#: core/models.py:576
|
||||||
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."
|
||||||
@@ -358,175 +328,154 @@ 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:599
|
#: core/models.py:584
|
||||||
msgid "Recording mode"
|
msgid "Recording mode"
|
||||||
msgstr "Recording mode"
|
msgstr "Recording mode"
|
||||||
|
|
||||||
#: core/models.py:600
|
#: core/models.py:585
|
||||||
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:605 core/models.py:606
|
#: core/models.py:590 core/models.py:591
|
||||||
msgid "Recording options"
|
msgid "Recording options"
|
||||||
msgstr "Recording options"
|
msgstr "Recording options"
|
||||||
|
|
||||||
#: core/models.py:613
|
#: core/models.py:597
|
||||||
msgid "External Process ID"
|
|
||||||
msgstr "External Process ID"
|
|
||||||
|
|
||||||
#: core/models.py:614
|
|
||||||
msgid "ID of the external process associated with the recording."
|
|
||||||
msgstr "ID of the external process associated with the recording."
|
|
||||||
|
|
||||||
#: core/models.py:620
|
|
||||||
msgid "Recording"
|
msgid "Recording"
|
||||||
msgstr "Recording"
|
msgstr "Recording"
|
||||||
|
|
||||||
#: core/models.py:621
|
#: core/models.py:598
|
||||||
msgid "Recordings"
|
msgid "Recordings"
|
||||||
msgstr "Recordings"
|
msgstr "Recordings"
|
||||||
|
|
||||||
#: core/models.py:731
|
#: core/models.py:706
|
||||||
msgid "Recording/user relation"
|
msgid "Recording/user relation"
|
||||||
msgstr "Recording/user relation"
|
msgstr "Recording/user relation"
|
||||||
|
|
||||||
#: core/models.py:732
|
#: core/models.py:707
|
||||||
msgid "Recording/user relations"
|
msgid "Recording/user relations"
|
||||||
msgstr "Recording/user relations"
|
msgstr "Recording/user relations"
|
||||||
|
|
||||||
#: core/models.py:738
|
#: core/models.py:713
|
||||||
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:744
|
#: core/models.py:719
|
||||||
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:750
|
#: core/models.py:725
|
||||||
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:767
|
#: core/models.py:742
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
#| msgid "created on"
|
#| msgid "created on"
|
||||||
msgid "Create rooms"
|
msgid "Create rooms"
|
||||||
msgstr "Create rooms"
|
msgstr "Create rooms"
|
||||||
|
|
||||||
#: core/models.py:768
|
#: core/models.py:743
|
||||||
msgid "List rooms"
|
msgid "List rooms"
|
||||||
msgstr "List rooms"
|
msgstr "List rooms"
|
||||||
|
|
||||||
#: core/models.py:769
|
#: core/models.py:744
|
||||||
msgid "Retrieve room details"
|
msgid "Retrieve room details"
|
||||||
msgstr "Retrieve room details"
|
msgstr "Retrieve room details"
|
||||||
|
|
||||||
#: core/models.py:770
|
#: core/models.py:745
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
#| msgid "updated on"
|
#| msgid "updated on"
|
||||||
msgid "Update rooms"
|
msgid "Update rooms"
|
||||||
msgstr "Update rooms"
|
msgstr "Update rooms"
|
||||||
|
|
||||||
#: core/models.py:771
|
#: core/models.py:746
|
||||||
msgid "Delete rooms"
|
msgid "Delete rooms"
|
||||||
msgstr "Delete rooms"
|
msgstr "Delete rooms"
|
||||||
|
|
||||||
#: core/models.py:784
|
#: core/models.py:759
|
||||||
msgid "Application name"
|
msgid "Application name"
|
||||||
msgstr "Application name"
|
msgstr "Application name"
|
||||||
|
|
||||||
#: core/models.py:785
|
#: core/models.py:760
|
||||||
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:795
|
#: core/models.py:770
|
||||||
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:806
|
#: core/models.py:781
|
||||||
msgid "Application"
|
msgid "Application"
|
||||||
msgstr "Application"
|
msgstr "Application"
|
||||||
|
|
||||||
#: core/models.py:807
|
#: core/models.py:782
|
||||||
msgid "Applications"
|
msgid "Applications"
|
||||||
msgstr "Applications"
|
msgstr "Applications"
|
||||||
|
|
||||||
#: core/models.py:830
|
#: core/models.py:805
|
||||||
msgid "Enter a valid domain"
|
msgid "Enter a valid domain"
|
||||||
msgstr "Enter a valid domain"
|
msgstr "Enter a valid domain"
|
||||||
|
|
||||||
#: core/models.py:833
|
#: core/models.py:808
|
||||||
msgid "Domain"
|
msgid "Domain"
|
||||||
msgstr "Domain"
|
msgstr "Domain"
|
||||||
|
|
||||||
#: core/models.py:834
|
#: core/models.py:809
|
||||||
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:846
|
#: core/models.py:821
|
||||||
msgid "Application domain"
|
msgid "Application domain"
|
||||||
msgstr "Application domain"
|
msgstr "Application domain"
|
||||||
|
|
||||||
#: core/models.py:847
|
#: core/models.py:822
|
||||||
msgid "Application domains"
|
msgid "Application domains"
|
||||||
msgstr "Application domains"
|
msgstr "Application domains"
|
||||||
|
|
||||||
#: core/models.py:865
|
#: core/models.py:840
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
#| msgid "Recording"
|
#| msgid "Recording"
|
||||||
msgid "Pending"
|
msgid "Pending"
|
||||||
msgstr "Pending"
|
msgstr "Pending"
|
||||||
|
|
||||||
#: core/models.py:866
|
#: core/models.py:848
|
||||||
msgid "Analyzing"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: core/models.py:873
|
|
||||||
msgid "Ready"
|
msgid "Ready"
|
||||||
msgstr "Ready"
|
msgstr "Ready"
|
||||||
|
|
||||||
#: core/models.py:879
|
#: core/models.py:854
|
||||||
msgid "Background image"
|
msgid "Background image"
|
||||||
msgstr "Background image"
|
msgstr "Background image"
|
||||||
|
|
||||||
#: core/models.py:891
|
#: core/models.py:866
|
||||||
msgid "title"
|
msgid "title"
|
||||||
msgstr "title"
|
msgstr "title"
|
||||||
|
|
||||||
#: core/models.py:915
|
#: core/models.py:890
|
||||||
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:920
|
#: core/models.py:895
|
||||||
msgid "File"
|
msgid "File"
|
||||||
msgstr "File"
|
msgstr "File"
|
||||||
|
|
||||||
#: core/models.py:921
|
#: core/models.py:896
|
||||||
msgid "Files"
|
msgid "Files"
|
||||||
msgstr "Files"
|
msgstr "Files"
|
||||||
|
|
||||||
#: core/models.py:1041
|
#: core/models.py:1000
|
||||||
#, 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:1051
|
#: core/models.py:1010
|
||||||
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:123
|
#: core/recording/event/notification.py:116
|
||||||
msgid "Your recording is ready"
|
msgid "Your recording is ready"
|
||||||
msgstr "Your recording is ready"
|
msgstr "Your recording is ready"
|
||||||
|
|
||||||
#: core/recording/event/notification.py:194
|
|
||||||
msgid "Transcription"
|
|
||||||
msgstr "Transcription"
|
|
||||||
|
|
||||||
#: core/recording/event/notification.py:204
|
|
||||||
#, python-brace-format
|
|
||||||
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
|
||||||
msgstr "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
|
||||||
|
|
||||||
#: core/services/invitation.py:44
|
#: core/services/invitation.py:44
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||||
@@ -622,7 +571,7 @@ msgid "To keep this recording permanently:"
|
|||||||
msgstr "To keep this recording permanently:"
|
msgstr "To keep this recording permanently:"
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:208
|
#: core/templates/mail/html/screen_recording.html:208
|
||||||
#, python-format
|
#: core/templates/mail/text/screen_recording.txt:13
|
||||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||||
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||||
|
|
||||||
@@ -651,24 +600,18 @@ msgstr ""
|
|||||||
" If you have any questions or need assistance, please contact our support "
|
" If you have any questions or need assistance, please contact our support "
|
||||||
"team at %(support_email)s. "
|
"team at %(support_email)s. "
|
||||||
|
|
||||||
#: core/templates/mail/text/screen_recording.txt:13
|
#: meet/settings.py:223
|
||||||
#, fuzzy, python-format
|
|
||||||
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
|
||||||
msgid "Click the \"Open [%(link)s]\" link below "
|
|
||||||
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
|
||||||
|
|
||||||
#: meet/settings.py:228
|
|
||||||
msgid "English"
|
msgid "English"
|
||||||
msgstr "English"
|
msgstr "English"
|
||||||
|
|
||||||
#: meet/settings.py:229
|
#: meet/settings.py:224
|
||||||
msgid "French"
|
msgid "French"
|
||||||
msgstr "French"
|
msgstr "French"
|
||||||
|
|
||||||
#: meet/settings.py:230
|
#: meet/settings.py:225
|
||||||
msgid "Dutch"
|
msgid "Dutch"
|
||||||
msgstr "Dutch"
|
msgstr "Dutch"
|
||||||
|
|
||||||
#: meet/settings.py:231
|
#: meet/settings.py:226
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr "German"
|
msgstr "German"
|
||||||
|
|||||||
@@ -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-07-02 10:47+0000\n"
|
"POT-Creation-Date: 2026-04-21 14:09+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"
|
||||||
@@ -17,93 +17,71 @@ msgstr ""
|
|||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
|
||||||
#: core/admin.py:67
|
#: core/admin.py:29
|
||||||
msgid "Personal info"
|
msgid "Personal info"
|
||||||
msgstr "Informations personnelles"
|
msgstr "Informations personnelles"
|
||||||
|
|
||||||
#: core/admin.py:80
|
#: core/admin.py:42
|
||||||
msgid "Permissions"
|
msgid "Permissions"
|
||||||
msgstr "Permissions"
|
msgstr "Permissions"
|
||||||
|
|
||||||
#: core/admin.py:92
|
#: core/admin.py:54
|
||||||
msgid "Important dates"
|
msgid "Important dates"
|
||||||
msgstr "Dates importantes"
|
msgstr "Dates importantes"
|
||||||
|
|
||||||
#: core/admin.py:206
|
#: core/admin.py:132 core/admin.py:275
|
||||||
msgid "Content"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: core/admin.py:217
|
|
||||||
#, fuzzy
|
|
||||||
#| msgid "Delete rooms"
|
|
||||||
msgid "Deletion"
|
|
||||||
msgstr "Supprimer les salles"
|
|
||||||
|
|
||||||
#: core/admin.py:226
|
|
||||||
msgid "Derived info"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: core/admin.py:237
|
|
||||||
msgid "Timestamps"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: core/admin.py:240
|
|
||||||
msgid "File preview"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: core/admin.py:300 core/admin.py:443
|
|
||||||
msgid "No owner"
|
msgid "No owner"
|
||||||
msgstr "Pas de propriétaire"
|
msgstr "Pas de propriétaire"
|
||||||
|
|
||||||
#: core/admin.py:303 core/admin.py:446
|
#: core/admin.py:135 core/admin.py:278
|
||||||
msgid "Multiple owners"
|
msgid "Multiple owners"
|
||||||
msgstr "Plusieurs propriétaires"
|
msgstr "Plusieurs propriétaires"
|
||||||
|
|
||||||
#: core/admin.py:316
|
#: core/admin.py:148
|
||||||
msgid "Resend notification to external service"
|
msgid "Resend notification to external service"
|
||||||
msgstr "Renvoyer la notification au service externe"
|
msgstr "Renvoyer la notification au service externe"
|
||||||
|
|
||||||
#: core/admin.py:339
|
#: core/admin.py:171
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s"
|
msgid "Failed to notify for recording %(id)s"
|
||||||
msgstr "Échec de la notification pour l’enregistrement %(id)s"
|
msgstr "Échec de la notification pour l’enregistrement %(id)s"
|
||||||
|
|
||||||
#: core/admin.py:347
|
#: core/admin.py:179
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||||
msgstr "Échec de la notification pour l’enregistrement %(id)s : %(error)s"
|
msgstr "Échec de la notification pour l’enregistrement %(id)s : %(error)s"
|
||||||
|
|
||||||
#: core/admin.py:355
|
#: core/admin.py:187
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||||
msgstr "Notifications envoyées avec succès pour %(count)s enregistrement(s)."
|
msgstr "Notifications envoyées avec succès pour %(count)s enregistrement(s)."
|
||||||
|
|
||||||
#: core/admin.py:363
|
#: core/admin.py:195
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Skipped %(count)s expired recording(s)."
|
msgid "Skipped %(count)s expired recording(s)."
|
||||||
msgstr "%(count)s enregistrement(s) expiré(s) ignoré(s)."
|
msgstr "%(count)s enregistrement(s) expiré(s) ignoré(s)."
|
||||||
|
|
||||||
#: core/admin.py:368
|
#: core/admin.py:200
|
||||||
msgid "Mark selected recordings as 'Failed to Stop'"
|
msgid "Mark selected recordings as 'Failed to Stop'"
|
||||||
msgstr "Marquer les enregistrements sélectionnés comme « Échec d’arrêt »"
|
msgstr "Marquer les enregistrements sélectionnés comme « Échec d’arrêt »"
|
||||||
|
|
||||||
#: core/admin.py:386
|
#: core/admin.py:218
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"%(count)s enregistrement(s) marqué(s) avec succès comme « Échec d’arrêt »."
|
"%(count)s enregistrement(s) marqué(s) avec succès comme « Échec d’arrêt »."
|
||||||
|
|
||||||
#: core/admin.py:394
|
#: core/admin.py:226
|
||||||
#, fuzzy, python-format
|
#, fuzzy, python-format
|
||||||
#| msgid "Skipped %(count)s expired recording(s)."
|
#| msgid "Skipped %(count)s expired recording(s)."
|
||||||
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
||||||
msgstr "%(count)s enregistrement(s) avec un statut inéligible ignoré(s)."
|
msgstr "%(count)s enregistrement(s) avec un statut inéligible ignoré(s)."
|
||||||
|
|
||||||
#: core/admin.py:510
|
#: core/admin.py:342
|
||||||
msgid "No scopes"
|
msgid "No scopes"
|
||||||
msgstr "Aucun scopes"
|
msgstr "Aucun scopes"
|
||||||
|
|
||||||
#: core/admin.py:512
|
#: core/admin.py:344
|
||||||
msgid "Scopes"
|
msgid "Scopes"
|
||||||
msgstr "Scopes"
|
msgstr "Scopes"
|
||||||
|
|
||||||
@@ -111,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:89
|
#: core/api/serializers.py:88
|
||||||
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:534
|
#: core/api/serializers.py:509
|
||||||
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:1222
|
#: core/api/viewsets.py:1090
|
||||||
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"
|
||||||
|
|
||||||
@@ -169,61 +147,53 @@ msgstr "Échec à l'arrêt"
|
|||||||
msgid "Notification succeeded"
|
msgid "Notification succeeded"
|
||||||
msgstr "Notification réussie"
|
msgstr "Notification réussie"
|
||||||
|
|
||||||
#: core/models.py:65
|
#: core/models.py:89
|
||||||
msgid "External process successful"
|
|
||||||
msgstr "Traitement externe : succès"
|
|
||||||
|
|
||||||
#: core/models.py:67
|
|
||||||
msgid "External process failed"
|
|
||||||
msgstr "Traitement externe : erreur"
|
|
||||||
|
|
||||||
#: core/models.py:96
|
|
||||||
msgid "SCREEN_RECORDING"
|
msgid "SCREEN_RECORDING"
|
||||||
msgstr "ENREGISTREMENT_ÉCRAN"
|
msgstr "ENREGISTREMENT_ÉCRAN"
|
||||||
|
|
||||||
#: core/models.py:97
|
#: core/models.py:90
|
||||||
msgid "TRANSCRIPT"
|
msgid "TRANSCRIPT"
|
||||||
msgstr "TRANSCRIPTION"
|
msgstr "TRANSCRIPTION"
|
||||||
|
|
||||||
#: core/models.py:103
|
#: core/models.py:96
|
||||||
msgid "Public Access"
|
msgid "Public Access"
|
||||||
msgstr "Accès public"
|
msgstr "Accès public"
|
||||||
|
|
||||||
#: core/models.py:104
|
#: core/models.py:97
|
||||||
msgid "Trusted Access"
|
msgid "Trusted Access"
|
||||||
msgstr "Accès de confiance"
|
msgstr "Accès de confiance"
|
||||||
|
|
||||||
#: core/models.py:105
|
#: core/models.py:98
|
||||||
msgid "Restricted Access"
|
msgid "Restricted Access"
|
||||||
msgstr "Accès restreint"
|
msgstr "Accès restreint"
|
||||||
|
|
||||||
#: core/models.py:117
|
#: core/models.py:110
|
||||||
msgid "id"
|
msgid "id"
|
||||||
msgstr "id"
|
msgstr "id"
|
||||||
|
|
||||||
#: core/models.py:118
|
#: core/models.py:111
|
||||||
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:124
|
#: core/models.py:117
|
||||||
msgid "created on"
|
msgid "created on"
|
||||||
msgstr "créé le"
|
msgstr "créé le"
|
||||||
|
|
||||||
#: core/models.py:125
|
#: core/models.py:118
|
||||||
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:130
|
#: core/models.py:123
|
||||||
msgid "updated on"
|
msgid "updated on"
|
||||||
msgstr "mis à jour le"
|
msgstr "mis à jour le"
|
||||||
|
|
||||||
#: core/models.py:131
|
#: core/models.py:124
|
||||||
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:151
|
#: core/models.py:144
|
||||||
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."
|
||||||
@@ -231,11 +201,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:157
|
#: core/models.py:150
|
||||||
msgid "sub"
|
msgid "sub"
|
||||||
msgstr "sub"
|
msgstr "sub"
|
||||||
|
|
||||||
#: core/models.py:159
|
#: core/models.py:152
|
||||||
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."
|
||||||
@@ -243,55 +213,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:168
|
#: core/models.py:161
|
||||||
msgid "identity email address"
|
msgid "identity email address"
|
||||||
msgstr "adresse e-mail d'identité"
|
msgstr "adresse e-mail d'identité"
|
||||||
|
|
||||||
#: core/models.py:173
|
#: core/models.py:166
|
||||||
msgid "admin email address"
|
msgid "admin email address"
|
||||||
msgstr "adresse e-mail d'administrateur"
|
msgstr "adresse e-mail d'administrateur"
|
||||||
|
|
||||||
#: core/models.py:175
|
#: core/models.py:168
|
||||||
msgid "full name"
|
msgid "full name"
|
||||||
msgstr "nom complet"
|
msgstr "nom complet"
|
||||||
|
|
||||||
#: core/models.py:177
|
#: core/models.py:170
|
||||||
msgid "short name"
|
msgid "short name"
|
||||||
msgstr "nom court"
|
msgstr "nom court"
|
||||||
|
|
||||||
#: core/models.py:183
|
#: core/models.py:176
|
||||||
msgid "language"
|
msgid "language"
|
||||||
msgstr "langue"
|
msgstr "langue"
|
||||||
|
|
||||||
#: core/models.py:184
|
#: core/models.py:177
|
||||||
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:190
|
#: core/models.py:183
|
||||||
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:193
|
#: core/models.py:186
|
||||||
msgid "device"
|
msgid "device"
|
||||||
msgstr "appareil"
|
msgstr "appareil"
|
||||||
|
|
||||||
#: core/models.py:195
|
#: core/models.py:188
|
||||||
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:198
|
#: core/models.py:191
|
||||||
msgid "staff status"
|
msgid "staff status"
|
||||||
msgstr "statut du personnel"
|
msgstr "statut du personnel"
|
||||||
|
|
||||||
#: core/models.py:200
|
#: core/models.py:193
|
||||||
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:203
|
#: core/models.py:196
|
||||||
msgid "active"
|
msgid "active"
|
||||||
msgstr "actif"
|
msgstr "actif"
|
||||||
|
|
||||||
#: core/models.py:206
|
#: core/models.py:199
|
||||||
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."
|
||||||
@@ -299,65 +269,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:219
|
#: core/models.py:212
|
||||||
msgid "user"
|
msgid "user"
|
||||||
msgstr "utilisateur"
|
msgstr "utilisateur"
|
||||||
|
|
||||||
#: core/models.py:220
|
#: core/models.py:213
|
||||||
msgid "users"
|
msgid "users"
|
||||||
msgstr "utilisateurs"
|
msgstr "utilisateurs"
|
||||||
|
|
||||||
#: core/models.py:286
|
#: core/models.py:272
|
||||||
msgid "Resource"
|
msgid "Resource"
|
||||||
msgstr "Ressource"
|
msgstr "Ressource"
|
||||||
|
|
||||||
#: core/models.py:287
|
#: core/models.py:273
|
||||||
msgid "Resources"
|
msgid "Resources"
|
||||||
msgstr "Ressources"
|
msgstr "Ressources"
|
||||||
|
|
||||||
#: core/models.py:345
|
#: core/models.py:331
|
||||||
msgid "Resource access"
|
msgid "Resource access"
|
||||||
msgstr "Accès aux ressources"
|
msgstr "Accès aux ressources"
|
||||||
|
|
||||||
#: core/models.py:346
|
#: core/models.py:332
|
||||||
msgid "Resource accesses"
|
msgid "Resource accesses"
|
||||||
msgstr "Accès aux ressources"
|
msgstr "Accès aux ressources"
|
||||||
|
|
||||||
#: core/models.py:352
|
#: core/models.py:338
|
||||||
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:409
|
#: core/models.py:394
|
||||||
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:410
|
#: core/models.py:395
|
||||||
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:417
|
#: core/models.py:402
|
||||||
msgid "Room PIN code"
|
msgid "Room PIN code"
|
||||||
msgstr "Code PIN de la salle"
|
msgstr "Code PIN de la salle"
|
||||||
|
|
||||||
#: core/models.py:418
|
#: core/models.py:403
|
||||||
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:424 core/models.py:578
|
#: core/models.py:409 core/models.py:563
|
||||||
msgid "Room"
|
msgid "Room"
|
||||||
msgstr "Salle"
|
msgstr "Salle"
|
||||||
|
|
||||||
#: core/models.py:425
|
#: core/models.py:410
|
||||||
msgid "Rooms"
|
msgid "Rooms"
|
||||||
msgstr "Salles"
|
msgstr "Salles"
|
||||||
|
|
||||||
#: core/models.py:589
|
#: core/models.py:574
|
||||||
msgid "Worker ID"
|
msgid "Worker ID"
|
||||||
msgstr "ID du Worker"
|
msgstr "ID du Worker"
|
||||||
|
|
||||||
#: core/models.py:591
|
#: core/models.py:576
|
||||||
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."
|
||||||
@@ -365,174 +335,153 @@ 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:599
|
#: core/models.py:584
|
||||||
msgid "Recording mode"
|
msgid "Recording mode"
|
||||||
msgstr "Mode d'enregistrement"
|
msgstr "Mode d'enregistrement"
|
||||||
|
|
||||||
#: core/models.py:600
|
#: core/models.py:585
|
||||||
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:605 core/models.py:606
|
#: core/models.py:590 core/models.py:591
|
||||||
msgid "Recording options"
|
msgid "Recording options"
|
||||||
msgstr "Options d'enregistrement"
|
msgstr "Options d'enregistrement"
|
||||||
|
|
||||||
#: core/models.py:613
|
#: core/models.py:597
|
||||||
msgid "External Process ID"
|
|
||||||
msgstr "ID Traitement externe"
|
|
||||||
|
|
||||||
#: core/models.py:614
|
|
||||||
msgid "ID of the external process associated with the recording."
|
|
||||||
msgstr "ID du traitement externe associé avec l'enregistrement."
|
|
||||||
|
|
||||||
#: core/models.py:620
|
|
||||||
msgid "Recording"
|
msgid "Recording"
|
||||||
msgstr "Enregistrement"
|
msgstr "Enregistrement"
|
||||||
|
|
||||||
#: core/models.py:621
|
#: core/models.py:598
|
||||||
msgid "Recordings"
|
msgid "Recordings"
|
||||||
msgstr "Enregistrements"
|
msgstr "Enregistrements"
|
||||||
|
|
||||||
#: core/models.py:731
|
#: core/models.py:706
|
||||||
msgid "Recording/user relation"
|
msgid "Recording/user relation"
|
||||||
msgstr "Relation enregistrement/utilisateur"
|
msgstr "Relation enregistrement/utilisateur"
|
||||||
|
|
||||||
#: core/models.py:732
|
#: core/models.py:707
|
||||||
msgid "Recording/user relations"
|
msgid "Recording/user relations"
|
||||||
msgstr "Relations enregistrement/utilisateur"
|
msgstr "Relations enregistrement/utilisateur"
|
||||||
|
|
||||||
#: core/models.py:738
|
#: core/models.py:713
|
||||||
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:744
|
#: core/models.py:719
|
||||||
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:750
|
#: core/models.py:725
|
||||||
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:767
|
#: core/models.py:742
|
||||||
msgid "Create rooms"
|
msgid "Create rooms"
|
||||||
msgstr "Créer des salles"
|
msgstr "Créer des salles"
|
||||||
|
|
||||||
#: core/models.py:768
|
#: core/models.py:743
|
||||||
msgid "List rooms"
|
msgid "List rooms"
|
||||||
msgstr "Lister les salles"
|
msgstr "Lister les salles"
|
||||||
|
|
||||||
#: core/models.py:769
|
#: core/models.py:744
|
||||||
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:770
|
#: core/models.py:745
|
||||||
msgid "Update rooms"
|
msgid "Update rooms"
|
||||||
msgstr "Mettre à jour les salles"
|
msgstr "Mettre à jour les salles"
|
||||||
|
|
||||||
#: core/models.py:771
|
#: core/models.py:746
|
||||||
msgid "Delete rooms"
|
msgid "Delete rooms"
|
||||||
msgstr "Supprimer les salles"
|
msgstr "Supprimer les salles"
|
||||||
|
|
||||||
#: core/models.py:784
|
#: core/models.py:759
|
||||||
msgid "Application name"
|
msgid "Application name"
|
||||||
msgstr "Nom de l’application"
|
msgstr "Nom de l’application"
|
||||||
|
|
||||||
#: core/models.py:785
|
#: core/models.py:760
|
||||||
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:795
|
#: core/models.py:770
|
||||||
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:806
|
#: core/models.py:781
|
||||||
msgid "Application"
|
msgid "Application"
|
||||||
msgstr "Application"
|
msgstr "Application"
|
||||||
|
|
||||||
#: core/models.py:807
|
#: core/models.py:782
|
||||||
msgid "Applications"
|
msgid "Applications"
|
||||||
msgstr "Applications"
|
msgstr "Applications"
|
||||||
|
|
||||||
#: core/models.py:830
|
#: core/models.py:805
|
||||||
msgid "Enter a valid domain"
|
msgid "Enter a valid domain"
|
||||||
msgstr "Saisissez un domaine valide"
|
msgstr "Saisissez un domaine valide"
|
||||||
|
|
||||||
#: core/models.py:833
|
#: core/models.py:808
|
||||||
msgid "Domain"
|
msgid "Domain"
|
||||||
msgstr "Domaine"
|
msgstr "Domaine"
|
||||||
|
|
||||||
#: core/models.py:834
|
#: core/models.py:809
|
||||||
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:846
|
#: core/models.py:821
|
||||||
msgid "Application domain"
|
msgid "Application domain"
|
||||||
msgstr "Domaine d’application"
|
msgstr "Domaine d’application"
|
||||||
|
|
||||||
#: core/models.py:847
|
#: core/models.py:822
|
||||||
msgid "Application domains"
|
msgid "Application domains"
|
||||||
msgstr "Domaines d’application"
|
msgstr "Domaines d’application"
|
||||||
|
|
||||||
#: core/models.py:865
|
#: core/models.py:840
|
||||||
msgid "Pending"
|
msgid "Pending"
|
||||||
msgstr "En attente"
|
msgstr "En attente"
|
||||||
|
|
||||||
#: core/models.py:866
|
#: core/models.py:848
|
||||||
msgid "Analyzing"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: core/models.py:873
|
|
||||||
msgid "Ready"
|
msgid "Ready"
|
||||||
msgstr "Prêt"
|
msgstr "Prêt"
|
||||||
|
|
||||||
#: core/models.py:879
|
#: core/models.py:854
|
||||||
msgid "Background image"
|
msgid "Background image"
|
||||||
msgstr "Image de fond"
|
msgstr "Image de fond"
|
||||||
|
|
||||||
#: core/models.py:891
|
#: core/models.py:866
|
||||||
msgid "title"
|
msgid "title"
|
||||||
msgstr "Titre"
|
msgstr "Titre"
|
||||||
|
|
||||||
#: core/models.py:915
|
#: core/models.py:890
|
||||||
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:920
|
#: core/models.py:895
|
||||||
msgid "File"
|
msgid "File"
|
||||||
msgstr "Fichier"
|
msgstr "Fichier"
|
||||||
|
|
||||||
#: core/models.py:921
|
#: core/models.py:896
|
||||||
msgid "Files"
|
msgid "Files"
|
||||||
msgstr "Fichiers"
|
msgstr "Fichiers"
|
||||||
|
|
||||||
#: core/models.py:1041
|
#: core/models.py:1000
|
||||||
#, 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:1051
|
#: core/models.py:1010
|
||||||
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:123
|
#: core/recording/event/notification.py:116
|
||||||
msgid "Your recording is ready"
|
msgid "Your recording is ready"
|
||||||
msgstr "Votre enregistrement est prêt"
|
msgstr "Votre enregistrement est prêt"
|
||||||
|
|
||||||
#: core/recording/event/notification.py:194
|
|
||||||
msgid "Transcription"
|
|
||||||
msgstr "Transcription"
|
|
||||||
|
|
||||||
#: core/recording/event/notification.py:204
|
|
||||||
#, python-brace-format
|
|
||||||
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
|
||||||
msgstr "Réunion \"{room}\" du {room_recording_date} à {room_recording_time}"
|
|
||||||
|
|
||||||
#: core/services/invitation.py:44
|
#: core/services/invitation.py:44
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||||
@@ -628,7 +577,7 @@ msgid "To keep this recording permanently:"
|
|||||||
msgstr "Pour conserver cet enregistrement de façon permanente :"
|
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
|
||||||
#, python-format
|
#: core/templates/mail/text/screen_recording.txt:13
|
||||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link 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 "
|
||||||
|
|
||||||
@@ -657,24 +606,18 @@ msgstr ""
|
|||||||
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
|
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
|
||||||
"équipe d'assistance à %(support_email)s. "
|
"équipe d'assistance à %(support_email)s. "
|
||||||
|
|
||||||
#: core/templates/mail/text/screen_recording.txt:13
|
#: meet/settings.py:223
|
||||||
#, fuzzy, python-format
|
|
||||||
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
|
||||||
msgid "Click the \"Open [%(link)s]\" link below "
|
|
||||||
msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" ci-dessous "
|
|
||||||
|
|
||||||
#: meet/settings.py:228
|
|
||||||
msgid "English"
|
msgid "English"
|
||||||
msgstr "Anglais"
|
msgstr "Anglais"
|
||||||
|
|
||||||
#: meet/settings.py:229
|
#: meet/settings.py:224
|
||||||
msgid "French"
|
msgid "French"
|
||||||
msgstr "Français"
|
msgstr "Français"
|
||||||
|
|
||||||
#: meet/settings.py:230
|
#: meet/settings.py:225
|
||||||
msgid "Dutch"
|
msgid "Dutch"
|
||||||
msgstr "Néerlandais"
|
msgstr "Néerlandais"
|
||||||
|
|
||||||
#: meet/settings.py:231
|
#: meet/settings.py:226
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr "Allemand"
|
msgstr "Allemand"
|
||||||
|
|||||||
@@ -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-07-02 10:47+0000\n"
|
"POT-Creation-Date: 2026-04-21 14:09+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"
|
||||||
@@ -17,92 +17,70 @@ msgstr ""
|
|||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
|
||||||
#: core/admin.py:67
|
#: core/admin.py:29
|
||||||
msgid "Personal info"
|
msgid "Personal info"
|
||||||
msgstr "Persoonlijke informatie"
|
msgstr "Persoonlijke informatie"
|
||||||
|
|
||||||
#: core/admin.py:80
|
#: core/admin.py:42
|
||||||
msgid "Permissions"
|
msgid "Permissions"
|
||||||
msgstr "Rechten"
|
msgstr "Rechten"
|
||||||
|
|
||||||
#: core/admin.py:92
|
#: core/admin.py:54
|
||||||
msgid "Important dates"
|
msgid "Important dates"
|
||||||
msgstr "Belangrijke datums"
|
msgstr "Belangrijke datums"
|
||||||
|
|
||||||
#: core/admin.py:206
|
#: core/admin.py:132 core/admin.py:275
|
||||||
msgid "Content"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: core/admin.py:217
|
|
||||||
#, fuzzy
|
|
||||||
#| msgid "Delete rooms"
|
|
||||||
msgid "Deletion"
|
|
||||||
msgstr "Ruimtes verwijderen"
|
|
||||||
|
|
||||||
#: core/admin.py:226
|
|
||||||
msgid "Derived info"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: core/admin.py:237
|
|
||||||
msgid "Timestamps"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: core/admin.py:240
|
|
||||||
msgid "File preview"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: core/admin.py:300 core/admin.py:443
|
|
||||||
msgid "No owner"
|
msgid "No owner"
|
||||||
msgstr "Geen eigenaar"
|
msgstr "Geen eigenaar"
|
||||||
|
|
||||||
#: core/admin.py:303 core/admin.py:446
|
#: core/admin.py:135 core/admin.py:278
|
||||||
msgid "Multiple owners"
|
msgid "Multiple owners"
|
||||||
msgstr "Meerdere eigenaren"
|
msgstr "Meerdere eigenaren"
|
||||||
|
|
||||||
#: core/admin.py:316
|
#: core/admin.py:148
|
||||||
msgid "Resend notification to external service"
|
msgid "Resend notification to external service"
|
||||||
msgstr "Melding opnieuw verzenden naar externe dienst"
|
msgstr "Melding opnieuw verzenden naar externe dienst"
|
||||||
|
|
||||||
#: core/admin.py:339
|
#: core/admin.py:171
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s"
|
msgid "Failed to notify for recording %(id)s"
|
||||||
msgstr "Melding voor opname %(id)s mislukt"
|
msgstr "Melding voor opname %(id)s mislukt"
|
||||||
|
|
||||||
#: core/admin.py:347
|
#: core/admin.py:179
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||||
msgstr "Melding voor opname %(id)s mislukt: %(error)s"
|
msgstr "Melding voor opname %(id)s mislukt: %(error)s"
|
||||||
|
|
||||||
#: core/admin.py:355
|
#: core/admin.py:187
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||||
msgstr "Meldingen succesvol verzonden voor %(count)s opname(n)."
|
msgstr "Meldingen succesvol verzonden voor %(count)s opname(n)."
|
||||||
|
|
||||||
#: core/admin.py:363
|
#: core/admin.py:195
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Skipped %(count)s expired recording(s)."
|
msgid "Skipped %(count)s expired recording(s)."
|
||||||
msgstr "%(count)s verlopen opname(n) overgeslagen."
|
msgstr "%(count)s verlopen opname(n) overgeslagen."
|
||||||
|
|
||||||
#: core/admin.py:368
|
#: core/admin.py:200
|
||||||
msgid "Mark selected recordings as 'Failed to Stop'"
|
msgid "Mark selected recordings as 'Failed to Stop'"
|
||||||
msgstr "Geselecteerde opnames markeren als 'Mislukt bij stoppen'"
|
msgstr "Geselecteerde opnames markeren als 'Mislukt bij stoppen'"
|
||||||
|
|
||||||
#: core/admin.py:386
|
#: core/admin.py:218
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
||||||
msgstr "%(count)s opname(s) succesvol gemarkeerd als 'Mislukt bij stoppen'."
|
msgstr "%(count)s opname(s) succesvol gemarkeerd als 'Mislukt bij stoppen'."
|
||||||
|
|
||||||
#: core/admin.py:394
|
#: core/admin.py:226
|
||||||
#, fuzzy, python-format
|
#, fuzzy, python-format
|
||||||
#| msgid "Skipped %(count)s expired recording(s)."
|
#| msgid "Skipped %(count)s expired recording(s)."
|
||||||
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
||||||
msgstr "%(count)s opname(s) met een niet-toegestane status overgeslagen."
|
msgstr "%(count)s opname(s) met een niet-toegestane status overgeslagen."
|
||||||
|
|
||||||
#: core/admin.py:510
|
#: core/admin.py:342
|
||||||
msgid "No scopes"
|
msgid "No scopes"
|
||||||
msgstr "Geen scopes"
|
msgstr "Geen scopes"
|
||||||
|
|
||||||
#: core/admin.py:512
|
#: core/admin.py:344
|
||||||
msgid "Scopes"
|
msgid "Scopes"
|
||||||
msgstr "Scopes"
|
msgstr "Scopes"
|
||||||
|
|
||||||
@@ -110,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:89
|
#: core/api/serializers.py:88
|
||||||
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:534
|
#: core/api/serializers.py:509
|
||||||
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:1222
|
#: core/api/viewsets.py:1090
|
||||||
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."
|
||||||
|
|
||||||
@@ -167,59 +145,51 @@ msgstr "Stoppen mislukt"
|
|||||||
msgid "Notification succeeded"
|
msgid "Notification succeeded"
|
||||||
msgstr "Notificatie geslaagd"
|
msgstr "Notificatie geslaagd"
|
||||||
|
|
||||||
#: core/models.py:65
|
#: core/models.py:89
|
||||||
msgid "External process successful"
|
|
||||||
msgstr "Externe procedure succesvol"
|
|
||||||
|
|
||||||
#: core/models.py:67
|
|
||||||
msgid "External process failed"
|
|
||||||
msgstr "Het externe proces is mislukt."
|
|
||||||
|
|
||||||
#: core/models.py:96
|
|
||||||
msgid "SCREEN_RECORDING"
|
msgid "SCREEN_RECORDING"
|
||||||
msgstr "SCHERM_OPNAME"
|
msgstr "SCHERM_OPNAME"
|
||||||
|
|
||||||
#: core/models.py:97
|
#: core/models.py:90
|
||||||
msgid "TRANSCRIPT"
|
msgid "TRANSCRIPT"
|
||||||
msgstr "TRANSCRIPT"
|
msgstr "TRANSCRIPT"
|
||||||
|
|
||||||
#: core/models.py:103
|
#: core/models.py:96
|
||||||
msgid "Public Access"
|
msgid "Public Access"
|
||||||
msgstr "Openbare toegang"
|
msgstr "Openbare toegang"
|
||||||
|
|
||||||
#: core/models.py:104
|
#: core/models.py:97
|
||||||
msgid "Trusted Access"
|
msgid "Trusted Access"
|
||||||
msgstr "Vertrouwde toegang"
|
msgstr "Vertrouwde toegang"
|
||||||
|
|
||||||
#: core/models.py:105
|
#: core/models.py:98
|
||||||
msgid "Restricted Access"
|
msgid "Restricted Access"
|
||||||
msgstr "Beperkte toegang"
|
msgstr "Beperkte toegang"
|
||||||
|
|
||||||
#: core/models.py:117
|
#: core/models.py:110
|
||||||
msgid "id"
|
msgid "id"
|
||||||
msgstr "id"
|
msgstr "id"
|
||||||
|
|
||||||
#: core/models.py:118
|
#: core/models.py:111
|
||||||
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:124
|
#: core/models.py:117
|
||||||
msgid "created on"
|
msgid "created on"
|
||||||
msgstr "aangemaakt op"
|
msgstr "aangemaakt op"
|
||||||
|
|
||||||
#: core/models.py:125
|
#: core/models.py:118
|
||||||
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:130
|
#: core/models.py:123
|
||||||
msgid "updated on"
|
msgid "updated on"
|
||||||
msgstr "bijgewerkt op"
|
msgstr "bijgewerkt op"
|
||||||
|
|
||||||
#: core/models.py:131
|
#: core/models.py:124
|
||||||
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:151
|
#: core/models.py:144
|
||||||
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."
|
||||||
@@ -227,11 +197,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:157
|
#: core/models.py:150
|
||||||
msgid "sub"
|
msgid "sub"
|
||||||
msgstr "sub"
|
msgstr "sub"
|
||||||
|
|
||||||
#: core/models.py:159
|
#: core/models.py:152
|
||||||
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."
|
||||||
@@ -239,55 +209,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:168
|
#: core/models.py:161
|
||||||
msgid "identity email address"
|
msgid "identity email address"
|
||||||
msgstr "identiteit e-mailadres"
|
msgstr "identiteit e-mailadres"
|
||||||
|
|
||||||
#: core/models.py:173
|
#: core/models.py:166
|
||||||
msgid "admin email address"
|
msgid "admin email address"
|
||||||
msgstr "beheerder e-mailadres"
|
msgstr "beheerder e-mailadres"
|
||||||
|
|
||||||
#: core/models.py:175
|
#: core/models.py:168
|
||||||
msgid "full name"
|
msgid "full name"
|
||||||
msgstr "volledige naam"
|
msgstr "volledige naam"
|
||||||
|
|
||||||
#: core/models.py:177
|
#: core/models.py:170
|
||||||
msgid "short name"
|
msgid "short name"
|
||||||
msgstr "korte naam"
|
msgstr "korte naam"
|
||||||
|
|
||||||
#: core/models.py:183
|
#: core/models.py:176
|
||||||
msgid "language"
|
msgid "language"
|
||||||
msgstr "taal"
|
msgstr "taal"
|
||||||
|
|
||||||
#: core/models.py:184
|
#: core/models.py:177
|
||||||
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:190
|
#: core/models.py:183
|
||||||
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:193
|
#: core/models.py:186
|
||||||
msgid "device"
|
msgid "device"
|
||||||
msgstr "apparaat"
|
msgstr "apparaat"
|
||||||
|
|
||||||
#: core/models.py:195
|
#: core/models.py:188
|
||||||
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:198
|
#: core/models.py:191
|
||||||
msgid "staff status"
|
msgid "staff status"
|
||||||
msgstr "personeelsstatus"
|
msgstr "personeelsstatus"
|
||||||
|
|
||||||
#: core/models.py:200
|
#: core/models.py:193
|
||||||
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:203
|
#: core/models.py:196
|
||||||
msgid "active"
|
msgid "active"
|
||||||
msgstr "actief"
|
msgstr "actief"
|
||||||
|
|
||||||
#: core/models.py:206
|
#: core/models.py:199
|
||||||
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."
|
||||||
@@ -295,64 +265,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:219
|
#: core/models.py:212
|
||||||
msgid "user"
|
msgid "user"
|
||||||
msgstr "gebruiker"
|
msgstr "gebruiker"
|
||||||
|
|
||||||
#: core/models.py:220
|
#: core/models.py:213
|
||||||
msgid "users"
|
msgid "users"
|
||||||
msgstr "gebruikers"
|
msgstr "gebruikers"
|
||||||
|
|
||||||
#: core/models.py:286
|
#: core/models.py:272
|
||||||
msgid "Resource"
|
msgid "Resource"
|
||||||
msgstr "Bron"
|
msgstr "Bron"
|
||||||
|
|
||||||
#: core/models.py:287
|
#: core/models.py:273
|
||||||
msgid "Resources"
|
msgid "Resources"
|
||||||
msgstr "Bronnen"
|
msgstr "Bronnen"
|
||||||
|
|
||||||
#: core/models.py:345
|
#: core/models.py:331
|
||||||
msgid "Resource access"
|
msgid "Resource access"
|
||||||
msgstr "Brontoegang"
|
msgstr "Brontoegang"
|
||||||
|
|
||||||
#: core/models.py:346
|
#: core/models.py:332
|
||||||
msgid "Resource accesses"
|
msgid "Resource accesses"
|
||||||
msgstr "Brontoegangsrechten"
|
msgstr "Brontoegangsrechten"
|
||||||
|
|
||||||
#: core/models.py:352
|
#: core/models.py:338
|
||||||
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:409
|
#: core/models.py:394
|
||||||
msgid "Visio room configuration"
|
msgid "Visio room configuration"
|
||||||
msgstr "Visio-ruimteconfiguratie"
|
msgstr "Visio-ruimteconfiguratie"
|
||||||
|
|
||||||
#: core/models.py:410
|
#: core/models.py:395
|
||||||
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:417
|
#: core/models.py:402
|
||||||
msgid "Room PIN code"
|
msgid "Room PIN code"
|
||||||
msgstr "Pincode van de kamer"
|
msgstr "Pincode van de kamer"
|
||||||
|
|
||||||
#: core/models.py:418
|
#: core/models.py:403
|
||||||
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:424 core/models.py:578
|
#: core/models.py:409 core/models.py:563
|
||||||
msgid "Room"
|
msgid "Room"
|
||||||
msgstr "Ruimte"
|
msgstr "Ruimte"
|
||||||
|
|
||||||
#: core/models.py:425
|
#: core/models.py:410
|
||||||
msgid "Rooms"
|
msgid "Rooms"
|
||||||
msgstr "Ruimtes"
|
msgstr "Ruimtes"
|
||||||
|
|
||||||
#: core/models.py:589
|
#: core/models.py:574
|
||||||
msgid "Worker ID"
|
msgid "Worker ID"
|
||||||
msgstr "Worker ID"
|
msgstr "Worker ID"
|
||||||
|
|
||||||
#: core/models.py:591
|
#: core/models.py:576
|
||||||
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."
|
||||||
@@ -360,152 +330,140 @@ 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:599
|
#: core/models.py:584
|
||||||
msgid "Recording mode"
|
msgid "Recording mode"
|
||||||
msgstr "Opnamemodus"
|
msgstr "Opnamemodus"
|
||||||
|
|
||||||
#: core/models.py:600
|
#: core/models.py:585
|
||||||
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:605 core/models.py:606
|
#: core/models.py:590 core/models.py:591
|
||||||
msgid "Recording options"
|
msgid "Recording options"
|
||||||
msgstr "Opnameopties"
|
msgstr "Opnameopties"
|
||||||
|
|
||||||
#: core/models.py:613
|
#: core/models.py:597
|
||||||
msgid "External Process ID"
|
|
||||||
msgstr "Externe proces-ID"
|
|
||||||
|
|
||||||
#: core/models.py:614
|
|
||||||
msgid "ID of the external process associated with the recording."
|
|
||||||
msgstr "ID van het externe proces dat aan de opname is gekoppeld."
|
|
||||||
|
|
||||||
#: core/models.py:620
|
|
||||||
msgid "Recording"
|
msgid "Recording"
|
||||||
msgstr "Opname"
|
msgstr "Opname"
|
||||||
|
|
||||||
#: core/models.py:621
|
#: core/models.py:598
|
||||||
msgid "Recordings"
|
msgid "Recordings"
|
||||||
msgstr "Opnames"
|
msgstr "Opnames"
|
||||||
|
|
||||||
#: core/models.py:731
|
#: core/models.py:706
|
||||||
msgid "Recording/user relation"
|
msgid "Recording/user relation"
|
||||||
msgstr "Opname/gebruiker-relatie"
|
msgstr "Opname/gebruiker-relatie"
|
||||||
|
|
||||||
#: core/models.py:732
|
#: core/models.py:707
|
||||||
msgid "Recording/user relations"
|
msgid "Recording/user relations"
|
||||||
msgstr "Opname/gebruiker-relaties"
|
msgstr "Opname/gebruiker-relaties"
|
||||||
|
|
||||||
#: core/models.py:738
|
#: core/models.py:713
|
||||||
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:744
|
#: core/models.py:719
|
||||||
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:750
|
#: core/models.py:725
|
||||||
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:767
|
#: core/models.py:742
|
||||||
msgid "Create rooms"
|
msgid "Create rooms"
|
||||||
msgstr "Ruimtes aanmaken"
|
msgstr "Ruimtes aanmaken"
|
||||||
|
|
||||||
#: core/models.py:768
|
#: core/models.py:743
|
||||||
msgid "List rooms"
|
msgid "List rooms"
|
||||||
msgstr "Ruimtes weergeven"
|
msgstr "Ruimtes weergeven"
|
||||||
|
|
||||||
#: core/models.py:769
|
#: core/models.py:744
|
||||||
msgid "Retrieve room details"
|
msgid "Retrieve room details"
|
||||||
msgstr "Details van een ruimte ophalen"
|
msgstr "Details van een ruimte ophalen"
|
||||||
|
|
||||||
#: core/models.py:770
|
#: core/models.py:745
|
||||||
msgid "Update rooms"
|
msgid "Update rooms"
|
||||||
msgstr "Ruimtes bijwerken"
|
msgstr "Ruimtes bijwerken"
|
||||||
|
|
||||||
#: core/models.py:771
|
#: core/models.py:746
|
||||||
msgid "Delete rooms"
|
msgid "Delete rooms"
|
||||||
msgstr "Ruimtes verwijderen"
|
msgstr "Ruimtes verwijderen"
|
||||||
|
|
||||||
#: core/models.py:784
|
#: core/models.py:759
|
||||||
msgid "Application name"
|
msgid "Application name"
|
||||||
msgstr "Naam van de applicatie"
|
msgstr "Naam van de applicatie"
|
||||||
|
|
||||||
#: core/models.py:785
|
#: core/models.py:760
|
||||||
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:795
|
#: core/models.py:770
|
||||||
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:806
|
#: core/models.py:781
|
||||||
msgid "Application"
|
msgid "Application"
|
||||||
msgstr "Applicatie"
|
msgstr "Applicatie"
|
||||||
|
|
||||||
#: core/models.py:807
|
#: core/models.py:782
|
||||||
msgid "Applications"
|
msgid "Applications"
|
||||||
msgstr "Applicaties"
|
msgstr "Applicaties"
|
||||||
|
|
||||||
#: core/models.py:830
|
#: core/models.py:805
|
||||||
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:833
|
#: core/models.py:808
|
||||||
msgid "Domain"
|
msgid "Domain"
|
||||||
msgstr "Domein"
|
msgstr "Domein"
|
||||||
|
|
||||||
#: core/models.py:834
|
#: core/models.py:809
|
||||||
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:846
|
#: core/models.py:821
|
||||||
msgid "Application domain"
|
msgid "Application domain"
|
||||||
msgstr "Applicatiedomein"
|
msgstr "Applicatiedomein"
|
||||||
|
|
||||||
#: core/models.py:847
|
#: core/models.py:822
|
||||||
msgid "Application domains"
|
msgid "Application domains"
|
||||||
msgstr "Applicatiedomeinen"
|
msgstr "Applicatiedomeinen"
|
||||||
|
|
||||||
#: core/models.py:865
|
#: core/models.py:840
|
||||||
msgid "Pending"
|
msgid "Pending"
|
||||||
msgstr "In afwachting"
|
msgstr "In afwachting"
|
||||||
|
|
||||||
#: core/models.py:866
|
#: core/models.py:848
|
||||||
msgid "Analyzing"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: core/models.py:873
|
|
||||||
msgid "Ready"
|
msgid "Ready"
|
||||||
msgstr "Klaar"
|
msgstr "Klaar"
|
||||||
|
|
||||||
#: core/models.py:879
|
#: core/models.py:854
|
||||||
msgid "Background image"
|
msgid "Background image"
|
||||||
msgstr "Achtergrondafbeelding"
|
msgstr "Achtergrondafbeelding"
|
||||||
|
|
||||||
#: core/models.py:891
|
#: core/models.py:866
|
||||||
msgid "title"
|
msgid "title"
|
||||||
msgstr "Titel"
|
msgstr "Titel"
|
||||||
|
|
||||||
#: core/models.py:915
|
#: core/models.py:890
|
||||||
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:920
|
#: core/models.py:895
|
||||||
msgid "File"
|
msgid "File"
|
||||||
msgstr "Bestand"
|
msgstr "Bestand"
|
||||||
|
|
||||||
#: core/models.py:921
|
#: core/models.py:896
|
||||||
msgid "Files"
|
msgid "Files"
|
||||||
msgstr "Bestanden"
|
msgstr "Bestanden"
|
||||||
|
|
||||||
#: core/models.py:1041
|
#: core/models.py:1000
|
||||||
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:1051
|
#: core/models.py:1010
|
||||||
#, 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."
|
||||||
@@ -513,20 +471,10 @@ 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:123
|
#: core/recording/event/notification.py:116
|
||||||
msgid "Your recording is ready"
|
msgid "Your recording is ready"
|
||||||
msgstr "Je opname is klaar"
|
msgstr "Je opname is klaar"
|
||||||
|
|
||||||
#: core/recording/event/notification.py:194
|
|
||||||
msgid "Transcription"
|
|
||||||
msgstr "Transcriptie"
|
|
||||||
|
|
||||||
#: core/recording/event/notification.py:204
|
|
||||||
#, python-brace-format
|
|
||||||
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
|
||||||
msgstr ""
|
|
||||||
"Vergadering \"{room}\" op {room_recording_date} om {room_recording_time}"
|
|
||||||
|
|
||||||
#: core/services/invitation.py:44
|
#: core/services/invitation.py:44
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||||
@@ -622,7 +570,7 @@ msgid "To keep this recording permanently:"
|
|||||||
msgstr "Om deze opname permanent te bewaren:"
|
msgstr "Om deze opname permanent te bewaren:"
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:208
|
#: core/templates/mail/html/screen_recording.html:208
|
||||||
#, python-format
|
#: core/templates/mail/text/screen_recording.txt:13
|
||||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link 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 "
|
||||||
|
|
||||||
@@ -651,24 +599,18 @@ msgstr ""
|
|||||||
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
|
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
|
||||||
"team via %(support_email)s. "
|
"team via %(support_email)s. "
|
||||||
|
|
||||||
#: core/templates/mail/text/screen_recording.txt:13
|
#: meet/settings.py:223
|
||||||
#, fuzzy, python-format
|
|
||||||
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
|
||||||
msgid "Click the \"Open [%(link)s]\" link below "
|
|
||||||
msgstr "Klik op de \"<a href=\"%(link)s\">Openen</a>\"-link hieronder "
|
|
||||||
|
|
||||||
#: meet/settings.py:228
|
|
||||||
msgid "English"
|
msgid "English"
|
||||||
msgstr "Engels"
|
msgstr "Engels"
|
||||||
|
|
||||||
#: meet/settings.py:229
|
#: meet/settings.py:224
|
||||||
msgid "French"
|
msgid "French"
|
||||||
msgstr "Frans"
|
msgstr "Frans"
|
||||||
|
|
||||||
#: meet/settings.py:230
|
#: meet/settings.py:225
|
||||||
msgid "Dutch"
|
msgid "Dutch"
|
||||||
msgstr "Nederlands"
|
msgstr "Nederlands"
|
||||||
|
|
||||||
#: meet/settings.py:231
|
#: meet/settings.py:226
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr "Duits"
|
msgstr "Duits"
|
||||||
|
|||||||
@@ -748,30 +748,12 @@ class Base(Configuration):
|
|||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
SUMMARY_SERVICE_VERSION = values.PositiveIntegerValue(
|
|
||||||
1, environ_name="SUMMARY_SERVICE_VERSION", environ_prefix=None
|
|
||||||
)
|
|
||||||
SUMMARY_SERVICE_ENDPOINT = values.Value(
|
SUMMARY_SERVICE_ENDPOINT = values.Value(
|
||||||
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
|
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
|
||||||
)
|
)
|
||||||
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
|
|
||||||
)
|
|
||||||
SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS = (
|
|
||||||
values.PositiveIntegerValue(
|
|
||||||
60 * 60 * 24,
|
|
||||||
environ_name="SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS",
|
|
||||||
environ_prefix=None,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
TRANSCRIPTION_SATISFACTION_FORM_BASE_URL = values.Value(
|
|
||||||
None,
|
|
||||||
environ_name="TRANSCRIPTION_SATISFACTION_FORM_BASE_URL",
|
|
||||||
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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -57,9 +57,7 @@ const RecordingDownload = () => {
|
|||||||
const isSaved =
|
const isSaved =
|
||||||
data?.status === RecordingStatus.Saved ||
|
data?.status === RecordingStatus.Saved ||
|
||||||
data?.status === RecordingStatus.NotificationSucceed ||
|
data?.status === RecordingStatus.NotificationSucceed ||
|
||||||
data?.status === RecordingStatus.FailedToStop ||
|
data?.status === RecordingStatus.FailedToStop
|
||||||
data?.status === RecordingStatus.ExternalProcessFailed ||
|
|
||||||
data?.status === RecordingStatus.ExternalProcessSuccessful
|
|
||||||
|
|
||||||
const pageTitle = useMemo(() => {
|
const pageTitle = useMemo(() => {
|
||||||
if (isError) return `${APP_TITLE} - ${t('error.title')}`
|
if (isError) return `${APP_TITLE} - ${t('error.title')}`
|
||||||
@@ -93,8 +91,6 @@ const RecordingDownload = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
data.status !== RecordingStatus.ExternalProcessFailed &&
|
|
||||||
data.status !== RecordingStatus.ExternalProcessSuccessful &&
|
|
||||||
data.status !== RecordingStatus.Saved &&
|
data.status !== RecordingStatus.Saved &&
|
||||||
data.status !== RecordingStatus.NotificationSucceed &&
|
data.status !== RecordingStatus.NotificationSucceed &&
|
||||||
data.status !== RecordingStatus.FailedToStop
|
data.status !== RecordingStatus.FailedToStop
|
||||||
|
|||||||
@@ -12,6 +12,4 @@ export enum RecordingStatus {
|
|||||||
FailedToStart = 'failedToStart',
|
FailedToStart = 'failedToStart',
|
||||||
FailedToStop = 'failedToStop',
|
FailedToStop = 'failedToStop',
|
||||||
NotificationSucceed = 'notification_succeeded',
|
NotificationSucceed = 'notification_succeeded',
|
||||||
ExternalProcessSuccessful = 'external_process_successful',
|
|
||||||
ExternalProcessFailed = 'external_process_failed',
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,25 +28,7 @@ _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: >
|
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"}]'
|
||||||
[
|
|
||||||
{
|
|
||||||
"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": "meet",
|
|
||||||
"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
|
||||||
@@ -187,9 +169,8 @@ 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/v2/async-jobs/transcribe/
|
SUMMARY_SERVICE_ENDPOINT: http://meet-summary:80/api/v1/tasks/
|
||||||
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
|
||||||
|
|
||||||
migrate:
|
migrate:
|
||||||
@@ -310,7 +291,7 @@ celeryTranscribe:
|
|||||||
- "--pool=solo"
|
- "--pool=solo"
|
||||||
- "--loglevel=info"
|
- "--loglevel=info"
|
||||||
- "-Q"
|
- "-Q"
|
||||||
- "transcribe-queue-v2"
|
- "transcribe-queue,transcribe-queue-v2"
|
||||||
|
|
||||||
celerySummarize:
|
celerySummarize:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
@@ -327,7 +308,7 @@ celerySummarize:
|
|||||||
- "--pool=solo"
|
- "--pool=solo"
|
||||||
- "--loglevel=info"
|
- "--loglevel=info"
|
||||||
- "-Q"
|
- "-Q"
|
||||||
- "summarize-queue-v2"
|
- "summarize-queue,summarize-queue-v2"
|
||||||
|
|
||||||
celerySummaryBackend:
|
celerySummaryBackend:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
|
|||||||
@@ -2,8 +2,11 @@
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
from summary.api.route import tasks_v2
|
from summary.api.route import tasks, tasks_v2
|
||||||
from summary.core.security import verify_tenant_api_key
|
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"])
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""API routes related to application tasks."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from celery.result import AsyncResult
|
||||||
|
from fastapi import APIRouter
|
||||||
|
from pydantic import BaseModel, field_validator
|
||||||
|
|
||||||
|
from summary.core.celery_worker import (
|
||||||
|
process_audio_transcribe_summarize_v2,
|
||||||
|
)
|
||||||
|
from summary.core.config import get_settings
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
|
||||||
|
|
||||||
|
class TranscribeSummarizeTaskCreation(BaseModel):
|
||||||
|
"""Transcription and summarization parameters."""
|
||||||
|
|
||||||
|
owner_id: str
|
||||||
|
recording_filename: str
|
||||||
|
metadata_filename: Optional[str] = None
|
||||||
|
email: str
|
||||||
|
sub: str
|
||||||
|
version: Optional[int] = 2
|
||||||
|
room: Optional[str]
|
||||||
|
owner_timezone: Optional[str]
|
||||||
|
language: Optional[str]
|
||||||
|
download_link: Optional[str]
|
||||||
|
context_language: Optional[str] = None
|
||||||
|
recording_start_at: Optional[str] = None
|
||||||
|
recording_end_at: Optional[str] = None
|
||||||
|
|
||||||
|
@field_validator("language")
|
||||||
|
@classmethod
|
||||||
|
def validate_language(cls, v):
|
||||||
|
"""Validate 'language' parameter."""
|
||||||
|
if v is not None and v not in settings.whisperx_allowed_languages:
|
||||||
|
raise ValueError(
|
||||||
|
f"Language '{v}' is not allowed. "
|
||||||
|
f"Allowed languages: {', '.join(settings.whisperx_allowed_languages)}"
|
||||||
|
)
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
router_tasks_v1 = APIRouter(prefix="/tasks")
|
||||||
|
|
||||||
|
|
||||||
|
@router_tasks_v1.post("/")
|
||||||
|
async def create_transcribe_summarize_task(request: TranscribeSummarizeTaskCreation):
|
||||||
|
"""Create a transcription and summarization task."""
|
||||||
|
task = process_audio_transcribe_summarize_v2.apply_async(
|
||||||
|
args=[
|
||||||
|
request.owner_id,
|
||||||
|
request.recording_filename,
|
||||||
|
request.metadata_filename,
|
||||||
|
request.email,
|
||||||
|
request.sub,
|
||||||
|
time.time(),
|
||||||
|
request.room,
|
||||||
|
request.owner_timezone,
|
||||||
|
request.language,
|
||||||
|
request.download_link,
|
||||||
|
request.context_language,
|
||||||
|
request.recording_start_at,
|
||||||
|
request.recording_end_at,
|
||||||
|
],
|
||||||
|
queue=settings.transcribe_queue,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"id": task.id, "message": "Task created"}
|
||||||
|
|
||||||
|
|
||||||
|
@router_tasks_v1.get("/{task_id}")
|
||||||
|
async def get_task_status(task_id: str):
|
||||||
|
"""Check task status by ID."""
|
||||||
|
task = AsyncResult(task_id)
|
||||||
|
return {"id": task_id, "status": task.status}
|
||||||
@@ -1,19 +1,15 @@
|
|||||||
"""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, status
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
|
||||||
from summary.core.analytics import get_analytics
|
|
||||||
from summary.core.celery_worker import (
|
from summary.core.celery_worker import (
|
||||||
celery,
|
celery,
|
||||||
process_audio_transcribe_v2_task,
|
process_audio_transcribe_v2_task,
|
||||||
summarize_v2_task,
|
summarize_v2_task,
|
||||||
)
|
)
|
||||||
from summary.core.config import AuthorizedTenant, get_settings
|
from summary.core.config import AuthorizedTenant
|
||||||
from summary.core.models import SummarizeTaskApiRequest, TranscribeTaskApiRequest
|
from summary.core.models import SummarizeTaskV2Request, TranscribeTaskV2Request
|
||||||
from summary.core.security import verify_tenant_api_key_v2
|
from summary.core.security import verify_tenant_api_key_v2
|
||||||
from summary.core.shared_models import (
|
from summary.core.shared_models import (
|
||||||
SummarizeWebhookFailurePayload,
|
SummarizeWebhookFailurePayload,
|
||||||
@@ -24,52 +20,17 @@ from summary.core.shared_models import (
|
|||||||
TranscribeWebhookSuccessPayload,
|
TranscribeWebhookSuccessPayload,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
router_tasks_v2 = APIRouter()
|
router_tasks_v2 = APIRouter()
|
||||||
|
|
||||||
analytics = get_analytics()
|
|
||||||
settings = get_settings()
|
|
||||||
|
|
||||||
|
|
||||||
@router_tasks_v2.post("/async-jobs/transcribe")
|
@router_tasks_v2.post("/async-jobs/transcribe")
|
||||||
async def create_transcribe_task_v2(
|
async def create_transcribe_task_v2(
|
||||||
request: TranscribeTaskApiRequest,
|
request: TranscribeTaskV2Request,
|
||||||
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.warning(
|
|
||||||
"Push to docs is not allowed for this tenant (%s).", 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=[
|
args=[{**request.model_dump(), "tenant_id": request_tenant.id}]
|
||||||
{
|
|
||||||
**request.model_dump(),
|
|
||||||
"tenant_id": request_tenant.id,
|
|
||||||
"received_at": datetime.now(timezone.utc),
|
|
||||||
}
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
# We track the request, this also properly initializes the user in the
|
|
||||||
# analytics system, so that later feature flags work properly
|
|
||||||
analytics.capture(
|
|
||||||
settings.posthog_event_request,
|
|
||||||
request.user_sub,
|
|
||||||
properties={
|
|
||||||
"kind": "transcribe",
|
|
||||||
"$set": {
|
|
||||||
"email": request.user_email,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return TranscribeWebhookPendingPayload(job_id=task.id).model_dump()
|
return TranscribeWebhookPendingPayload(job_id=task.id).model_dump()
|
||||||
@@ -77,32 +38,14 @@ async def create_transcribe_task_v2(
|
|||||||
|
|
||||||
@router_tasks_v2.post("/async-jobs/summarize")
|
@router_tasks_v2.post("/async-jobs/summarize")
|
||||||
async def create_summarize_task_v2(
|
async def create_summarize_task_v2(
|
||||||
request: SummarizeTaskApiRequest,
|
request: SummarizeTaskV2Request,
|
||||||
request_tenant: AuthorizedTenant = Depends(verify_tenant_api_key_v2),
|
request_tenant: AuthorizedTenant = Depends(verify_tenant_api_key_v2),
|
||||||
):
|
):
|
||||||
"""Create a summarization task."""
|
"""Create a summarization task."""
|
||||||
task = summarize_v2_task.apply_async(
|
task = summarize_v2_task.apply_async(
|
||||||
args=[
|
args=[{**request.model_dump(), "tenant_id": request_tenant.id}]
|
||||||
{
|
|
||||||
**request.model_dump(),
|
|
||||||
"tenant_id": request_tenant.id,
|
|
||||||
"received_at": datetime.now(timezone.utc),
|
|
||||||
}
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# We track the request, this also properly initializes the user in the
|
|
||||||
# analytics system, so that later feature flags work properly
|
|
||||||
analytics.capture(
|
|
||||||
settings.posthog_event_request,
|
|
||||||
request.user_sub,
|
|
||||||
properties={
|
|
||||||
"kind": "summarize",
|
|
||||||
"$set": {
|
|
||||||
"email": request.user_email,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return SummarizeWebhookPendingPayload(job_id=task.id).model_dump()
|
return SummarizeWebhookPendingPayload(job_id=task.id).model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,12 @@ 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 TranscribeTaskJob
|
|
||||||
|
|
||||||
logger = get_task_logger(__name__)
|
logger = get_task_logger(__name__)
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -109,24 +107,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: str, task_payload: TranscribeTaskJob):
|
def create(self, task_id, task_args):
|
||||||
"""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": clean_url,
|
"filename": filename,
|
||||||
"sub": task_payload.user_sub,
|
"email": email,
|
||||||
# avoid None in redis, it shouldn't happen anyway in prod
|
"queuing_time": round(start_time - received_at, 2),
|
||||||
"email": task_payload.user_email or "",
|
|
||||||
"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)
|
||||||
@@ -213,6 +210,6 @@ class MetadataManager:
|
|||||||
self.clear(task_id)
|
self.clear(task_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self._analytics.capture(event_name, metadata.get("sub"), metadata)
|
self._analytics.capture(event_name, metadata.get("email"), metadata)
|
||||||
except AnalyticsException:
|
except AnalyticsException:
|
||||||
logger.exception("Failed to capture analytics event")
|
logger.exception("Failed to capture analytics event")
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urljoin
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
@@ -17,15 +17,16 @@ 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_lasuite_docs
|
from summary.core.file_service import (
|
||||||
from summary.core.file_service import FileService, FileServiceException, TranscribeError
|
FileService,
|
||||||
|
FileServiceException,
|
||||||
|
TranscribeError,
|
||||||
|
)
|
||||||
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 (
|
||||||
PushToDocsBaseConfig,
|
SummarizeTaskV2Payload,
|
||||||
RecordingMetadata,
|
TranscribeTaskV2Payload,
|
||||||
SummarizeTaskJob,
|
|
||||||
TranscribeTaskJob,
|
|
||||||
)
|
)
|
||||||
from summary.core.prompt import (
|
from summary.core.prompt import (
|
||||||
FORMAT_NEXT_STEPS,
|
FORMAT_NEXT_STEPS,
|
||||||
@@ -49,6 +50,7 @@ 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()
|
||||||
@@ -84,22 +86,29 @@ 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: str,
|
cloud_storage_url=None,
|
||||||
raises: bool = False,
|
raises: bool = False,
|
||||||
):
|
):
|
||||||
"""Transcribe an audio file using WhisperX.
|
"""Transcribe an audio file using WhisperX.
|
||||||
|
|
||||||
Downloads the audio from a cloud storage URL, sends it to
|
Downloads the audio from MinIO or a cloud storage URL, sends it to
|
||||||
WhisperX for transcription, and tracks metadata throughout the process.
|
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")
|
||||||
|
|
||||||
# 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"]})
|
||||||
@@ -188,7 +197,11 @@ 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
|
||||||
@@ -198,34 +211,44 @@ def transcribe_audio(
|
|||||||
|
|
||||||
|
|
||||||
def resolve_speaker_identities_and_apply_to(
|
def resolve_speaker_identities_and_apply_to(
|
||||||
*, transcription: WhisperXResponse, recording_metadata: RecordingMetadata, task_id
|
transcription, recording_start_at, recording_end_at, metadata_filename, 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_metadata: Metadata of the recording
|
recording_start_at: sourced from LiveKit FileInfo via the egress_ended webhook
|
||||||
|
recording_end_at: sourced from LiveKit FileInfo via the egress_ended webhook
|
||||||
|
metadata_filename: name of metadata file containing VAD information in S3
|
||||||
task_id: current task id, for logging purposes
|
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_metadata.started_at,
|
recording_start_dt,
|
||||||
recording_metadata.ended_at,
|
recording_end_dt,
|
||||||
)
|
)
|
||||||
|
if (recording_start_dt is None) or (recording_end_dt is None):
|
||||||
|
logger.debug("Skipping resolve_speaker_identities")
|
||||||
|
return transcription
|
||||||
|
|
||||||
logger.debug("Running resolve_speaker_identities")
|
logger.debug("Running resolve_speaker_identities")
|
||||||
try:
|
try:
|
||||||
metadata = file_service.read_cloud_storage_json(
|
metadata = file_service.read_json(metadata_filename)
|
||||||
recording_metadata.cloud_storage_url
|
|
||||||
)
|
|
||||||
speaker_mapping = resolve_speaker_identities(
|
speaker_mapping = resolve_speaker_identities(
|
||||||
metadata,
|
metadata,
|
||||||
transcription.model_dump(),
|
transcription,
|
||||||
recording_metadata.started_at,
|
recording_start_dt,
|
||||||
recording_metadata.ended_at,
|
recording_end_dt,
|
||||||
)
|
)
|
||||||
new_transcription = speaker_mapping.apply_to(transcription.model_dump())
|
new_transcription = speaker_mapping.apply_to(transcription.model_dump())
|
||||||
return WhisperXResponse.model_validate(new_transcription)
|
return new_transcription
|
||||||
|
|
||||||
except FileServiceException as exc:
|
except FileServiceException as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
@@ -250,9 +273,12 @@ 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,
|
||||||
form_link: str | None,
|
form_link: str | None,
|
||||||
) -> str:
|
) -> tuple[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
|
||||||
@@ -265,6 +291,9 @@ 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,
|
||||||
form_link=form_link,
|
form_link=form_link,
|
||||||
)
|
)
|
||||||
@@ -288,8 +317,138 @@ 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,
|
||||||
|
)
|
||||||
|
|
||||||
|
form_base_url = settings.transcription_satisfaction_form_base_url
|
||||||
|
form_link = (
|
||||||
|
f"{form_base_url}?room_id={room}"
|
||||||
|
if (form_base_url and metadata_filename is not None)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
# Format output
|
||||||
|
content, title = format_transcript(
|
||||||
|
transcription,
|
||||||
|
context_language,
|
||||||
|
language,
|
||||||
|
room,
|
||||||
|
recording_start_at,
|
||||||
|
owner_timezone,
|
||||||
|
download_link,
|
||||||
|
form_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(
|
||||||
*, distinct_id: str, transcript: str, session_id: str
|
*, owner_id: str, transcript: str, session_id: str
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Generate a summary from the provided transcription text.
|
"""Generate a summary from the provided transcription text.
|
||||||
|
|
||||||
@@ -300,11 +459,11 @@ def summarize_transcription_internals(
|
|||||||
"""
|
"""
|
||||||
logger.info(
|
logger.info(
|
||||||
"Starting summarization task | Owner: %s",
|
"Starting summarization task | Owner: %s",
|
||||||
distinct_id,
|
owner_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
user_has_tracing_consent = analytics.is_feature_enabled(
|
user_has_tracing_consent = analytics.is_feature_enabled(
|
||||||
"summary-tracing-consent", distinct_id=distinct_id
|
"summary-tracing-consent", distinct_id=owner_id
|
||||||
)
|
)
|
||||||
|
|
||||||
# NOTE: We must instantiate a new LLMObservability client for each task invocation
|
# NOTE: We must instantiate a new LLMObservability client for each task invocation
|
||||||
@@ -315,7 +474,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=distinct_id,
|
user_id=owner_id,
|
||||||
)
|
)
|
||||||
llm_service = LLMService(llm_observability=llm_observability)
|
llm_service = LLMService(llm_observability=llm_observability)
|
||||||
|
|
||||||
@@ -367,57 +526,34 @@ 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
|
||||||
##################################################################################
|
##################################################################################
|
||||||
|
|
||||||
|
|
||||||
def _should_push_to_docs(
|
|
||||||
payload: TranscribeTaskJob | SummarizeTaskJob,
|
|
||||||
) -> bool:
|
|
||||||
"""Determines if the transcription should be pushed to docs.
|
|
||||||
|
|
||||||
Based on the payload and settings.
|
|
||||||
"""
|
|
||||||
if not payload.push_to_docs_config:
|
|
||||||
reason = "Push to docs is not requested in the payload"
|
|
||||||
elif not settings.is_lasuite_docs_integration_enabled:
|
|
||||||
reason = "Docs integration is disabled"
|
|
||||||
elif not settings.get_authorized_tenant(
|
|
||||||
tenant_id=payload.tenant_id
|
|
||||||
).allowed_push_to_docs:
|
|
||||||
reason = "Tenant is not allowed to push to docs"
|
|
||||||
else:
|
|
||||||
return True
|
|
||||||
|
|
||||||
logger.info("Push to docs is not requested: %s", reason)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def _should_auto_create_summary(payload: TranscribeTaskJob) -> bool:
|
|
||||||
"""Determines if the transcription should have an auto-created summary.
|
|
||||||
|
|
||||||
Based on the payload and settings.
|
|
||||||
"""
|
|
||||||
if (
|
|
||||||
payload.push_to_docs_config is None
|
|
||||||
or not payload.push_to_docs_config.auto_create_summary
|
|
||||||
):
|
|
||||||
reason = "Auto create summary is not requested in the payload"
|
|
||||||
elif not settings.is_summary_enabled:
|
|
||||||
reason = "Summary feature is disabled"
|
|
||||||
elif not analytics.is_feature_enabled(
|
|
||||||
"summary-enabled",
|
|
||||||
distinct_id=payload.user_sub,
|
|
||||||
):
|
|
||||||
reason = "Summary feature flag return false"
|
|
||||||
else:
|
|
||||||
return True
|
|
||||||
|
|
||||||
logger.info("Auto create summary is not requested: %s", reason)
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
@celery.task(
|
@celery.task(
|
||||||
max_retries=3,
|
max_retries=3,
|
||||||
queue=settings.call_webhook_queue_v2,
|
queue=settings.call_webhook_queue_v2,
|
||||||
@@ -454,7 +590,7 @@ def process_audio_transcribe_v2_task(
|
|||||||
self: Celery task instance (passed on with bind=True)
|
self: Celery task instance (passed on with bind=True)
|
||||||
payload: Serialized dictionary of TranscribeSummarizeTaskCreationV2
|
payload: Serialized dictionary of TranscribeSummarizeTaskCreationV2
|
||||||
"""
|
"""
|
||||||
payload = TranscribeTaskJob.model_validate(payload)
|
payload = TranscribeTaskV2Payload.model_validate(payload)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Transcribing for object received | Owner: %s",
|
"Transcribing for object received | Owner: %s",
|
||||||
payload.user_sub,
|
payload.user_sub,
|
||||||
@@ -481,60 +617,6 @@ def process_audio_transcribe_v2_task(
|
|||||||
)
|
)
|
||||||
return failure_payload.model_dump()
|
return failure_payload.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 Exception as e:
|
|
||||||
logger.error(f"Failed to resolve speaker identities, skipping: {e}")
|
|
||||||
|
|
||||||
should_push_to_docs = _should_push_to_docs(payload)
|
|
||||||
# We do it synchronously for now
|
|
||||||
if should_push_to_docs:
|
|
||||||
if payload.push_to_docs_config is None:
|
|
||||||
raise ValueError("Push to docs config is missing")
|
|
||||||
|
|
||||||
# Format output
|
|
||||||
content = format_transcript(
|
|
||||||
transcription_res.model_dump(),
|
|
||||||
payload.context_language,
|
|
||||||
payload.language,
|
|
||||||
payload.push_to_docs_config.download_link,
|
|
||||||
payload.push_to_docs_config.form_link,
|
|
||||||
)
|
|
||||||
|
|
||||||
create_document_in_lasuite_docs(
|
|
||||||
content=content,
|
|
||||||
title=payload.push_to_docs_config.title,
|
|
||||||
email=payload.push_to_docs_config.user_email,
|
|
||||||
sub=payload.user_sub,
|
|
||||||
)
|
|
||||||
|
|
||||||
if _should_auto_create_summary(payload):
|
|
||||||
locale = get_locale(payload.context_language, payload.language)
|
|
||||||
|
|
||||||
summarize_v2_task.apply_async(
|
|
||||||
args=[
|
|
||||||
SummarizeTaskJob(
|
|
||||||
received_at=datetime.now(timezone.utc),
|
|
||||||
tenant_id=payload.tenant_id,
|
|
||||||
user_sub=payload.user_sub,
|
|
||||||
user_email=payload.user_email,
|
|
||||||
push_to_docs_config=PushToDocsBaseConfig(
|
|
||||||
user_email=payload.push_to_docs_config.user_email,
|
|
||||||
title=locale.summary_title_template.format(
|
|
||||||
title=payload.push_to_docs_config.title
|
|
||||||
),
|
|
||||||
),
|
|
||||||
content=content,
|
|
||||||
).model_dump()
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
file_service.store_transcript(
|
file_service.store_transcript(
|
||||||
transcript=transcription_res,
|
transcript=transcription_res,
|
||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
@@ -547,30 +629,9 @@ 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, TranscribeTaskJob.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,
|
||||||
@@ -626,26 +687,15 @@ def summarize_v2_task(
|
|||||||
1. Run summary internals
|
1. Run summary internals
|
||||||
2. Sends the final summary via webhook.
|
2. Sends the final summary via webhook.
|
||||||
"""
|
"""
|
||||||
payload = SummarizeTaskJob.model_validate(payload)
|
payload = SummarizeTaskV2Payload.model_validate(payload)
|
||||||
summary = summarize_transcription_internals(
|
summary = summarize_transcription_internals(
|
||||||
distinct_id=payload.user_sub,
|
owner_id=payload.user_sub,
|
||||||
transcript=payload.content,
|
transcript=payload.content,
|
||||||
session_id=self.request.id,
|
session_id=self.request.id,
|
||||||
)
|
)
|
||||||
job_id = self.request.id
|
job_id = self.request.id
|
||||||
file_service.store_summary(summary=summary, job_id=job_id)
|
file_service.store_summary(summary=summary, job_id=job_id)
|
||||||
|
|
||||||
if _should_push_to_docs(payload):
|
|
||||||
if payload.push_to_docs_config is None:
|
|
||||||
raise ValueError("Push to docs config is missing")
|
|
||||||
|
|
||||||
create_document_in_lasuite_docs(
|
|
||||||
content=summary,
|
|
||||||
title=payload.push_to_docs_config.title,
|
|
||||||
email=payload.push_to_docs_config.user_email,
|
|
||||||
sub=payload.user_sub,
|
|
||||||
)
|
|
||||||
|
|
||||||
success_payload = SummarizeWebhookSuccessPayload(
|
success_payload = SummarizeWebhookSuccessPayload(
|
||||||
job_id=job_id,
|
job_id=job_id,
|
||||||
summary_data_url=file_service.get_summary_signed_url(job_id),
|
summary_data_url=file_service.get_summary_signed_url(job_id),
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
"""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, List, Mapping, Optional, Set
|
from typing import Annotated, Any, List, Literal, Mapping, Optional, Set
|
||||||
|
|
||||||
from fastapi import Depends
|
from fastapi import Depends
|
||||||
from pydantic import (
|
from pydantic import (
|
||||||
@@ -33,12 +34,9 @@ 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",
|
|
||||||
description="Whether to allow pushing transcript"
|
V1_DEFAULT_TENANT_ID = "__deprecated_meet_tenant__"
|
||||||
" and summaries to docs for this tenant.",
|
|
||||||
default=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
@@ -47,12 +45,14 @@ 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(min_length=1)
|
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,6 +72,8 @@ 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"
|
||||||
@@ -88,7 +90,7 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# AI-related settings
|
# AI-related settings
|
||||||
whisperx_api_key: SecretStr
|
whisperx_api_key: SecretStr
|
||||||
whisperx_base_url: Url = "https://api.openai.com/v1"
|
whisperx_base_url: str = "https://api.openai.com/v1"
|
||||||
whisperx_asr_model: str = "whisper-1"
|
whisperx_asr_model: str = "whisper-1"
|
||||||
# ISO 639-1 language code (e.g., "en", "fr", "es")
|
# ISO 639-1 language code (e.g., "en", "fr", "es")
|
||||||
whisperx_default_language: Optional[str] = None
|
whisperx_default_language: Optional[str] = None
|
||||||
@@ -110,17 +112,16 @@ class Settings(BaseSettings):
|
|||||||
webhook_max_retries: int = 2
|
webhook_max_retries: int = 2
|
||||||
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
|
||||||
app_external_user_agent: str = "summary"
|
|
||||||
|
# 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
|
||||||
|
transcription_satisfaction_form_base_url: Optional[str] = None
|
||||||
# Docs service configuration
|
|
||||||
is_lasuite_docs_integration_enabled: bool = False
|
|
||||||
lasuite_docs_base_url: Url = "https://example.com"
|
|
||||||
lasuite_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
|
||||||
@@ -132,7 +133,6 @@ class Settings(BaseSettings):
|
|||||||
posthog_api_host: Optional[str] = "https://eu.i.posthog.com"
|
posthog_api_host: Optional[str] = "https://eu.i.posthog.com"
|
||||||
posthog_event_failure: str = "transcript-failure"
|
posthog_event_failure: str = "transcript-failure"
|
||||||
posthog_event_success: str = "transcript-success"
|
posthog_event_success: str = "transcript-success"
|
||||||
posthog_event_request: str = "transcript-request"
|
|
||||||
|
|
||||||
# Langfuse (LLM Observability)
|
# Langfuse (LLM Observability)
|
||||||
langfuse_enabled: bool = False
|
langfuse_enabled: bool = False
|
||||||
@@ -145,9 +145,38 @@ 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."""
|
||||||
|
if len(self.authorized_tenants) == 0:
|
||||||
|
raise ValueError("No authorized tenants configured")
|
||||||
tenant_ids = {tenant.id for tenant in self.authorized_tenants}
|
tenant_ids = {tenant.id for tenant in self.authorized_tenants}
|
||||||
|
|
||||||
if len(tenant_ids) != len(self.authorized_tenants):
|
if len(tenant_ids) != len(self.authorized_tenants):
|
||||||
@@ -162,23 +191,13 @@ class Settings(BaseSettings):
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
def validate_docs_config(self):
|
def validate_default_v1_tenant(self):
|
||||||
"""Validate docs integration configuration."""
|
"""Validate default v1 tenant configuration."""
|
||||||
if not self.is_lasuite_docs_integration_enabled:
|
if not any(
|
||||||
return self
|
tenant.id == self.v1_tenant_id for tenant in self.authorized_tenants
|
||||||
|
):
|
||||||
|
raise ValueError("v1 tenant is not configured in authorized tenants")
|
||||||
|
|
||||||
if not self.lasuite_docs_base_url:
|
|
||||||
raise ValueError(
|
|
||||||
"lasuite_docs_base_url is required when docs integration is enabled"
|
|
||||||
)
|
|
||||||
if self.lasuite_docs_server_to_server_api_key.get_secret_value() in {
|
|
||||||
"",
|
|
||||||
"NO_API_KEY",
|
|
||||||
}:
|
|
||||||
raise ValueError(
|
|
||||||
"Valid lasuite_docs_server_to_server_api_key is required when"
|
|
||||||
" docs integration is enabled"
|
|
||||||
)
|
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
|
|||||||
@@ -1,94 +0,0 @@
|
|||||||
"""Service for delivering content to external destinations."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
from urllib.parse import urljoin
|
|
||||||
|
|
||||||
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}",
|
|
||||||
"User-Agent": settings.app_external_user_agent,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
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_lasuite_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 document to docs endpoint: %s", settings.lasuite_docs_base_url
|
|
||||||
)
|
|
||||||
logger.debug(
|
|
||||||
"Docs payload metadata | title_len=%s content_len=%s has_email=%s has_sub=%s",
|
|
||||||
len(title),
|
|
||||||
len(content),
|
|
||||||
bool(email),
|
|
||||||
bool(sub),
|
|
||||||
)
|
|
||||||
|
|
||||||
response = _post_with_retries(
|
|
||||||
url=urljoin(
|
|
||||||
settings.lasuite_docs_base_url, "/api/v1.0/documents/create-for-owner/"
|
|
||||||
),
|
|
||||||
api_key=settings.lasuite_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"
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Delivery success | Document %s submitted (HTTP %s)",
|
|
||||||
document_id,
|
|
||||||
response.status_code,
|
|
||||||
)
|
|
||||||
logger.debug("Docs response received (body omitted)")
|
|
||||||
@@ -14,6 +14,7 @@ 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
|
||||||
@@ -283,6 +284,50 @@ class FileService:
|
|||||||
|
|
||||||
self._max_duration_seconds = settings.recording_max_duration
|
self._max_duration_seconds = 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()
|
||||||
|
|
||||||
|
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(
|
||||||
@@ -347,17 +392,33 @@ class FileService:
|
|||||||
logger.error(error_msg)
|
logger.error(error_msg)
|
||||||
raise MediaDurationTooLongError(error_msg)
|
raise MediaDurationTooLongError(error_msg)
|
||||||
|
|
||||||
def read_cloud_storage_json(self, cloud_storage_url: str) -> dict:
|
def read_json(self, object_name: str) -> dict:
|
||||||
"""Read and parse a JSON file from a url."""
|
"""Read and parse a JSON file from MinIO storage."""
|
||||||
logger.info("Reading JSON: %s", cloud_storage_url)
|
logger.info("Reading JSON: %s", object_name)
|
||||||
res = requests.get(cloud_storage_url, timeout=(10, 100))
|
|
||||||
res.raise_for_status()
|
if not object_name:
|
||||||
return res.json()
|
raise ValueError("Invalid object_name")
|
||||||
|
|
||||||
|
response = None
|
||||||
|
try:
|
||||||
|
response = self._minio_client.get_object(self._bucket_name, object_name)
|
||||||
|
return json.loads(response.read())
|
||||||
|
except (MinioException, S3Error) as e:
|
||||||
|
raise FileServiceException(
|
||||||
|
"Unexpected error while reading JSON object."
|
||||||
|
) from e
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError) as 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,
|
||||||
cloud_storage_url: str,
|
remote_object_key: str | None = None,
|
||||||
|
cloud_storage_url: str | None = None,
|
||||||
):
|
):
|
||||||
"""Download and prepare audio file for processing.
|
"""Download and prepare audio file for processing.
|
||||||
|
|
||||||
@@ -370,7 +431,20 @@ class FileService:
|
|||||||
file_handle = None
|
file_handle = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
downloaded_path = self._download_from_cloud_storage_url(cloud_storage_url)
|
if bool(remote_object_key) == bool(cloud_storage_url):
|
||||||
|
raise ValueError(
|
||||||
|
(
|
||||||
|
"Exactly one of 'remote_object_key' or "
|
||||||
|
"'cloud_storage_url' must be provided."
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if cloud_storage_url:
|
||||||
|
downloaded_path = self._download_from_cloud_storage_url(
|
||||||
|
cloud_storage_url
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
downloaded_path = self._download_from_minio(remote_object_key)
|
||||||
|
|
||||||
media_info = get_media_info(downloaded_path)
|
media_info = get_media_info(downloaded_path)
|
||||||
|
|
||||||
|
|||||||
@@ -30,5 +30,8 @@ Einige Punkte, die wir Ihnen empfehlen zu überprüfen:
|
|||||||
"(externer Link)]({form_link})*\n"
|
"(externer Link)]({form_link})*\n"
|
||||||
),
|
),
|
||||||
hallucination_replacement_text="[Text konnte nicht transkribiert werden]",
|
hallucination_replacement_text="[Text konnte nicht transkribiert werden]",
|
||||||
summary_title_template="Zusammenfassung von {title}",
|
document_default_title="Transkription",
|
||||||
|
document_title_template=(
|
||||||
|
'Besprechung "{room}" am {room_recording_date} um {room_recording_time}'
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,5 +30,8 @@ A few things we recommend you check:
|
|||||||
"(external link)]({form_link})*\n"
|
"(external link)]({form_link})*\n"
|
||||||
),
|
),
|
||||||
hallucination_replacement_text="[Unable to transcribe text]",
|
hallucination_replacement_text="[Unable to transcribe text]",
|
||||||
summary_title_template="Summary of {title}",
|
document_default_title="Transcription",
|
||||||
|
document_title_template=(
|
||||||
|
'Meeting "{room}" on {room_recording_date} at {room_recording_time}'
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -29,5 +29,8 @@ Quelques points que nous vous conseillons de vérifier :
|
|||||||
"\n\n*[Donnez nous votre avis sur cette transcription]({form_link})*\n"
|
"\n\n*[Donnez nous votre avis sur cette transcription]({form_link})*\n"
|
||||||
),
|
),
|
||||||
hallucination_replacement_text="[Texte impossible à transcrire]",
|
hallucination_replacement_text="[Texte impossible à transcrire]",
|
||||||
summary_title_template="Résumé de {title}",
|
document_default_title="Transcription",
|
||||||
|
document_title_template=(
|
||||||
|
'Réunion "{room}" du {room_recording_date} à {room_recording_time}'
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,5 +30,8 @@ Een paar punten die wij u aanraden te controleren:
|
|||||||
"(externe link)]({form_link})*\n"
|
"(externe link)]({form_link})*\n"
|
||||||
),
|
),
|
||||||
hallucination_replacement_text="[Tekst kon niet worden getranscribeerd]",
|
hallucination_replacement_text="[Tekst kon niet worden getranscribeerd]",
|
||||||
summary_title_template="Samenvatting van {title}",
|
document_default_title="Transcriptie",
|
||||||
|
document_title_template=(
|
||||||
|
'Vergadering "{room}" op {room_recording_date} om {room_recording_time}'
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,4 +12,5 @@ class LocaleStrings:
|
|||||||
download_header_template: str
|
download_header_template: str
|
||||||
form_footer_template: str
|
form_footer_template: str
|
||||||
hallucination_replacement_text: str
|
hallucination_replacement_text: str
|
||||||
summary_title_template: str
|
document_default_title: str
|
||||||
|
document_title_template: str
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
"""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, EmailStr, 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
|
||||||
@@ -14,60 +12,10 @@ 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: EmailStr | None = Field(
|
|
||||||
default=None,
|
|
||||||
title="User Email",
|
|
||||||
description="The user's email for analytics purposes.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class RecordingMetadata(BaseModel):
|
class TranscribeTaskV2Request(SharedV2TaskCreation):
|
||||||
"""Model for recording metadata."""
|
"""Model for creating a transcribe and summarize task (used for API request)."""
|
||||||
|
|
||||||
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: EmailStr = 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: Url | None = Field(
|
|
||||||
default=None,
|
|
||||||
title="Download Link",
|
|
||||||
description="The link to download the recording.",
|
|
||||||
)
|
|
||||||
form_link: Url | None = Field(
|
|
||||||
default=None,
|
|
||||||
title="Form Link",
|
|
||||||
description="The link to fill out a form for 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 TranscribeTaskApiRequest(SharedV2TaskCreation):
|
|
||||||
"""Model for creating a transcribe task (used for API request)."""
|
|
||||||
|
|
||||||
cloud_storage_url: Url = Field(
|
cloud_storage_url: Url = Field(
|
||||||
title="Cloud storage URL",
|
title="Cloud storage URL",
|
||||||
@@ -79,17 +27,7 @@ class TranscribeTaskApiRequest(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 transcribe."
|
title="Language", description="The language of the content to summarize."
|
||||||
)
|
|
||||||
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")
|
||||||
@@ -104,30 +42,19 @@ class TranscribeTaskApiRequest(SharedV2TaskCreation):
|
|||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
class TranscribeTaskJob(TranscribeTaskApiRequest):
|
class TranscribeTaskV2Payload(TranscribeTaskV2Request):
|
||||||
"""Model for creating a transcribe task (used for actual task creation)."""
|
"""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 SummarizeTaskApiRequest(SharedV2TaskCreation):
|
class SummarizeTaskV2Request(SharedV2TaskCreation):
|
||||||
"""Model for creating a summarize task (used for API request)."""
|
"""Model for creating a summarize task (used for API request)."""
|
||||||
|
|
||||||
content: str = Field(title="Content", description="The content to summarize.")
|
content: str = Field(title="Content", description="The content to summarize.")
|
||||||
|
|
||||||
|
|
||||||
class SummarizeTaskJob(SummarizeTaskApiRequest):
|
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."
|
|
||||||
)
|
|
||||||
push_to_docs_config: PushToDocsBaseConfig | None = Field(
|
|
||||||
title="Push to Docs info",
|
|
||||||
description="If set, configuration for pushing to docs",
|
|
||||||
default=None,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -167,5 +167,4 @@ __all__ = [
|
|||||||
"SummarizeWebhookPayloads",
|
"SummarizeWebhookPayloads",
|
||||||
"WebhookPayloads",
|
"WebhookPayloads",
|
||||||
"WhisperXResponse",
|
"WhisperXResponse",
|
||||||
"webhook_payload_adapter",
|
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
"""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
|
||||||
@@ -35,13 +38,16 @@ class TranscriptFormatter:
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def format(
|
def format( # noqa: PLR0913
|
||||||
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,
|
||||||
form_link: str | None = None,
|
form_link: str | None = None,
|
||||||
) -> str:
|
) -> Tuple[str, str]:
|
||||||
"""Format transcription into the final document."""
|
"""Format transcription into the final document and its title."""
|
||||||
segments = self._get_segments(transcription)
|
segments = self._get_segments(transcription)
|
||||||
|
|
||||||
if not segments:
|
if not segments:
|
||||||
@@ -53,7 +59,9 @@ class TranscriptFormatter:
|
|||||||
if form_link:
|
if form_link:
|
||||||
content = self._add_footer(content, form_link)
|
content = self._add_footer(content, form_link)
|
||||||
|
|
||||||
return content
|
title = self._generate_title(room, recording_datetime, owner_timezone)
|
||||||
|
|
||||||
|
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,3 +104,23 @@ class TranscriptFormatter:
|
|||||||
|
|
||||||
footer = self._locale.form_footer_template.format(form_link=form_link)
|
footer = self._locale.form_footer_template.format(form_link=form_link)
|
||||||
return content + footer
|
return content + footer
|
||||||
|
|
||||||
|
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,6 +4,9 @@ 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 (
|
||||||
@@ -15,6 +18,83 @@ 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,
|
||||||
@@ -34,7 +114,6 @@ def call_webhook_v2(
|
|||||||
json=payload.model_dump(),
|
json=payload.model_dump(),
|
||||||
headers={
|
headers={
|
||||||
"Authorization": f"Bearer {tenant.webhook_api_key.get_secret_value()}",
|
"Authorization": f"Bearer {tenant.webhook_api_key.get_secret_value()}",
|
||||||
"User-Agent": settings.app_external_user_agent,
|
|
||||||
},
|
},
|
||||||
timeout=(10, 20),
|
timeout=(10, 20),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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_v2
|
from summary.api.main import api_router_v1, api_router_v2
|
||||||
from summary.core.config import get_settings
|
from summary.core.config import get_settings
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -17,5 +17,6 @@ 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)
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Integration tests for the API tasks endpoints."""
|
||||||
|
|
||||||
|
# tests/unit/test_api_tasks.py
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
|
||||||
|
class TestTasks:
|
||||||
|
"""Tests for the /v1/tasks endpoint."""
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"summary.api.route.tasks.process_audio_transcribe_summarize_v2.apply_async",
|
||||||
|
return_value=MagicMock(id="task-id-abc"),
|
||||||
|
)
|
||||||
|
@patch("summary.api.route.tasks.time.time", return_value=1735725600.0)
|
||||||
|
def test_create_task_returns_task_id(self, mock_time, mock_apply_async, client):
|
||||||
|
"""POST /tasks/ with valid payload returns id and dispatches Celery task."""
|
||||||
|
response = client.post(
|
||||||
|
"api/v1/tasks/",
|
||||||
|
headers={"Authorization": "Bearer test-api-token"},
|
||||||
|
json={
|
||||||
|
"owner_id": "owner-123",
|
||||||
|
"recording_filename": "recording.mp4",
|
||||||
|
"metadata_filename": "metadata.json",
|
||||||
|
"email": "user@example.com",
|
||||||
|
"sub": "sub-123",
|
||||||
|
"room": "room-abc",
|
||||||
|
"owner_timezone": "UTC",
|
||||||
|
"language": None,
|
||||||
|
"download_link": "https://example.com/file.mp4",
|
||||||
|
"form_link": "https://satisfaction.com?room_id=test",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"id": "task-id-abc", "message": "Task created"}
|
||||||
|
|
||||||
|
args = mock_apply_async.call_args.kwargs["args"]
|
||||||
|
assert args == [
|
||||||
|
"owner-123", # owner_id
|
||||||
|
"recording.mp4", # recording_filename
|
||||||
|
"metadata.json", # metadata_filename
|
||||||
|
"user@example.com", # email
|
||||||
|
"sub-123", # sub
|
||||||
|
1735725600.0, # received_at
|
||||||
|
"room-abc", # room
|
||||||
|
"UTC", # owner_timezone
|
||||||
|
None, # language
|
||||||
|
"https://example.com/file.mp4", # download_link
|
||||||
|
None, # context_language
|
||||||
|
None, # recording_start_at
|
||||||
|
None, # recording_end_at
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_create_task_invalid_language(self, client):
|
||||||
|
"""POST /tasks/ with an unsupported language returns 422."""
|
||||||
|
payload = {"language": "klingon"}
|
||||||
|
response = client.post(
|
||||||
|
"/api/v1/tasks/",
|
||||||
|
headers={"Authorization": "Bearer test-api-token"},
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"summary.api.route.tasks.AsyncResult",
|
||||||
|
return_value=MagicMock(status="PENDING"),
|
||||||
|
)
|
||||||
|
def test_get_task_status_pending(self, mock_result, client):
|
||||||
|
"""GET /tasks/{id} returns PENDING status when the task has not started yet."""
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/tasks/task-id-abc",
|
||||||
|
headers={"Authorization": "Bearer test-api-token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {"id": "task-id-abc", "status": "PENDING"}
|
||||||
|
|
||||||
|
@patch(
|
||||||
|
"summary.api.route.tasks.AsyncResult",
|
||||||
|
return_value=MagicMock(status="SUCCESS"),
|
||||||
|
)
|
||||||
|
def test_get_task_status_success(self, mock_result, client):
|
||||||
|
"""GET /tasks/{id} returns SUCCESS status when the task has completed."""
|
||||||
|
response = client.get(
|
||||||
|
"/api/v1/tasks/task-id-abc",
|
||||||
|
headers={"Authorization": "Bearer test-api-token"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["status"] == "SUCCESS"
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
"""Integration tests for the V2 task API endpoints."""
|
"""Integration tests for the V2 task API endpoints."""
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from unittest.mock import ANY, MagicMock, patch
|
from unittest.mock import ANY, MagicMock, patch
|
||||||
|
|
||||||
|
|
||||||
@@ -32,8 +31,6 @@ class TestTasksV2:
|
|||||||
}
|
}
|
||||||
|
|
||||||
args = mock_apply_async.call_args.kwargs["args"]
|
args = mock_apply_async.call_args.kwargs["args"]
|
||||||
received_at = args[0].pop("received_at")
|
|
||||||
assert isinstance(received_at, datetime)
|
|
||||||
assert args == [
|
assert args == [
|
||||||
{
|
{
|
||||||
"user_sub": "remote-001",
|
"user_sub": "remote-001",
|
||||||
@@ -41,9 +38,6 @@ class TestTasksV2:
|
|||||||
"language": "en",
|
"language": "en",
|
||||||
"context_language": "fr",
|
"context_language": "fr",
|
||||||
"tenant_id": "test-tenant",
|
"tenant_id": "test-tenant",
|
||||||
"user_email": None,
|
|
||||||
"metadata": None,
|
|
||||||
"push_to_docs_config": None,
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -70,14 +64,11 @@ class TestTasksV2:
|
|||||||
}
|
}
|
||||||
|
|
||||||
args = mock_apply_async.call_args.kwargs["args"]
|
args = mock_apply_async.call_args.kwargs["args"]
|
||||||
received_at = args[0].pop("received_at")
|
|
||||||
assert isinstance(received_at, datetime)
|
|
||||||
assert args == [
|
assert args == [
|
||||||
{
|
{
|
||||||
"user_sub": "remote-002",
|
"user_sub": "remote-002",
|
||||||
"content": "This is a long meeting transcript to summarize.",
|
"content": "This is a long meeting transcript to summarize.",
|
||||||
"tenant_id": "test-tenant",
|
"tenant_id": "test-tenant",
|
||||||
"user_email": None,
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ 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",
|
||||||
|
|||||||
Reference in New Issue
Block a user