Compare commits

..

2 Commits

Author SHA1 Message Date
leo f1e0bd7ac5 wip temp 2026-09-01 19:07:34 +02:00
leo 35d2bae0f0 ✨(backend) add per-recording encoding config to start-recording API
Add new options to query start-start recording API. A resolution
("540p", "720p", "1080p") and a profile ("talking_heads", "text", "mixed")
are resolved to provide a width, height, fps and bitrate which
are passed on to the encoder. Using profiles allows for some flexibility
on quality if necessary without changing front facing user config.

Co-authored-by: sarthakbahal <sarthakbahal.45@gmail.com>
2026-09-01 17:35:42 +02:00
177 changed files with 2477 additions and 7697 deletions
+2 -2
View File
@@ -252,13 +252,13 @@ jobs:
- name: Start MinIO - name: Start MinIO
run: | run: |
docker pull quay.io/minio/minio docker pull minio/minio
docker run -d --name minio \ docker run -d --name minio \
-p 9000:9000 \ -p 9000:9000 \
-e "MINIO_ACCESS_KEY=meet" \ -e "MINIO_ACCESS_KEY=meet" \
-e "MINIO_SECRET_KEY=password" \ -e "MINIO_SECRET_KEY=password" \
-v /data/media:/data \ -v /data/media:/data \
quay.io/minio/minio server --console-address :9001 /data minio/minio server --console-address :9001 /data
# Tool to wait for a service to be ready # Tool to wait for a service to be ready
- name: Install Dockerize - name: Install Dockerize
-3
View File
@@ -86,6 +86,3 @@ docker/livekit/rootCA.pem
# Frontend rollup-plugin-visualizer # Frontend rollup-plugin-visualizer
/src/frontend/rollup-plugin-visualizer/* /src/frontend/rollup-plugin-visualizer/*
# NixOS
.devenv
+5 -68
View File
@@ -10,76 +10,14 @@ and this project adheres to
### Added ### Added
- ✨(backend) make the LiveKit default video codec configurable - ✨(backend) add per-recording encoding quality presets to start-recording API
- 🔧(dev) add support for Bureautix workstations
- ✨(frontend) add screen share zoom controls #1498
### Changed
- 🔥(backend) remove unused API viewset and permission helpers
- 🔊(backend) pin the dockerflow logger level to WARNING
- 🚑️(summary) serve health endpoints with the dockerflow router
- ♻️(backend) serve the dockerflow views early in the middleware stack
- 📈(frontend) include LiveKit SIDs in the connection analytics event
- 🔇(backend) silence expected 401 warnings on /me
- 🔇(backend) silence noisy request summary info logs
- ⚡️(frontend) defer loading the Crisp script until idle
- ⬆️(frontend) upgrade react-i18next from 17.0.10 to 17.0.12
- ⬆️(frontend) upgrade posthog-js from 1.414.0 to 1.418.10
- ⬆️(addons) upgrade i18next from 26.3.6 to 26.4.0
- ⬆️(frontend) upgrade humanize-duration from 3.33.2 to 3.34.1
- ⬆️(addons) upgrade i18next from 26.4.0 to 26.4.1
- 🔖(helm) release chart 0.0.28
- ♻️(backend) decouple recording event handling from LiveKit egress statuses
### Fixed
- 🐛(helm) probe liveness on __lbheartbeat__ and readiness on __heartbeat__
- 🐛(helm) render periodSeconds and failureThreshold on probes
- 🐛(backend) report the app release to Sentry instead of "NA"
- 🐛(frontend) play the waiting room notification sound on every arrival
- 🐛(frontend) apply saved reception resolution when joining a meeting #1714
- 🐛(backend) acknowledge unknown LiveKit webhook events instead of 422
- 🔒️(backend) enforce display name setting on rename API
- 🔒️(backend) reject inactive users in resource server backend
- 🐛(frontend) fix file permissions in the Docker image
- 🚸(frontend) inform user that recording waits until a track is published
- 🔒(backend) upgrade base image to python:3.13.5-alpine3.24
- 🐛(backend) handle failed and aborted egresses
- 🩹(frontend) notify participants when a recording fails or is aborted
- 🔒️(frontend) fix HIGH CVE-2026-93990 in libexpat
## [1.31.0] - 2026-09-08
### Added
- ✨(frontend) add 1080p sending resolution option #1660
- ✨(backend) add Traefik support via configurable media-auth url header #1649
- ✨(backend) update a room's attributes from the external API
- 🔊(backend) log request duration in Gunicorn workers
- 📈(frontend) track missing lobby participant on accept/reject
- ✨(backend) sort waiting participants by their arrival time
### Changed
- ⬆️(dev) pin LiveKit server to v1.13.6
- 🔒(frontend) upgrade base image to 1.30.4-alpine3.24
### Fixed
- 🐛(backend) allow any printable ASCII characters in user sub field #1673
- 🐛(frontend) keep the sending resolution picked while the camera is off #1667
- 🐛(frontend) restore automatic lower-hand on speaking
- 🐛(frontend) center Avatar initials with a font-aware cap-height ratio
- 🐛(frontend) keep feedback buttons on one line for fr/es/en
- ⚡️(frontend) increase lobby polling interval on both sides
- ⚡️(frontend) add trailing slash on the /me endpoint call
- ⚡️(backend) refactor lobby storage to bound key lookups per room
- ⚡️(backend) refactor presence cache to bound key lookups per room
- 💄(frontend) position the login hint dynamically next to the button
## [1.30.0] - 2026-09-01 ## [1.30.0] - 2026-09-01
### Changed
- ♿️(frontend) close side panel with Escape key #1507
### Added ### Added
- ✨(agent) support Voxtral realtime as inference engine - ✨(agent) support Voxtral realtime as inference engine
@@ -95,7 +33,6 @@ and this project adheres to
- ⬆️(frontend) upgrade @fontsource/opendyslexic from 5.2.5 to 5.3.0 - ⬆️(frontend) upgrade @fontsource/opendyslexic from 5.2.5 to 5.3.0
- ⬆️(addons) upgrade core-js from 3.49.0 to 3.50.0 - ⬆️(addons) upgrade core-js from 3.49.0 to 3.50.0
- ♻️(backend) factorize s3 client creation in utils - ♻️(backend) factorize s3 client creation in utils
- ♿️(frontend) close side panel with Escape key #1507
### Fixed ### Fixed
+1 -1
View File
@@ -1,7 +1,7 @@
# Django Meet # Django Meet
# ---- base image to inherit from ---- # ---- base image to inherit from ----
FROM python:3.13.15-alpine3.24 AS base FROM python:3.13.5-alpine3.21 AS base
# Upgrade pip to its latest release to speed up dependencies installation # Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip RUN python -m pip install --upgrade pip
+1 -1
View File
@@ -292,7 +292,7 @@ shell: ## connect to database shell
# -- Database # -- Database
dbshell: ## connect to database shell dbshell: ## connect to database shell
@$(COMPOSE_EXEC_APP) python manage.py dbshell docker compose exec app-dev python manage.py dbshell
.PHONY: dbshell .PHONY: dbshell
resetdb: FLUSH_ARGS ?= resetdb: FLUSH_ARGS ?=
+86
View File
@@ -16,6 +16,92 @@ the following command inside your docker container:
## [Unreleased] ## [Unreleased]
### Recording encoding settings replaced by a resolution/profile model
The `RECORDING_ENCODING_*` settings introduced in v1.16.0 exposed raw encoder
values (width, height, framerate, bitrate). They are replaced by two named and configurable sets of
dimensions, a **resolution** (default: `540p`, `720p`, `1080p`) and a **profile**
(default: `talking_heads`, `text`, `mixed`, `full`), which are resolved to the width, height,
fps and video bitrate.
**The following environment variables are no longer read. If they are still set in
your deployment they are silently ignored, and your recordings will be encoded with
the new defaults instead of your tuned values.**
| Removed variable | Replaced by |
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
| `RECORDING_ENCODING_ENABLED` | Nothing. A default encoding is now always built (see below). **Not** `RECORDING_CUSTOM_ENCODING_ENABLED`, which gates a different feature. |
| `RECORDING_ENCODING_WIDTH` | The `width` of the entry selected by `RECORDING_ENCODING_DEFAULT_RESOLUTION` in `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`. |
| `RECORDING_ENCODING_HEIGHT` | The `height` of that same entry. |
| `RECORDING_ENCODING_FRAMERATE` | The `fps` of the profile selected by `RECORDING_ENCODING_DEFAULT_PROFILE` in `RECORDING_ENCODING_AVAILABLE_PROFILES`. |
| `RECORDING_ENCODING_VIDEO_BITRATE_KBPS` | That profile's `kbps`. |
`RECORDING_ENCODING_AUDIO_BITRATE_KBPS` and `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S`
are unchanged and keep their values.
#### If you never set `RECORDING_ENCODING_ENABLED=True`
No action is required. The shipped defaults (`RECORDING_ENCODING_DEFAULT_PROFILE=full`,
`RECORDING_ENCODING_DEFAULT_RESOLUTION=720p`) match LiveKit's built-in
`H264_720P_30` preset: 1280×720, 30 fps, 3000 kbps H.264 MAIN, 128 kbps AAC.
Note that these values are now sent explicitly as advanced `EncodingOptions`
rather than relying on LiveKit's preset, so `RECORDING_ENCODING_AUDIO_BITRATE_KBPS`
and `RECORDING_ENCODING_KEY_FRAME_INTERVAL_S` now apply to every recording. They
previously applied only when `RECORDING_ENCODING_ENABLED` was `True`.
To keep letting LiveKit pick the encoding instead, set either default to an empty
value:
```
RECORDING_ENCODING_DEFAULT_RESOLUTION=
RECORDING_ENCODING_DEFAULT_PROFILE=
```
#### If you had tuned `RECORDING_ENCODING_*` values
Translate your old values into a default resolution and a default profile. Declare your own resolution and/or profile. Both maps are read from the
environment as a single-line Python/JSON dict literal (parsed with
`ast.literal_eval`, so use double-quoted keys and no trailing commas, and do not
add outer quotes in `.env`-style files):
```bash
RECORDING_ENCODING_AVAILABLE_RESOLUTIONS={"540p": {"width": 960, "height": 540}, "720p": {"width": 1280, "height": 720}, "1080p": {"width": 1920, "height": 1080}}
RECORDING_ENCODING_AVAILABLE_PROFILES={"my_old_profile": {"fps": 15, "kbps": {"540p": 350, "720p": 600, "1080p": 1100}}}
RECORDING_ENCODING_DEFAULT_RESOLUTION=720p
RECORDING_ENCODING_DEFAULT_PROFILE=my_old_profile
```
Two constraints are validated at startup and may raise a `ValueError`:
- every profile in `RECORDING_ENCODING_AVAILABLE_PROFILES` must define a `kbps`
entry for **exactly** the keys of `RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`;
overriding one of the two maps usually means overriding both;
- `RECORDING_ENCODING_DEFAULT_RESOLUTION` and `RECORDING_ENCODING_DEFAULT_PROFILE`,
when non-empty, must be keys of their respective map.
#### Optional: per-recording encoding
`RECORDING_CUSTOM_ENCODING_ENABLED` (default `False`) toggles whether the
start-recording API accepts an `encoding` object
(`{"resolution": "720p", "profile": "talking_heads"}`, `profile` optional) that
overrides the default for a single recording. It does not enable or disable the
default encoding, which is built from the two `RECORDING_ENCODING_DEFAULT_*`
settings either way. Leaving it at `False` preserves the previous behaviour, where
every recording uses the server-side encoding: requests carrying
`options.encoding` are rejected with a `400` before the recording is created, so
nothing is persisted and no egress is started.
Before enabling it:
- clients can only pick keys you declared; there is no way to send a raw width or bitrate
- as of this implementation, the frontend never sends `encoding`
- `encoding` is accepted but ignored for `transcript` recordings, whose audio-only
egress has no video encoding to configure.
See [docs/features/recording.md](docs/features/recording.md#tuning-recording-encoding)
for the full setting reference, the shipped profile table and the tuning caveats.
## v1.30.0 ## v1.30.0
### Removing S3 storage-event webhooks for recordings ### Removing S3 storage-event webhooks for recordings
+2 -1
View File
@@ -5,7 +5,7 @@ set -eo pipefail
REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)" REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
UNSET_USER=0 UNSET_USER=0
export COMPOSE_FILE="${COMPOSE_FILE:-${REPO_DIR}/compose.yml}" COMPOSE_FILE="${REPO_DIR}/compose.yml"
COMPOSE_PROJECT="meet" COMPOSE_PROJECT="meet"
@@ -42,6 +42,7 @@ function _docker_compose() {
echo "🐳(compose) project: '${COMPOSE_PROJECT}' file: '${COMPOSE_FILE}'" echo "🐳(compose) project: '${COMPOSE_PROJECT}' file: '${COMPOSE_FILE}'"
docker compose \ docker compose \
-p "${COMPOSE_PROJECT}" \ -p "${COMPOSE_PROJECT}" \
-f "${COMPOSE_FILE}" \
--project-directory "${REPO_DIR}" \ --project-directory "${REPO_DIR}" \
"$@" "$@"
} }
Executable
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
# shellcheck source=bin/_config.sh
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
_docker_compose "$@"
+3 -5
View File
@@ -17,7 +17,7 @@ services:
minio: minio:
user: ${DOCKER_USER:-1000} user: ${DOCKER_USER:-1000}
image: quay.io/minio/minio image: minio/minio
environment: environment:
- MINIO_ROOT_USER=meet - MINIO_ROOT_USER=meet
- MINIO_ROOT_PASSWORD=password - MINIO_ROOT_PASSWORD=password
@@ -35,7 +35,7 @@ services:
- ./data/media:/data - ./data/media:/data
createbuckets: createbuckets:
image: quay.io/minio/mc image: minio/mc
depends_on: depends_on:
minio: minio:
condition: service_healthy condition: service_healthy
@@ -207,14 +207,12 @@ services:
- kc_postgresql - kc_postgresql
livekit: livekit:
image: livekit/livekit-server:v1.13.6 image: livekit/livekit-server
entrypoint: /livekit-server --dev --bind 0.0.0.0 --config ./config.yaml entrypoint: /livekit-server --dev --bind 0.0.0.0 --config ./config.yaml
ports: ports:
- "7880:7880" - "7880:7880"
- "7881:7881" - "7881:7881"
- "7882:7882/udp" - "7882:7882/udp"
- "3478:3478/udp"
- "30000-30100:30000-30100/udp"
volumes: volumes:
- ./docker/livekit/config/livekit-server.yaml:/config.yaml - ./docker/livekit/config/livekit-server.yaml:/config.yaml
depends_on: depends_on:
-47
View File
@@ -1,47 +0,0 @@
{
"nodes": {
"devenv": {
"locked": {
"dir": "src/modules",
"lastModified": 1778705847,
"narHash": "sha256-EQnZCy7r4VMO6KDoytxHBa0mFbM1D9g1kaDfs/s0YZA=",
"ref": "refs/tags/v2.1.2",
"rev": "ea3d94ac9d6bf6a1313773170122ca4e2ef5a0be",
"revCount": 6569,
"type": "git",
"url": "https://github.com/cachix/devenv"
},
"original": {
"dir": "src/modules",
"ref": "refs/tags/v2.1.2",
"type": "git",
"url": "https://github.com/cachix/devenv"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1789542786,
"narHash": "sha256-ajQuqcxnj6RYnwCjA/9FTCnrdR2+BDzstE8UembXf60=",
"ref": "nixos-26.05",
"rev": "4c7870105e7f1fdf9c48688c8d7efc21abf0688a",
"shallow": true,
"type": "git",
"url": "https://github.com/NixOS/nixpkgs"
},
"original": {
"ref": "nixos-26.05",
"shallow": true,
"type": "git",
"url": "https://github.com/NixOS/nixpkgs"
}
},
"root": {
"inputs": {
"devenv": "devenv",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
-265
View File
@@ -1,265 +0,0 @@
# =============================================================================
# devenv.nix — La Suite Meet ("Visio") developer environment
# =============================================================================
{
pkgs,
lib,
config,
...
}:
let
python = pkgs.python313;
nodejs = pkgs.nodejs_22;
backendDir = "src/backend";
agentsDir = "src/agents";
summaryDir = "src/summary";
frontendDir = "src/frontend";
readDotEnv =
file:
let
lines = lib.splitString "\n" (builtins.readFile file);
unquote =
v:
let
len = builtins.stringLength v;
in
if len >= 2 && lib.hasPrefix "\"" v && lib.hasSuffix "\"" v then
builtins.substring 1 (len - 2) v
else if len >= 2 && lib.hasPrefix "'" v && lib.hasSuffix "'" v then
builtins.substring 1 (len - 2) v
else
v;
parseLine =
line:
let
m = builtins.match "[ \t]*([A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(.*)" line;
in
if m == null then null else { name = builtins.elemAt m 0; value = unquote (builtins.elemAt m 1); };
in
builtins.listToAttrs (builtins.filter (x: x != null) (map parseLine lines));
# Reuse existing .env
dotEnv =
(readDotEnv ./env.d/development/common.dist)
// (readDotEnv ./env.d/development/postgresql.dist);
sharedEnv = builtins.removeAttrs dotEnv [ "PYTHONPATH" ]; # only makes sense inside the backend container.
in
{
options.meet = {
agents.enable = lib.mkEnableOption "tooling for the LiveKit agents in src/agents";
summary.enable = lib.mkEnableOption "tooling for the summary service in src/summary";
k8s.enable = lib.mkEnableOption "Kubernetes dev utilities";
};
config = {
# Profile can be activated with devenv --profile <profile> shell
profiles = {
agents.module = {
meet.agents.enable = true;
};
summary.module = {
meet.summary.enable = true;
};
k8s.module = {
meet.k8s.enable = true;
};
};
languages.python = {
enable = true;
package = python;
directory = backendDir;
manylinux.enable = pkgs.stdenv.hostPlatform.isLinux;
libraries = [
"${config.devenv.dotfile}/profile"
pkgs.file
pkgs.zlib
pkgs.libffi
pkgs.openssl
];
uv.enable = true;
uv.sync.enable = false;
venv.enable = false;
lsp.enable = true;
};
languages.javascript = {
enable = true;
package = nodejs;
directory = frontendDir;
npm.enable = true;
yarn.enable = true;
corepack.enable = false;
};
languages.typescript.enable = false;
languages.nix.enable = true;
packages =
with pkgs;
[
gnumake
file
shared-mime-info
gettext
postgresql_16
git
curl
jq
podman
podman-compose
docker-client
]
# -- LiveKit agents
++ lib.optionals config.meet.agents.enable [
glib
portaudio
livekit-cli
]
# -- summary service
++ lib.optionals config.meet.summary.enable [
redis
]
# -- Kubernetes tools
++ lib.optionals config.meet.k8s.enable [
kubectl
kubernetes-helm
helmfile
tilt
kind
mkcert
];
env = sharedEnv // {
UV_LINK_MODE = "copy";
PYTHONDONTWRITEBYTECODE = "1";
PYTHONUNBUFFERED = "1";
UV_PROJECT_ENVIRONMENT = lib.mkForce ".venv";
COMPOSE_PROJECT_NAME = "meet";
DJANGO_DATA_DIR = "${config.devenv.root}/data";
# Database / Pgsql
DB_HOST = "127.0.0.1";
DB_PORT = "15432";
PGHOST = "127.0.0.1";
PGPORT = "15432";
PGDATABASE = sharedEnv.DB_NAME;
PGUSER = sharedEnv.DB_USER;
PGPASSWORD = sharedEnv.DB_PASSWORD;
REDIS_URL = "redis://127.0.0.1:6379/1";
CELERY_BROKER_URL = "redis://127.0.0.1:6379/0";
# S3 / MinIO
AWS_S3_ENDPOINT_URL = "http://127.0.0.1:9000";
# OIDC
OIDC_OP_JWKS_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/certs";
OIDC_OP_TOKEN_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/token";
OIDC_OP_USER_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/userinfo";
OIDC_OP_INTROSPECTION_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/token/introspect";
# summary service
SUMMARY_SERVICE_ENDPOINT = "http://127.0.0.1:8001/api/v2/async-jobs/transcribe/";
SUMMARY_SERVICE_VERSION = "2";
# Mail
DJANGO_EMAIL_HOST = "127.0.0.1";
};
scripts = {
meet-venv = {
description = "Create/refresh meet uv virtualenvs for backend, agents and summary";
exec = ''
set -euo pipefail
cd "$DEVENV_ROOT"
echo "==> ${backendDir} (uv sync --locked, dependency-groups)"
( cd "${backendDir}" && uv sync --locked --all-groups )
echo "==> ${agentsDir} (uv sync --locked --all-extras)"
( cd "${agentsDir}" && uv sync --locked --all-extras )
echo "==> ${summaryDir} (uv sync --locked --all-extras)"
( cd "${summaryDir}" && uv sync --locked --all-extras )
echo
echo "Synced the following virtualenvs successfully:"
echo " ${backendDir}/.venv"
echo " ${agentsDir}/.venv"
echo " ${summaryDir}/.venv"
'';
};
};
enterShell = ''
# Make podman socket accessible in order to launch regular docker commands.
# Set MEET_PODMAN_SOCKET=0 to keep the DOCKER_HOST of the calling environment.
case "''${MEET_PODMAN_SOCKET:-1}" in
0|false|no|off) ;;
*)
_rundir="''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
export DOCKER_HOST="unix://$_rundir/podman/podman.sock"
unset _rundir
;;
esac
# Compose files to merge
_compose_dir="${config.devenv.root}/docker/compose.d"
_compose_files="${config.devenv.root}/compose.yml"
export DOCKER_USER="$(id -u):$(id -g)"
case "''${DOCKER_HOST:-}" in
*podman*)
_compose_files="$_compose_files:$_compose_dir/compose.podman.yml"
# Build images with Podman/Buildah rather than BuildKit. `docker
# compose build` otherwise has buildx boot a moby/buildkit container,
# and that container lands in its own network namespace with neither
# the proxy in its environment nor any route to it.
# Buildah has neither problem: base images are resolved by the Podman systemd
# service, which inherits the proxy from its systemd socket activated unit, and
# RUN steps execute in the *host* network namespace
export DOCKER_BUILDKIT=0
export COMPOSE_BAKE=false
;;
esac
# Apply Bureautix override
if [ -n "''${http_proxy:-}" ]; then
_compose_files="$_compose_files:$_compose_dir/compose.bureautix.yml"
fi
export COMPOSE_FILE="$_compose_files"
unset _compose_dir _compose_files
# Make binaries accessible
for _d in \
"$DEVENV_ROOT/${backendDir}/.venv/bin" \
"$DEVENV_ROOT/${agentsDir}/.venv/bin" \
"$DEVENV_ROOT/${summaryDir}/.venv/bin" \
"$DEVENV_ROOT/${frontendDir}/node_modules/.bin"
do
[ -d "$_d" ] && export PATH="$_d:$PATH"
done
unset _d
'';
};
}
-5
View File
@@ -1,5 +0,0 @@
inputs:
nixpkgs:
url: git+https://github.com/NixOS/nixpkgs?ref=nixos-26.05&shallow=1
devenv:
url: git+https://github.com/cachix/devenv?ref=refs/tags/v2.1.2&dir=src/modules
-48
View File
@@ -1,48 +0,0 @@
# Bureautix proxy overrides
#
# Builds submitted through the Docker API of the Podman service get none of
# the proxy variables in their RUN steps. We need to pass the http_proxy explicitely
# otherwise all connections fail during the build.
x-proxy-vars: &proxy-vars
http_proxy: ${http_proxy:-}
https_proxy: ${https_proxy:-}
no_proxy: ${no_proxy:-}
services:
app:
build:
args:
<<: *proxy-vars
app-dev:
build:
args:
<<: *proxy-vars
frontend:
build:
args:
<<: *proxy-vars
metadata-collector-dev:
build:
args:
<<: *proxy-vars
multi-user-transcriber-dev:
build:
args:
<<: *proxy-vars
app-summary-dev:
build:
args:
<<: *proxy-vars
celery-summary-transcribe:
build:
args:
<<: *proxy-vars
celery-summary-summarize:
build:
args:
<<: *proxy-vars
# The local proxy listens on 8080 and collides with Keycloak's published admin port.
keycloak:
ports: !override
- "8081:8080"
-33
View File
@@ -1,33 +0,0 @@
# Rootless Podman override for compose.yml.
#
# Rootless Podman maps container UID 0 to the host user and every other
# container UID to a subuid that owns nothing in the worktree. Meet compose.ymlruns
# its containers as DOCKER_USER=$(id -u):$(id -g), which would land on such a
# subuid and make every bind mount effectively read-only.
#
# `userns_mode: keep-id` maps the host user to the same UID and GID inside the
# container instead, so DOCKER_USER keeps its Docker value and files written
# through a bind mount are owned by the host user on both sides.
#
# Only the services that mount the worktree and run as DOCKER_USER are listed.
x-keep-id: &keep-id
userns_mode: keep-id
services:
app-dev:
<<: *keep-id
celery-dev:
<<: *keep-id
minio:
<<: *keep-id
node:
<<: *keep-id
crowdin:
<<: *keep-id
metadata-collector-dev:
<<: *keep-id
multi-user-transcriber-dev:
<<: *keep-id
app-summary-dev:
<<: *keep-id
+10 -4
View File
@@ -54,12 +54,18 @@ RUN npx webpack --mode production
# ---- Front-end image ---- # ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.30.4-alpine3.24 AS frontend-production FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
USER root USER root
RUN apk upgrade --no-cache libexpat && \
apk del curl # Security patches for known CVEs
USER nginx RUN apk update && apk upgrade \
libcrypto3>=3.5.7-r0 \
libssl3>=3.5.7-r0 \
musl \
musl-utils \
zlib>=1.3.2-r0 \
&& apk del curl
USER nginx USER nginx
-1
View File
@@ -1,6 +1,5 @@
:root { :root {
--fonts-sans: 'Marianne', ui-sans-serif, system-ui, sans-serif; --fonts-sans: 'Marianne', ui-sans-serif, system-ui, sans-serif;
--avatar-cap-height: 0.7;
} }
.Header-beforeLogo { .Header-beforeLogo {
@@ -14,4 +14,3 @@ accesslog = "-"
# Using '-' for the error log file makes gunicorn log errors to stderr # Using '-' for the error log file makes gunicorn log errors to stderr
errorlog = "-" errorlog = "-"
loglevel = "info" loglevel = "info"
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(M)s'
+1 -1
View File
@@ -1,4 +1,4 @@
FROM livekit/livekit-server:v1.13.6 FROM livekit/livekit-server:v1.9.4
# We inject the nip.io certificate manually because the livekit chart doesn't support volume mounting # We inject the nip.io certificate manually because the livekit chart doesn't support volume mounting
COPY rootCA.pem /etc/ssl/certs/ COPY rootCA.pem /etc/ssl/certs/
-20
View File
@@ -8,23 +8,3 @@ webhook:
api_key: devkey api_key: devkey
urls: urls:
- http://app-dev:8000/api/v1.0/rooms/webhooks-livekit/ - http://app-dev:8000/api/v1.0/rooms/webhooks-livekit/
turn:
enabled: true
domain: turn.127.0.0.1.nip.io
udp_port: 3478
tls_port: 0
external_tls: false
relay_range_start: 30000
relay_range_end: 30100
allow_restricted_peer_cidrs:
- 192.168.0.0/16
- 172.16.0.0/12
rtc:
node_ip: 127.0.0.1
advertise_internal_ip: true
udp_port: 7882
tcp_port: 7881
use_external_ip: false
+3 -78
View File
@@ -16,11 +16,11 @@ info:
* `rooms:list` – List rooms accessible to the delegated user. * `rooms:list` – List rooms accessible to the delegated user.
* `rooms:retrieve` – Retrieve details of a specific room. * `rooms:retrieve` – Retrieve details of a specific room.
* `rooms:create` – Create new rooms. * `rooms:create` – Create new rooms.
* `rooms:update` – Update the access level and configuration of existing rooms. * `rooms:update` – **Coming soon** Update existing rooms, e.g., add attendees to a room.
* `rooms:delete` – **Coming soon** Delete rooms generated by the application. * `rooms:delete` – **Coming soon** Delete rooms generated by the application.
#### Upcoming Features #### Upcoming Features
* **Add attendees to a room:** You will be able to update a room to include a list of attendees, allowing them to bypass the lobby system automatically. * **Add attendees to a room:** You will be able to update a room to include a list of attendees, allowing them to bypass the lobby system automatically.
* **Delete application-generated rooms:** Rooms created via the application can be deleted when no longer needed. * **Delete application-generated rooms:** Rooms created via the application can be deleted when no longer needed.
@@ -310,67 +310,6 @@ paths:
'404': '404':
$ref: '#/components/responses/RoomNotFoundError' $ref: '#/components/responses/RoomNotFoundError'
patch:
tags:
- Rooms
summary: Update a room
description: |
Partially updates a room. Only the delegated user's rooms where they are
administrator or owner can be updated; any other role gets a `403`.
**Updatable fields:** `access_level` and `configuration`. Every other field
(`id`, `name`, `slug`, `pin_code`) is read-only and silently ignored when sent.
`configuration` is replaced as a whole, it is not merged with the stored one.
Send the complete object you want the room to end up with.
Full replacement (`PUT`) is not supported. Use `PATCH` instead.
operationId: updateRoom
security:
- BearerAuth: [rooms:update]
parameters:
- name: id
in: path
required: true
description: Room UUID
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RoomUpdate'
examples:
accessLevelOnly:
summary: Change the access level
value:
access_level: "restricted"
configurationOnly:
summary: Replace the room configuration
value:
configuration:
everyone_can_mute: true
responses:
'200':
description: Room updated successfully
content:
application/json:
schema:
$ref: '#/components/schemas/Room'
'400':
$ref: '#/components/responses/BadRequestError'
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
$ref: '#/components/responses/ForbiddenError'
'404':
$ref: '#/components/responses/RoomNotFoundError'
'405':
description: |
Method not allowed, `PUT` is not supported on this endpoint.
components: components:
securitySchemes: securitySchemes:
BearerAuth: BearerAuth:
@@ -447,17 +386,6 @@ components:
configuration: configuration:
$ref: '#/components/schemas/RoomConfiguration' $ref: '#/components/schemas/RoomConfiguration'
RoomUpdate:
type: object
description: |
Fields that can be updated on an existing room. Both are optional, omitted
fields keep their current value.
properties:
access_level:
$ref: '#/components/schemas/RoomAccessLevel'
configuration:
$ref: '#/components/schemas/RoomConfiguration'
RoomConfiguration: RoomConfiguration:
type: object type: object
description: | description: |
@@ -499,9 +427,6 @@ components:
- `public`: Anyone with the room link can join directly, no authentication required. - `public`: Anyone with the room link can join directly, no authentication required.
- `trusted`: Authenticated users join directly. Unauthenticated users wait in the lobby for approval. - `trusted`: Authenticated users join directly. Unauthenticated users wait in the lobby for approval.
- `restricted`: Only participants explicitly trusted by the owner bypass the lobby. Everyone else waits for approval regardless of authentication. - `restricted`: Only participants explicitly trusted by the owner bypass the lobby. Everyone else waits for approval regardless of authentication.
`public` is rejected with a `400` unless the deployment explicitly enables it
for this API. This applies both when creating a room and when updating one.
example: "trusted" example: "trusted"
Room: Room:
+1 -76
View File
@@ -20,7 +20,7 @@ info:
* `lasuite_visio:rooms:list` – List rooms accessible to the delegated user. * `lasuite_visio:rooms:list` – List rooms accessible to the delegated user.
* `lasuite_visio:rooms:retrieve` – Retrieve details of a specific room. * `lasuite_visio:rooms:retrieve` – Retrieve details of a specific room.
* `lasuite_visio:rooms:create` – Create new rooms. * `lasuite_visio:rooms:create` – Create new rooms.
* `lasuite_visio:rooms:update` – Update the access level and configuration of existing rooms. * `lasuite_visio:rooms:update` – **Coming soon** Update existing rooms, e.g., add attendees to a room.
* `lasuite_visio:rooms:delete` – **Coming soon** Delete rooms generated by the application. * `lasuite_visio:rooms:delete` – **Coming soon** Delete rooms generated by the application.
#### Upcoming Features #### Upcoming Features
@@ -206,67 +206,6 @@ paths:
'404': '404':
$ref: '#/components/responses/RoomNotFoundError' $ref: '#/components/responses/RoomNotFoundError'
patch:
tags:
- Rooms
summary: Update a room
description: |
Partially updates a room. Only rooms where the user is administrator or
owner can be updated; any other role gets a `403`.
**Updatable fields:** `access_level` and `configuration`. Every other field
(`id`, `name`, `slug`, `pin_code`) is read-only and silently ignored when sent.
`configuration` is replaced as a whole, it is not merged with the stored one.
Send the complete object you want the room to end up with.
Full replacement (`PUT`) is not supported. Use `PATCH` instead.
operationId: updateRoom
security:
- BearerAuth: [rooms:update]
parameters:
- name: id
in: path
required: true
description: Room UUID
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/RoomUpdate'
examples:
accessLevelOnly:
summary: Change the access level
value:
access_level: "restricted"
configurationOnly:
summary: Replace the room configuration
value:
configuration:
everyone_can_mute: true
responses:
'200':
description: Room updated successfully
content:
application/json:
schema:
$ref: '#/components/schemas/Room'
'400':
$ref: '#/components/responses/BadRequestError'
'401':
$ref: '#/components/responses/UnauthorizedError'
'403':
$ref: '#/components/responses/ForbiddenError'
'404':
$ref: '#/components/responses/RoomNotFoundError'
'405':
description: |
Method not allowed, `PUT` is not supported on this endpoint.
components: components:
securitySchemes: securitySchemes:
BearerAuth: BearerAuth:
@@ -288,17 +227,6 @@ components:
configuration: configuration:
$ref: '#/components/schemas/RoomConfiguration' $ref: '#/components/schemas/RoomConfiguration'
RoomUpdate:
type: object
description: |
Fields that can be updated on an existing room. Both are optional, omitted
fields keep their current value.
properties:
access_level:
$ref: '#/components/schemas/RoomAccessLevel'
configuration:
$ref: '#/components/schemas/RoomConfiguration'
RoomConfiguration: RoomConfiguration:
type: object type: object
description: | description: |
@@ -340,9 +268,6 @@ components:
- `public`: Anyone with the room link can join directly, no authentication required. - `public`: Anyone with the room link can join directly, no authentication required.
- `trusted`: Authenticated users join directly. Unauthenticated users wait in the lobby for approval. - `trusted`: Authenticated users join directly. Unauthenticated users wait in the lobby for approval.
- `restricted`: Only participants explicitly trusted by the owner bypass the lobby. Everyone else waits for approval regardless of authentication. - `restricted`: Only participants explicitly trusted by the owner bypass the lobby. Everyone else waits for approval regardless of authentication.
`public` is rejected with a `400` unless the deployment explicitly enables it
for this API. This applies both when creating a room and when updating one.
example: "trusted" example: "trusted"
Room: Room:
-1
View File
@@ -34,7 +34,6 @@ Let's say you want to change the font of our application to a custom font. You c
:root { :root {
--fonts-sans: 'Roboto', ui-sans-serif, system-ui, sans-serif; --fonts-sans: 'Roboto', ui-sans-serif, system-ui, sans-serif;
--avatar-cap-height: 0.7;
} }
``` ```
+22 -8
View File
@@ -69,17 +69,31 @@ SUMMARY_SERVICE_WEBHOOK_API_TOKEN=webhook-password
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
# Recording encoding (LiveKit Egress advanced options). # Recording encoding (LiveKit Egress advanced options).
# When RECORDING_ENCODING_ENABLED is False (default), LiveKit uses its built-in # Encoding is described by a named resolution (width/height) and a named profile
# H264_720P_30 preset (1280x720, 30fps, 3000 kbps). Enable and tune to reduce # (framerate + video bitrate per resolution) instead of raw encoder values. The
# file size and CPU load on the egress worker. # start-recording API accepts a pair per recording, e.g.
# RECORDING_ENCODING_ENABLED=False # options.encoding={"resolution": "720p", "profile": "talking_heads"}; only keys
# RECORDING_ENCODING_WIDTH=1280 # declared in the two maps below are accepted, "profile" is optional.
# RECORDING_ENCODING_HEIGHT=720 # Both maps are read as a one-line Python dict literal (ast.literal_eval): double
# RECORDING_ENCODING_FRAMERATE=30 # quoted keys, no outer quotes, no trailing comma. Every profile must define a
# RECORDING_ENCODING_VIDEO_BITRATE_KBPS=3000 # kbps entry for exactly the resolutions of the resolutions map, or startup fails.
# RECORDING_ENCODING_AVAILABLE_RESOLUTIONS={"540p": {"width": 960, "height": 540}, "720p": {"width": 1280, "height": 720}, "1080p": {"width": 1920, "height": 1080}}
# RECORDING_ENCODING_AVAILABLE_PROFILES={"talking_heads": {"fps": 15, "kbps": {"540p": 400, "720p": 700, "1080p": 1200}}, "text": {"fps": 15, "kbps": {"540p": 600, "720p": 1000, "1080p": 1800}}, "mixed": {"fps": 20, "kbps": {"540p": 900, "720p": 1500, "1080p": 2500}}, "full": {"fps": 30, "kbps": {"540p": 2000, "720p": 3000, "1080p": 4500}}}
# Defaults for recordings that don't carry an encoding. Must be keys of the maps
# above. Declared but not yet read by the recording code at this commit.
# RECORDING_ENCODING_DEFAULT_RESOLUTION=720p
# RECORDING_ENCODING_DEFAULT_PROFILE=full
# Applied to every resolved encoding, independent of resolution and profile.
# RECORDING_ENCODING_AUDIO_BITRATE_KBPS=128 # RECORDING_ENCODING_AUDIO_BITRATE_KBPS=128
# RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0 # RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=4.0
# Server-wide encoding used when a recording carries no encoding of its own.
# Keep False: when True, the worker factory still reads RECORDING_ENCODING_WIDTH,
# _HEIGHT, _FRAMERATE and _VIDEO_BITRATE_KBPS, which no longer exist.
# RECORDING_ENCODING_ENABLED=False
# Telephony # Telephony
ROOM_TELEPHONY_ENABLED=True ROOM_TELEPHONY_ENABLED=True
+4 -4
View File
@@ -10,7 +10,7 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"core-js": "3.50.0", "core-js": "3.50.0",
"i18next": "26.4.1", "i18next": "26.3.6",
"i18next-browser-languagedetector": "8.2.1", "i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1" "regenerator-runtime": "0.14.1"
}, },
@@ -9367,9 +9367,9 @@
} }
}, },
"node_modules/i18next": { "node_modules/i18next": {
"version": "26.4.1", "version": "26.3.6",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.1.tgz", "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
"integrity": "sha512-9YbX5E6gd1H+yaOSX3izCsPj5iWXyH7X4oC+iuHHJJw8AeHglzOK5SJf+1CHxaYKYigcea+8jvzSxqYx46YvyA==", "integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
+1 -1
View File
@@ -27,7 +27,7 @@
}, },
"dependencies": { "dependencies": {
"core-js": "3.50.0", "core-js": "3.50.0",
"i18next": "26.4.1", "i18next": "26.3.6",
"i18next-browser-languagedetector": "8.2.1", "i18next-browser-languagedetector": "8.2.1",
"regenerator-runtime": "0.14.1" "regenerator-runtime": "0.14.1"
}, },
+2 -3
View File
@@ -1,8 +1,7 @@
FROM python:3.14.6-slim AS base FROM python:3.14.6-slim AS base
# Install system dependencies required by LiveKit, fetching packages over HTTPS only for Bureautix proxy # Install system dependencies required by LiveKit
RUN sed -i "s|^URIs: http://|URIs: https://|" /etc/apt/sources.list.d/debian.sources \ RUN apt-get update && apt-get install -y \
&& apt-get update && apt-get install -y --no-install-recommends \
libglib2.0-0 \ libglib2.0-0 \
libgobject-2.0-0 \ libgobject-2.0-0 \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
+1 -1
View File
@@ -1,7 +1,7 @@
[project] [project]
name = "agents" name = "agents"
version = "1.31.0" version = "1.29.0"
requires-python = ">=3.12" requires-python = ">=3.12"
dependencies = [ dependencies = [
"livekit-agents==1.6.7", "livekit-agents==1.6.7",
+1 -1
View File
@@ -9,7 +9,7 @@ resolution-markers = [
[[package]] [[package]]
name = "agents" name = "agents"
version = "1.31.0" version = "1.29.0"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "httpx" }, { name = "httpx" },
-1
View File
@@ -8,7 +8,6 @@ class AnalyticsEvent(StrEnum):
# Rooms # Rooms
ROOM_CREATED = "room_created" ROOM_CREATED = "room_created"
ROOM_UPDATED = "room_updated"
# Roomkit (meeting-room SIP devices) # Roomkit (meeting-room SIP devices)
ROOMKIT_JOINED = "roomkit_joined" ROOMKIT_JOINED = "roomkit_joined"
-4
View File
@@ -71,14 +71,10 @@ def get_frontend_configuration(request):
"force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL, "force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL,
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND, "enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES, "default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
"default_video_codec": settings.LIVEKIT_DEFAULT_VIDEO_CODEC,
}, },
"authenticated_users_can_edit_display_name": ( "authenticated_users_can_edit_display_name": (
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
), ),
"encryption": {
"is_enabled": settings.ENCRYPTION_ENABLED,
},
} }
frontend_configuration.update(settings.FRONTEND_CONFIGURATION) frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
return Response(frontend_configuration) return Response(frontend_configuration)
+13
View File
@@ -12,6 +12,10 @@ from ..services.participants_management import (
ParticipantsManagementException, ParticipantsManagementException,
) )
ACTION_FOR_METHOD_TO_PERMISSION = {
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
}
class IsAuthenticated(permissions.BasePermission): class IsAuthenticated(permissions.BasePermission):
""" """
@@ -23,6 +27,15 @@ class IsAuthenticated(permissions.BasePermission):
return bool(request.auth) or request.user.is_authenticated return bool(request.auth) or request.user.is_authenticated
class IsAuthenticatedOrSafe(IsAuthenticated):
"""Allows access to authenticated users (or anonymous users but only on safe methods)."""
def has_permission(self, request, view):
if request.method in permissions.SAFE_METHODS:
return True
return super().has_permission(request, view)
class IsSelf(IsAuthenticated): class IsSelf(IsAuthenticated):
""" """
Allows access only to authenticated users. Alternative method checking the presence Allows access only to authenticated users. Alternative method checking the presence
+51 -49
View File
@@ -13,7 +13,12 @@ from django.core.exceptions import SuspiciousOperation
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_pydantic_field.rest_framework import SchemaField from django_pydantic_field.rest_framework import SchemaField
from pydantic import BaseModel, Field, field_serializer from pydantic import (
BaseModel,
Field,
field_serializer,
field_validator,
)
from pydantic import ValidationError as PydanticValidationError from pydantic import ValidationError as PydanticValidationError
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
@@ -38,7 +43,6 @@ class UserSerializer(serializers.ModelSerializer):
"short_name", "short_name",
"timezone", "timezone",
"language", "language",
"default_encryption_mode",
"default_room_access_level", "default_room_access_level",
"default_room_configuration", "default_room_configuration",
] ]
@@ -54,14 +58,6 @@ class UserSerializer(serializers.ModelSerializer):
raise serializers.ValidationError(e.errors()) from e raise serializers.ValidationError(e.errors()) from e
return value return value
def validate_default_encryption_mode(self, value):
"""Reject a non-none default when the server has encryption disabled."""
if value != models.EncryptionMode.NONE and not settings.ENCRYPTION_ENABLED:
raise serializers.ValidationError(
_("End-to-end encryption is disabled on this server.")
)
return value
class UserLightSerializer(serializers.ModelSerializer): class UserLightSerializer(serializers.ModelSerializer):
"""Serialize users with limited fields.""" """Serialize users with limited fields."""
@@ -103,7 +99,6 @@ class ResourceAccessSerializerMixin:
raise PermissionDenied( raise PermissionDenied(
"Only owners of a room can assign other users as owners." "Only owners of a room can assign other users as owners."
) )
return data return data
def validate_resource(self, resource): def validate_resource(self, resource):
@@ -158,15 +153,7 @@ class RoomSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = models.Room model = models.Room
fields = [ fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"]
"id",
"name",
"slug",
"configuration",
"access_level",
"pin_code",
"encryption_mode",
]
read_only_fields = ["id", "slug", "pin_code"] read_only_fields = ["id", "slug", "pin_code"]
def validate_configuration(self, value): def validate_configuration(self, value):
@@ -179,32 +166,6 @@ class RoomSerializer(serializers.ModelSerializer):
raise serializers.ValidationError(e.errors()) from e raise serializers.ValidationError(e.errors()) from e
return value return value
def validate_encryption_mode(self, value):
"""Encryption mode is part of the link's semantics (the passphrase
lives in the URL hash for `basic` rooms) so it cannot be changed once
the room exists."""
instance = self.instance
if instance and instance.encryption_mode != value:
raise serializers.ValidationError(
"Encryption mode cannot be changed after room creation."
)
return value
def validate_access_level(self, value):
"""Encrypted rooms must stay restricted — the lobby is the only way
to enforce per-participant admission, and basic encryption relies on
the host vetting each joiner before they receive the in-URL key."""
instance = self.instance
if (
instance
and instance.encryption_mode != models.EncryptionMode.NONE
and value != models.RoomAccessLevel.RESTRICTED
):
raise serializers.ValidationError(
"Encrypted rooms require restricted access level."
)
return value
def to_representation(self, instance): def to_representation(self, instance):
""" """
Add users only for administrator users. Add users only for administrator users.
@@ -241,14 +202,12 @@ class RoomSerializer(serializers.ModelSerializer):
if should_access_room: if should_access_room:
room_id = f"{instance.id!s}" room_id = f"{instance.id!s}"
username = request.query_params.get("username", None) username = request.query_params.get("username", None)
output["livekit"] = utils.generate_livekit_config( output["livekit"] = utils.generate_livekit_config(
room_id=room_id, room_id=room_id,
user=request.user, user=request.user,
username=username, username=username,
configuration=output["configuration"], configuration=output["configuration"],
role=role, role=role,
encryption_mode=instance.encryption_mode,
) )
else: else:
del output["pin_code"] del output["pin_code"]
@@ -290,6 +249,49 @@ class BaseValidationOnlySerializer(serializers.Serializer):
raise NotImplementedError(f"{self.__class__.__name__} is validation-only") raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
class EncodingConfig(BaseModel):
"""Configuration options for recording encoding.
The allowed `resolution` and `profile` values are derived at validation time
from ``settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS`` and
``settings.RECORDING_ENCODING_AVAILABLE_PROFILES``, so adding a resolution or profile
to those maps is enough to make it accepted here.
Attributes:
resolution: Target video resolution.
profile: Encoding profile to balance quality and CPU usage. When `None`,
LiveKit default framerate/bitrate are used for the resolution.
"""
resolution: str
profile: str | None = None
model_config = {"extra": "forbid"}
@field_validator("resolution")
@classmethod
def _validate_resolution(cls, value):
"""Reject resolutions absent from RECORDING_ENCODING_AVAILABLE_RESOLUTIONS."""
allowed = set(settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS)
if value not in allowed:
raise ValueError(
f"Invalid resolution '{value}'. Choose from {sorted(allowed)}."
)
return value
@field_validator("profile")
@classmethod
def _validate_profile(cls, value):
"""Reject profiles absent from RECORDING_ENCODING_AVAILABLE_PROFILES."""
if value is None:
return None
allowed = set(settings.RECORDING_ENCODING_AVAILABLE_PROFILES)
if value not in allowed:
raise ValueError(
f"Invalid profile '{value}'. Choose from {sorted(allowed)}."
)
return value
class RecordingOptions(BaseModel): class RecordingOptions(BaseModel):
"""Configuration options for recording. """Configuration options for recording.
@@ -310,7 +312,7 @@ class RecordingOptions(BaseModel):
transcribe: bool | None = None transcribe: bool | None = None
collect_metadata: bool | None = None collect_metadata: bool | None = None
original_mode: Literal["screen_recording", "transcript"] | None = None original_mode: Literal["screen_recording", "transcript"] | None = None
encoding: EncodingConfig | None = None
model_config = {"extra": "forbid"} model_config = {"extra": "forbid"}
+96 -51
View File
@@ -60,6 +60,7 @@ from core.recording.worker.factories import (
from core.recording.worker.mediator import ( from core.recording.worker.mediator import (
WorkerServiceMediator, WorkerServiceMediator,
) )
from core.recording.worker.services import resolve_encoding_config
from core.services.invitation import InvitationService from core.services.invitation import InvitationService
from core.services.livekit_events import ( from core.services.livekit_events import (
LiveKitEventsService, LiveKitEventsService,
@@ -75,7 +76,11 @@ from core.services.participants_management import (
ParticipantsManagementException, ParticipantsManagementException,
) )
from core.services.room_creation import RoomCreation from core.services.room_creation import RoomCreation
from core.services.room_management import RoomManagement from core.services.room_management import (
RoomManagement,
RoomManagementException,
RoomNotFoundException,
)
from core.services.room_roles import ( from core.services.room_roles import (
RoomRoleError, RoomRoleError,
RoomRoleService, RoomRoleService,
@@ -95,6 +100,60 @@ from .feature_flag import FeatureFlag
logger = getLogger(__name__) logger = getLogger(__name__)
class NestedGenericViewSet(viewsets.GenericViewSet):
"""
A generic Viewset aims to be used in a nested route context.
e.g: `/api/v1.0/resource_1/<resource_1_pk>/resource_2/<resource_2_pk>/`
It allows to define all url kwargs and lookup fields to perform the lookup.
"""
lookup_fields: list[str] = ["pk"]
lookup_url_kwargs: list[str] = []
def __getattribute__(self, file):
"""
This method is overridden to allow to get the last lookup field or lookup url kwarg
when accessing the `lookup_field` or `lookup_url_kwarg` attribute. This is useful
to keep compatibility with all methods used by the parent class `GenericViewSet`.
"""
if file in ["lookup_field", "lookup_url_kwarg"]:
return getattr(self, file + "s", [None])[-1]
return super().__getattribute__(file)
def get_queryset(self):
"""
Get the list of files for this view.
`lookup_fields` attribute is enumerated here to perform the nested lookup.
"""
queryset = super().get_queryset()
# The last lookup field is removed to perform the nested lookup as it corresponds
# to the object pk, it is used within get_object method.
lookup_url_kwargs = (
self.lookup_url_kwargs[:-1]
if self.lookup_url_kwargs
else self.lookup_fields[:-1]
)
filter_kwargs = {}
for index, lookup_url_kwarg in enumerate(lookup_url_kwargs):
if lookup_url_kwarg not in self.kwargs:
raise KeyError(
f"Expected view {self.__class__.__name__} to be called with a URL "
f'keyword argument named "{lookup_url_kwarg}". Fix your URL conf, or '
"set the `.lookup_fields` attribute on the view correctly."
)
filter_kwargs.update(
{self.lookup_fields[index]: self.kwargs[lookup_url_kwarg]}
)
return queryset.filter(**filter_kwargs)
class SerializerPerActionMixin: class SerializerPerActionMixin:
""" """
A mixin to allow to define serializer classes for each action. A mixin to allow to define serializer classes for each action.
@@ -249,18 +308,6 @@ class RoomViewSet(
Apply the user's default room preferences (access level and configuration) Apply the user's default room preferences (access level and configuration)
unless the request explicitly provides its own values. unless the request explicitly provides its own values.
""" """
encryption_mode = serializer.validated_data.get(
"encryption_mode", models.EncryptionMode.NONE
)
if (
encryption_mode != models.EncryptionMode.NONE
and not settings.ENCRYPTION_ENABLED
):
raise drf_exceptions.ValidationError(
{"encryption_mode": "Encryption is not enabled on this server."}
)
user = self.request.user user = self.request.user
save_kwargs = {} save_kwargs = {}
@@ -310,7 +357,26 @@ class RoomViewSet(
): ):
return return
RoomManagement.sync_room_metadata(room) metadata = {
"configuration": room.configuration,
"access_level": room.access_level,
}
try:
RoomManagement().update_metadata(
room_name=str(room.id),
metadata=metadata,
)
except RoomNotFoundException:
logger.info(
"LiveKit room %s does not exist yet, skipping metadata sync",
room.id,
)
except RoomManagementException:
logger.warning(
"Failed to sync metadata to LiveKit for room %s",
room.id,
)
@decorators.action( @decorators.action(
detail=True, detail=True,
@@ -325,19 +391,22 @@ class RoomViewSet(
"""Start recording a room.""" """Start recording a room."""
serializer = serializers.StartRecordingSerializer(data=request.data) serializer = serializers.StartRecordingSerializer(data=request.data)
if not serializer.is_valid(): if not serializer.is_valid():
return drf_response.Response( return drf_response.Response(
{"detail": "Invalid request."}, {"detail": "Invalid request."}, status=drf_status.HTTP_400_BAD_REQUEST
status=drf_status.HTTP_400_BAD_REQUEST,
) )
mode = serializer.validated_data["mode"] mode = serializer.validated_data["mode"]
options = serializer.validated_data.get("options") options = serializer.validated_data.get("options")
room = self.get_object() room = self.get_object()
if room.is_encrypted: options_data = options.model_dump(exclude_none=True) if options else {}
raise drf_exceptions.ValidationError( if options is not None and options.encoding is not None:
{"detail": "Recording is unavailable in encrypted rooms."} # Persist the resolved encoding (concrete width/height/framerate/
# bitrate) alongside the requested resolution/profile for traceability.
options_data["encoding"]["resolved"] = resolve_encoding_config(
options.encoding
) )
try: try:
@@ -345,7 +414,7 @@ class RoomViewSet(
recording = models.Recording.objects.create( recording = models.Recording.objects.create(
room=room, room=room,
mode=mode, mode=mode,
options=options.model_dump(exclude_none=True) if options else {}, options=options_data,
) )
models.RecordingAccess.objects.create( models.RecordingAccess.objects.create(
user=self.request.user, user=self.request.user,
@@ -662,11 +731,6 @@ class RoomViewSet(
room = self.get_object() room = self.get_object()
if room.is_encrypted:
raise drf_exceptions.ValidationError(
{"detail": "Subtitles are unavailable in encrypted rooms."}
)
try: try:
SubtitleService().start_subtitle(room) SubtitleService().start_subtitle(room)
except SubtitleException: except SubtitleException:
@@ -877,15 +941,6 @@ class RoomViewSet(
"""Rename the current participant in the room.""" """Rename the current participant in the room."""
room = self.get_object() room = self.get_object()
if (
not settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
and request.user.is_authenticated
):
return drf_response.Response(
{"error": "Authenticated participants cannot edit their display name"},
status=drf_status.HTTP_403_FORBIDDEN,
)
serializer = serializers.RenameParticipantSerializer(data=request.data) serializer = serializers.RenameParticipantSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
@@ -1030,10 +1085,9 @@ class RecordingViewSet(
def _auth_get_original_url(self, request): def _auth_get_original_url(self, request):
""" """
Extracts and parses the original URL from the configured header. Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
Raises PermissionDenied if the header is missing. Raises PermissionDenied if the header is missing.
The original url is passed by the reverse proxy in the header named by the The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
MEDIA_AUTH_ORIGINAL_URL_HEADER setting, which defaults to "HTTP_X_ORIGINAL_URL".
See corresponding ingress configuration in Helm chart and read about the See corresponding ingress configuration in Helm chart and read about the
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
is configured to do this. is configured to do this.
@@ -1043,13 +1097,9 @@ class RecordingViewSet(
reasons. reasons.
""" """
# Extract the original URL from the request header # Extract the original URL from the request header
original_url = request.META.get(settings.MEDIA_AUTH_ORIGINAL_URL_HEADER) original_url = request.META.get("HTTP_X_ORIGINAL_URL")
if not original_url: if not original_url:
logger.warning( logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest")
"Missing %s header in subrequest. Set MEDIA_AUTH_ORIGINAL_URL_HEADER "
"to the header your reverse proxy sends.",
settings.MEDIA_AUTH_ORIGINAL_URL_HEADER,
)
raise drf_exceptions.PermissionDenied() raise drf_exceptions.PermissionDenied()
logger.debug("Original url: '%s'", original_url) logger.debug("Original url: '%s'", original_url)
@@ -1374,8 +1424,7 @@ class FileViewSet(
Authorize access based on the original URL of an Nginx subrequest Authorize access based on the original URL of an Nginx subrequest
and user permissions. Returns a dictionary of URL parameters if authorized. and user permissions. Returns a dictionary of URL parameters if authorized.
The original url is passed by the reverse proxy in the header named by the The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
MEDIA_AUTH_ORIGINAL_URL_HEADER setting, which defaults to "HTTP_X_ORIGINAL_URL".
See corresponding ingress configuration in Helm chart and read about the See corresponding ingress configuration in Helm chart and read about the
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
is configured to do this. is configured to do this.
@@ -1394,13 +1443,9 @@ class FileViewSet(
- PermissionDenied if authorization fails. - PermissionDenied if authorization fails.
""" """
# Extract the original URL from the request header # Extract the original URL from the request header
original_url = request.META.get(settings.MEDIA_AUTH_ORIGINAL_URL_HEADER) original_url = request.META.get("HTTP_X_ORIGINAL_URL")
if not original_url: if not original_url:
logger.warning( logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest")
"Missing %s header in subrequest. Set MEDIA_AUTH_ORIGINAL_URL_HEADER "
"to the header your reverse proxy sends.",
settings.MEDIA_AUTH_ORIGINAL_URL_HEADER,
)
raise drf_exceptions.PermissionDenied() raise drf_exceptions.PermissionDenied()
parsed_url = urlparse(original_url) parsed_url = urlparse(original_url)
+1 -19
View File
@@ -3,11 +3,7 @@
import contextlib import contextlib
from django.conf import settings from django.conf import settings
from django.core.exceptions import ( from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
ImproperlyConfigured,
SuspiciousOperation,
ValidationError,
)
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from lasuite.oidc_login.backends import ( from lasuite.oidc_login.backends import (
@@ -21,7 +17,6 @@ from core.services.marketing import (
ContactData, ContactData,
get_marketing_service, get_marketing_service,
) )
from core.validators import sub_validator
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend): class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
@@ -89,19 +84,6 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
def get_existing_user(self, sub, email): def get_existing_user(self, sub, email):
"""Fetch existing user by sub or email.""" """Fetch existing user by sub or email."""
sub = str(sub)
try:
sub_validator(sub)
except ValidationError as err:
raise SuspiciousOperation(
"User info contained an invalid sub claim"
) from err
if len(sub) > 255:
raise SuspiciousOperation("User info contained an invalid sub claim")
try: try:
return User.objects.get(sub=sub) return User.objects.get(sub=sub)
except User.DoesNotExist: except User.DoesNotExist:
-12
View File
@@ -5,7 +5,6 @@ Core application enums declaration
import re import re
from django.conf import global_settings, settings from django.conf import global_settings, settings
from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
UUID_REGEX = ( UUID_REGEX = (
@@ -33,14 +32,3 @@ ALL_LANGUAGES = getattr(
"ALL_LANGUAGES", "ALL_LANGUAGES",
[(language, _(name)) for language, name in global_settings.LANGUAGES], [(language, _(name)) for language, name in global_settings.LANGUAGES],
) )
class EncryptionMode(models.TextChoices):
"""Encryption mode for a room.
Kept as an enum (not a boolean) so future modes — e.g. a vault-managed
per-user key flow — can be added without another schema migration.
"""
NONE = "none", _("No encryption")
BASIC = "basic", _("Passphrase-in-URL encryption")
@@ -286,10 +286,6 @@ class ResourceServerBackend(LaSuiteBackend):
if user is None and settings.OIDC_CREATE_USER: if user is None and settings.OIDC_CREATE_USER:
user = self.create_user(sub) user = self.create_user(sub)
if user is not None and not user.is_active:
logger.warning("Inactive user attempted authentication: %s", user.pk)
raise SuspiciousOperation("User account is disabled.")
return user return user
def create_user(self, sub): def create_user(self, sub):
+21 -64
View File
@@ -1,6 +1,5 @@
"""External API endpoints""" """External API endpoints"""
import copy
from logging import getLogger from logging import getLogger
from django.conf import settings from django.conf import settings
@@ -26,7 +25,6 @@ from rest_framework import (
from core import analytics, api, models from core import analytics, api, models
from core.api.feature_flag import FeatureFlag from core.api.feature_flag import FeatureFlag
from core.services.jwt_token import JwtTokenService from core.services.jwt_token import JwtTokenService
from core.services.room_management import RoomManagement
from ..services.provisional_user_service import ( from ..services.provisional_user_service import (
ProvisionalUserCreationDisabledError, ProvisionalUserCreationDisabledError,
@@ -144,7 +142,6 @@ class RoomViewSet(
mixins.CreateModelMixin, mixins.CreateModelMixin,
mixins.RetrieveModelMixin, mixins.RetrieveModelMixin,
mixins.ListModelMixin, mixins.ListModelMixin,
mixins.UpdateModelMixin,
viewsets.GenericViewSet, viewsets.GenericViewSet,
): ):
"""Application-delegated API for room management. """Application-delegated API for room management.
@@ -157,12 +154,8 @@ class RoomViewSet(
- list: List rooms the user has access to (requires 'rooms:list' scope) - list: List rooms the user has access to (requires 'rooms:list' scope)
- retrieve: Get room details (requires 'rooms:retrieve' scope) - retrieve: Get room details (requires 'rooms:retrieve' scope)
- create: Create a new room owned by the user (requires 'rooms:create' scope) - create: Create a new room owned by the user (requires 'rooms:create' scope)
- partial_update: Update a room's access level and configuration, for
administrators and owners only (requires 'rooms:update' scope)
""" """
http_method_names = ["get", "post", "patch", "head", "options"]
authentication_classes = [ authentication_classes = [
authentication.ApplicationJWTAuthentication, authentication.ApplicationJWTAuthentication,
authentication.AddonsJWTAuthentication, authentication.AddonsJWTAuthentication,
@@ -196,39 +189,7 @@ class RoomViewSet(
serializer = self.get_serializer(queryset, many=True) serializer = self.get_serializer(queryset, many=True)
return drf_response.Response(serializer.data) return drf_response.Response(serializer.data)
def _track_room_event(self, room, event, **extra_properties): def perform_create(self, serializer):
"""Log a room operation for auditing and forward it to analytics."""
auth_method = type(self.request.successful_authenticator).__name__
client_id = (self.request.auth or {}).get("client_id", "unknown")
# Log for auditing
details = "".join(f", {key}={value}" for key, value in extra_properties.items())
logger.info(
"Room %s via application: room_id=%s, user_id=%s, client_id=%s, auth_method=%s%s",
event.removeprefix("room_"),
room.id,
self.request.user.id,
client_id,
auth_method,
details,
)
analytics.capture(
self.request.user,
event,
{
"room_id": str(room.pk),
"access_level": room.access_level,
"client_id": client_id,
"external_api": True,
"auth_method": auth_method,
**extra_properties,
"$set": {"email": self.request.user.email},
},
)
def perform_create(self, serializer: serializers.RoomSerializer):
"""Set the current user as owner of the newly created room.""" """Set the current user as owner of the newly created room."""
room = serializer.save() room = serializer.save()
models.ResourceAccess.objects.create( models.ResourceAccess.objects.create(
@@ -237,31 +198,27 @@ class RoomViewSet(
role=models.RoleChoices.OWNER, role=models.RoleChoices.OWNER,
) )
self._track_room_event(room, analytics.AnalyticsEvent.ROOM_CREATED) auth_method = type(self.request.successful_authenticator).__name__
client_id = (self.request.auth or {}).get("client_id", "unknown")
def perform_update(self, serializer: serializers.RoomSerializer): # Log for auditing
"""Persist the room update, sync it to LiveKit, then log and track it.""" logger.info(
"Room created via application: room_id=%s, user_id=%s, client_id=%s, auth_method=%s",
previous_values = { room.id,
"access_level": serializer.instance.access_level, self.request.user.id,
"configuration": copy.deepcopy(serializer.instance.configuration), client_id,
} auth_method,
room = serializer.save()
# Report the fields that actually changed, not the ones that were submitted.
updated_fields = sorted(
field
for field, previous_value in previous_values.items()
if getattr(room, field) != previous_value
) )
if updated_fields: analytics.capture(
RoomManagement.sync_room_metadata(room) self.request.user,
analytics.AnalyticsEvent.ROOM_CREATED,
self._track_room_event( {
room, "room_id": str(room.pk),
analytics.AnalyticsEvent.ROOM_UPDATED, "access_level": room.access_level,
updated_fields=updated_fields, "client_id": client_id,
previous_access_level=previous_values["access_level"], "external_api": True,
"auth_method": auth_method,
"$set": {"email": self.request.user.email},
},
) )
+4
View File
@@ -48,6 +48,8 @@ class ResourceFactory(factory.django.DjangoModelFactory):
else: else:
UserResourceAccessFactory(resource=self, user=item[0], role=item[1]) UserResourceAccessFactory(resource=self, user=item[0], role=item[1])
self.save()
class UserResourceAccessFactory(factory.django.DjangoModelFactory): class UserResourceAccessFactory(factory.django.DjangoModelFactory):
"""Create fake resource user accesses for testing.""" """Create fake resource user accesses for testing."""
@@ -95,6 +97,8 @@ class RecordingFactory(factory.django.DjangoModelFactory):
recording=self, user=item[0], role=item[1] recording=self, user=item[0], role=item[1]
) )
self.save()
class UserRecordingAccessFactory(factory.django.DjangoModelFactory): class UserRecordingAccessFactory(factory.django.DjangoModelFactory):
"""Create fake recording user accesses for testing.""" """Create fake recording user accesses for testing."""
-25
View File
@@ -1,25 +0,0 @@
"""Logging filters for the core application."""
import logging
from django.conf import settings
class SilenceExpected401(logging.Filter):
"""Drop the expected 401 from anonymous hits on the /me endpoint.
The frontend probes `/users/me/` to check authentication; a 401 for
anonymous users is normal, not a warning worth logging.
"""
def filter(self, record):
"""Return False for a 401 on a silenced path, True otherwise."""
if getattr(record, "status_code", None) != 401:
return True
request = getattr(record, "request", None)
path = getattr(request, "path", None)
if not path:
return True
return path not in settings.LOGGING_SILENCED_401_PATHS
+1 -3
View File
@@ -8,8 +8,6 @@ import uuid
from django.conf import settings from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
import core.validators
class Migration(migrations.Migration): class Migration(migrations.Migration):
@@ -43,7 +41,7 @@ class Migration(migrations.Migration):
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')), ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')), ('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')), ('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('sub', models.CharField(blank=True, help_text='Optional for pending users; required upon account activation. 255 characters or fewer. Printable ASCII characters only.', max_length=255, null=True, unique=True, validators=[core.validators.sub_validator], verbose_name='sub')), ('sub', models.CharField(blank=True, help_text='Optional for pending users; required upon account activation. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')), ('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')), ('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
('language', models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')), ('language', models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
@@ -1,18 +0,0 @@
# Generated by Django 5.2.16 on 2026-09-23 16:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0022_user_default_room_access_level_and_more'),
]
operations = [
migrations.AlterField(
model_name='recording',
name='status',
field=models.CharField(choices=[('initiated', 'Initiated'), ('active', 'Active'), ('stopped', 'Stopped'), ('saved', 'Saved'), ('aborted', 'Aborted'), ('failed', 'Failed'), ('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),
),
]
@@ -1,46 +0,0 @@
"""Add Room.encryption_mode and User.default_encryption_mode (enum-based).
We store the mode as an enum (CharField with choices) rather than a boolean
so a future "advanced" mode (per-user vault keys, etc.) can be added without
a schema migration.
"""
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0023_alter_recording_status"),
]
operations = [
migrations.AddField(
model_name="room",
name="encryption_mode",
field=models.CharField(
choices=[
("none", "No encryption"),
("basic", "Passphrase-in-URL encryption"),
],
default="none",
help_text="End-to-end encryption mode for this room.",
max_length=20,
verbose_name="Encryption mode",
),
),
migrations.AddField(
model_name="user",
name="default_encryption_mode",
field=models.CharField(
choices=[
("none", "No encryption"),
("basic", "Passphrase-in-URL encryption"),
],
default="none",
help_text="Encryption mode pre-selected when this user creates a new meeting.",
max_length=20,
verbose_name="Default encryption mode",
),
),
]
+17 -78
View File
@@ -26,9 +26,7 @@ from lasuite.tools.email import get_domain_from_email
from timezone_field import TimeZoneField from timezone_field import TimeZoneField
from . import fields, utils from . import fields, utils
from .enums import EncryptionMode
from .recording.enums import FileExtension from .recording.enums import FileExtension
from .validators import sub_validator
logger = getLogger(__name__) logger = getLogger(__name__)
@@ -59,7 +57,6 @@ class RecordingStatusChoices(models.TextChoices):
STOPPED = "stopped", _("Stopped") STOPPED = "stopped", _("Stopped")
SAVED = "saved", _("Saved") SAVED = "saved", _("Saved")
ABORTED = "aborted", _("Aborted") ABORTED = "aborted", _("Aborted")
FAILED = "failed", _("Failed")
FAILED_TO_START = "failed_to_start", _("Failed to Start") FAILED_TO_START = "failed_to_start", _("Failed to Start")
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop") FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded") NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded")
@@ -81,13 +78,17 @@ class RecordingStatusChoices(models.TextChoices):
cls.STOPPED, cls.STOPPED,
cls.SAVED, cls.SAVED,
cls.ABORTED, cls.ABORTED,
cls.FAILED,
cls.EXTERNAL_PROCESS_SUCCESSFUL, cls.EXTERNAL_PROCESS_SUCCESSFUL,
cls.EXTERNAL_PROCESS_FAILED, cls.EXTERNAL_PROCESS_FAILED,
cls.FAILED_TO_START, cls.FAILED_TO_START,
cls.FAILED_TO_STOP, cls.FAILED_TO_STOP,
} }
@classmethod
def is_unsuccessful(cls, status):
"""Determine if the recording status represents an unsuccessful state."""
return status in {cls.ABORTED, cls.FAILED_TO_START, cls.FAILED_TO_STOP}
class RecordingModeChoices(models.TextChoices): class RecordingModeChoices(models.TextChoices):
"""Recording mode choices.""" """Recording mode choices."""
@@ -144,11 +145,19 @@ class BaseModel(models.Model):
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin): class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"""User model to work with OIDC only authentication.""" """User model to work with OIDC only authentication."""
sub_validator = validators.RegexValidator(
regex=r"^[\w.@+-]+\Z",
message=_(
"Enter a valid sub. This value may contain only letters, "
"numbers, and @/./+/-/_ characters."
),
)
sub = models.CharField( sub = models.CharField(
_("sub"), _("sub"),
help_text=_( help_text=_(
"Optional for pending users; required upon account activation. " "Optional for pending users; required upon account activation. "
"255 characters or fewer. Printable ASCII characters only." "255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
), ),
max_length=255, max_length=255,
unique=True, unique=True,
@@ -217,15 +226,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
"Unselect this instead of deleting accounts." "Unselect this instead of deleting accounts."
), ),
) )
default_encryption_mode = models.CharField(
_("Default encryption mode"),
max_length=20,
choices=EncryptionMode.choices,
default=EncryptionMode.NONE,
help_text=_(
"Encryption mode pre-selected when this user creates a new meeting."
),
)
objects = auth_models.UserManager() objects = auth_models.UserManager()
@@ -421,13 +421,6 @@ class Room(Resource):
choices=RoomAccessLevel.choices, choices=RoomAccessLevel.choices,
default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL, default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
) )
encryption_mode = models.CharField(
max_length=20,
choices=EncryptionMode.choices,
default=EncryptionMode.NONE,
verbose_name=_("Encryption mode"),
help_text=_("End-to-end encryption mode for this room."),
)
# Public configuration exposed to any room participant via the API # Public configuration exposed to any room participant via the API
configuration = models.JSONField( configuration = models.JSONField(
blank=True, blank=True,
@@ -454,68 +447,20 @@ class Room(Resource):
return capfirst(self.name) return capfirst(self.name)
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
"""Restrict new encrypted rooms and allocate PINs for unencrypted rooms. """Generate a unique n-digit pin code for new rooms."""
Skip PIN allocation for encrypted rooms — the SIP gateway will # Roomkit devices also join by PIN, so a PIN is needed as soon as
always reject calls to them (no way to derive the key), and the # either integration is enabled.
PIN namespace is finite (10**length): no point burning slots that
can never be dialed.
Also run `clean()` so the encryption invariants are enforced on
every save path (ORM, admin, shell), not only via the DRF
serializer.
"""
# Override both explicit access levels and user defaults on creation.
# Updates remain subject to clean() instead of being silently normalized.
if self._state.adding and self.is_encrypted:
self.access_level = RoomAccessLevel.RESTRICTED
self.clean()
if ( if (
(settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED) (settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED)
and not self.pk and not self.pk
and not self.pin_code and not self.pin_code
and self.encryption_mode == EncryptionMode.NONE
): ):
self.pin_code = self.generate_unique_pin_code( self.pin_code = self.generate_unique_pin_code(
length=settings.ROOM_TELEPHONY_PIN_LENGTH length=settings.ROOM_TELEPHONY_PIN_LENGTH
) )
super().save(*args, **kwargs) super().save(*args, **kwargs)
def clean(self):
"""Enforce encryption-mode invariants outside DRF.
Two rules:
- `encryption_mode` is set at creation and never mutated afterwards
(the URL-hash passphrase encodes assumptions about it).
- An encrypted room must be at the RESTRICTED access level so the
host vets joiners before they ever see the in-URL key.
"""
super().clean()
if self.pk is not None:
previous = Room.objects.filter(pk=self.pk).only("encryption_mode").first()
if (
previous is not None
and previous.encryption_mode != self.encryption_mode
):
raise ValidationError(
{
"encryption_mode": _(
"Encryption mode cannot be changed after room creation."
)
}
)
if (
self.encryption_mode != EncryptionMode.NONE
and self.access_level != RoomAccessLevel.RESTRICTED
):
raise ValidationError(
{
"access_level": _(
"Encrypted rooms must use the 'restricted' access level."
)
}
)
def clean_fields(self, exclude=None): def clean_fields(self, exclude=None):
""" """
Automatically generate the slug from the name and make sure it does not look like a UUID. Automatically generate the slug from the name and make sure it does not look like a UUID.
@@ -538,11 +483,6 @@ class Room(Resource):
"""Check if a room is public""" """Check if a room is public"""
return self.access_level == RoomAccessLevel.PUBLIC return self.access_level == RoomAccessLevel.PUBLIC
@property
def is_encrypted(self):
"""Convenience: any non-none encryption mode counts as encrypted."""
return self.encryption_mode != EncryptionMode.NONE
@staticmethod @staticmethod
def generate_unique_pin_code(length): def generate_unique_pin_code(length):
"""Generate a unique n-digit PIN code""" """Generate a unique n-digit PIN code"""
@@ -649,7 +589,6 @@ class Recording(BaseModel):
4. NOTIFICATION_SUCCEEDED: External service has been notified of this recording 4. NOTIFICATION_SUCCEEDED: External service has been notified of this recording
Error States: Error States:
- FAILED: Egress failed mid-recording
- FAILED_TO_START: Worker failed to initialize recording - FAILED_TO_START: Worker failed to initialize recording
- FAILED_TO_STOP: Worker failed during stop operation - FAILED_TO_STOP: Worker failed during stop operation
- ABORTED: Recording was terminated before completion - ABORTED: Recording was terminated before completion
-47
View File
@@ -8,50 +8,3 @@ class FileExtension(Enum):
OGG = "ogg" OGG = "ogg"
MP4 = "mp4" MP4 = "mp4"
class RecordingWorkerEvent(Enum):
"""Lifecycle events a recording worker reports about a recording.
It is intended to be free of SFU-specific vocabulary.
"""
# The worker accepted the request but is not recording yet.
STARTING = "starting"
# The worker is recording.
STARTED = "started"
# The worker stopped recording and is flushing the media file.
SAVING = "saving"
# The recording ended, its media file is available.
COMPLETED = "completed"
# The recording ended on its configured limit, its media file is available.
LIMIT_REACHED = "limit reached"
# The worker stopped before it ever started recording, there is no media file.
ABORTED = "aborted"
# The worker hit a runtime error once recording had started; its media file
# may be available.
FAILED = "failed"
@classmethod
def is_terminal(cls, event):
"""Determine if the event ends the recording's lifecycle (successful or not)."""
return event in TERMINAL_EVENTS
SUCCESSFUL_EVENTS = frozenset(
{
RecordingWorkerEvent.COMPLETED,
RecordingWorkerEvent.LIMIT_REACHED,
}
)
UNSUCCESSFUL_EVENTS = frozenset(
{
RecordingWorkerEvent.ABORTED,
RecordingWorkerEvent.FAILED,
}
)
TERMINAL_EVENTS = SUCCESSFUL_EVENTS | UNSUCCESSFUL_EVENTS
@@ -1,13 +1,13 @@
"""Recording-related Events Service""" """Recording-related LiveKit Events Service"""
# pylint: disable=no-member
from logging import getLogger from logging import getLogger
from livekit import api
from core import models, utils from core import models, utils
from core.models import Recording from core.models import Recording
from core.recording.enums import (
UNSUCCESSFUL_EVENTS,
RecordingWorkerEvent,
)
from core.recording.event.notification import notification_service from core.recording.event.notification import notification_service
from core.services.room_management import ( from core.services.room_management import (
RoomManagement, RoomManagement,
@@ -26,113 +26,25 @@ class RecordingNotSavableError(Exception):
"""Recording cannot be saved because it is either in an error state or has already been saved""" """Recording cannot be saved because it is either in an error state or has already been saved"""
# Notification sent to the room's participants, per event and recording mode.
NOTIFICATION_PREFIXES = {
models.RecordingModeChoices.SCREEN_RECORDING: "screenRecording",
models.RecordingModeChoices.TRANSCRIPT: "transcription",
}
NOTIFICATION_SUFFIXES = {
RecordingWorkerEvent.LIMIT_REACHED: "LimitReached",
RecordingWorkerEvent.FAILED: "Failed",
RecordingWorkerEvent.ABORTED: "Aborted",
}
def get_notification_type(recording_mode, event):
"""Generate corresponding notification type string."""
try:
return f"{NOTIFICATION_PREFIXES[recording_mode]}{NOTIFICATION_SUFFIXES[event]}"
except KeyError:
return None
# Recording status in the room's metadata, per event.
ROOM_METADATA_RECORDING_STATUSES = {
RecordingWorkerEvent.STARTED: "started",
RecordingWorkerEvent.SAVING: "saving",
}
class RecordingEventsService: class RecordingEventsService:
"""Handles recording-related worker events. """Handles recording-related LiveKit webhook events."""
Two entry points: `handle_update` for the events a running recording
reports, and `handle_terminal_event` for the one ending it.
"""
@staticmethod @staticmethod
def log_worker_error(recording, event, error=None, error_code=None): def handle_update(recording: Recording, egress_status):
"""Log FAILED at error level and expected ABORTED outcomes at info level.""" """Handle egress status updates and sync recording state to room metadata."""
if event == RecordingWorkerEvent.FAILED:
log = logger.error
elif event == RecordingWorkerEvent.ABORTED:
log = logger.info
else:
return
log(
"Recording worker reported %s for recording %s (room=%s, mode=%s): %s (error_code=%s)",
event.value,
recording.id,
recording.room.id,
recording.mode,
error or "no error reported",
error_code or "no error_code reported",
)
@staticmethod
def _notify_participants(recording: Recording, event: RecordingWorkerEvent):
"""Notify the room's participants that a recording ended on the given event."""
recording_mode = recording.options.get("original_mode", None) or recording.mode
notification_type = get_notification_type(recording_mode, event)
if not notification_type:
logger.warning(
"Could not find notification type for: "
"room=%s, recording_id=%s, mode=%s, event=%s",
recording.room.id,
recording.id,
recording_mode,
event.value,
)
return
try:
utils.notify_participants(
room_name=str(recording.room.id),
notification_data={"type": notification_type},
)
except utils.NotificationError as e:
raise RecordingEventsError(
f"Failed to notify participants in room '{recording.room.id}' about "
f"recording {event.value} (recording_id={recording.id})"
) from e
@staticmethod
def _log_notification_failure(recording, event: RecordingWorkerEvent):
"""Log a participant notification error on an unsuccessful recording."""
logger.exception(
"Failed to notify participants that recording %s %s (room=%s)",
recording.id,
event.value,
recording.room.id,
)
@staticmethod
def handle_update(recording: Recording, event: RecordingWorkerEvent):
"""Handle non-terminal worker events and sync recording state to room metadata.
Terminal events are dispatched through `handle_terminal_event` instead.
"""
room_name = str(recording.room.id) room_name = str(recording.room.id)
recording_status = ROOM_METADATA_RECORDING_STATUSES.get(event) status_mapping = {
api.EgressStatus.EGRESS_ACTIVE: "started",
api.EgressStatus.EGRESS_ENDING: "saving",
api.EgressStatus.EGRESS_ABORTED: "aborted",
}
recording_status = status_mapping.get(egress_status)
if recording_status: if recording_status:
try: try:
RoomManagement.update_metadata( RoomManagement().update_metadata(
room_name, {"recording_status": recording_status} room_name, {"recording_status": recording_status}
) )
except RoomNotFoundException: except RoomNotFoundException:
@@ -143,113 +55,42 @@ class RecordingEventsService:
except RoomManagementException as e: except RoomManagementException as e:
logger.exception("Failed to update room's metadata: %s", e) logger.exception("Failed to update room's metadata: %s", e)
def handle_terminal_event(self, recording: Recording, event: RecordingWorkerEvent): @staticmethod
"""Run the appropriate handlers for a terminal event, given the recording's state.""" def handle_limit_reached(recording: Recording):
if not RecordingWorkerEvent.is_terminal(event):
logger.warning(
"Ignoring non-terminal event %s dispatched as a terminal event "
"for recording %s.",
event.value,
recording.id,
)
return
if event in UNSUCCESSFUL_EVENTS:
self._flag_unsuccessful_recording(recording, event)
else:
self._save_successful_recording(recording, event)
def _flag_unsuccessful_recording(
self, recording: Recording, event: RecordingWorkerEvent
):
"""Persist the outcome of a recording the worker announced as unsuccessful."""
# Aborted
if event == RecordingWorkerEvent.ABORTED:
if recording.status == models.RecordingStatusChoices.ACTIVE:
self._apply_outcome(recording, event, self._handle_aborted)
return
# Failed
if event == RecordingWorkerEvent.FAILED:
if recording.is_savable():
self._apply_outcome(recording, event, self._handle_failed)
return
logger.error(
"Unsuccessful event %s has no handler; recording %s keeps status '%s'.",
event.value,
recording.id,
recording.status,
)
def _save_successful_recording(
self, recording: Recording, event: RecordingWorkerEvent
):
"""Save a recording whose media file the worker made available."""
# Limit reached
if (
event == RecordingWorkerEvent.LIMIT_REACHED
and recording.status == models.RecordingStatusChoices.ACTIVE
):
self._apply_outcome(recording, event, self._handle_limit_reached)
try:
self._handle_successful(recording)
except RecordingNotSavableError:
logger.warning(
"Recording %s is not savable on a completed recording "
"(already saved or in an error state); ignoring.",
recording.id,
)
def _apply_outcome(
self, recording: Recording, event: RecordingWorkerEvent, handler
):
"""Keep notification failure non-fatal."""
try:
handler(recording)
except RecordingEventsError:
self._log_notification_failure(recording, event)
@classmethod
def _handle_limit_reached(cls, recording: Recording):
"""Stop recording and notify participants when limit is reached.""" """Stop recording and notify participants when limit is reached."""
recording.status = models.RecordingStatusChoices.STOPPED recording.status = models.RecordingStatusChoices.STOPPED
recording.save() recording.save()
cls._notify_participants(recording, RecordingWorkerEvent.LIMIT_REACHED) notification_mapping = {
models.RecordingModeChoices.SCREEN_RECORDING: "screenRecordingLimitReached",
models.RecordingModeChoices.TRANSCRIPT: "transcriptionLimitReached",
}
@classmethod notification_type = notification_mapping.get(recording.mode)
def _handle_failed(cls, recording: Recording): if not notification_type:
"""Set recording status to failed, matching the worker event, and notify participants. return
FAILED: used when an actual runtime/pipeline error occurs after the try:
recording has started utils.notify_participants(
""" room_name=str(recording.room.id),
recording.status = models.RecordingStatusChoices.FAILED notification_data={"type": notification_type},
recording.save() )
except utils.NotificationError as e:
cls._notify_participants(recording, RecordingWorkerEvent.FAILED) logger.exception(
"Failed to notify participants about recording limit reached: "
@classmethod "room=%s, recording_id=%s, mode=%s",
def _handle_aborted(cls, recording: Recording): recording.room.id,
"""Set recording status to aborted, matching the worker event, and notify participants. recording.id,
recording.mode,
ABORTED: used when the worker stops before it ever became )
active/recording raise RecordingEventsError(
""" f"Failed to notify participants in room '{recording.room.id}' about "
recording.status = models.RecordingStatusChoices.ABORTED f"recording limit reached (recording_id={recording.id})"
recording.save() ) from e
cls._notify_participants(recording, RecordingWorkerEvent.ABORTED)
@staticmethod @staticmethod
def _handle_successful(recording: Recording): def handle_complete(recording: Recording):
"""Notify external services and save recording.""" """Notify external services and save recording."""
if not recording.is_savable(): if not recording.is_savable():
+45 -17
View File
@@ -22,6 +22,37 @@ _RECORDING_AUDIO_CODEC = livekit_api.AudioCodec.AAC
_RECORDING_AUDIO_FREQUENCY_HZ = 48000 _RECORDING_AUDIO_FREQUENCY_HZ = 48000
def _build_default_encoding_options() -> Optional[Dict[str, Any]]:
"""Build the server-wide EncodingOptions kwargs, or None to keep LiveKit's preset.
Operator-tunable values live in Django settings; the default resolution gives
width / height, the default profile gives framerate and video bitrate, while
codec and frequency are pinned constants. Either default left empty means we
use the livekit defaults.
"""
resolution = settings.RECORDING_ENCODING_DEFAULT_RESOLUTION
profile = settings.RECORDING_ENCODING_DEFAULT_PROFILE
if not resolution or not profile:
return None
dimensions = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[resolution]
profile_spec = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile]
return {
"width": dimensions["width"],
"height": dimensions["height"],
"framerate": profile_spec["fps"],
"video_bitrate": profile_spec["kbps"][resolution],
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
"video_codec": _RECORDING_VIDEO_CODEC,
"audio_codec": _RECORDING_AUDIO_CODEC,
"audio_frequency": _RECORDING_AUDIO_FREQUENCY_HZ,
}
@dataclass(frozen=True) @dataclass(frozen=True)
class WorkerServiceConfig: class WorkerServiceConfig:
"""Declare Worker Service common configurations""" """Declare Worker Service common configurations"""
@@ -38,22 +69,14 @@ class WorkerServiceConfig:
logger.debug("Loading WorkerServiceConfig from settings.") logger.debug("Loading WorkerServiceConfig from settings.")
encoding_options: Optional[Dict[str, Any]] = None # Single source of truth for the EncodingOptions kwargs; the services
if settings.RECORDING_ENCODING_ENABLED: # layer only unpacks this dict. Recordings carrying their own encoding
# Single source of truth for the EncodingOptions kwargs: # resolve it per request and bypass this default.
# operator-tunable values live in Django settings, codec / frequency encoding_options: Optional[Dict[str, Any]] = (
# are pinned constants. The services layer only unpacks this dict. _build_default_encoding_options()
encoding_options = { if settings.RECORDING_ENCODING_ENABLED
"width": settings.RECORDING_ENCODING_WIDTH, else None
"height": settings.RECORDING_ENCODING_HEIGHT, )
"framerate": settings.RECORDING_ENCODING_FRAMERATE,
"video_bitrate": settings.RECORDING_ENCODING_VIDEO_BITRATE_KBPS,
"audio_bitrate": settings.RECORDING_ENCODING_AUDIO_BITRATE_KBPS,
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
"video_codec": _RECORDING_VIDEO_CODEC,
"audio_codec": _RECORDING_AUDIO_CODEC,
"audio_frequency": _RECORDING_AUDIO_FREQUENCY_HZ,
}
return cls( return cls(
output_folder=settings.RECORDING_OUTPUT_FOLDER, output_folder=settings.RECORDING_OUTPUT_FOLDER,
@@ -78,7 +101,12 @@ class WorkerService(Protocol):
def __init__(self, config: WorkerServiceConfig): def __init__(self, config: WorkerServiceConfig):
"""Initialize the service with the given configuration.""" """Initialize the service with the given configuration."""
def start(self, room_id: str, recording_id: str) -> str: def start(
self,
room_id: str,
recording_id: str,
encoding_options: Optional[Dict[str, Any]] = None,
) -> str:
"""Start a recording for a specified room.""" """Start a recording for a specified room."""
def stop(self, worker_id: str) -> str: def stop(self, worker_id: str) -> str:
@@ -51,8 +51,11 @@ class WorkerServiceMediator:
raise RecordingStartError() raise RecordingStartError()
room_name = str(recording.room.id) room_name = str(recording.room.id)
encoding_options = (recording.options.get("encoding") or {}).get("resolved")
try: try:
worker_id = self._worker_service.start(room_name, recording.id) worker_id = self._worker_service.start(
room_name, recording.id, encoding_options=encoding_options
)
except (WorkerRequestError, WorkerConnectionError, WorkerResponseError) as e: except (WorkerRequestError, WorkerConnectionError, WorkerResponseError) as e:
logger.exception( logger.exception(
"Failed to start recording for room %s: %s", recording.room.slug, e "Failed to start recording for room %s: %s", recording.room.slug, e
@@ -68,7 +71,7 @@ class WorkerServiceMediator:
mode = recording.options.get("original_mode", None) or recording.mode mode = recording.options.get("original_mode", None) or recording.mode
try: try:
RoomManagement.update_metadata( RoomManagement().update_metadata(
room_name, {"recording_mode": mode, "recording_status": "starting"} room_name, {"recording_mode": mode, "recording_status": "starting"}
) )
except RoomNotFoundException: except RoomNotFoundException:
+64 -40
View File
@@ -2,7 +2,7 @@
# pylint: disable=no-member # pylint: disable=no-member
import logging from django.conf import settings
from asgiref.sync import async_to_sync from asgiref.sync import async_to_sync
from livekit import api as livekit_api from livekit import api as livekit_api
@@ -12,7 +12,40 @@ from ..enums import FileExtension
from .exceptions import WorkerConnectionError, WorkerResponseError from .exceptions import WorkerConnectionError, WorkerResponseError
from .factories import WorkerServiceConfig from .factories import WorkerServiceConfig
logger = logging.getLogger(__name__)
def resolve_encoding_config(encoding_config):
"""Resolve a per-recording EncodingConfig to concrete encoding fields.
Returns a JSON-serializable dict of the LiveKit ``EncodingOptions`` kwargs
derived from the request's resolution / profile, or None when no
encoding_config is provided. This allows to derive width, height, fps, and
bitrate from (resolution, profile).
Only the fields that can actually be resolved are included: width/height
require a resolution, framerate/video_bitrate require both a resolution and a
profile.
"""
if encoding_config is None:
return None
resolution = encoding_config.resolution
profile = encoding_config.profile
resolved = {
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
}
if resolution:
dimensions = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[resolution]
resolved["width"] = dimensions["width"]
resolved["height"] = dimensions["height"]
if resolution and profile:
profile_spec = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile]
resolved["framerate"] = profile_spec["fps"]
resolved["video_bitrate"] = profile_spec["kbps"][resolution]
return resolved
class BaseEgressService: class BaseEgressService:
@@ -53,22 +86,6 @@ class BaseEgressService:
finally: finally:
await lkapi.aclose() await lkapi.aclose()
@staticmethod
def _log_egress_error(response, event: str):
"""Log the reason LiveKit reported an unsuccessful egress on stop.
Mirrors the logging done in the 'egress_ended' webhook. The
StopEgress response carries the same error fields.
"""
logger.error(
"Egress %s on stop (egress_id=%s, status=%s): %s (error_code=%s)",
event,
response.egress_id,
livekit_api.EgressStatus.Name(response.status),
response.error or "no error reported",
response.error_code or "no error_code reported",
)
def stop(self, worker_id: str) -> str: def stop(self, worker_id: str) -> str:
"""Stop an ongoing egress worker. """Stop an ongoing egress worker.
The StopEgressRequest is shared among all types of egress, The StopEgressRequest is shared among all types of egress,
@@ -86,29 +103,17 @@ class BaseEgressService:
"LiveKit response is missing the recording status." "LiveKit response is missing the recording status."
) )
# To avoid exposing EgressStatus values and coupling with LiveKit outside of this class,
# the response status is mapped to simpler "ABORTED", "STOPPED" or "FAILED_TO_STOP" strings.
if response.status == livekit_api.EgressStatus.EGRESS_ABORTED:
return "ABORTED"
if response.status == livekit_api.EgressStatus.EGRESS_ENDING: if response.status == livekit_api.EgressStatus.EGRESS_ENDING:
return "STOPPED" return "STOPPED"
if response.status == livekit_api.EgressStatus.EGRESS_LIMIT_REACHED:
return "STOPPED"
# Cases below should be very infrequent as status changes should be
# received and processed by `handle_ended`, thus `stop` would not
# be called (unless failure and stop are very close in time).
# We therefore accept not to notify the user in this code branch.
# This could be fixed in a future refactoring.
if response.status == livekit_api.EgressStatus.EGRESS_ABORTED:
self._log_egress_error(response, "aborted")
return "ABORTED"
if response.status == livekit_api.EgressStatus.EGRESS_FAILED:
self._log_egress_error(response, "failed")
return "FAILED"
self._log_egress_error(response, "failed to stop")
return "FAILED_TO_STOP" return "FAILED_TO_STOP"
def start(self, room_name, recording_id): def start(self, room_name, recording_id, encoding_options=None):
"""Start the egress process for a recording (not implemented in the base class). """Start the egress process for a recording (not implemented in the base class).
Each derived class must implement this method, providing the necessary parameters for Each derived class must implement this method, providing the necessary parameters for
its specific egress type (e.g. audio_only, streaming output). its specific egress type (e.g. audio_only, streaming output).
@@ -131,13 +136,24 @@ class BaseEgressService:
return livekit_api.EncodingOptions(**opts) return livekit_api.EncodingOptions(**opts)
def _resolve_encoding_options(self, encoding_options):
"""Build LiveKit EncodingOptions from a resolved per-recording dict, or None.
``encoding_options`` is the dict persisted by the API in
``recording.options["encoding"]["resolved"]``.
"""
if not encoding_options:
return None
return livekit_api.EncodingOptions(**encoding_options)
class VideoCompositeEgressService(BaseEgressService): class VideoCompositeEgressService(BaseEgressService):
"""Record multiple participant video and audio tracks into a single output '.mp4' file.""" """Record multiple participant video and audio tracks into a single output '.mp4' file."""
hrid = "video-recording-composite-livekit-egress" hrid = "video-recording-composite-livekit-egress"
def start(self, room_name, recording_id): def start(self, room_name, recording_id, encoding_options=None):
"""Start the video composite egress process for a recording.""" """Start the video composite egress process for a recording."""
# Save room's recording as a mp4 video file. # Save room's recording as a mp4 video file.
@@ -158,7 +174,10 @@ class VideoCompositeEgressService(BaseEgressService):
"layout": "speaker-light", "layout": "speaker-light",
} }
advanced = self._build_encoding_options() advanced = (
self._resolve_encoding_options(encoding_options)
or self._build_encoding_options()
)
if advanced is not None: if advanced is not None:
request_kwargs["advanced"] = advanced request_kwargs["advanced"] = advanced
@@ -177,8 +196,13 @@ class AudioCompositeEgressService(BaseEgressService):
hrid = "audio-recording-composite-livekit-egress" hrid = "audio-recording-composite-livekit-egress"
def start(self, room_name, recording_id): def start(self, room_name, recording_id, encoding_options=None):
"""Start the audio composite egress process for a recording.""" """Start the audio composite egress process for a recording.
``encoding_options`` is accepted for signature compatibility with the
WorkerService protocol but ignored: audio-only egress has no
encoding to configure.
"""
# Save room's recording as an ogg audio file. # Save room's recording as an ogg audio file.
file_type = livekit_api.EncodedFileType.OGG file_type = livekit_api.EncodedFileType.OGG
+45 -64
View File
@@ -12,12 +12,15 @@ from django.conf import settings
from livekit import api from livekit import api
from core import models from core import models
from core.recording.enums import RecordingWorkerEvent
from core.recording.services.metadata_collector import ( from core.recording.services.metadata_collector import (
MetadataCollectorException, MetadataCollectorException,
MetadataCollectorService, MetadataCollectorService,
) )
from core.recording.services.recording_events import RecordingEventsService from core.recording.services.recording_events import (
RecordingEventsError,
RecordingEventsService,
RecordingNotSavableError,
)
from .lobby import LobbyService from .lobby import LobbyService
from .presence import PresenceCache from .presence import PresenceCache
@@ -49,6 +52,12 @@ class InvalidPayloadError(LiveKitWebhookError):
status_code = 400 status_code = 400
class UnsupportedEventTypeError(LiveKitWebhookError):
"""Unsupported event type."""
status_code = 422
class ActionFailedError(LiveKitWebhookError): class ActionFailedError(LiveKitWebhookError):
"""Webhook action fails to process or complete.""" """Webhook action fails to process or complete."""
@@ -65,7 +74,6 @@ class LiveKitWebhookEventType(Enum):
# Participant events # Participant events
PARTICIPANT_JOINED = "participant_joined" PARTICIPANT_JOINED = "participant_joined"
PARTICIPANT_LEFT = "participant_left" PARTICIPANT_LEFT = "participant_left"
PARTICIPANT_CONNECTION_ABORTED = "participant_connection_aborted"
# Track events # Track events
TRACK_PUBLISHED = "track_published" TRACK_PUBLISHED = "track_published"
@@ -81,30 +89,6 @@ class LiveKitWebhookEventType(Enum):
INGRESS_ENDED = "ingress_ended" INGRESS_ENDED = "ingress_ended"
# LiveKit egress statuses mapped to recording worker event statuses
EGRESS_STATUS_TO_RECORDING_EVENT = {
api.EgressStatus.EGRESS_STARTING: RecordingWorkerEvent.STARTING,
api.EgressStatus.EGRESS_ACTIVE: RecordingWorkerEvent.STARTED,
api.EgressStatus.EGRESS_ENDING: RecordingWorkerEvent.SAVING,
api.EgressStatus.EGRESS_COMPLETE: RecordingWorkerEvent.COMPLETED,
api.EgressStatus.EGRESS_LIMIT_REACHED: RecordingWorkerEvent.LIMIT_REACHED,
api.EgressStatus.EGRESS_ABORTED: RecordingWorkerEvent.ABORTED,
api.EgressStatus.EGRESS_FAILED: RecordingWorkerEvent.FAILED,
}
def to_recording_event(egress_status):
"""Translate a LiveKit egress status into a recording worker event."""
event = EGRESS_STATUS_TO_RECORDING_EVENT.get(egress_status)
if event is None:
logger.warning(
"Unmapped LiveKit egress status '%s', ignoring the event.",
egress_status,
)
return event
class LiveKitEventsService: class LiveKitEventsService:
"""Service for processing and handling LiveKit webhook events and notifications.""" """Service for processing and handling LiveKit webhook events and notifications."""
@@ -169,13 +153,10 @@ class LiveKitEventsService:
try: try:
webhook_type = LiveKitWebhookEventType(data.event) webhook_type = LiveKitWebhookEventType(data.event)
except ValueError: except ValueError as e:
logger.warning( raise UnsupportedEventTypeError(
"Ignoring unknown LiveKit webhook event type '%s' for room '%s'", f"Unknown webhook type: {data.event}"
data.event, ) from e
room_name,
)
return
# Handle according to received webhook type # Handle according to received webhook type
handler = self._webhook_handlers.get(webhook_type.value) handler = self._webhook_handlers.get(webhook_type.value)
@@ -194,20 +175,12 @@ class LiveKitEventsService:
f"Recording with worker ID {egress_id} does not exist" f"Recording with worker ID {egress_id} does not exist"
) from err ) from err
event = to_recording_event(data.egress_info.status) egress_status = data.egress_info.status
if event is None: self.recording_events.handle_update(recording, egress_status)
return
self.recording_events.handle_update(recording, event)
def _handle_egress_ended(self, data): def _handle_egress_ended(self, data):
"""Handle 'egress_ended' event. """Handle 'egress_ended' event."""
Egress ended is sent with one of these statuses:
EGRESS_COMPLETE, EGRESS_FAILED, EGRESS_ABORTED, EGRESS_LIMIT_REACHED
"""
# Fetch recording
try: try:
recording = models.Recording.objects.select_related("room").get( recording = models.Recording.objects.select_related("room").get(
worker_id=data.egress_info.egress_id worker_id=data.egress_info.egress_id
@@ -217,20 +190,9 @@ class LiveKitEventsService:
f"Recording with worker ID {data.egress_info.egress_id} does not exist" f"Recording with worker ID {data.egress_info.egress_id} does not exist"
) from err ) from err
event = to_recording_event(data.egress_info.status)
# Log if/why the recording failed
self.recording_events.log_worker_error(
recording,
event,
error=data.egress_info.error,
error_code=data.egress_info.error_code,
)
# Update room
try: try:
room_name = str(recording.room.id) room_name = str(recording.room.id)
RoomManagement.update_metadata( RoomManagement().update_metadata(
room_name, remove_keys=["recording_mode", "recording_status"] room_name, remove_keys=["recording_mode", "recording_status"]
) )
except RoomNotFoundException: except RoomNotFoundException:
@@ -241,17 +203,38 @@ class LiveKitEventsService:
except RoomManagementException as e: except RoomManagementException as e:
logger.exception("Failed to update room's metadata: %s", e) logger.exception("Failed to update room's metadata: %s", e)
# Stop metadata collector
if recording.options.get("metadata_collector_dispatch_id", None) is not None: if recording.options.get("metadata_collector_dispatch_id", None) is not None:
try: try:
MetadataCollectorService().stop(recording) MetadataCollectorService().stop(recording)
except MetadataCollectorException: except MetadataCollectorException:
logger.warning("Failed to stop the MetadataCollectorService") logger.warning("Failed to stop the MetadataCollectorService")
if event is None: if (
return data.egress_info.status == api.EgressStatus.EGRESS_LIMIT_REACHED
and recording.status == models.RecordingStatusChoices.ACTIVE
):
try:
self.recording_events.handle_limit_reached(recording)
except RecordingEventsError as e:
raise ActionFailedError(
f"Failed to process limit reached event for recording {recording}"
) from e
self.recording_events.handle_terminal_event(recording, event) # Finalize the recording, the egress has uploaded the file to the storage
if data.egress_info.status in [
api.EgressStatus.EGRESS_COMPLETE,
api.EgressStatus.EGRESS_LIMIT_REACHED,
]:
try:
self.recording_events.handle_complete(recording)
except RecordingNotSavableError:
logger.warning(
"Recording %s is not savable on egress complete "
"(already saved or in an error state); ignoring.",
recording.id,
)
# Silently ignoring EGRESS_ABORTED, EGRESS_FAILED
@staticmethod @staticmethod
def _is_connection_test_room(room_name: str) -> bool: def _is_connection_test_room(room_name: str) -> bool:
@@ -275,9 +258,7 @@ class LiveKitEventsService:
except models.Room.DoesNotExist as err: except models.Room.DoesNotExist as err:
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
if ( if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED
) and not room.is_encrypted:
try: try:
self.sip_management.ensure_dispatch_rule(room) self.sip_management.ensure_dispatch_rule(room)
except SIPException as e: except SIPException as e:
+22 -106
View File
@@ -4,12 +4,11 @@ import logging
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
from typing import Dict, FrozenSet, Optional, Sequence, Tuple from typing import Dict, List, Optional, Tuple
from uuid import UUID from uuid import UUID
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.utils import timezone
from core import models, utils from core import models, utils
@@ -47,20 +46,14 @@ class LobbyParticipant:
username: str username: str
color: str color: str
id: str id: str
entered_at: str
# Whether the user signed in (e.g. via ProConnect). Surfaced to admins so
# they can decide whether to accept self-declared identities.
is_authenticated: bool = False
def to_dict(self) -> Dict[str, object]: def to_dict(self) -> Dict[str, str]:
"""Serialize the participant object to a dict representation.""" """Serialize the participant object to a dict representation."""
return { return {
"status": self.status.value, "status": self.status.value,
"username": self.username, "username": self.username,
"id": self.id, "id": self.id,
"color": self.color, "color": self.color,
"entered_at": self.entered_at,
"is_authenticated": self.is_authenticated,
} }
@classmethod @classmethod
@@ -75,8 +68,6 @@ class LobbyParticipant:
username=data["username"], username=data["username"],
id=data["id"], id=data["id"],
color=data["color"], color=data["color"],
entered_at=data["entered_at"],
is_authenticated=bool(data.get("is_authenticated", False)),
) )
except (KeyError, ValueError) as e: except (KeyError, ValueError) as e:
logger.exception("Error creating Participant from dict:") logger.exception("Error creating Participant from dict:")
@@ -95,47 +86,6 @@ class LobbyService:
"""Generate cache key for participant(s) data.""" """Generate cache key for participant(s) data."""
return f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}" return f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
@staticmethod
def _get_index_key(room_id: UUID) -> str:
"""Raw Redis key of the per-room participant index (a native SET)."""
return cache.client.make_key(f"{settings.LOBBY_KEY_PREFIX}-index_{room_id!s}")
@staticmethod
def _redis(write: bool = True):
"""Raw redis-py client.
SADD/SREM/SMEMBERS are not exposed by the Django cache API; this is
the documented django-redis escape hatch.
"""
return cache.client.get_client(write=write)
def _index_add(self, room_id: UUID, participant_id: str) -> None:
"""Record a participant id in the room index."""
index_key = self._get_index_key(room_id)
pipe = self._redis().pipeline(transaction=False)
pipe.sadd(index_key, participant_id)
pipe.expire(index_key, settings.LOBBY_ACCEPTED_TIMEOUT)
pipe.execute()
def _index_members(self, room_id: UUID) -> FrozenSet[str]:
"""All participant ids currently indexed for the room."""
members = self._redis(write=False).smembers(self._get_index_key(room_id))
return frozenset(
member.decode() if isinstance(member, bytes) else member
for member in members
)
def _index_touch(self, room_id: UUID) -> None:
"""Re-arm the room index backstop TTL."""
self._redis().expire(
self._get_index_key(room_id), settings.LOBBY_ACCEPTED_TIMEOUT
)
def _index_remove(self, room_id: UUID, *participant_ids: str) -> None:
"""Drop participant ids from the room index."""
if participant_ids:
self._redis().srem(self._get_index_key(room_id), *participant_ids)
@staticmethod @staticmethod
def _get_or_create_participant_id(request) -> str: def _get_or_create_participant_id(request) -> str:
"""Extract unique participant identifier from the request.""" """Extract unique participant identifier from the request."""
@@ -149,7 +99,7 @@ class LobbyService:
key=settings.LOBBY_COOKIE_NAME, key=settings.LOBBY_COOKIE_NAME,
value=participant_id, value=participant_id,
httponly=True, httponly=True,
secure=not settings.DEBUG, secure=True,
samesite="Lax", samesite="Lax",
) )
@@ -212,8 +162,6 @@ class LobbyService:
username=username, username=username,
id=participant_id, id=participant_id,
color=utils.generate_color(participant_id), color=utils.generate_color(participant_id),
entered_at=timezone.now().isoformat(),
is_authenticated=request.user.is_authenticated,
) )
else: else:
participant.status = LobbyParticipantStatus.ACCEPTED participant.status = LobbyParticipantStatus.ACCEPTED
@@ -226,24 +174,19 @@ class LobbyService:
configuration=room.configuration, configuration=room.configuration,
participant_id=participant_id, participant_id=participant_id,
role=user_role, role=user_role,
encryption_mode=room.encryption_mode,
) )
return participant, livekit_config return participant, livekit_config
livekit_config = None livekit_config = None
if participant is None: if participant is None:
participant = self.enter( participant = self.enter(room.id, participant_id, username)
room.id,
participant_id,
username,
is_authenticated=request.user.is_authenticated,
)
elif participant.status == LobbyParticipantStatus.WAITING: elif participant.status == LobbyParticipantStatus.WAITING:
self.refresh_waiting_status(room.id, participant_id) self.refresh_waiting_status(room.id, participant_id)
elif participant.status == LobbyParticipantStatus.ACCEPTED: elif participant.status == LobbyParticipantStatus.ACCEPTED:
# wrongly named, contains access token to join a room
livekit_config = utils.generate_livekit_config( livekit_config = utils.generate_livekit_config(
room_id=room_id, room_id=room_id,
user=request.user, user=request.user,
@@ -252,7 +195,6 @@ class LobbyService:
configuration=room.configuration, configuration=room.configuration,
participant_id=participant_id, participant_id=participant_id,
role=user_role, role=user_role,
encryption_mode=room.encryption_mode,
) )
return participant, livekit_config return participant, livekit_config
@@ -267,16 +209,15 @@ class LobbyService:
cache.touch( cache.touch(
self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT
) )
self._index_touch(room_id)
def enter( def enter(
self, self, room_id: UUID, participant_id: str, username: str
room_id: UUID,
participant_id: str,
username: str,
is_authenticated: bool = False,
) -> LobbyParticipant: ) -> LobbyParticipant:
"""Add participant to waiting lobby.""" """Add participant to waiting lobby.
Create a new participant entry in waiting status and notify room
participants of the new entry request.
"""
color = utils.generate_color(participant_id) color = utils.generate_color(participant_id)
@@ -285,8 +226,6 @@ class LobbyService:
username=username, username=username,
id=participant_id, id=participant_id,
color=color, color=color,
entered_at=timezone.now().isoformat(),
is_authenticated=is_authenticated,
) )
try: try:
@@ -306,7 +245,6 @@ class LobbyService:
participant.to_dict(), participant.to_dict(),
timeout=settings.LOBBY_WAITING_TIMEOUT, timeout=settings.LOBBY_WAITING_TIMEOUT,
) )
self._index_add(room_id, participant_id)
return participant return participant
@@ -328,42 +266,28 @@ class LobbyService:
cache.delete(cache_key) cache.delete(cache_key)
return None return None
def list_waiting_participants(self, room_id: UUID) -> Sequence[dict]: def list_waiting_participants(self, room_id: UUID) -> List[dict]:
"""List all waiting participants for a room.""" """List all waiting participants for a room."""
member_ids = self._index_members(room_id) pattern = self._get_cache_key(room_id, "*")
keys = list(cache.iter_keys(pattern, itersize=utils.CACHE_SCAN_ITERSIZE))
if not member_ids: if not keys:
return () return []
keys_by_id = { data = cache.get_many(keys)
participant_id: self._get_cache_key(room_id, participant_id)
for participant_id in member_ids
}
data = cache.get_many(list(keys_by_id.values()))
dead_ids = []
waiting_participants = [] waiting_participants = []
for cache_key, raw_participant in data.items():
for participant_id, cache_key in keys_by_id.items():
raw_participant = data.get(cache_key)
if raw_participant is None:
dead_ids.append(participant_id)
continue
try: try:
participant = LobbyParticipant.from_dict(raw_participant) participant = LobbyParticipant.from_dict(raw_participant)
except LobbyParticipantParsingError: except LobbyParticipantParsingError:
cache.delete(cache_key) cache.delete(cache_key)
dead_ids.append(participant_id)
continue continue
if participant.status == LobbyParticipantStatus.WAITING: if participant.status == LobbyParticipantStatus.WAITING:
waiting_participants.append(participant.to_dict()) waiting_participants.append(participant.to_dict())
self._index_remove(room_id, *dead_ids) return waiting_participants
waiting_participants.sort(key=lambda p: p["entered_at"], reverse=True)
return tuple(waiting_participants)
def handle_participant_entry( def handle_participant_entry(
self, self,
@@ -417,24 +341,16 @@ class LobbyService:
participant.status = status participant.status = status
cache.set(cache_key, participant.to_dict(), timeout=timeout) cache.set(cache_key, participant.to_dict(), timeout=timeout)
self._index_touch(room_id)
def clear_room_cache(self, room_id: UUID) -> None: def clear_room_cache(self, room_id: UUID) -> None:
"""Clear all participant entries from the cache for a specific room.""" """Clear all participant entries from the cache for a specific room."""
member_ids = self._index_members(room_id) cache.delete_pattern(
if member_ids: self._get_cache_key(room_id, "*"), itersize=utils.CACHE_SCAN_ITERSIZE
cache.delete_many( )
[
self._get_cache_key(room_id, participant_id)
for participant_id in member_ids
]
)
self._redis().delete(self._get_index_key(room_id))
def clear_participant_cache(self, room_id: UUID, participant_id: str) -> None: def clear_participant_cache(self, room_id: UUID, participant_id: str) -> None:
"""Clear a given participant entry from the cache for a specific room.""" """Clear a given participant entry from the cache for a specific room."""
cache_key = self._get_cache_key(room_id, participant_id) cache_key = self._get_cache_key(room_id, participant_id)
cache.delete(cache_key) cache.delete(cache_key)
self._index_remove(room_id, participant_id)
+21 -48
View File
@@ -1,11 +1,25 @@
"""Presence cache.""" """Presence cache.
Redis-backed memo of "this identity is currently connected to this room".
This module is intentionally a *pure cache store* with no dependency on other
services, so that `participants_management` (which talks to LiveKit) can
import it without creating an import cycle. The composition of "check cache,
fall back to LiveKit" lives in
`ParticipantsManagement.check_if_in_meeting_cached`.
Only positive answers are stored: a sticky negative would lock out someone
who joins right after a miss for the whole TTL. The TTL is a safety net in
case an invalidation webhook is lost.
"""
from typing import FrozenSet
from uuid import UUID from uuid import UUID
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from core.utils import CACHE_SCAN_ITERSIZE
class PresenceCache: class PresenceCache:
"""Store and invalidate (room, identity) presence entries.""" """Store and invalidate (room, identity) presence entries."""
@@ -15,65 +29,24 @@ class PresenceCache:
"""Cache key for a (room, identity) presence entry.""" """Cache key for a (room, identity) presence entry."""
return f"{settings.PRESENCE_KEY_PREFIX}_{room_id!s}_{identity}" return f"{settings.PRESENCE_KEY_PREFIX}_{room_id!s}_{identity}"
@staticmethod
def _get_index_key(room_id: UUID | str) -> str:
"""Raw Redis key of the per-room identity index (a native SET).
Built through django-redis' make_key so it lives under the same
KEY_PREFIX/version namespace as the presence entries.
"""
return cache.client.make_key(
f"{settings.PRESENCE_KEY_PREFIX}-index_{room_id!s}"
)
@staticmethod
def _redis(write: bool = True):
"""Raw redis-py client.
SADD/SREM/SMEMBERS are not exposed by the Django cache API; this is
the documented django-redis escape hatch.
"""
return cache.client.get_client(write=write)
def _index_members(self, room_id: UUID | str) -> FrozenSet[str]:
"""All identities currently indexed for the room."""
members = self._redis(write=False).smembers(self._get_index_key(room_id))
return frozenset(
member.decode() if isinstance(member, bytes) else member
for member in members
)
def is_marked_present(self, room_id: UUID | str, identity: str) -> bool: def is_marked_present(self, room_id: UUID | str, identity: str) -> bool:
"""Return True if a positive presence entry exists in cache.""" """Return True if a positive presence entry exists in cache."""
return bool(cache.get(self._get_cache_key(room_id, identity))) return bool(cache.get(self._get_cache_key(room_id, identity)))
def mark_present(self, room_id: UUID | str, identity: str) -> None: def mark_present(self, room_id: UUID | str, identity: str) -> None:
"""Record that `identity` is in `room_id` and index it for the room.""" """Record that `identity` is in `room_id`."""
cache.set( cache.set(
self._get_cache_key(room_id, identity), self._get_cache_key(room_id, identity),
True, True,
timeout=settings.PRESENCE_CACHE_TIMEOUT, timeout=settings.PRESENCE_CACHE_TIMEOUT,
) )
index_key = self._get_index_key(room_id)
pipe = self._redis().pipeline(transaction=False)
pipe.sadd(index_key, identity)
pipe.expire(index_key, settings.PRESENCE_CACHE_TIMEOUT)
pipe.execute()
def clear(self, room_id: UUID | str, identity: str) -> None: def clear(self, room_id: UUID | str, identity: str) -> None:
"""Forget presence for one participant (e.g. on participant_left).""" """Forget presence for one participant (e.g. on participant_left)."""
cache.delete(self._get_cache_key(room_id, identity)) cache.delete(self._get_cache_key(room_id, identity))
self._redis().srem(self._get_index_key(room_id), identity)
def clear_room(self, room_id: UUID | str) -> None: def clear_room(self, room_id: UUID | str) -> None:
"""Forget presence for every participant of a room (on room_finished). """Forget presence for every participant of a room (on room_finished)."""
cache.delete_pattern(
Deletes the indexed entries and the index itself with targeted self._get_cache_key(room_id, "*"), itersize=CACHE_SCAN_ITERSIZE
commands instead of a full-keyspace pattern scan. )
"""
identities = self._index_members(room_id)
if identities:
cache.delete_many(
[self._get_cache_key(room_id, identity) for identity in identities]
)
self._redis().delete(self._get_index_key(room_id))
+6 -33
View File
@@ -30,10 +30,9 @@ class RoomNotFoundException(RoomManagementException):
class RoomManagement: class RoomManagement:
"""Service for managing LiveKit rooms.""" """Service for managing LiveKit rooms."""
@classmethod
@async_to_sync @async_to_sync
async def update_metadata( async def update_metadata(
cls, self,
room_name: str, room_name: str,
metadata: Optional[Dict] = None, metadata: Optional[Dict] = None,
remove_keys: Optional[list[str]] = None, remove_keys: Optional[list[str]] = None,
@@ -76,6 +75,10 @@ class RoomManagement:
except TwirpError as e: except TwirpError as e:
if e.code == "not_found": if e.code == "not_found":
logger.warning(
"Room %s not found in LiveKit, skipping metadata update",
room_name,
)
raise RoomNotFoundException("Room does not exist") from e raise RoomNotFoundException("Room does not exist") from e
logger.exception( logger.exception(
@@ -87,9 +90,8 @@ class RoomManagement:
finally: finally:
await lkapi.aclose() await lkapi.aclose()
@classmethod
@async_to_sync @async_to_sync
async def delete_room(cls, room_name: str): async def delete_room(self, room_name: str):
"""Delete a LiveKit room and disconnect all participants. """Delete a LiveKit room and disconnect all participants.
Raises: Raises:
@@ -114,32 +116,3 @@ class RoomManagement:
raise RoomManagementException("Could not delete room") from e raise RoomManagementException("Could not delete room") from e
finally: finally:
await lkapi.aclose() await lkapi.aclose()
@classmethod
def sync_room_metadata(cls, room):
"""Push a room's configuration and access level to its LiveKit room metadata.
Failures are swallowed: a room that is not live yet, or a LiveKit hiccup,
should never fail the request that triggered the update.
"""
metadata = {
"configuration": room.configuration,
"access_level": room.access_level,
}
try:
cls.update_metadata(
room_name=str(room.id),
metadata=metadata,
)
except RoomNotFoundException:
logger.info(
"LiveKit room %s does not exist yet, skipping metadata sync",
room.id,
)
except RoomManagementException:
logger.warning(
"Failed to sync metadata to LiveKit for room %s",
room.id,
)
+1 -1
View File
@@ -31,7 +31,7 @@ def delete_connection_test_room(room_name: str):
return return
try: try:
RoomManagement.delete_room(room_name) RoomManagement().delete_room(room_name)
except RoomNotFoundException: except RoomNotFoundException:
# Room may already be gone after empty/departure timeout. # Room may already be gone after empty/departure timeout.
logger.info("Connection test room '%s' already gone.", room_name) logger.info("Connection test room '%s' already gone.", room_name)
@@ -40,111 +40,6 @@ def test_authentication_getter_existing_user(monkeypatch):
assert user == db_user assert user == db_user
@pytest.mark.parametrize(
"sub",
[
# NUL (U+0000) passes str.isascii() but PostgreSQL text fields
# cannot store or compare it (DataError)
"auth0|abc\x00def",
# lone surrogates cannot be encoded to UTF-8 for the DB lookup
# (UnicodeEncodeError), which runs before any model validation
"bad\ud800sub",
# plainly invalid subs would otherwise escape as ValidationError
# on user creation, which mozilla-django-oidc does not catch
"\u00e9milie",
"a" * 256,
# ASCII control characters are rejected by policy
"tab\tsub",
"del\x7fsub",
],
)
def test_authentication_getter_invalid_sub_rejected_cleanly(monkeypatch, sub):
"""
Subs that can never be persisted should be rejected with
SuspiciousOperation (turned into a clean authentication failure by
mozilla-django-oidc) instead of leaking DataError, UnicodeEncodeError
or ValidationError as a server error.
"""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {"sub": sub, "email": "john@example.com"}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with pytest.raises(
SuspiciousOperation,
match="User info contained an invalid sub claim",
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
assert models.User.objects.exists() is False
def test_authentication_getter_numeric_sub(monkeypatch):
"""
Some providers serialize the sub as a JSON number. It should keep working
(CharField coerces it to a string on save) and must not crash the early
sub checks in get_existing_user.
"""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {"sub": 12345, "email": "john@example.com"}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user.sub == "12345"
assert models.User.objects.count() == 1
def test_authentication_getter_new_user_auth0_pipe_sub(monkeypatch):
"""
A first login with an Auth0-style sub containing a pipe ("provider|user-id")
should create the user instead of raising a ValidationError.
Regression test for https://github.com/suitenumerique/meet/issues/[XXX].
"""
klass = OIDCAuthenticationBackend()
def get_userinfo_mocked(*args):
return {"sub": "auth0|644c0bc8f1874ef6d339fb34", "email": "john@example.com"}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user.sub == "auth0|644c0bc8f1874ef6d339fb34"
assert user.email == "john@example.com"
assert models.User.objects.count() == 1
def test_authentication_getter_existing_user_auth0_pipe_sub(monkeypatch):
"""
A returning user with an Auth0-style pipe sub should be matched by sub,
not duplicated or rejected.
"""
klass = OIDCAuthenticationBackend()
db_user = UserFactory(sub="auth0|644c0bc8f1874ef6d339fb34")
def get_userinfo_mocked(*args):
return {"sub": db_user.sub}
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
assert user == db_user
assert models.User.objects.count() == 1
def test_authentication_getter_new_user_no_email(monkeypatch): def test_authentication_getter_new_user_no_email(monkeypatch):
""" """
If no user matches, a user should be created. If no user matches, a user should be created.
@@ -7,7 +7,6 @@ from urllib.parse import quote, urlparse
from django.conf import settings from django.conf import settings
from django.core.files.storage import default_storage from django.core.files.storage import default_storage
from django.test import override_settings
from django.utils import timezone from django.utils import timezone
import pytest import pytest
@@ -144,59 +143,3 @@ def test_api_files_media_auth_own_file_deleted():
) )
assert response.status_code == 403 assert response.status_code == 403
@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI")
def test_api_files_media_auth_custom_original_url_header():
"""
Authorization should honour the configured original-url header.
Covers the attachment subrequest path, which resolves the header separately
from the recording one. Reverse proxies other than nginx-ingress use
different headers: Traefik's ForwardAuth sends X-Forwarded-Uri and cannot
emit X-Original-URL at all.
"""
user = factories.UserFactory()
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.READY,
creator=user,
)
client = APIClient()
client.force_login(user)
default_storage.save(file.file_key, BytesIO(b"my prose"))
original_url = f"http://localhost/media/{file.file_key:s}"
response = client.get(
"/api/v1.0/files/media-auth/", HTTP_X_FORWARDED_URI=original_url
)
assert response.status_code == 200
assert "AWS4-HMAC-SHA256 Credential=" in response["Authorization"]
@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI")
def test_api_files_media_auth_default_header_ignored_when_reconfigured():
"""
Only the configured header should be honoured, never a hardcoded fallback.
"""
user = factories.UserFactory()
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.READY,
creator=user,
)
client = APIClient()
client.force_login(user)
original_url = f"http://localhost/media/{file.file_key:s}"
response = client.get(
"/api/v1.0/files/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
@@ -2,23 +2,18 @@
Test RecordingEventsService service. Test RecordingEventsService service.
""" """
# pylint: disable=redefined-outer-name,protected-access # pylint: disable=redefined-outer-name
import logging
from unittest import mock from unittest import mock
import pytest import pytest
from core.factories import RecordingFactory from core.factories import RecordingFactory
from core.recording.enums import RecordingWorkerEvent
from core.recording.services.recording_events import ( from core.recording.services.recording_events import (
RecordingEventsError, RecordingEventsError,
RecordingEventsService, RecordingEventsService,
RecordingNotSavableError, RecordingNotSavableError,
) )
from core.services.room_management import (
RoomManagementException,
)
from core.utils import NotificationError from core.utils import NotificationError
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -39,10 +34,10 @@ def service():
) )
@mock.patch("core.utils.notify_participants") @mock.patch("core.utils.notify_participants")
def test_handle_limit_reached_success(mock_notify, mode, notification_type, service): def test_handle_limit_reached_success(mock_notify, mode, notification_type, service):
"""Test _handle_limit_reached stops recording and notifies participants.""" """Test handle_limit_reached stops recording and notifies participants."""
recording = RecordingFactory(status="active", mode=mode) recording = RecordingFactory(status="active", mode=mode)
service._handle_limit_reached(recording) service.handle_limit_reached(recording)
assert recording.status == "stopped" assert recording.status == "stopped"
mock_notify.assert_called_once_with( mock_notify.assert_called_once_with(
@@ -53,69 +48,13 @@ def test_handle_limit_reached_success(mock_notify, mode, notification_type, serv
@pytest.mark.parametrize( @pytest.mark.parametrize(
("mode", "notification_type"), ("mode", "notification_type"),
( (
("screen_recording", "screenRecordingFailed"), ("screen_recording", "screenRecordingLimitReached"),
("transcript", "transcriptionFailed"), ("transcript", "transcriptionLimitReached"),
), ),
) )
@mock.patch("core.utils.notify_participants") @mock.patch("core.utils.notify_participants")
def test_handle_failed_success(mock_notify, mode, notification_type, service): def test_handle_limit_reached_error(mock_notify, mode, notification_type, service):
"""Test _handle_failed marks recording as failed and notifies participants.""" """Test handle_limit_reached raises RecordingEventsError when notification fails."""
recording = RecordingFactory(status="active", mode=mode)
service._handle_failed(recording)
assert recording.status == "failed"
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
@pytest.mark.parametrize(
("mode", "notification_type"),
(
("screen_recording", "screenRecordingAborted"),
("transcript", "transcriptionAborted"),
),
)
@mock.patch("core.utils.notify_participants")
def test_handle_aborted_success(mock_notify, mode, notification_type, service):
"""Test _handle_aborted marks recording as aborted and notifies participants."""
recording = RecordingFactory(status="active", mode=mode)
service._handle_aborted(recording)
assert recording.status == "aborted"
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
@pytest.mark.parametrize(
("mode", "notification_prefix"),
(("screen_recording", "screenRecording"), ("transcript", "transcription")),
)
@pytest.mark.parametrize(
("handler", "expected_status", "event", "notification_suffix"),
(
("_handle_limit_reached", "stopped", "limit reached", "LimitReached"),
("_handle_failed", "failed", "failed", "Failed"),
("_handle_aborted", "aborted", "aborted", "Aborted"),
),
)
@mock.patch("core.utils.notify_participants")
def test_handle_event_notification_error( # noqa: PLR0913, PLR0917
mock_notify,
handler,
expected_status,
event,
notification_suffix,
mode,
notification_prefix,
service,
): # pylint: disable=too-many-arguments,too-many-positional-arguments
"""Test handlers raise RecordingEventsError when notifying participants fails,
while still applying the recording status of their event.
"""
mock_notify.side_effect = NotificationError("Error notifying") mock_notify.side_effect = NotificationError("Error notifying")
@@ -123,15 +62,14 @@ def test_handle_event_notification_error( # noqa: PLR0913, PLR0917
with pytest.raises( with pytest.raises(
RecordingEventsError, RecordingEventsError,
match=rf"Failed to notify participants in room '.+' " match=r"Failed to notify participants in room '.+' "
rf"about recording {event} \(recording_id=.+\)", r"about recording limit reached \(recording_id=.+\)",
): ):
getattr(service, handler)(recording) service.handle_limit_reached(recording)
assert recording.status == expected_status assert recording.status == "stopped"
mock_notify.assert_called_once_with( mock_notify.assert_called_once_with(
room_name=str(recording.room.id), room_name=str(recording.room.id), notification_data={"type": notification_type}
notification_data={"type": f"{notification_prefix}{notification_suffix}"},
) )
@@ -144,19 +82,19 @@ def test_handle_event_notification_error( # noqa: PLR0913, PLR0917
"core.recording.services.recording_events.notification_service." "core.recording.services.recording_events.notification_service."
"notify_external_services" "notify_external_services"
) )
def test_handle_successful_saves_recording( # pylint: disable=too-many-arguments, too-many-positional-arguments def test_handle_complete_saves_recording( # pylint: disable=too-many-arguments, too-many-positional-arguments
mock_notify_external_services, mock_notify_external_services,
notify_return_value, notify_return_value,
expected_status, expected_status,
status, status,
service, service,
): ):
"""Test _handle_successful notifies external services and saves a savable recording.""" """Test handle_complete notifies external services and saves a savable recording."""
mock_notify_external_services.return_value = notify_return_value mock_notify_external_services.return_value = notify_return_value
recording = RecordingFactory(status=status) recording = RecordingFactory(status=status)
service._handle_successful(recording) service.handle_complete(recording)
mock_notify_external_services.assert_called_once_with(recording) mock_notify_external_services.assert_called_once_with(recording)
@@ -166,293 +104,23 @@ def test_handle_successful_saves_recording( # pylint: disable=too-many-argument
@pytest.mark.parametrize( @pytest.mark.parametrize(
"status", "status",
[ ["initiated", "saved", "notification_succeeded", "aborted", "failed_to_start"],
"initiated",
"saved",
"notification_succeeded",
"aborted",
"failed",
"failed_to_start",
],
) )
@mock.patch( @mock.patch(
"core.recording.services.recording_events.notification_service." "core.recording.services.recording_events.notification_service."
"notify_external_services" "notify_external_services"
) )
def test_handle_successful_non_savable_recording( def test_handle_complete_non_savable_recording(
mock_notify_external_services, status, service mock_notify_external_services, status, service
): ):
"""Test _handle_successful refuses recordings that are already saved or in error.""" """Test handle_complete refuses recordings that are already saved or in error."""
recording = RecordingFactory(status=status) recording = RecordingFactory(status=status)
with pytest.raises(RecordingNotSavableError): with pytest.raises(RecordingNotSavableError):
service._handle_successful(recording) service.handle_complete(recording)
mock_notify_external_services.assert_not_called() mock_notify_external_services.assert_not_called()
recording.refresh_from_db() recording.refresh_from_db()
assert recording.status == status assert recording.status == status
@pytest.mark.parametrize(
("event", "recording_status"),
(
(RecordingWorkerEvent.STARTED, "started"),
(RecordingWorkerEvent.SAVING, "saving"),
),
)
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_update_syncs_room_metadata(
mock_update_metadata, event, recording_status, service
):
"""Test handle_update updates the room's metadata."""
recording = RecordingFactory(status="active")
service.handle_update(recording, event)
mock_update_metadata.assert_called_once_with(
str(recording.room.id), {"recording_status": recording_status}
)
@pytest.mark.parametrize(
"event",
(
RecordingWorkerEvent.STARTING,
RecordingWorkerEvent.COMPLETED,
RecordingWorkerEvent.LIMIT_REACHED,
RecordingWorkerEvent.ABORTED,
RecordingWorkerEvent.FAILED,
),
)
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_update_ignores_events_without_a_metadata_status(
mock_update_metadata, event, service
):
"""Test handle_update doesn't update metadata for events it doesn't match."""
recording = RecordingFactory(status="active")
service.handle_update(recording, event)
mock_update_metadata.assert_not_called()
@pytest.mark.parametrize(
("event", "initial_status", "expected_status", "notification_type"),
(
(RecordingWorkerEvent.LIMIT_REACHED, "active", "saved", "LimitReached"),
(RecordingWorkerEvent.LIMIT_REACHED, "stopped", "saved", None),
(RecordingWorkerEvent.LIMIT_REACHED, "saved", "saved", None),
(RecordingWorkerEvent.ABORTED, "active", "aborted", "Aborted"),
(RecordingWorkerEvent.ABORTED, "failed_to_stop", "failed_to_stop", None),
(RecordingWorkerEvent.FAILED, "active", "failed", "Failed"),
(RecordingWorkerEvent.FAILED, "stopped", "failed", "Failed"),
(RecordingWorkerEvent.FAILED, "aborted", "aborted", None),
(RecordingWorkerEvent.COMPLETED, "active", "saved", None),
(RecordingWorkerEvent.COMPLETED, "saved", "saved", None),
),
)
@mock.patch(
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
@mock.patch("core.utils.notify_participants")
def test_handle_terminal_event_dispatches_on_event_and_status( # noqa: PLR0913, PLR0917
mock_notify,
mock_notify_external_services,
event,
initial_status,
expected_status,
notification_type,
service,
): # pylint: disable=too-many-arguments,too-many-positional-arguments
"""Test handle_terminal_event chooses the right handler from the event and status."""
mock_notify_external_services.return_value = False
recording = RecordingFactory(status=initial_status, mode="screen_recording")
service.handle_terminal_event(recording, event)
recording.refresh_from_db()
assert recording.status == expected_status
if notification_type is None:
mock_notify.assert_not_called()
else:
mock_notify.assert_called_once_with(
room_name=str(recording.room.id),
notification_data={"type": f"screenRecording{notification_type}"},
)
@pytest.mark.parametrize(
"event",
(
RecordingWorkerEvent.STARTING,
RecordingWorkerEvent.STARTED,
RecordingWorkerEvent.SAVING,
),
)
@mock.patch(
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
@mock.patch("core.utils.notify_participants")
def test_handle_terminal_event_ignores_non_terminal_events(
mock_notify, mock_notify_external_services, event, service, caplog
):
"""Test handle_terminal_event refuses non-terminal events."""
recording = RecordingFactory(status="active")
with caplog.at_level(logging.WARNING):
service.handle_terminal_event(recording, event)
assert f"Ignoring non-terminal event {event.value}" in caplog.text
mock_notify.assert_not_called()
mock_notify_external_services.assert_not_called()
recording.refresh_from_db()
assert recording.status == "active"
@pytest.mark.parametrize(
("event", "expected_status"),
(
(RecordingWorkerEvent.LIMIT_REACHED, "saved"),
(RecordingWorkerEvent.ABORTED, "aborted"),
(RecordingWorkerEvent.FAILED, "failed"),
),
)
@mock.patch(
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
@mock.patch("core.utils.notify_participants")
def test_handle_terminal_event_survives_a_notification_failure( # noqa: PLR0913, PLR0917
mock_notify,
mock_notify_external_services,
event,
expected_status,
service,
caplog,
): # pylint: disable=too-many-arguments,too-many-positional-arguments
"""Test handle_terminal_event logs a notification failure instead of raising.
The recording status must still be persisted: participants missing their
notification should not disturb recording.
"""
mock_notify_external_services.return_value = False
mock_notify.side_effect = NotificationError("Error notifying")
recording = RecordingFactory(status="active")
with caplog.at_level(logging.ERROR):
service.handle_terminal_event(recording, event)
assert f"Failed to notify participants that recording {recording.id}" in caplog.text
recording.refresh_from_db()
assert recording.status == expected_status
@pytest.mark.parametrize(
"status",
["failed_to_start", "aborted", "failed", "failed_to_stop", "saved", "initiated"],
)
@mock.patch(
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
def test_handle_terminal_event_ignores_a_non_savable_recording(
mock_notify_external_services, status, service, caplog
):
"""Test handle_terminal_event handles a redelivered event idempotently.
A terminal event may be redelivered for an already finalized recording;
this must not raise, otherwise the webhook would 500 and be retried.
"""
recording = RecordingFactory(status=status)
with caplog.at_level(logging.WARNING):
service.handle_terminal_event(recording, RecordingWorkerEvent.COMPLETED)
assert f"Recording {recording.id} is not savable" in caplog.text
mock_notify_external_services.assert_not_called()
recording.refresh_from_db()
assert recording.status == status
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_update_survives_a_metadata_failure(
mock_update_metadata, service, caplog
):
"""Test handle_update logs a metadata failure instead of raising."""
mock_update_metadata.side_effect = RoomManagementException("Error updating")
recording = RecordingFactory(status="active")
with caplog.at_level(logging.ERROR):
service.handle_update(recording, RecordingWorkerEvent.SAVING)
assert "Failed to update room's metadata" in caplog.text
@pytest.mark.parametrize(
("event", "expected_level"),
(
(RecordingWorkerEvent.ABORTED, logging.INFO),
(RecordingWorkerEvent.FAILED, logging.ERROR),
),
)
def test_log_worker_error_reports_an_unsuccessful_event(
event, expected_level, service, caplog
):
"""Test log_worker_error records the reason the recording did not succeed."""
recording = RecordingFactory(status="active", mode="screen_recording")
with caplog.at_level(logging.INFO):
service.log_worker_error(
recording, event, error="could not connect to the room", error_code=500
)
assert (
f"Recording worker reported {event.value} for recording {recording.id}"
in caplog.text
)
assert "could not connect to the room" in caplog.text
assert "error_code=500" in caplog.text
worker_logs = [
record
for record in caplog.records
if record.name == "core.recording.services.recording_events"
]
assert [record.levelno for record in worker_logs] == [expected_level]
@pytest.mark.parametrize(
"event",
(
RecordingWorkerEvent.STARTING,
RecordingWorkerEvent.STARTED,
RecordingWorkerEvent.SAVING,
RecordingWorkerEvent.COMPLETED,
RecordingWorkerEvent.LIMIT_REACHED,
None,
),
)
def test_log_worker_error_stays_quiet_on_anything_else(event, service, caplog):
"""Test log_worker_error ignores events other than FAILED and ABORTED."""
recording = RecordingFactory(status="active")
with caplog.at_level(logging.INFO):
service.log_worker_error(recording, event, error="some error", error_code=500)
assert "Recording worker reported" not in caplog.text
@@ -8,7 +8,6 @@ from uuid import uuid4
from django.conf import settings from django.conf import settings
from django.core.files.storage import default_storage from django.core.files.storage import default_storage
from django.test import override_settings
from django.utils import timezone from django.utils import timezone
import pytest import pytest
@@ -283,63 +282,3 @@ def test_api_recordings_media_auth_success_administrator(mode):
timeout=1, timeout=1,
) )
assert response.content.decode("utf-8") == "my prose" assert response.content.decode("utf-8") == "my prose"
def test_api_recordings_media_auth_missing_header():
"""
Test that a subrequest without the configured original-url header is rejected.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/recordings/media-auth/")
assert response.status_code == 403
@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI")
def test_api_recordings_media_auth_custom_original_url_header():
"""
Test that the header carrying the original URL can be configured.
Reverse proxies other than nginx-ingress use different headers: Traefik's
ForwardAuth sends X-Forwarded-Uri and cannot emit X-Original-URL at all.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
original_url = f"http://localhost/media/recordings/{uuid4()!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_FORWARDED_URI=original_url
)
# The header was read and parsed: we get as far as looking the recording up,
# rather than being rejected for a missing header.
assert response.status_code == 404
@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI")
def test_api_recordings_media_auth_default_header_ignored_when_reconfigured():
"""
Test that only the configured header is honoured.
Guards against the header being read from a hardcoded name in parallel with
the setting.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
original_url = f"http://localhost/media/recordings/{uuid4()!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
@@ -224,7 +224,6 @@ def test_api_recording_retrieve_expired(settings):
RecordingStatusChoices.INITIATED, RecordingStatusChoices.INITIATED,
RecordingStatusChoices.ACTIVE, RecordingStatusChoices.ACTIVE,
RecordingStatusChoices.SAVED, RecordingStatusChoices.SAVED,
RecordingStatusChoices.FAILED,
RecordingStatusChoices.FAILED_TO_START, RecordingStatusChoices.FAILED_TO_START,
RecordingStatusChoices.FAILED_TO_STOP, RecordingStatusChoices.FAILED_TO_STOP,
RecordingStatusChoices.ABORTED, RecordingStatusChoices.ABORTED,
@@ -0,0 +1,109 @@
"""Tests for the per-recording encoding resolution in BaseEgressService."""
# pylint: disable=protected-access,redefined-outer-name,unused-argument
from unittest.mock import Mock
from django.conf import settings
import pytest
from pydantic import ValidationError as PydanticValidationError
from core.api.serializers import EncodingConfig
from core.recording.worker.services import (
VideoCompositeEgressService,
resolve_encoding_config,
)
def make_config():
"""Build a minimal WorkerServiceConfig-like mock for service instantiation."""
config = Mock()
config.bucket_args = {
"endpoint": "https://s3.test.com",
"access_key": "test_key",
"secret": "test_secret",
"region": "test-region",
"bucket": "test-bucket",
"force_path_style": True,
}
config.encoding_options = None
return config
@pytest.fixture
def service():
"""Return a VideoCompositeEgressService with mocked handle_request."""
svc = VideoCompositeEgressService(make_config())
svc._handle_request = Mock()
return svc
# --- resolve_encoding_config ---
def test_resolve_config_returns_none_without_config():
"""Resolver should return None when no encoding config is provided."""
assert resolve_encoding_config(None) is None
def test_resolve_config_without_profile_omits_profile_fields():
"""A resolution-only config should resolve dimensions but no framerate/bitrate."""
resolved = resolve_encoding_config(EncodingConfig(resolution="720p"))
assert resolved == {
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
"width": 1280,
"height": 720,
}
def test_encoding_config_requires_resolution():
"""A profile-only or empty encoding config should be rejected at validation."""
with pytest.raises(PydanticValidationError):
EncodingConfig(profile="mixed")
with pytest.raises(PydanticValidationError):
EncodingConfig()
# --- _resolve_encoding_options ---
@pytest.mark.parametrize("encoding_options", [None, {}])
def test_resolve_options_returns_none_when_empty(service, encoding_options):
"""Resolver should return None when the resolved dict is empty or missing."""
assert service._resolve_encoding_options(encoding_options) is None
@pytest.mark.parametrize(
"resolution",
list(settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS),
)
@pytest.mark.parametrize(
"profile",
list(settings.RECORDING_ENCODING_AVAILABLE_PROFILES),
)
def test_resolve_profile_resolution_combinations(service, profile, resolution):
"""Every (profile, resolution) pair should resolve to the values from settings."""
dimensions = settings.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS[resolution]
profile_spec = settings.RECORDING_ENCODING_AVAILABLE_PROFILES[profile]
resolved = resolve_encoding_config(
EncodingConfig(resolution=resolution, profile=profile)
)
result = service._resolve_encoding_options(resolved)
assert result.width == dimensions["width"]
assert result.height == dimensions["height"]
assert result.framerate == profile_spec["fps"]
assert result.video_bitrate == profile_spec["kbps"][resolution]
def test_resolve_options_none_profile_uses_livekit_defaults(service):
"""Missing profile should pass 0 fps/bitrate (LiveKit protobuf default)."""
resolved = resolve_encoding_config(EncodingConfig(resolution="720p"))
result = service._resolve_encoding_options(resolved)
assert result.width == 1280
assert result.height == 720
assert result.framerate == 0
assert result.video_bitrate == 0
@@ -85,18 +85,22 @@ def test_config_immutability(default_config):
AWS_S3_REGION_NAME="test-region", AWS_S3_REGION_NAME="test-region",
AWS_STORAGE_BUCKET_NAME="test-bucket", AWS_STORAGE_BUCKET_NAME="test-bucket",
RECORDING_ENCODING_ENABLED=True, RECORDING_ENCODING_ENABLED=True,
RECORDING_ENCODING_WIDTH=1280, RECORDING_ENCODING_AVAILABLE_RESOLUTIONS={
RECORDING_ENCODING_HEIGHT=720, "720p": {"width": 1280, "height": 720},
RECORDING_ENCODING_FRAMERATE=15, },
RECORDING_ENCODING_VIDEO_BITRATE_KBPS=600, RECORDING_ENCODING_AVAILABLE_PROFILES={
"talking_heads": {"fps": 15, "kbps": {"720p": 600}},
},
RECORDING_ENCODING_DEFAULT_RESOLUTION="720p",
RECORDING_ENCODING_DEFAULT_PROFILE="talking_heads",
RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64, RECORDING_ENCODING_AUDIO_BITRATE_KBPS=64,
RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=10.0, RECORDING_ENCODING_KEY_FRAME_INTERVAL_S=10.0,
) )
def test_config_encoding_options_enabled(): def test_config_encoding_options_enabled():
"""When RECORDING_ENCODING_ENABLED is True, encoding options are populated. """When RECORDING_ENCODING_ENABLED is True, encoding options are populated.
The dict mixes operator-tunable values from settings with pinned codec / The dict mixes values resolved from the default resolution / profile with
frequency constants, so the services layer can simply unpack it. pinned codec / frequency constants, so the services layer can simply unpack it.
""" """
WorkerServiceConfig.from_settings.cache_clear() WorkerServiceConfig.from_settings.cache_clear()
@@ -115,6 +119,27 @@ def test_config_encoding_options_enabled():
} }
@override_settings(
RECORDING_OUTPUT_FOLDER="/test/output",
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
AWS_S3_ENDPOINT_URL="https://s3.test.com",
AWS_S3_ACCESS_KEY_ID="test_key",
AWS_S3_SECRET_ACCESS_KEY="test_secret",
AWS_S3_REGION_NAME="test-region",
AWS_STORAGE_BUCKET_NAME="test-bucket",
RECORDING_ENCODING_ENABLED=True,
RECORDING_ENCODING_DEFAULT_RESOLUTION="",
RECORDING_ENCODING_DEFAULT_PROFILE="",
)
def test_config_encoding_options_without_defaults():
"""An empty default resolution or profile leaves the encoding to LiveKit."""
WorkerServiceConfig.from_settings.cache_clear()
config = WorkerServiceConfig.from_settings()
assert config.encoding_options is None
@override_settings( @override_settings(
RECORDING_OUTPUT_FOLDER="/test/output", RECORDING_OUTPUT_FOLDER="/test/output",
LIVEKIT_CONFIGURATION={"server": "test.example.com"}, LIVEKIT_CONFIGURATION={"server": "test.example.com"},
@@ -50,7 +50,7 @@ def test_start_recording_success(mock_update_metadata, mediator, mock_worker_ser
# Verify worker service call # Verify worker service call
expected_room_name = str(mock_recording.room.id) expected_room_name = str(mock_recording.room.id)
mock_worker_service.start.assert_called_once_with( mock_worker_service.start.assert_called_once_with(
expected_room_name, mock_recording.id expected_room_name, mock_recording.id, encoding_options=None
) )
# Verify recording updates # Verify recording updates
@@ -64,6 +64,38 @@ def test_start_recording_success(mock_update_metadata, mediator, mock_worker_ser
) )
@mock.patch("core.utils.update_room_metadata")
def test_start_recording_passes_resolved_encoding(
mock_update_room_metadata, mediator, mock_worker_service
):
"""The resolved encoding persisted in recording.options reaches the worker."""
mock_worker_service.start.return_value = "test-worker-123"
resolved = {
"key_frame_interval": 4.0,
"width": 1280,
"height": 720,
"framerate": 15,
"video_bitrate": 700,
}
mock_recording = RecordingFactory(
status=RecordingStatusChoices.INITIATED,
worker_id=None,
options={
"encoding": {
"resolution": "720p",
"profile": "talking_heads",
"resolved": resolved,
}
},
)
mediator.start(mock_recording)
mock_worker_service.start.assert_called_once_with(
str(mock_recording.room.id), mock_recording.id, encoding_options=resolved
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError] "error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
) )
@@ -4,7 +4,6 @@ Test worker service classes.
# pylint: disable=protected-access,redefined-outer-name,unused-argument,no-member # pylint: disable=protected-access,redefined-outer-name,unused-argument,no-member
import logging
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import AsyncMock, Mock, patch
import pytest import pytest
@@ -155,9 +154,9 @@ def test_base_egress_filepath_construction(service, filename, extension, expecte
"response_status,expected_result", "response_status,expected_result",
[ [
(livekit_api.EgressStatus.EGRESS_ABORTED, "ABORTED"), (livekit_api.EgressStatus.EGRESS_ABORTED, "ABORTED"),
(livekit_api.EgressStatus.EGRESS_FAILED, "FAILED"),
(livekit_api.EgressStatus.EGRESS_COMPLETE, "FAILED_TO_STOP"), (livekit_api.EgressStatus.EGRESS_COMPLETE, "FAILED_TO_STOP"),
(livekit_api.EgressStatus.EGRESS_ENDING, "STOPPED"), (livekit_api.EgressStatus.EGRESS_ENDING, "STOPPED"),
(livekit_api.EgressStatus.EGRESS_FAILED, "FAILED_TO_STOP"),
], ],
) )
def test_base_egress_stop_with_status(service, response_status, expected_result): def test_base_egress_stop_with_status(service, response_status, expected_result):
@@ -176,32 +175,6 @@ def test_base_egress_stop_with_status(service, response_status, expected_result)
assert result == expected_result assert result == expected_result
@pytest.mark.parametrize(
"response_status,event",
[
(livekit_api.EgressStatus.EGRESS_ABORTED, "aborted"),
(livekit_api.EgressStatus.EGRESS_FAILED, "failed"),
(livekit_api.EgressStatus.EGRESS_COMPLETE, "failed to stop"),
],
)
def test_base_egress_stop_logs_livekit_error(service, response_status, event, caplog):
"""Should log the reason LiveKit reported for an unsuccessful stop."""
mock_response = Mock(
status=response_status,
egress_id="test_worker_id",
error="could not connect to the room",
error_code=500,
)
service._handle_request = Mock(return_value=mock_response)
with caplog.at_level(logging.ERROR):
service.stop("test_worker_id")
assert f"Egress {event} on stop (egress_id=test_worker_id" in caplog.text
assert "could not connect to the room" in caplog.text
assert "error_code=500" in caplog.text
def test_base_egress_stop_missing_status(service): def test_base_egress_stop_missing_status(service):
"""Test stop method when response is missing status""" """Test stop method when response is missing status"""
# Mock _handle_request with missing status # Mock _handle_request with missing status
@@ -312,34 +312,3 @@ def test_api_rooms_create_authenticated_blank_user_default_access_level():
assert response.status_code == 201 assert response.status_code == 201
room = Room.objects.get() room = Room.objects.get()
assert room.access_level == settings.RESOURCE_DEFAULT_ACCESS_LEVEL assert room.access_level == settings.RESOURCE_DEFAULT_ACCESS_LEVEL
@pytest.mark.parametrize("encryption_mode", ["none", "basic"])
@pytest.mark.parametrize(
"user_default", [None, RoomAccessLevel.PUBLIC, RoomAccessLevel.TRUSTED]
)
@pytest.mark.parametrize(
"requested_access", [None, RoomAccessLevel.PUBLIC, RoomAccessLevel.TRUSTED]
)
def test_api_rooms_create_encryption_access_precedence(
settings, encryption_mode, user_default, requested_access
):
"""Encryption overrides request and user access defaults only for encrypted rooms."""
settings.ENCRYPTION_ENABLED = True
user = UserFactory(default_room_access_level=user_default)
client = APIClient()
client.force_login(user)
data = {"name": "New room", "encryption_mode": encryption_mode}
if requested_access is not None:
data["access_level"] = requested_access
response = client.post("/api/v1.0/rooms/", data)
assert response.status_code == 201
expected_access = (
RoomAccessLevel.RESTRICTED
if encryption_mode == "basic"
else requested_access or user_default or settings.RESOURCE_DEFAULT_ACCESS_LEVEL
)
assert response.json()["access_level"] == expected_access
assert Room.objects.get().access_level == expected_access
@@ -9,7 +9,6 @@ from unittest import mock
from django.core.cache import cache from django.core.cache import cache
import pytest import pytest
from freezegun import freeze_time
from rest_framework.test import APIClient from rest_framework.test import APIClient
from ... import utils from ... import utils
@@ -25,7 +24,6 @@ pytestmark = pytest.mark.django_db
# Tests for request_entry endpoint # Tests for request_entry endpoint
@freeze_time("2025-01-01 10:00:00")
def test_request_entry_anonymous(settings): def test_request_entry_anonymous(settings):
"""Anonymous users should be allowed to request entry to a room.""" """Anonymous users should be allowed to request entry to a room."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
@@ -61,8 +59,6 @@ def test_request_entry_anonymous(settings):
"username": "test_user", "username": "test_user",
"status": "waiting", "status": "waiting",
"color": "mocked-color", "color": "mocked-color",
"entered_at": "2025-01-01T10:00:00+00:00",
"is_authenticated": False,
"livekit": None, "livekit": None,
} }
@@ -75,13 +71,9 @@ def test_request_entry_anonymous(settings):
assert participant_data.get("username") == "test_user" assert participant_data.get("username") == "test_user"
@freeze_time("2025-01-01 10:00:00") def test_request_entry_authenticated_user(settings):
@pytest.mark.parametrize("encryption_mode", ["none", "basic"])
def test_request_entry_authenticated_user(settings, encryption_mode):
"""Authenticated users should be allowed to request entry.""" """Authenticated users should be allowed to request entry."""
room = RoomFactory( room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
access_level=RoomAccessLevel.RESTRICTED, encryption_mode=encryption_mode
)
user = UserFactory() user = UserFactory()
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
@@ -116,8 +108,6 @@ def test_request_entry_authenticated_user(settings, encryption_mode):
"username": "test_user", "username": "test_user",
"status": "waiting", "status": "waiting",
"color": "mocked-color", "color": "mocked-color",
"entered_at": "2025-01-01T10:00:00+00:00",
"is_authenticated": True,
"livekit": None, "livekit": None,
} }
@@ -130,7 +120,6 @@ def test_request_entry_authenticated_user(settings, encryption_mode):
assert participant_data.get("username") == "test_user" assert participant_data.get("username") == "test_user"
@freeze_time("2025-01-01 10:00:00")
def test_request_entry_with_existing_participants(settings): def test_request_entry_with_existing_participants(settings):
"""Anonymous users should be allowed to request entry to a room with existing participants.""" """Anonymous users should be allowed to request entry to a room with existing participants."""
# Create a restricted access room # Create a restricted access room
@@ -149,7 +138,6 @@ def test_request_entry_with_existing_participants(settings):
"username": "user1", "username": "user1",
"status": "waiting", "status": "waiting",
"color": "#123456", "color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
}, },
) )
cache.set( cache.set(
@@ -159,7 +147,6 @@ def test_request_entry_with_existing_participants(settings):
"username": "user2", "username": "user2",
"status": "accepted", "status": "accepted",
"color": "#654321", "color": "#654321",
"entered_at": "2025-01-01T10:00:00+00:00",
}, },
) )
@@ -191,10 +178,8 @@ def test_request_entry_with_existing_participants(settings):
assert response.json() == { assert response.json() == {
"id": participant_id, "id": participant_id,
"username": "test_user", "username": "test_user",
"entered_at": "2025-01-01T10:00:00+00:00",
"status": "waiting", "status": "waiting",
"color": "mocked-color", "color": "mocked-color",
"is_authenticated": False,
"livekit": None, "livekit": None,
} }
@@ -207,7 +192,6 @@ def test_request_entry_with_existing_participants(settings):
assert participant_data.get("username") == "test_user" assert participant_data.get("username") == "test_user"
@freeze_time("2025-01-01 10:00:00")
def test_request_entry_public_room(settings): def test_request_entry_public_room(settings):
"""Entry requests to public rooms should return ACCEPTED status with LiveKit config.""" """Entry requests to public rooms should return ACCEPTED status with LiveKit config."""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC) room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
@@ -246,10 +230,8 @@ def test_request_entry_public_room(settings):
assert response.json() == { assert response.json() == {
"id": "123", "id": "123",
"username": "test_user", "username": "test_user",
"entered_at": "2025-01-01T10:00:00+00:00",
"status": "accepted", "status": "accepted",
"color": "mocked-color", "color": "mocked-color",
"is_authenticated": False,
"livekit": {"token": "test-token"}, "livekit": {"token": "test-token"},
} }
@@ -258,7 +240,6 @@ def test_request_entry_public_room(settings):
assert not lobby_keys assert not lobby_keys
@freeze_time("2025-01-01 10:00:00")
def test_request_entry_authenticated_user_public_room(settings): def test_request_entry_authenticated_user_public_room(settings):
"""While authenticated, entry request to public rooms should get accepted.""" """While authenticated, entry request to public rooms should get accepted."""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC) room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
@@ -301,10 +282,8 @@ def test_request_entry_authenticated_user_public_room(settings):
assert response.json() == { assert response.json() == {
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"username": "test_user", "username": "test_user",
"entered_at": "2025-01-01T10:00:00+00:00",
"status": "accepted", "status": "accepted",
"color": "mocked-color", "color": "mocked-color",
"is_authenticated": True,
"livekit": {"token": "test-token"}, "livekit": {"token": "test-token"},
} }
@@ -313,7 +292,6 @@ def test_request_entry_authenticated_user_public_room(settings):
assert not lobby_keys assert not lobby_keys
@freeze_time("2025-01-01 10:00:00")
def test_request_entry_waiting_participant_public_room(settings): def test_request_entry_waiting_participant_public_room(settings):
"""While waiting, entry request to public rooms should get accepted.""" """While waiting, entry request to public rooms should get accepted."""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC) room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
@@ -330,7 +308,6 @@ def test_request_entry_waiting_participant_public_room(settings):
"username": "user1", "username": "user1",
"status": "waiting", "status": "waiting",
"color": "#123456", "color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
}, },
) )
@@ -361,8 +338,6 @@ def test_request_entry_waiting_participant_public_room(settings):
"username": "user1", "username": "user1",
"status": "accepted", "status": "accepted",
"color": "#123456", "color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
"is_authenticated": False,
"livekit": {"token": "test-token"}, "livekit": {"token": "test-token"},
} }
@@ -468,7 +443,6 @@ def test_allow_participant_to_enter_success(settings, allow_entry, updated_statu
"status": "waiting", "status": "waiting",
"username": "foo", "username": "foo",
"color": "123", "color": "123",
"entered_at": "2025-01-01T10:00:00+00:00",
}, },
) )
@@ -604,7 +578,6 @@ def test_list_waiting_participants_success(settings):
"username": "user1", "username": "user1",
"status": "waiting", "status": "waiting",
"color": "#123456", "color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
}, },
) )
cache.set( cache.set(
@@ -614,37 +587,28 @@ def test_list_waiting_participants_success(settings):
"username": "user2", "username": "user2",
"status": "waiting", "status": "waiting",
"color": "#654321", "color": "#654321",
"entered_at": "2025-01-01T10:05:00+00:00",
}, },
) )
lobby_service = LobbyService()
lobby_service._index_add(room.id, "2f7f162f-e7d1-421b-90e7-02bfbfbf8def")
lobby_service._index_add(room.id, "f4ca3ab8a6c04ad88097b8da33f60f10")
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/") response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == { participants = response.json().get("participants")
"participants": [ assert sorted(participants, key=lambda p: p["id"]) == [
{ {
"id": "f4ca3ab8a6c04ad88097b8da33f60f10", "id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"username": "user2", "username": "user1",
"status": "waiting", "status": "waiting",
"color": "#654321", "color": "#123456",
"is_authenticated": False, },
"entered_at": "2025-01-01T10:05:00+00:00", {
}, "id": "f4ca3ab8a6c04ad88097b8da33f60f10",
{ "username": "user2",
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "status": "waiting",
"username": "user1", "color": "#654321",
"status": "waiting", },
"color": "#123456", ]
"is_authenticated": False,
"entered_at": "2025-01-01T10:00:00+00:00",
},
]
}
def test_list_waiting_participants_empty(settings): def test_list_waiting_participants_empty(settings):
@@ -372,67 +372,6 @@ def test_rename_participant_unexpected_twirp_error(mock_livekit_client, room, to
mock_livekit_client.aclose.assert_called_once() mock_livekit_client.aclose.assert_called_once()
@pytest.mark.parametrize("name", ["John Doe", "Admin", "Room Owner"])
def test_rename_participant_forbidden_when_display_name_edit_disabled(
mock_livekit_client, settings, room, token, name
):
"""
Test rename is rejected for authenticated users when the self-hoster
disables AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME.
"""
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url, {"name": name}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.data == {
"error": "Authenticated participants cannot edit their display name"
}
mock_livekit_client.room.update_participant.assert_not_called()
def test_rename_participant_allowed_when_display_name_edit_enabled(
mock_livekit_client, settings, room, token
):
"""Test rename still works for authenticated users when the setting is enabled."""
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = True
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
)
assert response.status_code == status.HTTP_200_OK
mock_livekit_client.room.update_participant.assert_called_once()
def test_rename_participant_anonymous_allowed_when_display_name_edit_disabled(
mock_livekit_client, settings, room, anonymous_token
):
"""
Test the setting only restricts authenticated users: anonymous participants
have no account name to fall back on and can still rename themselves.
"""
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
client = APIClient()
url = reverse("rooms-rename", kwargs={"pk": room.id})
response = client.post(
url,
{"name": "Guest User"},
format="json",
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
)
assert response.status_code == status.HTTP_200_OK
mock_livekit_client.room.update_participant.assert_called_once()
def test_rename_participant_success_anonymous( def test_rename_participant_success_anonymous(
mock_livekit_client, room, anonymous_token mock_livekit_client, room, anonymous_token
): ):
@@ -33,7 +33,6 @@ def test_api_rooms_retrieve_anonymous_private_pk():
"id": str(room.id), "id": str(room.id),
"name": room.name, "name": room.name,
"slug": room.slug, "slug": room.slug,
"encryption_mode": room.encryption_mode,
} }
@@ -53,7 +52,6 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
"id": str(room.id), "id": str(room.id),
"name": room.name, "name": room.name,
"slug": room.slug, "slug": room.slug,
"encryption_mode": room.encryption_mode,
} }
@@ -72,7 +70,6 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
"id": str(room.id), "id": str(room.id),
"name": room.name, "name": room.name,
"slug": room.slug, "slug": room.slug,
"encryption_mode": room.encryption_mode,
} }
@@ -89,7 +86,6 @@ def test_api_rooms_retrieve_anonymous_private_slug():
"id": str(room.id), "id": str(room.id),
"name": room.name, "name": room.name,
"slug": room.slug, "slug": room.slug,
"encryption_mode": room.encryption_mode,
} }
@@ -106,7 +102,6 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
"id": str(room.id), "id": str(room.id),
"name": room.name, "name": room.name,
"slug": room.slug, "slug": room.slug,
"encryption_mode": room.encryption_mode,
} }
@@ -222,7 +217,6 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
"name": room.name, "name": room.name,
"pin_code": room.pin_code, "pin_code": room.pin_code,
"slug": room.slug, "slug": room.slug,
"encryption_mode": room.encryption_mode,
} }
mock_token.assert_called_once() mock_token.assert_called_once()
@@ -269,7 +263,6 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
"name": room.name, "name": room.name,
"pin_code": room.pin_code, "pin_code": room.pin_code,
"slug": room.slug, "slug": room.slug,
"encryption_mode": room.encryption_mode,
} }
mock_token.assert_called_once_with( mock_token.assert_called_once_with(
@@ -280,7 +273,6 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
sources=["camera"], sources=["camera"],
role=None, role=None,
participant_id=None, participant_id=None,
encryption_mode="none",
) )
@@ -322,7 +314,6 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
"name": room.name, "name": room.name,
"pin_code": room.pin_code, "pin_code": room.pin_code,
"slug": room.slug, "slug": room.slug,
"encryption_mode": room.encryption_mode,
} }
mock_token.assert_called_once_with( mock_token.assert_called_once_with(
@@ -333,7 +324,6 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
sources=None, sources=None,
role=None, role=None,
participant_id=None, participant_id=None,
encryption_mode="none",
) )
@@ -359,7 +349,6 @@ def test_api_rooms_retrieve_authenticated():
"id": str(room.id), "id": str(room.id),
"name": room.name, "name": room.name,
"slug": room.slug, "slug": room.slug,
"encryption_mode": room.encryption_mode,
} }
@@ -411,7 +400,6 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
"name": room.name, "name": room.name,
"pin_code": room.pin_code, "pin_code": room.pin_code,
"slug": room.slug, "slug": room.slug,
"encryption_mode": room.encryption_mode,
} }
mock_token.assert_called_once_with( mock_token.assert_called_once_with(
@@ -422,7 +410,6 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
sources=["camera"], sources=["camera"],
role=str(RoleChoices.MEMBER), role=str(RoleChoices.MEMBER),
participant_id=None, participant_id=None,
encryption_mode="none",
) )
@@ -474,7 +461,6 @@ def test_api_rooms_retrieve_administrators(
"short_name": other_user_access.user.short_name, "short_name": other_user_access.user.short_name,
"timezone": "UTC", "timezone": "UTC",
"language": other_user_access.user.language, "language": other_user_access.user.language,
"default_encryption_mode": "none",
}, },
"resource": str(room.id), "resource": str(room.id),
"role": other_user_access.role, "role": other_user_access.role,
@@ -490,7 +476,6 @@ def test_api_rooms_retrieve_administrators(
"short_name": user_access.user.short_name, "short_name": user_access.user.short_name,
"timezone": "UTC", "timezone": "UTC",
"language": user_access.user.language, "language": user_access.user.language,
"default_encryption_mode": "none",
}, },
"resource": str(room.id), "resource": str(room.id),
"role": user_access.role, "role": user_access.role,
@@ -511,7 +496,6 @@ def test_api_rooms_retrieve_administrators(
"name": room.name, "name": room.name,
"pin_code": room.pin_code, "pin_code": room.pin_code,
"slug": room.slug, "slug": room.slug,
"encryption_mode": room.encryption_mode,
} }
mock_token.assert_called_once_with( mock_token.assert_called_once_with(
@@ -522,25 +506,4 @@ def test_api_rooms_retrieve_administrators(
sources=None, sources=None,
role=str(user_access.role), role=str(user_access.role),
participant_id=None, participant_id=None,
encryption_mode="none",
) )
@pytest.mark.parametrize("encryption_mode", ["none", "basic"])
@mock.patch("core.utils.generate_token", return_value="test-token")
def test_api_rooms_retrieve_custom_username(mock_token, encryption_mode, settings):
"""Encryption does not override the participant's requested display name."""
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = True
user = UserFactory(full_name="Profile Name")
room = RoomFactory(
access_level=RoomAccessLevel.RESTRICTED, encryption_mode=encryption_mode
)
UserResourceAccessFactory(resource=room, user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/rooms/{room.id}/", {"username": "Custom Name"})
assert response.status_code == 200
assert mock_token.call_args.kwargs["username"] == "Custom Name"
assert mock_token.call_args.kwargs["encryption_mode"] == encryption_mode
@@ -470,6 +470,160 @@ def test_start_recording_options_unknown_field_rejected(settings):
assert response.status_code == 400 assert response.status_code == 400
def test_start_recording_options_encoding_valid(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Should accept a valid encoding configuration."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{
"mode": "screen_recording",
"options": {"encoding": {"resolution": "720p", "profile": "talking_heads"}},
},
format="json",
)
assert response.status_code == 201
def test_start_recording_persists_resolved_encoding(
settings, mock_worker_service_factory, mock_worker_manager
):
"""The resolved encoding should be persisted in recording.options alongside
the requested resolution/profile for traceability."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{
"mode": "screen_recording",
"options": {"encoding": {"resolution": "720p", "profile": "talking_heads"}},
},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options["encoding"] == {
"resolution": "720p",
"profile": "talking_heads",
"resolved": {
"key_frame_interval": settings.RECORDING_ENCODING_KEY_FRAME_INTERVAL_S,
"width": 1280,
"height": 720,
"framerate": 15,
"video_bitrate": 700,
},
}
def test_start_recording_forwards_resolved_encoding_to_worker(
settings, mock_worker_service, mock_worker_service_factory
):
"""The resolved encoding should passed on to the worker."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
mock_worker_service.start.return_value = "egress-123"
with mock.patch("core.utils.update_room_metadata"):
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{
"mode": "screen_recording",
"options": {
"encoding": {"resolution": "720p", "profile": "talking_heads"}
},
},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
mock_worker_service.start.assert_called_once_with(
str(room.id),
recording.id,
encoding_options=recording.options["encoding"]["resolved"],
)
def test_start_recording_options_encoding_invalid_resolution(settings):
"""Should reject invalid encoding resolution values."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"encoding": {"resolution": "4K"}}},
format="json",
)
assert response.status_code == 400
def test_start_recording_options_encoding_unknown_key_rejected(settings):
"""Should reject unknown keys in encoding configuration."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{
"mode": "screen_recording",
"options": {"encoding": {"bitrate": 9000}},
},
format="json",
)
assert response.status_code == 400
def test_start_recording_options_without_encoding_unchanged(
settings, mock_worker_service_factory, mock_worker_manager
):
"""Requests without encoding should keep existing options behavior."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-recording/",
{"mode": "screen_recording", "options": {"language": "fr"}},
format="json",
)
assert response.status_code == 201
recording = Recording.objects.get(room=room)
assert recording.options == {"language": "fr"}
@pytest.mark.parametrize("value", ["foo", 12]) @pytest.mark.parametrize("value", ["foo", 12])
def test_start_recording_options_invalid_transcribe_type(settings, value): def test_start_recording_options_invalid_transcribe_type(settings, value):
"""Should reject non-boolean transcribe values.""" """Should reject non-boolean transcribe values."""
@@ -117,11 +117,9 @@ def test_start_subtitle_invalid_token():
assert response.json() == {"detail": "Invalid LiveKit token: Not enough segments"} assert response.json() == {"detail": "Invalid LiveKit token: Not enough segments"}
def test_start_subtitle_disabled_by_default(mock_livekit_token, settings): def test_start_subtitle_disabled_by_default(mock_livekit_token):
"""Test that subtitle functionality is disabled when feature flag is off.""" """Test that subtitle functionality is disabled when feature flag is off."""
settings.ROOM_SUBTITLE_ENABLED = False
room = RoomFactory() room = RoomFactory()
user = UserFactory() user = UserFactory()
client = APIClient() client = APIClient()
@@ -381,11 +381,9 @@ def test_api_rooms_update_administrators_of_another():
assert other_room.slug == "old-name" assert other_room.slug == "old-name"
@pytest.mark.parametrize("exception", [RoomNotFoundException, RoomManagementException]) @patch.object(RoomManagement, "update_metadata", side_effect=RoomNotFoundException)
@patch.object(RoomManagement, "update_metadata") def test_api_rooms_update_livekit_room_not_found(mock_update_metadata):
def test_api_rooms_update_livekit_sync_failure(mock_update_metadata, exception): """Should not fail the API request when the LiveKit room does not exist yet."""
"""Should not fail the API request when the LiveKit metadata sync fails."""
mock_update_metadata.side_effect = exception
user = UserFactory() user = UserFactory()
room = RoomFactory( room = RoomFactory(
users=[(user, random.choice(["administrator", "owner"]))], users=[(user, random.choice(["administrator", "owner"]))],
@@ -412,22 +410,30 @@ def test_api_rooms_update_livekit_sync_failure(mock_update_metadata, exception):
) )
@pytest.mark.parametrize( @patch.object(RoomManagement, "update_metadata", side_effect=RoomManagementException)
"access_level", [RoomAccessLevel.PUBLIC, RoomAccessLevel.TRUSTED] def test_api_rooms_update_livekit_sync_failure(mock_update_metadata):
) """Should not fail the API request when the LiveKit metadata sync fails."""
def test_api_rooms_update_encrypted_access_rejected(access_level):
"""API updates cannot change an encrypted room away from restricted access."""
room = RoomFactory(encryption_mode="basic")
user = UserFactory() user = UserFactory()
room.accesses.create(user=user, role="owner") room = RoomFactory(
users=[(user, random.choice(["administrator", "owner"]))],
configuration={},
)
client = APIClient() client = APIClient()
client.force_login(user) client.force_login(user)
response = client.patch( response = client.patch(
f"/api/v1.0/rooms/{room.id}/", {"access_level": access_level} f"/api/v1.0/rooms/{room.id!s}/",
{"configuration": {"can_publish_sources": ["camera"]}},
format="json",
) )
assert response.status_code == 200
assert response.status_code == 400
assert "access_level" in response.json()
room.refresh_from_db() room.refresh_from_db()
assert room.access_level == RoomAccessLevel.RESTRICTED assert room.configuration == {"can_publish_sources": ["camera"]}
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"access_level": room.access_level,
"configuration": {"can_publish_sources": ["camera"]},
},
)
@@ -94,7 +94,7 @@ def test_invalid_payload(client, auth_token, mock_livekit_config):
def test_unknown_event_type(client, mock_livekit_config): def test_unknown_event_type(client, mock_livekit_config):
"""Should acknowledge (200) an unknown event type rather than reject it.""" """Should return 422 for unknown event type."""
event_data = json.dumps({"event": "unknown_event_type"}) event_data = json.dumps({"event": "unknown_event_type"})
# Generate auth token for this specific payload # Generate auth token for this specific payload
@@ -112,8 +112,10 @@ def test_unknown_event_type(client, mock_livekit_config):
HTTP_AUTHORIZATION=auth_token, HTTP_AUTHORIZATION=auth_token,
) )
assert response.status_code == 200 assert response.status_code == 422
assert response.json() == {"status": "success"} assert response.json() == {
"status": "error",
}
@mock.patch.object(LiveKitEventsService, "_handle_room_finished") @mock.patch.object(LiveKitEventsService, "_handle_room_finished")
@@ -3,7 +3,6 @@ Test LiveKitEvents service.
""" """
# pylint: disable=W0621,W0613, W0212, E0611 # pylint: disable=W0621,W0613, W0212, E0611
import logging
import uuid import uuid
from unittest import mock from unittest import mock
@@ -11,16 +10,14 @@ import pytest
from livekit.api import EgressStatus from livekit.api import EgressStatus
from core.factories import RecordingFactory, RoomFactory from core.factories import RecordingFactory, RoomFactory
from core.recording.enums import RecordingWorkerEvent
from core.recording.services.recording_events import RecordingEventsService from core.recording.services.recording_events import RecordingEventsService
from core.services.livekit_events import ( from core.services.livekit_events import (
EGRESS_STATUS_TO_RECORDING_EVENT,
ActionFailedError, ActionFailedError,
AuthenticationError, AuthenticationError,
InvalidPayloadError, InvalidPayloadError,
LiveKitEventsService, LiveKitEventsService,
UnsupportedEventTypeError,
api, api,
to_recording_event,
) )
from core.services.lobby import LobbyService from core.services.lobby import LobbyService
from core.services.room_management import RoomManagementException from core.services.room_management import RoomManagementException
@@ -81,7 +78,7 @@ def test_initialization(
def test_handle_egress_ended_success( # pylint: disable=too-many-arguments, too-many-positional-arguments def test_handle_egress_ended_success( # pylint: disable=too-many-arguments, too-many-positional-arguments
mock_update_metadata, mock_notify, mode, notification_type, service mock_update_metadata, mock_notify, mode, notification_type, service
): ):
"""Should successfully stop recording and notify all participant.""" """Should successfully stop recording and notifies all participant."""
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active") recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
mock_data = mock.MagicMock() mock_data = mock.MagicMock()
@@ -108,6 +105,7 @@ def test_handle_egress_ended_success( # pylint: disable=too-many-arguments, too
( (
(EgressStatus.EGRESS_ACTIVE, "started"), (EgressStatus.EGRESS_ACTIVE, "started"),
(EgressStatus.EGRESS_ENDING, "saving"), (EgressStatus.EGRESS_ENDING, "saving"),
(EgressStatus.EGRESS_ABORTED, "aborted"),
), ),
) )
@mock.patch("core.services.room_management.RoomManagement.update_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
@@ -128,6 +126,29 @@ def test_handle_egress_updated_success(
) )
@pytest.mark.parametrize(
"egress_status",
(
EgressStatus.EGRESS_FAILED,
EgressStatus.EGRESS_LIMIT_REACHED,
),
)
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_updated_non_handled(
mock_update_metadata, egress_status, service
):
"""Should ignore certain egress status and don't trigger metadata updates."""
recording = RecordingFactory(worker_id="worker-1", status="initiated")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = egress_status
service._handle_egress_updated(mock_data)
mock_update_metadata.assert_not_called()
@pytest.mark.parametrize( @pytest.mark.parametrize(
("mode", "notification_type"), ("mode", "notification_type"),
( (
@@ -160,38 +181,33 @@ def test_handle_egress_ended_metadata_update_fails( # pylint: disable=too-many-
assert recording.status == "saved" assert recording.status == "saved"
@mock.patch(
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
@mock.patch("core.utils.notify_participants") @mock.patch("core.utils.notify_participants")
@mock.patch("core.services.room_management.RoomManagement.update_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_notification_fails( def test_handle_egress_ended_notification_fails(
mock_update_metadata, mock_notify, mock_notify_external_services, service mock_update_metadata, mock_notify, service
): ):
"""Should still stop and save the recording when notifying participants fails.""" """Should raise ActionFailedError when notification fails but still stop recording."""
mock_notify_external_services.return_value = False
mock_notify.side_effect = NotificationError("Error notifying")
recording = RecordingFactory(worker_id="worker-1", status="active") recording = RecordingFactory(worker_id="worker-1", status="active")
mock_data = mock.MagicMock() mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
service._handle_egress_ended(mock_data) mock_notify.side_effect = NotificationError("Error notifying")
with pytest.raises(
ActionFailedError,
match=r"Failed to process limit reached event for recording .+",
):
service._handle_egress_ended(mock_data)
recording.refresh_from_db()
assert recording.status == "stopped"
mock_notify.assert_called_once_with(
room_name=str(recording.room.id),
notification_data={"type": "screenRecordingLimitReached"},
)
mock_update_metadata.assert_called_once_with( mock_update_metadata.assert_called_once_with(
str(recording.room.id), remove_keys=["recording_mode", "recording_status"] str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
) )
recording.refresh_from_db()
assert recording.status == "saved"
@mock.patch("core.utils.notify_participants") @mock.patch("core.utils.notify_participants")
@mock.patch("core.services.room_management.RoomManagement.update_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
@@ -217,25 +233,17 @@ def test_handle_egress_ended_recording_not_found(
assert recording.status == "active" assert recording.status == "active"
@pytest.mark.parametrize(
"egress_status",
(
EgressStatus.EGRESS_FAILED,
EgressStatus.EGRESS_ABORTED,
EgressStatus.EGRESS_LIMIT_REACHED,
),
)
@mock.patch("core.utils.notify_participants") @mock.patch("core.utils.notify_participants")
@mock.patch("core.services.room_management.RoomManagement.update_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_recording_should_not_be_saved( def test_handle_egress_ended_recording_not_active(
mock_update_metadata, mock_notify, egress_status, service mock_update_metadata, mock_notify, service
): ):
"""Don't update status for recordings that must not be saved.""" """Should ignore non-active recordings."""
recording = RecordingFactory(worker_id="worker-1", status="failed_to_stop") recording = RecordingFactory(worker_id="worker-1", status="failed_to_stop")
mock_data = mock.MagicMock() mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = "worker-1" mock_data.egress_info.egress_id = "worker-1"
mock_data.egress_info.status = egress_status mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
service._handle_egress_ended(mock_data) service._handle_egress_ended(mock_data)
@@ -248,24 +256,12 @@ def test_handle_egress_ended_recording_should_not_be_saved(
assert recording.status == "failed_to_stop" assert recording.status == "failed_to_stop"
@mock.patch(
"core.recording.services.recording_events.notification_service."
"notify_external_services"
)
@mock.patch("core.utils.notify_participants") @mock.patch("core.utils.notify_participants")
@mock.patch("core.services.room_management.RoomManagement.update_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_complete_does_not_notify_participants( def test_handle_egress_ended_recording_not_limit_reached(
mock_update_metadata, mock_notify, mock_notify_external_services, service mock_update_metadata, mock_notify, service
): ):
"""Shouldn't notify participants on a successful egress. """Should ignore egress non-limit-reached statuses."""
EGRESS_COMPLETE is the only ended status that notifies no one: limit
reached, aborted and failed egresses each send their own notification.
A stopped recording is simply finalized, which is the nominal flow once
the egress uploaded the file of a user-initiated stop.
"""
mock_notify_external_services.return_value = False
recording = RecordingFactory(worker_id="worker-1", status="stopped") recording = RecordingFactory(worker_id="worker-1", status="stopped")
mock_data = mock.MagicMock() mock_data = mock.MagicMock()
@@ -278,10 +274,7 @@ def test_handle_egress_ended_complete_does_not_notify_participants(
mock_update_metadata.assert_called_once_with( mock_update_metadata.assert_called_once_with(
str(recording.room.id), remove_keys=["recording_mode", "recording_status"] str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
) )
mock_notify_external_services.assert_called_once_with(recording) assert recording.status == "stopped"
recording.refresh_from_db()
assert recording.status == "saved"
@mock.patch("core.services.livekit_events.MetadataCollectorService") @mock.patch("core.services.livekit_events.MetadataCollectorService")
@@ -341,163 +334,96 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
mock_collector.stop.assert_not_called() mock_collector.stop.assert_not_called()
@pytest.mark.parametrize(
("egress_status", "recording_status", "event", "expected_level"),
(
(EgressStatus.EGRESS_ABORTED, "active", "aborted", logging.INFO),
(EgressStatus.EGRESS_FAILED, "active", "failed", logging.ERROR),
# The synchronous stop may already have persisted the terminal status,
# and the error details exist only in the webhook payload.
(EgressStatus.EGRESS_ABORTED, "aborted", "aborted", logging.INFO),
(EgressStatus.EGRESS_FAILED, "failed", "failed", logging.ERROR),
),
)
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_logs_livekit_error( # noqa: PLR0913, PLR0917
mock_update_metadata,
mock_notify,
egress_status,
recording_status,
event,
expected_level,
service,
caplog,
): # pylint: disable=too-many-arguments,too-many-positional-arguments
"""Should log the reason LiveKit reported an unsuccessful egress."""
recording = RecordingFactory(worker_id="worker-1", status=recording_status)
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = egress_status
mock_data.egress_info.error = "could not connect to the room"
mock_data.egress_info.error_code = 500
with caplog.at_level(logging.INFO):
service._handle_egress_ended(mock_data)
assert (
f"Recording worker reported {event} for recording {recording.id}" in caplog.text
)
assert "could not connect to the room" in caplog.text
assert "error_code=500" in caplog.text
worker_logs = [
record
for record in caplog.records
if record.name == "core.recording.services.recording_events"
]
assert [record.levelno for record in worker_logs] == [expected_level]
@pytest.mark.parametrize(
"egress_status",
(EgressStatus.EGRESS_COMPLETE, EgressStatus.EGRESS_LIMIT_REACHED),
)
@mock.patch( @mock.patch(
"core.recording.services.recording_events.notification_service." "core.recording.services.recording_events.notification_service."
"notify_external_services" "notify_external_services"
) )
@mock.patch("core.utils.notify_participants") @mock.patch("core.utils.notify_participants")
@mock.patch("core.services.room_management.RoomManagement.update_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_does_not_log_error_on_successful_egress( # noqa: PLR0913, PLR0917 @pytest.mark.parametrize(
"egress_status",
[EgressStatus.EGRESS_COMPLETE, EgressStatus.EGRESS_LIMIT_REACHED],
)
@pytest.mark.parametrize(
"notify_return_value, recording_status",
[(True, "notification_succeeded"), (False, "saved")],
)
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913, PLR0917
mock_update_metadata, mock_update_metadata,
mock_notify, mock_notify,
mock_notify_external_services, mock_notify_external_services,
notify_return_value,
recording_status,
egress_status, egress_status,
service, service,
caplog,
): # pylint: disable=too-many-arguments,too-many-positional-arguments ): # pylint: disable=too-many-arguments,too-many-positional-arguments
"""Shouldn't log an egress error when LiveKit reports a successful egress.""" """Should notify external services and save the recording on egress completion
(EGRESS_COMPLETE or EGRESS_LIMIT_REACHED).
"""
mock_notify_external_services.return_value = notify_return_value
recording = RecordingFactory(worker_id="worker-1", status="active") recording = RecordingFactory(worker_id="worker-1", status="active")
mock_data = mock.MagicMock() mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = egress_status mock_data.egress_info.status = egress_status
with caplog.at_level(logging.ERROR): service._handle_egress_ended(mock_data)
service._handle_egress_ended(mock_data)
assert "Recording worker reported" not in caplog.text mock_notify_external_services.assert_called_once_with(recording)
recording.refresh_from_db()
assert recording.status == recording_status
@mock.patch("core.utils.notify_participants") @pytest.mark.parametrize(
"egress_status",
[
EgressStatus.EGRESS_STARTING,
EgressStatus.EGRESS_ACTIVE,
EgressStatus.EGRESS_ENDING,
EgressStatus.EGRESS_FAILED,
EgressStatus.EGRESS_ABORTED,
],
)
@mock.patch("core.services.room_management.RoomManagement.update_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_ended_logs_livekit_error_before_cleaning_up( def test_handle_egress_ended_does_not_save_on_wrong_status(
mock_update_metadata, mock_notify, service, caplog mock_update_metadata, egress_status, service
): ):
"""Should log the failure reason even when the cleanup fails afterwards.""" """Shouldn't save on invalid status."""
mock_update_metadata.side_effect = RuntimeError("LiveKit is unreachable")
recording = RecordingFactory(worker_id="worker-1", status="active") recording = RecordingFactory(worker_id="worker-1", status="active")
mock_data = mock.MagicMock() mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = EgressStatus.EGRESS_FAILED mock_data.egress_info.status = egress_status
mock_data.egress_info.error = "could not connect to the room"
mock_data.egress_info.error_code = 500
with caplog.at_level(logging.ERROR), pytest.raises(RuntimeError): service._handle_egress_ended(mock_data)
service._handle_egress_ended(mock_data)
assert ( recording.refresh_from_db()
f"Recording worker reported failed for recording {recording.id}" in caplog.text assert recording.status == "active"
)
assert "could not connect to the room" in caplog.text
def test_egress_status_mapping_covers_every_livekit_status():
"""Every egress status LiveKit can report must translate to a recording event."""
unmapped = [
name
for name in EgressStatus.keys()
if getattr(EgressStatus, name) not in EGRESS_STATUS_TO_RECORDING_EVENT
]
assert not unmapped
@pytest.mark.parametrize( @pytest.mark.parametrize(
("egress_status", "expected_event"), "status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
(
(EgressStatus.EGRESS_STARTING, RecordingWorkerEvent.STARTING),
(EgressStatus.EGRESS_ACTIVE, RecordingWorkerEvent.STARTED),
(EgressStatus.EGRESS_ENDING, RecordingWorkerEvent.SAVING),
(EgressStatus.EGRESS_COMPLETE, RecordingWorkerEvent.COMPLETED),
(EgressStatus.EGRESS_LIMIT_REACHED, RecordingWorkerEvent.LIMIT_REACHED),
(EgressStatus.EGRESS_ABORTED, RecordingWorkerEvent.ABORTED),
(EgressStatus.EGRESS_FAILED, RecordingWorkerEvent.FAILED),
),
) )
def test_to_recording_event_translates_egress_status(egress_status, expected_event):
"""Should translate a LiveKit egress status into a recording worker event."""
assert to_recording_event(egress_status) == expected_event
def test_to_recording_event_returns_none_on_unmapped_status(caplog):
"""Should warn and return None when LiveKit reports an unknown status."""
with caplog.at_level(logging.WARNING):
event = to_recording_event(999)
assert event is None
assert "Unmapped LiveKit egress status" in caplog.text
@mock.patch("core.services.room_management.RoomManagement.update_metadata") @mock.patch("core.services.room_management.RoomManagement.update_metadata")
def test_handle_egress_updated_ignores_unmapped_status(mock_update_metadata, service): def test_handle_egress_ended_ignores_non_savable_recording(
"""Shouldn't touch the room's metadata when the egress status is unknown.""" mock_update_metadata, status, service
):
"""Should handle non-savable recordings idempotently without raising.
RecordingFactory(worker_id="worker-1", status="active") 'egress_ended' may be redelivered (e.g. for an already-saved recording);
this must not raise, otherwise the webhook would 500 and LiveKit would retry.
"""
recording = RecordingFactory(worker_id="worker-1", status=status)
mock_data = mock.MagicMock() mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = "worker-1" mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = 999 mock_data.egress_info.status = EgressStatus.EGRESS_COMPLETE
service._handle_egress_updated(mock_data) service._handle_egress_ended(mock_data)
mock_update_metadata.assert_not_called() recording.refresh_from_db()
assert recording.status == status
@mock.patch.object(LobbyService, "clear_room_cache") @mock.patch.object(LobbyService, "clear_room_cache")
@@ -739,27 +665,22 @@ def test_receive_missing_auth(service):
@mock.patch.object(api.WebhookReceiver, "receive") @mock.patch.object(api.WebhookReceiver, "receive")
def test_receive_unknown_event_is_acknowledged(mock_receive, service, caplog): def test_receive_unsupported_event(mock_receive, service):
"""Unknown event types are logged and ignored, not rejected. """Should raise LiveKitWebhookError for unsupported events."""
LiveKit adds event types over time and does not retry 4xx responses, so
raising here would silently drop the event.
"""
mock_request = mock.MagicMock() mock_request = mock.MagicMock()
mock_request.headers = {"Authorization": "test_token"} mock_request.headers = {"Authorization": "test_token"}
mock_request.body = b"{}" mock_request.body = b"{}"
# Mock returned data with unsupported event type
mock_data = mock.MagicMock() mock_data = mock.MagicMock()
mock_data.room.name = str(uuid.uuid4()) mock_data.room.name = str(uuid.uuid4())
mock_data.event = "some_future_event" mock_data.event = "unsupported_event"
mock_receive.return_value = mock_data mock_receive.return_value = mock_data
with caplog.at_level("WARNING", logger="core.services.livekit_events"): with pytest.raises(
service.receive(mock_request) # must not raise UnsupportedEventTypeError, match="Unknown webhook type: unsupported_event"
):
assert "Ignoring unknown LiveKit webhook event type 'some_future_event'" in ( service.receive(mock_request)
caplog.text
)
@mock.patch.object(api.WebhookReceiver, "receive") @mock.patch.object(api.WebhookReceiver, "receive")
+23 -180
View File
@@ -2,7 +2,7 @@
Test lobby service. Test lobby service.
""" """
# pylint: disable=W0621,W0613, W0212, R0913, C0302 # pylint: disable=W0621,W0613, W0212, R0913
# ruff: noqa: PLR0913, PLR0917 # ruff: noqa: PLR0913, PLR0917
import uuid import uuid
@@ -14,7 +14,6 @@ from django.core.cache import cache
from django.http import HttpResponse from django.http import HttpResponse
import pytest import pytest
from freezegun import freeze_time
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
from core.models import RoleChoices, RoomAccessLevel from core.models import RoleChoices, RoomAccessLevel
@@ -25,6 +24,7 @@ from core.services.lobby import (
LobbyParticipantStatus, LobbyParticipantStatus,
LobbyService, LobbyService,
) )
from core.services.presence import CACHE_SCAN_ITERSIZE
from core.utils import NotificationError from core.utils import NotificationError
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -56,7 +56,6 @@ def participant_dict():
"username": "test-username", "username": "test-username",
"id": "test-participant-id", "id": "test-participant-id",
"color": "#123456", "color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
} }
@@ -68,7 +67,6 @@ def participant_data():
username="test-username", username="test-username",
id="test-participant-id", id="test-participant-id",
color="#123456", color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
) )
@@ -80,7 +78,6 @@ def test_lobby_participant_to_dict(participant_data):
assert result["username"] == "test-username" assert result["username"] == "test-username"
assert result["id"] == "test-participant-id" assert result["id"] == "test-participant-id"
assert result["color"] == "#123456" assert result["color"] == "#123456"
assert result["entered_at"] == "2025-01-01T10:00:00+00:00"
def test_lobby_participant_from_dict_success(participant_dict): def test_lobby_participant_from_dict_success(participant_dict):
@@ -91,20 +88,6 @@ def test_lobby_participant_from_dict_success(participant_dict):
assert participant.username == "test-username" assert participant.username == "test-username"
assert participant.id == "test-participant-id" assert participant.id == "test-participant-id"
assert participant.color == "#123456" assert participant.color == "#123456"
assert participant.entered_at == "2025-01-01T10:00:00+00:00"
def test_lobby_participant_from_dict_missing_entered_at():
"""`entered_at` is mandatory; data without it is rejected."""
data = {
"status": "waiting",
"username": "test-username",
"id": "test-participant-id",
"color": "#123456",
}
with pytest.raises(LobbyParticipantParsingError, match="Invalid participant data"):
LobbyParticipant.from_dict(data)
def test_lobby_participant_from_dict_default_status(): def test_lobby_participant_from_dict_default_status():
@@ -113,7 +96,6 @@ def test_lobby_participant_from_dict_default_status():
"username": "test-username", "username": "test-username",
"id": "test-participant-id", "id": "test-participant-id",
"color": "#123456", "color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
} }
participant = LobbyParticipant.from_dict(data_without_status) participant = LobbyParticipant.from_dict(data_without_status)
@@ -139,7 +121,6 @@ def test_lobby_participant_from_dict_invalid_status():
"username": "test-username", "username": "test-username",
"id": "test-participant-id", "id": "test-participant-id",
"color": "#123456", "color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
} }
with pytest.raises(LobbyParticipantParsingError, match="Invalid participant data"): with pytest.raises(LobbyParticipantParsingError, match="Invalid participant data"):
@@ -284,7 +265,6 @@ def test_request_entry_public_room(
username=username, username=username,
id=participant_id, id=participant_id,
color="#123456", color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
) )
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id) lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
@@ -303,7 +283,6 @@ def test_request_entry_public_room(
configuration=room.configuration, configuration=room.configuration,
participant_id="test-participant-id", participant_id="test-participant-id",
role=None, role=None,
encryption_mode="none",
) )
lobby_service._get_participant.assert_called_once_with(room.id, participant_id) lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -324,7 +303,6 @@ def test_request_entry_trusted_room(
username=username, username=username,
id=participant_id, id=participant_id,
color="#123456", color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
) )
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id) lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
@@ -343,7 +321,6 @@ def test_request_entry_trusted_room(
configuration=room.configuration, configuration=room.configuration,
participant_id="test-participant-id", participant_id="test-participant-id",
role=None, role=None,
encryption_mode="none",
) )
lobby_service._get_participant.assert_called_once_with(room.id, participant_id) lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -368,7 +345,6 @@ def test_request_entry_new_participant(
username=username, username=username,
id=participant_id, id=participant_id,
color="#123456", color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
) )
mock_enter.return_value = participant_data mock_enter.return_value = participant_data
@@ -376,12 +352,7 @@ def test_request_entry_new_participant(
assert participant == participant_data assert participant == participant_data
assert livekit_config is None assert livekit_config is None
mock_enter.assert_called_once_with( mock_enter.assert_called_once_with(room.id, participant_id, username)
room.id,
participant_id,
username,
is_authenticated=request.user.is_authenticated,
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id) lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -401,7 +372,6 @@ def test_request_entry_waiting_participant(
username=username, username=username,
id=participant_id, id=participant_id,
color="#123456", color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
) )
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id) lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant) lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
@@ -430,7 +400,6 @@ def test_request_entry_accepted_participant(
username=username, username=username,
id=participant_id, id=participant_id,
color="#123456", color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
) )
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id) lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant) lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
@@ -449,7 +418,6 @@ def test_request_entry_accepted_participant(
configuration=room.configuration, configuration=room.configuration,
participant_id="test-participant-id", participant_id="test-participant-id",
role=None, role=None,
encryption_mode="none",
) )
lobby_service._get_participant.assert_called_once_with(room.id, participant_id) lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -472,7 +440,6 @@ def test_request_entry_participant_with_role(
username=username, username=username,
id=participant_id, id=participant_id,
color="#123456", color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
) )
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id) lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant) lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
@@ -491,7 +458,6 @@ def test_request_entry_participant_with_role(
configuration=room.configuration, configuration=room.configuration,
participant_id="test-participant-id", participant_id="test-participant-id",
role="administrator", role="administrator",
encryption_mode="none",
) )
lobby_service._get_participant.assert_called_once_with(room.id, participant_id) lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -500,23 +466,18 @@ def test_request_entry_participant_with_role(
def test_refresh_waiting_status(mock_cache, lobby_service, participant_id): def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
"""Test refreshing waiting status for a participant.""" """Test refreshing waiting status for a participant."""
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key") lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
lobby_service._index_touch = mock.Mock()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
lobby_service.refresh_waiting_status(room.id, participant_id) lobby_service.refresh_waiting_status(room.id, participant_id)
mock_cache.touch.assert_called_once_with( mock_cache.touch.assert_called_once_with(
"mocked_cache_key", settings.LOBBY_WAITING_TIMEOUT "mocked_cache_key", settings.LOBBY_WAITING_TIMEOUT
) )
lobby_service._index_touch.assert_called_once_with(room.id)
# pylint: disable=R0917 # pylint: disable=R0917
@mock.patch("core.services.lobby.cache") @mock.patch("core.services.lobby.cache")
@mock.patch("core.utils.generate_color") @mock.patch("core.utils.generate_color")
@mock.patch("core.utils.notify_participants") @mock.patch("core.utils.notify_participants")
@mock.patch("core.services.lobby.LobbyService._index_add")
@freeze_time("2025-01-01 10:00:00")
def test_enter_success( def test_enter_success(
mock_index_add,
mock_notify, mock_notify,
mock_generate_color, mock_generate_color,
mock_cache, mock_cache,
@@ -536,7 +497,6 @@ def test_enter_success(
assert participant.username == username assert participant.username == username
assert participant.id == participant_id assert participant.id == participant_id
assert participant.color == "#123456" assert participant.color == "#123456"
assert participant.entered_at == "2025-01-01T10:00:00+00:00"
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id) lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
@@ -548,16 +508,13 @@ def test_enter_success(
mock_notify.assert_called_once_with( mock_notify.assert_called_once_with(
room_name=str(room.pk), notification_data={"type": "participantWaiting"} room_name=str(room.pk), notification_data={"type": "participantWaiting"}
) )
mock_index_add.assert_called_once_with(room.id, participant_id)
# pylint: disable=R0917 # pylint: disable=R0917
@mock.patch("core.services.lobby.cache") @mock.patch("core.services.lobby.cache")
@mock.patch("core.utils.generate_color") @mock.patch("core.utils.generate_color")
@mock.patch("core.utils.notify_participants") @mock.patch("core.utils.notify_participants")
@mock.patch("core.services.lobby.LobbyService._index_add")
def test_enter_with_notification_error( def test_enter_with_notification_error(
mock_index_add,
mock_notify, mock_notify,
mock_generate_color, mock_generate_color,
mock_cache, mock_cache,
@@ -584,7 +541,6 @@ def test_enter_with_notification_error(
participant.to_dict(), participant.to_dict(),
timeout=settings.LOBBY_WAITING_TIMEOUT, timeout=settings.LOBBY_WAITING_TIMEOUT,
) )
mock_index_add.assert_called_once_with(room.id, participant_id)
@mock.patch("core.services.lobby.cache") @mock.patch("core.services.lobby.cache")
@@ -623,15 +579,14 @@ def test_get_participant_parsing_error(
@mock.patch("core.services.lobby.cache") @mock.patch("core.services.lobby.cache")
def test_list_waiting_participants_empty(mock_cache, lobby_service): def test_list_waiting_participants_empty(mock_cache, lobby_service):
"""Test listing waiting participants when none exist.""" """Test listing waiting participants when none exist."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) mock_cache.iter_keys.return_value = []
lobby_service._index_members = mock.Mock(return_value=[])
lobby_service._index_remove = mock.Mock()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
result = lobby_service.list_waiting_participants(room.id) result = lobby_service.list_waiting_participants(room.id)
assert result == () assert result == []
lobby_service._index_members.assert_called_once_with(room.id) pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
lobby_service._index_remove.assert_not_called() mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
mock_cache.get_many.assert_not_called() mock_cache.get_many.assert_not_called()
@@ -640,8 +595,7 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
"""Test listing waiting participants with valid data.""" """Test listing waiting participants with valid data."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1" cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
lobby_service._index_members = mock.Mock(return_value=["participant1"]) mock_cache.iter_keys.return_value = [cache_key]
lobby_service._index_remove = mock.Mock()
mock_cache.get_many.return_value = {cache_key: participant_dict} mock_cache.get_many.return_value = {cache_key: participant_dict}
result = lobby_service.list_waiting_participants(room.id) result = lobby_service.list_waiting_participants(room.id)
@@ -649,8 +603,8 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
assert len(result) == 1 assert len(result) == 1
assert result[0]["status"] == "waiting" assert result[0]["status"] == "waiting"
assert result[0]["username"] == "test-username" assert result[0]["username"] == "test-username"
lobby_service._index_members.assert_called_once_with(room.id) pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
lobby_service._index_remove.assert_called_once_with(room.id) mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
mock_cache.get_many.assert_called_once_with([cache_key]) mock_cache.get_many.assert_called_once_with([cache_key])
@@ -666,7 +620,6 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
"username": "user1", "username": "user1",
"id": "participant1", "id": "participant1",
"color": "#123456", "color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
} }
participant2 = { participant2 = {
@@ -674,13 +627,9 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
"username": "user2", "username": "user2",
"id": "participant2", "id": "participant2",
"color": "#654321", "color": "#654321",
"entered_at": "2025-01-01T10:05:00+00:00",
} }
lobby_service._index_members = mock.Mock( mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
return_value=["participant1", "participant2"]
)
lobby_service._index_remove = mock.Mock()
mock_cache.get_many.return_value = { mock_cache.get_many.return_value = {
cache_key1: participant1, cache_key1: participant1,
cache_key2: participant2, cache_key2: participant2,
@@ -690,15 +639,15 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
assert len(result) == 2 assert len(result) == 2
# Most recent entry comes first # Verify both participants are in the result
assert [p["id"] for p in result] == ["participant2", "participant1"] assert any(p["id"] == "participant1" and p["username"] == "user1" for p in result)
assert result[0]["username"] == "user2" assert any(p["id"] == "participant2" and p["username"] == "user2" for p in result)
assert result[1]["username"] == "user1"
# Verify all participants have waiting status # Verify all participants have waiting status
assert all(p["status"] == "waiting" for p in result) assert all(p["status"] == "waiting" for p in result)
lobby_service._index_members.assert_called_once_with(room.id) pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2]) mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
@@ -707,13 +656,12 @@ def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service):
"""Test listing waiting participants with corrupted data.""" """Test listing waiting participants with corrupted data."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1" cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
lobby_service._index_members = mock.Mock(return_value=["participant1"]) mock_cache.iter_keys.return_value = [cache_key]
lobby_service._index_remove = mock.Mock()
mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}} mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}}
result = lobby_service.list_waiting_participants(room.id) result = lobby_service.list_waiting_participants(room.id)
assert result == () assert result == []
mock_cache.delete.assert_called_once_with(cache_key) mock_cache.delete.assert_called_once_with(cache_key)
@@ -729,15 +677,11 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
"username": "user2", "username": "user2",
"id": "participant2", "id": "participant2",
"color": "#654321", "color": "#654321",
"entered_at": "2025-01-01T10:00:00+00:00",
} }
corrupted_participant = {"invalid": "data"} corrupted_participant = {"invalid": "data"}
lobby_service._index_members = mock.Mock( mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
return_value=["participant1", "participant2"]
)
lobby_service._index_remove = mock.Mock()
mock_cache.get_many.return_value = { mock_cache.get_many.return_value = {
cache_key1: corrupted_participant, cache_key1: corrupted_participant,
cache_key2: valid_participant, cache_key2: valid_participant,
@@ -755,6 +699,8 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
mock_cache.delete.assert_called_once_with(cache_key1) mock_cache.delete.assert_called_once_with(cache_key1)
# Verify both cache keys were queried # Verify both cache keys were queried
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2]) mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
@@ -770,20 +716,15 @@ def test_list_waiting_participants_non_waiting(mock_cache, lobby_service):
"username": "user1", "username": "user1",
"id": "participant1", "id": "participant1",
"color": "#123456", "color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
} }
participant2 = { participant2 = {
"status": "accepted", "status": "accepted",
"username": "user2", "username": "user2",
"id": "participant2", "id": "participant2",
"color": "#654321", "color": "#654321",
"entered_at": "2025-01-01T10:00:00+00:00",
} }
lobby_service._index_members = mock.Mock( mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
return_value=["participant1", "participant2"]
)
lobby_service._index_remove = mock.Mock()
mock_cache.get_many.return_value = { mock_cache.get_many.return_value = {
cache_key1: participant1, cache_key1: participant1,
cache_key2: participant2, cache_key2: participant2,
@@ -875,12 +816,10 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
"username": "test-username", "username": "test-username",
"id": participant_id, "id": participant_id,
"color": "#123456", "color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
} }
mock_cache.get.return_value = participant_dict mock_cache.get.return_value = participant_dict
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key") lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
lobby_service._index_touch = mock.Mock()
lobby_service._update_participant_status( lobby_service._update_participant_status(
room.id, room.id,
@@ -894,13 +833,10 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
"username": "test-username", "username": "test-username",
"id": participant_id, "id": participant_id,
"color": "#123456", "color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
"is_authenticated": False,
} }
mock_cache.set.assert_called_once_with( mock_cache.set.assert_called_once_with(
"mocked_cache_key", expected_data, timeout=60 "mocked_cache_key", expected_data, timeout=60
) )
lobby_service._index_touch.assert_called_once_with(room.id)
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id) lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
@@ -921,7 +857,6 @@ def test_clear_room_cache(settings, lobby_service):
username="participant1", username="participant1",
id="participant1", id="participant1",
color="#123456", color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
), ),
timeout=settings.LOBBY_WAITING_TIMEOUT, timeout=settings.LOBBY_WAITING_TIMEOUT,
) )
@@ -932,7 +867,6 @@ def test_clear_room_cache(settings, lobby_service):
username="participant2", username="participant2",
id="participant2", id="participant2",
color="#123456", color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
), ),
timeout=settings.LOBBY_ACCEPTED_TIMEOUT, timeout=settings.LOBBY_ACCEPTED_TIMEOUT,
) )
@@ -943,18 +877,13 @@ def test_clear_room_cache(settings, lobby_service):
username="participant3", username="participant3",
id="participant3", id="participant3",
color="#123456", color="#123456",
entered_at="2025-01-01T10:00:00+00:00",
), ),
timeout=settings.LOBBY_DENIED_TIMEOUT, timeout=settings.LOBBY_DENIED_TIMEOUT,
) )
for participant_id in ("participant1", "participant2", "participant3"):
lobby_service._index_add(room_id, participant_id)
lobby_service.clear_room_cache(room_id) lobby_service.clear_room_cache(room_id)
assert cache.keys(f"test-lobby_{room_id!s}_*") == [] assert cache.keys(f"test-lobby_{room_id!s}_*") == []
assert lobby_service._index_members(room_id) == frozenset()
def test_clear_room_empty(settings, lobby_service): def test_clear_room_empty(settings, lobby_service):
@@ -979,16 +908,12 @@ def test_clear_participant_cache(lobby_service):
"username": "test-username", "username": "test-username",
"id": participant_id, "id": participant_id,
"color": "#123456", "color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
} }
cache.set(cache_key, participant_data, timeout=settings.LOBBY_WAITING_TIMEOUT) cache.set(cache_key, participant_data, timeout=settings.LOBBY_WAITING_TIMEOUT)
lobby_service._index_add(room_id, participant_id)
assert cache.get(cache_key) is not None assert cache.get(cache_key) is not None
assert participant_id in lobby_service._index_members(room_id)
lobby_service.clear_participant_cache(room_id, participant_id) lobby_service.clear_participant_cache(room_id, participant_id)
assert cache.get(cache_key) is None assert cache.get(cache_key) is None
assert participant_id not in lobby_service._index_members(room_id)
def test_clear_participant_cache_nonexistent(lobby_service): def test_clear_participant_cache_nonexistent(lobby_service):
@@ -1002,85 +927,3 @@ def test_clear_participant_cache_nonexistent(lobby_service):
lobby_service.clear_participant_cache(room_id, participant_id) lobby_service.clear_participant_cache(room_id, participant_id)
assert cache.get(cache_key) is None assert cache.get(cache_key) is None
def test_index_add_members_remove_roundtrip(lobby_service):
"""The room index records, lists and forgets participant ids."""
room_id = uuid.uuid4()
assert lobby_service._index_members(room_id) == frozenset()
lobby_service._index_add(room_id, "participant1")
lobby_service._index_add(room_id, "participant2")
assert sorted(lobby_service._index_members(room_id)) == [
"participant1",
"participant2",
]
# The index carries a backstop TTL so abandoned rooms cannot leak it.
ttl = lobby_service._redis().ttl(lobby_service._get_index_key(room_id))
assert 0 < ttl <= settings.LOBBY_ACCEPTED_TIMEOUT
lobby_service._index_remove(room_id, "participant1")
assert lobby_service._index_members(room_id) == frozenset(["participant2"])
@mock.patch("core.utils.notify_participants")
def test_enter_registers_participant_in_room_index(
mock_notify, lobby_service, participant_id, username
):
"""Entering the lobby must index the participant id for the room."""
room_id = uuid.uuid4()
lobby_service.enter(room_id, participant_id, username)
assert lobby_service._index_members(room_id) == frozenset([participant_id])
def test_list_waiting_participants_prunes_stale_index_ids(settings, lobby_service):
"""Indexed ids whose cache entry expired are pruned and not listed."""
settings.LOBBY_KEY_PREFIX = "test-lobby-prune"
room_id = uuid.uuid4()
cache.set(
f"test-lobby-prune_{room_id!s}_participant1",
{
"id": "participant1",
"username": "user1",
"status": "waiting",
"color": "#123456",
"entered_at": "2025-01-01T10:00:00+00:00",
},
timeout=100,
)
lobby_service._index_add(room_id, "participant1")
# participant2 is indexed but its cache entry has expired.
lobby_service._index_add(room_id, "participant2")
result = lobby_service.list_waiting_participants(room_id)
assert [participant["id"] for participant in result] == ["participant1"]
assert lobby_service._index_members(room_id) == frozenset(["participant1"])
def test_refresh_waiting_status_rearms_room_index_ttl(lobby_service, participant_id):
"""A lone waiter's polling must keep the room index alive.
Regression test: the index backstop TTL is only armed at enter() time,
so a participant whose rolling WAITING refreshes outlast it would keep
their entry alive while silently vanishing from the moderator list.
Refreshing the waiting status must therefore re-arm the index TTL.
"""
room_id = uuid.uuid4()
lobby_service._index_add(room_id, participant_id)
index_key = lobby_service._get_index_key(room_id)
redis_client = lobby_service._redis()
redis_client.expire(index_key, 10)
assert redis_client.ttl(index_key) <= 10
lobby_service.refresh_waiting_status(room_id, participant_id)
assert redis_client.ttl(index_key) > 10
assert lobby_service._index_members(room_id) == frozenset([participant_id])
@@ -90,18 +90,19 @@ def test_presence_clear_and_clear_room():
assert presence.is_marked_present(other_room, "a") is True assert presence.is_marked_present(other_room, "a") is True
def test_presence_clear_room_removes_many_entries_and_the_index(): def test_presence_clear_room_scans_in_pages():
"""clear_room removes every entry of the room through the index — never """clear_room removes every match, even across several SCAN pages,
a keyspace scan — and leaves other rooms untouched.""" and only within the room."""
room_id, other_room = str(uuid4()), str(uuid4()) room_id, other_room = str(uuid4()), str(uuid4())
presence = PresenceCache() presence = PresenceCache()
for i in range(7): for i in range(7):
presence.mark_present(room_id, f"user-{i}") presence.mark_present(room_id, f"user-{i}")
presence.mark_present(other_room, "user-0") presence.mark_present(other_room, "user-0")
presence.clear_room(room_id) # An itersize smaller than the match count forces delete_pattern to
# page through several SCAN cursors rather than finish in one pass.
with mock.patch("core.utils.CACHE_SCAN_ITERSIZE", 3):
presence.clear_room(room_id)
assert all(not presence.is_marked_present(room_id, f"user-{i}") for i in range(7)) assert all(not presence.is_marked_present(room_id, f"user-{i}") for i in range(7))
assert presence.is_marked_present(other_room, "user-0") is True assert presence.is_marked_present(other_room, "user-0") is True
assert presence._index_members(room_id) == frozenset([])
assert presence._index_members(other_room) == frozenset(["user-0"])
@@ -5,8 +5,6 @@ from unittest import mock
import pytest import pytest
from livekit.api import TwirpError from livekit.api import TwirpError
from core.factories import RoomFactory
from core.models import RoomAccessLevel
from core.services.room_management import ( from core.services.room_management import (
RoomManagement, RoomManagement,
RoomManagementException, RoomManagementException,
@@ -22,7 +20,7 @@ def test_delete_room_calls_livekit(mock_create_livekit_client):
mock_api.aclose = mock.AsyncMock() mock_api.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api mock_create_livekit_client.return_value = mock_api
RoomManagement.delete_room("room-abc") RoomManagement().delete_room("room-abc")
mock_api.room.delete_room.assert_awaited_once() mock_api.room.delete_room.assert_awaited_once()
request = mock_api.room.delete_room.await_args.args[0] request = mock_api.room.delete_room.await_args.args[0]
@@ -41,7 +39,7 @@ def test_delete_room_raises_not_found(mock_create_livekit_client):
mock_create_livekit_client.return_value = mock_api mock_create_livekit_client.return_value = mock_api
with pytest.raises(RoomNotFoundException): with pytest.raises(RoomNotFoundException):
RoomManagement.delete_room("missing-room") RoomManagement().delete_room("missing-room")
mock_api.aclose.assert_awaited_once() mock_api.aclose.assert_awaited_once()
@@ -57,25 +55,6 @@ def test_delete_room_raises_management_exception(mock_create_livekit_client):
mock_create_livekit_client.return_value = mock_api mock_create_livekit_client.return_value = mock_api
with pytest.raises(RoomManagementException): with pytest.raises(RoomManagementException):
RoomManagement.delete_room("room-abc") RoomManagement().delete_room("room-abc")
mock_api.aclose.assert_awaited_once() mock_api.aclose.assert_awaited_once()
@mock.patch.object(RoomManagement, "update_metadata")
def test_sync_room_metadata_pushes_configuration_and_access_level(mock_update_metadata):
"""The room's configuration and access level are forwarded to LiveKit."""
room = RoomFactory.build(
access_level=RoomAccessLevel.RESTRICTED,
configuration={"everyone_can_mute": True},
)
RoomManagement.sync_room_metadata(room)
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"configuration": {"everyone_can_mute": True},
"access_level": RoomAccessLevel.RESTRICTED,
},
)
-1
View File
@@ -127,7 +127,6 @@ def test_api_users_retrieve_me_authenticated(settings):
"short_name": user.short_name, "short_name": user.short_name,
"language": user.language, "language": user.language,
"timezone": "UTC", "timezone": "UTC",
"default_encryption_mode": "none",
} }
-78
View File
@@ -1,78 +0,0 @@
"""Tests for the Dockerflow health endpoints backing the Kubernetes probes."""
from unittest import mock
from django.core import checks
from django.test.utils import override_settings
import pytest
from dockerflow.django.views import django_check_registry
pytestmark = pytest.mark.django_db
@pytest.mark.parametrize("path", ["/__lbheartbeat__", "/__heartbeat__"])
def test_dockerflow_endpoints_are_anonymous(client, path):
"""Both endpoints answer without authentication."""
response = client.get(path)
assert response.status_code == 200
def test_dockerflow_lbheartbeat_is_a_liveness_signal(client, django_assert_num_queries):
"""The load balancer heartbeat answers 200 without touching the database."""
with django_assert_num_queries(0):
response = client.get("/__lbheartbeat__")
assert response.status_code == 200
def test_dockerflow_lbheartbeat_ignores_allowed_hosts(client):
"""The probes are served before ALLOWED_HOSTS is enforced."""
response = client.get("/__lbheartbeat__", headers={"host": "1.2.3.4:8000"})
assert response.status_code == 200
def test_dockerflow_heartbeat_reports_the_configured_checks(client):
"""The heartbeat runs the database, migrations and redis checks."""
with override_settings(DEBUG=True):
response = client.get("/__heartbeat__")
assert response.status_code == 200
payload = response.json()
assert payload["status"] == "ok"
assert payload["details"] == {}
assert payload["checks"]["check_database_connected"] == "ok"
assert payload["checks"]["check_migrations_applied"] == "ok"
def test_dockerflow_heartbeat_is_a_readiness_signal(client):
"""A check reporting an error takes the heartbeat down with a 500."""
def failing_check(**kwargs):
return [checks.Error("Could not connect to database", id="health.E001")]
with mock.patch.object(
django_check_registry, "get_checks", return_value=[failing_check]
):
response = client.get("/__heartbeat__")
assert response.status_code == 500
assert response.json()["status"] == "error"
def test_dockerflow_heartbeat_tolerates_warnings(client):
"""A warning in the check should not affect the probes."""
def warning_check(**kwargs):
return [checks.Warning("Unapplied migration", id="health.W001")]
with mock.patch.object(
django_check_registry, "get_checks", return_value=[warning_check]
):
response = client.get("/__heartbeat__")
assert response.status_code == 200
assert response.json()["status"] == "warning"
@@ -1,96 +0,0 @@
"""Tests for the external API ResourceServerBackend."""
from django.core.exceptions import SuspiciousOperation
import pytest
import responses
from rest_framework.test import APIClient
from core.external_api.authentication import ResourceServerBackend
from core.factories import UserFactory
from core.models import User
pytestmark = pytest.mark.django_db
def _payload(sub):
return {"sub": sub, "active": True, "scope": "lasuite_meet", "client_id": "app"}
def test_resource_server_backend_get_or_create_user_active():
"""An existing active user matching the sub should be returned."""
user = UserFactory()
result = ResourceServerBackend().get_or_create_user(
access_token="token", id_token=None, payload=_payload(user.sub)
)
assert result == user
def test_resource_server_backend_get_or_create_user_inactive():
"""An inactive user should be rejected even with a valid token."""
user = UserFactory(is_active=False)
with pytest.raises(SuspiciousOperation, match="User account is disabled."):
ResourceServerBackend().get_or_create_user(
access_token="token", id_token=None, payload=_payload(user.sub)
)
def test_resource_server_backend_get_or_create_user_creates(settings):
"""An unknown sub should create an active user when OIDC_CREATE_USER is set."""
settings.OIDC_CREATE_USER = True
result = ResourceServerBackend().get_or_create_user(
access_token="token", id_token=None, payload=_payload("new-sub")
)
assert result.sub == "new-sub"
assert result.is_active is True
assert User.objects.filter(sub="new-sub").exists()
def test_resource_server_backend_get_or_create_user_no_creation(settings):
"""An unknown sub should return None when OIDC_CREATE_USER is unset."""
settings.OIDC_CREATE_USER = False
result = ResourceServerBackend().get_or_create_user(
access_token="token", id_token=None, payload=_payload("new-sub")
)
assert result is None
assert not User.objects.filter(sub="new-sub").exists()
@responses.activate
def test_api_rooms_list_resource_server_inactive_user(settings):
"""End to end: a valid introspected token for an inactive user should get 401."""
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
settings.OIDC_OP_URL = "https://oidc.example.com"
user = UserFactory(is_active=False)
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"active": True,
"sub": user.sub,
"scope": "openid lasuite_meet rooms:list",
"client_id": "app",
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer rs-token")
response = client.get("/external-api/v1.0/rooms/")
assert response.status_code == 401
assert "login failed" in str(response.data).lower()
@@ -16,17 +16,8 @@ import responses
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
from rest_framework.test import APIClient from rest_framework.test import APIClient
from core.analytics import AnalyticsEvent
from core.factories import ApplicationFactory, RoomFactory, UserFactory from core.factories import ApplicationFactory, RoomFactory, UserFactory
from core.models import ( from core.models import ApplicationScope, RoleChoices, Room, RoomAccessLevel, User
Application,
ApplicationScope,
RoleChoices,
Room,
RoomAccessLevel,
User,
)
from core.services.room_management import RoomManagement
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -889,509 +880,6 @@ def test_api_rooms_create_public_access_level_when_default_is_public(settings):
assert response.data["access_level"] == RoomAccessLevel.PUBLIC assert response.data["access_level"] == RoomAccessLevel.PUBLIC
@mock.patch("core.external_api.viewsets.analytics.capture")
def test_api_rooms_create_tracks_analytics(mock_capture):
"""Creating a room should emit a ROOM_CREATED analytics event."""
user = UserFactory()
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
application = Application.objects.get()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.post(
"/external-api/v1.0/rooms/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 201
mock_capture.assert_called_once()
captured_user, event, properties = mock_capture.call_args[0]
assert captured_user == user
assert event == AnalyticsEvent.ROOM_CREATED
assert properties == {
"room_id": response.data["id"],
"access_level": RoomAccessLevel.RESTRICTED,
"client_id": str(application.client_id),
"external_api": True,
"auth_method": "ApplicationJWTAuthentication",
"$set": {"email": user.email},
}
def test_api_rooms_update_requires_authentication():
"""Updating a room without authentication should return 401."""
room = RoomFactory(users=[(UserFactory(), RoleChoices.OWNER)])
client = APIClient()
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 401
def test_api_rooms_update_requires_scope():
"""Updating a room requires the ROOMS_UPDATE scope."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
# Token without ROOMS_UPDATE scope
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 403
assert (
"insufficient permissions. required scope: rooms:update"
in str(response.data).lower()
)
def test_api_rooms_update_no_scope():
"""Updating a room without any scope should return 403."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
token = generate_test_token(user, [])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 403
assert "insufficient permissions." in str(response.data).lower()
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_owner_success(mock_update_metadata, settings):
"""An owner should be able to update the access level and the configuration."""
settings.APPLICATION_BASE_URL = "http://your-application.com"
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
configuration={},
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{
"access_level": RoomAccessLevel.RESTRICTED,
"configuration": {"everyone_can_mute": True},
},
format="json",
)
assert response.status_code == 200
assert response.data["id"] == str(room.id)
assert response.data["access_level"] == RoomAccessLevel.RESTRICTED
assert response.data["configuration"] == {"everyone_can_mute": True}
assert response.data["url"] == f"http://your-application.com/{room.slug}"
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.RESTRICTED
assert room.configuration == {"everyone_can_mute": True}
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"configuration": {"everyone_can_mute": True},
"access_level": RoomAccessLevel.RESTRICTED,
},
)
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_replaces_configuration(mock_update_metadata):
"""The configuration is replaced as a whole, it is not merged with the stored one."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
configuration={"can_publish_sources": ["camera"], "everyone_can_mute": True},
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"configuration": {"everyone_can_mute": False}},
format="json",
)
assert response.status_code == 200
# The keys missing from the payload are dropped, not kept.
assert response.data["configuration"] == {"everyone_can_mute": False}
room.refresh_from_db()
assert room.configuration == {"everyone_can_mute": False}
mock_update_metadata.assert_called_once_with(
room_name=str(room.id),
metadata={
"configuration": {"everyone_can_mute": False},
"access_level": room.access_level,
},
)
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_administrator_success(mock_update_metadata):
"""An administrator should be able to update a room."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.ADMIN)],
access_level=RoomAccessLevel.TRUSTED,
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.RESTRICTED
mock_update_metadata.assert_called_once()
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_put_not_allowed(mock_update_metadata):
"""PUT is not exposed: full replacement is not supported, only PATCH is."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
configuration={},
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.put(
f"/external-api/v1.0/rooms/{room.id}/",
{
"access_level": RoomAccessLevel.RESTRICTED,
"configuration": {"everyone_can_mute": True},
},
format="json",
)
assert response.status_code == 405
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.TRUSTED
assert room.configuration == {}
mock_update_metadata.assert_not_called()
@pytest.mark.parametrize("role", [RoleChoices.MEMBER, None])
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_without_privileges(mock_update_metadata, role):
"""Members and users without any role should not be able to update a room."""
user = UserFactory()
users = [(user, role)] if role else []
room = RoomFactory(users=users, access_level=RoomAccessLevel.TRUSTED)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 403
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.TRUSTED
mock_update_metadata.assert_not_called()
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_readonly_enforcement(mock_update_metadata):
"""Read-only fields provided on update should be ignored, the slug stays immutable."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
)
expected_id, expected_name = str(room.id), room.name
expected_slug, expected_pin_code = room.slug, room.pin_code
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{
"id": str(uuid.uuid4()),
"name": "fake-name",
"slug": "fake-slug",
"pin_code": "000000",
"access_level": RoomAccessLevel.RESTRICTED,
},
format="json",
)
assert response.status_code == 200
assert response.data["id"] == expected_id
assert response.data["name"] == expected_name
assert response.data["slug"] == expected_slug
room.refresh_from_db()
assert str(room.id) == expected_id
assert room.name == expected_name
assert room.slug == expected_slug
assert room.pin_code == expected_pin_code
# The one writable field in the payload was applied
assert room.access_level == RoomAccessLevel.RESTRICTED
mock_update_metadata.assert_called_once()
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_rejects_invalid_configuration(mock_update_metadata):
"""Updating a room with unsupported configuration keys should fail."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)], configuration={})
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"configuration": {"unsupported_flag": True}},
format="json",
)
assert response.status_code == 400
assert "extra inputs are not permitted" in str(response.data).lower()
room.refresh_from_db()
assert room.configuration == {}
mock_update_metadata.assert_not_called()
@pytest.mark.parametrize(
"invalid_configuration",
[
{"can_publish_sources": ["invalid-source"]},
{"everyone_can_mute": "invalid-value"},
],
)
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_rejects_invalid_configuration_values(
mock_update_metadata, invalid_configuration
):
"""Updating a room with invalid configuration values should fail."""
user = UserFactory()
room = RoomFactory(users=[(user, RoleChoices.OWNER)], configuration={})
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"configuration": invalid_configuration},
format="json",
)
assert response.status_code == 400
room.refresh_from_db()
assert room.configuration == {}
mock_update_metadata.assert_not_called()
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_public_access_disabled_by_default(mock_update_metadata):
"""Switching a room to public should be disabled for the external API by default."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.PUBLIC},
format="json",
)
assert response.status_code == 400
assert "public rooms are disabled" in str(response.data).lower()
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.TRUSTED
mock_update_metadata.assert_not_called()
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_public_access_enabled_with_settings(
mock_update_metadata, settings
):
"""Switching a room to public should be allowed when explicitly enabled."""
settings.EXTERNAL_API_ALLOW_PUBLIC_ACCESS = True
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.PUBLIC},
format="json",
)
assert response.status_code == 200
assert response.data["access_level"] == RoomAccessLevel.PUBLIC
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.PUBLIC
mock_update_metadata.assert_called_once()
@mock.patch("core.external_api.viewsets.analytics.capture")
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_unchanged_skips_livekit_sync(
mock_update_metadata, mock_capture
):
"""An update that changes nothing should not sync metadata nor report changes."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
configuration={"everyone_can_mute": True},
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{
"access_level": RoomAccessLevel.TRUSTED,
"configuration": {"everyone_can_mute": True},
},
format="json",
)
assert response.status_code == 200
mock_update_metadata.assert_not_called()
# The event is still emitted for auditing, but reports an empty delta.
_, _, properties = mock_capture.call_args[0]
assert properties["updated_fields"] == []
@mock.patch("core.external_api.viewsets.analytics.capture")
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_tracks_analytics(mock_update_metadata, mock_capture):
"""Updating a room should emit a ROOM_UPDATED analytics event."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
configuration={},
)
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
application = Application.objects.get()
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{
"access_level": RoomAccessLevel.RESTRICTED,
"configuration": {"everyone_can_mute": True},
},
format="json",
)
assert response.status_code == 200
mock_capture.assert_called_once()
captured_user, event, properties = mock_capture.call_args[0]
assert captured_user == user
assert event == AnalyticsEvent.ROOM_UPDATED
assert properties == {
"room_id": str(room.pk),
"access_level": RoomAccessLevel.RESTRICTED,
"updated_fields": ["access_level", "configuration"],
"previous_access_level": RoomAccessLevel.TRUSTED,
"client_id": str(application.client_id),
"external_api": True,
"auth_method": "ApplicationJWTAuthentication",
"$set": {"email": user.email},
}
mock_update_metadata.assert_called_once()
def test_api_rooms_response_no_url(settings): def test_api_rooms_response_no_url(settings):
"""Response should not include url field when APPLICATION_BASE_URL is None.""" """Response should not include url field when APPLICATION_BASE_URL is None."""
settings.APPLICATION_BASE_URL = None settings.APPLICATION_BASE_URL = None
@@ -2009,106 +1497,6 @@ def test_resource_server_denies_access_with_insufficient_scopes(settings):
assert response.status_code == 403 assert response.status_code == 403
@responses.activate
@mock.patch.object(RoomManagement, "update_metadata")
def test_resource_server_updates_room_with_prefixed_scope(
mock_update_metadata, settings
):
"""A resource server token carrying the prefixed update scope should be accepted."""
user = UserFactory(sub="very-specific-sub")
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
)
settings.OIDC_RS_CLIENT_ID = "some_client_id"
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
settings.OIDC_RS_SCOPES_PREFIX = "lasuite_meet"
settings.OIDC_OP_URL = "https://oidc.example.com"
settings.OIDC_VERIFY_SSL = False
settings.OIDC_TIMEOUT = 5
settings.OIDC_PROXY = None
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
"sub": "very-specific-sub",
"client_id": "some_service_provider",
"scope": "openid lasuite_meet lasuite_meet:rooms:update",
"active": True,
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.RESTRICTED
mock_update_metadata.assert_called_once()
@responses.activate
def test_resource_server_denies_room_update_without_update_scope(settings):
"""A resource server token without the update scope should be denied."""
user = UserFactory(sub="very-specific-sub")
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
)
settings.OIDC_RS_CLIENT_ID = "some_client_id"
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
settings.OIDC_RS_SCOPES_PREFIX = "lasuite_meet"
settings.OIDC_OP_URL = "https://oidc.example.com"
settings.OIDC_VERIFY_SSL = False
settings.OIDC_TIMEOUT = 5
settings.OIDC_PROXY = None
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
responses.add(
responses.POST,
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
"sub": "very-specific-sub",
"client_id": "some_service_provider",
"scope": "openid lasuite_meet lasuite_meet:rooms:retrieve",
"active": True,
},
)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 403
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.TRUSTED
# ============================== # ==============================
# Addons # Addons
# ============================== # ==============================
@@ -2160,32 +1548,6 @@ def test_api_rooms_create_with_valid_addons_token():
assert room.get_role(user) == RoleChoices.OWNER assert room.get_role(user) == RoleChoices.OWNER
@mock.patch.object(RoomManagement, "update_metadata")
def test_api_rooms_update_with_valid_addons_token(mock_update_metadata):
"""Updating a room with a valid addons token should succeed."""
user = UserFactory()
room = RoomFactory(
users=[(user, RoleChoices.OWNER)],
access_level=RoomAccessLevel.TRUSTED,
)
token = generate_addons_test_token(user, [ApplicationScope.ROOMS_UPDATE])
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
response = client.patch(
f"/external-api/v1.0/rooms/{room.id}/",
{"access_level": RoomAccessLevel.RESTRICTED},
format="json",
)
assert response.status_code == 200
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.RESTRICTED
mock_update_metadata.assert_called_once()
def test_api_rooms_addons_token_inactive_user(): def test_api_rooms_addons_token_inactive_user():
"""Addons token for an inactive user should return 401.""" """Addons token for an inactive user should return 401."""
user = UserFactory(is_active=False) user = UserFactory(is_active=False)
@@ -157,7 +157,6 @@ def test_models_recording_is_savable_normal():
@pytest.mark.parametrize( @pytest.mark.parametrize(
"status", "status",
[ [
RecordingStatusChoices.FAILED,
RecordingStatusChoices.FAILED_TO_STOP, RecordingStatusChoices.FAILED_TO_STOP,
RecordingStatusChoices.FAILED_TO_START, RecordingStatusChoices.FAILED_TO_START,
RecordingStatusChoices.ABORTED, RecordingStatusChoices.ABORTED,
@@ -280,7 +279,6 @@ def test_models_recording_is_saved_false_initiated():
@pytest.mark.parametrize( @pytest.mark.parametrize(
"status", "status",
[ [
RecordingStatusChoices.FAILED,
RecordingStatusChoices.FAILED_TO_STOP, RecordingStatusChoices.FAILED_TO_STOP,
RecordingStatusChoices.FAILED_TO_START, RecordingStatusChoices.FAILED_TO_START,
RecordingStatusChoices.ABORTED, RecordingStatusChoices.ABORTED,
@@ -360,35 +360,3 @@ def test_pin_generation_upper_bound(mock_randbelow, settings):
# Assert called with the right exclusive upper bound, 10^5 # Assert called with the right exclusive upper bound, 10^5
mock_randbelow.assert_called_with(100000) mock_randbelow.assert_called_with(100000)
@pytest.mark.parametrize("access_level", [None, *RoomAccessLevel.values])
@pytest.mark.parametrize("use_manager", [False, True])
def test_models_encrypted_room_creation_is_restricted(access_level, use_manager):
"""Both save and manager creation normalize encrypted rooms before validation."""
fields = {"name": "Encrypted room", "encryption_mode": "basic"}
if access_level is not None:
fields["access_level"] = access_level
if use_manager:
room = Room.objects.create(**fields)
else:
room = Room(**fields)
# A caller may assign the primary key before the first save.
room.pk = room.id
room.save()
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.RESTRICTED
@pytest.mark.parametrize(
"access_level", [RoomAccessLevel.PUBLIC, RoomAccessLevel.TRUSTED]
)
def test_models_encrypted_room_access_update_rejected(access_level):
"""Existing encrypted rooms reject incompatible access instead of normalizing it."""
room = Room.objects.create(name="Encrypted room", encryption_mode="basic")
room.access_level = access_level
with pytest.raises(ValidationError) as excinfo:
room.save()
assert "access_level" in excinfo.value.message_dict
room.refresh_from_db()
assert room.access_level == RoomAccessLevel.RESTRICTED
@@ -26,64 +26,6 @@ def test_models_users_id_unique():
factories.UserFactory(id=user.id) factories.UserFactory(id=user.id)
@pytest.mark.parametrize(
"sub,is_valid",
[
# cases from suitenumerique/docs PR #1295 (same validator)
("valid_sub.@+-:=/", True),
("invalid süb", False),
(12345, True),
# Auth0 emits "provider|user-id" subject identifiers
("auth0|644c0bc8f1874ef6d339fb34", True),
("google-oauth2|103547991597142817347", True),
# Keycloak-style UUID
("f:550e8400-e29b-41d4-a716-446655440000:jdoe", True),
# base64/URN-style identifiers
("dGVzdC1zdWItdmFsdWU=", True),
("urn:example:user/42", True),
# legacy format still accepted
("user@example.com", True),
# space (U+0020) is printable ASCII and remains allowed
("sub with space", True),
# non-ASCII values are rejected
("émilie", False),
# ASCII control characters (U+0000-U+001F, U+007F) are rejected:
# NUL passes isascii() but cannot be stored in PostgreSQL text
# fields, and the others invite log injection and interop issues
("nul\x00sub", False),
("\x00", False),
("tab\tsub", False),
("newline\nsub", False),
("del\x7fsub", False),
],
)
def test_models_users_sub_validator(sub, is_valid):
"""
The "sub" field should accept any ASCII string as required by
OpenID Connect Core 1.0 §2 and RFC 7519 §4.1.2, and reject non-ASCII values.
"""
user = factories.UserFactory()
user.sub = sub
if is_valid:
user.full_clean()
else:
with pytest.raises(
ValidationError,
match="Enter a valid sub. This value should be printable ASCII only.",
):
user.full_clean()
def test_models_users_sub_max_length():
"""The "sub" field should enforce the 255 ASCII characters limit of OIDC Core 1.0 §2."""
user = factories.UserFactory(sub="a" * 255)
assert user.sub == "a" * 255
user.sub = "a" * 256
with pytest.raises(ValidationError, match="at most 255 characters"):
user.full_clean()
def test_models_users_send_mail_main_existing(): def test_models_users_send_mail_main_existing():
"""The "email_user' method should send mail to the user's email address.""" """The "email_user' method should send mail to the user's email address."""
user = factories.UserFactory() user = factories.UserFactory()
@@ -1,47 +0,0 @@
"""Unit tests for the get_release settings helper."""
import re
import pytest
from meet.settings import get_release
@pytest.fixture(name="base_dir")
def fixture_empty_base_dir(tmp_path, monkeypatch):
"""Point get_release at an empty directory."""
monkeypatch.setattr("meet.settings.BASE_DIR", str(tmp_path))
return tmp_path
def test_get_release_reads_project_pyproject():
"""Should return the semantic version of the backend's pyproject.toml."""
assert re.fullmatch(r"\d+\.\d+\.\d+", get_release())
def test_get_release_reads_pyproject_version(base_dir):
"""Should return the version declared in the [project] table."""
(base_dir / "pyproject.toml").write_text(
'[project]\nname = "meet"\nversion = "1.2.3"\n', encoding="utf-8"
)
assert get_release() == "1.2.3"
@pytest.mark.usefixtures("base_dir")
def test_get_release_missing_pyproject():
"""Should fall back to "NA" without a pyproject.toml."""
assert get_release() == "NA"
@pytest.mark.parametrize(
"content",
[
'[project]\nname = "meet"\n', # no version
"[tool.uv]\npackage = true\n", # no [project] table
"[project\nversion = ", # malformed TOML
],
)
def test_get_release_unreadable_version(base_dir, content):
"""Should fall back to "NA" without a readable version in pyproject.toml."""
(base_dir / "pyproject.toml").write_text(content, encoding="utf-8")
assert get_release() == "NA"
@@ -1,22 +0,0 @@
"""Unit tests for the LIVEKIT_DEFAULT_VIDEO_CODEC setting value."""
import pytest
from meet.settings import VideoCodecValue
@pytest.mark.parametrize(
"raw,expected",
[("vp9", "vp9"), ("AV1", "av1"), (" h264 ", "h264")],
)
def test_video_codec_value_normalizes(raw, expected):
"""Whitespace is trimmed and the name is lowercased before it is checked."""
# environ=False keeps __new__ from resolving the value, so the instance survives.
assert VideoCodecValue(environ=False).to_python(raw) == expected
@pytest.mark.parametrize("raw", ["vp10", "", "h.264"])
def test_video_codec_value_rejects_unsupported(raw):
"""A name outside the accepted list stops the settings module loading."""
with pytest.raises(ValueError, match="Unsupported video codec"):
VideoCodecValue(environ=False).to_python(raw)
+4 -18
View File
@@ -57,35 +57,21 @@ def test_generate_token_authenticated_fallback_user_representation():
assert claims["name"] == str(user) assert claims["name"] == str(user)
@pytest.mark.parametrize("encryption_mode", ["none", "basic"]) def test_generate_token_explicit_username_overrides_default():
def test_generate_token_explicit_username_overrides_default(encryption_mode):
"""An explicitly provided username should take precedence over the full name.""" """An explicitly provided username should take precedence over the full name."""
user = UserFactory(full_name="Jane Doe") user = UserFactory(full_name="Jane Doe")
token = generate_token( token = generate_token(room="my-room", user=user, username="Custom Name")
room="my-room",
user=user,
username="Custom Name",
encryption_mode=encryption_mode,
)
claims = decode_token(token) claims = decode_token(token)
assert claims["name"] == "Custom Name" assert claims["name"] == "Custom Name"
@pytest.mark.parametrize("encryption_mode", ["none", "basic"]) def test_authenticated_username_ignored_when_editing_disabled(settings):
def test_authenticated_username_ignored_when_editing_disabled(
settings, encryption_mode
):
"""With editing disabled, an authenticated user's username is ignored.""" """With editing disabled, an authenticated user's username is ignored."""
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
user = UserFactory(full_name="Jane Doe") user = UserFactory(full_name="Jane Doe")
token = generate_token( token = generate_token(room="my-room", user=user, username="Custom Name")
room="my-room",
user=user,
username="Custom Name",
encryption_mode=encryption_mode,
)
claims = decode_token(token) claims = decode_token(token)
assert claims["name"] == "Jane Doe" assert claims["name"] == "Jane Doe"
+6 -15
View File
@@ -34,9 +34,6 @@ from livekit.api import ( # pylint: disable=E0611
TwirpError, TwirpError,
VideoGrants, VideoGrants,
) )
from livekit.protocol.room import RoomConfiguration # pylint: disable=E0611
from core.enums import EncryptionMode
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -72,7 +69,6 @@ def generate_token( # noqa: PLR0917
role: Optional[str] = None, role: Optional[str] = None,
participant_id: Optional[str] = None, participant_id: Optional[str] = None,
ttl: Optional[timedelta] = None, ttl: Optional[timedelta] = None,
encryption_mode: str = "none",
) -> str: ) -> str:
"""Generate a LiveKit access token for a user in a specific room. """Generate a LiveKit access token for a user in a specific room.
@@ -95,8 +91,10 @@ def generate_token( # noqa: PLR0917
""" """
is_admin_or_owner = role in ("owner", "administrator") is_admin_or_owner = role in ("owner", "administrator")
if is_admin_or_owner:
sources = settings.LIVEKIT_DEFAULT_SOURCES
if is_admin_or_owner or sources is None: if sources is None:
sources = settings.LIVEKIT_DEFAULT_SOURCES sources = settings.LIVEKIT_DEFAULT_SOURCES
video_grants = VideoGrants( video_grants = VideoGrants(
@@ -143,14 +141,6 @@ def generate_token( # noqa: PLR0917
if ttl is not None: if ttl is not None:
token = token.with_ttl(ttl) token = token.with_ttl(ttl)
if encryption_mode != EncryptionMode.NONE:
token = token.with_room_config(
RoomConfiguration(
name=room,
metadata=json.dumps({"encryption_mode": encryption_mode}),
)
)
return token.to_jwt() return token.to_jwt()
@@ -162,7 +152,6 @@ def generate_livekit_config( # noqa: PLR0917
color: Optional[str] = None, color: Optional[str] = None,
configuration: Optional[dict] = None, configuration: Optional[dict] = None,
participant_id: Optional[str] = None, participant_id: Optional[str] = None,
encryption_mode: str = "none",
) -> dict: ) -> dict:
"""Generate LiveKit configuration for room access. """Generate LiveKit configuration for room access.
@@ -195,7 +184,6 @@ def generate_livekit_config( # noqa: PLR0917
sources=sources, sources=sources,
role=role, role=role,
participant_id=participant_id, participant_id=participant_id,
encryption_mode=encryption_mode,
), ),
} }
@@ -511,3 +499,6 @@ def build_telephony_config():
"default_country": country, "default_country": country,
"international_phone_number": international, "international_phone_number": international,
} }
CACHE_SCAN_ITERSIZE = 500
-23
View File
@@ -1,23 +0,0 @@
"""Custom validators for the core app."""
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
def sub_validator(value):
"""Validate that the sub is printable ASCII only.
OpenID Connect Core 1.0 (section 2) allows any ASCII (RFC 20) string of
at most 255 characters, so no character whitelist is applied: providers
legitimately emit "|" (Auth0), ":" (Keycloak), "=", "/", etc. As a
deliberate hardening beyond the spec, ASCII control characters
(U+0000-U+001F and U+007F) are rejected: no known provider emits them,
NUL cannot be stored in PostgreSQL text fields, and the others invite
log-injection and interoperability issues. For str values,
``isprintable()`` is false exactly for those control characters, while
space (U+0020) remains allowed.
"""
if not value.isascii() or not value.isprintable():
raise ValidationError(
_("Enter a valid sub. This value should be printable ASCII only.")
)
+159 -186
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-02 22:52+0000\n" "POT-Creation-Date: 2026-07-02 10:47+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -51,11 +51,11 @@ msgstr ""
msgid "File preview" msgid "File preview"
msgstr "" msgstr ""
#: core/admin.py:300 core/admin.py:450 #: core/admin.py:300 core/admin.py:443
msgid "No owner" msgid "No owner"
msgstr "Kein Eigentümer" msgstr "Kein Eigentümer"
#: core/admin.py:303 core/admin.py:453 #: core/admin.py:303 core/admin.py:446
msgid "Multiple owners" msgid "Multiple owners"
msgstr "Mehrere Eigentümer" msgstr "Mehrere Eigentümer"
@@ -98,11 +98,11 @@ msgstr "%(count)s Aufnahme(n) erfolgreich als ‚Fehler beim Stoppen‘ markiert
msgid "Skipped %(count)s recording(s) with an ineligible status." msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen." msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
#: core/admin.py:517 #: core/admin.py:510
msgid "No scopes" msgid "No scopes"
msgstr "Keine Scopes" msgstr "Keine Scopes"
#: core/admin.py:519 #: core/admin.py:512
msgid "Scopes" msgid "Scopes"
msgstr "Scopes" msgstr "Scopes"
@@ -110,201 +110,185 @@ msgstr "Scopes"
msgid "Creator is me" msgid "Creator is me"
msgstr "Ersteller bin ich" msgstr "Ersteller bin ich"
#: core/api/serializers.py:108 #: core/api/serializers.py:89
msgid "You must be administrator or owner of a room to add accesses to it." msgid "You must be administrator or owner of a room to add accesses to it."
msgstr "" msgstr ""
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe " "Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
"hinzuzufügen." "hinzuzufügen."
#: core/api/serializers.py:560 #: core/api/serializers.py:534
msgid "This file extension is not allowed." msgid "This file extension is not allowed."
msgstr "Diese Dateiendung ist nicht erlaubt." msgstr "Diese Dateiendung ist nicht erlaubt."
#: core/api/viewsets.py:1268 #: core/api/viewsets.py:1222
msgid "You have reached the maximum number of files for this type." msgid "You have reached the maximum number of files for this type."
msgstr "Sie haben die maximale Anzahl an Dateien dieses Typs erreicht." msgstr "Sie haben die maximale Anzahl an Dateien dieses Typs erreicht."
#: core/models.py:38 #: core/models.py:37
msgid "Member" msgid "Member"
msgstr "Mitglied" msgstr "Mitglied"
#: core/models.py:39 #: core/models.py:38
msgid "Administrator" msgid "Administrator"
msgstr "Administrator" msgstr "Administrator"
#: core/models.py:40 #: core/models.py:39
msgid "Owner" msgid "Owner"
msgstr "Eigentümer" msgstr "Eigentümer"
#: core/models.py:56 #: core/models.py:55
msgid "Initiated" msgid "Initiated"
msgstr "Gestartet" msgstr "Gestartet"
#: core/models.py:57 #: core/models.py:56
msgid "Active" msgid "Active"
msgstr "Aktiv" msgstr "Aktiv"
#: core/models.py:58 #: core/models.py:57
msgid "Stopped" msgid "Stopped"
msgstr "Beendet" msgstr "Beendet"
#: core/models.py:59 #: core/models.py:58
msgid "Saved" msgid "Saved"
msgstr "Gespeichert" msgstr "Gespeichert"
#: core/models.py:60 #: core/models.py:59
msgid "Aborted" msgid "Aborted"
msgstr "Abgebrochen" msgstr "Abgebrochen"
#: core/models.py:61 #: core/models.py:60
msgid "Failed to Start" msgid "Failed to Start"
msgstr "Start fehlgeschlagen" msgstr "Start fehlgeschlagen"
#: core/models.py:62 #: core/models.py:61
msgid "Failed to Stop" msgid "Failed to Stop"
msgstr "Stopp fehlgeschlagen" msgstr "Stopp fehlgeschlagen"
#: core/models.py:63 #: core/models.py:62
msgid "Notification succeeded" msgid "Notification succeeded"
msgstr "Benachrichtigung erfolgreich" msgstr "Benachrichtigung erfolgreich"
#: core/models.py:66 #: core/models.py:65
msgid "External process successful" msgid "External process successful"
msgstr "Externer Prozess erfolgreich" msgstr "Externer Prozess erfolgreich"
#: core/models.py:68 #: core/models.py:67
msgid "External process failed" msgid "External process failed"
msgstr "Externer Prozess fehlgeschlagen" msgstr "Externer Prozess fehlgeschlagen"
#: core/models.py:97 #: core/models.py:96
msgid "SCREEN_RECORDING" msgid "SCREEN_RECORDING"
msgstr "BILDSCHIRMAUFZEICHNUNG" msgstr "BILDSCHIRMAUFZEICHNUNG"
#: core/models.py:98 #: core/models.py:97
msgid "TRANSCRIPT" msgid "TRANSCRIPT"
msgstr "TRANSKRIPT" msgstr "TRANSKRIPT"
#: core/models.py:104 #: core/models.py:103
msgid "Public Access" msgid "Public Access"
msgstr "Öffentlicher Zugriff" msgstr "Öffentlicher Zugriff"
#: core/models.py:105 #: core/models.py:104
msgid "Trusted Access" msgid "Trusted Access"
msgstr "Vertrauenswürdiger Zugriff" msgstr "Vertrauenswürdiger Zugriff"
#: core/models.py:106 #: core/models.py:105
msgid "Restricted Access" msgid "Restricted Access"
msgstr "Eingeschränkter Zugriff" msgstr "Eingeschränkter Zugriff"
#: core/models.py:118 #: core/models.py:117
msgid "id" msgid "id"
msgstr "ID" msgstr "ID"
#: core/models.py:119 #: core/models.py:118
msgid "primary key for the record as UUID" msgid "primary key for the record as UUID"
msgstr "Primärschlüssel des Eintrags als UUID" msgstr "Primärschlüssel des Eintrags als UUID"
#: core/models.py:125 #: core/models.py:124
msgid "created on" msgid "created on"
msgstr "erstellt am" msgstr "erstellt am"
#: core/models.py:126 #: core/models.py:125
msgid "date and time at which a record was created" msgid "date and time at which a record was created"
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags" msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
#: core/models.py:131 #: core/models.py:130
msgid "updated on" msgid "updated on"
msgstr "aktualisiert am" msgstr "aktualisiert am"
#: core/models.py:132 #: core/models.py:131
msgid "date and time at which a record was last updated" msgid "date and time at which a record was last updated"
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags" msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
#: core/models.py:150 #: core/models.py:151
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
"Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen "
"und die Zeichen @/./+/-/_ enthalten."
#: core/models.py:157
msgid "sub" msgid "sub"
msgstr "Sub" msgstr "Sub"
#: core/models.py:152 #: core/models.py:159
msgid "" msgid ""
"Optional for pending users; required upon account activation. 255 characters " "Optional for pending users; required upon account activation. 255 characters "
"or fewer. Printable ASCII characters only." "or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr "" msgstr ""
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. " "Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
"Maximal 255 Zeichen. Nur druckbare ASCII-Zeichen erlaubt." "Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt."
#: core/validators.py:22 #: core/models.py:168
msgid "Enter a valid sub. This value should be printable ASCII only."
msgstr "Geben Sie einen gültigen sub ein. Dieser Wert darf nur druckbare ASCII-Zeichen enthalten."
#: core/models.py:161
msgid "identity email address" msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse" msgstr "Identitäts-E-Mail-Adresse"
#: core/models.py:166 #: core/models.py:173
msgid "admin email address" msgid "admin email address"
msgstr "Administrator-E-Mail-Adresse" msgstr "Administrator-E-Mail-Adresse"
#: core/models.py:168 #: core/models.py:175
msgid "full name" msgid "full name"
msgstr "Vollständiger Name" msgstr "Vollständiger Name"
#: core/models.py:170 #: core/models.py:177
msgid "short name" msgid "short name"
msgstr "Kurzname" msgstr "Kurzname"
#: core/models.py:176 #: core/models.py:183
msgid "language" msgid "language"
msgstr "Sprache" msgstr "Sprache"
#: core/models.py:177 #: core/models.py:184
msgid "The language in which the user wants to see the interface." msgid "The language in which the user wants to see the interface."
msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte." msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
#: core/models.py:183 #: core/models.py:190
msgid "The timezone in which the user wants to see times." msgid "The timezone in which the user wants to see times."
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte." msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
#: core/models.py:190 #: core/models.py:193
msgid "default room access level"
msgstr ""
#: core/models.py:192
msgid ""
"Access level applied by default to new rooms created by this user. When "
"empty, the instance default is used."
msgstr ""
#: core/models.py:199
#, fuzzy
#| msgid "Visio room configuration"
msgid "default room configuration"
msgstr "Visio-Raumkonfiguration"
#: core/models.py:201
msgid "Configurations applied by default to new rooms created by this user."
msgstr ""
#: core/models.py:205
msgid "device" msgid "device"
msgstr "Gerät" msgstr "Gerät"
#: core/models.py:207 #: core/models.py:195
msgid "Whether the user is a device or a real user." msgid "Whether the user is a device or a real user."
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt." msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
#: core/models.py:210 #: core/models.py:198
msgid "staff status" msgid "staff status"
msgstr "Mitarbeiterstatus" msgstr "Mitarbeiterstatus"
#: core/models.py:212 #: core/models.py:200
msgid "Whether the user can log into this admin site." msgid "Whether the user can log into this admin site."
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann." msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
#: core/models.py:215 #: core/models.py:203
msgid "active" msgid "active"
msgstr "aktiv" msgstr "aktiv"
#: core/models.py:218 #: core/models.py:206
msgid "" msgid ""
"Whether this user should be treated as active. Unselect this instead of " "Whether this user should be treated as active. Unselect this instead of "
"deleting accounts." "deleting accounts."
@@ -312,66 +296,66 @@ msgstr ""
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies " "Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
"anstelle des Löschens des Kontos." "anstelle des Löschens des Kontos."
#: core/models.py:231 #: core/models.py:219
msgid "user" msgid "user"
msgstr "Benutzer" msgstr "Benutzer"
#: core/models.py:232 #: core/models.py:220
msgid "users" msgid "users"
msgstr "Benutzer" msgstr "Benutzer"
#: core/models.py:298 #: core/models.py:286
msgid "Resource" msgid "Resource"
msgstr "Ressource" msgstr "Ressource"
#: core/models.py:299 #: core/models.py:287
msgid "Resources" msgid "Resources"
msgstr "Ressourcen" msgstr "Ressourcen"
#: core/models.py:357 #: core/models.py:345
msgid "Resource access" msgid "Resource access"
msgstr "Ressourcenzugriff" msgstr "Ressourcenzugriff"
#: core/models.py:358 #: core/models.py:346
msgid "Resource accesses" msgid "Resource accesses"
msgstr "Ressourcenzugriffe" msgstr "Ressourcenzugriffe"
#: core/models.py:364 #: core/models.py:352
msgid "Resource access with this User and Resource already exists." msgid "Resource access with this User and Resource already exists."
msgstr "" msgstr ""
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert " "Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
"bereits." "bereits."
#: core/models.py:421 #: core/models.py:409
msgid "Visio room configuration" msgid "Visio room configuration"
msgstr "Visio-Raumkonfiguration" msgstr "Visio-Raumkonfiguration"
#: core/models.py:422 #: core/models.py:410
msgid "Values for Visio parameters to configure the room." msgid "Values for Visio parameters to configure the room."
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums." msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
#: core/models.py:429 #: core/models.py:417
msgid "Room PIN code" msgid "Room PIN code"
msgstr "PIN-Code für den Raum" msgstr "PIN-Code für den Raum"
#: core/models.py:430 #: core/models.py:418
msgid "Unique n-digit code that identifies this room in telephony mode." msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "" msgstr ""
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert." "Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
#: core/models.py:436 core/models.py:597 #: core/models.py:424 core/models.py:578
msgid "Room" msgid "Room"
msgstr "Raum" msgstr "Raum"
#: core/models.py:437 #: core/models.py:425
msgid "Rooms" msgid "Rooms"
msgstr "Räume" msgstr "Räume"
#: core/models.py:608 #: core/models.py:589
msgid "Worker ID" msgid "Worker ID"
msgstr "Worker-ID" msgstr "Worker-ID"
#: core/models.py:610 #: core/models.py:591
msgid "" msgid ""
"Enter an identifier for the worker recording.This ID is retained even when " "Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking." "the worker stops, allowing for easy tracking."
@@ -380,153 +364,153 @@ msgstr ""
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen " "erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
"ermöglicht." "ermöglicht."
#: core/models.py:618 #: core/models.py:599
msgid "Recording mode" msgid "Recording mode"
msgstr "Aufzeichnungsmodus" msgstr "Aufzeichnungsmodus"
#: core/models.py:619 #: core/models.py:600
msgid "Defines the mode of recording being called." msgid "Defines the mode of recording being called."
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus." msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
#: core/models.py:624 core/models.py:625 #: core/models.py:605 core/models.py:606
msgid "Recording options" msgid "Recording options"
msgstr "Aufnahmeoptionen" msgstr "Aufnahmeoptionen"
#: core/models.py:632 #: core/models.py:613
msgid "External Process ID" msgid "External Process ID"
msgstr "External Process ID" msgstr "External Process ID"
#: core/models.py:633 #: core/models.py:614
msgid "ID of the external process associated with the recording." msgid "ID of the external process associated with the recording."
msgstr "ID des externen Prozesses, der mit der Aufzeichnung verknüpft ist" msgstr "ID des externen Prozesses, der mit der Aufzeichnung verknüpft ist"
#: core/models.py:639 #: core/models.py:620
msgid "Recording" msgid "Recording"
msgstr "Aufzeichnung" msgstr "Aufzeichnung"
#: core/models.py:640 #: core/models.py:621
msgid "Recordings" msgid "Recordings"
msgstr "Aufzeichnungen" msgstr "Aufzeichnungen"
#: core/models.py:750 #: core/models.py:731
msgid "Recording/user relation" msgid "Recording/user relation"
msgstr "Beziehung Aufzeichnung/Benutzer" msgstr "Beziehung Aufzeichnung/Benutzer"
#: core/models.py:751 #: core/models.py:732
msgid "Recording/user relations" msgid "Recording/user relations"
msgstr "Beziehungen Aufzeichnung/Benutzer" msgstr "Beziehungen Aufzeichnung/Benutzer"
#: core/models.py:757 #: core/models.py:738
msgid "This user is already in this recording." msgid "This user is already in this recording."
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung." msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
#: core/models.py:763 #: core/models.py:744
msgid "This team is already in this recording." msgid "This team is already in this recording."
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung." msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
#: core/models.py:769 #: core/models.py:750
msgid "Either user or team must be set, not both." msgid "Either user or team must be set, not both."
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides." msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
#: core/models.py:786 #: core/models.py:767
msgid "Create rooms" msgid "Create rooms"
msgstr "Räume erstellen" msgstr "Räume erstellen"
#: core/models.py:787 #: core/models.py:768
msgid "List rooms" msgid "List rooms"
msgstr "Räume auflisten" msgstr "Räume auflisten"
#: core/models.py:788 #: core/models.py:769
msgid "Retrieve room details" msgid "Retrieve room details"
msgstr "Raumdetails abrufen" msgstr "Raumdetails abrufen"
#: core/models.py:789 #: core/models.py:770
msgid "Update rooms" msgid "Update rooms"
msgstr "Räume aktualisieren" msgstr "Räume aktualisieren"
#: core/models.py:790 #: core/models.py:771
msgid "Delete rooms" msgid "Delete rooms"
msgstr "Räume löschen" msgstr "Räume löschen"
#: core/models.py:803 #: core/models.py:784
msgid "Application name" msgid "Application name"
msgstr "Anwendungsname" msgstr "Anwendungsname"
#: core/models.py:804 #: core/models.py:785
msgid "Descriptive name for this application." msgid "Descriptive name for this application."
msgstr "Beschreibender Name für diese Anwendung." msgstr "Beschreibender Name für diese Anwendung."
#: core/models.py:814 #: core/models.py:795
msgid "Hashed on Save. Copy it now if this is a new secret." msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "" msgstr ""
"Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist." "Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
#: core/models.py:825 #: core/models.py:806
msgid "Application" msgid "Application"
msgstr "Anwendung" msgstr "Anwendung"
#: core/models.py:826 #: core/models.py:807
msgid "Applications" msgid "Applications"
msgstr "Anwendungen" msgstr "Anwendungen"
#: core/models.py:849 #: core/models.py:830
msgid "Enter a valid domain" msgid "Enter a valid domain"
msgstr "Geben Sie eine gültige Domain ein" msgstr "Geben Sie eine gültige Domain ein"
#: core/models.py:852 #: core/models.py:833
msgid "Domain" msgid "Domain"
msgstr "Domain" msgstr "Domain"
#: core/models.py:853 #: core/models.py:834
msgid "Email domain this application can act on behalf of." msgid "Email domain this application can act on behalf of."
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann." msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
#: core/models.py:865 #: core/models.py:846
msgid "Application domain" msgid "Application domain"
msgstr "Anwendungsdomain" msgstr "Anwendungsdomain"
#: core/models.py:866 #: core/models.py:847
msgid "Application domains" msgid "Application domains"
msgstr "Anwendungsdomains" msgstr "Anwendungsdomains"
#: core/models.py:884 #: core/models.py:865
msgid "Pending" msgid "Pending"
msgstr "Ausstehend" msgstr "Ausstehend"
#: core/models.py:885 #: core/models.py:866
msgid "Analyzing" msgid "Analyzing"
msgstr "" msgstr ""
#: core/models.py:892 #: core/models.py:873
msgid "Ready" msgid "Ready"
msgstr "Bereit" msgstr "Bereit"
#: core/models.py:898 #: core/models.py:879
msgid "Background image" msgid "Background image"
msgstr "Hintergrundbild" msgstr "Hintergrundbild"
#: core/models.py:910 #: core/models.py:891
msgid "title" msgid "title"
msgstr "Titel" msgstr "Titel"
#: core/models.py:934 #: core/models.py:915
msgid "Malware detection info when the analysis status is unsafe." msgid "Malware detection info when the analysis status is unsafe."
msgstr "" msgstr ""
"Informationen zur Malware-Erkennung, wenn der Analyse-Status unsicher ist." "Informationen zur Malware-Erkennung, wenn der Analyse-Status unsicher ist."
#: core/models.py:939 #: core/models.py:920
msgid "File" msgid "File"
msgstr "Datei" msgstr "Datei"
#: core/models.py:940 #: core/models.py:921
msgid "Files" msgid "Files"
msgstr "Dateien" msgstr "Dateien"
#: core/models.py:1060 #: core/models.py:1041
msgid "This file is already hard deleted." msgid "This file is already hard deleted."
msgstr "Diese Datei wurde bereits endgültig gelöscht." msgstr "Diese Datei wurde bereits endgültig gelöscht."
#: core/models.py:1070 #: core/models.py:1051
#, fuzzy #, fuzzy
#| msgid "To hard delete a file, it must first be soft deleted." #| msgid "To hard delete a file, it must first be soft deleted."
msgid "To hard delete a file, it must first be soft deleted." msgid "To hard delete a file, it must first be soft deleted."
@@ -534,15 +518,15 @@ msgstr ""
"Um eine Datei endgültig zu löschen, muss sie zuvor weich gelöscht worden " "Um eine Datei endgültig zu löschen, muss sie zuvor weich gelöscht worden "
"sein." "sein."
#: core/recording/event/notification.py:124 #: core/recording/event/notification.py:123
msgid "Your recording is ready" msgid "Your recording is ready"
msgstr "Ihre Aufzeichnung ist bereit" msgstr "Ihre Aufzeichnung ist bereit"
#: core/recording/event/notification.py:195 #: core/recording/event/notification.py:194
msgid "Transcription" msgid "Transcription"
msgstr "Transkription" msgstr "Transkription"
#: core/recording/event/notification.py:206 #: core/recording/event/notification.py:204
#, python-brace-format #, python-brace-format
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}" msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
msgstr "" msgstr ""
@@ -553,25 +537,25 @@ msgstr ""
msgid "Video call in progress: {sender.email} is waiting for you to connect" msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Videoanruf läuft: {sender.email} wartet auf Ihre Teilnahme" msgstr "Videoanruf läuft: {sender.email} wartet auf Ihre Teilnahme"
#: core/templates/mail/html/invitation.html:151 #: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:151 #: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:4 #: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/screen_recording.txt:4 #: core/templates/mail/text/screen_recording.txt:3
msgid "Logo email" msgid "Logo email"
msgstr "Logo-E-Mail" msgstr "Logo-E-Mail"
#: core/templates/mail/html/invitation.html:181 #: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:6 #: core/templates/mail/text/invitation.txt:5
msgid "invites you to join an ongoing video call" msgid "invites you to join an ongoing video call"
msgstr "lädt Sie zu einem laufenden Videoanruf ein" msgstr "lädt Sie zu einem laufenden Videoanruf ein"
#: core/templates/mail/html/invitation.html:192 #: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:8 #: core/templates/mail/text/invitation.txt:7
msgid "JOIN THE CALL" msgid "JOIN THE CALL"
msgstr "AM ANRUF TEILNEHMEN" msgstr "AM ANRUF TEILNEHMEN"
#: core/templates/mail/html/invitation.html:219 #: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:14 #: core/templates/mail/text/invitation.txt:13
msgid "" msgid ""
"If you can't click the button, copy and paste the URL into your browser to " "If you can't click the button, copy and paste the URL into your browser to "
"join the call." "join the call."
@@ -579,47 +563,41 @@ msgstr ""
"Wenn Sie den Button nicht anklicken können, kopieren Sie die URL und fügen " "Wenn Sie den Button nicht anklicken können, kopieren Sie die URL und fügen "
"Sie sie in Ihren Browser ein, um am Anruf teilzunehmen." "Sie sie in Ihren Browser ein, um am Anruf teilzunehmen."
#: core/templates/mail/html/invitation.html:227 #: core/templates/mail/html/invitation.html:235
#: core/templates/mail/text/invitation.txt:16 #: core/templates/mail/text/invitation.txt:15
msgid "Tips for a better experience:" msgid "Tips for a better experience:"
msgstr "Tipps für ein besseres Erlebnis:" msgstr "Tipps für ein besseres Erlebnis:"
#: core/templates/mail/html/invitation.html:229 #: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:18 #: core/templates/mail/text/invitation.txt:17
msgid "Use Chrome or Firefox for better call quality" msgid "Use Chrome or Firefox for better call quality"
msgstr "Verwenden Sie Chrome oder Firefox für eine bessere Anrufqualität" msgstr "Verwenden Sie Chrome oder Firefox für eine bessere Anrufqualität"
#: core/templates/mail/html/invitation.html:230 #: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:19 #: core/templates/mail/text/invitation.txt:18
msgid "Test your microphone and camera before joining" msgid "Test your microphone and camera before joining"
msgstr "Testen Sie Ihr Mikrofon und Ihre Kamera vor dem Beitritt" msgstr "Testen Sie Ihr Mikrofon und Ihre Kamera vor dem Beitritt"
#: core/templates/mail/html/invitation.html:231 #: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:20 #: core/templates/mail/text/invitation.txt:19
msgid "Make sure you have a stable internet connection" msgid "Make sure you have a stable internet connection"
msgstr "Stellen Sie sicher, dass Sie eine stabile Internetverbindung haben" msgstr "Stellen Sie sicher, dass Sie eine stabile Internetverbindung haben"
#: core/templates/mail/html/invitation.html:240 #: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:237 #: core/templates/mail/html/screen_recording.html:245
#: core/templates/mail/text/invitation.txt:22 #: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:24 #: core/templates/mail/text/screen_recording.txt:23
#, python-format #, python-format
msgid " Thank you for using %(brandname)s. " msgid " Thank you for using %(brandname)s. "
msgstr " Vielen Dank für die Nutzung von %(brandname)s. " msgstr " Vielen Dank für die Nutzung von %(brandname)s. "
#: core/templates/mail/html/invitation.html:271 #: core/templates/mail/html/screen_recording.html:188
#, python-format #: core/templates/mail/text/screen_recording.txt:6
msgid ""
"This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
#: core/templates/mail/html/screen_recording.html:180
#: core/templates/mail/text/screen_recording.txt:7
msgid "Your recording is ready!" msgid "Your recording is ready!"
msgstr "Ihre Aufzeichnung ist fertig!" msgstr "Ihre Aufzeichnung ist fertig!"
#: core/templates/mail/html/screen_recording.html:187 #: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:9 #: core/templates/mail/text/screen_recording.txt:8
#, python-format #, python-format
msgid "" msgid ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at " " Your recording of \"%(room_name)s\" on %(recording_date)s at "
@@ -628,14 +606,14 @@ msgstr ""
" Ihre Aufzeichnung von \"%(room_name)s\" am %(recording_date)s um " " Ihre Aufzeichnung von \"%(room_name)s\" am %(recording_date)s um "
"%(recording_time)s steht nun zum Herunterladen bereit. " "%(recording_time)s steht nun zum Herunterladen bereit. "
#: core/templates/mail/html/screen_recording.html:187 #: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:9 #: core/templates/mail/text/screen_recording.txt:8
#, python-format #, python-format
msgid " The recording will expire in %(days)s days. " msgid " The recording will expire in %(days)s days. "
msgstr " Die Aufzeichnung wird in %(days)s Tagen ablaufen. " msgstr " Die Aufzeichnung wird in %(days)s Tagen ablaufen. "
#: core/templates/mail/html/screen_recording.html:192 #: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/text/screen_recording.txt:10 #: core/templates/mail/text/screen_recording.txt:9
msgid "" msgid ""
" Sharing the recording via link is not yet available. Only organizers can " " Sharing the recording via link is not yet available. Only organizers can "
"download it. " "download it. "
@@ -643,33 +621,33 @@ msgstr ""
" Die Freigabe der Aufzeichnung per Link ist noch nicht verfügbar. Nur " " Die Freigabe der Aufzeichnung per Link ist noch nicht verfügbar. Nur "
"Organisatoren können sie herunterladen. " "Organisatoren können sie herunterladen. "
#: core/templates/mail/html/screen_recording.html:198 #: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:12 #: core/templates/mail/text/screen_recording.txt:11
msgid "To keep this recording permanently:" msgid "To keep this recording permanently:"
msgstr "So speichern Sie diese Aufzeichnung dauerhaft:" msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
#: core/templates/mail/html/screen_recording.html:200 #: core/templates/mail/html/screen_recording.html:208
#, python-format #, python-format
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below " msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten " msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
#: core/templates/mail/html/screen_recording.html:201 #: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:15 #: core/templates/mail/text/screen_recording.txt:14
msgid "Use the \"Download\" button in the interface " msgid "Use the \"Download\" button in the interface "
msgstr "Verwenden Sie den Button „Herunterladen“ in der Oberfläche " msgstr "Verwenden Sie den Button „Herunterladen“ in der Oberfläche "
#: core/templates/mail/html/screen_recording.html:202 #: core/templates/mail/html/screen_recording.html:210
#: core/templates/mail/text/screen_recording.txt:16 #: core/templates/mail/text/screen_recording.txt:15
msgid "Save the file to your preferred location" msgid "Save the file to your preferred location"
msgstr "Speichern Sie die Datei an einem gewünschten Ort" msgstr "Speichern Sie die Datei an einem gewünschten Ort"
#: core/templates/mail/html/screen_recording.html:213 #: core/templates/mail/html/screen_recording.html:221
#: core/templates/mail/text/screen_recording.txt:18 #: core/templates/mail/text/screen_recording.txt:17
msgid "Open" msgid "Open"
msgstr "Öffnen" msgstr "Öffnen"
#: core/templates/mail/html/screen_recording.html:222 #: core/templates/mail/html/screen_recording.html:230
#: core/templates/mail/text/screen_recording.txt:20 #: core/templates/mail/text/screen_recording.txt:19
#, python-format #, python-format
msgid "" msgid ""
" If you have any questions or need assistance, please contact our support " " If you have any questions or need assistance, please contact our support "
@@ -678,33 +656,28 @@ msgstr ""
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte " " Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
"an unser Support-Team unter %(support_email)s. " "an unser Support-Team unter %(support_email)s. "
#: core/templates/mail/text/invitation.txt:24 #: core/templates/mail/text/screen_recording.txt:13
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: core/templates/mail/text/screen_recording.txt:14
#, fuzzy, python-format #, fuzzy, python-format
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below " #| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgid "Click the \"Open [%(link)s]\" link below " msgid "Click the \"Open [%(link)s]\" link below "
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten " msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
#: meet/settings.py:238 #: meet/settings.py:228
msgid "English" msgid "English"
msgstr "Englisch" msgstr "Englisch"
#: meet/settings.py:239 #: meet/settings.py:229
msgid "French" msgid "French"
msgstr "Französisch" msgstr "Französisch"
#: meet/settings.py:240 #: meet/settings.py:230
msgid "Dutch" msgid "Dutch"
msgstr "Niederländisch" msgstr "Niederländisch"
#: meet/settings.py:241 #: meet/settings.py:231
msgid "German" msgid "German"
msgstr "Deutsch" msgstr "Deutsch"
#: meet/settings.py:242 #: meet/settings.py:233
msgid "Spanish" msgid "Spanish"
msgstr "Spanisch" msgstr "Spanisch"
+160 -187
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-02 22:52+0000\n" "POT-Creation-Date: 2026-07-02 10:47+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -51,11 +51,11 @@ msgstr ""
msgid "File preview" msgid "File preview"
msgstr "" msgstr ""
#: core/admin.py:300 core/admin.py:450 #: core/admin.py:300 core/admin.py:443
msgid "No owner" msgid "No owner"
msgstr "No owner" msgstr "No owner"
#: core/admin.py:303 core/admin.py:453 #: core/admin.py:303 core/admin.py:446
msgid "Multiple owners" msgid "Multiple owners"
msgstr "Multiple owners" msgstr "Multiple owners"
@@ -98,11 +98,11 @@ msgstr "%(count)s recording(s) successfully marked as 'Failed to Stop'."
msgid "Skipped %(count)s recording(s) with an ineligible status." msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "Skipped %(count)s expired recording(s)." msgstr "Skipped %(count)s expired recording(s)."
#: core/admin.py:517 #: core/admin.py:510
msgid "No scopes" msgid "No scopes"
msgstr "No scopes" msgstr "No scopes"
#: core/admin.py:519 #: core/admin.py:512
msgid "Scopes" msgid "Scopes"
msgstr "Scopes" msgstr "Scopes"
@@ -110,199 +110,183 @@ msgstr "Scopes"
msgid "Creator is me" msgid "Creator is me"
msgstr "Creator is me" msgstr "Creator is me"
#: core/api/serializers.py:108 #: core/api/serializers.py:89
msgid "You must be administrator or owner of a room to add accesses to it." msgid "You must be administrator or owner of a room to add accesses to it."
msgstr "You must be administrator or owner of a room to add accesses to it." msgstr "You must be administrator or owner of a room to add accesses to it."
#: core/api/serializers.py:560 #: core/api/serializers.py:534
msgid "This file extension is not allowed." msgid "This file extension is not allowed."
msgstr "This file extension is not allowed." msgstr "This file extension is not allowed."
#: core/api/viewsets.py:1268 #: core/api/viewsets.py:1222
msgid "You have reached the maximum number of files for this type." msgid "You have reached the maximum number of files for this type."
msgstr "You have reached the maximum number of files for this type." msgstr "You have reached the maximum number of files for this type."
#: core/models.py:38 #: core/models.py:37
msgid "Member" msgid "Member"
msgstr "Member" msgstr "Member"
#: core/models.py:39 #: core/models.py:38
msgid "Administrator" msgid "Administrator"
msgstr "Administrator" msgstr "Administrator"
#: core/models.py:40 #: core/models.py:39
msgid "Owner" msgid "Owner"
msgstr "Owner" msgstr "Owner"
#: core/models.py:56 #: core/models.py:55
msgid "Initiated" msgid "Initiated"
msgstr "Initiated" msgstr "Initiated"
#: core/models.py:57 #: core/models.py:56
msgid "Active" msgid "Active"
msgstr "Active" msgstr "Active"
#: core/models.py:58 #: core/models.py:57
msgid "Stopped" msgid "Stopped"
msgstr "Stopped" msgstr "Stopped"
#: core/models.py:59 #: core/models.py:58
msgid "Saved" msgid "Saved"
msgstr "Saved" msgstr "Saved"
#: core/models.py:60 #: core/models.py:59
msgid "Aborted" msgid "Aborted"
msgstr "Aborted" msgstr "Aborted"
#: core/models.py:61 #: core/models.py:60
msgid "Failed to Start" msgid "Failed to Start"
msgstr "Failed to Start" msgstr "Failed to Start"
#: core/models.py:62 #: core/models.py:61
msgid "Failed to Stop" msgid "Failed to Stop"
msgstr "Failed to Stop" msgstr "Failed to Stop"
#: core/models.py:63 #: core/models.py:62
msgid "Notification succeeded" msgid "Notification succeeded"
msgstr "Notification succeeded" msgstr "Notification succeeded"
#: core/models.py:66 #: core/models.py:65
msgid "External process successful" msgid "External process successful"
msgstr "External process successful" msgstr "External process successful"
#: core/models.py:68 #: core/models.py:67
msgid "External process failed" msgid "External process failed"
msgstr "External process failed" msgstr "External process failed"
#: core/models.py:97 #: core/models.py:96
msgid "SCREEN_RECORDING" msgid "SCREEN_RECORDING"
msgstr "SCREEN_RECORDING" msgstr "SCREEN_RECORDING"
#: core/models.py:98 #: core/models.py:97
msgid "TRANSCRIPT" msgid "TRANSCRIPT"
msgstr "TRANSCRIPT" msgstr "TRANSCRIPT"
#: core/models.py:104 #: core/models.py:103
msgid "Public Access" msgid "Public Access"
msgstr "Public Access" msgstr "Public Access"
#: core/models.py:105 #: core/models.py:104
msgid "Trusted Access" msgid "Trusted Access"
msgstr "Trusted Access" msgstr "Trusted Access"
#: core/models.py:106 #: core/models.py:105
msgid "Restricted Access" msgid "Restricted Access"
msgstr "Restricted Access" msgstr "Restricted Access"
#: core/models.py:118 #: core/models.py:117
msgid "id" msgid "id"
msgstr "id" msgstr "id"
#: core/models.py:119 #: core/models.py:118
msgid "primary key for the record as UUID" msgid "primary key for the record as UUID"
msgstr "primary key for the record as UUID" msgstr "primary key for the record as UUID"
#: core/models.py:125 #: core/models.py:124
msgid "created on" msgid "created on"
msgstr "created on" msgstr "created on"
#: core/models.py:126 #: core/models.py:125
msgid "date and time at which a record was created" msgid "date and time at which a record was created"
msgstr "date and time at which a record was created" msgstr "date and time at which a record was created"
#: core/models.py:131 #: core/models.py:130
msgid "updated on" msgid "updated on"
msgstr "updated on" msgstr "updated on"
#: core/models.py:132 #: core/models.py:131
msgid "date and time at which a record was last updated" msgid "date and time at which a record was last updated"
msgstr "date and time at which a record was last updated" msgstr "date and time at which a record was last updated"
#: core/models.py:150 #: core/models.py:151
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
#: core/models.py:157
msgid "sub" msgid "sub"
msgstr "sub" msgstr "sub"
#: core/models.py:152 #: core/models.py:159
msgid "" msgid ""
"Optional for pending users; required upon account activation. 255 characters " "Optional for pending users; required upon account activation. 255 characters "
"or fewer. Printable ASCII characters only." "or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr "" msgstr ""
"Optional for pending users; required upon account activation. 255 characters " "Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"or fewer. Printable ASCII characters only." "characters only."
#: core/validators.py:22 #: core/models.py:168
msgid "Enter a valid sub. This value should be printable ASCII only."
msgstr "Enter a valid sub. This value should be printable ASCII only."
#: core/models.py:161
msgid "identity email address" msgid "identity email address"
msgstr "identity email address" msgstr "identity email address"
#: core/models.py:166 #: core/models.py:173
msgid "admin email address" msgid "admin email address"
msgstr "admin email address" msgstr "admin email address"
#: core/models.py:168 #: core/models.py:175
msgid "full name" msgid "full name"
msgstr "full name" msgstr "full name"
#: core/models.py:170 #: core/models.py:177
msgid "short name" msgid "short name"
msgstr "short name" msgstr "short name"
#: core/models.py:176 #: core/models.py:183
msgid "language" msgid "language"
msgstr "language" msgstr "language"
#: core/models.py:177 #: core/models.py:184
msgid "The language in which the user wants to see the interface." msgid "The language in which the user wants to see the interface."
msgstr "The language in which the user wants to see the interface." msgstr "The language in which the user wants to see the interface."
#: core/models.py:183 #: core/models.py:190
msgid "The timezone in which the user wants to see times." msgid "The timezone in which the user wants to see times."
msgstr "The timezone in which the user wants to see times." msgstr "The timezone in which the user wants to see times."
#: core/models.py:190 #: core/models.py:193
msgid "default room access level"
msgstr ""
#: core/models.py:192
msgid ""
"Access level applied by default to new rooms created by this user. When "
"empty, the instance default is used."
msgstr ""
#: core/models.py:199
#, fuzzy
#| msgid "Visio room configuration"
msgid "default room configuration"
msgstr "Visio room configuration"
#: core/models.py:201
msgid "Configurations applied by default to new rooms created by this user."
msgstr ""
#: core/models.py:205
msgid "device" msgid "device"
msgstr "device" msgstr "device"
#: core/models.py:207 #: core/models.py:195
msgid "Whether the user is a device or a real user." msgid "Whether the user is a device or a real user."
msgstr "Whether the user is a device or a real user." msgstr "Whether the user is a device or a real user."
#: core/models.py:210 #: core/models.py:198
msgid "staff status" msgid "staff status"
msgstr "staff status" msgstr "staff status"
#: core/models.py:212 #: core/models.py:200
msgid "Whether the user can log into this admin site." msgid "Whether the user can log into this admin site."
msgstr "Whether the user can log into this admin site." msgstr "Whether the user can log into this admin site."
#: core/models.py:215 #: core/models.py:203
msgid "active" msgid "active"
msgstr "active" msgstr "active"
#: core/models.py:218 #: core/models.py:206
msgid "" msgid ""
"Whether this user should be treated as active. Unselect this instead of " "Whether this user should be treated as active. Unselect this instead of "
"deleting accounts." "deleting accounts."
@@ -310,63 +294,63 @@ msgstr ""
"Whether this user should be treated as active. Unselect this instead of " "Whether this user should be treated as active. Unselect this instead of "
"deleting accounts." "deleting accounts."
#: core/models.py:231 #: core/models.py:219
msgid "user" msgid "user"
msgstr "user" msgstr "user"
#: core/models.py:232 #: core/models.py:220
msgid "users" msgid "users"
msgstr "users" msgstr "users"
#: core/models.py:298 #: core/models.py:286
msgid "Resource" msgid "Resource"
msgstr "Resource" msgstr "Resource"
#: core/models.py:299 #: core/models.py:287
msgid "Resources" msgid "Resources"
msgstr "Resources" msgstr "Resources"
#: core/models.py:357 #: core/models.py:345
msgid "Resource access" msgid "Resource access"
msgstr "Resource access" msgstr "Resource access"
#: core/models.py:358 #: core/models.py:346
msgid "Resource accesses" msgid "Resource accesses"
msgstr "Resource accesses" msgstr "Resource accesses"
#: core/models.py:364 #: core/models.py:352
msgid "Resource access with this User and Resource already exists." msgid "Resource access with this User and Resource already exists."
msgstr "Resource access with this User and Resource already exists." msgstr "Resource access with this User and Resource already exists."
#: core/models.py:421 #: core/models.py:409
msgid "Visio room configuration" msgid "Visio room configuration"
msgstr "Visio room configuration" msgstr "Visio room configuration"
#: core/models.py:422 #: core/models.py:410
msgid "Values for Visio parameters to configure the room." msgid "Values for Visio parameters to configure the room."
msgstr "Values for Visio parameters to configure the room." msgstr "Values for Visio parameters to configure the room."
#: core/models.py:429 #: core/models.py:417
msgid "Room PIN code" msgid "Room PIN code"
msgstr "Room PIN code" msgstr "Room PIN code"
#: core/models.py:430 #: core/models.py:418
msgid "Unique n-digit code that identifies this room in telephony mode." msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Unique n-digit code that identifies this room in telephony mode." msgstr "Unique n-digit code that identifies this room in telephony mode."
#: core/models.py:436 core/models.py:597 #: core/models.py:424 core/models.py:578
msgid "Room" msgid "Room"
msgstr "Room" msgstr "Room"
#: core/models.py:437 #: core/models.py:425
msgid "Rooms" msgid "Rooms"
msgstr "Rooms" msgstr "Rooms"
#: core/models.py:608 #: core/models.py:589
msgid "Worker ID" msgid "Worker ID"
msgstr "Worker ID" msgstr "Worker ID"
#: core/models.py:610 #: core/models.py:591
msgid "" msgid ""
"Enter an identifier for the worker recording.This ID is retained even when " "Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking." "the worker stops, allowing for easy tracking."
@@ -374,171 +358,171 @@ msgstr ""
"Enter an identifier for the worker recording.This ID is retained even when " "Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking." "the worker stops, allowing for easy tracking."
#: core/models.py:618 #: core/models.py:599
msgid "Recording mode" msgid "Recording mode"
msgstr "Recording mode" msgstr "Recording mode"
#: core/models.py:619 #: core/models.py:600
msgid "Defines the mode of recording being called." msgid "Defines the mode of recording being called."
msgstr "Defines the mode of recording being called." msgstr "Defines the mode of recording being called."
#: core/models.py:624 core/models.py:625 #: core/models.py:605 core/models.py:606
msgid "Recording options" msgid "Recording options"
msgstr "Recording options" msgstr "Recording options"
#: core/models.py:632 #: core/models.py:613
msgid "External Process ID" msgid "External Process ID"
msgstr "External Process ID" msgstr "External Process ID"
#: core/models.py:633 #: core/models.py:614
msgid "ID of the external process associated with the recording." msgid "ID of the external process associated with the recording."
msgstr "ID of the external process associated with the recording." msgstr "ID of the external process associated with the recording."
#: core/models.py:639 #: core/models.py:620
msgid "Recording" msgid "Recording"
msgstr "Recording" msgstr "Recording"
#: core/models.py:640 #: core/models.py:621
msgid "Recordings" msgid "Recordings"
msgstr "Recordings" msgstr "Recordings"
#: core/models.py:750 #: core/models.py:731
msgid "Recording/user relation" msgid "Recording/user relation"
msgstr "Recording/user relation" msgstr "Recording/user relation"
#: core/models.py:751 #: core/models.py:732
msgid "Recording/user relations" msgid "Recording/user relations"
msgstr "Recording/user relations" msgstr "Recording/user relations"
#: core/models.py:757 #: core/models.py:738
msgid "This user is already in this recording." msgid "This user is already in this recording."
msgstr "This user is already in this recording." msgstr "This user is already in this recording."
#: core/models.py:763 #: core/models.py:744
msgid "This team is already in this recording." msgid "This team is already in this recording."
msgstr "This team is already in this recording." msgstr "This team is already in this recording."
#: core/models.py:769 #: core/models.py:750
msgid "Either user or team must be set, not both." msgid "Either user or team must be set, not both."
msgstr "Either user or team must be set, not both." msgstr "Either user or team must be set, not both."
#: core/models.py:786 #: core/models.py:767
#, fuzzy #, fuzzy
#| msgid "created on" #| msgid "created on"
msgid "Create rooms" msgid "Create rooms"
msgstr "Create rooms" msgstr "Create rooms"
#: core/models.py:787 #: core/models.py:768
msgid "List rooms" msgid "List rooms"
msgstr "List rooms" msgstr "List rooms"
#: core/models.py:788 #: core/models.py:769
msgid "Retrieve room details" msgid "Retrieve room details"
msgstr "Retrieve room details" msgstr "Retrieve room details"
#: core/models.py:789 #: core/models.py:770
#, fuzzy #, fuzzy
#| msgid "updated on" #| msgid "updated on"
msgid "Update rooms" msgid "Update rooms"
msgstr "Update rooms" msgstr "Update rooms"
#: core/models.py:790 #: core/models.py:771
msgid "Delete rooms" msgid "Delete rooms"
msgstr "Delete rooms" msgstr "Delete rooms"
#: core/models.py:803 #: core/models.py:784
msgid "Application name" msgid "Application name"
msgstr "Application name" msgstr "Application name"
#: core/models.py:804 #: core/models.py:785
msgid "Descriptive name for this application." msgid "Descriptive name for this application."
msgstr "Descriptive name for this application." msgstr "Descriptive name for this application."
#: core/models.py:814 #: core/models.py:795
msgid "Hashed on Save. Copy it now if this is a new secret." msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "Hashed on Save. Copy it now if this is a new secret." msgstr "Hashed on Save. Copy it now if this is a new secret."
#: core/models.py:825 #: core/models.py:806
msgid "Application" msgid "Application"
msgstr "Application" msgstr "Application"
#: core/models.py:826 #: core/models.py:807
msgid "Applications" msgid "Applications"
msgstr "Applications" msgstr "Applications"
#: core/models.py:849 #: core/models.py:830
msgid "Enter a valid domain" msgid "Enter a valid domain"
msgstr "Enter a valid domain" msgstr "Enter a valid domain"
#: core/models.py:852 #: core/models.py:833
msgid "Domain" msgid "Domain"
msgstr "Domain" msgstr "Domain"
#: core/models.py:853 #: core/models.py:834
msgid "Email domain this application can act on behalf of." msgid "Email domain this application can act on behalf of."
msgstr "Email domain this application can act on behalf of." msgstr "Email domain this application can act on behalf of."
#: core/models.py:865 #: core/models.py:846
msgid "Application domain" msgid "Application domain"
msgstr "Application domain" msgstr "Application domain"
#: core/models.py:866 #: core/models.py:847
msgid "Application domains" msgid "Application domains"
msgstr "Application domains" msgstr "Application domains"
#: core/models.py:884 #: core/models.py:865
#, fuzzy #, fuzzy
#| msgid "Recording" #| msgid "Recording"
msgid "Pending" msgid "Pending"
msgstr "Pending" msgstr "Pending"
#: core/models.py:885 #: core/models.py:866
msgid "Analyzing" msgid "Analyzing"
msgstr "" msgstr ""
#: core/models.py:892 #: core/models.py:873
msgid "Ready" msgid "Ready"
msgstr "Ready" msgstr "Ready"
#: core/models.py:898 #: core/models.py:879
msgid "Background image" msgid "Background image"
msgstr "Background image" msgstr "Background image"
#: core/models.py:910 #: core/models.py:891
msgid "title" msgid "title"
msgstr "title" msgstr "title"
#: core/models.py:934 #: core/models.py:915
msgid "Malware detection info when the analysis status is unsafe." msgid "Malware detection info when the analysis status is unsafe."
msgstr "Malware detection info when the analysis status is unsafe." msgstr "Malware detection info when the analysis status is unsafe."
#: core/models.py:939 #: core/models.py:920
msgid "File" msgid "File"
msgstr "File" msgstr "File"
#: core/models.py:940 #: core/models.py:921
msgid "Files" msgid "Files"
msgstr "Files" msgstr "Files"
#: core/models.py:1060 #: core/models.py:1041
#, fuzzy #, fuzzy
#| msgid "This user is already in this recording." #| msgid "This user is already in this recording."
msgid "This file is already hard deleted." msgid "This file is already hard deleted."
msgstr "This file is already hard deleted." msgstr "This file is already hard deleted."
#: core/models.py:1070 #: core/models.py:1051
msgid "To hard delete a file, it must first be soft deleted." msgid "To hard delete a file, it must first be soft deleted."
msgstr "To hard delete a file, it must first be soft deleted." msgstr "To hard delete a file, it must first be soft deleted."
#: core/recording/event/notification.py:124 #: core/recording/event/notification.py:123
msgid "Your recording is ready" msgid "Your recording is ready"
msgstr "Your recording is ready" msgstr "Your recording is ready"
#: core/recording/event/notification.py:195 #: core/recording/event/notification.py:194
msgid "Transcription" msgid "Transcription"
msgstr "Transcription" msgstr "Transcription"
#: core/recording/event/notification.py:206 #: core/recording/event/notification.py:204
#, python-brace-format #, python-brace-format
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}" msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
msgstr "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}" msgstr "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
@@ -548,25 +532,25 @@ msgstr "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
msgid "Video call in progress: {sender.email} is waiting for you to connect" msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Video call in progress: {sender.email} is waiting for you to connect" msgstr "Video call in progress: {sender.email} is waiting for you to connect"
#: core/templates/mail/html/invitation.html:151 #: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:151 #: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:4 #: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/screen_recording.txt:4 #: core/templates/mail/text/screen_recording.txt:3
msgid "Logo email" msgid "Logo email"
msgstr "Logo email" msgstr "Logo email"
#: core/templates/mail/html/invitation.html:181 #: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:6 #: core/templates/mail/text/invitation.txt:5
msgid "invites you to join an ongoing video call" msgid "invites you to join an ongoing video call"
msgstr "invites you to join an ongoing video call" msgstr "invites you to join an ongoing video call"
#: core/templates/mail/html/invitation.html:192 #: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:8 #: core/templates/mail/text/invitation.txt:7
msgid "JOIN THE CALL" msgid "JOIN THE CALL"
msgstr "JOIN THE CALL" msgstr "JOIN THE CALL"
#: core/templates/mail/html/invitation.html:219 #: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:14 #: core/templates/mail/text/invitation.txt:13
msgid "" msgid ""
"If you can't click the button, copy and paste the URL into your browser to " "If you can't click the button, copy and paste the URL into your browser to "
"join the call." "join the call."
@@ -574,47 +558,41 @@ msgstr ""
"If you can't click the button, copy and paste the URL into your browser to " "If you can't click the button, copy and paste the URL into your browser to "
"join the call." "join the call."
#: core/templates/mail/html/invitation.html:227 #: core/templates/mail/html/invitation.html:235
#: core/templates/mail/text/invitation.txt:16 #: core/templates/mail/text/invitation.txt:15
msgid "Tips for a better experience:" msgid "Tips for a better experience:"
msgstr "Tips for a better experience:" msgstr "Tips for a better experience:"
#: core/templates/mail/html/invitation.html:229 #: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:18 #: core/templates/mail/text/invitation.txt:17
msgid "Use Chrome or Firefox for better call quality" msgid "Use Chrome or Firefox for better call quality"
msgstr "Use Chrome or Firefox for better call quality" msgstr "Use Chrome or Firefox for better call quality"
#: core/templates/mail/html/invitation.html:230 #: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:19 #: core/templates/mail/text/invitation.txt:18
msgid "Test your microphone and camera before joining" msgid "Test your microphone and camera before joining"
msgstr "Test your microphone and camera before joining" msgstr "Test your microphone and camera before joining"
#: core/templates/mail/html/invitation.html:231 #: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:20 #: core/templates/mail/text/invitation.txt:19
msgid "Make sure you have a stable internet connection" msgid "Make sure you have a stable internet connection"
msgstr "Make sure you have a stable internet connection" msgstr "Make sure you have a stable internet connection"
#: core/templates/mail/html/invitation.html:240 #: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:237 #: core/templates/mail/html/screen_recording.html:245
#: core/templates/mail/text/invitation.txt:22 #: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:24 #: core/templates/mail/text/screen_recording.txt:23
#, python-format #, python-format
msgid " Thank you for using %(brandname)s. " msgid " Thank you for using %(brandname)s. "
msgstr " Thank you for using %(brandname)s. " msgstr " Thank you for using %(brandname)s. "
#: core/templates/mail/html/invitation.html:271 #: core/templates/mail/html/screen_recording.html:188
#, python-format #: core/templates/mail/text/screen_recording.txt:6
msgid ""
"This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
#: core/templates/mail/html/screen_recording.html:180
#: core/templates/mail/text/screen_recording.txt:7
msgid "Your recording is ready!" msgid "Your recording is ready!"
msgstr "Your recording is ready!" msgstr "Your recording is ready!"
#: core/templates/mail/html/screen_recording.html:187 #: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:9 #: core/templates/mail/text/screen_recording.txt:8
#, python-format #, python-format
msgid "" msgid ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at " " Your recording of \"%(room_name)s\" on %(recording_date)s at "
@@ -623,14 +601,14 @@ msgstr ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at " " Your recording of \"%(room_name)s\" on %(recording_date)s at "
"%(recording_time)s is now ready to download. " "%(recording_time)s is now ready to download. "
#: core/templates/mail/html/screen_recording.html:187 #: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:9 #: core/templates/mail/text/screen_recording.txt:8
#, python-format #, python-format
msgid " The recording will expire in %(days)s days. " msgid " The recording will expire in %(days)s days. "
msgstr " The recording will expire in %(days)s days. " msgstr " The recording will expire in %(days)s days. "
#: core/templates/mail/html/screen_recording.html:192 #: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/text/screen_recording.txt:10 #: core/templates/mail/text/screen_recording.txt:9
msgid "" msgid ""
" Sharing the recording via link is not yet available. Only organizers can " " Sharing the recording via link is not yet available. Only organizers can "
"download it. " "download it. "
@@ -638,33 +616,33 @@ msgstr ""
" Sharing the recording via link is not yet available. Only organizers can " " Sharing the recording via link is not yet available. Only organizers can "
"download it. " "download it. "
#: core/templates/mail/html/screen_recording.html:198 #: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:12 #: core/templates/mail/text/screen_recording.txt:11
msgid "To keep this recording permanently:" msgid "To keep this recording permanently:"
msgstr "To keep this recording permanently:" msgstr "To keep this recording permanently:"
#: core/templates/mail/html/screen_recording.html:200 #: core/templates/mail/html/screen_recording.html:208
#, python-format #, python-format
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below " msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below " msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
#: core/templates/mail/html/screen_recording.html:201 #: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:15 #: core/templates/mail/text/screen_recording.txt:14
msgid "Use the \"Download\" button in the interface " msgid "Use the \"Download\" button in the interface "
msgstr "Use the \"Download\" button in the interface " msgstr "Use the \"Download\" button in the interface "
#: core/templates/mail/html/screen_recording.html:202 #: core/templates/mail/html/screen_recording.html:210
#: core/templates/mail/text/screen_recording.txt:16 #: core/templates/mail/text/screen_recording.txt:15
msgid "Save the file to your preferred location" msgid "Save the file to your preferred location"
msgstr "Save the file to your preferred location" msgstr "Save the file to your preferred location"
#: core/templates/mail/html/screen_recording.html:213 #: core/templates/mail/html/screen_recording.html:221
#: core/templates/mail/text/screen_recording.txt:18 #: core/templates/mail/text/screen_recording.txt:17
msgid "Open" msgid "Open"
msgstr "Open" msgstr "Open"
#: core/templates/mail/html/screen_recording.html:222 #: core/templates/mail/html/screen_recording.html:230
#: core/templates/mail/text/screen_recording.txt:20 #: core/templates/mail/text/screen_recording.txt:19
#, python-format #, python-format
msgid "" msgid ""
" If you have any questions or need assistance, please contact our support " " If you have any questions or need assistance, please contact our support "
@@ -673,33 +651,28 @@ msgstr ""
" If you have any questions or need assistance, please contact our support " " If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. " "team at %(support_email)s. "
#: core/templates/mail/text/invitation.txt:24 #: core/templates/mail/text/screen_recording.txt:13
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: core/templates/mail/text/screen_recording.txt:14
#, fuzzy, python-format #, fuzzy, python-format
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below " #| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgid "Click the \"Open [%(link)s]\" link below " msgid "Click the \"Open [%(link)s]\" link below "
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below " msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
#: meet/settings.py:238 #: meet/settings.py:228
msgid "English" msgid "English"
msgstr "English" msgstr "English"
#: meet/settings.py:239 #: meet/settings.py:229
msgid "French" msgid "French"
msgstr "French" msgstr "French"
#: meet/settings.py:240 #: meet/settings.py:230
msgid "Dutch" msgid "Dutch"
msgstr "Dutch" msgstr "Dutch"
#: meet/settings.py:241 #: meet/settings.py:231
msgid "German" msgid "German"
msgstr "German" msgstr "German"
#: meet/settings.py:242 #: meet/settings.py:233
msgid "Spanish" msgid "Spanish"
msgstr "Spanish" msgstr "Spanish"
+174 -229
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-02 22:52+0000\n" "POT-Creation-Date: 2026-07-02 10:47+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -41,20 +41,20 @@ msgstr "Eliminar Salas"
msgid "Derived info" msgid "Derived info"
msgstr "" msgstr ""
# 'Marcas de tiempo' is a bad translation for spanish
#: core/admin.py:237 #: core/admin.py:237
# 'Marcas de tiempo' is a bad translation for spanish
msgid "Timestamps" msgid "Timestamps"
msgstr "" msgstr ""
#: core/admin.py:240 #: core/admin.py:240
msgid "File preview" msgid "File preview"
msgstr "Vista previa" msgstr "Vista previa"
#: core/admin.py:300 core/admin.py:450 #: core/admin.py:300 core/admin.py:443
msgid "No owner" msgid "No owner"
msgstr "Sin propietario" msgstr "Sin propietario"
#: core/admin.py:303 core/admin.py:453 #: core/admin.py:303 core/admin.py:446
msgid "Multiple owners" msgid "Multiple owners"
msgstr "Varios propietarios" msgstr "Varios propietarios"
@@ -89,19 +89,18 @@ msgstr "Marcar las grabaciones seleccionadas como «Error al detener»"
#: core/admin.py:386 #: core/admin.py:386
#, python-format #, python-format
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'." msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
msgstr "" msgstr "%(count)s grabación(es) marcada(s) correctamente como «Error al detener»."
"%(count)s grabación(es) marcada(s) correctamente como «Error al detener»."
#: core/admin.py:394 #: core/admin.py:394
#, python-format #, python-format
msgid "Skipped %(count)s recording(s) with an ineligible status." msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "Se han omitido %(count)s grabación(es) con un estado no elegible." msgstr "Se han omitido %(count)s grabación(es) con un estado no elegible."
#: core/admin.py:517 #: core/admin.py:510
msgid "No scopes" msgid "No scopes"
msgstr "" msgstr ""
#: core/admin.py:519 #: core/admin.py:512
msgid "Scopes" msgid "Scopes"
msgstr "Ámbitos" msgstr "Ámbitos"
@@ -109,599 +108,545 @@ msgstr "Ámbitos"
msgid "Creator is me" msgid "Creator is me"
msgstr "Yo soy el creador" msgstr "Yo soy el creador"
#: core/api/serializers.py:108 #: core/api/serializers.py:89
msgid "You must be administrator or owner of a room to add accesses to it." msgid "You must be administrator or owner of a room to add accesses to it."
msgstr "" msgstr "Debes ser administrador o propietario de una reunión para añadirle accesos."
"Debes ser administrador o propietario de una reunión para añadirle accesos."
#: core/api/serializers.py:560 #: core/api/serializers.py:534
msgid "This file extension is not allowed." msgid "This file extension is not allowed."
msgstr "Esta extensión de archivo no está permitida." msgstr "Esta extensión de archivo no está permitida."
#: core/api/viewsets.py:1268 #: core/api/viewsets.py:1222
msgid "You have reached the maximum number of files for this type." msgid "You have reached the maximum number of files for this type."
msgstr "Has alcanzado el número máximo de archivos de este tipo." msgstr "Has alcanzado el número máximo de archivos de este tipo."
#: core/models.py:38 #: core/models.py:37
msgid "Member" msgid "Member"
msgstr "Miembro" msgstr "Miembro"
#: core/models.py:39 #: core/models.py:38
msgid "Administrator" msgid "Administrator"
msgstr "Administrador" msgstr "Administrador"
#: core/models.py:40 #: core/models.py:39
msgid "Owner" msgid "Owner"
msgstr "Propietario" msgstr "Propietario"
#: core/models.py:55
# To check here and following lines for gender agreement (Masculine/Feminine) # To check here and following lines for gender agreement (Masculine/Feminine)
#: core/models.py:56
msgid "Initiated" msgid "Initiated"
msgstr "Iniciada" msgstr "Iniciada"
#: core/models.py:57 #: core/models.py:56
msgid "Active" msgid "Active"
msgstr "Activa" msgstr "Activa"
#: core/models.py:58 #: core/models.py:57
msgid "Stopped" msgid "Stopped"
msgstr "Detenida" msgstr "Detenida"
#: core/models.py:59 #: core/models.py:58
msgid "Saved" msgid "Saved"
msgstr "Guardada" msgstr "Guardada"
#: core/models.py:60 #: core/models.py:59
msgid "Aborted" msgid "Aborted"
msgstr "Cancelada" msgstr "Cancelada"
#: core/models.py:61 #: core/models.py:60
msgid "Failed to Start" msgid "Failed to Start"
msgstr "Error al iniciar" msgstr "Error al iniciar"
#: core/models.py:62 #: core/models.py:61
msgid "Failed to Stop" msgid "Failed to Stop"
msgstr "Error al detener" msgstr "Error al detener"
#: core/models.py:63 #: core/models.py:62
msgid "Notification succeeded" msgid "Notification succeeded"
msgstr "Notificado correctamente" msgstr "Notificado correctamente"
#: core/models.py:66 #: core/models.py:65
msgid "External process successful" msgid "External process successful"
msgstr "Proceso externo finalizado correctamente" msgstr "Proceso externo finalizado correctamente"
#: core/models.py:68 #: core/models.py:67
msgid "External process failed" msgid "External process failed"
msgstr "Error en el Proceso externo" msgstr "Error en el Proceso externo"
#: core/models.py:97 #: core/models.py:96
msgid "SCREEN_RECORDING" msgid "SCREEN_RECORDING"
msgstr "GRABACIÓN_DE_PANTALLA" msgstr "GRABACIÓN_DE_PANTALLA"
#: core/models.py:98 #: core/models.py:97
msgid "TRANSCRIPT" msgid "TRANSCRIPT"
msgstr "TRANSCRIPCIÓN" msgstr "TRANSCRIPCIÓN"
#: core/models.py:104 #: core/models.py:103
msgid "Public Access" msgid "Public Access"
msgstr "Acceso público" msgstr "Acceso público"
#: core/models.py:105 #: core/models.py:104
msgid "Trusted Access" msgid "Trusted Access"
msgstr "Acceso usuarios autorizados" msgstr "Acceso usuarios autorizados"
#: core/models.py:106 #: core/models.py:105
msgid "Restricted Access" msgid "Restricted Access"
msgstr "Acceso restringido" msgstr "Acceso restringido"
#: core/models.py:118 #: core/models.py:117
msgid "id" msgid "id"
msgstr "id" msgstr "id"
#: core/models.py:119 #: core/models.py:118
msgid "primary key for the record as UUID" msgid "primary key for the record as UUID"
msgstr "clave primaria del registro en forma de UUID" msgstr "clave primaria del registro en forma de UUID"
#: core/models.py:125 #: core/models.py:124
msgid "created on" msgid "created on"
msgstr "creado el" msgstr "creado el"
#: core/models.py:126 #: core/models.py:125
msgid "date and time at which a record was created" msgid "date and time at which a record was created"
msgstr "fecha y hora en que se creó un registro" msgstr "fecha y hora en que se creó un registro"
#: core/models.py:131 #: core/models.py:130
msgid "updated on" msgid "updated on"
msgstr "actualizado el" msgstr "actualizado el"
#: core/models.py:132 #: core/models.py:131
msgid "date and time at which a record was last updated" msgid "date and time at which a record was last updated"
msgstr "fecha y hora de la última actualización de un registro" msgstr "fecha y hora de la última actualización de un registro"
#: core/models.py:150 #: core/models.py:151
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr "Introduce un sub válido. Este valor solo puede contener letras, números y los caracteres @/./+/-/_."
#: core/models.py:157
msgid "sub" msgid "sub"
msgstr "sub" msgstr "sub"
#: core/models.py:152 #: core/models.py:159
msgid "" msgid ""
"Optional for pending users; required upon account activation. 255 characters " "Optional for pending users; required upon account activation. 255 characters "
"or fewer. Printable ASCII characters only." "or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr "" msgstr "Opcional para los usuarios pendientes; obligatorio al activar la cuenta. 255 caracteres como máximo. Solo letras, números y los caracteres @/./+/-/_."
"Opcional para los usuarios pendientes; obligatorio al activar la cuenta. "
"255 caracteres como máximo. Solo caracteres ASCII imprimibles."
#: core/validators.py:22 #: core/models.py:168
msgid "Enter a valid sub. This value should be printable ASCII only."
msgstr "Introduzca un sub válido. Este valor solo debe contener caracteres ASCII imprimibles."
#: core/models.py:161
msgid "identity email address" msgid "identity email address"
msgstr "dirección de correo electrónico" msgstr "dirección de correo electrónico"
#: core/models.py:166 #: core/models.py:173
msgid "admin email address" msgid "admin email address"
msgstr "dirección de correo electrónico de administrador" msgstr "dirección de correo electrónico de administrador"
#: core/models.py:168 #: core/models.py:175
msgid "full name" msgid "full name"
msgstr "nombre completo" msgstr "nombre completo"
#: core/models.py:170 #: core/models.py:177
msgid "short name" msgid "short name"
msgstr "nombre corto" msgstr "nombre corto"
#: core/models.py:176 #: core/models.py:183
msgid "language" msgid "language"
msgstr "idioma" msgstr "idioma"
#: core/models.py:177 #: core/models.py:184
msgid "The language in which the user wants to see the interface." msgid "The language in which the user wants to see the interface."
msgstr "El idioma preferido de interfaz." msgstr "El idioma preferido de interfaz."
#: core/models.py:183 #: core/models.py:190
msgid "The timezone in which the user wants to see times." msgid "The timezone in which the user wants to see times."
msgstr "La zona horaria en la que el usuario quiere ver las horas." msgstr "La zona horaria en la que el usuario quiere ver las horas."
#: core/models.py:190 #: core/models.py:193
msgid "default room access level"
msgstr ""
#: core/models.py:192
msgid ""
"Access level applied by default to new rooms created by this user. When "
"empty, the instance default is used."
msgstr ""
#: core/models.py:199
#, fuzzy
#| msgid "Visio room configuration"
msgid "default room configuration"
msgstr "Configuración de la videoconferencia"
#: core/models.py:201
msgid "Configurations applied by default to new rooms created by this user."
msgstr ""
#: core/models.py:205
msgid "device" msgid "device"
msgstr "dispositivo" msgstr "dispositivo"
#: core/models.py:207 #: core/models.py:195
msgid "Whether the user is a device or a real user." msgid "Whether the user is a device or a real user."
msgstr "Si el usuario es un dispositivo o un usuario real." msgstr "Si el usuario es un dispositivo o un usuario real."
#: core/models.py:210 #: core/models.py:198
msgid "staff status" msgid "staff status"
msgstr "estado del personal" msgstr "estado del personal"
#: core/models.py:212 #: core/models.py:200
msgid "Whether the user can log into this admin site." msgid "Whether the user can log into this admin site."
msgstr "Si el usuario puede acceder a este sitio de administración." msgstr "Si el usuario puede acceder a este sitio de administración."
#: core/models.py:215 #: core/models.py:203
msgid "active" msgid "active"
msgstr "activo" msgstr "activo"
#: core/models.py:218 #: core/models.py:206
msgid "" msgid ""
"Whether this user should be treated as active. Unselect this instead of " "Whether this user should be treated as active. Unselect this instead of "
"deleting accounts." "deleting accounts."
msgstr "" msgstr "Si este usuario debe considerarse activo. Desmarca esta opción en lugar de eliminar cuentas."
"Si este usuario debe considerarse activo. Desmarca esta opción en lugar de "
"eliminar cuentas."
#: core/models.py:231 #: core/models.py:219
msgid "user" msgid "user"
msgstr "usuario" msgstr "usuario"
#: core/models.py:232 #: core/models.py:220
msgid "users" msgid "users"
msgstr "usuarios" msgstr "usuarios"
#: core/models.py:298 #: core/models.py:286
msgid "Resource" msgid "Resource"
msgstr "Recurso" msgstr "Recurso"
#: core/models.py:299 #: core/models.py:287
msgid "Resources" msgid "Resources"
msgstr "Recursos" msgstr "Recursos"
#: core/models.py:357 #: core/models.py:345
msgid "Resource access" msgid "Resource access"
msgstr "Acceso a recursos" msgstr "Acceso a recursos"
#: core/models.py:358 #: core/models.py:346
msgid "Resource accesses" msgid "Resource accesses"
msgstr "Accesos a recursos" msgstr "Accesos a recursos"
#: core/models.py:364 #: core/models.py:352
msgid "Resource access with this User and Resource already exists." msgid "Resource access with this User and Resource already exists."
msgstr "Ya existe un acceso al recurso con este usuario y este recurso." msgstr "Ya existe un acceso al recurso con este usuario y este recurso."
#: core/models.py:421 #: core/models.py:409
msgid "Visio room configuration" msgid "Visio room configuration"
msgstr "Configuración de la videoconferencia" msgstr "Configuración de la videoconferencia"
#: core/models.py:422 #: core/models.py:410
msgid "Values for Visio parameters to configure the room." msgid "Values for Visio parameters to configure the room."
msgstr "" msgstr "Valores de los parámetros de videoconferencia para configurar la reunión."
"Valores de los parámetros de videoconferencia para configurar la reunión."
#: core/models.py:429 #: core/models.py:417
msgid "Room PIN code" msgid "Room PIN code"
msgstr "Código PIN de la reunión" msgstr "Código PIN de la reunión"
#: core/models.py:430 #: core/models.py:418
msgid "Unique n-digit code that identifies this room in telephony mode." msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "" msgstr "Código único de n dígitos que identifica esta reunión en modo telefónico."
"Código único de n dígitos que identifica esta reunión en modo telefónico."
#: core/models.py:436 core/models.py:597 #: core/models.py:424 core/models.py:578
msgid "Room" msgid "Room"
msgstr "Reunión" msgstr "Reunión"
#: core/models.py:437 #: core/models.py:425
msgid "Rooms" msgid "Rooms"
msgstr "Reuniones" msgstr "Reuniones"
#: core/models.py:608 #: core/models.py:589
msgid "Worker ID" msgid "Worker ID"
msgstr "ID del worker" msgstr "ID del worker"
#: core/models.py:610 #: core/models.py:591
msgid "" msgid ""
"Enter an identifier for the worker recording.This ID is retained even when " "Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking." "the worker stops, allowing for easy tracking."
msgstr "" msgstr "Introduce un identificador para la grabación del worker. Este identificador se conserva incluso cuando el worker se detiene, lo que permite un seguimiento sencillo."
"Introduce un identificador para la grabación del worker. Este identificador "
"se conserva incluso cuando el worker se detiene, lo que permite un "
"seguimiento sencillo."
#: core/models.py:618 #: core/models.py:599
msgid "Recording mode" msgid "Recording mode"
msgstr "Modo de grabación" msgstr "Modo de grabación"
#: core/models.py:619 #: core/models.py:600
msgid "Defines the mode of recording being called." msgid "Defines the mode of recording being called."
msgstr "Define el modo de grabación a utilizar." msgstr "Define el modo de grabación a utilizar."
#: core/models.py:624 core/models.py:625 #: core/models.py:605 core/models.py:606
msgid "Recording options" msgid "Recording options"
msgstr "Opciones de grabación" msgstr "Opciones de grabación"
#: core/models.py:632 #: core/models.py:613
msgid "External Process ID" msgid "External Process ID"
msgstr "ID del proceso externo" msgstr "ID del proceso externo"
#: core/models.py:633 #: core/models.py:614
msgid "ID of the external process associated with the recording." msgid "ID of the external process associated with the recording."
msgstr "ID del proceso externo asociado a la grabación." msgstr "ID del proceso externo asociado a la grabación."
#: core/models.py:639 #: core/models.py:620
msgid "Recording" msgid "Recording"
msgstr "Grabación" msgstr "Grabación"
#: core/models.py:640 #: core/models.py:621
msgid "Recordings" msgid "Recordings"
msgstr "Grabaciones" msgstr "Grabaciones"
#: core/models.py:750 #: core/models.py:731
msgid "Recording/user relation" msgid "Recording/user relation"
msgstr "Relación grabación/usuario" msgstr "Relación grabación/usuario"
#: core/models.py:751 #: core/models.py:732
msgid "Recording/user relations" msgid "Recording/user relations"
msgstr "Relaciones grabación/usuario" msgstr "Relaciones grabación/usuario"
#: core/models.py:757 #: core/models.py:738
msgid "This user is already in this recording." msgid "This user is already in this recording."
msgstr "Este usuario ya está en esta grabación." msgstr "Este usuario ya está en esta grabación."
#: core/models.py:763 #: core/models.py:744
msgid "This team is already in this recording." msgid "This team is already in this recording."
msgstr "Este equipo ya está en esta grabación." msgstr "Este equipo ya está en esta grabación."
#: core/models.py:769 #: core/models.py:750
msgid "Either user or team must be set, not both." msgid "Either user or team must be set, not both."
msgstr "Debe definirse el usuario o el equipo, pero no ambos." msgstr "Debe definirse el usuario o el equipo, pero no ambos."
#: core/models.py:786 #: core/models.py:767
msgid "Create rooms" msgid "Create rooms"
msgstr "Crear reunión" msgstr "Crear reunión"
#: core/models.py:787 #: core/models.py:768
msgid "List rooms" msgid "List rooms"
msgstr "Listar reuniones" msgstr "Listar reuniones"
#: core/models.py:788 #: core/models.py:769
msgid "Retrieve room details" msgid "Retrieve room details"
msgstr "Ver los detalles de una reunión" msgstr "Ver los detalles de una reunión"
#: core/models.py:789 #: core/models.py:770
msgid "Update rooms" msgid "Update rooms"
msgstr "Actualizar las reuniones" msgstr "Actualizar las reuniones"
#: core/models.py:790 #: core/models.py:771
msgid "Delete rooms" msgid "Delete rooms"
msgstr "Eliminar las reuniones" msgstr "Eliminar las reuniones"
#: core/models.py:803 #: core/models.py:784
msgid "Application name" msgid "Application name"
msgstr "Nombre de la aplicación" msgstr "Nombre de la aplicación"
#: core/models.py:804 #: core/models.py:785
msgid "Descriptive name for this application." msgid "Descriptive name for this application."
msgstr "Nombre descriptivo de esta aplicación." msgstr "Nombre descriptivo de esta aplicación."
#: core/models.py:814 #: core/models.py:795
msgid "Hashed on Save. Copy it now if this is a new secret." msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "Se cifra al guardar. Cópialo ahora si se trata de un secreto nuevo." msgstr "Se cifra al guardar. Cópialo ahora si se trata de un secreto nuevo."
#: core/models.py:825 #: core/models.py:806
msgid "Application" msgid "Application"
msgstr "Aplicación" msgstr "Aplicación"
#: core/models.py:826 #: core/models.py:807
msgid "Applications" msgid "Applications"
msgstr "Aplicaciones" msgstr "Aplicaciones"
#: core/models.py:849 #: core/models.py:830
msgid "Enter a valid domain" msgid "Enter a valid domain"
msgstr "Introduce un dominio válido" msgstr "Introduce un dominio válido"
#: core/models.py:852 #: core/models.py:833
msgid "Domain" msgid "Domain"
msgstr "Dominio" msgstr "Dominio"
#: core/models.py:853 #: core/models.py:834
msgid "Email domain this application can act on behalf of." msgid "Email domain this application can act on behalf of."
msgstr "" msgstr "Dominio de correo electrónico en cuyo nombre puede actuar esta aplicación."
"Dominio de correo electrónico en cuyo nombre puede actuar esta aplicación."
#: core/models.py:865 #: core/models.py:846
msgid "Application domain" msgid "Application domain"
msgstr "Dominio de aplicación" msgstr "Dominio de aplicación"
#: core/models.py:866 #: core/models.py:847
msgid "Application domains" msgid "Application domains"
msgstr "Dominios de aplicación" msgstr "Dominios de aplicación"
#: core/models.py:884 #: core/models.py:865
msgid "Pending" msgid "Pending"
msgstr "Pendiente" msgstr "Pendiente"
#: core/models.py:885 #: core/models.py:866
msgid "Analyzing" msgid "Analyzing"
msgstr "Analizando" msgstr "Analizando"
#: core/models.py:892 #: core/models.py:873
msgid "Ready" msgid "Ready"
msgstr "Listo" msgstr "Listo"
#: core/models.py:898 #: core/models.py:879
msgid "Background image" msgid "Background image"
msgstr "Imagen de fondo" msgstr "Imagen de fondo"
#: core/models.py:910 #: core/models.py:891
msgid "title" msgid "title"
msgstr "título" msgstr "título"
#: core/models.py:934 #: core/models.py:915
msgid "Malware detection info when the analysis status is unsafe." msgid "Malware detection info when the analysis status is unsafe."
msgstr "" msgstr "Información sobre la detección de malware cuando el estado del análisis no es seguro."
"Información sobre la detección de malware cuando el estado del análisis no "
"es seguro."
#: core/models.py:939 #: core/models.py:920
msgid "File" msgid "File"
msgstr "Archivo" msgstr "Archivo"
#: core/models.py:940 #: core/models.py:921
msgid "Files" msgid "Files"
msgstr "Archivos" msgstr "Archivos"
#: core/models.py:1060 #: core/models.py:1041
msgid "This file is already hard deleted." msgid "This file is already hard deleted."
msgstr "Este archivo ya se ha eliminado definitivamente." msgstr "Este archivo ya se ha eliminado definitivamente."
#: core/models.py:1070 #: core/models.py:1051
msgid "To hard delete a file, it must first be soft deleted." msgid "To hard delete a file, it must first be soft deleted."
msgstr "" msgstr "Para eliminar definitivamente un archivo, primero debe haberse marcado como eliminado."
"Para eliminar definitivamente un archivo, primero debe haberse marcado como "
"eliminado."
#: core/recording/event/notification.py:124 #: core/recording/event/notification.py:123
msgid "Your recording is ready" msgid "Your recording is ready"
msgstr "Tu grabación está lista" msgstr "Tu grabación está lista"
#: core/recording/event/notification.py:195 #: core/recording/event/notification.py:194
msgid "Transcription" msgid "Transcription"
msgstr "Transcripción" msgstr "Transcripción"
#: core/recording/event/notification.py:206 #: core/recording/event/notification.py:204
#, python-brace-format #, python-brace-format
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}" msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
msgstr "" msgstr "Reunión \"{room}\" del {room_recording_date} a las {room_recording_time}"
"Reunión \"{room}\" del {room_recording_date} a las {room_recording_time}"
#: core/services/invitation.py:44 #: core/services/invitation.py:44
#, python-brace-format #, python-brace-format
msgid "Video call in progress: {sender.email} is waiting for you to connect" msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Videollamada en curso: {sender.email} está esperando a que te conectes" msgstr "Videollamada en curso: {sender.email} está esperando a que te conectes"
#: core/templates/mail/html/invitation.html:151 #: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:151 #: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:4 #: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/screen_recording.txt:4 #: core/templates/mail/text/screen_recording.txt:3
msgid "Logo email" msgid "Logo email"
msgstr "Logotipo del correo" msgstr "Logotipo del correo"
#: core/templates/mail/html/invitation.html:181 #: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:6 #: core/templates/mail/text/invitation.txt:5
msgid "invites you to join an ongoing video call" msgid "invites you to join an ongoing video call"
msgstr "te invita a unirte a una videollamada en curso" msgstr "te invita a unirte a una videollamada en curso"
#: core/templates/mail/html/invitation.html:192 #: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:8 #: core/templates/mail/text/invitation.txt:7
msgid "JOIN THE CALL" msgid "JOIN THE CALL"
msgstr "UNIRSE A LA LLAMADA" msgstr "UNIRSE A LA LLAMADA"
#: core/templates/mail/html/invitation.html:219 #: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:14 #: core/templates/mail/text/invitation.txt:13
msgid "" msgid ""
"If you can't click the button, copy and paste the URL into your browser to " "If you can't click the button, copy and paste the URL into your browser to "
"join the call." "join the call."
msgstr "" msgstr "Si no puedes hacer clic en el botón, copia y pega la URL en tu navegador para unirte a la llamada."
"Si no puedes hacer clic en el botón, copia y pega la URL en tu navegador "
"para unirte a la llamada."
#: core/templates/mail/html/invitation.html:227 #: core/templates/mail/html/invitation.html:235
#: core/templates/mail/text/invitation.txt:16 #: core/templates/mail/text/invitation.txt:15
msgid "Tips for a better experience:" msgid "Tips for a better experience:"
msgstr "Consejos para una mejor experiencia:" msgstr "Consejos para una mejor experiencia:"
#: core/templates/mail/html/invitation.html:229 #: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:18 #: core/templates/mail/text/invitation.txt:17
msgid "Use Chrome or Firefox for better call quality" msgid "Use Chrome or Firefox for better call quality"
msgstr "Usa Chrome o Firefox para una mejor calidad de llamada" msgstr "Usa Chrome o Firefox para una mejor calidad de llamada"
#: core/templates/mail/html/invitation.html:230 #: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:19 #: core/templates/mail/text/invitation.txt:18
msgid "Test your microphone and camera before joining" msgid "Test your microphone and camera before joining"
msgstr "Prueba tu micrófono y tu cámara antes de unirte" msgstr "Prueba tu micrófono y tu cámara antes de unirte"
#: core/templates/mail/html/invitation.html:231 #: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:20 #: core/templates/mail/text/invitation.txt:19
msgid "Make sure you have a stable internet connection" msgid "Make sure you have a stable internet connection"
msgstr "Asegúrate de tener una conexión a internet estable" msgstr "Asegúrate de tener una conexión a internet estable"
#: core/templates/mail/html/invitation.html:240 #: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:237 #: core/templates/mail/html/screen_recording.html:245
#: core/templates/mail/text/invitation.txt:22 #: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:24 #: core/templates/mail/text/screen_recording.txt:23
#, python-format #, python-format
msgid " Thank you for using %(brandname)s. " msgid " Thank you for using %(brandname)s. "
msgstr " Gracias por usar %(brandname)s. " msgstr " Gracias por usar %(brandname)s. "
#: core/templates/mail/html/invitation.html:271 #: core/templates/mail/html/screen_recording.html:188
#, python-format #: core/templates/mail/text/screen_recording.txt:6
msgid ""
"This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
#: core/templates/mail/html/screen_recording.html:180
#: core/templates/mail/text/screen_recording.txt:7
msgid "Your recording is ready!" msgid "Your recording is ready!"
msgstr "¡Tu grabación está lista!" msgstr "¡Tu grabación está lista!"
#: core/templates/mail/html/screen_recording.html:187 #: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:9 #: core/templates/mail/text/screen_recording.txt:8
#, python-format #, python-format
msgid "" msgid ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at " " Your recording of \"%(room_name)s\" on %(recording_date)s at "
"%(recording_time)s is now ready to download. " "%(recording_time)s is now ready to download. "
msgstr "" msgstr " Tu grabación de \"%(room_name)s\" del %(recording_date)s a las %(recording_time)s ya está lista para descargar. "
" Tu grabación de \"%(room_name)s\" del %(recording_date)s a las "
"%(recording_time)s ya está lista para descargar. "
#: core/templates/mail/html/screen_recording.html:187 #: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:9 #: core/templates/mail/text/screen_recording.txt:8
#, python-format #, python-format
msgid " The recording will expire in %(days)s days. " msgid " The recording will expire in %(days)s days. "
msgstr " La grabación caducará dentro de %(days)s días. " msgstr " La grabación caducará dentro de %(days)s días. "
#: core/templates/mail/html/screen_recording.html:192 #: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/text/screen_recording.txt:10 #: core/templates/mail/text/screen_recording.txt:9
msgid "" msgid ""
" Sharing the recording via link is not yet available. Only organizers can " " Sharing the recording via link is not yet available. Only organizers can "
"download it. " "download it. "
msgstr "" msgstr " Compartir la grabación mediante un enlace todavía no está disponible. Solo los organizadores pueden descargarla. "
" Compartir la grabación mediante un enlace todavía no está disponible. Solo "
"los organizadores pueden descargarla. "
#: core/templates/mail/html/screen_recording.html:198 #: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:12 #: core/templates/mail/text/screen_recording.txt:11
msgid "To keep this recording permanently:" msgid "To keep this recording permanently:"
msgstr "Para conservar esta grabación de forma permanente:" msgstr "Para conservar esta grabación de forma permanente:"
#: core/templates/mail/html/screen_recording.html:200 #: core/templates/mail/html/screen_recording.html:208
#, python-format #, python-format
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below " msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgstr "" msgstr "Haz clic en el enlace \"<a href=\"%(link)s\">Abrir</a>\" que aparece abajo "
"Haz clic en el enlace \"<a href=\"%(link)s\">Abrir</a>\" que aparece abajo "
#: core/templates/mail/html/screen_recording.html:201 #: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:15 #: core/templates/mail/text/screen_recording.txt:14
msgid "Use the \"Download\" button in the interface " msgid "Use the \"Download\" button in the interface "
msgstr "Usa el botón \"Descargar\" de la interfaz " msgstr "Usa el botón \"Descargar\" de la interfaz "
#: core/templates/mail/html/screen_recording.html:202 #: core/templates/mail/html/screen_recording.html:210
#: core/templates/mail/text/screen_recording.txt:16 #: core/templates/mail/text/screen_recording.txt:15
msgid "Save the file to your preferred location" msgid "Save the file to your preferred location"
msgstr "Guarda el archivo en la ubicación que prefieras" msgstr "Guarda el archivo en la ubicación que prefieras"
#: core/templates/mail/html/screen_recording.html:213 #: core/templates/mail/html/screen_recording.html:221
#: core/templates/mail/text/screen_recording.txt:18 #: core/templates/mail/text/screen_recording.txt:17
msgid "Open" msgid "Open"
msgstr "Abrir" msgstr "Abrir"
#: core/templates/mail/html/screen_recording.html:222 #: core/templates/mail/html/screen_recording.html:230
#: core/templates/mail/text/screen_recording.txt:20 #: core/templates/mail/text/screen_recording.txt:19
#, python-format #, python-format
msgid "" msgid ""
" If you have any questions or need assistance, please contact our support " " If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. " "team at %(support_email)s. "
msgstr "" msgstr " Si tienes alguna pregunta o necesitas ayuda, ponte en contacto con nuestro equipo de soporte en %(support_email)s. "
" Si tienes alguna pregunta o necesitas ayuda, ponte en contacto con nuestro "
"equipo de soporte en %(support_email)s. "
#: core/templates/mail/text/invitation.txt:24 #: core/templates/mail/text/screen_recording.txt:13
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: core/templates/mail/text/screen_recording.txt:14
#, python-format #, python-format
msgid "Click the \"Open [%(link)s]\" link below " msgid "Click the \"Open [%(link)s]\" link below "
msgstr "Haz clic en el enlace \"Abrir [%(link)s]\" que aparece abajo " msgstr "Haz clic en el enlace \"Abrir [%(link)s]\" que aparece abajo "
#: meet/settings.py:238 #: meet/settings.py:228
msgid "English" msgid "English"
msgstr "Inglés" msgstr "Inglés"
#: meet/settings.py:239 #: meet/settings.py:229
msgid "French" msgid "French"
msgstr "Francés" msgstr "Francés"
#: meet/settings.py:240 #: meet/settings.py:230
msgid "Dutch" msgid "Dutch"
msgstr "Neerlandés" msgstr "Neerlandés"
#: meet/settings.py:241 #: meet/settings.py:231
msgid "German" msgid "German"
msgstr "Alemán" msgstr "Alemán"
#: meet/settings.py:242 #: meet/settings.py:233
msgid "Spanish" msgid "Spanish"
msgstr "Español" msgstr "Español"
+160 -187
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-02 22:52+0000\n" "POT-Creation-Date: 2026-07-02 10:47+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n" "Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -51,11 +51,11 @@ msgstr ""
msgid "File preview" msgid "File preview"
msgstr "" msgstr ""
#: core/admin.py:300 core/admin.py:450 #: core/admin.py:300 core/admin.py:443
msgid "No owner" msgid "No owner"
msgstr "Pas de propriétaire" msgstr "Pas de propriétaire"
#: core/admin.py:303 core/admin.py:453 #: core/admin.py:303 core/admin.py:446
msgid "Multiple owners" msgid "Multiple owners"
msgstr "Plusieurs propriétaires" msgstr "Plusieurs propriétaires"
@@ -99,11 +99,11 @@ msgstr ""
msgid "Skipped %(count)s recording(s) with an ineligible status." msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "%(count)s enregistrement(s) avec un statut inéligible ignoré(s)." msgstr "%(count)s enregistrement(s) avec un statut inéligible ignoré(s)."
#: core/admin.py:517 #: core/admin.py:510
msgid "No scopes" msgid "No scopes"
msgstr "Aucun scopes" msgstr "Aucun scopes"
#: core/admin.py:519 #: core/admin.py:512
msgid "Scopes" msgid "Scopes"
msgstr "Scopes" msgstr "Scopes"
@@ -111,203 +111,187 @@ msgstr "Scopes"
msgid "Creator is me" msgid "Creator is me"
msgstr "Je suis le créateur" msgstr "Je suis le créateur"
#: core/api/serializers.py:108 #: core/api/serializers.py:89
msgid "You must be administrator or owner of a room to add accesses to it." msgid "You must be administrator or owner of a room to add accesses to it."
msgstr "" msgstr ""
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter " "Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
"des accès." "des accès."
#: core/api/serializers.py:560 #: core/api/serializers.py:534
msgid "This file extension is not allowed." msgid "This file extension is not allowed."
msgstr "Cette extension n'est pas autorisée" msgstr "Cette extension n'est pas autorisée"
#: core/api/viewsets.py:1268 #: core/api/viewsets.py:1222
msgid "You have reached the maximum number of files for this type." msgid "You have reached the maximum number of files for this type."
msgstr "Vous avez atteint le nombre maximum de fichiers de ce type" msgstr "Vous avez atteint le nombre maximum de fichiers de ce type"
#: core/models.py:38 #: core/models.py:37
msgid "Member" msgid "Member"
msgstr "Membre" msgstr "Membre"
#: core/models.py:39 #: core/models.py:38
msgid "Administrator" msgid "Administrator"
msgstr "Administrateur" msgstr "Administrateur"
#: core/models.py:40 #: core/models.py:39
msgid "Owner" msgid "Owner"
msgstr "Propriétaire" msgstr "Propriétaire"
#: core/models.py:56 #: core/models.py:55
msgid "Initiated" msgid "Initiated"
msgstr "Initié" msgstr "Initié"
#: core/models.py:57 #: core/models.py:56
msgid "Active" msgid "Active"
msgstr "Actif" msgstr "Actif"
#: core/models.py:58 #: core/models.py:57
msgid "Stopped" msgid "Stopped"
msgstr "Arrêté" msgstr "Arrêté"
#: core/models.py:59 #: core/models.py:58
msgid "Saved" msgid "Saved"
msgstr "Enregistré" msgstr "Enregistré"
#: core/models.py:60 #: core/models.py:59
msgid "Aborted" msgid "Aborted"
msgstr "Abandonné" msgstr "Abandonné"
#: core/models.py:61 #: core/models.py:60
msgid "Failed to Start" msgid "Failed to Start"
msgstr "Échec au démarrage" msgstr "Échec au démarrage"
#: core/models.py:62 #: core/models.py:61
msgid "Failed to Stop" msgid "Failed to Stop"
msgstr "Échec à l'arrêt" msgstr "Échec à l'arrêt"
#: core/models.py:63 #: core/models.py:62
msgid "Notification succeeded" msgid "Notification succeeded"
msgstr "Notification réussie" msgstr "Notification réussie"
#: core/models.py:66 #: core/models.py:65
msgid "External process successful" msgid "External process successful"
msgstr "Traitement externe : succès" msgstr "Traitement externe : succès"
#: core/models.py:68 #: core/models.py:67
msgid "External process failed" msgid "External process failed"
msgstr "Traitement externe : erreur" msgstr "Traitement externe : erreur"
#: core/models.py:97 #: core/models.py:96
msgid "SCREEN_RECORDING" msgid "SCREEN_RECORDING"
msgstr "ENREGISTREMENT_ÉCRAN" msgstr "ENREGISTREMENT_ÉCRAN"
#: core/models.py:98 #: core/models.py:97
msgid "TRANSCRIPT" msgid "TRANSCRIPT"
msgstr "TRANSCRIPTION" msgstr "TRANSCRIPTION"
#: core/models.py:104 #: core/models.py:103
msgid "Public Access" msgid "Public Access"
msgstr "Accès public" msgstr "Accès public"
#: core/models.py:105 #: core/models.py:104
msgid "Trusted Access" msgid "Trusted Access"
msgstr "Accès de confiance" msgstr "Accès de confiance"
#: core/models.py:106 #: core/models.py:105
msgid "Restricted Access" msgid "Restricted Access"
msgstr "Accès restreint" msgstr "Accès restreint"
#: core/models.py:118 #: core/models.py:117
msgid "id" msgid "id"
msgstr "id" msgstr "id"
#: core/models.py:119 #: core/models.py:118
msgid "primary key for the record as UUID" msgid "primary key for the record as UUID"
msgstr "clé primaire pour l'enregistrement sous forme d'UUID" msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
#: core/models.py:125 #: core/models.py:124
msgid "created on" msgid "created on"
msgstr "créé le" msgstr "créé le"
#: core/models.py:126 #: core/models.py:125
msgid "date and time at which a record was created" msgid "date and time at which a record was created"
msgstr "date et heure auxquelles un enregistrement a été créé" msgstr "date et heure auxquelles un enregistrement a été créé"
#: core/models.py:131 #: core/models.py:130
msgid "updated on" msgid "updated on"
msgstr "mis à jour le" msgstr "mis à jour le"
#: core/models.py:132 #: core/models.py:131
msgid "date and time at which a record was last updated" msgid "date and time at which a record was last updated"
msgstr "" msgstr ""
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière " "date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
"fois" "fois"
#: core/models.py:150 #: core/models.py:151
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des "
"chiffres et les caractères @/./+/-/_."
#: core/models.py:157
msgid "sub" msgid "sub"
msgstr "sub" msgstr "sub"
#: core/models.py:152 #: core/models.py:159
msgid "" msgid ""
"Optional for pending users; required upon account activation. 255 characters " "Optional for pending users; required upon account activation. 255 characters "
"or fewer. Printable ASCII characters only." "or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr "" msgstr ""
"Facultatif pour les utilisateurs en attente ; obligatoire lors de l’activation " "Optionnel pour les utilisateurs en attente ; requis lors de l'activation du "
"du compte. 255 caractères maximum. Caractères ASCII imprimables uniquement." "compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement."
#: core/validators.py:22 #: core/models.py:168
msgid "Enter a valid sub. This value should be printable ASCII only."
msgstr "Saisissez un sub valide. Cette valeur ne doit contenir que des caractères ASCII imprimables."
#: core/models.py:161
msgid "identity email address" msgid "identity email address"
msgstr "adresse e-mail d'identité" msgstr "adresse e-mail d'identité"
#: core/models.py:166 #: core/models.py:173
msgid "admin email address" msgid "admin email address"
msgstr "adresse e-mail d'administrateur" msgstr "adresse e-mail d'administrateur"
#: core/models.py:168 #: core/models.py:175
msgid "full name" msgid "full name"
msgstr "nom complet" msgstr "nom complet"
#: core/models.py:170 #: core/models.py:177
msgid "short name" msgid "short name"
msgstr "nom court" msgstr "nom court"
#: core/models.py:176 #: core/models.py:183
msgid "language" msgid "language"
msgstr "langue" msgstr "langue"
#: core/models.py:177 #: core/models.py:184
msgid "The language in which the user wants to see the interface." msgid "The language in which the user wants to see the interface."
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface." msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
#: core/models.py:183 #: core/models.py:190
msgid "The timezone in which the user wants to see times." msgid "The timezone in which the user wants to see times."
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures." msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: core/models.py:190 #: core/models.py:193
msgid "default room access level"
msgstr ""
#: core/models.py:192
msgid ""
"Access level applied by default to new rooms created by this user. When "
"empty, the instance default is used."
msgstr ""
#: core/models.py:199
#, fuzzy
#| msgid "Visio room configuration"
msgid "default room configuration"
msgstr "Configuration de la salle de visioconférence"
#: core/models.py:201
msgid "Configurations applied by default to new rooms created by this user."
msgstr ""
#: core/models.py:205
msgid "device" msgid "device"
msgstr "appareil" msgstr "appareil"
#: core/models.py:207 #: core/models.py:195
msgid "Whether the user is a device or a real user." msgid "Whether the user is a device or a real user."
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel." msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: core/models.py:210 #: core/models.py:198
msgid "staff status" msgid "staff status"
msgstr "statut du personnel" msgstr "statut du personnel"
#: core/models.py:212 #: core/models.py:200
msgid "Whether the user can log into this admin site." msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à ce site d'administration." msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: core/models.py:215 #: core/models.py:203
msgid "active" msgid "active"
msgstr "actif" msgstr "actif"
#: core/models.py:218 #: core/models.py:206
msgid "" msgid ""
"Whether this user should be treated as active. Unselect this instead of " "Whether this user should be treated as active. Unselect this instead of "
"deleting accounts." "deleting accounts."
@@ -315,65 +299,65 @@ msgstr ""
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option " "Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
"au lieu de supprimer des comptes." "au lieu de supprimer des comptes."
#: core/models.py:231 #: core/models.py:219
msgid "user" msgid "user"
msgstr "utilisateur" msgstr "utilisateur"
#: core/models.py:232 #: core/models.py:220
msgid "users" msgid "users"
msgstr "utilisateurs" msgstr "utilisateurs"
#: core/models.py:298 #: core/models.py:286
msgid "Resource" msgid "Resource"
msgstr "Ressource" msgstr "Ressource"
#: core/models.py:299 #: core/models.py:287
msgid "Resources" msgid "Resources"
msgstr "Ressources" msgstr "Ressources"
#: core/models.py:357 #: core/models.py:345
msgid "Resource access" msgid "Resource access"
msgstr "Accès aux ressources" msgstr "Accès aux ressources"
#: core/models.py:358 #: core/models.py:346
msgid "Resource accesses" msgid "Resource accesses"
msgstr "Accès aux ressources" msgstr "Accès aux ressources"
#: core/models.py:364 #: core/models.py:352
msgid "Resource access with this User and Resource already exists." msgid "Resource access with this User and Resource already exists."
msgstr "" msgstr ""
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà." "L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
#: core/models.py:421 #: core/models.py:409
msgid "Visio room configuration" msgid "Visio room configuration"
msgstr "Configuration de la salle de visioconférence" msgstr "Configuration de la salle de visioconférence"
#: core/models.py:422 #: core/models.py:410
msgid "Values for Visio parameters to configure the room." msgid "Values for Visio parameters to configure the room."
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle." msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
#: core/models.py:429 #: core/models.py:417
msgid "Room PIN code" msgid "Room PIN code"
msgstr "Code PIN de la salle" msgstr "Code PIN de la salle"
#: core/models.py:430 #: core/models.py:418
msgid "Unique n-digit code that identifies this room in telephony mode." msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "" msgstr ""
"Code unique à n chiffres qui identifie cette salle en mode téléphonique." "Code unique à n chiffres qui identifie cette salle en mode téléphonique."
#: core/models.py:436 core/models.py:597 #: core/models.py:424 core/models.py:578
msgid "Room" msgid "Room"
msgstr "Salle" msgstr "Salle"
#: core/models.py:437 #: core/models.py:425
msgid "Rooms" msgid "Rooms"
msgstr "Salles" msgstr "Salles"
#: core/models.py:608 #: core/models.py:589
msgid "Worker ID" msgid "Worker ID"
msgstr "ID du Worker" msgstr "ID du Worker"
#: core/models.py:610 #: core/models.py:591
msgid "" msgid ""
"Enter an identifier for the worker recording.This ID is retained even when " "Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking." "the worker stops, allowing for easy tracking."
@@ -381,170 +365,170 @@ msgstr ""
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est " "Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
"conservé même lorsque le Worker s'arrête, permettant un suivi facile." "conservé même lorsque le Worker s'arrête, permettant un suivi facile."
#: core/models.py:618 #: core/models.py:599
msgid "Recording mode" msgid "Recording mode"
msgstr "Mode d'enregistrement" msgstr "Mode d'enregistrement"
#: core/models.py:619 #: core/models.py:600
msgid "Defines the mode of recording being called." msgid "Defines the mode of recording being called."
msgstr "Définit le mode d'enregistrement appelé." msgstr "Définit le mode d'enregistrement appelé."
#: core/models.py:624 core/models.py:625 #: core/models.py:605 core/models.py:606
msgid "Recording options" msgid "Recording options"
msgstr "Options d'enregistrement" msgstr "Options d'enregistrement"
#: core/models.py:632 #: core/models.py:613
msgid "External Process ID" msgid "External Process ID"
msgstr "ID Traitement externe" msgstr "ID Traitement externe"
#: core/models.py:633 #: core/models.py:614
msgid "ID of the external process associated with the recording." msgid "ID of the external process associated with the recording."
msgstr "ID du traitement externe associé avec l'enregistrement." msgstr "ID du traitement externe associé avec l'enregistrement."
#: core/models.py:639 #: core/models.py:620
msgid "Recording" msgid "Recording"
msgstr "Enregistrement" msgstr "Enregistrement"
#: core/models.py:640 #: core/models.py:621
msgid "Recordings" msgid "Recordings"
msgstr "Enregistrements" msgstr "Enregistrements"
#: core/models.py:750 #: core/models.py:731
msgid "Recording/user relation" msgid "Recording/user relation"
msgstr "Relation enregistrement/utilisateur" msgstr "Relation enregistrement/utilisateur"
#: core/models.py:751 #: core/models.py:732
msgid "Recording/user relations" msgid "Recording/user relations"
msgstr "Relations enregistrement/utilisateur" msgstr "Relations enregistrement/utilisateur"
#: core/models.py:757 #: core/models.py:738
msgid "This user is already in this recording." msgid "This user is already in this recording."
msgstr "Cet utilisateur est déjà dans cet enregistrement." msgstr "Cet utilisateur est déjà dans cet enregistrement."
#: core/models.py:763 #: core/models.py:744
msgid "This team is already in this recording." msgid "This team is already in this recording."
msgstr "Cette équipe est déjà dans cet enregistrement." msgstr "Cette équipe est déjà dans cet enregistrement."
#: core/models.py:769 #: core/models.py:750
msgid "Either user or team must be set, not both." msgid "Either user or team must be set, not both."
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux." msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
#: core/models.py:786 #: core/models.py:767
msgid "Create rooms" msgid "Create rooms"
msgstr "Créer des salles" msgstr "Créer des salles"
#: core/models.py:787 #: core/models.py:768
msgid "List rooms" msgid "List rooms"
msgstr "Lister les salles" msgstr "Lister les salles"
#: core/models.py:788 #: core/models.py:769
msgid "Retrieve room details" msgid "Retrieve room details"
msgstr "Afficher les détails d’une salle" msgstr "Afficher les détails d’une salle"
#: core/models.py:789 #: core/models.py:770
msgid "Update rooms" msgid "Update rooms"
msgstr "Mettre à jour les salles" msgstr "Mettre à jour les salles"
#: core/models.py:790 #: core/models.py:771
msgid "Delete rooms" msgid "Delete rooms"
msgstr "Supprimer les salles" msgstr "Supprimer les salles"
#: core/models.py:803 #: core/models.py:784
msgid "Application name" msgid "Application name"
msgstr "Nom de l’application" msgstr "Nom de l’application"
#: core/models.py:804 #: core/models.py:785
msgid "Descriptive name for this application." msgid "Descriptive name for this application."
msgstr "Nom descriptif de cette application." msgstr "Nom descriptif de cette application."
#: core/models.py:814 #: core/models.py:795
msgid "Hashed on Save. Copy it now if this is a new secret." msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "" msgstr ""
"Haché lors de l’enregistrement. Copiez-le maintenant s’il s’agit d’un " "Haché lors de l’enregistrement. Copiez-le maintenant s’il s’agit d’un "
"nouveau secret." "nouveau secret."
#: core/models.py:825 #: core/models.py:806
msgid "Application" msgid "Application"
msgstr "Application" msgstr "Application"
#: core/models.py:826 #: core/models.py:807
msgid "Applications" msgid "Applications"
msgstr "Applications" msgstr "Applications"
#: core/models.py:849 #: core/models.py:830
msgid "Enter a valid domain" msgid "Enter a valid domain"
msgstr "Saisissez un domaine valide" msgstr "Saisissez un domaine valide"
#: core/models.py:852 #: core/models.py:833
msgid "Domain" msgid "Domain"
msgstr "Domaine" msgstr "Domaine"
#: core/models.py:853 #: core/models.py:834
msgid "Email domain this application can act on behalf of." msgid "Email domain this application can act on behalf of."
msgstr "Domaine de messagerie au nom duquel cette application peut agir." msgstr "Domaine de messagerie au nom duquel cette application peut agir."
#: core/models.py:865 #: core/models.py:846
msgid "Application domain" msgid "Application domain"
msgstr "Domaine d’application" msgstr "Domaine d’application"
#: core/models.py:866 #: core/models.py:847
msgid "Application domains" msgid "Application domains"
msgstr "Domaines d’application" msgstr "Domaines d’application"
#: core/models.py:884 #: core/models.py:865
msgid "Pending" msgid "Pending"
msgstr "En attente" msgstr "En attente"
#: core/models.py:885 #: core/models.py:866
msgid "Analyzing" msgid "Analyzing"
msgstr "" msgstr ""
#: core/models.py:892 #: core/models.py:873
msgid "Ready" msgid "Ready"
msgstr "Prêt" msgstr "Prêt"
#: core/models.py:898 #: core/models.py:879
msgid "Background image" msgid "Background image"
msgstr "Image de fond" msgstr "Image de fond"
#: core/models.py:910 #: core/models.py:891
msgid "title" msgid "title"
msgstr "Titre" msgstr "Titre"
#: core/models.py:934 #: core/models.py:915
msgid "Malware detection info when the analysis status is unsafe." msgid "Malware detection info when the analysis status is unsafe."
msgstr "" msgstr ""
"Information concernant la détection de Malware cand le statut n'est pas sain" "Information concernant la détection de Malware cand le statut n'est pas sain"
#: core/models.py:939 #: core/models.py:920
msgid "File" msgid "File"
msgstr "Fichier" msgstr "Fichier"
#: core/models.py:940 #: core/models.py:921
msgid "Files" msgid "Files"
msgstr "Fichiers" msgstr "Fichiers"
#: core/models.py:1060 #: core/models.py:1041
#, fuzzy #, fuzzy
#| msgid "This user is already in this recording." #| msgid "This user is already in this recording."
msgid "This file is already hard deleted." msgid "This file is already hard deleted."
msgstr "Ce fichier a été supprimé." msgstr "Ce fichier a été supprimé."
#: core/models.py:1070 #: core/models.py:1051
msgid "To hard delete a file, it must first be soft deleted." msgid "To hard delete a file, it must first be soft deleted."
msgstr "" msgstr ""
"Pour supprimer définitivement un fichier il doit d'abord avoir été marqué " "Pour supprimer définitivement un fichier il doit d'abord avoir été marqué "
"comme supprimé (soft delete)" "comme supprimé (soft delete)"
#: core/recording/event/notification.py:124 #: core/recording/event/notification.py:123
msgid "Your recording is ready" msgid "Your recording is ready"
msgstr "Votre enregistrement est prêt" msgstr "Votre enregistrement est prêt"
#: core/recording/event/notification.py:195 #: core/recording/event/notification.py:194
msgid "Transcription" msgid "Transcription"
msgstr "Transcription" msgstr "Transcription"
#: core/recording/event/notification.py:206 #: core/recording/event/notification.py:204
#, python-brace-format #, python-brace-format
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}" msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
msgstr "Réunion \"{room}\" du {room_recording_date} à {room_recording_time}" msgstr "Réunion \"{room}\" du {room_recording_date} à {room_recording_time}"
@@ -554,25 +538,25 @@ msgstr "Réunion \"{room}\" du {room_recording_date} à {room_recording_time}"
msgid "Video call in progress: {sender.email} is waiting for you to connect" msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Appel vidéo en cours : {sender.email} attend que vous vous connectiez" msgstr "Appel vidéo en cours : {sender.email} attend que vous vous connectiez"
#: core/templates/mail/html/invitation.html:151 #: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:151 #: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:4 #: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/screen_recording.txt:4 #: core/templates/mail/text/screen_recording.txt:3
msgid "Logo email" msgid "Logo email"
msgstr "Logo email" msgstr "Logo email"
#: core/templates/mail/html/invitation.html:181 #: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:6 #: core/templates/mail/text/invitation.txt:5
msgid "invites you to join an ongoing video call" msgid "invites you to join an ongoing video call"
msgstr "vous invite à rejoindre un appel vidéo en cours" msgstr "vous invite à rejoindre un appel vidéo en cours"
#: core/templates/mail/html/invitation.html:192 #: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:8 #: core/templates/mail/text/invitation.txt:7
msgid "JOIN THE CALL" msgid "JOIN THE CALL"
msgstr "REJOINDRE L'APPEL" msgstr "REJOINDRE L'APPEL"
#: core/templates/mail/html/invitation.html:219 #: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:14 #: core/templates/mail/text/invitation.txt:13
msgid "" msgid ""
"If you can't click the button, copy and paste the URL into your browser to " "If you can't click the button, copy and paste the URL into your browser to "
"join the call." "join the call."
@@ -580,47 +564,41 @@ msgstr ""
"Si vous ne pouvez pas cliquer sur le bouton, copiez et collez l'URL dans " "Si vous ne pouvez pas cliquer sur le bouton, copiez et collez l'URL dans "
"votre navigateur pour rejoindre l'appel." "votre navigateur pour rejoindre l'appel."
#: core/templates/mail/html/invitation.html:227 #: core/templates/mail/html/invitation.html:235
#: core/templates/mail/text/invitation.txt:16 #: core/templates/mail/text/invitation.txt:15
msgid "Tips for a better experience:" msgid "Tips for a better experience:"
msgstr "Conseils pour une meilleure expérience :" msgstr "Conseils pour une meilleure expérience :"
#: core/templates/mail/html/invitation.html:229 #: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:18 #: core/templates/mail/text/invitation.txt:17
msgid "Use Chrome or Firefox for better call quality" msgid "Use Chrome or Firefox for better call quality"
msgstr "Utilisez Chrome ou Firefox pour une meilleure qualité d'appel" msgstr "Utilisez Chrome ou Firefox pour une meilleure qualité d'appel"
#: core/templates/mail/html/invitation.html:230 #: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:19 #: core/templates/mail/text/invitation.txt:18
msgid "Test your microphone and camera before joining" msgid "Test your microphone and camera before joining"
msgstr "Testez votre microphone et votre caméra avant de rejoindre" msgstr "Testez votre microphone et votre caméra avant de rejoindre"
#: core/templates/mail/html/invitation.html:231 #: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:20 #: core/templates/mail/text/invitation.txt:19
msgid "Make sure you have a stable internet connection" msgid "Make sure you have a stable internet connection"
msgstr "Assurez-vous d'avoir une connexion Internet stable" msgstr "Assurez-vous d'avoir une connexion Internet stable"
#: core/templates/mail/html/invitation.html:240 #: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:237 #: core/templates/mail/html/screen_recording.html:245
#: core/templates/mail/text/invitation.txt:22 #: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:24 #: core/templates/mail/text/screen_recording.txt:23
#, python-format #, python-format
msgid " Thank you for using %(brandname)s. " msgid " Thank you for using %(brandname)s. "
msgstr " Merci d'utiliser %(brandname)s. " msgstr " Merci d'utiliser %(brandname)s. "
#: core/templates/mail/html/invitation.html:271 #: core/templates/mail/html/screen_recording.html:188
#, python-format #: core/templates/mail/text/screen_recording.txt:6
msgid ""
"This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
#: core/templates/mail/html/screen_recording.html:180
#: core/templates/mail/text/screen_recording.txt:7
msgid "Your recording is ready!" msgid "Your recording is ready!"
msgstr "Votre enregistrement est prêt !" msgstr "Votre enregistrement est prêt !"
#: core/templates/mail/html/screen_recording.html:187 #: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:9 #: core/templates/mail/text/screen_recording.txt:8
#, python-format #, python-format
msgid "" msgid ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at " " Your recording of \"%(room_name)s\" on %(recording_date)s at "
@@ -629,14 +607,14 @@ msgstr ""
" Votre enregistrement de \"%(room_name)s\" du %(recording_date)s à " " Votre enregistrement de \"%(room_name)s\" du %(recording_date)s à "
"%(recording_time)s est maintenant prêt à être téléchargé. " "%(recording_time)s est maintenant prêt à être téléchargé. "
#: core/templates/mail/html/screen_recording.html:187 #: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:9 #: core/templates/mail/text/screen_recording.txt:8
#, python-format #, python-format
msgid " The recording will expire in %(days)s days. " msgid " The recording will expire in %(days)s days. "
msgstr " L'enregistrement expirera dans %(days)s jours. " msgstr " L'enregistrement expirera dans %(days)s jours. "
#: core/templates/mail/html/screen_recording.html:192 #: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/text/screen_recording.txt:10 #: core/templates/mail/text/screen_recording.txt:9
msgid "" msgid ""
" Sharing the recording via link is not yet available. Only organizers can " " Sharing the recording via link is not yet available. Only organizers can "
"download it. " "download it. "
@@ -644,33 +622,33 @@ msgstr ""
"Le partage de l'enregistrement via lien n'est pas encore disponible. Seuls " "Le partage de l'enregistrement via lien n'est pas encore disponible. Seuls "
"les organisateurs peuvent le télécharger." "les organisateurs peuvent le télécharger."
#: core/templates/mail/html/screen_recording.html:198 #: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:12 #: core/templates/mail/text/screen_recording.txt:11
msgid "To keep this recording permanently:" msgid "To keep this recording permanently:"
msgstr "Pour conserver cet enregistrement de façon permanente :" msgstr "Pour conserver cet enregistrement de façon permanente :"
#: core/templates/mail/html/screen_recording.html:200 #: core/templates/mail/html/screen_recording.html:208
#, python-format #, python-format
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below " msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" ci-dessous " msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" ci-dessous "
#: core/templates/mail/html/screen_recording.html:201 #: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:15 #: core/templates/mail/text/screen_recording.txt:14
msgid "Use the \"Download\" button in the interface " msgid "Use the \"Download\" button in the interface "
msgstr "Utilisez le bouton \"Télécharger\" dans l'interface " msgstr "Utilisez le bouton \"Télécharger\" dans l'interface "
#: core/templates/mail/html/screen_recording.html:202 #: core/templates/mail/html/screen_recording.html:210
#: core/templates/mail/text/screen_recording.txt:16 #: core/templates/mail/text/screen_recording.txt:15
msgid "Save the file to your preferred location" msgid "Save the file to your preferred location"
msgstr "Enregistrez le fichier à l'emplacement de votre choix" msgstr "Enregistrez le fichier à l'emplacement de votre choix"
#: core/templates/mail/html/screen_recording.html:213 #: core/templates/mail/html/screen_recording.html:221
#: core/templates/mail/text/screen_recording.txt:18 #: core/templates/mail/text/screen_recording.txt:17
msgid "Open" msgid "Open"
msgstr "Ouvrir" msgstr "Ouvrir"
#: core/templates/mail/html/screen_recording.html:222 #: core/templates/mail/html/screen_recording.html:230
#: core/templates/mail/text/screen_recording.txt:20 #: core/templates/mail/text/screen_recording.txt:19
#, python-format #, python-format
msgid "" msgid ""
" If you have any questions or need assistance, please contact our support " " If you have any questions or need assistance, please contact our support "
@@ -679,33 +657,28 @@ msgstr ""
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre " " Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
"équipe d'assistance à %(support_email)s. " "équipe d'assistance à %(support_email)s. "
#: core/templates/mail/text/invitation.txt:24 #: core/templates/mail/text/screen_recording.txt:13
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: core/templates/mail/text/screen_recording.txt:14
#, fuzzy, python-format #, fuzzy, python-format
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below " #| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgid "Click the \"Open [%(link)s]\" link below " msgid "Click the \"Open [%(link)s]\" link below "
msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" ci-dessous " msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" ci-dessous "
#: meet/settings.py:238 #: meet/settings.py:228
msgid "English" msgid "English"
msgstr "Anglais" msgstr "Anglais"
#: meet/settings.py:239 #: meet/settings.py:229
msgid "French" msgid "French"
msgstr "Français" msgstr "Français"
#: meet/settings.py:240 #: meet/settings.py:230
msgid "Dutch" msgid "Dutch"
msgstr "Néerlandais" msgstr "Néerlandais"
#: meet/settings.py:241 #: meet/settings.py:231
msgid "German" msgid "German"
msgstr "Allemand" msgstr "Allemand"
#: meet/settings.py:242 #: meet/settings.py:233
msgid "Spanish" msgid "Spanish"
msgstr "Espagnol" msgstr "Espagnol"
+160 -187
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-02 22:52+0000\n" "POT-Creation-Date: 2026-07-02 10:47+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -51,11 +51,11 @@ msgstr ""
msgid "File preview" msgid "File preview"
msgstr "" msgstr ""
#: core/admin.py:300 core/admin.py:450 #: core/admin.py:300 core/admin.py:443
msgid "No owner" msgid "No owner"
msgstr "Geen eigenaar" msgstr "Geen eigenaar"
#: core/admin.py:303 core/admin.py:453 #: core/admin.py:303 core/admin.py:446
msgid "Multiple owners" msgid "Multiple owners"
msgstr "Meerdere eigenaren" msgstr "Meerdere eigenaren"
@@ -98,11 +98,11 @@ msgstr "%(count)s opname(s) succesvol gemarkeerd als 'Mislukt bij stoppen'."
msgid "Skipped %(count)s recording(s) with an ineligible status." msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "%(count)s opname(s) met een niet-toegestane status overgeslagen." msgstr "%(count)s opname(s) met een niet-toegestane status overgeslagen."
#: core/admin.py:517 #: core/admin.py:510
msgid "No scopes" msgid "No scopes"
msgstr "Geen scopes" msgstr "Geen scopes"
#: core/admin.py:519 #: core/admin.py:512
msgid "Scopes" msgid "Scopes"
msgstr "Scopes" msgstr "Scopes"
@@ -110,200 +110,184 @@ msgstr "Scopes"
msgid "Creator is me" msgid "Creator is me"
msgstr "Maker ben ik" msgstr "Maker ben ik"
#: core/api/serializers.py:108 #: core/api/serializers.py:89
msgid "You must be administrator or owner of a room to add accesses to it." msgid "You must be administrator or owner of a room to add accesses to it."
msgstr "" msgstr ""
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen." "Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
#: core/api/serializers.py:560 #: core/api/serializers.py:534
msgid "This file extension is not allowed." msgid "This file extension is not allowed."
msgstr "Deze bestandsextensie is niet toegestaan." msgstr "Deze bestandsextensie is niet toegestaan."
#: core/api/viewsets.py:1268 #: core/api/viewsets.py:1222
msgid "You have reached the maximum number of files for this type." msgid "You have reached the maximum number of files for this type."
msgstr "Het maximale aantal bestanden voor dit type is bereikt." msgstr "Het maximale aantal bestanden voor dit type is bereikt."
#: core/models.py:38 #: core/models.py:37
msgid "Member" msgid "Member"
msgstr "Lid" msgstr "Lid"
#: core/models.py:39 #: core/models.py:38
msgid "Administrator" msgid "Administrator"
msgstr "Beheerder" msgstr "Beheerder"
#: core/models.py:40 #: core/models.py:39
msgid "Owner" msgid "Owner"
msgstr "Eigenaar" msgstr "Eigenaar"
#: core/models.py:56 #: core/models.py:55
msgid "Initiated" msgid "Initiated"
msgstr "Gestart" msgstr "Gestart"
#: core/models.py:57 #: core/models.py:56
msgid "Active" msgid "Active"
msgstr "Actief" msgstr "Actief"
#: core/models.py:58 #: core/models.py:57
msgid "Stopped" msgid "Stopped"
msgstr "Gestopt" msgstr "Gestopt"
#: core/models.py:59 #: core/models.py:58
msgid "Saved" msgid "Saved"
msgstr "Opgeslagen" msgstr "Opgeslagen"
#: core/models.py:60 #: core/models.py:59
msgid "Aborted" msgid "Aborted"
msgstr "Afgebroken" msgstr "Afgebroken"
#: core/models.py:61 #: core/models.py:60
msgid "Failed to Start" msgid "Failed to Start"
msgstr "Starten mislukt" msgstr "Starten mislukt"
#: core/models.py:62 #: core/models.py:61
msgid "Failed to Stop" msgid "Failed to Stop"
msgstr "Stoppen mislukt" msgstr "Stoppen mislukt"
#: core/models.py:63 #: core/models.py:62
msgid "Notification succeeded" msgid "Notification succeeded"
msgstr "Notificatie geslaagd" msgstr "Notificatie geslaagd"
#: core/models.py:66 #: core/models.py:65
msgid "External process successful" msgid "External process successful"
msgstr "Externe procedure succesvol" msgstr "Externe procedure succesvol"
#: core/models.py:68 #: core/models.py:67
msgid "External process failed" msgid "External process failed"
msgstr "Het externe proces is mislukt." msgstr "Het externe proces is mislukt."
#: core/models.py:97 #: core/models.py:96
msgid "SCREEN_RECORDING" msgid "SCREEN_RECORDING"
msgstr "SCHERM_OPNAME" msgstr "SCHERM_OPNAME"
#: core/models.py:98 #: core/models.py:97
msgid "TRANSCRIPT" msgid "TRANSCRIPT"
msgstr "TRANSCRIPT" msgstr "TRANSCRIPT"
#: core/models.py:104 #: core/models.py:103
msgid "Public Access" msgid "Public Access"
msgstr "Openbare toegang" msgstr "Openbare toegang"
#: core/models.py:105 #: core/models.py:104
msgid "Trusted Access" msgid "Trusted Access"
msgstr "Vertrouwde toegang" msgstr "Vertrouwde toegang"
#: core/models.py:106 #: core/models.py:105
msgid "Restricted Access" msgid "Restricted Access"
msgstr "Beperkte toegang" msgstr "Beperkte toegang"
#: core/models.py:118 #: core/models.py:117
msgid "id" msgid "id"
msgstr "id" msgstr "id"
#: core/models.py:119 #: core/models.py:118
msgid "primary key for the record as UUID" msgid "primary key for the record as UUID"
msgstr "primaire sleutel voor het record als UUID" msgstr "primaire sleutel voor het record als UUID"
#: core/models.py:125 #: core/models.py:124
msgid "created on" msgid "created on"
msgstr "aangemaakt op" msgstr "aangemaakt op"
#: core/models.py:126 #: core/models.py:125
msgid "date and time at which a record was created" msgid "date and time at which a record was created"
msgstr "datum en tijd waarop een record werd aangemaakt" msgstr "datum en tijd waarop een record werd aangemaakt"
#: core/models.py:131 #: core/models.py:130
msgid "updated on" msgid "updated on"
msgstr "bijgewerkt op" msgstr "bijgewerkt op"
#: core/models.py:132 #: core/models.py:131
msgid "date and time at which a record was last updated" msgid "date and time at which a record was last updated"
msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt" msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt"
#: core/models.py:150 #: core/models.py:151
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
msgstr ""
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
"_ tekens bevatten."
#: core/models.py:157
msgid "sub" msgid "sub"
msgstr "sub" msgstr "sub"
#: core/models.py:152 #: core/models.py:159
msgid "" msgid ""
"Optional for pending users; required upon account activation. 255 characters " "Optional for pending users; required upon account activation. 255 characters "
"or fewer. Printable ASCII characters only." "or fewer. Letters, numbers, and @/./+/-/_ characters only."
msgstr "" msgstr ""
"Optioneel voor gebruikers in afwachting; verplicht bij het activeren van het " "Optioneel voor gebruikers in afwachting; vereist bij accountactivering. "
"account. Maximaal 255 tekens. Alleen afdrukbare ASCII-tekens." "Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan."
#: core/validators.py:22 #: core/models.py:168
msgid "Enter a valid sub. This value should be printable ASCII only."
msgstr "Voer een geldige sub in. Deze waarde mag alleen afdrukbare ASCII-tekens bevatten."
#: core/models.py:161
msgid "identity email address" msgid "identity email address"
msgstr "identiteit e-mailadres" msgstr "identiteit e-mailadres"
#: core/models.py:166 #: core/models.py:173
msgid "admin email address" msgid "admin email address"
msgstr "beheerder e-mailadres" msgstr "beheerder e-mailadres"
#: core/models.py:168 #: core/models.py:175
msgid "full name" msgid "full name"
msgstr "volledige naam" msgstr "volledige naam"
#: core/models.py:170 #: core/models.py:177
msgid "short name" msgid "short name"
msgstr "korte naam" msgstr "korte naam"
#: core/models.py:176 #: core/models.py:183
msgid "language" msgid "language"
msgstr "taal" msgstr "taal"
#: core/models.py:177 #: core/models.py:184
msgid "The language in which the user wants to see the interface." msgid "The language in which the user wants to see the interface."
msgstr "De taal waarin de gebruiker de interface wil zien." msgstr "De taal waarin de gebruiker de interface wil zien."
#: core/models.py:183 #: core/models.py:190
msgid "The timezone in which the user wants to see times." msgid "The timezone in which the user wants to see times."
msgstr "De tijdzone waarin de gebruiker tijden wil zien." msgstr "De tijdzone waarin de gebruiker tijden wil zien."
#: core/models.py:190 #: core/models.py:193
msgid "default room access level"
msgstr ""
#: core/models.py:192
msgid ""
"Access level applied by default to new rooms created by this user. When "
"empty, the instance default is used."
msgstr ""
#: core/models.py:199
#, fuzzy
#| msgid "Visio room configuration"
msgid "default room configuration"
msgstr "Visio-ruimteconfiguratie"
#: core/models.py:201
msgid "Configurations applied by default to new rooms created by this user."
msgstr ""
#: core/models.py:205
msgid "device" msgid "device"
msgstr "apparaat" msgstr "apparaat"
#: core/models.py:207 #: core/models.py:195
msgid "Whether the user is a device or a real user." msgid "Whether the user is a device or a real user."
msgstr "Of de gebruiker een apparaat is of een echte gebruiker." msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
#: core/models.py:210 #: core/models.py:198
msgid "staff status" msgid "staff status"
msgstr "personeelsstatus" msgstr "personeelsstatus"
#: core/models.py:212 #: core/models.py:200
msgid "Whether the user can log into this admin site." msgid "Whether the user can log into this admin site."
msgstr "Of de gebruiker kan inloggen op deze beheersite." msgstr "Of de gebruiker kan inloggen op deze beheersite."
#: core/models.py:215 #: core/models.py:203
msgid "active" msgid "active"
msgstr "actief" msgstr "actief"
#: core/models.py:218 #: core/models.py:206
msgid "" msgid ""
"Whether this user should be treated as active. Unselect this instead of " "Whether this user should be treated as active. Unselect this instead of "
"deleting accounts." "deleting accounts."
@@ -311,64 +295,64 @@ msgstr ""
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in " "Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
"plaats van accounts te verwijderen." "plaats van accounts te verwijderen."
#: core/models.py:231 #: core/models.py:219
msgid "user" msgid "user"
msgstr "gebruiker" msgstr "gebruiker"
#: core/models.py:232 #: core/models.py:220
msgid "users" msgid "users"
msgstr "gebruikers" msgstr "gebruikers"
#: core/models.py:298 #: core/models.py:286
msgid "Resource" msgid "Resource"
msgstr "Bron" msgstr "Bron"
#: core/models.py:299 #: core/models.py:287
msgid "Resources" msgid "Resources"
msgstr "Bronnen" msgstr "Bronnen"
#: core/models.py:357 #: core/models.py:345
msgid "Resource access" msgid "Resource access"
msgstr "Brontoegang" msgstr "Brontoegang"
#: core/models.py:358 #: core/models.py:346
msgid "Resource accesses" msgid "Resource accesses"
msgstr "Brontoegangsrechten" msgstr "Brontoegangsrechten"
#: core/models.py:364 #: core/models.py:352
msgid "Resource access with this User and Resource already exists." msgid "Resource access with this User and Resource already exists."
msgstr "Brontoegang met deze gebruiker en bron bestaat al." msgstr "Brontoegang met deze gebruiker en bron bestaat al."
#: core/models.py:421 #: core/models.py:409
msgid "Visio room configuration" msgid "Visio room configuration"
msgstr "Visio-ruimteconfiguratie" msgstr "Visio-ruimteconfiguratie"
#: core/models.py:422 #: core/models.py:410
msgid "Values for Visio parameters to configure the room." msgid "Values for Visio parameters to configure the room."
msgstr "Waarden voor Visio-parameters om de ruimte te configureren." msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
#: core/models.py:429 #: core/models.py:417
msgid "Room PIN code" msgid "Room PIN code"
msgstr "Pincode van de kamer" msgstr "Pincode van de kamer"
#: core/models.py:430 #: core/models.py:418
msgid "Unique n-digit code that identifies this room in telephony mode." msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "" msgstr ""
"Unieke n-cijferige code die deze kamer identificeert in telefonie-modus." "Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
#: core/models.py:436 core/models.py:597 #: core/models.py:424 core/models.py:578
msgid "Room" msgid "Room"
msgstr "Ruimte" msgstr "Ruimte"
#: core/models.py:437 #: core/models.py:425
msgid "Rooms" msgid "Rooms"
msgstr "Ruimtes" msgstr "Ruimtes"
#: core/models.py:608 #: core/models.py:589
msgid "Worker ID" msgid "Worker ID"
msgstr "Worker ID" msgstr "Worker ID"
#: core/models.py:610 #: core/models.py:591
msgid "" msgid ""
"Enter an identifier for the worker recording.This ID is retained even when " "Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking." "the worker stops, allowing for easy tracking."
@@ -376,152 +360,152 @@ msgstr ""
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, " "Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is." "zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
#: core/models.py:618 #: core/models.py:599
msgid "Recording mode" msgid "Recording mode"
msgstr "Opnamemodus" msgstr "Opnamemodus"
#: core/models.py:619 #: core/models.py:600
msgid "Defines the mode of recording being called." msgid "Defines the mode of recording being called."
msgstr "Definieert de modus van opname die wordt aangeroepen." msgstr "Definieert de modus van opname die wordt aangeroepen."
#: core/models.py:624 core/models.py:625 #: core/models.py:605 core/models.py:606
msgid "Recording options" msgid "Recording options"
msgstr "Opnameopties" msgstr "Opnameopties"
#: core/models.py:632 #: core/models.py:613
msgid "External Process ID" msgid "External Process ID"
msgstr "Externe proces-ID" msgstr "Externe proces-ID"
#: core/models.py:633 #: core/models.py:614
msgid "ID of the external process associated with the recording." msgid "ID of the external process associated with the recording."
msgstr "ID van het externe proces dat aan de opname is gekoppeld." msgstr "ID van het externe proces dat aan de opname is gekoppeld."
#: core/models.py:639 #: core/models.py:620
msgid "Recording" msgid "Recording"
msgstr "Opname" msgstr "Opname"
#: core/models.py:640 #: core/models.py:621
msgid "Recordings" msgid "Recordings"
msgstr "Opnames" msgstr "Opnames"
#: core/models.py:750 #: core/models.py:731
msgid "Recording/user relation" msgid "Recording/user relation"
msgstr "Opname/gebruiker-relatie" msgstr "Opname/gebruiker-relatie"
#: core/models.py:751 #: core/models.py:732
msgid "Recording/user relations" msgid "Recording/user relations"
msgstr "Opname/gebruiker-relaties" msgstr "Opname/gebruiker-relaties"
#: core/models.py:757 #: core/models.py:738
msgid "This user is already in this recording." msgid "This user is already in this recording."
msgstr "Deze gebruiker is al in deze opname." msgstr "Deze gebruiker is al in deze opname."
#: core/models.py:763 #: core/models.py:744
msgid "This team is already in this recording." msgid "This team is already in this recording."
msgstr "Dit team is al in deze opname." msgstr "Dit team is al in deze opname."
#: core/models.py:769 #: core/models.py:750
msgid "Either user or team must be set, not both." msgid "Either user or team must be set, not both."
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide." msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
#: core/models.py:786 #: core/models.py:767
msgid "Create rooms" msgid "Create rooms"
msgstr "Ruimtes aanmaken" msgstr "Ruimtes aanmaken"
#: core/models.py:787 #: core/models.py:768
msgid "List rooms" msgid "List rooms"
msgstr "Ruimtes weergeven" msgstr "Ruimtes weergeven"
#: core/models.py:788 #: core/models.py:769
msgid "Retrieve room details" msgid "Retrieve room details"
msgstr "Details van een ruimte ophalen" msgstr "Details van een ruimte ophalen"
#: core/models.py:789 #: core/models.py:770
msgid "Update rooms" msgid "Update rooms"
msgstr "Ruimtes bijwerken" msgstr "Ruimtes bijwerken"
#: core/models.py:790 #: core/models.py:771
msgid "Delete rooms" msgid "Delete rooms"
msgstr "Ruimtes verwijderen" msgstr "Ruimtes verwijderen"
#: core/models.py:803 #: core/models.py:784
msgid "Application name" msgid "Application name"
msgstr "Naam van de applicatie" msgstr "Naam van de applicatie"
#: core/models.py:804 #: core/models.py:785
msgid "Descriptive name for this application." msgid "Descriptive name for this application."
msgstr "Beschrijvende naam voor deze applicatie." msgstr "Beschrijvende naam voor deze applicatie."
#: core/models.py:814 #: core/models.py:795
msgid "Hashed on Save. Copy it now if this is a new secret." msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "" msgstr ""
"Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is." "Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
#: core/models.py:825 #: core/models.py:806
msgid "Application" msgid "Application"
msgstr "Applicatie" msgstr "Applicatie"
#: core/models.py:826 #: core/models.py:807
msgid "Applications" msgid "Applications"
msgstr "Applicaties" msgstr "Applicaties"
#: core/models.py:849 #: core/models.py:830
msgid "Enter a valid domain" msgid "Enter a valid domain"
msgstr "Voer een geldig domein in" msgstr "Voer een geldig domein in"
#: core/models.py:852 #: core/models.py:833
msgid "Domain" msgid "Domain"
msgstr "Domein" msgstr "Domein"
#: core/models.py:853 #: core/models.py:834
msgid "Email domain this application can act on behalf of." msgid "Email domain this application can act on behalf of."
msgstr "E-maildomein namens welke deze applicatie kan handelen." msgstr "E-maildomein namens welke deze applicatie kan handelen."
#: core/models.py:865 #: core/models.py:846
msgid "Application domain" msgid "Application domain"
msgstr "Applicatiedomein" msgstr "Applicatiedomein"
#: core/models.py:866 #: core/models.py:847
msgid "Application domains" msgid "Application domains"
msgstr "Applicatiedomeinen" msgstr "Applicatiedomeinen"
#: core/models.py:884 #: core/models.py:865
msgid "Pending" msgid "Pending"
msgstr "In afwachting" msgstr "In afwachting"
#: core/models.py:885 #: core/models.py:866
msgid "Analyzing" msgid "Analyzing"
msgstr "" msgstr ""
#: core/models.py:892 #: core/models.py:873
msgid "Ready" msgid "Ready"
msgstr "Klaar" msgstr "Klaar"
#: core/models.py:898 #: core/models.py:879
msgid "Background image" msgid "Background image"
msgstr "Achtergrondafbeelding" msgstr "Achtergrondafbeelding"
#: core/models.py:910 #: core/models.py:891
msgid "title" msgid "title"
msgstr "Titel" msgstr "Titel"
#: core/models.py:934 #: core/models.py:915
msgid "Malware detection info when the analysis status is unsafe." msgid "Malware detection info when the analysis status is unsafe."
msgstr "Informatie over malwaredetectie wanneer de analysestatus onveilig is." msgstr "Informatie over malwaredetectie wanneer de analysestatus onveilig is."
#: core/models.py:939 #: core/models.py:920
msgid "File" msgid "File"
msgstr "Bestand" msgstr "Bestand"
#: core/models.py:940 #: core/models.py:921
msgid "Files" msgid "Files"
msgstr "Bestanden" msgstr "Bestanden"
#: core/models.py:1060 #: core/models.py:1041
msgid "This file is already hard deleted." msgid "This file is already hard deleted."
msgstr "Dit bestand is al definitief verwijderd." msgstr "Dit bestand is al definitief verwijderd."
#: core/models.py:1070 #: core/models.py:1051
#, fuzzy #, fuzzy
#| msgid "To hard delete a file, it must first be soft deleted." #| msgid "To hard delete a file, it must first be soft deleted."
msgid "To hard delete a file, it must first be soft deleted." msgid "To hard delete a file, it must first be soft deleted."
@@ -529,15 +513,15 @@ msgstr ""
"Om een bestand definitief te verwijderen, moet het eerst zacht verwijderd " "Om een bestand definitief te verwijderen, moet het eerst zacht verwijderd "
"zijn." "zijn."
#: core/recording/event/notification.py:124 #: core/recording/event/notification.py:123
msgid "Your recording is ready" msgid "Your recording is ready"
msgstr "Je opname is klaar" msgstr "Je opname is klaar"
#: core/recording/event/notification.py:195 #: core/recording/event/notification.py:194
msgid "Transcription" msgid "Transcription"
msgstr "Transcriptie" msgstr "Transcriptie"
#: core/recording/event/notification.py:206 #: core/recording/event/notification.py:204
#, python-brace-format #, python-brace-format
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}" msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
msgstr "" msgstr ""
@@ -548,25 +532,25 @@ msgstr ""
msgid "Video call in progress: {sender.email} is waiting for you to connect" msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Video-oproep bezig: {sender.email} wacht op je verbinding" msgstr "Video-oproep bezig: {sender.email} wacht op je verbinding"
#: core/templates/mail/html/invitation.html:151 #: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:151 #: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:4 #: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/screen_recording.txt:4 #: core/templates/mail/text/screen_recording.txt:3
msgid "Logo email" msgid "Logo email"
msgstr "Logo e-mail" msgstr "Logo e-mail"
#: core/templates/mail/html/invitation.html:181 #: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:6 #: core/templates/mail/text/invitation.txt:5
msgid "invites you to join an ongoing video call" msgid "invites you to join an ongoing video call"
msgstr "nodigt je uit om deel te nemen aan een lopende video-oproep" msgstr "nodigt je uit om deel te nemen aan een lopende video-oproep"
#: core/templates/mail/html/invitation.html:192 #: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:8 #: core/templates/mail/text/invitation.txt:7
msgid "JOIN THE CALL" msgid "JOIN THE CALL"
msgstr "NEEM DEEL AAN DE OPROEP" msgstr "NEEM DEEL AAN DE OPROEP"
#: core/templates/mail/html/invitation.html:219 #: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:14 #: core/templates/mail/text/invitation.txt:13
msgid "" msgid ""
"If you can't click the button, copy and paste the URL into your browser to " "If you can't click the button, copy and paste the URL into your browser to "
"join the call." "join the call."
@@ -574,47 +558,41 @@ msgstr ""
"Als je niet op de knop kunt klikken, kopieer en plak dan de URL in je " "Als je niet op de knop kunt klikken, kopieer en plak dan de URL in je "
"browser om deel te nemen aan de oproep." "browser om deel te nemen aan de oproep."
#: core/templates/mail/html/invitation.html:227 #: core/templates/mail/html/invitation.html:235
#: core/templates/mail/text/invitation.txt:16 #: core/templates/mail/text/invitation.txt:15
msgid "Tips for a better experience:" msgid "Tips for a better experience:"
msgstr "Tips voor een betere ervaring:" msgstr "Tips voor een betere ervaring:"
#: core/templates/mail/html/invitation.html:229 #: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:18 #: core/templates/mail/text/invitation.txt:17
msgid "Use Chrome or Firefox for better call quality" msgid "Use Chrome or Firefox for better call quality"
msgstr "Gebruik Chrome of Firefox voor betere gesprekskwaliteit" msgstr "Gebruik Chrome of Firefox voor betere gesprekskwaliteit"
#: core/templates/mail/html/invitation.html:230 #: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:19 #: core/templates/mail/text/invitation.txt:18
msgid "Test your microphone and camera before joining" msgid "Test your microphone and camera before joining"
msgstr "Test je microfoon en camera voordat je deelneemt" msgstr "Test je microfoon en camera voordat je deelneemt"
#: core/templates/mail/html/invitation.html:231 #: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:20 #: core/templates/mail/text/invitation.txt:19
msgid "Make sure you have a stable internet connection" msgid "Make sure you have a stable internet connection"
msgstr "Zorg ervoor dat je een stabiele internetverbinding hebt" msgstr "Zorg ervoor dat je een stabiele internetverbinding hebt"
#: core/templates/mail/html/invitation.html:240 #: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:237 #: core/templates/mail/html/screen_recording.html:245
#: core/templates/mail/text/invitation.txt:22 #: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:24 #: core/templates/mail/text/screen_recording.txt:23
#, python-format #, python-format
msgid " Thank you for using %(brandname)s. " msgid " Thank you for using %(brandname)s. "
msgstr " Bedankt voor het gebruik van %(brandname)s. " msgstr " Bedankt voor het gebruik van %(brandname)s. "
#: core/templates/mail/html/invitation.html:271 #: core/templates/mail/html/screen_recording.html:188
#, python-format #: core/templates/mail/text/screen_recording.txt:6
msgid ""
"This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
msgstr ""
#: core/templates/mail/html/screen_recording.html:180
#: core/templates/mail/text/screen_recording.txt:7
msgid "Your recording is ready!" msgid "Your recording is ready!"
msgstr "Je opname is klaar!" msgstr "Je opname is klaar!"
#: core/templates/mail/html/screen_recording.html:187 #: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:9 #: core/templates/mail/text/screen_recording.txt:8
#, python-format #, python-format
msgid "" msgid ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at " " Your recording of \"%(room_name)s\" on %(recording_date)s at "
@@ -623,14 +601,14 @@ msgstr ""
" Je opname van \"%(room_name)s\" op %(recording_date)s om %(recording_time)s " " Je opname van \"%(room_name)s\" op %(recording_date)s om %(recording_time)s "
"is nu klaar om te downloaden. " "is nu klaar om te downloaden. "
#: core/templates/mail/html/screen_recording.html:187 #: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:9 #: core/templates/mail/text/screen_recording.txt:8
#, python-format #, python-format
msgid " The recording will expire in %(days)s days. " msgid " The recording will expire in %(days)s days. "
msgstr " De opname verloopt over %(days)s dagen. " msgstr " De opname verloopt over %(days)s dagen. "
#: core/templates/mail/html/screen_recording.html:192 #: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/text/screen_recording.txt:10 #: core/templates/mail/text/screen_recording.txt:9
msgid "" msgid ""
" Sharing the recording via link is not yet available. Only organizers can " " Sharing the recording via link is not yet available. Only organizers can "
"download it. " "download it. "
@@ -638,33 +616,33 @@ msgstr ""
"Het delen van de opname via een link is nog niet beschikbaar. Alleen " "Het delen van de opname via een link is nog niet beschikbaar. Alleen "
"organisatoren kunnen deze downloaden." "organisatoren kunnen deze downloaden."
#: core/templates/mail/html/screen_recording.html:198 #: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:12 #: core/templates/mail/text/screen_recording.txt:11
msgid "To keep this recording permanently:" msgid "To keep this recording permanently:"
msgstr "Om deze opname permanent te bewaren:" msgstr "Om deze opname permanent te bewaren:"
#: core/templates/mail/html/screen_recording.html:200 #: core/templates/mail/html/screen_recording.html:208
#, python-format #, python-format
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below " msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgstr "Klik op de \"<a href=\"%(link)s\">Openen</a>\"-link hieronder " msgstr "Klik op de \"<a href=\"%(link)s\">Openen</a>\"-link hieronder "
#: core/templates/mail/html/screen_recording.html:201 #: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:15 #: core/templates/mail/text/screen_recording.txt:14
msgid "Use the \"Download\" button in the interface " msgid "Use the \"Download\" button in the interface "
msgstr "Gebruik de \"Download\"-knop in de interface " msgstr "Gebruik de \"Download\"-knop in de interface "
#: core/templates/mail/html/screen_recording.html:202 #: core/templates/mail/html/screen_recording.html:210
#: core/templates/mail/text/screen_recording.txt:16 #: core/templates/mail/text/screen_recording.txt:15
msgid "Save the file to your preferred location" msgid "Save the file to your preferred location"
msgstr "Sla het bestand op naar je gewenste locatie" msgstr "Sla het bestand op naar je gewenste locatie"
#: core/templates/mail/html/screen_recording.html:213 #: core/templates/mail/html/screen_recording.html:221
#: core/templates/mail/text/screen_recording.txt:18 #: core/templates/mail/text/screen_recording.txt:17
msgid "Open" msgid "Open"
msgstr "Openen" msgstr "Openen"
#: core/templates/mail/html/screen_recording.html:222 #: core/templates/mail/html/screen_recording.html:230
#: core/templates/mail/text/screen_recording.txt:20 #: core/templates/mail/text/screen_recording.txt:19
#, python-format #, python-format
msgid "" msgid ""
" If you have any questions or need assistance, please contact our support " " If you have any questions or need assistance, please contact our support "
@@ -673,33 +651,28 @@ msgstr ""
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support " " Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
"team via %(support_email)s. " "team via %(support_email)s. "
#: core/templates/mail/text/invitation.txt:24 #: core/templates/mail/text/screen_recording.txt:13
#, python-format
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
msgstr ""
#: core/templates/mail/text/screen_recording.txt:14
#, fuzzy, python-format #, fuzzy, python-format
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below " #| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
msgid "Click the \"Open [%(link)s]\" link below " msgid "Click the \"Open [%(link)s]\" link below "
msgstr "Klik op de \"<a href=\"%(link)s\">Openen</a>\"-link hieronder " msgstr "Klik op de \"<a href=\"%(link)s\">Openen</a>\"-link hieronder "
#: meet/settings.py:238 #: meet/settings.py:228
msgid "English" msgid "English"
msgstr "Engels" msgstr "Engels"
#: meet/settings.py:239 #: meet/settings.py:229
msgid "French" msgid "French"
msgstr "Frans" msgstr "Frans"
#: meet/settings.py:240 #: meet/settings.py:230
msgid "Dutch" msgid "Dutch"
msgstr "Nederlands" msgstr "Nederlands"
#: meet/settings.py:241 #: meet/settings.py:231
msgid "German" msgid "German"
msgstr "Duits" msgstr "Duits"
#: meet/settings.py:242 #: meet/settings.py:233
msgid "Spanish" msgid "Spanish"
msgstr "Spaans" msgstr "Spaans"
+113 -94
View File
@@ -12,7 +12,7 @@ https://docs.djangoproject.com/en/3.1/ref/settings/
# pylint: disable=too-many-lines # pylint: disable=too-many-lines
import tomllib import json
import warnings import warnings
from os import path from os import path
from socket import gethostbyname, gethostname from socket import gethostbyname, gethostname
@@ -37,34 +37,22 @@ GB = 1024 * MB
def get_release(): def get_release():
""" """
Get the current release of the application Get the current release of the application
By release, we mean the release from the version.json file à la Mozilla [1]
(if any). If this file has not been found, it defaults to "NA".
[1]
https://github.com/mozilla-services/Dockerflow/blob/master/docs/version_object.md
""" """
# Try to get the current release from the version.json file generated by the
# CI during the Docker image build
try: try:
with open(path.join(BASE_DIR, "pyproject.toml"), "rb") as pyproject: with open(path.join(BASE_DIR, "version.json"), encoding="utf8") as version:
return tomllib.load(pyproject)["project"]["version"] return json.load(version)["version"]
except (FileNotFoundError, KeyError, tomllib.TOMLDecodeError): except FileNotFoundError:
return "NA" # Default: not available return "NA" # Default: not available
class VideoCodecValue(values.Value):
"""
A video codec name, normalized to lowercase and validated against the codecs
supported by the LiveKit client, so that a typo fails at startup instead of
silently downgrading every publisher to another codec.
"""
codecs = frozenset(("vp8", "h264", "vp9", "av1"))
def to_python(self, value):
"""Normalize the codec name and ensure it is a supported one."""
codec = super().to_python(value).strip().lower()
if codec not in self.codecs:
raise ValueError(
f"Unsupported video codec {value!r}, "
f"expected one of: {', '.join(sorted(self.codecs))}."
)
return codec
class Base(Configuration): class Base(Configuration):
""" """
This is the base configuration every configuration (aka environment) should inherit from. It This is the base configuration every configuration (aka environment) should inherit from. It
@@ -141,15 +129,6 @@ class Base(Configuration):
MEDIA_BASE_URL = values.Value( MEDIA_BASE_URL = values.Value(
"", environ_name="MEDIA_BASE_URL", environ_prefix=None "", environ_name="MEDIA_BASE_URL", environ_prefix=None
) )
# Header the reverse proxy uses to pass the original request URL to the
# media-auth subrequest views. nginx-ingress sends X-Original-URL, which is
# the default. Other proxies use different headers -- Traefik's ForwardAuth,
# for instance, sends X-Forwarded-Uri and cannot emit X-Original-URL at all.
MEDIA_AUTH_ORIGINAL_URL_HEADER = values.Value(
default="HTTP_X_ORIGINAL_URL",
environ_name="MEDIA_AUTH_ORIGINAL_URL_HEADER",
environ_prefix=None,
)
SITE_ID = 1 SITE_ID = 1
@@ -287,7 +266,6 @@ class Base(Configuration):
MIDDLEWARE = [ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"dockerflow.django.middleware.DockerflowMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware", "whitenoise.middleware.WhiteNoiseMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware", "django.middleware.locale.LocaleMiddleware",
@@ -297,6 +275,7 @@ class Base(Configuration):
"django.middleware.csrf.CsrfViewMiddleware", "django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "django.contrib.messages.middleware.MessageMiddleware",
"dockerflow.django.middleware.DockerflowMiddleware",
] ]
AUTHENTICATION_BACKENDS = [ AUTHENTICATION_BACKENDS = [
@@ -481,9 +460,6 @@ class Base(Configuration):
# Sentry # Sentry
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN") SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN")
SENTRY_TRACES_SAMPLE_RATE = values.FloatValue(
0.0, environ_name="SENTRY_TRACES_SAMPLE_RATE", environ_prefix=None
)
# Easy thumbnails # Easy thumbnails
THUMBNAIL_EXTENSION = "webp" THUMBNAIL_EXTENSION = "webp"
@@ -690,9 +666,6 @@ class Base(Configuration):
environ_prefix=None, environ_prefix=None,
default=False, default=False,
) )
LIVEKIT_DEFAULT_VIDEO_CODEC = VideoCodecValue(
"vp9", environ_name="LIVEKIT_DEFAULT_VIDEO_CODEC", environ_prefix=None
)
CONNECTION_TEST_ENABLED = values.BooleanValue( CONNECTION_TEST_ENABLED = values.BooleanValue(
environ_name="CONNECTION_TEST_ENABLED", environ_name="CONNECTION_TEST_ENABLED",
environ_prefix=None, environ_prefix=None,
@@ -774,26 +747,63 @@ class Base(Configuration):
# recordings), whose request never carries advanced EncodingOptions. # recordings), whose request never carries advanced EncodingOptions.
# When disabled, LiveKit falls back to its built-in H264_720P_30 preset # When disabled, LiveKit falls back to its built-in H264_720P_30 preset
# (1280x720, 30 fps, 3000 kbps H.264 MAIN video, 128 kbps AAC audio). # (1280x720, 30 fps, 3000 kbps H.264 MAIN video, 128 kbps AAC audio).
# When enabled, the values below are passed to LiveKit as EncodingOptions # When enabled, the encoding parameters are resolved from the default profile
# (advanced) and replace the preset. Lowering framerate and bitrate reduces # and resolution below and passed to LiveKit as EncodingOptions (advanced),
# output file size and CPU load on the egress worker. # replacing the preset. Lowering framerate and bitrate reduces output file
# size and CPU load on the egress worker.
RECORDING_ENCODING_ENABLED = values.BooleanValue( RECORDING_ENCODING_ENABLED = values.BooleanValue(
False, environ_name="RECORDING_ENCODING_ENABLED", environ_prefix=None False, environ_name="RECORDING_ENCODING_ENABLED", environ_prefix=None
) )
RECORDING_ENCODING_WIDTH = values.PositiveIntegerValue(
1280, environ_name="RECORDING_ENCODING_WIDTH", environ_prefix=None # Map resolution name -> {"width": ..., "height": ...} in pixels.
) RECORDING_ENCODING_AVAILABLE_RESOLUTIONS = values.DictValue(
RECORDING_ENCODING_HEIGHT = values.PositiveIntegerValue( {
720, environ_name="RECORDING_ENCODING_HEIGHT", environ_prefix=None "540p": {"width": 960, "height": 540},
) "720p": {"width": 1280, "height": 720},
RECORDING_ENCODING_FRAMERATE = values.PositiveIntegerValue( "1080p": {"width": 1920, "height": 1080},
30, environ_name="RECORDING_ENCODING_FRAMERATE", environ_prefix=None },
) environ_name="RECORDING_ENCODING_AVAILABLE_RESOLUTIONS",
RECORDING_ENCODING_VIDEO_BITRATE_KBPS = values.PositiveIntegerValue(
3000,
environ_name="RECORDING_ENCODING_VIDEO_BITRATE_KBPS",
environ_prefix=None, environ_prefix=None,
) )
# Bitrate scales with resolution so quality stays consistent across sizes.
RECORDING_ENCODING_AVAILABLE_PROFILES = values.DictValue(
{
"talking_heads": {
"fps": 15,
"kbps": {"540p": 400, "720p": 700, "1080p": 1200},
},
"text": {
"fps": 15,
"kbps": {"540p": 600, "720p": 1000, "1080p": 1800},
},
"mixed": {
"fps": 20,
"kbps": {"540p": 900, "720p": 1500, "1080p": 2500},
},
"full": {
"fps": 30,
"kbps": {"540p": 2000, "720p": 3000, "1080p": 4500},
},
},
environ_name="RECORDING_ENCODING_AVAILABLE_PROFILES",
environ_prefix=None,
)
# Defaults used when no profile/resolution is specified per recording.
# Must be keys of the two dicts above (validated at startup).
RECORDING_ENCODING_DEFAULT_PROFILE = values.Value(
"full",
environ_name="RECORDING_ENCODING_DEFAULT_PROFILE",
environ_prefix=None,
)
RECORDING_ENCODING_DEFAULT_RESOLUTION = values.Value(
"720p",
environ_name="RECORDING_ENCODING_DEFAULT_RESOLUTION",
environ_prefix=None,
)
# Settings independent of profile/resolution.
RECORDING_ENCODING_AUDIO_BITRATE_KBPS = values.PositiveIntegerValue( RECORDING_ENCODING_AUDIO_BITRATE_KBPS = values.PositiveIntegerValue(
128, 128,
environ_name="RECORDING_ENCODING_AUDIO_BITRATE_KBPS", environ_name="RECORDING_ENCODING_AUDIO_BITRATE_KBPS",
@@ -808,6 +818,7 @@ class Base(Configuration):
SUMMARY_SERVICE_VERSION = values.PositiveIntegerValue( SUMMARY_SERVICE_VERSION = values.PositiveIntegerValue(
1, environ_name="SUMMARY_SERVICE_VERSION", environ_prefix=None 1, environ_name="SUMMARY_SERVICE_VERSION", environ_prefix=None
) )
SUMMARY_SERVICE_ENDPOINT = values.Value( SUMMARY_SERVICE_ENDPOINT = values.Value(
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
) )
@@ -883,7 +894,7 @@ class Base(Configuration):
"room_lobby", environ_name="LOBBY_KEY_PREFIX", environ_prefix=None "room_lobby", environ_name="LOBBY_KEY_PREFIX", environ_prefix=None
) )
LOBBY_WAITING_TIMEOUT = values.PositiveIntegerValue( LOBBY_WAITING_TIMEOUT = values.PositiveIntegerValue(
6, environ_name="LOBBY_WAITING_TIMEOUT", environ_prefix=None 3, environ_name="LOBBY_WAITING_TIMEOUT", environ_prefix=None
) )
LOBBY_DENIED_TIMEOUT = values.PositiveIntegerValue( LOBBY_DENIED_TIMEOUT = values.PositiveIntegerValue(
5, environ_name="LOBBY_DENIED_TIMEOUT", environ_prefix=None 5, environ_name="LOBBY_DENIED_TIMEOUT", environ_prefix=None
@@ -978,10 +989,6 @@ class Base(Configuration):
environ_prefix=None, environ_prefix=None,
) )
ENCRYPTION_ENABLED = values.BooleanValue(
False, environ_name="ENCRYPTION_ENABLED", environ_prefix=None
)
# External Applications # External Applications
APPLICATION_ENABLED = values.BooleanValue( APPLICATION_ENABLED = values.BooleanValue(
False, environ_name="APPLICATION_ENABLED", environ_prefix=None False, environ_name="APPLICATION_ENABLED", environ_prefix=None
@@ -1132,12 +1139,6 @@ class Base(Configuration):
environ_prefix=None, environ_prefix=None,
) )
LOGGING_SILENCED_401_PATHS = values.ListValue(
default=["/api/v1.0/users/me/"],
environ_name="LOGGING_SILENCED_401_PATHS",
environ_prefix=None,
)
# Logging # Logging
# We want to make it easy to log to console but by default we log production # We want to make it easy to log to console but by default we log production
# to Sentry and don't want to log to console. # to Sentry and don't want to log to console.
@@ -1150,16 +1151,10 @@ class Base(Configuration):
"style": "{", "style": "{",
}, },
}, },
"filters": {
"silence_expected_401": {
"()": "core.logging_filters.SilenceExpected401",
},
},
"handlers": { "handlers": {
"console": { "console": {
"class": "logging.StreamHandler", "class": "logging.StreamHandler",
"formatter": "simple", "formatter": "simple",
"filters": ["silence_expected_401"],
}, },
}, },
# Override root logger to send it to console # Override root logger to send it to console
@@ -1170,20 +1165,6 @@ class Base(Configuration):
), ),
}, },
"loggers": { "loggers": {
"request.summary": {
"level": values.Value(
"WARNING",
environ_name="LOGGING_LEVEL_REQUEST_SUMMARY",
environ_prefix="",
)
},
"dockerflow": {
"level": values.Value(
"WARNING",
environ_name="LOGGING_LEVEL_DOCKERFLOW",
environ_prefix="",
)
},
"core": { "core": {
"handlers": ["console"], "handlers": ["console"],
"level": values.Value( "level": values.Value(
@@ -1226,6 +1207,49 @@ class Base(Configuration):
}, },
} }
@classmethod
def _check_recording_encoding_maps(cls):
"""Ensure the per-recording encoding maps are mutually consistent.
Every profile in RECORDING_ENCODING_AVAILABLE_PROFILES must declare an fps and
a bitrate for each resolution declared in RECORDING_ENCODING_AVAILABLE_RESOLUTIONS,
and each non-empty default must name an entry of its map.
"""
# DictValue resolves to a dict at runtime; pylint sees the descriptor.
# pylint: disable=no-member
resolutions = set(cls.RECORDING_ENCODING_AVAILABLE_RESOLUTIONS)
for profile, spec in cls.RECORDING_ENCODING_AVAILABLE_PROFILES.items():
if "fps" not in spec or "kbps" not in spec:
raise ValueError(
f"Profile '{profile}' in RECORDING_ENCODING_AVAILABLE_PROFILES must "
"define both 'fps' and 'kbps'."
)
profile_resolutions = set(spec["kbps"])
if profile_resolutions != resolutions:
raise ValueError(
f"Profile '{profile}' in RECORDING_ENCODING_AVAILABLE_PROFILES must "
"define a bitrate for exactly the resolutions in "
"RECORDING_ENCODING_AVAILABLE_RESOLUTIONS, mismatch on: "
f"{resolutions ^ profile_resolutions}"
)
default_resolution = cls.RECORDING_ENCODING_DEFAULT_RESOLUTION
if default_resolution and default_resolution not in resolutions:
raise ValueError(
f"RECORDING_ENCODING_DEFAULT_RESOLUTION '{default_resolution}' is not a "
"key of RECORDING_ENCODING_AVAILABLE_RESOLUTIONS, choose from "
f"{sorted(resolutions)}."
)
profiles = set(cls.RECORDING_ENCODING_AVAILABLE_PROFILES)
default_profile = cls.RECORDING_ENCODING_DEFAULT_PROFILE
if default_profile and default_profile not in profiles:
raise ValueError(
f"RECORDING_ENCODING_DEFAULT_PROFILE '{default_profile}' is not a key of "
f"RECORDING_ENCODING_AVAILABLE_PROFILES, choose from {sorted(profiles)}."
)
@classmethod @classmethod
def post_setup(cls): def post_setup(cls):
"""Post setup configuration. """Post setup configuration.
@@ -1239,6 +1263,8 @@ class Base(Configuration):
"FILE_UPLOAD_TMP_PATH cannot be the same as FILE_UPLOAD_PATH" "FILE_UPLOAD_TMP_PATH cannot be the same as FILE_UPLOAD_PATH"
) )
cls._check_recording_encoding_maps()
if ( if (
cls.SUMMARY_SERVICE_VERSION == 1 cls.SUMMARY_SERVICE_VERSION == 1
and cls.SUMMARY_SERVICE_ENDPOINT is not None and cls.SUMMARY_SERVICE_ENDPOINT is not None
@@ -1259,14 +1285,7 @@ class Base(Configuration):
dsn=cls.SENTRY_DSN, dsn=cls.SENTRY_DSN,
environment=cls.__name__.lower(), # build, test, development, production environment=cls.__name__.lower(), # build, test, development, production
release=get_release(), release=get_release(),
traces_sample_rate=cls.SENTRY_TRACES_SAMPLE_RATE, integrations=[DjangoIntegration()],
integrations=[
DjangoIntegration(
transaction_style="url",
middleware_spans=True,
cache_spans=True,
)
],
) )
sentry_sdk.set_tag("application", "backend") sentry_sdk.set_tag("application", "backend")
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "uv_build"
[project] [project]
name = "meet" name = "meet"
version = "1.31.0" version = "1.30.0"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }] authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [ classifiers = [
"Development Status :: 5 - Production/Stable", "Development Status :: 5 - Production/Stable",
+1 -1
View File
@@ -1187,7 +1187,7 @@ wheels = [
[[package]] [[package]]
name = "meet" name = "meet"
version = "1.31.0" version = "1.30.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiohttp" }, { name = "aiohttp" },
+14 -6
View File
@@ -4,12 +4,12 @@ USER node
WORKDIR /home/frontend/ WORKDIR /home/frontend/
COPY --chown=node:node ./src/frontend/package.json ./package.json COPY ./src/frontend/package.json ./package.json
COPY --chown=node:node ./src/frontend/package-lock.json ./package-lock.json COPY ./src/frontend/package-lock.json ./package-lock.json
RUN npm ci RUN npm ci
COPY --chown=node:node .dockerignore ./.dockerignore COPY .dockerignore ./.dockerignore
COPY --chown=node:node ./src/frontend/ . COPY --chown=node:node ./src/frontend/ .
### ---- Front-end builder image ---- ### ---- Front-end builder image ----
@@ -42,11 +42,19 @@ ENV VITE_APP_TITLE=${VITE_APP_TITLE}
RUN npm run build RUN npm run build
# ---- Front-end image ---- # ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:1.30.4-alpine3.24 AS frontend-production FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
USER root USER root
RUN apk upgrade --no-cache libexpat && \
apk del curl # Security patches for known CVEs
RUN apk update && apk upgrade \
libcrypto3>=3.5.7-r0 \
libssl3>=3.5.7-r0 \
musl \
musl-utils \
zlib>=1.3.2-r0 \
&& apk del curl
USER nginx USER nginx
# Un-privileged user running the application # Un-privileged user running the application
+45 -38
View File
@@ -1,12 +1,12 @@
{ {
"name": "meet", "name": "meet",
"version": "1.31.0", "version": "1.30.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "meet", "name": "meet",
"version": "1.31.0", "version": "1.30.0",
"dependencies": { "dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0", "@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
"@fontsource-variable/lexend": "5.3.0", "@fontsource-variable/lexend": "5.3.0",
@@ -23,18 +23,18 @@
"@timephy/rnnoise-wasm": "1.0.0", "@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.1.2", "crisp-sdk-web": "1.1.2",
"hoofd": "1.7.3", "hoofd": "1.7.3",
"humanize-duration": "3.34.1", "humanize-duration": "3.33.2",
"i18next": "26.3.6", "i18next": "26.3.6",
"i18next-browser-languagedetector": "8.2.1", "i18next-browser-languagedetector": "8.2.1",
"i18next-parser": "9.4.0", "i18next-parser": "9.4.0",
"i18next-resources-to-backend": "1.2.3", "i18next-resources-to-backend": "1.2.3",
"livekit-client": "2.21.0", "livekit-client": "2.21.0",
"posthog-js": "1.418.10", "posthog-js": "1.414.0",
"react": "18.3.1", "react": "18.3.1",
"react-aria": "3.50.0", "react-aria": "3.50.0",
"react-aria-components": "1.19.0", "react-aria-components": "1.19.0",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-i18next": "17.0.12", "react-i18next": "17.0.10",
"react-stately": "3.48.0", "react-stately": "3.48.0",
"use-sound": "5.0.0", "use-sound": "5.0.0",
"valtio": "2.3.2", "valtio": "2.3.2",
@@ -1720,28 +1720,28 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@posthog/browser-common": { "node_modules/@posthog/browser-common": {
"version": "0.5.2", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.5.2.tgz", "resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.4.0.tgz",
"integrity": "sha512-8GvfEshFdeKIccuy3kpp6mDBxawQtRamMYRCwzy1r1ixLKQVLAGs3afhOf6r75yp59ZQBi5mtXQgUJ2Jz8eHuw==", "integrity": "sha512-W9DCGVks15docUMPvJ2nd8NS16Gn74bsGWuaeg31beEKFSjdW8wvnQ1ETY6WSql5pYxZb3GdJmEUZVVstKSrBQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@posthog/core": "^1.48.11", "@posthog/core": "^1.46.8",
"@posthog/types": "^1.405.3" "@posthog/types": "^1.402.0"
} }
}, },
"node_modules/@posthog/core": { "node_modules/@posthog/core": {
"version": "1.53.2", "version": "1.49.1",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.53.2.tgz", "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.49.1.tgz",
"integrity": "sha512-Knx8442G2LyPVIzLvhcBX/TIdeHpZhrYrtZou3Uw8FWfbr9gQtQeOZDCog3MWcDxJ+4vAnMAAOzf5cYpc7Gxyw==", "integrity": "sha512-jdZh85tG56OXLH881CVwBZyiXCPPaZasfYeWwm9kVUvxC/Rb+lz7wYN9GuqSEmNPJgVnK4v8wS0bCaFc3OmVEA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@posthog/types": "^1.411.1" "@posthog/types": "^1.407.0"
} }
}, },
"node_modules/@posthog/types": { "node_modules/@posthog/types": {
"version": "1.411.1", "version": "1.407.1",
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.411.1.tgz", "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.407.1.tgz",
"integrity": "sha512-Gd7tnSYctcSXup3naVlAgavvenByRao6rsSYdMgWgP35KE9jFn+rMHNRJeI5LNjJuLzMXHEVcgzAN7xcATefdw==", "integrity": "sha512-WhbkXPC2rgylXqmxHqv70ffI3k+KxyR6s7DBIfr5NvIqHkxp6v0pk31D/jbz0DNVbzwkLjyll2pxr4FNbJiYzg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@react-aria/overlays": { "node_modules/@react-aria/overlays": {
@@ -6354,12 +6354,11 @@
"integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w==" "integrity": "sha512-iARIBPgcQrwtEr+tALF+rapJ8qSc+Set2GJQl7xT1MQzWaVkFebdJhR3alVlSiUf5U7nAANKuj3aWpwerocD5w=="
}, },
"node_modules/html-parse-stringify": { "node_modules/html-parse-stringify": {
"version": "4.0.1", "version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz", "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==", "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT", "dependencies": {
"funding": { "void-elements": "3.1.0"
"url": "https://locize.com"
} }
}, },
"node_modules/htmlparser2": { "node_modules/htmlparser2": {
@@ -6402,9 +6401,9 @@
} }
}, },
"node_modules/humanize-duration": { "node_modules/humanize-duration": {
"version": "3.34.1", "version": "3.33.2",
"resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.34.1.tgz", "resolved": "https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.33.2.tgz",
"integrity": "sha512-YIiigjQ+O31rvcyDJPK1ptTZtvSErjBtmHy93VhbwxB/VwMG0FszKs2IB667tFxmOhNapePQQxa24luQRDkKFQ==", "integrity": "sha512-K7Ny/ULO1hDm2nnhvAY+SJV1skxFb61fd073SG1IWJl+D44ULrruCuTyjHKjBVVcSuTlnY99DKtgEG39CM5QOQ==",
"license": "Unlicense", "license": "Unlicense",
"funding": { "funding": {
"url": "https://github.com/sponsors/EvanHahn" "url": "https://github.com/sponsors/EvanHahn"
@@ -9132,16 +9131,16 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/posthog-js": { "node_modules/posthog-js": {
"version": "1.418.10", "version": "1.414.0",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.418.10.tgz", "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.414.0.tgz",
"integrity": "sha512-XMvmmnuFoesSPjFo+wvzXkvuzYqIho8CVqxugG1Wt768offFARYNZDd1/xWfm9vk/pJTJ4RhUEHRzLpggmGx9Q==", "integrity": "sha512-dtZd4asdskr8lNyltAEX6zyn48uO1pO0EMvx6AXJU65PFhu6yn2LPbKtQcyLysjcN57FJPNT9QYL6St5SBJHqw==",
"license": "(Apache-2.0 AND MIT)", "license": "(Apache-2.0 AND MIT)",
"dependencies": { "dependencies": {
"@posthog/browser-common": "^0.5.0", "@posthog/browser-common": "^0.4.0",
"@posthog/core": "^1.48.8", "@posthog/core": "^1.46.9",
"@posthog/types": "^1.405.1", "@posthog/types": "^1.402.2",
"core-js": "^3.49.0", "core-js": "^3.49.0",
"dompurify": "^3.4.13", "dompurify": "^3.4.12",
"fflate": "^0.4.8", "fflate": "^0.4.8",
"preact": "^10.29.3", "preact": "^10.29.3",
"query-selector-shadow-dom": "^1.0.1", "query-selector-shadow-dom": "^1.0.1",
@@ -9436,13 +9435,13 @@
} }
}, },
"node_modules/react-i18next": { "node_modules/react-i18next": {
"version": "17.0.12", "version": "17.0.10",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.12.tgz", "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.10.tgz",
"integrity": "sha512-lFWPEGkxQ6RhusdUkysFBD58VHfSSzvHBzqMgN0SvfVpdQGfwtNkStTqdy08/sJd7s807qqutgx93fRpD0DJ3Q==", "integrity": "sha512-XneHftyYA774MJkkccSkZ5oKrUpCnXIPmxio3wemqrVzCRLWiGXOMbIzObrer03fNDEnm8g8R5yYls4HcE+esg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/runtime": "^7.29.7", "@babel/runtime": "^7.29.2",
"html-parse-stringify": "^4.0.1", "html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0" "use-sync-external-store": "^1.6.0"
}, },
"peerDependencies": { "peerDependencies": {
@@ -11609,6 +11608,14 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/walk-sync": { "node_modules/walk-sync": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/walk-sync/-/walk-sync-2.2.0.tgz", "resolved": "https://registry.npmjs.org/walk-sync/-/walk-sync-2.2.0.tgz",
+4 -4
View File
@@ -1,7 +1,7 @@
{ {
"name": "meet", "name": "meet",
"private": true, "private": true,
"version": "1.31.0", "version": "1.30.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "panda codegen && vite", "dev": "panda codegen && vite",
@@ -30,18 +30,18 @@
"@timephy/rnnoise-wasm": "1.0.0", "@timephy/rnnoise-wasm": "1.0.0",
"crisp-sdk-web": "1.1.2", "crisp-sdk-web": "1.1.2",
"hoofd": "1.7.3", "hoofd": "1.7.3",
"humanize-duration": "3.34.1", "humanize-duration": "3.33.2",
"i18next": "26.3.6", "i18next": "26.3.6",
"i18next-browser-languagedetector": "8.2.1", "i18next-browser-languagedetector": "8.2.1",
"i18next-parser": "9.4.0", "i18next-parser": "9.4.0",
"i18next-resources-to-backend": "1.2.3", "i18next-resources-to-backend": "1.2.3",
"livekit-client": "2.21.0", "livekit-client": "2.21.0",
"posthog-js": "1.418.10", "posthog-js": "1.414.0",
"react": "18.3.1", "react": "18.3.1",
"react-aria": "3.50.0", "react-aria": "3.50.0",
"react-aria-components": "1.19.0", "react-aria-components": "1.19.0",
"react-dom": "18.3.1", "react-dom": "18.3.1",
"react-i18next": "17.0.12", "react-i18next": "17.0.10",
"react-stately": "3.48.0", "react-stately": "3.48.0",
"use-sound": "5.0.0", "use-sound": "5.0.0",
"valtio": "2.3.2", "valtio": "2.3.2",
+1 -2
View File
@@ -3,7 +3,7 @@ import { keys } from './queryKeys'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { RecordingMode } from '@/features/recording' import { RecordingMode } from '@/features/recording'
import type { ApiAccessLevel } from '@/features/rooms/api/ApiRoom' import type { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
import type { Track, VideoCodec } from 'livekit-client' import type { Track } from 'livekit-client'
type Source = Track.Source type Source = Track.Source
export interface ApiConfig { export interface ApiConfig {
@@ -62,7 +62,6 @@ export interface ApiConfig {
force_wss_protocol: boolean force_wss_protocol: boolean
enable_firefox_proxy_workaround: boolean enable_firefox_proxy_workaround: boolean
default_sources: Source[] default_sources: Source[]
default_video_codec: VideoCodec
} }
transcription_destination?: string transcription_destination?: string
max_participants_for_sound: number max_participants_for_sound: number
+42 -6
View File
@@ -1,5 +1,5 @@
import { css, cva, RecipeVariantProps } from '@/styled-system/css' import { css, cva, RecipeVariantProps } from '@/styled-system/css'
import React, { useMemo } from 'react' import React, { useLayoutEffect, useMemo } from 'react'
const avatar = cva({ const avatar = cva({
base: { base: {
@@ -28,17 +28,24 @@ const avatar = cva({
}, },
}) })
// Instantiating a segmenter is expensive; create it once and reuse it.
const graphemeSegmenter = const graphemeSegmenter =
typeof Intl !== 'undefined' && 'Segmenter' in Intl typeof Intl !== 'undefined' && 'Segmenter' in Intl
? new Intl.Segmenter(undefined, { granularity: 'grapheme' }) ? new Intl.Segmenter(undefined, { granularity: 'grapheme' })
: undefined : undefined
/**
* Returns the first user-perceived character. Some Unicode characters span
* multiple UTF-16 code units, so a naive index into the string can split them
* and yield a broken glyph.
*/
const getFirstGrapheme = (value: string): string => { const getFirstGrapheme = (value: string): string => {
if (!value) return '' if (!value) return ''
if (graphemeSegmenter) { if (graphemeSegmenter) {
const [first] = graphemeSegmenter.segment(value) const [first] = graphemeSegmenter.segment(value)
return first?.segment ?? '' return first?.segment ?? ''
} }
// Fallback: keeps single code points intact (including surrogate pairs).
return Array.from(value)[0] ?? '' return Array.from(value)[0] ?? ''
} }
@@ -59,6 +66,36 @@ export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
export const Avatar = React.memo( export const Avatar = React.memo(
({ name, bgColor, context, notification, style, ...props }: AvatarProps) => { ({ name, bgColor, context, notification, style, ...props }: AvatarProps) => {
const initials = useMemo(() => getInitials(name), [name]) const initials = useMemo(() => getInitials(name), [name])
const textRef = React.useRef<SVGTextElement>(null)
const [offsetY, setOffsetY] = React.useState(0)
// Optically center the initials: measure the ink bounding box of the
// rendered glyphs and shift them so the box's center sits at the middle
// of the viewBox. Works for any font, weight or glyph shape, unlike a
// hand-tuned dy offset. getBBox() is in local (pre-transform)
// coordinates, so applying the translation never changes the measure.
useLayoutEffect(() => {
const text = textRef.current
if (!text) return
const center = () => {
const box = text.getBBox()
// A hidden element measures as an empty box; keep the default then.
if (box.height === 0) return
setOffsetY(50 - (box.y + box.height / 2))
}
center()
// Glyph metrics can change once webfonts finish loading.
let cancelled = false
document.fonts?.ready.then(() => {
if (!cancelled) center()
})
return () => {
cancelled = true
}
}, [initials])
return ( return (
<div <div
style={{ backgroundColor: bgColor, ...style }} style={{ backgroundColor: bgColor, ...style }}
@@ -71,16 +108,15 @@ export const Avatar = React.memo(
className={css({ width: '100%', height: '100%', display: 'block' })} className={css({ width: '100%', height: '100%', display: 'block' })}
> >
<text <text
ref={textRef}
x="50" x="50"
y={50} y="50"
transform={`translate(0 ${offsetY})`}
textAnchor="middle" textAnchor="middle"
dominantBaseline="central"
fontSize="52" fontSize="52"
fontWeight="500" fontWeight="500"
fill="currentColor" fill="currentColor"
className={css({
transform:
'translateY(calc(var(--avatar-cap-height, 0.7) * 0.5em))',
})}
> >
{initials} {initials}
</text> </text>
@@ -18,7 +18,7 @@ export const fetchUser = (
} }
): Promise<ApiUser | false> => { ): Promise<ApiUser | false> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
fetchApi<ApiUser>('/users/me/') fetchApi<ApiUser>('/users/me')
.then(resolve) .then(resolve)
.catch((error) => { .catch((error) => {
// we assume that a 401 means the user is not logged in // we assume that a 401 means the user is not logged in

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