Compare commits

..

1 Commits

Author SHA1 Message Date
renovate[bot] 50fb093d39 ⬆️(dependencies) update django to v5.2.15 2026-07-01 01:35:02 +00:00
106 changed files with 4322 additions and 4521 deletions
+1
View File
@@ -305,6 +305,7 @@ jobs:
working-directory: src/summary
env:
V1_TENANT_ID: 'test-tenant'
AUTHORIZED_TENANTS: '[{"id": "test-tenant", "api_key": "test-api-token", "webhook_url": "https://example.com/webhook", "webhook_api_key": "test-webhook-api-key"}]'
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
AWS_S3_ENDPOINT_URL: "minio:9000"
-41
View File
@@ -8,39 +8,6 @@ and this project adheres to
## [Unreleased]
## [1.23.0] - 2026-07-08
### Added
- ✨(backend) extend analytics module to support feature flags
- ✨(backend) implement feature flags in Posthog analytics backend
- ✨(agents) report errors to Sentry for all LiveKit agents
### Changed
- ⬆️(agents) upgrade to python 3.14 slim
- ⬆️(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
- 🔥(summary) remove call to summary enabled feature flag
- ♻️(frontend) wrap MuteEveryoneButton with AdminOrOwnerOnly
- ⬆️(frontend) upgrade livekit-client from 2.19.0 to 2.19.2
- ⬆️(frontend) upgrade posthog-js from 1.386.5 to 1.387.0
- ⬆️(frontend) upgrade @tanstack/react-query from 5.100.14 to 5.101.0
- ⬆️(frontend) update the frontend build image to Node 22
- 🔒️(frontend) update docker image to nginx-unprivileged:1.30.3-alpine3.23
- ✨(summary) more precise analytics events
### Fixed
- 🚀(front) fix frontend build failure
- 🐛(makefile) fix args in make test
- 🩹(backend) fix case-insensitive email deduplication in merge command
- 🐛(summary) support media files with bad streams #1478
## [1.22.0] - 2026-07-03
### Added
- ✨(frontend) cap and paginate tiles in picture-in-picture #1383
@@ -49,20 +16,12 @@ and this project adheres to
- 🧱(helm) run clean files command as cronjob
- ✨(backend) add fallback to save recordings without S3/MinIO webhooks
- 🩹(frontend) enable screen share button in PiP #1458
- 🐛(backend) support unencoded S3 notification object keys #1455
### Changed
- ✨(summary) generalized stt api call #1420
- ♻️(env) refactor env variables handling
- 🚸(frontend) use "Advanced" instead of "Premium" in the sidepanel
- ♿️(frontend) make fullscreen share warning keyboard accessible #1459
- ⬆️(summary) update docker alpine to 3.24 & ffmpeg to 8.1.2 #1471
### Fixed
- 🛂(backend) reject user access tokens on the API
- 🩹(helm) fix Helm ingress rendering when passing multiple hosts
## [1.21.0] - 2026-06-15
+12 -13
View File
@@ -211,25 +211,24 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
@$(COMPOSE_RUN_APP) pylint meet demo core
.PHONY: lint-pylint
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"`
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
$(MAKE) test-back-parallel ARGS="$${args}" && \
$(MAKE) test-summary ARGS="$${args}"
test: ## run project tests
@$(MAKE) test-back-parallel
@$(MAKE) test-summary
.PHONY: test
test-back: ## run back-end tests (pass extra pytest args via ARGS)
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
bin/pytest $${args}
test-back: ## run back-end tests
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
bin/pytest $${args:-${1}}
.PHONY: test-back
test-back-parallel: ## run all back-end tests in parallel (pass extra pytest args via ARGS)
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
bin/pytest -n auto $${args}
test-back-parallel: ## run all back-end tests in parallel
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
bin/pytest -n auto $${args:-${1}}
.PHONY: test-back-parallel
test-summary: ## run summary tests (pass extra pytest args via ARGS)
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
bin/pytest-summary $${args}
test-summary: ## run summary tests
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
bin/pytest-summary $${args:-${1}}
.PHONY: test-summary
makemigrations: ## run django makemigrations for the Meet project.
-6
View File
@@ -113,12 +113,6 @@ update_python_version "summary"
# Update agents pyproject.toml
update_python_version "agents"
# Run uv lock in agents
print_info "Running uv lock in agents..."
cd "src/agents"
uv lock
cd -
# Update CHANGELOG
print_info "Updating CHANGELOG..."
+2 -2
View File
@@ -301,7 +301,7 @@ services:
context: ./src/summary
dockerfile: Dockerfile
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.d/development/summary
volumes:
@@ -321,7 +321,7 @@ services:
context: ./src/summary
dockerfile: Dockerfile
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.d/development/summary
volumes:
+2 -2
View File
@@ -1,5 +1,5 @@
# ---- Front-end image ----
FROM node:22-alpine AS frontend-deps
FROM node:20-alpine AS frontend-deps
WORKDIR /home/frontend/
@@ -54,7 +54,7 @@ RUN npx webpack --mode production
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
FROM nginxinc/nginx-unprivileged:alpine3.23 AS frontend-production
USER root
+1 -2
View File
@@ -65,9 +65,8 @@ ALLOW_UNREGISTERED_ROOMS=False
RECORDING_ENABLE=True
RECORDING_STORAGE_EVENT_ENABLE=True
RECORDING_STORAGE_EVENT_TOKEN=password
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe/
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v1/tasks/
SUMMARY_SERVICE_API_TOKEN=password
SUMMARY_SERVICE_WEBHOOK_API_TOKEN=webhook-password
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
# Recording encoding (LiveKit Egress advanced options).
@@ -9,6 +9,3 @@ DEEPGRAM_API_KEY=
KYUTAI_STT_BASE_URL=
KYUTAI_API_KEY=
SENTRY_DSN=
SENTRY_ENVIRONMENT=
-1
View File
@@ -30,4 +30,3 @@ POSTHOG_ENABLED="False"
# Transcription
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}]'
+1 -1
View File
@@ -1,4 +1,4 @@
FROM python:3.14.6-slim AS base
FROM python:3.13.13-slim AS base
# Install system dependencies required by LiveKit
RUN apt-get update && apt-get install -y \
+10 -26
View File
@@ -32,8 +32,6 @@ from minio import Minio
from minio.error import S3Error
from exceptions import MissingConfigError
from observability import configure_sentry, set_job_context
from tasks import done_callback
load_dotenv()
@@ -44,7 +42,6 @@ AGENT_NAME = os.getenv("METADATA_COLLECTOR_AGENT_NAME", "metadata-collector")
def prewarm(proc: JobProcess):
"""Preload voice activity detection model."""
configure_sentry(AGENT_NAME)
proc.userdata["vad"] = silero.VAD.load()
@@ -177,13 +174,7 @@ class MetadataCollector:
self.on_chat_message_received(reader, participant_identity)
)
self._tasks.add(task)
task.add_done_callback(
done_callback(
logger,
self._tasks,
f"process chat stream from {participant_identity}",
)
)
task.add_done_callback(lambda _: self._tasks.remove(task))
def save(self):
"""Serialize collected events and upload as JSON to S3."""
@@ -279,18 +270,16 @@ class MetadataCollector:
logger.info("Participant disconnected: %s", participant.identity)
task = asyncio.create_task(self._close_session(session))
self._tasks.add(task)
task.add_done_callback(
done_callback(
logger,
self._tasks,
f"close VAD session for {participant.identity}",
on_success=lambda _: logger.info(
"VAD session closed for %s (remaining sessions: %d)",
participant.identity,
len(self._sessions),
),
def on_close_done(_):
self._tasks.discard(task)
logger.info(
"VAD session closed for %s (remaining sessions: %d)",
participant.identity,
len(self._sessions),
)
)
task.add_done_callback(on_close_done)
def on_participant_name_changed(self, participant: rtc.RemoteParticipant):
"""Update stored participant name when it changes."""
@@ -371,8 +360,6 @@ async def handle_job_request(job_req: JobRequest) -> None:
@server.rtc_session(agent_name=AGENT_NAME, on_request=handle_job_request)
async def entrypoint(ctx: JobContext):
"""Initialize and run the metadata collector."""
set_job_context(room=ctx.room.name, job_id=ctx.job.id)
logger.info("Starting metadata agent in room: %s", ctx.room.name)
recording_id = ctx.job.metadata
metadata_collector = MetadataCollector(ctx, recording_id)
@@ -390,7 +377,4 @@ async def entrypoint(ctx: JobContext):
if __name__ == "__main__":
# Initialize Sentry for the worker process. Each job runs in its own
# (forked) process and re-initializes Sentry via prewarm().
configure_sentry(AGENT_NAME)
cli.run_app(server)
+10 -25
View File
@@ -25,9 +25,6 @@ from livekit.agents import (
)
from livekit.plugins import deepgram, silero
from observability import configure_sentry, set_job_context
from tasks import done_callback
load_dotenv()
logger = logging.getLogger("transcriber")
@@ -102,29 +99,24 @@ class MultiUserTranscriber:
logger.info(f"starting session for {participant.identity}")
task = asyncio.create_task(self._start_session(participant))
self._tasks.add(task)
task.add_done_callback(
done_callback(
logger,
self._tasks,
f"start transcription session for {participant.identity}",
)
)
def on_task_done(task: asyncio.Task):
try:
self._sessions[participant.identity] = task.result()
finally:
self._tasks.discard(task)
task.add_done_callback(on_task_done)
def on_participant_disconnected(self, participant: rtc.RemoteParticipant):
"""Handle participant disconnection by closing transcription session."""
if (session := self._sessions.pop(participant.identity, None)) is None:
if (session := self._sessions.pop(participant.identity)) is None:
return
logger.info(f"closing session for {participant.identity}")
task = asyncio.create_task(self._close_session(session))
self._tasks.add(task)
task.add_done_callback(
done_callback(
logger,
self._tasks,
f"close transcription session for {participant.identity}",
)
)
task.add_done_callback(lambda _: self._tasks.discard(task))
async def _start_session(self, participant: rtc.RemoteParticipant) -> AgentSession:
"""Create and start transcription session for participant."""
@@ -147,7 +139,6 @@ class MultiUserTranscriber:
participant_identity=participant.identity,
)
)
self._sessions[participant.identity] = session
return session
async def _close_session(self, sess: AgentSession) -> None:
@@ -158,8 +149,6 @@ class MultiUserTranscriber:
async def entrypoint(ctx: JobContext):
"""Initialize and run the multi-user transcriber."""
set_job_context(room=ctx.room.name, job_id=ctx.job.id)
transcriber = MultiUserTranscriber(ctx)
transcriber.start()
@@ -204,15 +193,11 @@ async def handle_transcriber_job_request(job_req: JobRequest) -> None:
def prewarm(proc: JobProcess):
"""Preload voice activity detection model."""
configure_sentry(TRANSCRIBER_AGENT_NAME)
if ENABLE_SILERO_VAD:
proc.userdata["vad"] = silero.VAD.load()
if __name__ == "__main__":
# Initialize Sentry for the worker process. Each job runs in its own
# (forked) process and re-initializes Sentry via prewarm().
configure_sentry(TRANSCRIBER_AGENT_NAME)
cli.run_app(
WorkerOptions(
entrypoint_fnc=entrypoint,
-83
View File
@@ -1,83 +0,0 @@
"""Sentry helpers for the LiveKit agents."""
import logging
import os
import tomllib
from os import path
import sentry_sdk
from sentry_sdk.integrations.logging import LoggingIntegration
logger = logging.getLogger("observability")
BASE_DIR = path.dirname(path.abspath(__file__))
def get_release():
"""Get the current release of the application.
By release, we mean the ``version`` declared in ``pyproject.toml``.
If the file cannot be read or declares no version, it defaults to "NA".
"""
try:
with open(path.join(BASE_DIR, "pyproject.toml"), "rb") as pyproject:
return tomllib.load(pyproject)["project"]["version"]
except (FileNotFoundError, KeyError, tomllib.TOMLDecodeError):
return "NA" # Default: not available
def configure_sentry(agent_name: str) -> None:
"""Initialize Sentry for the current agent process.
No-op if ``SENTRY_DSN`` is not configured. Otherwise (re)initializes Sentry
unconditionally so the calling process gets its own live transport.
Must be called once per process: in the worker entrypoint and again in the
per-job ``prewarm``/``setup_fnc`` hook, because LiveKit runs each job in a
forked process. A forked child inherits the parent's initialized Sentry
client but not its background transport thread (threads do not survive
``fork()``), so it must re-init to get a working transport. For that reason,
do NOT guard this with ``sentry_sdk.is_initialized()``: the child inherits it
as ``True`` and would skip init, silently dropping every event.
Args:
agent_name: Identifier of the agent, attached as a tag to Sentry issues
"""
# Read the DSN at call time so it picks up variables that load_dotenv()
# populated after this module was first imported.
sentry_dsn = os.getenv("SENTRY_DSN")
if not sentry_dsn:
logger.debug("SENTRY_DSN not defined for agent '%s'", agent_name)
return
sentry_sdk.init(
dsn=sentry_dsn,
environment=os.getenv("SENTRY_ENVIRONMENT"),
release=get_release(),
debug=False,
integrations=[
# Capture log records emitted at ERROR and above as Sentry events.
# This covers the agents' explicit logger.exception(...) calls as
# well as asyncio's "Exception in callback" / "Task exception was
# never retrieved" records, so unhandled task failures surface too.
LoggingIntegration(level=logging.INFO, event_level=logging.ERROR),
],
)
sentry_sdk.set_tag("application", "agents")
sentry_sdk.set_tag("agent", agent_name)
logger.info("Sentry initialized for agent '%s' (pid %d)", agent_name, os.getpid())
def set_job_context(*, room: str | None = None, job_id: str | None = None) -> None:
"""Tag the current Sentry scope with the LiveKit job being handled.
Args:
room: Name of the room the job is serving.
job_id: LiveKit job identifier.
"""
scope = sentry_sdk.get_current_scope()
if room is not None:
scope.set_tag("room", room)
if job_id is not None:
scope.set_tag("job_id", job_id)
+6 -7
View File
@@ -1,22 +1,21 @@
[project]
name = "agents"
version = "1.23.0"
version = "1.21.0"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.6.4",
"livekit-plugins-deepgram==1.6.4",
"livekit-plugins-silero==1.6.4",
"livekit-agents==1.5.13",
"livekit-plugins-deepgram==1.5.13",
"livekit-plugins-silero==1.5.13",
"livekit-plugins-kyutai-lasuite==0.0.6",
"python-dotenv==1.2.2",
"protobuf==6.33.6",
"minio==7.2.20",
"sentry-sdk==2.60.0",
"minio==7.2.20"
]
[project.optional-dependencies]
dev = [
"ruff==0.15.19",
"ruff==0.15.14",
]
[tool.uv]
-42
View File
@@ -1,42 +0,0 @@
"""Helpers for managing asyncio tasks."""
import asyncio
import logging
from collections.abc import Callable
from typing import Any
def done_callback(
logger: logging.Logger,
tasks: set[asyncio.Task],
description: str,
*,
on_success: Callable[[Any], None] | None = None,
) -> Callable[[asyncio.Task], None]:
"""Build a done-callback for a background task.
Meant to be passed to `asyncio.Task.add_done_callback`.
Args:
logger: Logger used to report failures, so records keep the caller's
logger name.
tasks: Set the task was registered in; the task is discarded from it.
description: Human-readable intended action
on_success: Optional callback invoked with the task's result when it
completes without error.
Returns:
A callback suitable for ``task.add_done_callback(...)``.
"""
def _finalize(task: asyncio.Task) -> None:
tasks.discard(task)
if task.cancelled():
return
if (exc := task.exception()) is not None:
logger.exception("failed to %s", description, exc_info=exc)
return
if on_success is not None:
on_success(task.result())
return _finalize
+833 -937
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -11,7 +11,7 @@ from core.recording.event import notification
from . import models
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):
@@ -242,7 +242,7 @@ class FileAdmin(admin.ModelAdmin):
"""Return a clickable preview URL for the file."""
if not obj.is_ready:
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(
'<a href="{}" target="_blank" rel="noopener noreferrer">Open File</a>', url
-67
View File
@@ -1,67 +0,0 @@
"""
Pluggable analytics.
Usage anywhere in the codebase:
from core import analytics
analytics.capture(request.user, "room_created", {"room_id": str(room.pk)})
The concrete backend is resolved lazily from Django settings, so swapping
PostHog for anything else is a configuration change, not a code change.
"""
from functools import lru_cache
from typing import Any
from django.conf import settings
from django.utils.module_loading import import_string
from .base import AnalyticsBackend, NoOpAnalytics
from .events import AnalyticsEvent
from .user_feature_flags import UserFeatureFlag
__all__ = [
"get_analytics",
"identify",
"capture",
"AnalyticsBackend",
"AnalyticsEvent",
"is_user_feature_flag_enabled",
"UserFeatureFlag",
]
@lru_cache(maxsize=1)
def get_analytics() -> AnalyticsBackend:
"""Instantiate the configured backend once per process."""
dotted_path = getattr(settings, "ANALYTICS_BACKEND", None)
options = getattr(settings, "ANALYTICS_BACKEND_SETTINGS", {}) or {}
if not dotted_path:
return NoOpAnalytics()
backend_class = import_string(dotted_path)
return backend_class(**options)
# Convenience module-level shortcuts
analytics_instance = get_analytics()
def identify(user, properties: dict[str, Any] | None = None) -> None:
"""Associate traits with an identified user."""
analytics_instance.identify(user, properties)
def capture(
user, event: AnalyticsEvent, properties: dict[str, Any] | None = None
) -> None:
"""Record an event performed by an identified user."""
analytics_instance.capture(user, event, properties)
def is_user_feature_flag_enabled(user, feature_name: UserFeatureFlag) -> bool:
"""Check if a feature is enabled at the user level."""
return analytics_instance.is_user_feature_enabled(user, feature_name)
-65
View File
@@ -1,65 +0,0 @@
"""Analytics backend protocol and default no-op implementation."""
from abc import ABC, abstractmethod
from typing import Any, Mapping
from ..models import User
from .events import AnalyticsEvent
from .user_feature_flags import UserFeatureFlag
class AnalyticsBackend(ABC):
"""
Interface every analytics backend must implement.
Backends are instantiated once (singleton) with the kwargs declared in
settings.ANALYTICS_BACKEND_SETTINGS, e.g.:
ANALYTICS_BACKEND = "core.analytics.posthog.PostHogAnalytics"
ANALYTICS_BACKEND_SETTINGS = {"api_key": "...", "host": "..."}
"""
@abstractmethod
def identify(self, user: User, properties: dict[str, Any] | None = None) -> None:
"""Associate traits (email, name, ...) with an identified user."""
@abstractmethod
def capture(
self,
user: User,
event: AnalyticsEvent,
properties: dict[str, Any] | None = None,
) -> None:
"""Record an event performed by an identified user."""
@abstractmethod
def shutdown(self) -> None:
"""Flush pending events. Called on process exit."""
def get_user_feature_flags(
self,
user: User, # pylint: disable=unused-argument
) -> Mapping[UserFeatureFlag, bool | str | None]:
"""Return a dict of feature flags for the given user."""
# We return an empty dict here by default to avoid a breaking change
# By making this method abstract.
return {}
def is_user_feature_enabled(
self, user: User, feature_name: UserFeatureFlag
) -> bool:
"""Check if a feature is enabled at the user level."""
return self.get_user_feature_flags(user).get(feature_name, False) is True
class NoOpAnalytics(AnalyticsBackend):
"""Default backend: silently discards everything."""
def identify(self, user: User, properties=None) -> None:
"""No-op: discards identify calls."""
def capture(self, user, event, properties=None) -> None:
"""No-op: discards captured events."""
def shutdown(self) -> None:
"""No-op: nothing to flush."""
-10
View File
@@ -1,10 +0,0 @@
"""Catalog of all analytics events emitted by the backend."""
from enum import StrEnum
class AnalyticsEvent(StrEnum):
"""All trackable events. Values are the wire names sent to the provider."""
# Rooms
ROOM_CREATED = "room_created"
-116
View File
@@ -1,116 +0,0 @@
"""PostHog implementation of the analytics backend protocol."""
import logging
from typing import Any, Mapping
from django.core.cache import cache
from posthog import Posthog
from ..models import User
from .base import AnalyticsBackend
from .events import AnalyticsEvent
from .user_feature_flags import UserFeatureFlag
logger = logging.getLogger(__name__)
class PostHogAnalytics(AnalyticsBackend):
"""Send events to PostHog, keyed on the user's primary key (UUID)."""
def __init__(
self,
*,
api_key: str,
host: str = "https://eu.i.posthog.com",
feature_flags_cache_ttl: int = 60,
feature_flags_cache_prefix: str = "user_feature_flags:",
**kwargs: Any,
) -> None:
# The SDK batches and sends in a background thread by default,
# so calls below never block the request/response cycle.
self._client = Posthog(
project_api_key=api_key,
host=host,
**kwargs,
)
self._feature_flags_cache_ttl = feature_flags_cache_ttl
self._feature_flags_cache_prefix = feature_flags_cache_prefix
@staticmethod
def _distinct_id(user: User) -> str | None:
"""Return the PostHog distinct_id for a user, or None if anonymous."""
if user is None or not getattr(user, "is_authenticated", False):
return None
return str(user.pk)
def identify(self, user: User, properties: dict[str, Any] | None = None) -> None:
"""Associate traits (email, name, ...) with an identified user."""
distinct_id = self._distinct_id(user)
if distinct_id is None:
return
try:
self._client.set(
distinct_id=distinct_id,
properties=properties or {},
)
except Exception: # pylint: disable=broad-exception-caught
logger.exception("PostHog identify failed")
def capture(
self,
user: User,
event: AnalyticsEvent,
properties: dict[str, Any] | None = None,
) -> None:
"""Record an event performed by an identified user."""
distinct_id = self._distinct_id(user)
if distinct_id is None:
return
try:
self._client.capture(
distinct_id=distinct_id,
event=str(event),
properties=properties or {},
)
except Exception: # pylint: disable=broad-exception-caught
logger.exception("PostHog capture failed for event %s", event)
def shutdown(self) -> None:
"""Flush pending events. Called on process exit."""
self._client.shutdown()
def _fetch_user_feature_flags(
self, user: User
) -> Mapping[UserFeatureFlag, bool | str | None]:
"""Compute feature flags for a user."""
distinct_id = self._distinct_id(user)
if distinct_id is None:
return {}
flags = self._client.evaluate_flags(distinct_id)
out: dict[UserFeatureFlag, bool | str | None] = {}
for flag_key in UserFeatureFlag:
out[flag_key] = flags.get_flag(flag_key.value)
return out
def get_user_feature_flags(
self, user: User
) -> Mapping[UserFeatureFlag, bool | str | None]:
"""Get feature flags for a user. Caches the result for a short time."""
distinct_id = self._distinct_id(user)
if distinct_id is None:
return {}
try:
return cache.get_or_set(
f"{self._feature_flags_cache_prefix}{distinct_id}",
default=lambda: self._fetch_user_feature_flags(user),
timeout=self._feature_flags_cache_ttl,
)
except Exception: # pylint: disable=broad-exception-caught
logger.exception("Failed to get feature flags for user %s", user.pk)
return {}
@@ -1,9 +0,0 @@
"""Catalog of all analytics feature flags used by the backend."""
from enum import StrEnum
class UserFeatureFlag(StrEnum):
"""All feature flags configured in the app."""
TRANSCRIPT_SUMMARY_ENABLED = "summary-enabled"
-10
View File
@@ -563,13 +563,3 @@ class RenameParticipantSerializer(BaseValidationOnlySerializer):
"""Serializer for renaming a participant in a room."""
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)
+2 -69
View File
@@ -35,14 +35,11 @@ from rest_framework import (
)
from rest_framework.settings import api_settings
from core import analytics, enums, models, utils
from core import enums, models, utils
from core.api.filters import ListFileFilter
from core.enums import MEDIA_STORAGE_URL_PATTERN
from core.recording.enums import FileExtension
from core.recording.event.authentication import (
RecordingProcessWebhookAuthentication,
StorageEventAuthentication,
)
from core.recording.event.authentication import StorageEventAuthentication
from core.recording.event.exceptions import (
InvalidBucketError,
InvalidFilepathError,
@@ -311,16 +308,6 @@ class RoomViewSet(
if callback_id := self.request.data.get("callback_id"):
RoomCreation().persist_callback_state(callback_id, room)
analytics.capture(
self.request.user,
analytics.AnalyticsEvent.ROOM_CREATED,
{
"room_id": str(room.pk),
"access_level": room.access_level,
"from_callback": bool(self.request.data.get("callback_id")),
},
)
def perform_update(self, serializer):
"""Persist the room update, then sync metadata to LiveKit."""
@@ -1002,60 +989,6 @@ class RecordingViewSet(
{"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):
"""
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
@@ -9,7 +9,6 @@ from django.utils.translation import gettext_lazy as _
from lasuite.oidc_login.backends import (
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
)
from rest_framework.authentication import SessionAuthentication
from core.models import User
from core.services.marketing import (
@@ -97,17 +96,3 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
"Multiple user accounts share a common email."
) from e
return None
class SessionAuthenticationWith401(SessionAuthentication):
"""
Identical to DRF's SessionAuthentication, but returns a WWW-Authenticate
header so unauthenticated requests get a 401 instead of a 403.
The scheme is deliberately NOT 'Basic' — that would trigger the browser's
native login popup. 'Session' is ignored by the browser's auth UI but is
still truthy, so DRF keeps the status at 401.
"""
def authenticate_header(self, request):
return "Session"
+3 -19
View File
@@ -19,7 +19,7 @@ from rest_framework import (
status as drf_status,
)
from core import analytics, api, models
from core import api, models
from core.api.feature_flag import FeatureFlag
from core.services.jwt_token import JwtTokenService
@@ -194,26 +194,10 @@ class RoomViewSet(
role=models.RoleChoices.OWNER,
)
auth_method = type(self.request.successful_authenticator).__name__
client_id = (self.request.auth or {}).get("client_id", "unknown")
# Log for auditing
logger.info(
"Room created via application: room_id=%s, user_id=%s, client_id=%s, auth_method=%s",
"Room created via application: room_id=%s, user_id=%s, client_id=%s",
room.id,
self.request.user.id,
client_id,
auth_method,
)
analytics.capture(
self.request.user,
analytics.AnalyticsEvent.ROOM_CREATED,
{
"room_id": str(room.pk),
"access_level": room.access_level,
"client_id": client_id,
"external_api": True,
"auth_method": auth_method,
},
getattr(self.request.auth, "client_id", "unknown"),
)
@@ -6,7 +6,6 @@ from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
from django.db import transaction
from django.db.models import Count
from django.db.models.functions import Lower
from core.models import File, RecordingAccess, ResourceAccess, RoleChoices
@@ -21,14 +20,11 @@ ROLE_PRIORITY = {
class Command(BaseCommand):
"""
Merge duplicate users sharing the same email (case-insensitive) into the
most recently created one.
Merge duplicate users sharing the same email into the most recently created one.
Emails are compared case-insensitively, so 'John@Example.com' and
'john@example.com' are treated as duplicates. The KEPT user is the most
recently created. All room memberships, recording accesses and files are
transferred to it. When a conflict exists, the higher-privilege role wins.
Stale users are then deleted.
The KEPT user is the most recently created. All room memberships, recording
accesses and files are transferred to it. When a conflict exists, the
higher-privilege role wins. Stale users are then deleted.
Each email group is processed inside a single database transaction.
"""
@@ -60,16 +56,13 @@ class Command(BaseCommand):
users_qs = users_qs.filter(email__icontains=email_filter)
self.stdout.write(f"[INFO] Filtering emails containing '{email_filter}'.\n")
# Group emails case-insensitively so 'John@X.com' and 'john@x.com'
# are detected as duplicates of each other.
duplicate_emails = (
users_qs.exclude(email__isnull=True)
.exclude(email="")
.annotate(email_lower=Lower("email"))
.values("email_lower")
.values("email")
.annotate(cnt=Count("id"))
.filter(cnt__gt=1)
.values_list("email_lower", flat=True)
.values_list("email", flat=True)
)
if not duplicate_emails:
@@ -85,12 +78,9 @@ class Command(BaseCommand):
failed_emails = []
for email in duplicate_emails:
# Case-insensitive lookup to fetch every casing variant of the email.
# Secondary sort by id ensures a stable, deterministic order when
# created_at timestamps are equal (common in tests and bulk imports).
users = list(
User.objects.filter(email__iexact=email).order_by("created_at", "id")
)
users = list(User.objects.filter(email=email).order_by("created_at", "id"))
kept_user = users[-1]
stale_users = users[:-1]
@@ -130,10 +120,6 @@ class Command(BaseCommand):
failed_emails.append(email)
self.stderr.write(f"[ERROR] Failed to merge '{email}': {exc}")
if not kept_user.email.islower():
kept_user.email = kept_user.email.lower()
kept_user.save(update_fields=["email"])
if failed_emails:
raise CommandError(
f"Failed to merge {len(failed_emails)} email group(s): {', '.join(failed_emails)}"
@@ -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),
),
]
-17
View File
@@ -60,11 +60,6 @@ class RecordingStatusChoices(models.TextChoices):
FAILED_TO_START = "failed_to_start", _("Failed to Start")
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded")
EXTERNAL_PROCESS_SUCCESSFUL = (
"external_process_successful",
_("External process successful"),
)
EXTERNAL_PROCESS_FAILED = "external_process_failed", _("External process failed")
@classmethod
def is_final(cls, status):
@@ -78,8 +73,6 @@ class RecordingStatusChoices(models.TextChoices):
cls.STOPPED,
cls.SAVED,
cls.ABORTED,
cls.EXTERNAL_PROCESS_SUCCESSFUL,
cls.EXTERNAL_PROCESS_FAILED,
cls.FAILED_TO_START,
cls.FAILED_TO_STOP,
}
@@ -605,14 +598,6 @@ class Recording(BaseModel):
verbose_name=_("Recording options"),
help_text=_("Recording options"),
)
external_process_id = models.CharField(
max_length=255,
null=True,
blank=True,
unique=True,
verbose_name=_("External Process ID"),
help_text=_("ID of the external process associated with the recording."),
)
class Meta:
db_table = "meet_recording"
@@ -668,8 +653,6 @@ class Recording(BaseModel):
return self.status in {
RecordingStatusChoices.NOTIFICATION_SUCCEEDED,
RecordingStatusChoices.SAVED,
RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL,
RecordingStatusChoices.EXTERNAL_PROCESS_FAILED,
}
@property
@@ -14,9 +14,9 @@ logger = logging.getLogger(__name__)
class MachineUser:
"""Represent a non-interactive system user for automated storage operations."""
def __init__(self, username: str = "storage_event_user") -> None:
def __init__(self) -> None:
self.pk = None
self.username = username
self.username = "storage_event_user"
self.is_active = True
@property
@@ -34,33 +34,33 @@ class MachineUser:
return self.username
class HeaderBasedAuthentication(BaseAuthentication):
"""Authenticate requests using a header with a secret key."""
class StorageEventAuthentication(BaseAuthentication):
"""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"
TOKEN_TYPE = "Bearer" # noqa S105
REALM = ""
IS_ENFORCED_SETTINGS_KEY = None
EXPECTED_TOKEN_SETTINGS_KEY = None
def authenticate(self, request):
"""Validate the Bearer token from the Authorization header."""
if self.IS_ENFORCED_SETTINGS_KEY is not None:
if not getattr(settings, self.IS_ENFORCED_SETTINGS_KEY):
return MachineUser(), None
if not settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
return MachineUser(), None
if (
self.EXPECTED_TOKEN_SETTINGS_KEY is None
or (required_token := getattr(settings, self.EXPECTED_TOKEN_SETTINGS_KEY))
is None
):
raise AuthenticationFailed(
"Authentication is enabled but token is not configured."
)
required_token = settings.RECORDING_STORAGE_EVENT_TOKEN
if not required_token:
if settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
raise AuthenticationFailed(
"Authentication is enabled but token is not configured."
)
return MachineUser(), None
auth_header = request.headers.get(self.AUTH_HEADER)
if not auth_header:
logger.warning(
"Authentication failed: Missing Authorization header (ip: %s)",
@@ -68,10 +68,15 @@ class HeaderBasedAuthentication(BaseAuthentication):
)
raise AuthenticationFailed("Authorization header is required")
scheme, _, token = auth_header.partition(" ")
if scheme.lower() != self.TOKEN_TYPE.lower() or not token.strip():
raise AuthenticationFailed("Invalid authorization header format.")
token = token.strip()
auth_parts = auth_header.split(" ")
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
logger.warning(
"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
if not secrets.compare_digest(token.encode(), required_token.encode()):
@@ -85,26 +90,4 @@ class HeaderBasedAuthentication(BaseAuthentication):
def authenticate_header(self, request):
"""Return the WWW-Authenticate header value."""
return f"{self.TOKEN_TYPE} realm='{self.REALM}'"
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
return f"{self.TOKEN_TYPE} realm='Storage event API'"
@@ -4,7 +4,6 @@ import asyncio
import logging
import smtplib
from datetime import datetime, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from django.conf import settings
from django.core.mail import send_mail
@@ -18,8 +17,6 @@ from asgiref.sync import async_to_sync
from livekit import api as livekit_api
from core import models, utils
from core.analytics import UserFeatureFlag, is_user_feature_flag_enabled
from core.utils import generate_download_s3_url
logger = logging.getLogger(__name__)
@@ -181,49 +178,8 @@ class NotificationService:
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
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."""
if (
@@ -297,120 +253,5 @@ class NotificationService:
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,
"auto_create_summary": is_user_feature_flag_enabled(
owner_access.user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
),
},
"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()
@@ -6,7 +6,6 @@ import re
from dataclasses import dataclass
from functools import lru_cache
from typing import Any, Dict, Optional, Protocol
from urllib.parse import quote
from django.conf import settings
from django.utils.module_loading import import_string
@@ -165,9 +164,6 @@ class S3Parser(BaseS3Parser):
if not filepath:
raise ParsingEventDataError("Missing object key name")
filetype, _ = mimetypes.guess_type(filepath)
# Normalize raw S3-compatible object keys without re-encoding
# already encoded AWS S3 notification keys.
filepath = quote(filepath, safe="%+")
return StorageEvent(
filepath=filepath,
filetype=filetype,
@@ -1,318 +0,0 @@
"""
Unit tests for PostHogAnalytics.
"""
# pylint: disable=redefined-outer-name,unused-argument,protected-access
from unittest.mock import patch
from django.contrib.auth.models import AnonymousUser
import pytest
from core.analytics.events import AnalyticsEvent
from core.analytics.posthog import PostHogAnalytics
from core.analytics.user_feature_flags import UserFeatureFlag
from core.factories import UserFactory
pytestmark = pytest.mark.django_db
# ==============================
# __init__
# ==============================
@patch("core.analytics.posthog.Posthog")
def test_init_constructs_posthog_client_with_api_key_and_host(mock_posthog_cls):
"""Should forward api_key and host to the Posthog SDK constructor."""
PostHogAnalytics(api_key="my-key", host="https://custom.i.posthog.com")
mock_posthog_cls.assert_called_once_with(
project_api_key="my-key",
host="https://custom.i.posthog.com",
)
@patch("core.analytics.posthog.Posthog")
def test_init_defaults_to_eu_host(mock_posthog_cls):
"""Should default host to the EU PostHog cloud when not specified."""
PostHogAnalytics(api_key="my-key")
_, kwargs = mock_posthog_cls.call_args
assert kwargs["host"] == "https://eu.i.posthog.com"
@patch("core.analytics.posthog.Posthog")
def test_init_forwards_extra_kwargs_to_client(mock_posthog_cls):
"""Should pass through arbitrary extra kwargs (e.g. debug, disabled) to the SDK."""
PostHogAnalytics(api_key="my-key", debug=True, disabled=False)
_, kwargs = mock_posthog_cls.call_args
assert kwargs["debug"] is True
assert kwargs["disabled"] is False
# ==============================
# _distinct_id
# ==============================
@patch("core.analytics.posthog.Posthog")
def test_distinct_id_returns_none_for_none_user(mock_posthog_cls):
"""Should return None when user is None."""
backend = PostHogAnalytics(api_key="test-api-key")
assert backend._distinct_id(None) is None
@patch("core.analytics.posthog.Posthog")
def test_distinct_id_returns_none_for_anonymous_user(mock_posthog_cls):
"""Should return None when user.is_authenticated is falsy."""
backend = PostHogAnalytics(api_key="test-api-key")
assert backend._distinct_id(AnonymousUser()) is None
@patch("core.analytics.posthog.Posthog")
def test_distinct_id_returns_none_when_attribute_missing(mock_posthog_cls):
"""Should return None when the user object has no is_authenticated attribute at all."""
backend = PostHogAnalytics(api_key="test-api-key")
assert backend._distinct_id(object()) is None
@patch("core.analytics.posthog.Posthog")
def test_distinct_id_returns_stringified_pk_for_authenticated_user(mock_posthog_cls):
"""Should return str(user.pk) for an authenticated user."""
backend = PostHogAnalytics(api_key="test-api-key")
user = UserFactory()
assert backend._distinct_id(user) == str(user.pk)
# ==============================
# identify
# ==============================
@patch("core.analytics.posthog.Posthog")
def test_identify_noop_for_anonymous_user(mock_posthog_cls):
"""Should not call the SDK when the user is anonymous."""
backend = PostHogAnalytics(api_key="test-api-key")
backend.identify(AnonymousUser(), {"email": "a@example.com"})
mock_posthog_cls.return_value.set.assert_not_called()
@patch("core.analytics.posthog.Posthog")
def test_identify_noop_for_none_user(mock_posthog_cls):
"""Should not call the SDK when user is None."""
backend = PostHogAnalytics(api_key="test-api-key")
backend.identify(None, {"email": "a@example.com"})
mock_posthog_cls.return_value.set.assert_not_called()
@patch("core.analytics.posthog.Posthog")
def test_identify_sends_set_properties_for_authenticated_user(mock_posthog_cls):
"""Should call capture with event=$identify and properties wrapped in $set."""
backend = PostHogAnalytics(api_key="test-api-key")
user = UserFactory()
backend.identify(user, {"email": "a@example.com", "name": "A"})
mock_posthog_cls.return_value.set.assert_called_once_with(
distinct_id=str(user.pk),
properties={"email": "a@example.com", "name": "A"},
)
@patch("core.analytics.posthog.Posthog")
def test_identify_defaults_properties_to_empty_dict(mock_posthog_cls):
"""Should send an empty $set payload when properties is None."""
backend = PostHogAnalytics(api_key="test-api-key")
user = UserFactory()
backend.identify(user, None)
mock_posthog_cls.return_value.set.assert_called_once_with(
distinct_id=str(user.pk),
properties={},
)
@patch("core.analytics.posthog.Posthog")
def test_identify_swallows_sdk_exceptions(mock_posthog_cls):
"""Should log and not raise when the SDK call fails."""
mock_posthog_cls.return_value.set.side_effect = RuntimeError("network down")
backend = PostHogAnalytics(api_key="test-api-key")
user = UserFactory()
# Must not propagate.
backend.identify(user, {"email": "a@example.com"})
# ==============================
# capture
# ==============================
@patch("core.analytics.posthog.Posthog")
def test_capture_noop_for_anonymous_user(mock_posthog_cls):
"""Should not call the SDK when the user is anonymous."""
backend = PostHogAnalytics(api_key="test-api-key")
backend.capture(AnonymousUser(), AnalyticsEvent.ROOM_CREATED, {"room_id": "1"})
mock_posthog_cls.return_value.capture.assert_not_called()
@patch("core.analytics.posthog.Posthog")
def test_capture_noop_for_none_user(mock_posthog_cls):
"""Should not call the SDK when user is None."""
backend = PostHogAnalytics(api_key="test-api-key")
backend.capture(None, AnalyticsEvent.ROOM_CREATED, {"room_id": "1"})
mock_posthog_cls.return_value.capture.assert_not_called()
@patch("core.analytics.posthog.Posthog")
def test_capture_sends_event_and_properties_for_authenticated_user(mock_posthog_cls):
"""Should call capture with the distinct_id, event name, and properties."""
backend = PostHogAnalytics(api_key="test-api-key")
user = UserFactory()
backend.capture(user, AnalyticsEvent.ROOM_CREATED, {"room_id": "room-1"})
mock_posthog_cls.return_value.capture.assert_called_once_with(
distinct_id=str(user.pk),
event="room_created",
properties={"room_id": "room-1"},
)
@patch("core.analytics.posthog.Posthog")
def test_capture_serializes_event_enum_to_plain_string(mock_posthog_cls):
"""Should send the wire string, not the AnalyticsEvent enum member, to the SDK."""
backend = PostHogAnalytics(api_key="test-api-key")
user = UserFactory()
backend.capture(user, AnalyticsEvent.ROOM_CREATED)
_, kwargs = mock_posthog_cls.return_value.capture.call_args
assert kwargs["event"] == "room_created"
assert isinstance(
kwargs["event"], str
) # not AnalyticsEvent, not StrEnum subclass leaking through
@patch("core.analytics.posthog.Posthog")
def test_capture_defaults_properties_to_empty_dict(mock_posthog_cls):
"""Should send an empty properties dict when properties is None."""
backend = PostHogAnalytics(api_key="test-api-key")
user = UserFactory()
backend.capture(user, AnalyticsEvent.ROOM_CREATED, None)
mock_posthog_cls.return_value.capture.assert_called_once_with(
distinct_id=str(user.pk),
event="room_created",
properties={},
)
@patch("core.analytics.posthog.Posthog")
def test_capture_swallows_sdk_exceptions(mock_posthog_cls):
"""Should log and not raise when the SDK call fails."""
mock_posthog_cls.return_value.capture.side_effect = RuntimeError("network down")
backend = PostHogAnalytics(api_key="test-api-key")
user = UserFactory()
# Must not propagate.
backend.capture(user, AnalyticsEvent.ROOM_CREATED, {"room_id": "1"})
@patch("core.analytics.posthog.Posthog")
def test_capture_logs_the_failing_event_name_on_exception(mock_posthog_cls, caplog):
"""Should log which event failed, to aid debugging without crashing the caller."""
mock_posthog_cls.return_value.capture.side_effect = RuntimeError("network down")
backend = PostHogAnalytics(api_key="test-api-key")
user = UserFactory()
with caplog.at_level("ERROR"):
backend.capture(user, AnalyticsEvent.ROOM_CREATED)
assert any("PostHog capture failed" in record.message for record in caplog.records)
# ==============================
# feature flags
# ==============================
@patch("core.analytics.posthog.Posthog")
def test_compute_feature_flags_returns_all_catalog_entries(mock_posthog_cls):
"""Should map every declared feature flag key to the SDK evaluated value."""
backend = PostHogAnalytics(api_key="test-api-key")
user = UserFactory()
mock_posthog_cls.return_value.evaluate_flags.return_value.get_flag.return_value = (
True
)
flags = backend._fetch_user_feature_flags(user)
assert flags == {UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED: True}
mock_posthog_cls.return_value.evaluate_flags.assert_called_once_with(str(user.pk))
mock_posthog_cls.return_value.evaluate_flags.return_value.get_flag.assert_called_once_with(
UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED.value
)
@patch("core.analytics.posthog.cache.get_or_set")
@patch("core.analytics.posthog.Posthog")
def test_get_feature_flags_uses_cache_get_or_set(
mock_posthog_cls, mock_cache_get_or_set
):
"""Should cache feature flags by user distinct id with configured TTL."""
cached_flags = {UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED: False}
mock_cache_get_or_set.return_value = cached_flags
backend = PostHogAnalytics(api_key="test-api-key", feature_flags_cache_ttl=120)
user = UserFactory()
flags = backend.get_user_feature_flags(user)
assert flags == cached_flags
mock_cache_get_or_set.assert_called_once()
args, kwargs = mock_cache_get_or_set.call_args
assert kwargs["timeout"] == 120
assert args[0] == f"user_feature_flags:{user.pk}"
assert callable(kwargs["default"])
@patch("core.analytics.posthog.Posthog")
def test_get_feature_flags_returns_empty_dict_on_exception(mock_posthog_cls):
"""Should swallow failures and return an empty mapping."""
backend = PostHogAnalytics(api_key="test-api-key")
user = UserFactory()
with patch("core.analytics.posthog.cache.get_or_set", side_effect=RuntimeError):
assert backend.get_user_feature_flags(user) == {}
# ==============================
# shutdown
# ==============================
@patch("core.analytics.posthog.Posthog")
def test_shutdown_flushes_the_client(mock_posthog_cls):
"""Should delegate to the SDK's shutdown to flush pending events."""
backend = PostHogAnalytics(api_key="test-api-key")
backend.shutdown()
mock_posthog_cls.return_value.shutdown.assert_called_once()
@@ -36,26 +36,6 @@ def test_merge_keeps_most_recently_created_user():
assert User.objects.filter(id=user2.id).exists()
def test_merge_user_case_insensitive():
"""Emails differing only by case should be treated as duplicates and merged,
keeping the most recently created user."""
user1 = UserFactory(email="Dup@example.com")
user2 = UserFactory(email="dup@example.com")
call_command("merge_duplicate_users")
assert not User.objects.filter(id=user1.id).exists()
assert User.objects.filter(id=user2.id).exists()
user3 = UserFactory(email="joe@example.com")
user4 = UserFactory(email="Joe@example.com")
call_command("merge_duplicate_users")
assert not User.objects.filter(id=user3.id).exists()
assert User.objects.filter(id=user4.id).exists()
user4.refresh_from_db()
assert user4.email.islower()
def test_merge_deletes_all_stale_users():
"""Command should delete all stale users and keep only the most recently created one."""
email = "many@example.com"
@@ -477,4 +457,4 @@ def test_merge_email_filter_is_case_insensitive():
UserFactory(email="user1@Example.com")
UserFactory(email="user1@Example.com")
call_command("merge_duplicate_users", email_filter="@example.com")
assert User.objects.filter(email="user1@example.com").count() == 1
assert User.objects.filter(email="user1@Example.com").count() == 1
@@ -136,10 +136,10 @@ def test_authenticate_header():
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"
request = RequestFactory().get("/")
request.headers = {"Authorization": "Bearer extra-spaces-token"}
header = StorageEventAuthentication().authenticate_header(request)
assert header == "Bearer realm='Storage event API'"
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
StorageEventAuthentication().authenticate(request)
@@ -13,7 +13,6 @@ from django.contrib.sites.models import Site
import pytest
from core import factories, models
from core.analytics import UserFeatureFlag
from core.recording.event.notification import NotificationService, notification_service
pytestmark = pytest.mark.django_db
@@ -244,177 +243,3 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
assert result is False
assert mock_send_mail.call_count == 2
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": False,
"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
)
@pytest.mark.parametrize("auto_create_summary_enabled", [False, True])
def test_notify_summary_service_post_args_without_metadata(
mock_get_recording_timestamps,
mock_generate_download_s3_url,
mock_post,
auto_create_summary_enabled,
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
with mock.patch(
"core.recording.event.notification.is_user_feature_flag_enabled",
return_value=auto_create_summary_enabled,
) as mock_is_feature_flag_enabled:
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": auto_create_summary_enabled,
"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)
mock_is_feature_flag_enabled.assert_called_once_with(
owner, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
)
@@ -360,75 +360,6 @@ def test_s3_parse_unrecognized_extension(s3_parser):
s3_parser.parse(event_with_unknown_ext)
def test_s3_parser_keeps_encoded_filepath_compatible(settings):
"""Test S3 parser keeps already encoded object keys compatible."""
settings.RECORDING_OUTPUT_FOLDER = "recordings"
recording_id = "80ae9fe5-639a-438b-b86e-9e3dd2d55f4d"
parser = S3Parser(bucket_name="recordings-bucket")
data = {
"Records": [
{
"s3": {
"bucket": {"name": "recordings-bucket"},
"object": {
"key": f"recordings%2F{recording_id}.mp4",
},
}
}
]
}
assert parser.get_recording_id(data) == recording_id
def test_s3_parser_accepts_unencoded_filepath(settings):
"""Test S3 parser accepts raw object keys with slash separators."""
settings.RECORDING_OUTPUT_FOLDER = "recordings"
recording_id = "80ae9fe5-639a-438b-b86e-9e3dd2d55f4d"
parser = S3Parser(bucket_name="recordings-bucket")
data = {
"Records": [
{
"s3": {
"bucket": {"name": "recordings-bucket"},
"object": {
"key": f"recordings/{recording_id}.mp4",
},
}
}
]
}
assert parser.get_recording_id(data) == recording_id
def test_s3_parser_preserves_plus_signs_in_encoded_filepath(settings):
"""Test S3 parser preserves plus signs in already encoded object keys."""
settings.RECORDING_OUTPUT_FOLDER = "recordings"
recording_id = "80ae9fe5-639a-438b-b86e-9e3dd2d55f4d"
parser = S3Parser(bucket_name="recordings-bucket")
data = {
"Records": [
{
"s3": {
"bucket": {"name": "recordings-bucket"},
"object": {
"key": f"folder+name%2Frecordings%2F{recording_id}.mp4",
},
}
}
]
}
assert parser.get_recording_id(data) == recording_id
def test_s3_get_recording_id_success(s3_parser, valid_s3_event):
"""Test successful extraction of recording ID from S3 event."""
recording_id = s3_parser.get_recording_id(valid_s3_event)
@@ -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
+4 -6
View File
@@ -459,14 +459,12 @@ def generate_upload_policy(file):
return policy
def generate_download_s3_url(
key: str, *, expires_in: int, override_domain: bool = True
):
def generate_download_file_url(file, *, 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
# can't connect to the object storage with the same domain. This is the case in the
+101 -159
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\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"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,92 +17,70 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:67
#: core/admin.py:29
msgid "Personal info"
msgstr "Persönliche Informationen"
#: core/admin.py:80
#: core/admin.py:42
msgid "Permissions"
msgstr "Berechtigungen"
#: core/admin.py:92
#: core/admin.py:54
msgid "Important dates"
msgstr "Wichtige Daten"
#: core/admin.py:206
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
#: core/admin.py:132 core/admin.py:275
msgid "No owner"
msgstr "Kein Eigentümer"
#: core/admin.py:303 core/admin.py:446
#: core/admin.py:135 core/admin.py:278
msgid "Multiple owners"
msgstr "Mehrere Eigentümer"
#: core/admin.py:316
#: core/admin.py:148
msgid "Resend notification to external service"
msgstr "Benachrichtigung erneut an externen Dienst senden"
#: core/admin.py:339
#: core/admin.py:171
#, python-format
msgid "Failed to notify for recording %(id)s"
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen"
#: core/admin.py:347
#: core/admin.py:179
#, python-format
msgid "Failed to notify for recording %(id)s: %(error)s"
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen: %(error)s"
#: core/admin.py:355
#: core/admin.py:187
#, python-format
msgid "Successfully sent notifications for %(count)s recording(s)."
msgstr "Benachrichtigungen für %(count)s Aufnahme(n) erfolgreich gesendet."
#: core/admin.py:363
#: core/admin.py:195
#, python-format
msgid "Skipped %(count)s expired recording(s)."
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
#: core/admin.py:368
#: core/admin.py:200
msgid "Mark selected recordings as 'Failed to Stop'"
msgstr "Ausgewählte Aufnahmen als Fehler beim Stoppen markieren"
#: core/admin.py:386
#: core/admin.py:218
#, python-format
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
msgstr "%(count)s Aufnahme(n) erfolgreich als Fehler beim Stoppen markiert."
#: core/admin.py:394
#: core/admin.py:226
#, fuzzy, python-format
#| msgid "Skipped %(count)s expired recording(s)."
msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
#: core/admin.py:510
#: core/admin.py:342
msgid "No scopes"
msgstr "Keine Scopes"
#: core/admin.py:512
#: core/admin.py:344
msgid "Scopes"
msgstr "Scopes"
@@ -110,17 +88,17 @@ msgstr "Scopes"
msgid "Creator is me"
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."
msgstr ""
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
"hinzuzufügen."
#: core/api/serializers.py:534
#: core/api/serializers.py:509
msgid "This file extension is not allowed."
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."
msgstr "Sie haben die maximale Anzahl an Dateien dieses Typs erreicht."
@@ -168,59 +146,51 @@ msgstr "Stopp fehlgeschlagen"
msgid "Notification succeeded"
msgstr "Benachrichtigung erfolgreich"
#: core/models.py:65
msgid "External process successful"
msgstr "Externer Prozess erfolgreich"
#: core/models.py:67
msgid "External process failed"
msgstr "Externer Prozess fehlgeschlagen"
#: core/models.py:96
#: core/models.py:89
msgid "SCREEN_RECORDING"
msgstr "BILDSCHIRMAUFZEICHNUNG"
#: core/models.py:97
#: core/models.py:90
msgid "TRANSCRIPT"
msgstr "TRANSKRIPT"
#: core/models.py:103
#: core/models.py:96
msgid "Public Access"
msgstr "Öffentlicher Zugriff"
#: core/models.py:104
#: core/models.py:97
msgid "Trusted Access"
msgstr "Vertrauenswürdiger Zugriff"
#: core/models.py:105
#: core/models.py:98
msgid "Restricted Access"
msgstr "Eingeschränkter Zugriff"
#: core/models.py:117
#: core/models.py:110
msgid "id"
msgstr "ID"
#: core/models.py:118
#: core/models.py:111
msgid "primary key for the record as UUID"
msgstr "Primärschlüssel des Eintrags als UUID"
#: core/models.py:124
#: core/models.py:117
msgid "created on"
msgstr "erstellt am"
#: core/models.py:125
#: core/models.py:118
msgid "date and time at which a record was created"
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
#: core/models.py:130
#: core/models.py:123
msgid "updated on"
msgstr "aktualisiert am"
#: core/models.py:131
#: core/models.py:124
msgid "date and time at which a record was last updated"
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
#: core/models.py:151
#: core/models.py:144
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -228,11 +198,11 @@ msgstr ""
"Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen "
"und die Zeichen @/./+/-/_ enthalten."
#: core/models.py:157
#: core/models.py:150
msgid "sub"
msgstr "Sub"
#: core/models.py:159
#: core/models.py:152
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -240,55 +210,55 @@ msgstr ""
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
"Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt."
#: core/models.py:168
#: core/models.py:161
msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse"
#: core/models.py:173
#: core/models.py:166
msgid "admin email address"
msgstr "Administrator-E-Mail-Adresse"
#: core/models.py:175
#: core/models.py:168
msgid "full name"
msgstr "Vollständiger Name"
#: core/models.py:177
#: core/models.py:170
msgid "short name"
msgstr "Kurzname"
#: core/models.py:183
#: core/models.py:176
msgid "language"
msgstr "Sprache"
#: core/models.py:184
#: core/models.py:177
msgid "The language in which the user wants to see the interface."
msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
#: core/models.py:190
#: core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
#: core/models.py:193
#: core/models.py:186
msgid "device"
msgstr "Gerät"
#: core/models.py:195
#: core/models.py:188
msgid "Whether the user is a device or a real user."
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
#: core/models.py:198
#: core/models.py:191
msgid "staff status"
msgstr "Mitarbeiterstatus"
#: core/models.py:200
#: core/models.py:193
msgid "Whether the user can log into this admin site."
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
#: core/models.py:203
#: core/models.py:196
msgid "active"
msgstr "aktiv"
#: core/models.py:206
#: core/models.py:199
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -296,66 +266,66 @@ msgstr ""
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
"anstelle des Löschens des Kontos."
#: core/models.py:219
#: core/models.py:212
msgid "user"
msgstr "Benutzer"
#: core/models.py:220
#: core/models.py:213
msgid "users"
msgstr "Benutzer"
#: core/models.py:286
#: core/models.py:272
msgid "Resource"
msgstr "Ressource"
#: core/models.py:287
#: core/models.py:273
msgid "Resources"
msgstr "Ressourcen"
#: core/models.py:345
#: core/models.py:331
msgid "Resource access"
msgstr "Ressourcenzugriff"
#: core/models.py:346
#: core/models.py:332
msgid "Resource accesses"
msgstr "Ressourcenzugriffe"
#: core/models.py:352
#: core/models.py:338
msgid "Resource access with this User and Resource already exists."
msgstr ""
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
"bereits."
#: core/models.py:409
#: core/models.py:394
msgid "Visio room configuration"
msgstr "Visio-Raumkonfiguration"
#: core/models.py:410
#: core/models.py:395
msgid "Values for Visio parameters to configure the room."
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
#: core/models.py:417
#: core/models.py:402
msgid "Room PIN code"
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."
msgstr ""
"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"
msgstr "Raum"
#: core/models.py:425
#: core/models.py:410
msgid "Rooms"
msgstr "Räume"
#: core/models.py:589
#: core/models.py:574
msgid "Worker ID"
msgstr "Worker-ID"
#: core/models.py:591
#: core/models.py:576
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -364,153 +334,141 @@ msgstr ""
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
"ermöglicht."
#: core/models.py:599
#: core/models.py:584
msgid "Recording mode"
msgstr "Aufzeichnungsmodus"
#: core/models.py:600
#: core/models.py:585
msgid "Defines the mode of recording being called."
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
#: core/models.py:605 core/models.py:606
#: core/models.py:590 core/models.py:591
msgid "Recording options"
msgstr "Aufnahmeoptionen"
#: core/models.py:613
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
#: core/models.py:597
msgid "Recording"
msgstr "Aufzeichnung"
#: core/models.py:621
#: core/models.py:598
msgid "Recordings"
msgstr "Aufzeichnungen"
#: core/models.py:731
#: core/models.py:706
msgid "Recording/user relation"
msgstr "Beziehung Aufzeichnung/Benutzer"
#: core/models.py:732
#: core/models.py:707
msgid "Recording/user relations"
msgstr "Beziehungen Aufzeichnung/Benutzer"
#: core/models.py:738
#: core/models.py:713
msgid "This user is already in this recording."
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
#: core/models.py:744
#: core/models.py:719
msgid "This team is already in this recording."
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."
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
#: core/models.py:767
#: core/models.py:742
msgid "Create rooms"
msgstr "Räume erstellen"
#: core/models.py:768
#: core/models.py:743
msgid "List rooms"
msgstr "Räume auflisten"
#: core/models.py:769
#: core/models.py:744
msgid "Retrieve room details"
msgstr "Raumdetails abrufen"
#: core/models.py:770
#: core/models.py:745
msgid "Update rooms"
msgstr "Räume aktualisieren"
#: core/models.py:771
#: core/models.py:746
msgid "Delete rooms"
msgstr "Räume löschen"
#: core/models.py:784
#: core/models.py:759
msgid "Application name"
msgstr "Anwendungsname"
#: core/models.py:785
#: core/models.py:760
msgid "Descriptive name for this application."
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."
msgstr ""
"Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
#: core/models.py:806
#: core/models.py:781
msgid "Application"
msgstr "Anwendung"
#: core/models.py:807
#: core/models.py:782
msgid "Applications"
msgstr "Anwendungen"
#: core/models.py:830
#: core/models.py:805
msgid "Enter a valid domain"
msgstr "Geben Sie eine gültige Domain ein"
#: core/models.py:833
#: core/models.py:808
msgid "Domain"
msgstr "Domain"
#: core/models.py:834
#: core/models.py:809
msgid "Email domain this application can act on behalf of."
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
#: core/models.py:846
#: core/models.py:821
msgid "Application domain"
msgstr "Anwendungsdomain"
#: core/models.py:847
#: core/models.py:822
msgid "Application domains"
msgstr "Anwendungsdomains"
#: core/models.py:865
#: core/models.py:840
msgid "Pending"
msgstr "Ausstehend"
#: core/models.py:866
msgid "Analyzing"
msgstr ""
#: core/models.py:873
#: core/models.py:848
msgid "Ready"
msgstr "Bereit"
#: core/models.py:879
#: core/models.py:854
msgid "Background image"
msgstr "Hintergrundbild"
#: core/models.py:891
#: core/models.py:866
msgid "title"
msgstr "Titel"
#: core/models.py:915
#: core/models.py:890
msgid "Malware detection info when the analysis status is unsafe."
msgstr ""
"Informationen zur Malware-Erkennung, wenn der Analyse-Status unsicher ist."
#: core/models.py:920
#: core/models.py:895
msgid "File"
msgstr "Datei"
#: core/models.py:921
#: core/models.py:896
msgid "Files"
msgstr "Dateien"
#: core/models.py:1041
#: core/models.py:1000
msgid "This file is already hard deleted."
msgstr "Diese Datei wurde bereits endgültig gelöscht."
#: core/models.py:1051
#: core/models.py:1010
#, 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."
@@ -518,20 +476,10 @@ msgstr ""
"Um eine Datei endgültig zu löschen, muss sie zuvor weich gelöscht worden "
"sein."
#: core/recording/event/notification.py:123
#: core/recording/event/notification.py:116
msgid "Your recording is ready"
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
#, python-brace-format
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:"
#: 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 "
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 "
"an unser Support-Team unter %(support_email)s. "
#: core/templates/mail/text/screen_recording.txt:13
#, 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
#: meet/settings.py:223
msgid "English"
msgstr "Englisch"
#: meet/settings.py:229
#: meet/settings.py:224
msgid "French"
msgstr "Französisch"
#: meet/settings.py:230
#: meet/settings.py:225
msgid "Dutch"
msgstr "Niederländisch"
#: meet/settings.py:231
#: meet/settings.py:226
msgid "German"
msgstr "Deutsch"
+101 -158
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\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"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,92 +17,70 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:67
#: core/admin.py:29
msgid "Personal info"
msgstr "Personal info"
#: core/admin.py:80
#: core/admin.py:42
msgid "Permissions"
msgstr "Permissions"
#: core/admin.py:92
#: core/admin.py:54
msgid "Important dates"
msgstr "Important dates"
#: core/admin.py:206
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
#: core/admin.py:132 core/admin.py:275
msgid "No owner"
msgstr "No owner"
#: core/admin.py:303 core/admin.py:446
#: core/admin.py:135 core/admin.py:278
msgid "Multiple owners"
msgstr "Multiple owners"
#: core/admin.py:316
#: core/admin.py:148
msgid "Resend notification to external service"
msgstr "Resend notification to external service"
#: core/admin.py:339
#: core/admin.py:171
#, python-format
msgid "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
msgid "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
msgid "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
msgid "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'"
msgstr "Mark selected recordings as 'Failed to Stop'"
#: core/admin.py:386
#: core/admin.py:218
#, python-format
msgid "%(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
#| msgid "Skipped %(count)s expired recording(s)."
msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "Skipped %(count)s expired recording(s)."
#: core/admin.py:510
#: core/admin.py:342
msgid "No scopes"
msgstr "No scopes"
#: core/admin.py:512
#: core/admin.py:344
msgid "Scopes"
msgstr "Scopes"
@@ -110,15 +88,15 @@ msgstr "Scopes"
msgid "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."
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."
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."
msgstr "You have reached the maximum number of files for this type."
@@ -166,59 +144,51 @@ msgstr "Failed to Stop"
msgid "Notification succeeded"
msgstr "Notification succeeded"
#: core/models.py:65
msgid "External process successful"
msgstr "External process successful"
#: core/models.py:67
msgid "External process failed"
msgstr "External process failed"
#: core/models.py:96
#: core/models.py:89
msgid "SCREEN_RECORDING"
msgstr "SCREEN_RECORDING"
#: core/models.py:97
#: core/models.py:90
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:103
#: core/models.py:96
msgid "Public Access"
msgstr "Public Access"
#: core/models.py:104
#: core/models.py:97
msgid "Trusted Access"
msgstr "Trusted Access"
#: core/models.py:105
#: core/models.py:98
msgid "Restricted Access"
msgstr "Restricted Access"
#: core/models.py:117
#: core/models.py:110
msgid "id"
msgstr "id"
#: core/models.py:118
#: core/models.py:111
msgid "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"
msgstr "created on"
#: core/models.py:125
#: core/models.py:118
msgid "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"
msgstr "updated on"
#: core/models.py:131
#: core/models.py:124
msgid "date and time at which a record was last updated"
msgstr "date and time at which a record was last updated"
#: core/models.py:151
#: core/models.py:144
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -226,11 +196,11 @@ msgstr ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
#: core/models.py:157
#: core/models.py:150
msgid "sub"
msgstr "sub"
#: core/models.py:159
#: core/models.py:152
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -238,55 +208,55 @@ msgstr ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
#: core/models.py:168
#: core/models.py:161
msgid "identity email address"
msgstr "identity email address"
#: core/models.py:173
#: core/models.py:166
msgid "admin email address"
msgstr "admin email address"
#: core/models.py:175
#: core/models.py:168
msgid "full name"
msgstr "full name"
#: core/models.py:177
#: core/models.py:170
msgid "short name"
msgstr "short name"
#: core/models.py:183
#: core/models.py:176
msgid "language"
msgstr "language"
#: core/models.py:184
#: core/models.py:177
msgid "The language in which the user wants to see the interface."
msgstr "The language in which the user wants to see the interface."
#: core/models.py:190
#: core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr "The timezone in which the user wants to see times."
#: core/models.py:193
#: core/models.py:186
msgid "device"
msgstr "device"
#: core/models.py:195
#: core/models.py:188
msgid "Whether the user is a device or a real user."
msgstr "Whether the user is a device or a real user."
#: core/models.py:198
#: core/models.py:191
msgid "staff status"
msgstr "staff status"
#: core/models.py:200
#: core/models.py:193
msgid "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"
msgstr "active"
#: core/models.py:206
#: core/models.py:199
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -294,63 +264,63 @@ msgstr ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
#: core/models.py:219
#: core/models.py:212
msgid "user"
msgstr "user"
#: core/models.py:220
#: core/models.py:213
msgid "users"
msgstr "users"
#: core/models.py:286
#: core/models.py:272
msgid "Resource"
msgstr "Resource"
#: core/models.py:287
#: core/models.py:273
msgid "Resources"
msgstr "Resources"
#: core/models.py:345
#: core/models.py:331
msgid "Resource access"
msgstr "Resource access"
#: core/models.py:346
#: core/models.py:332
msgid "Resource accesses"
msgstr "Resource accesses"
#: core/models.py:352
#: core/models.py:338
msgid "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"
msgstr "Visio room configuration"
#: core/models.py:410
#: core/models.py:395
msgid "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"
msgstr "Room PIN code"
#: core/models.py:418
#: core/models.py:403
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Unique n-digit code that identifies this room in telephony mode."
#: core/models.py:424 core/models.py:578
#: core/models.py:409 core/models.py:563
msgid "Room"
msgstr "Room"
#: core/models.py:425
#: core/models.py:410
msgid "Rooms"
msgstr "Rooms"
#: core/models.py:589
#: core/models.py:574
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:591
#: core/models.py:576
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"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 "
"the worker stops, allowing for easy tracking."
#: core/models.py:599
#: core/models.py:584
msgid "Recording mode"
msgstr "Recording mode"
#: core/models.py:600
#: core/models.py:585
msgid "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"
msgstr "Recording options"
#: core/models.py:613
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
#: core/models.py:597
msgid "Recording"
msgstr "Recording"
#: core/models.py:621
#: core/models.py:598
msgid "Recordings"
msgstr "Recordings"
#: core/models.py:731
#: core/models.py:706
msgid "Recording/user relation"
msgstr "Recording/user relation"
#: core/models.py:732
#: core/models.py:707
msgid "Recording/user relations"
msgstr "Recording/user relations"
#: core/models.py:738
#: core/models.py:713
msgid "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."
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."
msgstr "Either user or team must be set, not both."
#: core/models.py:767
#: core/models.py:742
#, fuzzy
#| msgid "created on"
msgid "Create rooms"
msgstr "Create rooms"
#: core/models.py:768
#: core/models.py:743
msgid "List rooms"
msgstr "List rooms"
#: core/models.py:769
#: core/models.py:744
msgid "Retrieve room details"
msgstr "Retrieve room details"
#: core/models.py:770
#: core/models.py:745
#, fuzzy
#| msgid "updated on"
msgid "Update rooms"
msgstr "Update rooms"
#: core/models.py:771
#: core/models.py:746
msgid "Delete rooms"
msgstr "Delete rooms"
#: core/models.py:784
#: core/models.py:759
msgid "Application name"
msgstr "Application name"
#: core/models.py:785
#: core/models.py:760
msgid "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."
msgstr "Hashed on Save. Copy it now if this is a new secret."
#: core/models.py:806
#: core/models.py:781
msgid "Application"
msgstr "Application"
#: core/models.py:807
#: core/models.py:782
msgid "Applications"
msgstr "Applications"
#: core/models.py:830
#: core/models.py:805
msgid "Enter a valid domain"
msgstr "Enter a valid domain"
#: core/models.py:833
#: core/models.py:808
msgid "Domain"
msgstr "Domain"
#: core/models.py:834
#: core/models.py:809
msgid "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"
msgstr "Application domain"
#: core/models.py:847
#: core/models.py:822
msgid "Application domains"
msgstr "Application domains"
#: core/models.py:865
#: core/models.py:840
#, fuzzy
#| msgid "Recording"
msgid "Pending"
msgstr "Pending"
#: core/models.py:866
msgid "Analyzing"
msgstr ""
#: core/models.py:873
#: core/models.py:848
msgid "Ready"
msgstr "Ready"
#: core/models.py:879
#: core/models.py:854
msgid "Background image"
msgstr "Background image"
#: core/models.py:891
#: core/models.py:866
msgid "title"
msgstr "title"
#: core/models.py:915
#: core/models.py:890
msgid "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"
msgstr "File"
#: core/models.py:921
#: core/models.py:896
msgid "Files"
msgstr "Files"
#: core/models.py:1041
#: core/models.py:1000
#, fuzzy
#| msgid "This user is already in this recording."
msgid "This file is already hard deleted."
msgstr "This file is already hard deleted."
#: core/models.py:1051
#: core/models.py:1010
msgid "To hard delete a file, it must first be soft deleted."
msgstr "To hard delete a file, it must first be soft deleted."
#: core/recording/event/notification.py:123
#: core/recording/event/notification.py:116
msgid "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
#, python-brace-format
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:"
#: 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 "
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 "
"team at %(support_email)s. "
#: core/templates/mail/text/screen_recording.txt:13
#, 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
#: meet/settings.py:223
msgid "English"
msgstr "English"
#: meet/settings.py:229
#: meet/settings.py:224
msgid "French"
msgstr "French"
#: meet/settings.py:230
#: meet/settings.py:225
msgid "Dutch"
msgstr "Dutch"
#: meet/settings.py:231
#: meet/settings.py:226
msgid "German"
msgstr "German"
+101 -158
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\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"
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,93 +17,71 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:67
#: core/admin.py:29
msgid "Personal info"
msgstr "Informations personnelles"
#: core/admin.py:80
#: core/admin.py:42
msgid "Permissions"
msgstr "Permissions"
#: core/admin.py:92
#: core/admin.py:54
msgid "Important dates"
msgstr "Dates importantes"
#: core/admin.py:206
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
#: core/admin.py:132 core/admin.py:275
msgid "No owner"
msgstr "Pas de propriétaire"
#: core/admin.py:303 core/admin.py:446
#: core/admin.py:135 core/admin.py:278
msgid "Multiple owners"
msgstr "Plusieurs propriétaires"
#: core/admin.py:316
#: core/admin.py:148
msgid "Resend notification to external service"
msgstr "Renvoyer la notification au service externe"
#: core/admin.py:339
#: core/admin.py:171
#, python-format
msgid "Failed to notify for recording %(id)s"
msgstr "Échec de la notification pour lenregistrement %(id)s"
#: core/admin.py:347
#: core/admin.py:179
#, python-format
msgid "Failed to notify for recording %(id)s: %(error)s"
msgstr "Échec de la notification pour lenregistrement %(id)s : %(error)s"
#: core/admin.py:355
#: core/admin.py:187
#, python-format
msgid "Successfully sent notifications for %(count)s recording(s)."
msgstr "Notifications envoyées avec succès pour %(count)s enregistrement(s)."
#: core/admin.py:363
#: core/admin.py:195
#, python-format
msgid "Skipped %(count)s expired recording(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'"
msgstr "Marquer les enregistrements sélectionnés comme « Échec darrêt »"
#: core/admin.py:386
#: core/admin.py:218
#, python-format
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
msgstr ""
"%(count)s enregistrement(s) marqué(s) avec succès comme « Échec darrêt »."
#: core/admin.py:394
#: core/admin.py:226
#, fuzzy, python-format
#| msgid "Skipped %(count)s expired recording(s)."
msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "%(count)s enregistrement(s) avec un statut inéligible ignoré(s)."
#: core/admin.py:510
#: core/admin.py:342
msgid "No scopes"
msgstr "Aucun scopes"
#: core/admin.py:512
#: core/admin.py:344
msgid "Scopes"
msgstr "Scopes"
@@ -111,17 +89,17 @@ msgstr "Scopes"
msgid "Creator is me"
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."
msgstr ""
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
"des accès."
#: core/api/serializers.py:534
#: core/api/serializers.py:509
msgid "This file extension is not allowed."
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."
msgstr "Vous avez atteint le nombre maximum de fichiers de ce type"
@@ -169,61 +147,53 @@ msgstr "Échec à l'arrêt"
msgid "Notification succeeded"
msgstr "Notification réussie"
#: core/models.py:65
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
#: core/models.py:89
msgid "SCREEN_RECORDING"
msgstr "ENREGISTREMENT_ÉCRAN"
#: core/models.py:97
#: core/models.py:90
msgid "TRANSCRIPT"
msgstr "TRANSCRIPTION"
#: core/models.py:103
#: core/models.py:96
msgid "Public Access"
msgstr "Accès public"
#: core/models.py:104
#: core/models.py:97
msgid "Trusted Access"
msgstr "Accès de confiance"
#: core/models.py:105
#: core/models.py:98
msgid "Restricted Access"
msgstr "Accès restreint"
#: core/models.py:117
#: core/models.py:110
msgid "id"
msgstr "id"
#: core/models.py:118
#: core/models.py:111
msgid "primary key for the record as UUID"
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
#: core/models.py:124
#: core/models.py:117
msgid "created on"
msgstr "créé le"
#: core/models.py:125
#: core/models.py:118
msgid "date and time at which a record was created"
msgstr "date et heure auxquelles un enregistrement a été créé"
#: core/models.py:130
#: core/models.py:123
msgid "updated on"
msgstr "mis à jour le"
#: core/models.py:131
#: core/models.py:124
msgid "date and time at which a record was last updated"
msgstr ""
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
"fois"
#: core/models.py:151
#: core/models.py:144
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -231,11 +201,11 @@ msgstr ""
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des "
"chiffres et les caractères @/./+/-/_."
#: core/models.py:157
#: core/models.py:150
msgid "sub"
msgstr "sub"
#: core/models.py:159
#: core/models.py:152
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -243,55 +213,55 @@ msgstr ""
"Optionnel pour les utilisateurs en attente ; requis lors de l'activation du "
"compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement."
#: core/models.py:168
#: core/models.py:161
msgid "identity email address"
msgstr "adresse e-mail d'identité"
#: core/models.py:173
#: core/models.py:166
msgid "admin email address"
msgstr "adresse e-mail d'administrateur"
#: core/models.py:175
#: core/models.py:168
msgid "full name"
msgstr "nom complet"
#: core/models.py:177
#: core/models.py:170
msgid "short name"
msgstr "nom court"
#: core/models.py:183
#: core/models.py:176
msgid "language"
msgstr "langue"
#: core/models.py:184
#: core/models.py:177
msgid "The language in which the user wants to see the interface."
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
#: core/models.py:190
#: core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: core/models.py:193
#: core/models.py:186
msgid "device"
msgstr "appareil"
#: core/models.py:195
#: core/models.py:188
msgid "Whether the user is a device or a real user."
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: core/models.py:198
#: core/models.py:191
msgid "staff status"
msgstr "statut du personnel"
#: core/models.py:200
#: core/models.py:193
msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: core/models.py:203
#: core/models.py:196
msgid "active"
msgstr "actif"
#: core/models.py:206
#: core/models.py:199
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -299,65 +269,65 @@ msgstr ""
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
"au lieu de supprimer des comptes."
#: core/models.py:219
#: core/models.py:212
msgid "user"
msgstr "utilisateur"
#: core/models.py:220
#: core/models.py:213
msgid "users"
msgstr "utilisateurs"
#: core/models.py:286
#: core/models.py:272
msgid "Resource"
msgstr "Ressource"
#: core/models.py:287
#: core/models.py:273
msgid "Resources"
msgstr "Ressources"
#: core/models.py:345
#: core/models.py:331
msgid "Resource access"
msgstr "Accès aux ressources"
#: core/models.py:346
#: core/models.py:332
msgid "Resource accesses"
msgstr "Accès aux ressources"
#: core/models.py:352
#: core/models.py:338
msgid "Resource access with this User and Resource already exists."
msgstr ""
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
#: core/models.py:409
#: core/models.py:394
msgid "Visio room configuration"
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."
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
#: core/models.py:417
#: core/models.py:402
msgid "Room PIN code"
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."
msgstr ""
"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"
msgstr "Salle"
#: core/models.py:425
#: core/models.py:410
msgid "Rooms"
msgstr "Salles"
#: core/models.py:589
#: core/models.py:574
msgid "Worker ID"
msgstr "ID du Worker"
#: core/models.py:591
#: core/models.py:576
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -365,174 +335,153 @@ msgstr ""
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
"conservé même lorsque le Worker s'arrête, permettant un suivi facile."
#: core/models.py:599
#: core/models.py:584
msgid "Recording mode"
msgstr "Mode d'enregistrement"
#: core/models.py:600
#: core/models.py:585
msgid "Defines the mode of recording being called."
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"
msgstr "Options d'enregistrement"
#: core/models.py:613
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
#: core/models.py:597
msgid "Recording"
msgstr "Enregistrement"
#: core/models.py:621
#: core/models.py:598
msgid "Recordings"
msgstr "Enregistrements"
#: core/models.py:731
#: core/models.py:706
msgid "Recording/user relation"
msgstr "Relation enregistrement/utilisateur"
#: core/models.py:732
#: core/models.py:707
msgid "Recording/user relations"
msgstr "Relations enregistrement/utilisateur"
#: core/models.py:738
#: core/models.py:713
msgid "This user is already in this recording."
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."
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."
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"
msgstr "Créer des salles"
#: core/models.py:768
#: core/models.py:743
msgid "List rooms"
msgstr "Lister les salles"
#: core/models.py:769
#: core/models.py:744
msgid "Retrieve room details"
msgstr "Afficher les détails dune salle"
#: core/models.py:770
#: core/models.py:745
msgid "Update rooms"
msgstr "Mettre à jour les salles"
#: core/models.py:771
#: core/models.py:746
msgid "Delete rooms"
msgstr "Supprimer les salles"
#: core/models.py:784
#: core/models.py:759
msgid "Application name"
msgstr "Nom de lapplication"
#: core/models.py:785
#: core/models.py:760
msgid "Descriptive name for this 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."
msgstr ""
"Haché lors de lenregistrement. Copiez-le maintenant sil sagit dun "
"nouveau secret."
#: core/models.py:806
#: core/models.py:781
msgid "Application"
msgstr "Application"
#: core/models.py:807
#: core/models.py:782
msgid "Applications"
msgstr "Applications"
#: core/models.py:830
#: core/models.py:805
msgid "Enter a valid domain"
msgstr "Saisissez un domaine valide"
#: core/models.py:833
#: core/models.py:808
msgid "Domain"
msgstr "Domaine"
#: core/models.py:834
#: core/models.py:809
msgid "Email domain this application can act on behalf of."
msgstr "Domaine de messagerie au nom duquel cette application peut agir."
#: core/models.py:846
#: core/models.py:821
msgid "Application domain"
msgstr "Domaine dapplication"
#: core/models.py:847
#: core/models.py:822
msgid "Application domains"
msgstr "Domaines dapplication"
#: core/models.py:865
#: core/models.py:840
msgid "Pending"
msgstr "En attente"
#: core/models.py:866
msgid "Analyzing"
msgstr ""
#: core/models.py:873
#: core/models.py:848
msgid "Ready"
msgstr "Prêt"
#: core/models.py:879
#: core/models.py:854
msgid "Background image"
msgstr "Image de fond"
#: core/models.py:891
#: core/models.py:866
msgid "title"
msgstr "Titre"
#: core/models.py:915
#: core/models.py:890
msgid "Malware detection info when the analysis status is unsafe."
msgstr ""
"Information concernant la détection de Malware cand le statut n'est pas sain"
#: core/models.py:920
#: core/models.py:895
msgid "File"
msgstr "Fichier"
#: core/models.py:921
#: core/models.py:896
msgid "Files"
msgstr "Fichiers"
#: core/models.py:1041
#: core/models.py:1000
#, fuzzy
#| msgid "This user is already in this recording."
msgid "This file is already hard deleted."
msgstr "Ce fichier a été supprimé."
#: core/models.py:1051
#: core/models.py:1010
msgid "To hard delete a file, it must first be soft deleted."
msgstr ""
"Pour supprimer définitivement un fichier il doit d'abord avoir été marqué "
"comme supprimé (soft delete)"
#: core/recording/event/notification.py:123
#: core/recording/event/notification.py:116
msgid "Your recording is ready"
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
#, python-brace-format
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 :"
#: 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 "
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 "
"équipe d'assistance à %(support_email)s. "
#: core/templates/mail/text/screen_recording.txt:13
#, 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
#: meet/settings.py:223
msgid "English"
msgstr "Anglais"
#: meet/settings.py:229
#: meet/settings.py:224
msgid "French"
msgstr "Français"
#: meet/settings.py:230
#: meet/settings.py:225
msgid "Dutch"
msgstr "Néerlandais"
#: meet/settings.py:231
#: meet/settings.py:226
msgid "German"
msgstr "Allemand"
+101 -159
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\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"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -17,92 +17,70 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:67
#: core/admin.py:29
msgid "Personal info"
msgstr "Persoonlijke informatie"
#: core/admin.py:80
#: core/admin.py:42
msgid "Permissions"
msgstr "Rechten"
#: core/admin.py:92
#: core/admin.py:54
msgid "Important dates"
msgstr "Belangrijke datums"
#: core/admin.py:206
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
#: core/admin.py:132 core/admin.py:275
msgid "No owner"
msgstr "Geen eigenaar"
#: core/admin.py:303 core/admin.py:446
#: core/admin.py:135 core/admin.py:278
msgid "Multiple owners"
msgstr "Meerdere eigenaren"
#: core/admin.py:316
#: core/admin.py:148
msgid "Resend notification to external service"
msgstr "Melding opnieuw verzenden naar externe dienst"
#: core/admin.py:339
#: core/admin.py:171
#, python-format
msgid "Failed to notify for recording %(id)s"
msgstr "Melding voor opname %(id)s mislukt"
#: core/admin.py:347
#: core/admin.py:179
#, python-format
msgid "Failed to notify for recording %(id)s: %(error)s"
msgstr "Melding voor opname %(id)s mislukt: %(error)s"
#: core/admin.py:355
#: core/admin.py:187
#, python-format
msgid "Successfully sent notifications for %(count)s recording(s)."
msgstr "Meldingen succesvol verzonden voor %(count)s opname(n)."
#: core/admin.py:363
#: core/admin.py:195
#, python-format
msgid "Skipped %(count)s expired recording(s)."
msgstr "%(count)s verlopen opname(n) overgeslagen."
#: core/admin.py:368
#: core/admin.py:200
msgid "Mark selected recordings as 'Failed to Stop'"
msgstr "Geselecteerde opnames markeren als 'Mislukt bij stoppen'"
#: core/admin.py:386
#: core/admin.py:218
#, python-format
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
msgstr "%(count)s opname(s) succesvol gemarkeerd als 'Mislukt bij stoppen'."
#: core/admin.py:394
#: core/admin.py:226
#, fuzzy, python-format
#| msgid "Skipped %(count)s expired recording(s)."
msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "%(count)s opname(s) met een niet-toegestane status overgeslagen."
#: core/admin.py:510
#: core/admin.py:342
msgid "No scopes"
msgstr "Geen scopes"
#: core/admin.py:512
#: core/admin.py:344
msgid "Scopes"
msgstr "Scopes"
@@ -110,16 +88,16 @@ msgstr "Scopes"
msgid "Creator is me"
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."
msgstr ""
"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."
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."
msgstr "Het maximale aantal bestanden voor dit type is bereikt."
@@ -167,59 +145,51 @@ msgstr "Stoppen mislukt"
msgid "Notification succeeded"
msgstr "Notificatie geslaagd"
#: core/models.py:65
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
#: core/models.py:89
msgid "SCREEN_RECORDING"
msgstr "SCHERM_OPNAME"
#: core/models.py:97
#: core/models.py:90
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:103
#: core/models.py:96
msgid "Public Access"
msgstr "Openbare toegang"
#: core/models.py:104
#: core/models.py:97
msgid "Trusted Access"
msgstr "Vertrouwde toegang"
#: core/models.py:105
#: core/models.py:98
msgid "Restricted Access"
msgstr "Beperkte toegang"
#: core/models.py:117
#: core/models.py:110
msgid "id"
msgstr "id"
#: core/models.py:118
#: core/models.py:111
msgid "primary key for the record as UUID"
msgstr "primaire sleutel voor het record als UUID"
#: core/models.py:124
#: core/models.py:117
msgid "created on"
msgstr "aangemaakt op"
#: core/models.py:125
#: core/models.py:118
msgid "date and time at which a record was created"
msgstr "datum en tijd waarop een record werd aangemaakt"
#: core/models.py:130
#: core/models.py:123
msgid "updated on"
msgstr "bijgewerkt op"
#: core/models.py:131
#: core/models.py:124
msgid "date and time at which a record was last updated"
msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt"
#: core/models.py:151
#: core/models.py:144
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -227,11 +197,11 @@ msgstr ""
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
"_ tekens bevatten."
#: core/models.py:157
#: core/models.py:150
msgid "sub"
msgstr "sub"
#: core/models.py:159
#: core/models.py:152
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -239,55 +209,55 @@ msgstr ""
"Optioneel voor gebruikers in afwachting; vereist bij accountactivering. "
"Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan."
#: core/models.py:168
#: core/models.py:161
msgid "identity email address"
msgstr "identiteit e-mailadres"
#: core/models.py:173
#: core/models.py:166
msgid "admin email address"
msgstr "beheerder e-mailadres"
#: core/models.py:175
#: core/models.py:168
msgid "full name"
msgstr "volledige naam"
#: core/models.py:177
#: core/models.py:170
msgid "short name"
msgstr "korte naam"
#: core/models.py:183
#: core/models.py:176
msgid "language"
msgstr "taal"
#: core/models.py:184
#: core/models.py:177
msgid "The language in which the user wants to see the interface."
msgstr "De taal waarin de gebruiker de interface wil zien."
#: core/models.py:190
#: core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
#: core/models.py:193
#: core/models.py:186
msgid "device"
msgstr "apparaat"
#: core/models.py:195
#: core/models.py:188
msgid "Whether the user is a device or a real user."
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
#: core/models.py:198
#: core/models.py:191
msgid "staff status"
msgstr "personeelsstatus"
#: core/models.py:200
#: core/models.py:193
msgid "Whether the user can log into this admin site."
msgstr "Of de gebruiker kan inloggen op deze beheersite."
#: core/models.py:203
#: core/models.py:196
msgid "active"
msgstr "actief"
#: core/models.py:206
#: core/models.py:199
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -295,64 +265,64 @@ msgstr ""
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
"plaats van accounts te verwijderen."
#: core/models.py:219
#: core/models.py:212
msgid "user"
msgstr "gebruiker"
#: core/models.py:220
#: core/models.py:213
msgid "users"
msgstr "gebruikers"
#: core/models.py:286
#: core/models.py:272
msgid "Resource"
msgstr "Bron"
#: core/models.py:287
#: core/models.py:273
msgid "Resources"
msgstr "Bronnen"
#: core/models.py:345
#: core/models.py:331
msgid "Resource access"
msgstr "Brontoegang"
#: core/models.py:346
#: core/models.py:332
msgid "Resource accesses"
msgstr "Brontoegangsrechten"
#: core/models.py:352
#: core/models.py:338
msgid "Resource access with this User and Resource already exists."
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
#: core/models.py:409
#: core/models.py:394
msgid "Visio room configuration"
msgstr "Visio-ruimteconfiguratie"
#: core/models.py:410
#: core/models.py:395
msgid "Values for Visio parameters to configure the room."
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
#: core/models.py:417
#: core/models.py:402
msgid "Room PIN code"
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."
msgstr ""
"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"
msgstr "Ruimte"
#: core/models.py:425
#: core/models.py:410
msgid "Rooms"
msgstr "Ruimtes"
#: core/models.py:589
#: core/models.py:574
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:591
#: core/models.py:576
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -360,152 +330,140 @@ msgstr ""
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
#: core/models.py:599
#: core/models.py:584
msgid "Recording mode"
msgstr "Opnamemodus"
#: core/models.py:600
#: core/models.py:585
msgid "Defines the mode of recording being called."
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"
msgstr "Opnameopties"
#: core/models.py:613
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
#: core/models.py:597
msgid "Recording"
msgstr "Opname"
#: core/models.py:621
#: core/models.py:598
msgid "Recordings"
msgstr "Opnames"
#: core/models.py:731
#: core/models.py:706
msgid "Recording/user relation"
msgstr "Opname/gebruiker-relatie"
#: core/models.py:732
#: core/models.py:707
msgid "Recording/user relations"
msgstr "Opname/gebruiker-relaties"
#: core/models.py:738
#: core/models.py:713
msgid "This user is already in this recording."
msgstr "Deze gebruiker is al in deze opname."
#: core/models.py:744
#: core/models.py:719
msgid "This team is already in this recording."
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."
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
#: core/models.py:767
#: core/models.py:742
msgid "Create rooms"
msgstr "Ruimtes aanmaken"
#: core/models.py:768
#: core/models.py:743
msgid "List rooms"
msgstr "Ruimtes weergeven"
#: core/models.py:769
#: core/models.py:744
msgid "Retrieve room details"
msgstr "Details van een ruimte ophalen"
#: core/models.py:770
#: core/models.py:745
msgid "Update rooms"
msgstr "Ruimtes bijwerken"
#: core/models.py:771
#: core/models.py:746
msgid "Delete rooms"
msgstr "Ruimtes verwijderen"
#: core/models.py:784
#: core/models.py:759
msgid "Application name"
msgstr "Naam van de applicatie"
#: core/models.py:785
#: core/models.py:760
msgid "Descriptive name for this application."
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."
msgstr ""
"Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
#: core/models.py:806
#: core/models.py:781
msgid "Application"
msgstr "Applicatie"
#: core/models.py:807
#: core/models.py:782
msgid "Applications"
msgstr "Applicaties"
#: core/models.py:830
#: core/models.py:805
msgid "Enter a valid domain"
msgstr "Voer een geldig domein in"
#: core/models.py:833
#: core/models.py:808
msgid "Domain"
msgstr "Domein"
#: core/models.py:834
#: core/models.py:809
msgid "Email domain this application can act on behalf of."
msgstr "E-maildomein namens welke deze applicatie kan handelen."
#: core/models.py:846
#: core/models.py:821
msgid "Application domain"
msgstr "Applicatiedomein"
#: core/models.py:847
#: core/models.py:822
msgid "Application domains"
msgstr "Applicatiedomeinen"
#: core/models.py:865
#: core/models.py:840
msgid "Pending"
msgstr "In afwachting"
#: core/models.py:866
msgid "Analyzing"
msgstr ""
#: core/models.py:873
#: core/models.py:848
msgid "Ready"
msgstr "Klaar"
#: core/models.py:879
#: core/models.py:854
msgid "Background image"
msgstr "Achtergrondafbeelding"
#: core/models.py:891
#: core/models.py:866
msgid "title"
msgstr "Titel"
#: core/models.py:915
#: core/models.py:890
msgid "Malware detection info when the analysis status is unsafe."
msgstr "Informatie over malwaredetectie wanneer de analysestatus onveilig is."
#: core/models.py:920
#: core/models.py:895
msgid "File"
msgstr "Bestand"
#: core/models.py:921
#: core/models.py:896
msgid "Files"
msgstr "Bestanden"
#: core/models.py:1041
#: core/models.py:1000
msgid "This file is already hard deleted."
msgstr "Dit bestand is al definitief verwijderd."
#: core/models.py:1051
#: core/models.py:1010
#, 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."
@@ -513,20 +471,10 @@ msgstr ""
"Om een bestand definitief te verwijderen, moet het eerst zacht verwijderd "
"zijn."
#: core/recording/event/notification.py:123
#: core/recording/event/notification.py:116
msgid "Your recording is ready"
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
#, python-brace-format
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:"
#: 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 "
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 "
"team via %(support_email)s. "
#: core/templates/mail/text/screen_recording.txt:13
#, 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
#: meet/settings.py:223
msgid "English"
msgstr "Engels"
#: meet/settings.py:229
#: meet/settings.py:224
msgid "French"
msgstr "Frans"
#: meet/settings.py:230
#: meet/settings.py:225
msgid "Dutch"
msgstr "Nederlands"
#: meet/settings.py:231
#: meet/settings.py:226
msgid "German"
msgstr "Duits"
+3 -28
View File
@@ -323,7 +323,8 @@ class Base(Configuration):
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": (
"core.authentication.backends.SessionAuthenticationWith401",
"mozilla_django_oidc.contrib.drf.OIDCAuthentication",
"rest_framework.authentication.SessionAuthentication",
),
"DEFAULT_PARSER_CLASSES": [
"rest_framework.parsers.JSONParser",
@@ -748,30 +749,12 @@ class Base(Configuration):
environ_prefix=None,
)
SUMMARY_SERVICE_VERSION = values.PositiveIntegerValue(
1, environ_name="SUMMARY_SERVICE_VERSION", environ_prefix=None
)
SUMMARY_SERVICE_ENDPOINT = values.Value(
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
)
SUMMARY_SERVICE_API_TOKEN = SecretFileValue(
None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None
)
SUMMARY_SERVICE_WEBHOOK_API_TOKEN = SecretFileValue(
None, environ_name="SUMMARY_SERVICE_WEBHOOK_API_TOKEN", environ_prefix=None
)
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(
None, environ_name="SCREEN_RECORDING_BASE_URL", environ_prefix=None
)
@@ -779,14 +762,6 @@ class Base(Configuration):
None, environ_name="RECORDING_DOWNLOAD_BASE_URL", environ_prefix=None
)
# Analytics
ANALYTICS_BACKEND = values.Value(
None, environ_name="ANALYTICS_BACKEND", environ_prefix=None
)
ANALYTICS_BACKEND_SETTINGS = values.DictValue(
{}, environ_name="ANALYTICS_BACKEND_SETTINGS", environ_prefix=None
)
# Marketing and communication settings
SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue(
False, # When enabled, new users are automatically added to mailing list.
@@ -1132,7 +1107,7 @@ class Base(Configuration):
if cls.SENTRY_DSN is not None:
sentry_sdk.init(
dsn=cls.SENTRY_DSN,
environment=cls.__name__.lower(), # build, test, development, production
environment=cls.__name__.lower(),
release=get_release(),
integrations=[DjangoIntegration()],
)
+14 -15
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project]
name = "meet"
version = "1.23.0"
version = "1.21.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -24,23 +24,23 @@ keywords = ["Django", "Contacts", "Templates", "RBAC"]
license = "MIT"
requires-python = ">=3.13"
dependencies = [
"boto3==1.43.36",
"boto3==1.43.14",
"Brotli==1.2.0",
"brevo-python==1.2.0",
"celery[redis]==5.6.3",
"dj-database-url==3.1.2",
"django-configurations==2.5.1",
"django-cors-headers==4.9.0",
"django-countries==9.0.0",
"django-countries==8.2.0",
"django-filter==25.2",
"django-lasuite[all]==0.0.27",
"django-lasuite[all]==0.0.26",
"django-parler==2.4",
"redis==5.2.1",
"django-redis==7.0.0",
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
"django-timezone-field>=5.1",
"django-pydantic-field==0.5.4",
"django==5.2.14",
"django==5.2.15",
"djangorestframework==3.17.1",
"drf_spectacular==0.29.0",
"dockerflow==2026.3.4",
@@ -50,20 +50,19 @@ dependencies = [
"jsonschema==4.26.0",
"markdown==3.10.2",
"nested-multipart-parser==1.6.0",
"posthog==7.16.1",
"psycopg[binary]==3.3.4",
"pydantic==2.13.4",
"PyJWT==2.13.0",
"python-frontmatter==1.3.0",
"python-magic==0.4.27",
"requests==2.34.2",
"sentry-sdk==2.63.0",
"sentry-sdk==2.60.0",
"whitenoise==6.12.0",
"mozilla-django-oidc==5.0.2",
"livekit-api==1.1.1",
"aiohttp==3.14.1",
"livekit-api==1.1.0",
"aiohttp==3.14.0",
"urllib3==2.7.0",
"phonenumbers==9.0.33",
"phonenumbers==9.0.31",
]
[project.urls]
@@ -75,20 +74,20 @@ dependencies = [
[dependency-groups]
dev = [
"django-extensions==4.1",
"drf-spectacular-sidecar==2026.6.1",
"drf-spectacular-sidecar==2026.5.1",
"freezegun==1.5.5",
"ipdb==0.13.13",
"ipython==9.14.1",
"ipython==9.13.0",
"pyfakefs==6.2.0",
"pylint-django==2.7.0",
"pylint<4.0.0",
"pytest-cov==7.1.0",
"pytest-django==4.12.0",
"pytest==9.1.1",
"pytest==9.0.3",
"pytest-icdiff==0.9",
"pytest-xdist==3.8.0",
"responses==0.26.1",
"ruff==0.15.19",
"ruff==0.15.14",
"types-requests==2.33.0.20260518",
]
+521 -547
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,4 +1,4 @@
FROM node:22-alpine AS frontend-deps
FROM node:20-alpine AS frontend-deps
USER node
@@ -39,7 +39,7 @@ ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
RUN npm run build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
FROM nginxinc/nginx-unprivileged:alpine3.23 AS frontend-production
USER root
+1698 -43
View File
File diff suppressed because it is too large Load Diff
+7 -9
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "1.23.0",
"version": "1.21.0",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -23,26 +23,25 @@
"@livekit/components-styles": "1.2.0",
"@livekit/track-processors": "0.7.2",
"@pandacss/preset-panda": "1.11.3",
"@react-aria/toast": "3.0.10",
"@react-types/overlays": "3.10.0",
"@remixicon/react": "4.9.0",
"@tanstack/react-query": "5.101.0",
"@tanstack/react-query": "5.100.14",
"@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.1.2",
"derive-valtio": "0.2.0",
"hoofd": "1.7.3",
"humanize-duration": "3.33.2",
"i18next": "26.3.1",
"i18next": "26.2.0",
"i18next-browser-languagedetector": "8.2.1",
"i18next-parser": "9.4.0",
"i18next-resources-to-backend": "1.2.1",
"livekit-client": "2.19.2",
"posthog-js": "1.387.0",
"livekit-client": "2.19.0",
"posthog-js": "1.382.0",
"react": "18.3.1",
"react-aria": "3.49.0",
"react-aria-components": "1.18.0",
"react-aria-components": "1.14.0",
"react-dom": "18.3.1",
"react-i18next": "17.0.8",
"react-stately": "3.47.0",
"use-sound": "5.0.0",
"valtio": "2.3.2",
"wouter": "3.10.0"
@@ -53,7 +52,6 @@
"@tanstack/eslint-plugin-query": "5.100.14",
"@tanstack/react-query-devtools": "5.100.14",
"@types/humanize-duration": "3.27.4",
"@types/node": "24.12.4",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "6.0.2",
-1
View File
@@ -9,7 +9,6 @@ export interface ApiConfig {
analytics?: {
id: string
host: string
flags_api_host?: string
}
support?: {
id: string
@@ -28,16 +28,10 @@ export const terminateAnalyticsSession = async () => {
export type useAnalyticsProps = {
id?: string
host?: string
flags_api_host?: string
isDisabled?: boolean
}
export const useAnalytics = ({
id,
host,
flags_api_host,
isDisabled,
}: useAnalyticsProps) => {
export const useAnalytics = ({ id, host, isDisabled }: useAnalyticsProps) => {
const [location] = useLocation()
const { user } = useUser()
@@ -45,13 +39,9 @@ export const useAnalytics = ({
if (!id || !host || isDisabled) return
getPosthog().then((ph) => {
if (ph.__loaded) return
ph.init(id, {
api_host: host,
flags_api_host: flags_api_host,
person_profiles: 'always',
})
ph.init(id, { api_host: host, person_profiles: 'always' })
})
}, [id, host, flags_api_host, isDisabled])
}, [id, host, isDisabled])
useEffect(() => {
if (!user) return
@@ -1,10 +1,10 @@
import { useToast } from 'react-aria'
import { useToast } from '@react-aria/toast'
import { Button } from '@/primitives'
import { RiCloseLine } from '@remixicon/react'
import { useRef } from 'react'
import type { ToastState } from 'react-stately'
import type { ToastState } from '@react-stately/toast'
import type { ToastData } from './ToastProvider'
import type { QueuedToast } from 'react-stately'
import type { QueuedToast } from '@react-stately/toast'
import { StyledToastContainer } from './StyledToastContainer'
import { StyledToast } from './StyledToast'
@@ -1,4 +1,4 @@
import { useToast } from 'react-aria'
import { useToast } from '@react-aria/toast'
import { useMemo, useRef } from 'react'
import type { ToastProps } from './Toast'
@@ -1,4 +1,4 @@
import { useToast } from 'react-aria'
import { useToast } from '@react-aria/toast'
import { useRef } from 'react'
import { type ToastProps } from './Toast'
@@ -1,4 +1,4 @@
import { useToast } from 'react-aria'
import { useToast } from '@react-aria/toast'
import { useRef } from 'react'
import { Button as RACButton } from 'react-aria-components'
import { Track } from 'livekit-client'
@@ -1,4 +1,4 @@
import { useToast } from 'react-aria'
import { useToast } from '@react-aria/toast'
import { useRef } from 'react'
import { type ToastProps } from './Toast'
@@ -1,4 +1,4 @@
import { useToast } from 'react-aria'
import { useToast } from '@react-aria/toast'
import { useEffect, useRef } from 'react'
import { type ToastProps } from './Toast'
@@ -1,4 +1,4 @@
import { useToast } from 'react-aria'
import { useToast } from '@react-aria/toast'
import { useRef } from 'react'
import { type ToastProps } from './Toast'
@@ -1,4 +1,4 @@
import { useToast } from 'react-aria'
import { useToast } from '@react-aria/toast'
import { useMemo, useRef } from 'react'
import { type ToastProps } from './Toast'
@@ -1,5 +1,5 @@
/* eslint-disable react-refresh/only-export-components */
import { ToastQueue, useToastQueue } from 'react-stately'
import { ToastQueue, useToastQueue } from '@react-stately/toast'
import { ToastRegion } from './ToastRegion'
import { Participant } from 'livekit-client'
import type { NotificationType } from '../NotificationType'
@@ -1,4 +1,4 @@
import { useToast } from 'react-aria'
import { useToast } from '@react-aria/toast'
import { useRef } from 'react'
import { type ToastProps } from './Toast'
@@ -1,4 +1,4 @@
import { useToast } from 'react-aria'
import { useToast } from '@react-aria/toast'
import { useMemo, useRef } from 'react'
import { type ToastProps } from './Toast'
@@ -1,4 +1,4 @@
import { useToast } from 'react-aria'
import { useToast } from '@react-aria/toast'
import { useMemo, useRef } from 'react'
import { Text } from '@/primitives'
@@ -1,5 +1,5 @@
import { AriaToastRegionProps, useToastRegion } from 'react-aria'
import type { QueuedToast, ToastState } from 'react-stately'
import { AriaToastRegionProps, useToastRegion } from '@react-aria/toast'
import type { QueuedToast, ToastState } from '@react-stately/toast'
import { Toast } from './Toast'
import { useRef } from 'react'
import { NotificationType } from '../NotificationType'
@@ -1,6 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { RiMoreFill } from '@remixicon/react'
import { FocusScope } from 'react-aria'
import { FocusScope } from '@react-aria/focus'
import { Box, Button } from '@/primitives'
import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
@@ -1,19 +1,12 @@
import { useTranslation } from 'react-i18next'
import { styled } from '@/styled-system/jsx'
import { useLocalParticipant } from '@livekit/components-react'
export const StageFrame = ({ children }: { children: React.ReactNode }) => {
const { t } = useTranslation('rooms', {
keyPrefix: 'pictureInPicture',
})
const { localParticipant } = useLocalParticipant()
return (
<Container
role="region"
aria-label={t('stage')}
{...(!localParticipant.isScreenShareEnabled ? { inert: '' } : {})}
>
<Container role="region" aria-label={t('stage')} {...{ inert: '' }}>
{children}
</Container>
)
@@ -12,7 +12,7 @@ import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
import { Button } from '@/primitives'
import { ReactionsKeyboardNavigation } from './ReactionsKeyboardNavigation'
import { FocusScope } from 'react-aria'
import { FocusScope } from '@react-aria/focus'
import { CONTROL_BAR_REGION_ID } from '@/features/layout/components/ControlBarRegion'
import { REACTIONS_TOGGLE_ID } from '../ReactionsToggle'
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'
import { useFocusManager } from 'react-aria'
import { useFocusManager } from '@react-aria/focus'
import { getFirstControlBarFocusable } from '@/utils/dom'
import { REACTIONS_TOOLBAR_ID } from '../../constants'
import { useReactionsToolbar } from '../../hooks/useReactionsToolbar'
@@ -57,9 +57,7 @@ const RecordingDownload = () => {
const isSaved =
data?.status === RecordingStatus.Saved ||
data?.status === RecordingStatus.NotificationSucceed ||
data?.status === RecordingStatus.FailedToStop ||
data?.status === RecordingStatus.ExternalProcessFailed ||
data?.status === RecordingStatus.ExternalProcessSuccessful
data?.status === RecordingStatus.FailedToStop
const pageTitle = useMemo(() => {
if (isError) return `${APP_TITLE} - ${t('error.title')}`
@@ -93,8 +91,6 @@ const RecordingDownload = () => {
}
if (
data.status !== RecordingStatus.ExternalProcessFailed &&
data.status !== RecordingStatus.ExternalProcessSuccessful &&
data.status !== RecordingStatus.Saved &&
data.status !== RecordingStatus.NotificationSucceed &&
data.status !== RecordingStatus.FailedToStop
@@ -12,6 +12,4 @@ export enum RecordingStatus {
FailedToStart = 'failedToStart',
FailedToStop = 'failedToStop',
NotificationSucceed = 'notification_succeeded',
ExternalProcessSuccessful = 'external_process_successful',
ExternalProcessFailed = 'external_process_failed',
}
@@ -1,6 +1,6 @@
import { css } from '@/styled-system/css'
import { Button, Text } from '@/primitives'
import { useCallback, useMemo, useRef } from 'react'
import { useMemo, useRef } from 'react'
import { screenSharePreferenceStore } from '@/stores/screenSharePreferences'
import { useSnapshot } from 'valtio'
import { useLocalParticipant } from '@livekit/components-react'
@@ -61,19 +61,9 @@ export const FullScreenShareWarning = ({
await localParticipant.setScreenShareEnabled(false, {}, {})
}
const handleDismissWarning = useCallback(() => {
const handleDismissWarning = () => {
screenSharePreferenceStore.enabled = false
}, [])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation()
handleDismissWarning()
}
},
[handleDismissWarning]
)
}
if (!shouldShowWarning) return null
@@ -108,7 +98,6 @@ export const FullScreenShareWarning = ({
})}
>
<Text
role="alert"
style={{
color: 'white',
flexBasis: '55%',
@@ -128,14 +117,11 @@ export const FullScreenShareWarning = ({
})}
>
<Button
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus
variant="tertiary"
size="sm"
style={{
height: 'fit-content',
}}
onKeyDown={handleKeyDown}
onPress={async () => {
await handleStopScreenShare()
}}
@@ -148,7 +134,6 @@ export const FullScreenShareWarning = ({
style={{
height: 'fit-content',
}}
onKeyDown={handleKeyDown}
onPress={() => handleDismissWarning()}
>
{t('ignore')}
@@ -1,20 +1,23 @@
import { Button } from '@/primitives'
import { useTranslation } from 'react-i18next'
import type { Participant } from 'livekit-client'
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
import { useMuteParticipants } from '@/features/rooms/api/muteParticipants'
import { RiMicOffLine } from '@remixicon/react'
import { css } from '@/styled-system/css'
import { AdminOrOwnerOnly } from '@/features/rooms/components/AdminOrOwnerOnly'
type MuteEveryoneButtonProps = {
participants: Array<Participant>
}
const MuteEveryoneButtonInner = ({ participants }: MuteEveryoneButtonProps) => {
export const MuteEveryoneButton = ({
participants,
}: MuteEveryoneButtonProps) => {
const { muteParticipants } = useMuteParticipants()
const { t } = useTranslation('rooms')
if (!participants.length) return null
const isAdminOrOwner = useIsAdminOrOwner()
if (!isAdminOrOwner || !participants.length) return null
return (
<Button
@@ -33,13 +36,3 @@ const MuteEveryoneButtonInner = ({ participants }: MuteEveryoneButtonProps) => {
</Button>
)
}
export const MuteEveryoneButton = ({
participants,
}: MuteEveryoneButtonProps) => {
return (
<AdminOrOwnerOnly>
<MuteEveryoneButtonInner participants={participants} />
</AdminOrOwnerOnly>
)
}
+4 -23
View File
@@ -28,25 +28,7 @@ _summaryEnvVars: &summaryEnvVars
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
AWS_S3_SECURE_ACCESS: False
AUTHORIZED_TENANTS: >
[
{
"id": "dictaphone",
"api_key": "dictaphone_token",
"webhook_url": "http://dictaphone-backend.dictaphone.svc.cluster.local/api/v1.0/ai-jobs/webhook/",
"webhook_api_key": "token_summary",
"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
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"}]'
WHISPERX_API_KEY:
secretKeyRef:
name: secret-dev
@@ -187,9 +169,8 @@ backend:
RECORDING_ENABLE: True
RECORDING_STORAGE_EVENT_ENABLE: True
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_WEBHOOK_API_TOKEN: webhook-password
RECORDING_DOWNLOAD_BASE_URL: https://meet.127.0.0.1.nip.io/recording
migrate:
@@ -310,7 +291,7 @@ celeryTranscribe:
- "--pool=solo"
- "--loglevel=info"
- "-Q"
- "transcribe-queue-v2"
- "transcribe-queue,transcribe-queue-v2"
celerySummarize:
replicas: 1
@@ -327,7 +308,7 @@ celerySummarize:
- "--pool=solo"
- "--loglevel=info"
- "-Q"
- "summarize-queue-v2"
- "summarize-queue,summarize-queue-v2"
celerySummaryBackend:
replicas: 1
+1 -1
View File
@@ -1,4 +1,4 @@
apiVersion: v2
type: application
name: meet
version: 0.0.26
version: 0.0.25
+2 -2
View File
@@ -72,11 +72,11 @@ spec:
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ include "meet.posthog.fullname" $ }}-proxy
name: {{ include "meet.posthog.fullname" . }}-proxy
port:
number: {{ $.Values.posthog.service.port }}
{{- else }}
serviceName: {{ include "meet.posthog.fullname" $ }}-proxy
serviceName: {{ include "meet.posthog.fullname" . }}-proxy
servicePort: {{ $.Values.posthog.service.port }}
{{- end }}
{{- with $.Values.posthog.assetsService.customBackends }}
@@ -72,11 +72,11 @@ spec:
backend:
{{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }}
service:
name: {{ include "meet.posthog.fullname" $ }}-assets-proxy
name: {{ include "meet.posthog.fullname" . }}-assets-proxy
port:
number: {{ $.Values.posthog.assetsService.service.port }}
{{- else }}
serviceName: {{ include "meet.posthog.fullname" $ }}-assets-proxy
serviceName: {{ include "meet.posthog.fullname" . }}-assets-proxy
servicePort: {{ $.Values.posthog.assetsService.service.port }}
{{- end }}
{{- with $.Values.posthog.assetsService.customBackends }}
-4
View File
@@ -1170,8 +1170,6 @@ agentMetadata:
## @extra agentMetadata.envVars.FROM_SECRET.secretKeyRef.key Key within a Secret when configuring env vars from a Secret
## @extra agentMetadata.envVars.DEEPGRAM_STT_MODEL Deepgram model to use for speech-to-text (default: nova-3)
## @extra agentMetadata.envVars.DEEPGRAM_STT_LANGUAGE Language code for transcription or 'multi' for automatic multilingual support with real-time code-switching (default: multi, supports: en, es, fr, de, hi, ru, pt, ja, it, nl)
## @extra agentMetadata.envVars.SENTRY_DSN Sentry DSN to enable error reporting (disabled when unset)
## @extra agentMetadata.envVars.SENTRY_ENVIRONMENT Environment name reported to Sentry (e.g. production, staging)
## @skip agentMetadata.envVars
envVars:
<<: *commonEnvVars
@@ -1251,8 +1249,6 @@ agentSubtitles:
## @extra agentSubtitles.envVars.FROM_SECRET.secretKeyRef.key Key within a Secret when configuring env vars from a Secret
## @extra agentSubtitles.envVars.DEEPGRAM_STT_MODEL Deepgram model to use for speech-to-text (default: nova-3)
## @extra agentSubtitles.envVars.DEEPGRAM_STT_LANGUAGE Language code for transcription or 'multi' for automatic multilingual support with real-time code-switching (default: multi, supports: en, es, fr, de, hi, ru, pt, ja, it, nl)
## @extra agentSubtitles.envVars.SENTRY_DSN Sentry DSN to enable error reporting (disabled when unset)
## @extra agentSubtitles.envVars.SENTRY_ENVIRONMENT Environment name reported to Sentry (e.g. production, staging)
## @skip agentSubtitles.envVars
envVars:
<<: *commonEnvVars
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "1.23.0",
"version": "1.21.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "1.23.0",
"version": "1.21.0",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.5.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "1.23.0",
"version": "1.21.0",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sdk",
"version": "1.23.0",
"version": "1.21.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "1.23.0",
"version": "1.21.0",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "1.23.0",
"version": "1.21.0",
"author": "",
"license": "ISC",
"description": "",
+2 -2
View File
@@ -1,9 +1,9 @@
FROM python:3.13-alpine3.24 AS base
FROM python:3.13-alpine3.23 AS base
# Install ffmpeg for audio/video processing (format conversion, extraction, compression)
# See summary/core/file_service.py for usage.
RUN apk add --no-cache "ffmpeg=8.1.2-r0"
RUN apk add --no-cache "ffmpeg=8.0.1-r1"
FROM base AS builder
+7 -7
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "1.23.0"
version = "1.21.0"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",
@@ -10,17 +10,17 @@ dependencies = [
"celery==5.6.3",
"redis==5.2.1",
"minio==7.2.20",
"openai==2.44.0",
"posthog==7.20.5",
"openai==2.38.0",
"posthog==7.15.4",
"requests==2.34.2",
"sentry-sdk[fastapi, celery]==2.63.0",
"langfuse==4.11.0"
"sentry-sdk[fastapi, celery]==2.60.0",
"langfuse==4.6.1"
]
[project.optional-dependencies]
dev = [
"ruff==0.15.19",
"pytest==9.1.1",
"ruff==0.15.14",
"pytest==9.0.3",
"responses>=0.25.8",
]
+4 -1
View File
@@ -2,8 +2,11 @@
from fastapi import APIRouter, Depends
from summary.api.route import tasks_v2
from summary.api.route import tasks, tasks_v2
from summary.core.security import verify_tenant_api_key
api_router_v1 = APIRouter(dependencies=[Depends(verify_tenant_api_key)])
api_router_v1.include_router(tasks.router_tasks_v1, tags=["tasks"])
api_router_v2 = APIRouter(dependencies=[Depends(verify_tenant_api_key)])
api_router_v2.include_router(tasks_v2.router_tasks_v2, tags=["tasks"])
+79
View File
@@ -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}
+7 -63
View File
@@ -1,19 +1,15 @@
"""API routes related to application tasks (V2 / tenant friendly)."""
import logging
from datetime import datetime, timezone
from celery.result import AsyncResult
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi import APIRouter, Depends, HTTPException, Request
from summary.core.analytics import get_analytics
from summary.core.celery_worker import (
celery,
process_audio_transcribe_v2_task,
summarize_v2_task,
)
from summary.core.config import AuthorizedTenant, get_settings
from summary.core.models import SummarizeTaskApiRequest, TranscribeTaskApiRequest
from summary.core.config import AuthorizedTenant
from summary.core.models import SummarizeTaskV2Request, TranscribeTaskV2Request
from summary.core.security import verify_tenant_api_key_v2
from summary.core.shared_models import (
SummarizeWebhookFailurePayload,
@@ -24,51 +20,17 @@ from summary.core.shared_models import (
TranscribeWebhookSuccessPayload,
)
logger = logging.getLogger(__name__)
router_tasks_v2 = APIRouter()
analytics = get_analytics()
settings = get_settings()
@router_tasks_v2.post("/async-jobs/transcribe")
async def create_transcribe_task_v2(
request: TranscribeTaskApiRequest,
request: TranscribeTaskV2Request,
request_tenant: AuthorizedTenant = Depends(verify_tenant_api_key_v2),
):
"""Create a transcription task."""
if (
request.push_to_docs_config is not None
and not request_tenant.allowed_push_to_docs
):
logger.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(
args=[
{
**request.model_dump(),
"tenant_id": request_tenant.id,
"received_at": datetime.now(timezone.utc),
}
]
)
properties = {}
if request.user_email:
properties["$set"] = {"email": request.user_email}
# 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_transcript_request,
request.user_sub,
properties=properties,
args=[{**request.model_dump(), "tenant_id": request_tenant.id}]
)
return TranscribeWebhookPendingPayload(job_id=task.id).model_dump()
@@ -76,32 +38,14 @@ async def create_transcribe_task_v2(
@router_tasks_v2.post("/async-jobs/summarize")
async def create_summarize_task_v2(
request: SummarizeTaskApiRequest,
request: SummarizeTaskV2Request,
request_tenant: AuthorizedTenant = Depends(verify_tenant_api_key_v2),
):
"""Create a summarization task."""
task = summarize_v2_task.apply_async(
args=[
{
**request.model_dump(),
"tenant_id": request_tenant.id,
"received_at": datetime.now(timezone.utc),
}
]
args=[{**request.model_dump(), "tenant_id": request_tenant.id}]
)
# We track the request, this also properly initializes the user in the
# analytics system, so that later feature flags work properly
properties = {}
if request.user_email:
properties["$set"] = {"email": request.user_email}
analytics.capture(
settings.posthog_summary_request,
request.user_sub,
properties=properties,
)
return SummarizeWebhookPendingPayload(job_id=task.id).model_dump()
+10 -18
View File
@@ -4,14 +4,12 @@ import json
import time
from collections import Counter
from functools import lru_cache
from urllib.parse import urlsplit, urlunsplit
import redis
from celery.utils.log import get_task_logger
from posthog import Posthog
from summary.core.config import get_settings
from summary.core.models import SummarizeTaskJob, TranscribeTaskJob
logger = get_task_logger(__name__)
settings = get_settings()
@@ -109,31 +107,25 @@ class MetadataManager:
"""Check if task_id exists in tasks metadata cache."""
return self._redis.exists(self._get_redis_key(task_id))
def create(self, task_id: str, task_payload: TranscribeTaskJob | SummarizeTaskJob):
def create(self, task_id, task_args):
"""Create initial metadata entry for a new task."""
if self._is_disabled or self.has_task_id(task_id):
return
# Positional args mirror process_audio_transcribe_summarize_v2 signature:
# owner_id, recording_filename, metadata_filename, email, sub, received_at, ...
_, filename, _, email, _, received_at, *_ = task_args
start_time = time.time()
initial_metadata = {
"start_time": start_time,
"asr_model": settings.whisperx_asr_model,
"retries": 0,
"sub": task_payload.user_sub,
# avoid None in redis, it shouldn't happen anyway in prod
"email": task_payload.user_email or "",
"tenant_id": task_payload.tenant_id,
"queuing_time": round(start_time - task_payload.received_at.timestamp(), 2),
"filename": filename,
"email": email,
"queuing_time": round(start_time - received_at, 2),
}
if isinstance(task_payload, TranscribeTaskJob):
parts = urlsplit(task_payload.cloud_storage_url)
clean_url = urlunsplit((parts.scheme, parts.netloc, parts.path, "", ""))
initial_metadata["source_url"] = clean_url
initial_metadata["asr_model"] = settings.whisperx_asr_model
elif isinstance(task_payload, SummarizeTaskJob):
initial_metadata["content_length"] = len(task_payload.content)
initial_metadata["llm_model"] = settings.llm_model
self._save_metadata(task_id, initial_metadata)
def retry(self, task_id):
@@ -218,6 +210,6 @@ class MetadataManager:
self.clear(task_id)
try:
self._analytics.capture(event_name, metadata.get("sub"), metadata)
self._analytics.capture(event_name, metadata.get("email"), metadata)
except AnalyticsException:
logger.exception("Failed to capture analytics event")
+212 -178
View File
@@ -4,7 +4,7 @@
import json
import time
from datetime import datetime, timezone
from datetime import datetime
from typing import Any
from urllib.parse import urljoin
@@ -17,15 +17,16 @@ from requests import exceptions
from summary.core.analytics import MetadataManager, get_analytics
from summary.core.config import get_settings
from summary.core.docs_service import create_document_in_lasuite_docs
from summary.core.file_service import FileService, FileServiceException, TranscribeError
from summary.core.file_service import (
FileService,
FileServiceException,
TranscribeError,
)
from summary.core.llm_service import LLMException, LLMObservability, LLMService
from summary.core.locales import get_locale
from summary.core.models import (
PushToDocsBaseConfig,
RecordingMetadata,
SummarizeTaskJob,
TranscribeTaskJob,
SummarizeTaskV2Payload,
TranscribeTaskV2Payload,
)
from summary.core.prompt import (
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.webhook_service import (
call_webhook_v2,
submit_content,
)
settings = get_settings()
@@ -84,22 +86,29 @@ file_service = FileService()
def transcribe_audio(
*,
task_id: str,
recording_filename: str | None = None,
language: str,
cloud_storage_url: str,
cloud_storage_url=None,
raises: bool = False,
):
"""Transcribe an audio file using WhisperX.
Downloads the audio from a cloud storage URL, sends it to
Downloads the audio from MinIO or a cloud storage URL, sends it to
WhisperX for transcription, and tracks metadata throughout the process.
Returns the transcription object, or None if the file could not be retrieved.
"""
if bool(recording_filename) == bool(cloud_storage_url):
raise ValueError(
"Either filename or cloud_storage_url must be provided, but not both."
)
logger.info("Initiating WhisperX client")
# Transcription
try:
with file_service.prepare_audio_file(
remote_object_key=recording_filename,
cloud_storage_url=cloud_storage_url,
) as (audio_file, metadata):
metadata_manager.track(task_id, {"audio_length": metadata["duration"]})
@@ -188,7 +197,11 @@ def transcribe_audio(
cloud_storage_url.split("?", 1)[0] if cloud_storage_url else None
)
logger.exception(
("Unexpected error while preparing file %s "),
(
"Unexpected error while preparing file | filename: %s "
"| cloud_storage_url: %s"
),
recording_filename,
redacted_cloud_storage_url,
)
return None
@@ -198,34 +211,44 @@ def transcribe_audio(
def resolve_speaker_identities_and_apply_to(
*, transcription: WhisperXResponse, recording_metadata: RecordingMetadata, task_id
) -> WhisperXResponse:
transcription, recording_start_at, recording_end_at, metadata_filename, task_id
):
"""Assign users to detected speakers and rewrite the transcriptions.
Args:
transcription: output of meet-whisperx after transcription and diarization
recording_metadata: Metadata of the recording
recording_start_at: sourced from LiveKit FileInfo via the egress_ended webhook
recording_end_at: sourced from LiveKit FileInfo via the egress_ended webhook
metadata_filename: name of metadata file containing VAD information in S3
task_id: current task id, for logging purposes
"""
recording_start_dt = (
datetime.fromisoformat(recording_start_at) if recording_start_at else None
)
recording_end_dt = (
datetime.fromisoformat(recording_end_at) if recording_end_at else None
)
logger.debug(
"recording_start_dt: %s ; recording_end_dt: %s",
recording_metadata.started_at,
recording_metadata.ended_at,
recording_start_dt,
recording_end_dt,
)
if (recording_start_dt is None) or (recording_end_dt is None):
logger.debug("Skipping resolve_speaker_identities")
return transcription
logger.debug("Running resolve_speaker_identities")
try:
metadata = file_service.read_cloud_storage_json(
recording_metadata.cloud_storage_url
)
metadata = file_service.read_json(metadata_filename)
speaker_mapping = resolve_speaker_identities(
metadata,
transcription.model_dump(),
recording_metadata.started_at,
recording_metadata.ended_at,
transcription,
recording_start_dt,
recording_end_dt,
)
new_transcription = speaker_mapping.apply_to(transcription.model_dump())
return WhisperXResponse.model_validate(new_transcription)
return new_transcription
except FileServiceException as exc:
logger.error(
@@ -250,9 +273,12 @@ def format_transcript(
transcription,
context_language: str | None,
language: str,
room: str | None,
recording_datetime: str | None,
owner_timezone: str | None,
download_link: str | None,
form_link: str | None,
) -> str:
) -> tuple[str, str]:
"""Format a transcription into readable content with a title.
Resolves the locale from context_language / language, then uses
@@ -265,6 +291,9 @@ def format_transcript(
return formatter.format(
transcription,
room=room,
recording_datetime=recording_datetime,
owner_timezone=owner_timezone,
download_link=download_link,
form_link=form_link,
)
@@ -288,8 +317,138 @@ def format_actions(llm_output: dict) -> str:
return ""
@celery.task(
bind=True,
autoretry_for=[exceptions.HTTPError],
max_retries=settings.celery_max_retries,
queue=settings.transcribe_queue,
)
def process_audio_transcribe_summarize_v2(
self,
owner_id: str,
recording_filename: str,
metadata_filename: str | None,
email: str,
sub: str,
received_at: float,
room: str | None,
owner_timezone: str | None,
language: str | None,
download_link: str | None,
context_language: str | None = None,
recording_start_at: str | None = None,
recording_end_at: str | None = None,
):
"""Process an audio file by transcribing it and generating a summary.
This Celery task orchestrates:
1. Audio transcription via WhisperX
2. Transcript formatting
3. Webhook submission
4. Conditional summarization queuing
Args:
self: Celery task instance (passed on with bind=True)
owner_id: Unique identifier of the recording owner.
recording_filename: Name of the audio file in MinIO storage.
metadata_filename: Name of the audio file in MinIO storage.
email: Email address of the recording owner.
sub: OIDC subject identifier of the recording owner.
received_at: Unix timestamp when the recording was received.
room: room name where the recording took place.
owner_timezone: IANA timezone of the recording owner (e.g. "Europe/Paris").
language: ISO 639-1 language code for transcription.
download_link: URL to download the original recording.
context_language: ISO 639-1 language code of the meeting summary context text.
recording_start_at: ISO 8601 timestamp of when file recording actually started
(from LiveKit FileInfo.started_at via the egress_ended webhook).
recording_end_at: ISO 8601 timestamp of when file recording ended
(from LiveKit FileInfo.ended_at via the egress_ended webhook).
"""
logger.info(
"Notification received | Owner: %s | Room: %s",
owner_id,
room,
)
task_id = self.request.id
# Transcribe the audio
transcription = transcribe_audio(
task_id=task_id, recording_filename=recording_filename, language=language
)
if transcription is None:
return
# Assign speakers and rewrite transcription/diarization output
if settings.is_resolve_speaker_identities_enabled and (
metadata_filename is not None
):
transcription = resolve_speaker_identities_and_apply_to(
transcription,
recording_start_at,
recording_end_at,
metadata_filename,
task_id,
)
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(
*, distinct_id: str, transcript: str, session_id: str
*, owner_id: str, transcript: str, session_id: str
) -> str:
"""Generate a summary from the provided transcription text.
@@ -300,11 +459,11 @@ def summarize_transcription_internals(
"""
logger.info(
"Starting summarization task | Owner: %s",
distinct_id,
owner_id,
)
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
@@ -315,7 +474,7 @@ def summarize_transcription_internals(
llm_observability = LLMObservability(
user_has_tracing_consent=user_has_tracing_consent,
session_id=session_id,
user_id=distinct_id,
user_id=owner_id,
)
llm_service = LLMService(llm_observability=llm_observability)
@@ -367,52 +526,34 @@ def summarize_transcription_internals(
return summary
@celery.task(
bind=True,
autoretry_for=[LLMException, Exception],
max_retries=settings.celery_max_retries,
queue=settings.summarize_queue,
)
def summarize_transcription(
self, owner_id: str, transcript: str, email: str, sub: str, title: str
):
"""Generate a summary from the provided transcription text.
This Celery task performs the following operations:
1. Run summary internals
2. Sends the final summary via webhook.
"""
summary = summarize_transcription_internals(
owner_id=owner_id, transcript=transcript, session_id=self.request.id
)
summary_title = settings.summary_title_template.format(title=title)
submit_content(summary, summary_title, email, sub)
##################################################################################
# Tasks v2
##################################################################################
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"
else:
return True
logger.info("Auto create summary is not requested: %s", reason)
return False
@celery.task(
max_retries=3,
queue=settings.call_webhook_queue_v2,
@@ -449,7 +590,7 @@ def process_audio_transcribe_v2_task(
self: Celery task instance (passed on with bind=True)
payload: Serialized dictionary of TranscribeSummarizeTaskCreationV2
"""
payload = TranscribeTaskJob.model_validate(payload)
payload = TranscribeTaskV2Payload.model_validate(payload)
logger.info(
"Transcribing for object received | Owner: %s",
payload.user_sub,
@@ -476,60 +617,6 @@ def process_audio_transcribe_v2_task(
)
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(
transcript=transcription_res,
job_id=job_id,
@@ -542,30 +629,9 @@ def process_audio_transcribe_v2_task(
call_webhook_v2_task.apply_async(
args=[success_payload.model_dump(), payload.tenant_id]
)
metadata_manager.capture(job_id, settings.posthog_transcript_success)
return success_payload.model_dump()
@signals.task_prerun.connect(sender=process_audio_transcribe_v2_task)
def task_started_transcript(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_transcript(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_transcript(task_id, exception=None, **kwargs):
"""Signal handler called when task execution fails permanently."""
metadata_manager.capture(task_id, settings.posthog_transcript_failure)
@signals.task_failure.connect(sender=process_audio_transcribe_v2_task)
def handle_transcribe_v2_failed(
sender,
@@ -621,26 +687,15 @@ def summarize_v2_task(
1. Run summary internals
2. Sends the final summary via webhook.
"""
payload = SummarizeTaskJob.model_validate(payload)
payload = SummarizeTaskV2Payload.model_validate(payload)
summary = summarize_transcription_internals(
distinct_id=payload.user_sub,
owner_id=payload.user_sub,
transcript=payload.content,
session_id=self.request.id,
)
job_id = self.request.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(
job_id=job_id,
summary_data_url=file_service.get_summary_signed_url(job_id),
@@ -648,30 +703,9 @@ def summarize_v2_task(
call_webhook_v2_task.apply_async(
args=[success_payload.model_dump(), payload.tenant_id]
)
metadata_manager.capture(job_id, settings.posthog_summary_success)
return success_payload.model_dump()
@signals.task_prerun.connect(sender=summarize_v2_task)
def task_started_summary(task_id=None, task=None, args=None, **kwargs):
"""Signal handler called before task execution begins."""
if args:
metadata_manager.create(task_id, SummarizeTaskJob.model_validate(args[0]))
@signals.task_retry.connect(sender=summarize_v2_task)
def task_retry_handler_summary(request=None, reason=None, einfo=None, **kwargs):
"""Signal handler called when task execution retries."""
metadata_manager.retry(request.id)
@signals.task_failure.connect(sender=summarize_v2_task)
def task_failure_handler_summary(task_id, exception=None, **kwargs):
"""Signal handler called when task execution fails permanently."""
metadata_manager.capture(task_id, settings.posthog_summary_failure)
@signals.task_failure.connect(sender=summarize_v2_task)
def handle_summarize_v2_failed(
sender,
+55 -39
View File
@@ -1,8 +1,9 @@
"""Application configuration and settings."""
import logging
import os
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 pydantic import (
@@ -33,12 +34,9 @@ class AuthorizedTenant(BaseModel):
title="Webhook API Key",
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"
" and summaries to docs for this tenant.",
default=False,
)
V1_DEFAULT_TENANT_ID = "__deprecated_meet_tenant__"
class Settings(BaseSettings):
@@ -47,12 +45,14 @@ class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", frozen=True)
app_name: str = "summary"
app_api_v1_str: str = "/api/v1"
app_api_v2_str: str = "/api/v2"
# Authorized Tenants
# Using env variables to store authorized tenants for now
# to avoid any other external dependency (DB)
authorized_tenants: tuple[AuthorizedTenant, ...] = Field(min_length=1)
authorized_tenants: tuple[AuthorizedTenant, ...] = Field(default_factory=tuple)
v1_tenant_id: str = V1_DEFAULT_TENANT_ID
# Audio recordings
recording_max_duration: Optional[int] = None
@@ -72,6 +72,8 @@ class Settings(BaseSettings):
celery_result_backend: str = "redis://redis/0"
celery_max_retries: int = 1
transcribe_queue: str = "transcribe-queue"
summarize_queue: str = "summarize-queue"
# v2 tasks
transcribe_queue_v2: str = "transcribe-queue-v2"
summarize_queue_v2: str = "summarize-queue-v2"
@@ -88,7 +90,7 @@ class Settings(BaseSettings):
# AI-related settings
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"
# ISO 639-1 language code (e.g., "en", "fr", "es")
whisperx_default_language: Optional[str] = None
@@ -110,17 +112,16 @@ class Settings(BaseSettings):
webhook_max_retries: int = 2
webhook_status_forcelist: List[int] = [502, 503, 504]
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
is_summary_enabled: bool = True
# 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"
)
transcription_satisfaction_form_base_url: Optional[str] = None
# Sentry
sentry_is_enabled: bool = False
@@ -130,12 +131,8 @@ class Settings(BaseSettings):
posthog_enabled: bool = False
posthog_api_key: Optional[str] = None
posthog_api_host: Optional[str] = "https://eu.i.posthog.com"
posthog_transcript_request: str = "transcript-request"
posthog_transcript_failure: str = "transcript-failure"
posthog_transcript_success: str = "transcript-success"
posthog_summary_request: str = "summary-request"
posthog_summary_failure: str = "summary-failure"
posthog_summary_success: str = "summary-success"
posthog_event_failure: str = "transcript-failure"
posthog_event_success: str = "transcript-success"
# Langfuse (LLM Observability)
langfuse_enabled: bool = False
@@ -148,9 +145,38 @@ class Settings(BaseSettings):
task_tracker_redis_url: str = "redis://redis/0"
task_tracker_prefix: str = "task_metadata:"
@model_validator(mode="before")
@classmethod
def legacy_default_tenant_config(cls, data: Any) -> Any:
"""Migrate the legacy default tenant configuration."""
if isinstance(data, dict):
api_key = os.getenv("APP_API_TOKEN")
webhook_api_key = os.getenv("WEBHOOK_API_TOKEN")
webhook_url = os.getenv("WEBHOOK_URL")
if api_key and webhook_api_key and webhook_url:
logger.warning(
"Deprecated legacy app configuration detected, "
"please use only the new 'authorized_tenants' field instead."
)
authorized_tenants = list(data.get("authorized_tenants", []))
authorized_tenants.append(
AuthorizedTenant(
id=V1_DEFAULT_TENANT_ID,
api_key=SecretStr(api_key),
webhook_url=webhook_url,
webhook_api_key=SecretStr(webhook_api_key),
)
)
data["authorized_tenants"] = tuple(authorized_tenants)
return data
@model_validator(mode="after")
def validate_authorized_tenants(self):
"""Validate authorized tenants configuration."""
if len(self.authorized_tenants) == 0:
raise ValueError("No authorized tenants configured")
tenant_ids = {tenant.id for tenant in self.authorized_tenants}
if len(tenant_ids) != len(self.authorized_tenants):
@@ -165,23 +191,13 @@ class Settings(BaseSettings):
return self
@model_validator(mode="after")
def validate_docs_config(self):
"""Validate docs integration configuration."""
if not self.is_lasuite_docs_integration_enabled:
return self
def validate_default_v1_tenant(self):
"""Validate default v1 tenant configuration."""
if not any(
tenant.id == self.v1_tenant_id for tenant in self.authorized_tenants
):
raise ValueError("v1 tenant is not configured in authorized tenants")
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
@cached_property
-94
View File
@@ -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)")
+86 -20
View File
@@ -14,6 +14,7 @@ from urllib.parse import urlparse
import requests
from minio import Minio
from minio.error import MinioException, S3Error
from summary.core.config import get_settings
from summary.core.shared_models import WhisperXResponse
@@ -141,7 +142,6 @@ class MediaInfo:
has_video: bool
audio_duration_seconds: float | None
audio_codec_name: str | None
has_bad_stream: bool = False
def get_media_info(local_path: Path) -> MediaInfo:
@@ -174,9 +174,8 @@ def get_media_info(local_path: Path) -> MediaInfo:
data = json.loads(result.stdout)
streams = data.get("streams", [])
has_audio = any(el.get("codec_type") == "audio" for el in streams)
has_video = any(el.get("codec_type") == "video" for el in streams)
has_bad_stream = any(el.get("codec_type", None) is None for el in streams)
has_audio = any(el["codec_type"] == "audio" for el in streams)
has_video = any(el["codec_type"] == "video" for el in streams)
audio_codec_name = next(
(
stream.get("codec_name")
@@ -194,11 +193,10 @@ def get_media_info(local_path: Path) -> MediaInfo:
has_video=has_video,
audio_duration_seconds=audio_duration_seconds,
audio_codec_name=audio_codec_name,
has_bad_stream=has_bad_stream,
)
def extract_audio_from_media(media_info: MediaInfo) -> Path:
def extract_audio_from_video(media_info: MediaInfo) -> Path:
"""Extracts the audio track from a video file and saves it as a separate audio file.
Based on the provided audio codec,
@@ -283,6 +281,50 @@ class FileService:
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:
"""Download file from a cloud storage URL to local temporary file."""
logger.info(
@@ -347,17 +389,33 @@ class FileService:
logger.error(error_msg)
raise MediaDurationTooLongError(error_msg)
def read_cloud_storage_json(self, cloud_storage_url: str) -> dict:
"""Read and parse a JSON file from a url."""
logger.info("Reading JSON: %s", cloud_storage_url)
res = requests.get(cloud_storage_url, timeout=(10, 100))
res.raise_for_status()
return res.json()
def read_json(self, object_name: str) -> dict:
"""Read and parse a JSON file from MinIO storage."""
logger.info("Reading JSON: %s", object_name)
if not object_name:
raise ValueError("Invalid object_name")
response = None
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
def prepare_audio_file(
self,
cloud_storage_url: str,
remote_object_key: str | None = None,
cloud_storage_url: str | None = None,
):
"""Download and prepare audio file for processing.
@@ -370,7 +428,20 @@ class FileService:
file_handle = None
try:
downloaded_path = self._download_from_cloud_storage_url(cloud_storage_url)
if bool(remote_object_key) == bool(cloud_storage_url):
raise ValueError(
(
"Exactly one of 'remote_object_key' or "
"'cloud_storage_url' must be provided."
)
)
if cloud_storage_url:
downloaded_path = self._download_from_cloud_storage_url(
cloud_storage_url
)
else:
downloaded_path = self._download_from_minio(remote_object_key)
media_info = get_media_info(downloaded_path)
@@ -380,12 +451,7 @@ class FileService:
if media_info.has_video:
logger.info("Video file detected, extracting audio...")
processed_path = extract_audio_from_media(media_info)
# Bad streams may cause transcription issues on WhisperX,
# So we extract the audio properly
elif media_info.has_bad_stream:
logger.info("Bad stream detected, extracting audio...")
processed_path = extract_audio_from_media(media_info)
processed_path = extract_audio_from_video(media_info)
else:
processed_path = downloaded_path
+4 -1
View File
@@ -30,5 +30,8 @@ Einige Punkte, die wir Ihnen empfehlen zu überprüfen:
"(externer Link)]({form_link})*\n"
),
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}'
),
)
+4 -1
View File
@@ -30,5 +30,8 @@ A few things we recommend you check:
"(external link)]({form_link})*\n"
),
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}'
),
)
+4 -1
View File
@@ -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"
),
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}'
),
)
+4 -1
View File
@@ -30,5 +30,8 @@ Een paar punten die wij u aanraden te controleren:
"(externe link)]({form_link})*\n"
),
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}'
),
)
+2 -1
View File
@@ -12,4 +12,5 @@ class LocaleStrings:
download_header_template: str
form_footer_template: str
hallucination_replacement_text: str
summary_title_template: str
document_default_title: str
document_title_template: str
+8 -81
View File
@@ -1,8 +1,6 @@
"""Models for the API & Celery tasks creation."""
from datetime import datetime
from pydantic import AwareDatetime, BaseModel, EmailStr, Field, field_validator
from pydantic import BaseModel, Field, field_validator
from summary.core.config import get_settings
from summary.core.types import Url
@@ -14,60 +12,10 @@ class SharedV2TaskCreation(BaseModel):
"""Model that holds basic information for task creation."""
user_sub: str = Field(title="User Sub", description="The user's sub.")
user_email: EmailStr | None = Field(
default=None,
title="User Email",
description="The user's email for analytics purposes.",
)
class RecordingMetadata(BaseModel):
"""Model for recording metadata."""
cloud_storage_url: Url = Field(
title="Cloud Storage URL",
description="The URL of the metadata file for speaker assignement.",
)
started_at: AwareDatetime = Field(title="Start time of the recording to transcribe")
ended_at: AwareDatetime = Field(title="End time of the recording to transcribe")
class PushToDocsBaseConfig(BaseModel):
"""Model containing information for pushing transcript and summaries to docs."""
user_email: 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)."""
class TranscribeTaskV2Request(SharedV2TaskCreation):
"""Model for creating a transcribe and summarize task (used for API request)."""
cloud_storage_url: Url = Field(
title="Cloud storage URL",
@@ -79,17 +27,7 @@ class TranscribeTaskApiRequest(SharedV2TaskCreation):
description="The language of the context text.",
)
language: str = Field(
title="Language", description="The language of the content to transcribe."
)
metadata: RecordingMetadata | None = Field(
title="Metadata",
description="The metadata for the transcribe task.",
default=None,
)
push_to_docs_config: PushToDocsTranscriptConfig | None = Field(
title="Push to Docs info",
description="If set, configuration for pushing to docs",
default=None,
title="Language", description="The language of the content to summarize."
)
@field_validator("language")
@@ -104,30 +42,19 @@ class TranscribeTaskApiRequest(SharedV2TaskCreation):
return v
class TranscribeTaskJob(TranscribeTaskApiRequest):
"""Model for creating a transcribe task (used for actual task creation)."""
class TranscribeTaskV2Payload(TranscribeTaskV2Request):
"""Model for creating a transcribe and summarize task (used for actual task creation).""" # noqa: E501
tenant_id: str = Field(title="Tenant ID", description="The ID of the tenant.")
received_at: datetime = Field(
title="Received At", description="The time the task was received."
)
class SummarizeTaskApiRequest(SharedV2TaskCreation):
class SummarizeTaskV2Request(SharedV2TaskCreation):
"""Model for creating a summarize task (used for API request)."""
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)."""
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",
"WebhookPayloads",
"WhisperXResponse",
"webhook_payload_adapter",
]
@@ -1,6 +1,9 @@
"""Transcript formatting into readable conversation format with speaker labels."""
import logging
from datetime import datetime
from typing import Tuple
from zoneinfo import ZoneInfo
from summary.core.config import get_settings
from summary.core.locales import LocaleStrings
@@ -35,13 +38,16 @@ class TranscriptFormatter:
return None
def format(
def format( # noqa: PLR0913
self,
transcription,
room: str | None = None,
recording_datetime: str | None = None,
owner_timezone: str | None = None,
download_link: str | None = None,
form_link: str | None = None,
) -> str:
"""Format transcription into the final document."""
) -> Tuple[str, str]:
"""Format transcription into the final document and its title."""
segments = self._get_segments(transcription)
if not segments:
@@ -53,7 +59,9 @@ class TranscriptFormatter:
if 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:
"""Remove hallucination patterns from content."""
@@ -96,3 +104,23 @@ class TranscriptFormatter:
footer = self._locale.form_footer_template.format(form_link=form_link)
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"),
)

Some files were not shown because too many files have changed in this diff Show More