Compare commits

..

1 Commits

Author SHA1 Message Date
lebaudantoine 68ce56c1c8 🔒️(frontend) prevent disconnected users from accessing recording tools
Block recording and transcript features when user isn't connected to prevent
database state corruption. Users were previously able to trigger these
actions despite being disconnected.
2025-05-03 19:15:14 +02:00
404 changed files with 7784 additions and 18053 deletions
+35
View File
@@ -0,0 +1,35 @@
name: Deploy
on:
push:
tags:
- 'preprod'
- 'production'
jobs:
notify-argocd:
runs-on: ubuntu-latest
steps:
-
name: Checkout repository
uses: actions/checkout@v4
-
name: Call argocd github webhook
run: |
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${{ secrets.ARGOCD_WEBHOOK_SECRET }}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ vars.ARGOCD_WEBHOOK_URL }}
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${{ secrets.ARGOCD_PRODUCTION_WEBHOOK_SECRET }}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ secrets.ARGOCD_PRODUCTION_WEBHOOK_URL }}
start-test-on-preprod:
needs:
- notify-argocd
runs-on: ubuntu-latest
if: startsWith(github.event.ref, 'refs/tags/preprod')
steps:
-
name: Debug
run: |
echo "Start test when preprod is ready"
+5 -84
View File
@@ -31,10 +31,7 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
@@ -52,7 +49,7 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-and-push-frontend-generic:
build-and-push-frontend:
runs-on: ubuntu-latest
steps:
-
@@ -67,10 +64,7 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
@@ -89,43 +83,6 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-and-push-frontend-dinum:
runs-on: ubuntu-latest
steps:
-
name: Checkout repository
uses: actions/checkout@v4
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/meet-frontend-dinum
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
docker-image-name: 'docker.io/lasuite/meet-frontend-dinum:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/dinum-frontend/Dockerfile
target: frontend-production
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-and-push-summary:
runs-on: ubuntu-latest
steps:
@@ -141,10 +98,7 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
-
name: Build and push
uses: docker/build-push-action@v6
@@ -157,44 +111,11 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-and-push-agents:
runs-on: ubuntu-latest
steps:
-
name: Checkout repository
uses: actions/checkout@v4
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/meet-agents
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Build and push
uses: docker/build-push-action@v6
with:
context: ./src/agents
file: ./src/agents/Dockerfile
target: production
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
notify-argocd:
needs:
- build-and-push-frontend-generic
- build-and-push-frontend-dinum
- build-and-push-frontend
- build-and-push-backend
- build-and-push-summary
- build-and-push-agents
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
steps:
+66 -48
View File
@@ -20,18 +20,14 @@ jobs:
- name: show
run: git log
- name: Enforce absence of print statements in code
if: always()
run: |
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/meet.yml' | grep "print("
- name: Check absence of fixup commits
if: always()
run: |
! git log | grep 'fixup!'
- name: Install gitlint
if: always()
run: pip install --user requests gitlint
- name: Lint commit messages added to main
if: always()
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
build-mails:
@@ -85,8 +81,7 @@ jobs:
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: "pip"
python-version: "3.12"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Check code formatting with ruff
@@ -96,46 +91,6 @@ jobs:
- name: Lint code with pylint
run: ~/.local/bin/pylint meet demo core
lint-agents:
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/agents
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Check code formatting with ruff
run: ~/.local/bin/ruff format . --diff
- name: Lint code with ruff
run: ~/.local/bin/ruff check .
lint-summary:
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/summary
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Check code formatting with ruff
run: ~/.local/bin/ruff format . --diff
- name: Lint code with ruff
run: ~/.local/bin/ruff check .
test-back:
runs-on: ubuntu-latest
needs: build-mails
@@ -230,8 +185,7 @@ jobs:
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: "pip"
python-version: "3.12"
- name: Install development dependencies
run: pip install --user .[dev]
@@ -295,3 +249,67 @@ jobs:
- name: Build SDK
run: npm run build
i18n-crowdin:
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "infrastructure,secrets"
- name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
- name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
- name: Install gettext (required to make messages)
run: |
sudo apt-get update
sudo apt-get install -y gettext
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install development dependencies
working-directory: src/backend
run: pip install --user .[dev]
- name: Generate the translation base file
run: ~/.local/bin/django-admin makemessages --keep-pot --all
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18.x"
cache: "npm"
cache-dependency-path: src/frontend/package-lock.json
- name: Install dependencies
run: cd src/frontend/ && npm ci
- name: Extract the frontend translation
run: make frontend-i18n-extract
- name: Upload files to Crowdin
uses: crowdin/github-action@v2
with:
config: crowdin/config.yml
upload_sources: true
upload_translations: true
download_translations: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_BASE_PATH: ${{ github.workspace }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
-2
View File
@@ -8,5 +8,3 @@ and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
- 🔧(backend) support `_FILE` for secret environment variables #566
+4 -4
View File
@@ -1,7 +1,7 @@
# Django Meet
# ---- base image to inherit from ----
FROM python:3.13.5-alpine3.21 AS base
FROM python:3.12.6-alpine3.20 AS base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip setuptools
@@ -62,11 +62,11 @@ FROM base AS core
ENV PYTHONUNBUFFERED=1
RUN apk --no-cache add \
cairo \
gdk-pixbuf \
RUN apk add \
gettext \
cairo \
libffi-dev \
gdk-pixbuf \
pango \
shared-mime-info
+1 -80
View File
@@ -45,7 +45,6 @@ COMPOSE_RUN = $(COMPOSE) run --rm
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
WAIT_MINIO = @$(COMPOSE_RUN) dockerize -wait tcp://minio:9000 -timeout 60s
# -- Backend
MANAGE = $(COMPOSE_RUN_APP) python manage.py
@@ -54,21 +53,6 @@ MAIL_NPM = $(COMPOSE_RUN) -w /app/src/mail node npm
# -- Frontend
PATH_FRONT = ./src/frontend
# -- MinIO / Webhook
MINIO_ALIAS ?= meet
MINIO_ENDPOINT ?= http://127.0.0.1:9000
MINIO_ACCESS_KEY ?= meet
MINIO_SECRET_KEY ?= password
MINIO_BUCKET ?= meet-media-storage
MINIO_WEBHOOK_NAME ?= recording
MINIO_WEBHOOK_ENDPOINT ?= http://app-dev:8000/api/v1.0/recordings/storage-hook/
MINIO_QUEUE_DIR ?= /data/minio/events
MINIO_QUEUE_LIMIT ?= 100000
STORAGE_EVENT_TOKEN ?= password
# ==============================================================================
# RULES
@@ -87,8 +71,7 @@ create-env-files: \
env.d/development/common \
env.d/development/crowdin \
env.d/development/postgresql \
env.d/development/kc_postgresql \
env.d/development/summary
env.d/development/kc_postgresql
.PHONY: create-env-files
bootstrap: ## Prepare Docker images for the project
@@ -133,15 +116,9 @@ run-backend: ## start only the backend application and all needed services
@$(WAIT_DB)
.PHONY: run-backend
run-summary: ## start only the summary application and all needed services
@$(COMPOSE) up --force-recreate -d celery-summary-transcribe
@$(COMPOSE) up --force-recreate -d celery-summary-summarize
.PHONY: run-summary
run:
run: ## start the wsgi (production) and development server
@$(MAKE) run-backend
@$(MAKE) run-summary
@$(COMPOSE) up --force-recreate -d frontend
.PHONY: run
@@ -153,55 +130,6 @@ stop: ## stop the development server using Docker
@$(COMPOSE) stop
.PHONY: stop
# -- MinIO webhook (configuration & events)
minio-wait: ## wait for minio to be ready
@echo "$(BOLD)Waiting for MinIO$(RESET)"
$(WAIT_MINIO)
.PHONY: minio-wait
minio-queue-dir: minio-wait ## ensure queue dir exists in MinIO container
@echo "$(BOLD)Ensuring MinIO queue dir$(RESET)"
@$(COMPOSE) exec minio sh -lc 'mkdir -p $(MINIO_QUEUE_DIR) && chmod -R 777 $(dir $(MINIO_QUEUE_DIR)) || true'
.PHONY: minio-queue-dir
minio-alias: minio-wait ## set mc alias to MinIO
@echo "$(BOLD)Setting mc alias $(MINIO_ALIAS) -> $(MINIO_ENDPOINT)$(RESET)"
@mc alias set $(MINIO_ALIAS) $(MINIO_ENDPOINT) $(MINIO_ACCESS_KEY) $(MINIO_SECRET_KEY) >/dev/null
.PHONY: minio-alias
minio-webhook-config: minio-alias minio-queue-dir ## configure webhook on MinIO
@echo "$(BOLD)Configuring MinIO webhook $(MINIO_WEBHOOK_NAME)$(RESET)"
@mc admin config set $(MINIO_ALIAS) notify_webhook:$(MINIO_WEBHOOK_NAME) \
endpoint="$(MINIO_WEBHOOK_ENDPOINT)" \
auth_token="Bearer $(STORAGE_EVENT_TOKEN)" \
queue_dir="$(MINIO_QUEUE_DIR)" \
queue_limit="$(MINIO_QUEUE_LIMIT)"
.PHONY: minio-webhook-config
minio-restart: minio-alias ## restart MinIO after config change
@echo "$(BOLD)Restarting MinIO service$(RESET)"
@mc admin service restart $(MINIO_ALIAS)
.PHONY: minio-restart
minio-events-reset: minio-alias ## remove all bucket notifications
@echo "$(BOLD)Removing existing bucket events on $(MINIO_BUCKET)$(RESET)"
@mc event remove --force $(MINIO_ALIAS)/$(MINIO_BUCKET) >/dev/null || true
.PHONY: minio-events-reset
minio-event-add: minio-alias ## add put event -> webhook
@echo "$(BOLD)Adding put event -> webhook $(MINIO_WEBHOOK_NAME)$(RESET)"
@mc event add $(MINIO_ALIAS)/$(MINIO_BUCKET) arn:minio:sqs::$(MINIO_WEBHOOK_NAME):webhook --event put
@mc event list $(MINIO_ALIAS)/$(MINIO_BUCKET)
.PHONY: minio-event-add
minio-webhook-setup: ## full setup: alias, config, restart, reset events, add event
minio-webhook-setup: \
minio-webhook-config \
minio-restart \
minio-events-reset \
minio-event-add
.PHONY: minio-webhook-setup
# -- Front
frontend-development-install: ## install the frontend locally
@@ -318,9 +246,6 @@ env.d/development/postgresql:
env.d/development/kc_postgresql:
cp -n env.d/development/kc_postgresql.dist env.d/development/kc_postgresql
env.d/development/summary:
cp -n env.d/development/summary.dist env.d/development/summary
# -- Internationalization
env.d/development/crowdin:
@@ -422,7 +347,3 @@ start-tilt: ## start the kubernetes cluster using kind
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
DEV_ENV=dev-keycloak tilt up -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
start-tilt-dinum: ## start the kubernetes cluster using kind, without Pro Connect for authentication, but with DINUM styles
DEV_ENV=dev-dinum tilt up -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
+2 -3
View File
@@ -33,9 +33,9 @@ Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level perfo
- Support for multiple screen sharing streams
- Non-persistent, secure chat
- End-to-end encryption (coming soon)
- Meeting recording
- Meeting recording (coming soon)
- Meeting transcription (currently in beta)
- Telephony integration
- Telephony integration (in development)
- Secure participation with robust authentication and access control
- LiveKit Advances features including :
- speaker detection
@@ -111,7 +111,6 @@ Come help us make La Suite Meet even better. We're growing fast and [would love
## Credits
We're using the awesome [LiveKit](https://livekit.io/) implementation. We're also thankful to the teams behind [Django Rest Framework](https://www.django-rest-framework.org/), [Vite.js](https://vite.dev/), and [React Aria](https://github.com/adobe/react-spectrum) — Thanks for your amazing work!
This project is tested with BrowserStack.
## License
+5 -45
View File
@@ -2,15 +2,6 @@ load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
load('ext://namespace', 'namespace_create', 'namespace_inject')
namespace_create('meet')
DEV_ENV = os.getenv('DEV_ENV', 'dev')
if DEV_ENV == 'dev-dinum':
update_settings(suppress_unused_image_warnings=["localhost:5001/meet-frontend-generic:latest"])
if DEV_ENV == 'dev-keycloak':
update_settings(suppress_unused_image_warnings=["localhost:5001/meet-frontend-dinum:latest"])
def clean_old_images(image_name):
local('docker images -q %s | tail -n +2 | xargs -r docker rmi' % image_name)
@@ -31,19 +22,7 @@ docker_build(
clean_old_images('localhost:5001/meet-backend')
docker_build(
'localhost:5001/meet-frontend-dinum:latest',
context='..',
dockerfile='../docker/dinum-frontend/Dockerfile',
only=['./src/frontend', './docker', './.dockerignore'],
target = 'frontend-production',
live_update=[
sync('../src/frontend', '/home/frontend'),
]
)
clean_old_images('localhost:5001/meet-frontend-dinum')
docker_build(
'localhost:5001/meet-frontend-generic:latest',
'localhost:5001/meet-frontend:latest',
context='..',
dockerfile='../src/frontend/Dockerfile',
only=['./src/frontend', './docker', './.dockerignore'],
@@ -52,37 +31,25 @@ docker_build(
sync('../src/frontend', '/home/frontend'),
]
)
clean_old_images('localhost:5001/meet-frontend-generic')
clean_old_images('localhost:5001/meet-frontend')
docker_build(
'localhost:5001/meet-summary:latest',
context='../src/summary',
dockerfile='../src/summary/Dockerfile',
only=['.'],
only=['.', '../../docker', '../../.dockerignore'],
target = 'production',
live_update=[
sync('../src/summary', '/app'),
sync('../src/summary', '/home/summary'),
]
)
clean_old_images('localhost:5001/meet-summary')
docker_build(
'localhost:5001/meet-agents:latest',
context='../src/agents',
dockerfile='../src/agents/Dockerfile',
only=['.'],
target = 'production',
live_update=[
sync('../src/agents', '/app'),
]
)
clean_old_images('localhost:5001/meet-agents')
# Copy the mkcert root CA certificate to our Docker build context
# This is necessary because we need to inject the certificate into our LiveKit container
local_resource(
'copy-root-ca',
cmd='cp -f "$(mkcert -CAROOT)/rootCA.pem" ../docker/livekit/rootCA.pem',
cmd='cp "$(mkcert -CAROOT)/rootCA.pem" ../docker/livekit/rootCA.pem',
deps=[], # No dependencies needed
)
# Build a custom LiveKit Docker image that includes our root CA certificate
@@ -97,13 +64,6 @@ clean_old_images('localhost:5001/meet-livekit')
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .'))
k8s_resource('minio-bucket', resource_deps=['minio'])
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
k8s_resource('meet-backend-migrate', resource_deps=['meet-backend'])
k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
k8s_resource('keycloak', resource_deps=['kc-postgresql'])
k8s_resource('meet-backend-createsuperuser', resource_deps=['meet-backend-migrate'])
migration = '''
set -eu
# get k8s pod name from tilt resource name
-62
View File
@@ -138,7 +138,6 @@ services:
target: frontend-production
args:
VITE_API_BASE_URL: "http://localhost:8071"
VITE_APP_TITLE: "LaSuite Meet"
image: meet:frontend-development
ports:
- "3000:8080"
@@ -221,64 +220,3 @@ services:
- ./docker/livekit/out:/out
depends_on:
- redis
redis-summary:
image: redis
ports:
- "6379:6379"
app-summary-dev:
build:
context: src/summary
target: development
args:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
env_file:
- env.d/development/summary
ports:
- "8001:8000"
volumes:
- ./src/summary:/app
depends_on:
- redis-summary
celery-summary-transcribe:
container_name: celery-summary-transcribe
build:
context: ./src/summary
dockerfile: Dockerfile
target: production
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q transcribe-queue
env_file:
- env.d/development/summary
volumes:
- ./src/summary:/app
depends_on:
- redis-summary
- app-summary-dev
- minio
develop:
watch:
- action: rebuild
path: ./src/summary
celery-summary-summarize:
container_name: celery-summary-summarize
build:
context: ./src/summary
dockerfile: Dockerfile
target: production
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q summarize-queue
env_file:
- env.d/development/summary
volumes:
- ./src/summary:/app
depends_on:
- redis-summary
- app-summary-dev
- minio
develop:
watch:
- action: rebuild
path: ./src/summary
-62
View File
@@ -1,62 +0,0 @@
# ---- Front-end image ----
FROM node:20-alpine AS frontend-deps
WORKDIR /home/frontend/
COPY ./src/frontend/package.json ./package.json
COPY ./src/frontend/package-lock.json ./package-lock.json
RUN npm ci
COPY .dockerignore ./.dockerignore
COPY ./src/frontend/ .
# ---- Front-end builder image ----
FROM frontend-deps AS meet-builder
WORKDIR /home/frontend
ENV VITE_APP_TITLE="Visio"
ENV VITE_BUILD_SOURCEMAP="true"
RUN npm run build
# Inject PostHog sourcemap metadata into the built assets
# This metadata is essential for correctly mapping errors to source maps in production
RUN set -e && \
npx @posthog/cli sourcemap inject --directory ./dist/assets
COPY ./docker/dinum-frontend/dinum-styles.css \
./dist/assets/
COPY ./docker/dinum-frontend/logo.svg \
./dist/assets/logo.svg
COPY ./docker/dinum-frontend/assets/ \
./dist/assets/
COPY ./docker/dinum-frontend/fonts/ \
./dist/assets/fonts/
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
USER root
RUN apk update && apk upgrade libssl3 libcrypto3 libxml2>=2.12.7-r2 libxslt>=1.1.39-r2
USER nginx
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
COPY --from=meet-builder \
/home/frontend/dist \
/usr/share/nginx/html
COPY ./src/frontend/default.conf /etc/nginx/conf.d
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
CMD ["nginx", "-g", "daemon off;"]
@@ -1,61 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="38.194309mm"
height="3.4877367mm"
viewBox="0 0 38.194309 3.4877367"
version="1.1"
id="svg1"
sodipodi:docname="gouvernement.svg"
inkscape:version="1.4.1 (93de688d07, 2025-03-30)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="6.1918409"
inkscape:cx="79.620909"
inkscape:cy="44.332534"
inkscape:window-width="1901"
inkscape:window-height="1037"
inkscape:window-x="5"
inkscape:window-y="5"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" />
<defs
id="defs1">
<rect
x="111.07374"
y="333.22122"
width="541.4845"
height="297.51895"
id="rect2" />
<rect
x="158.10136"
y="358.3631"
width="425.55618"
height="204.21426"
id="rect1" />
</defs>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-53.656101,-100.08408)">
<path
d="m 55.435527,103.08144 c -0.72898,0 -1.249045,-0.56007 -1.249045,-1.25349 0,-0.69342 0.502285,-1.25349 1.204595,-1.25349 0.41783,0 0.760095,0.20447 0.96901,0.50673 l 0.333375,-0.25781 c -0.28448,-0.38227 -0.742315,-0.64008 -1.302385,-0.64008 -0.973455,0 -1.63576,0.75565 -1.63576,1.64465 0,0.889 0.680085,1.64465 1.68021,1.64465 0.582295,0 1.05791,-0.24892 1.346835,-0.64008 v -1.21349 h -1.31572 v 0.36005 h 0.89789 v 0.70231 c -0.217805,0.24447 -0.54229,0.40005 -0.929005,0.40005 z m 3.569334,-2.89814 c -0.973455,0 -1.63576,0.75565 -1.63576,1.64465 0,0.889 0.662305,1.64465 1.63576,1.64465 0.96901,0 1.631315,-0.75565 1.631315,-1.64465 0,-0.889 -0.662305,-1.64465 -1.631315,-1.64465 z m 0,2.89814 c -0.70231,0 -1.204595,-0.56007 -1.204595,-1.25349 0,-0.69342 0.502285,-1.25349 1.204595,-1.25349 0.697865,0 1.20015,0.56007 1.20015,1.25349 0,0.69342 -0.502285,1.25349 -1.20015,1.25349 z m 4.276088,-0.84455 c 0,0.53784 -0.31115,0.84455 -0.786765,0.84455 -0.48006,0 -0.79121,-0.30671 -0.79121,-0.84455 v -1.96469 h -0.41783 v 1.93802 c 0,0.8001 0.48006,1.26238 1.20904,1.26238 0.72898,0 1.204595,-0.46228 1.204595,-1.26238 v -1.93802 h -0.41783 z m 0.942341,-1.96469 1.2446,3.1115 h 0.55118 l 1.2446,-3.1115 h -0.4445 l -1.07569,2.68922 -1.07569,-2.68922 z m 3.653795,3.1115 h 1.67132 v -0.3556 h -1.25349 v -1.05347 h 1.07569 v -0.35115 h -1.07569 v -0.99568 h 1.25349 v -0.3556 h -1.67132 z m 2.502533,0 h 0.41783 v -1.36462 h 0.40894 c 0.03111,0 0.06667,0 0.09779,-0.004 l 0.902335,1.36906 h 0.493395 l -1.00457,-1.44907 c 0.333375,-0.13335 0.52451,-0.41339 0.52451,-0.78677 0,-0.5334 -0.386715,-0.87566 -1.01346,-0.87566 h -0.82677 z m 0.84455,-2.7559 c 0.3556,0 0.564515,0.19558 0.564515,0.51117 0,0.33338 -0.208915,0.52451 -0.564515,0.52451 h -0.42672 v -1.03568 z m 2.004699,2.7559 h 0.41783 v -2.60033 l 1.76022,2.60033 h 0.546735 v -3.1115 h -0.41783 v 2.60032 l -1.76022,-2.60032 h -0.546735 z m 3.71158,0 h 1.67132 v -0.3556 h -1.25349 v -1.05347 h 1.07569 v -0.35115 h -1.07569 v -0.99568 h 1.25349 v -0.3556 h -1.67132 z m 2.502533,0 h 0.41783 v -2.55143 l 0.90678,1.48463 h 0.32004 l 0.90678,-1.48463 v 2.55143 h 0.41783 v -3.1115 h -0.5334 l -0.95123,1.56908 -0.95123,-1.56908 h -0.5334 z m 3.951605,0 h 1.67132 v -0.3556 h -1.25349 v -1.05347 h 1.07569 v -0.35115 h -1.07569 v -0.99568 h 1.25349 v -0.3556 h -1.67132 z m 2.502535,0 h 0.41783 v -2.60033 l 1.76022,2.60033 h 0.546735 v -3.1115 h -0.41783 v 2.60032 l -1.76022,-2.60032 H 85.89712 Z m 3.373758,-2.72923 h 1.03124 v 2.72923 h 0.41783 v -2.72923 h 1.03124 v -0.38227 h -2.48031 z"
id="text3"
style="font-size:4.445px;line-height:0.661464px;font-family:Marianne;-inkscape-font-specification:'Marianne, Normal';letter-spacing:0px;word-spacing:0px;display:inline;stroke:#000000;stroke-width:0.198437"
aria-label="GOUVERNEMENT" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.3 KiB

-70
View File
@@ -1,70 +0,0 @@
:root {
--fonts-sans: 'Marianne', ui-sans-serif, system-ui, sans-serif;
}
.Header-beforeLogo {
display: block;
}
.Header-beforeLogo::before {
content: '';
display: block;
background-image: url(/assets/marianne.svg);
background-position: 0 -0.046875rem;
background-size: 2.0625rem 0.84375rem;
height: 0.75rem;
margin-bottom: 0.1rem;
width: 2.0625rem;
}
.Header-beforeLogo::after {
content: '';
display: block;
background-image: url(/assets/gouvernement.svg), url(/assets/devise.svg);
background-repeat: no-repeat, no-repeat;
background-size: 108.8px 10px, 40px 29px;
background-position: 0 3px, 0 18.9px;
width: 108.8px;
height: 48px;
margin-top: 0.25rem;
}
@font-face {
font-family: 'Marianne';
src: url('/assets/fonts/Marianne-Regular-subset.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Marianne';
src: url('/assets/fonts/Marianne-Regular_Italic-subset.woff2') format('woff2');
font-weight: 400;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Marianne';
src: url('/assets/fonts/Marianne-Medium-subset.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Marianne';
src: url('/assets/fonts/Marianne-Bold-subset.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Marianne';
src: url('/assets/fonts/Marianne-ExtraBold-subset.woff2') format('woff2');
font-weight: 800;
font-style: normal;
font-display: swap;
}
+1 -1
View File
@@ -1,4 +1,4 @@
FROM livekit/livekit-server:v1.9.0
FROM livekit/livekit-server:v1.8.0
# We inject the nip.io certificate manually because the livekit chart doesn't support volume mounting
COPY rootCA.pem /etc/ssl/certs/
+1 -1
View File
@@ -142,4 +142,4 @@ Once the Kubernetes cluster is ready, start the application stack locally:
$ make start-tilt-keycloak
```
Monitor Tilts progress at [http://localhost:10350/](http://localhost:10350/). After Tilt actions finish, you can access the app at [https://meet.127.0.0.1.nip.io/](https://meet.127.0.0.1.nip.io/).
Monitor Tilts progress at [http://localhost:10350/](http://localhost:10350/). After Tilt actions finish, you can access the app at [https://visio.127.0.0.1.nip.io/](https://visio.127.0.0.1.nip.io/).
+3
View File
@@ -35,6 +35,9 @@ backend:
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
POSTGRES_DB: meet
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
REDIS_URL: redis://default:pass@redis-master:6379/1
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
LIVEKIT_API_SECRET: secret
+105 -144
View File
@@ -1,8 +1,8 @@
# Installation on a k8s cluster
This document is a step-by-step guide that describes how to install LaSuite Meet on a k8s cluster without AI features.
This document is a step-by-step guide that describes how to install Visio on a k8s cluster without AI features.
## Prerequisites for a kubernetes setup
## Prerequisites
- k8s cluster with an nginx-ingress controller
- an OIDC provider (if you don't have one, we will provide an example)
@@ -12,25 +12,14 @@ This document is a step-by-step guide that describes how to install LaSuite Meet
### Test cluster
If you do not have a kubernetes test cluster, you can install everything on a local kind cluster. In this case, the simplest way is to use our script located in this repo under **bin/start-kind.sh**.
If you do not have a test cluster, you can install everything on a local kind cluster. In this case, the simplest way is to use our script **bin/start-kind.sh**.
IMPORTANT: The kind method will only deploy meet as a local instance(127.0.0.1) that can only be accessed from the device where it has been deployed.
To be able to use the script, you will need to install the following components:
To be able to use the script, you will need to install:
- Docker (https://docs.docker.com/desktop/)
- Kind (https://kind.sigs.k8s.io/docs/user/quick-start/#installation)
- Mkcert (https://github.com/FiloSottile/mkcert#installation)
- Helm (https://helm.sh/docs/intro/quickstart/#install-helm)
- kubectl (https://kubernetes.io/docs/tasks/tools/)
In order to initiate the local kind installation via **start-kind.sh** do the following:
1) Make sure administrator/root user context is able to execute mkcert, docker, kind etc. commands or the script might fail
2) Download the script to the device where the above components are installed
3) Make the script executable
4) Run the script with proper permissions (administrator/sudo etc.)
The output of the script will resemble the below example:
```
$ ./bin/start-kind.sh
@@ -110,11 +99,11 @@ When your k8s cluster is ready, you can start the deployment. This cluster is sp
Please remember that \*.127.0.0.1.nip.io will always resolve to 127.0.0.1, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP.
## Preparation of components
## Preparation
### What will you use to authenticate your users ?
LaSuite Meet uses OIDC, so if you already have an OIDC provider, obtain the necessary information to use it. In the next step, we will see how to configure Django (and thus LaSuite Meet) to use it. If you do not have a provider, we will show you how to deploy a local Keycloak instance (this is not a production deployment, just a demo).
Visio uses OIDC, so if you already have an OIDC provider, obtain the necessary information to use it. In the next step, we will see how to configure Django (and thus Visio) to use it. If you do not have a provider, we will show you how to deploy a local Keycloak instance (this is not a production deployment, just a demo).
If you haven't run the script **bin/start-kind.sh**, you'll need to manually create the namespace by running the following command:
@@ -122,7 +111,7 @@ If you haven't run the script **bin/start-kind.sh**, you'll need to manually cre
$ kubectl create namespace meet
```
If you have already run the script, you can skip this step and proceed to the next instruction. NOTE: Before you proceed, and is using the kind method, make sure you download this repo examples/ directory and its contents to the location where you will be executing the helm command. Helm will look for "examples/<name>values.yaml" from based on the path it is being executed.
If you have already run the script, you can skip this step and proceed to the next instruction.
```
$ kubectl config set-context --current --namespace=meet
@@ -134,8 +123,6 @@ keycloak-0 1/1 Running 0 6m48s
keycloak-postgresql-0 1/1 Running 0 6m48s
```
In your OIDC provider, set LaSuite Meet's redirect URI to `https://.../api/v1.0/callback/` where `...` should be replaced with the domain name LaSuite Meet is hosted on.
From here the important information you will need are :
```
@@ -154,7 +141,7 @@ You can find these values in **examples/keycloak.values.yaml**
### Find livekit server connexion values
LaSuite Meet use livekit for streaming part so if you have a livekit provider, obtain the necessary information to use it. If you do not have a provider, you can install a livekit testing environment as follows:
Visio use livekit for streaming part so if you have a livekit provider, obtain the necessary information to use it. If you do not have a provider, you can install a livekit testing environment as follows:
Livekit need a redis (and meet too) so we will start by deploying a redis :
@@ -196,7 +183,7 @@ CELERY_RESULT_BACKEND: redis://default:pass@redis-master:6379/1
### Find postgresql connexion values
LaSuite Meet uses a postgresql db as backend so if you have a provider, obtain the necessary information to use it. If you do not have, you can install a postgresql testing environment as follows:
Visio uses a postgresql db as backend so if you have a provider, obtain the necessary information to use it. If you do not have, you can install a postgresql testing environment as follows:
```
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml
@@ -217,11 +204,14 @@ DB_NAME: meet
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
POSTGRES_DB: meet
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
```
## Deployment
Now you are ready to deploy LaSuite Meet without AI. AI required more dependencies (Openai-compliant API, LiveKit Egress, Cold storage and a docs deployment to push resumes). To deploy meet you need to provide all previous information to the helm chart.
Now you are ready to deploy Visio without AI. AI required more dependencies (Openai-compliant API, LiveKit Egress, Cold storage and a docs deployment to push resumes). To deploy meet you need to provide all previous information to the helm chart.
```
$ helm repo add meet https://suitenumerique.github.io/meet/
@@ -242,129 +232,100 @@ meet <none> meet.127.0.0.1.nip.io localhost 80, 44
meet-admin <none> meet.127.0.0.1.nip.io localhost 80, 443 52m
```
You can use LaSuite Meet on https://meet.127.0.0.1.nip.io from the local device. The provisioning user in keycloak is meet/meet.
You can use Visio on https://meet.127.0.0.1.nip.io. The provisioning user in keycloak is meet/meet.
## All options
These are the environmental options available on meet backend.
| Option | Description | default |
|-------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|
| DATA_DIR | Data directory location | /data |
| DJANGO_ALLOWED_HOSTS | Hosts that are allowed | [] |
| DJANGO_SECRET_KEY | Secret key used for Django security | |
| DJANGO_SILENCED_SYSTEM_CHECKS | Silence Django system checks | [] |
| DJANGO_ALLOW_UNSECURE_USER_LISTING | Allow unsecure user listing | false |
| DB_ENGINE | Database engine used | django.db.backends.postgresql_psycopg2 |
| DB_NAME | Name of the database | meet |
| DB_USER | User used to connect to database | dinum |
| DB_PASSWORD | Password used to connect to the database | pass |
| DB_HOST | Hostname of the database | localhost |
| DB_PORT | Port to connect to database | 5432 |
| STORAGES_STATICFILES_BACKEND | Static file serving engine | whitenoise.storage.CompressedManifestStaticFilesStorage |
| AWS_S3_ENDPOINT_URL | S3 host endpoint | |
| AWS_S3_ACCESS_KEY_ID | S3 access key | |
| AWS_S3_SECRET_ACCESS_KEY | S3 secret key | |
| AWS_S3_REGION_NAME | S3 region | |
| AWS_STORAGE_BUCKET_NAME | S3 bucket name | meet-media-storage |
| DJANGO_LANGUAGE_CODE | Default language | en-us |
| REDIS_URL | Redis endpoint | redis://redis:6379/1 |
| SESSION_COOKIE_AGE | Session cookie expiration in seconds | 43200 (12 hours) |
| REQUEST_ENTRY_THROTTLE_RATES | Entry request throttle rates | 150/minute |
| CREATION_CALLBACK_THROTTLE_RATES | Creation callback throttle rates | 600/minute |
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | Enable Django deploy check | false |
| CSRF_TRUSTED_ORIGINS | CSRF trusted origins list | [] |
| FRONTEND_CUSTOM_CSS_URL | URL of an additional CSS file to load in the frontend app. If set, a `<link>` tag with this URL as href is added to the `<head>` of the frontend app | |
| FRONTEND_ANALYTICS | Analytics information | {} |
| FRONTEND_SUPPORT | Crisp frontend support configuration, also you can pass help articles, with `help_article_transcript`, `help_article_recording`, `help_article_more_tools` | {} |
| FRONTEND_TRANSCRIPT | Frontend transcription configuration, you can pass a beta form, with `form_beta_users` | {} |
| FRONTEND_MANIFEST_LINK | Link to the "Learn more" button on the homepage | {} |
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
| FRONTEND_USE_FRENCH_GOV_FOOTER | Show the French government footer in the homepage | false |
| FRONTEND_USE_PROCONNECT_BUTTON | Show a "Login with ProConnect" button in the homepage instead of a "Login" button | false |
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
| DJANGO_EMAIL_HOST | Host of the email server | |
| DJANGO_EMAIL_HOST_USER | User to connect to the email server | |
| DJANGO_EMAIL_HOST_PASSWORD | Password to connect to the email server | |
| DJANGO_EMAIL_PORT | Port to connect to the email server | |
| DJANGO_EMAIL_USE_TLS | Enable TLS on email connection | false |
| DJANGO_EMAIL_USE_SSL | Enable SSL on email connection | false |
| DJANGO_EMAIL_FROM | Email from account | from@example.com |
| EMAIL_BRAND_NAME | Email branding name | |
| EMAIL_SUPPORT_EMAIL | Support email address | |
| EMAIL_LOGO_IMG | Email logo image | |
| EMAIL_DOMAIN | Email domain | |
| EMAIL_APP_BASE_URL | Email app base URL | |
| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false |
| DJANGO_CORS_ALLOWED_ORIGINS | Origins to allow (string list) | [] |
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | Origins to allow (regex patterns) | [] |
| SENTRY_DSN | Sentry server DSN | |
| DJANGO_CELERY_BROKER_URL | Celery broker host | redis://redis:6379/0 |
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker options | {} |
| OIDC_CREATE_USER | Create OIDC user if not exists | true |
| OIDC_VERIFY_SSL | Verify SSL for OIDC | true |
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | false |
| OIDC_RP_SIGN_ALGO | Token verification algorithm used by OIDC | RS256 |
| OIDC_RP_CLIENT_ID | OIDC client ID | meet |
| OIDC_RP_CLIENT_SECRET | OIDC client secret | |
| OIDC_OP_JWKS_ENDPOINT | OIDC endpoint for JWKS | |
| OIDC_OP_AUTHORIZATION_ENDPOINT | OIDC endpoint for authorization | |
| OIDC_OP_TOKEN_ENDPOINT | OIDC endpoint for token | |
| OIDC_OP_USER_ENDPOINT | OIDC endpoint for user | |
| OIDC_OP_USER_ENDPOINT_FORMAT | OIDC endpoint format (AUTO, JWT, JSON) | AUTO |
| OIDC_OP_LOGOUT_ENDPOINT | OIDC endpoint for logout | |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | Extra parameters for OIDC request | {} |
| OIDC_RP_SCOPES | OIDC scopes | openid email |
| OIDC_USE_NONCE | Use nonce for OIDC | true |
| OIDC_REDIRECT_REQUIRE_HTTPS | Require HTTPS for OIDC | false |
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed redirect hosts for OIDC | [] |
| OIDC_STORE_ID_TOKEN | Store OIDC ID token | true |
| OIDC_REDIRECT_FIELD_NAME | Redirect field for OIDC | returnTo |
| OIDC_USERINFO_FULLNAME_FIELDS | Full name claim from OIDC token | ["given_name", "usual_name"] |
| OIDC_USERINFO_SHORTNAME_FIELD | Short name claim from OIDC token | given_name |
| OIDC_USERINFO_ESSENTIAL_CLAIMS | Required claims from OIDC token | [] |
| OIDC_USE_PKCE | Enable the use of PKCE (Proof Key for Code Exchange) during the OAuth 2.0 authorization code flow. Recommended for enhanced security. | False |
| OIDC_PKCE_CODE_CHALLENGE_METHOD | Method used to generate the PKCE code challenge. Common values include S256 and plain. Refer to the mozilla-django-oidc documentation for supported options. | S256 |
| OIDC_PKCE_CODE_VERIFIER_SIZE | Length of the random string used as the PKCE code verifier. Must be an integer between 43 and 128, inclusive. | 64 |
| LOGIN_REDIRECT_URL | Login redirect URL | |
| LOGIN_REDIRECT_URL_FAILURE | Login redirect URL for failure | |
| LOGOUT_REDIRECT_URL | URL to redirect to on logout | |
| ALLOW_LOGOUT_GET_METHOD | Allow logout through GET method | true |
| LIVEKIT_API_KEY | LiveKit API key | |
| LIVEKIT_API_SECRET | LiveKit API secret | |
| LIVEKIT_API_URL | LiveKit API URL | |
| LIVEKIT_VERIFY_SSL | Verify SSL for LiveKit connections | true |
| LIVEKIT_FORCE_WSS_PROTOCOL | Enables WSS protocol conversion for legacy browser compatibility (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs fail in WebSocket() constructor. | false |
| LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND | Firefox-only connection warmup: pre-calls WebSocket endpoint (expecting 401) to initialize cache, resolving proxy/network connectivity issues. | false |
| RESOURCE_DEFAULT_ACCESS_LEVEL | Default resource access level for rooms | public |
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
| RECORDING_ENABLE | Record meeting option | false |
| RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings |
| RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
| RECORDING_EVENT_PARSER_CLASS | Storage event engine for recording | core.recording.event.parsers.MinioParser |
| RECORDING_ENABLE_STORAGE_EVENT_AUTH | Enable storage event authorization | true |
| RECORDING_STORAGE_EVENT_ENABLE | Enable recording storage events | false |
| RECORDING_STORAGE_EVENT_TOKEN | Recording storage event token | |
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
| RECORDING_MAX_DURATION | Maximum recording duration in milliseconds. Must match LiveKit Egress configuration exactly. | |
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
| SUMMARY_SERVICE_ENDPOINT | Summary service endpoint | |
| SUMMARY_SERVICE_API_TOKEN | API token for summary service | |
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Signup users to marketing emails | false |
| MARKETING_SERVICE_CLASS | Marketing service class | core.services.marketing.BrevoMarketingService |
| BREVO_API_KEY | Brevo API key for marketing emails | |
| BREVO_API_CONTACT_LIST_IDS | Brevo API contact list IDs | [] |
| DJANGO_BREVO_API_CONTACT_ATTRIBUTES | Brevo contact attributes | {"VISIO_USER": true} |
| BREVO_API_TIMEOUT | Brevo timeout in seconds | 1 |
| LOBBY_KEY_PREFIX | Lobby key prefix | room_lobby |
| LOBBY_WAITING_TIMEOUT | Lobby waiting timeout in seconds | 3 |
| LOBBY_DENIED_TIMEOUT | Lobby deny timeout in seconds | 5 |
| LOBBY_ACCEPTED_TIMEOUT | Lobby accept timeout in seconds | 21600 (6 hours) |
| LOBBY_NOTIFICATION_TYPE | Lobby notification types | participantWaiting |
| LOBBY_COOKIE_NAME | Lobby cookie name | lobbyParticipantId |
| ROOM_CREATION_CALLBACK_CACHE_TIMEOUT | Room creation callback cache timeout | 600 (10 minutes) |
| ROOM_TELEPHONY_ENABLED | Enable SIP telephony feature | false |
| ROOM_TELEPHONY_PIN_LENGTH | Telephony PIN length | 10 |
| ROOM_TELEPHONY_PIN_MAX_RETRIES | Telephony PIN maximum retries | 5 |
| Option | Description | default |
| ----------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| DJANGO_ALLOWED_HOSTS | Host that are allowed | [] |
| DJANGO_SECRET_KEY | Secret key used | |
| DJANGO_SILENCED_SYSTEM_CHECKS | Silence system checks | [] |
| DJANGO_ALLOW_UNSECURE_USER_LISTING | | false |
| DB_ENGINE | Database engine used | django.db.backends.postgresql_psycopg2 |
| DB_NAME | name of the database | meet |
| DB_USER | user used to connect to database | dinum |
| DB_PASSWORD | password used to connect to the database | pass |
| DB_HOST | hostname of the database | localhost |
| DB_PORT | port to connect to database | 5432 |
| STORAGES_STATICFILES_BACKEND | Static file serving engine | whitenoise.storage.CompressedManifestStaticFilesStorage |
| AWS_S3_ENDPOINT_URL | S3 host endpoint | |
| AWS_S3_ACCESS_KEY_ID | s3 access key | |
| AWS_S3_SECRET_ACCESS_KEY | s3 secret key | |
| AWS_S3_REGION_NAME | s3 region | |
| AWS_STORAGE_BUCKET_NAME | s3 bucket name | |
| DJANGO_LANGUAGE_CODE | Default language | en-us |
| REDIS_URL | redis endpoint | redis://redis:6379/1 |
| REQUEST_ENTRY_THROTTLE_RATES | throttle rates | 150/minute |
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | deploy check | False |
| FRONTEND_ANALYTICS | analytics information | {} |
| FRONTEND_SUPPORT | crisp frontend support | {} |
| FRONTEND_SILENCE_LIVEKIT_DEBUG | silence livekit debug | false |
| DJANGO_EMAIL_BACKEND | email backend library | django.core.mail.backends.smtp.EmailBackend |
| DJANGO_EMAIL_HOST | host of the email server | |
| DJANGO_EMAIL_HOST_USER | user to connect to the email server | |
| DJANGO_EMAIL_HOST_PASSWORD | password to connect tto the email server | |
| DJANGO_EMAIL_PORT | por tot connect to the email server | |
| DJANGO_EMAIL_USE_TLS | enable tls on email connection | false |
| DJANGO_EMAIL_USE_SSL | enable tls on email connection | false |
| DJANGO_EMAIL_FROM | email from account | from@example.com |
| DJANGO_CORS_ALLOW_ALL_ORIGINS | allow all cors origins | true |
| DJANGO_CORS_ALLOWED_ORIGINS | origins to allow in string | [] |
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | origins to allow in regex | [] |
| SENTRY_DSN | sentry server | |
| DJANGO_CELERY_BROKER_URL | celery broker host | redis://redis:6379/0 |
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | celery broker options | {} |
| OIDC_CREATE_USER | create oidc user if not exists | false |
| OIDC_VERIFY_SSL | verify ssl for oidc | true |
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | fallback to email for identification | false |
| OIDC_RP_SIGN_ALGO | token verification algoritm used by oidc | RS256 |
| OIDC_RP_CLIENT_ID | oidc client | meet |
| OIDC_RP_CLIENT_SECRET | oidc client secret | |
| OIDC_OP_JWKS_ENDPOINT | oidc endpoint for JWKS | |
| OIDC_OP_AUTHORIZATION_ENDPOINT | oidc endpoint for authorization | |
| OIDC_OP_TOKEN_ENDPOINT | oidc endpoint for token | |
| OIDC_OP_USER_ENDPOINT | oidc endpoint for user | |
| OIDC_OP_USER_ENDPOINT_FORMAT | oidc endpoint format (AUTO, JWT, JSON) | AUTO |
| OIDC_OP_LOGOUT_ENDPOINT | oidc endpoint for logout | |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | extra parameters for oidc request | |
| OIDC_RP_SCOPES | oidc scopes | openid email |
| LOGIN_REDIRECT_URL | login redirect url | |
| LOGIN_REDIRECT_URL_FAILURE | login redurect url for failure | |
| LOGOUT_REDIRECT_URL | url to redirect to on logout | |
| OIDC_USE_NONCE | use nonce for oidc | true |
| OIDC_REDIRECT_REQUIRE_HTTPS | require https for oidc | false |
| OIDC_REDIRECT_ALLOWED_HOSTS | allowed redirect hosts for oidc | [] |
| OIDC_STORE_ID_TOKEN | store oidc ID token | true |
| ALLOW_LOGOUT_GET_METHOD | allow logout through get method | true |
| OIDC_REDIRECT_FIELD_NAME | direct field for oidc | returnTo |
| OIDC_USERINFO_FULLNAME_FIELDS | full name claim from OIDC token | ["given_name", "usual_name"] |
| OIDC_USERINFO_SHORTNAME_FIELD | shortname claim from OIDC token | given_name |
| OIDC_USERINFO_ESSENTIAL_CLAIMS | required claims from OIDC token | [] |
| LIVEKIT_API_KEY | livekit api key | |
| LIVEKIT_API_SECRET | livekit api secret | |
| LIVEKIT_API_URL | livekit api url | |
| RESOURCE_DEFAULT_ACCESS_LEVEL | default resource access level for rooms | public |
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
| RECORDING_ENABLE | record meeting option | false |
| RECORDING_OUTPUT_FOLDER | folder to store meetings | recordings |
| RECORDING_WORKER_CLASSES | worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
| RECORDING_EVENT_PARSER_CLASS | storage event engine for recording | core.recording.event.parsers.MinioParser |
| RECORDING_ENABLE_STORAGE_EVENT_AUTH | enable storage event authorization | true |
| RECORDING_STORAGE_EVENT_ENABLE | enable recording storage events | false |
| RECORDING_STORAGE_EVENT_TOKEN | recording storage event token | |
| SUMMARY_SERVICE_ENDPOINT | summary service endpoint | |
| SUMMARY_SERVICE_API_TOKEN | api token voor summary service | |
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | signup users to marketing emails | false |
| MARKETING_SERVICE_CLASS | markering class | core.services.marketing.BrevoMarketingService |
| BREVO_API_KEY | breva api key for marketing emails | |
| BREVO_API_CONTACT_LIST_IDS | brevo api contact list ids | [] |
| DJANGO_BREVO_API_CONTACT_ATTRIBUTES | brevo contact attributes | {"VISIO_USER": True} |
| BREVO_API_TIMEOUT | brevo timeout | 1 |
| LOBBY_KEY_PREFIX | lobby prefix | room_lobby |
| LOBBY_WAITING_TIMEOUT | lobby waiting tumeout | 3 |
| LOBBY_DENIED_TIMEOUT | lobby deny timeout | 5 |
| LOBBY_ACCEPTED_TIMEOUT | lobby accept timeout | 21600 |
| LOBBY_NOTIFICATION_TYPE | lobby notification types | participantWaiting |
| LOBBY_COOKIE_NAME | lobby cookie name | lobbyParticipantId |
+1 -12
View File
@@ -42,7 +42,7 @@ LOGIN_REDIRECT_URL=http://localhost:3000
LOGIN_REDIRECT_URL_FAILURE=http://localhost:3000
LOGOUT_REDIRECT_URL=http://localhost:3000
OIDC_REDIRECT_ALLOWED_HOSTS=localhost:8083,localhost:3000
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
# Livekit Token settings
@@ -53,15 +53,4 @@ LIVEKIT_VERIFY_SSL=False
ALLOW_UNREGISTERED_ROOMS=False
# Recording
RECORDING_ENABLE=True
RECORDING_STORAGE_EVENT_ENABLE=True
RECORDING_STORAGE_EVENT_TOKEN=password
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v1/tasks/
SUMMARY_SERVICE_API_TOKEN=password
SCREEN_RECORDING_BASE_URL=http://localhost:3000/recordings
# Telephony
ROOM_TELEPHONY_ENABLED=True
FRONTEND_USE_FRENCH_GOV_FOOTER=False
FRONTEND_USE_PROCONNECT_BUTTON=False
-23
View File
@@ -1,23 +0,0 @@
APP_NAME="meet-app-summary-dev"
APP_API_TOKEN="password"
AWS_STORAGE_BUCKET_NAME="meet-media-storage"
AWS_S3_ENDPOINT_URL="minio:9000"
AWS_S3_SECURE_ACCESS=false
AWS_S3_ACCESS_KEY_ID="meet"
AWS_S3_SECRET_ACCESS_KEY="password"
WHISPERX_BASE_URL="https://configure-your-url.com"
WHISPERX_ASR_MODEL="large-v2"
WHISPERX_API_KEY="your-secret-key"
LLM_BASE_URL="https://configure-your-url.com"
LLM_API_KEY="dev-apikey"
LLM_MODEL="Qwen/Qwen2.5-Coder-32B-Instruct-AWQ"
WEBHOOK_API_TOKEN="secret"
WEBHOOK_URL="https://configure-your-url.com"
POSTHOG_API_KEY="your-posthog-key"
POSTHOG_ENABLED="False"
+1 -1
View File
@@ -7,7 +7,7 @@
"enabled": false,
"groupName": "ignored python dependencies",
"matchManagers": ["pep621"],
"matchPackageNames": ["redis"]
"matchPackageNames": []
},
{
"enabled": false,
-24
View File
@@ -1,24 +0,0 @@
FROM python:3.13-slim AS base
FROM base AS builder
WORKDIR /builder
COPY pyproject.toml .
RUN mkdir /install && \
pip install --prefix=/install .
FROM base AS production
WORKDIR /app
ARG DOCKER_USER
USER ${DOCKER_USER}
# Un-privileged user running the application
COPY --from=builder /install /usr/local
COPY . .
CMD ["python", "multi-user-transcriber.py", "start"]
-189
View File
@@ -1,189 +0,0 @@
"""Multi user transcription agent."""
import asyncio
import logging
import os
from dotenv import load_dotenv
from livekit import api, rtc
from livekit.agents import (
Agent,
AgentSession,
AutoSubscribe,
JobContext,
JobProcess,
JobRequest,
RoomInputOptions,
RoomIO,
RoomOutputOptions,
WorkerOptions,
WorkerPermissions,
cli,
utils,
)
from livekit.plugins import deepgram, silero
load_dotenv()
logger = logging.getLogger("transcriber")
TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcriber")
class Transcriber(Agent):
"""Create a transcription agent for a specific participant."""
def __init__(self, *, participant_identity: str):
"""Init transcription agent."""
super().__init__(
instructions="not-needed",
stt=deepgram.STT(),
)
self.participant_identity = participant_identity
class MultiUserTranscriber:
"""Manage transcription sessions for multiple room participants."""
def __init__(self, ctx: JobContext):
"""Init multi user transcription agent."""
self.ctx = ctx
self._sessions: dict[str, AgentSession] = {}
self._tasks: set[asyncio.Task] = set()
def start(self):
"""Start listening for participant connection events."""
self.ctx.room.on("participant_connected", self.on_participant_connected)
self.ctx.room.on("participant_disconnected", self.on_participant_disconnected)
async def aclose(self):
"""Close all sessions and cleanup resources."""
await utils.aio.cancel_and_wait(*self._tasks)
await asyncio.gather(
*[self._close_session(session) for session in self._sessions.values()]
)
self.ctx.room.off("participant_connected", self.on_participant_connected)
self.ctx.room.off("participant_disconnected", self.on_participant_disconnected)
def on_participant_connected(self, participant: rtc.RemoteParticipant):
"""Handle new participant connection by starting transcription session."""
if participant.identity in self._sessions:
return
logger.info(f"starting session for {participant.identity}")
task = asyncio.create_task(self._start_session(participant))
self._tasks.add(task)
def on_task_done(task: asyncio.Task):
try:
self._sessions[participant.identity] = task.result()
finally:
self._tasks.discard(task)
task.add_done_callback(on_task_done)
def on_participant_disconnected(self, participant: rtc.RemoteParticipant):
"""Handle participant disconnection by closing transcription session."""
if (session := self._sessions.pop(participant.identity)) is None:
return
logger.info(f"closing session for {participant.identity}")
task = asyncio.create_task(self._close_session(session))
self._tasks.add(task)
task.add_done_callback(lambda _: self._tasks.discard(task))
async def _start_session(self, participant: rtc.RemoteParticipant) -> AgentSession:
"""Create and start transcription session for participant."""
if participant.identity in self._sessions:
return self._sessions[participant.identity]
session = AgentSession(
vad=self.ctx.proc.userdata["vad"],
)
room_io = RoomIO(
agent_session=session,
room=self.ctx.room,
participant=participant,
input_options=RoomInputOptions(
text_enabled=False,
),
output_options=RoomOutputOptions(
transcription_enabled=True,
audio_enabled=False,
),
)
await room_io.start()
await session.start(
agent=Transcriber(
participant_identity=participant.identity,
)
)
return session
async def _close_session(self, sess: AgentSession) -> None:
"""Close and cleanup transcription session."""
await sess.drain()
await sess.aclose()
async def entrypoint(ctx: JobContext):
"""Initialize and run the multi-user transcriber."""
transcriber = MultiUserTranscriber(ctx)
transcriber.start()
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
for participant in ctx.room.remote_participants.values():
transcriber.on_participant_connected(participant)
async def cleanup():
await transcriber.aclose()
ctx.add_shutdown_callback(cleanup)
async def handle_transcriber_job_request(job_req: JobRequest) -> None:
"""Accept job if no transcriber exists in room, otherwise reject."""
room_name = job_req.room.name
transcriber_id = f"{TRANSCRIBER_AGENT_NAME}-{room_name}"
async with api.LiveKitAPI() as lkapi:
try:
response = await lkapi.room.list_participants(
list=api.ListParticipantsRequest(room=room_name)
)
transcriber_exists = any(
p.kind == rtc.ParticipantKind.PARTICIPANT_KIND_AGENT
and p.identity == transcriber_id
for p in response.participants
)
if transcriber_exists:
logger.info(f"Transcriber exists in {room_name} - rejecting")
await job_req.reject()
else:
logger.info(f"Accepting job for {room_name}")
await job_req.accept(identity=transcriber_id)
except Exception:
logger.exception(f"Error processing job for {room_name}")
await job_req.reject()
def prewarm(proc: JobProcess):
"""Preload voice activity detection model."""
proc.userdata["vad"] = silero.VAD.load()
if __name__ == "__main__":
cli.run_app(
WorkerOptions(
entrypoint_fnc=entrypoint,
request_fnc=handle_transcriber_job_request,
prewarm_fnc=prewarm,
agent_name=TRANSCRIBER_AGENT_NAME,
permissions=WorkerPermissions(hidden=True),
)
)
-51
View File
@@ -1,51 +0,0 @@
[project]
name = "agents"
version = "0.1.38"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.2.6",
"livekit-plugins-deepgram==1.2.6",
"livekit-plugins-silero==1.2.6",
"python-dotenv==1.1.1"
]
[project.optional-dependencies]
dev = [
"ruff==0.12.0",
]
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[tool.ruff]
target-version = "py313"
[tool.ruff.lint]
select = [
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"D", # pydocstyle
"E", # pycodestyle error
"F", # Pyflakes
"I", # Isort
"ISC", # flake8-implicit-str-concat
"PLC", # Pylint Convention
"PLE", # Pylint Error
"PLR", # Pylint Refactor
"PLW", # Pylint Warning
"RUF100", # Ruff unused-noqa
"S", # flake8-bandit
"T20", # flake8-print
"W", # pycodestyle warning
]
[tool.ruff.lint.per-file-ignores]
"tests/*" = [
"S101", # use of assert
]
[tool.ruff.lint.pydocstyle]
# Use Google-style docstrings.
convention = "google"
+1 -30
View File
@@ -99,7 +99,6 @@ class ResourceAccessInline(admin.TabularInline):
model = models.ResourceAccess
extra = 0
autocomplete_fields = ["user"]
@admin.register(models.Room)
@@ -107,10 +106,6 @@ class RoomAdmin(admin.ModelAdmin):
"""Room admin interface declaration."""
inlines = (ResourceAccessInline,)
search_fields = ["name", "slug", "=id"]
list_display = ["name", "slug", "access_level", "created_at"]
list_filter = ["access_level", "created_at"]
readonly_fields = ["id", "created_at", "updated_at"]
class RecordingAccessInline(admin.TabularInline):
@@ -125,28 +120,4 @@ class RecordingAdmin(admin.ModelAdmin):
"""Recording admin interface declaration."""
inlines = (RecordingAccessInline,)
search_fields = ["status", "=id", "worker_id", "room__slug", "=room__id"]
list_display = ("id", "status", "room", "get_owner", "created_at", "worker_id")
list_filter = ["status", "room", "created_at"]
readonly_fields = ["id", "created_at", "updated_at"]
def get_queryset(self, request):
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
return super().get_queryset(request).prefetch_related("accesses__user")
def get_owner(self, obj):
"""Return the owner of the recording for display in the admin list."""
owners = [
access
for access in obj.accesses.all()
if access.role == models.RoleChoices.OWNER
]
if not owners:
return _("No owner")
if len(owners) > 1:
return _("Multiple owners")
return str(owners[0].user)
list_display = ("id", "status", "room", "created_at", "worker_id")
-15
View File
@@ -41,21 +41,6 @@ def get_frontend_configuration(request):
"is_enabled": settings.RECORDING_ENABLE,
"available_modes": settings.RECORDING_WORKER_CLASSES.keys(),
"expiration_days": settings.RECORDING_EXPIRATION_DAYS,
"max_duration": settings.RECORDING_MAX_DURATION,
},
"telephony": {
"enabled": settings.ROOM_TELEPHONY_ENABLED,
"phone_number": settings.ROOM_TELEPHONY_PHONE_NUMBER
if settings.ROOM_TELEPHONY_ENABLED
else None,
"default_country": settings.ROOM_TELEPHONY_DEFAULT_COUNTRY,
},
"subtitle": {"enabled": settings.ROOM_SUBTITLE_ENABLED},
"livekit": {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL,
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
},
}
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
-45
View File
@@ -1,45 +0,0 @@
"""Feature flag handler for the Meet core app."""
from functools import wraps
from django.conf import settings
from django.http import Http404
class FeatureFlag:
"""Check if features are enabled and return error responses."""
FLAGS = {
"recording": "RECORDING_ENABLE",
"storage_event": "RECORDING_STORAGE_EVENT_ENABLE",
"subtitle": "ROOM_SUBTITLE_ENABLED",
}
@classmethod
def flag_is_active(cls, flag_name):
"""Check if a feature flag is active."""
setting_name = cls.FLAGS.get(flag_name)
if setting_name is None:
return False
return getattr(settings, setting_name, False)
@classmethod
def require(cls, flag_name):
"""Decorator to check feature at the beginning of endpoint methods."""
if flag_name not in cls.FLAGS:
raise ValueError(f"Unknown feature flag: {flag_name}")
def decorator(view_func):
@wraps(view_func)
def wrapper(self, request, *args, **kwargs):
if not cls.flag_is_active(flag_name):
raise Http404
return view_func(self, request, *args, **kwargs)
return wrapper
return decorator
+22 -9
View File
@@ -1,5 +1,7 @@
"""Permission handlers for the Meet core app."""
from django.conf import settings
from rest_framework import permissions
from ..models import RoleChoices
@@ -62,7 +64,7 @@ class RoomPermissions(permissions.BasePermission):
if request.method == "DELETE":
return obj.is_owner(user)
return obj.is_administrator_or_owner(user)
return obj.is_administrator(user)
class ResourceAccessPermission(IsAuthenticated):
@@ -78,7 +80,7 @@ class ResourceAccessPermission(IsAuthenticated):
if request.method == "DELETE" and obj.role == RoleChoices.OWNER:
return obj.user == user
return obj.resource.is_administrator_or_owner(user)
return obj.resource.is_administrator(user)
class HasAbilityPermission(IsAuthenticated):
@@ -96,13 +98,24 @@ class HasPrivilegesOnRoom(IsAuthenticated):
def has_object_permission(self, request, view, obj):
"""Determine if user has privileges on room."""
return obj.is_administrator_or_owner(request.user)
return obj.is_owner(request.user) or obj.is_administrator(request.user)
class HasLiveKitRoomAccess(permissions.BasePermission):
"""Check if authenticated user's LiveKit token is for the specific room."""
class IsRecordingEnabled(permissions.BasePermission):
"""Check if the recording feature is enabled."""
def has_object_permission(self, request, view, obj):
if not request.auth or not hasattr(request.auth, "video"):
return False
return request.auth.video.room == str(obj.id)
message = "Access denied, recording is disabled."
def has_permission(self, request, view):
"""Determine if access is allowed based on settings."""
return settings.RECORDING_ENABLE
class IsStorageEventEnabled(permissions.BasePermission):
"""Check if the storage event feature is enabled."""
message = "Access denied, storage event is disabled."
def has_permission(self, request, view):
"""Determine if access is allowed based on settings."""
return settings.RECORDING_STORAGE_EVENT_ENABLE
+53 -104
View File
@@ -1,10 +1,7 @@
"""Client serializers for the Meet core app."""
# pylint: disable=abstract-method,no-name-in-module
from django.utils.translation import gettext_lazy as _
from livekit.api import ParticipantPermission
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from timezone_field.rest_framework import TimeZoneSerializerField
@@ -40,10 +37,10 @@ class ResourceAccessSerializerMixin:
# Update
self.instance
and (
data.get("role") == models.RoleChoices.OWNER
data["role"] == models.RoleChoices.OWNER
and not self.instance.resource.is_owner(user)
or self.instance.role == models.RoleChoices.OWNER
and self.instance.user != user
and not self.instance.user == user
)
) or (
# Create
@@ -61,9 +58,7 @@ class ResourceAccessSerializerMixin:
request = self.context.get("request", None)
user = getattr(request, "user", None)
if not (
user and user.is_authenticated and resource.is_administrator_or_owner(user)
):
if not (user and user.is_authenticated and resource.is_administrator(user)):
raise PermissionDenied(
_("You must be administrator or owner of a room to add accesses to it.")
)
@@ -108,8 +103,8 @@ class RoomSerializer(serializers.ModelSerializer):
class Meta:
model = models.Room
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"]
read_only_fields = ["id", "slug", "pin_code"]
fields = ["id", "name", "slug", "configuration", "access_level"]
read_only_fields = ["id", "slug"]
def to_representation(self, instance):
"""
@@ -123,11 +118,9 @@ class RoomSerializer(serializers.ModelSerializer):
return output
role = instance.get_role(request.user)
is_admin_or_owner = models.RoleChoices.check_administrator_role(
role
) or models.RoleChoices.check_owner_role(role)
is_admin = models.RoleChoices.check_administrator_role(role)
if is_admin_or_owner:
if is_admin:
access_serializer = NestedResourceAccessSerializer(
instance.accesses.select_related("resource", "user").all(),
context=self.context,
@@ -135,9 +128,7 @@ class RoomSerializer(serializers.ModelSerializer):
)
output["accesses"] = access_serializer.data
configuration = output["configuration"]
if not is_admin_or_owner:
if not is_admin:
del output["configuration"]
should_access_room = (
@@ -153,14 +144,10 @@ class RoomSerializer(serializers.ModelSerializer):
room_id = f"{instance.id!s}"
username = request.query_params.get("username", None)
output["livekit"] = utils.generate_livekit_config(
room_id=room_id,
user=request.user,
username=username,
configuration=configuration,
is_admin_or_owner=is_admin_or_owner,
room_id=room_id, user=request.user, username=username
)
output["is_administrable"] = is_admin_or_owner
output["is_administrable"] = is_admin
return output
@@ -186,19 +173,7 @@ class RecordingSerializer(serializers.ModelSerializer):
read_only_fields = fields
class BaseValidationOnlySerializer(serializers.Serializer):
"""Base serializer for validation-only operations."""
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
class StartRecordingSerializer(BaseValidationOnlySerializer):
class StartRecordingSerializer(serializers.Serializer):
"""Validate start recording requests."""
mode = serializers.ChoiceField(
@@ -211,93 +186,67 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
},
)
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("StartRecordingSerializer is validation-only")
class RequestEntrySerializer(BaseValidationOnlySerializer):
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("StartRecordingSerializer is validation-only")
class RequestEntrySerializer(serializers.Serializer):
"""Validate request entry data."""
username = serializers.CharField(required=True)
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("RequestEntrySerializer is validation-only")
class ParticipantEntrySerializer(BaseValidationOnlySerializer):
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("RequestEntrySerializer is validation-only")
class ParticipantEntrySerializer(serializers.Serializer):
"""Validate participant entry decision data."""
participant_id = serializers.UUIDField(required=True)
participant_id = serializers.CharField(required=True)
allow_entry = serializers.BooleanField(required=True)
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("ParticipantEntrySerializer is validation-only")
class CreationCallbackSerializer(BaseValidationOnlySerializer):
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("ParticipantEntrySerializer is validation-only")
class CreationCallbackSerializer(serializers.Serializer):
"""Validate room creation callback data."""
callback_id = serializers.CharField(required=True)
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("CreationCallbackSerializer is validation-only")
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("CreationCallbackSerializer is validation-only")
class RoomInviteSerializer(serializers.Serializer):
"""Validate room invite creation data."""
emails = serializers.ListField(child=serializers.EmailField(), allow_empty=False)
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("RoomInviteSerializer is validation-only")
class BaseParticipantsManagementSerializer(BaseValidationOnlySerializer):
"""Base serializer for participant management operations."""
participant_identity = serializers.UUIDField(
help_text="LiveKit participant identity (UUID format)"
)
class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
"""Validate participant muting data."""
track_sid = serializers.CharField(
max_length=255, help_text="LiveKit track SID to mute"
)
class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
"""Validate participant update data."""
metadata = serializers.DictField(
required=False, allow_null=True, help_text="Participant metadata as JSON object"
)
attributes = serializers.DictField(
required=False,
allow_null=True,
help_text="Participant attributes as JSON object",
)
permission = serializers.DictField(
required=False,
allow_null=True,
help_text="Participant permission as JSON object",
)
name = serializers.CharField(
max_length=255,
required=False,
allow_blank=True,
allow_null=True,
help_text="Display name for the participant",
)
def validate(self, attrs):
"""Ensure at least one update field is provided."""
update_fields = ["metadata", "attributes", "permission", "name"]
has_update = any(
field in attrs and attrs[field] is not None and attrs[field] != ""
for field in update_fields
)
if not has_update:
raise serializers.ValidationError(
f"At least one of the following fields must be provided: "
f"{', '.join(update_fields)}."
)
if "permission" in attrs:
try:
ParticipantPermission(**attrs["permission"])
except ValueError as e:
raise serializers.ValidationError(
{"permission": f"Invalid permission: {str(e)}"}
) from e
return attrs
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("RoomInviteSerializer is validation-only")
+4 -141
View File
@@ -50,16 +50,9 @@ from core.services.lobby import (
LobbyParticipantNotFound,
LobbyService,
)
from core.services.participants_management import (
ParticipantsManagement,
ParticipantsManagementException,
)
from core.services.room_creation import RoomCreation
from core.services.subtitle import SubtitleException, SubtitleService
from ..authentication.livekit import LiveKitTokenAuthentication
from . import permissions, serializers
from .feature_flag import FeatureFlag
# pylint: disable=too-many-ancestors
@@ -294,9 +287,9 @@ class RoomViewSet(
url_path="start-recording",
permission_classes=[
permissions.HasPrivilegesOnRoom,
permissions.IsRecordingEnabled,
],
)
@FeatureFlag.require("recording")
def start_room_recording(self, request, pk=None): # pylint: disable=unused-argument
"""Start recording a room."""
@@ -339,9 +332,9 @@ class RoomViewSet(
url_path="stop-recording",
permission_classes=[
permissions.HasPrivilegesOnRoom,
permissions.IsRecordingEnabled,
],
)
@FeatureFlag.require("recording")
def stop_room_recording(self, request, pk=None): # pylint: disable=unused-argument
"""Stop room recording."""
@@ -424,8 +417,7 @@ class RoomViewSet(
try:
lobby_service.handle_participant_entry(
room_id=room.id,
participant_id=str(serializer.validated_data.get("participant_id")),
allow_entry=serializer.validated_data.get("allow_entry"),
**serializer.validated_data,
)
return drf_response.Response({"message": "Participant was updated."})
@@ -538,135 +530,6 @@ class RoomViewSet(
status=drf_status.HTTP_200_OK,
)
@decorators.action(
detail=True,
methods=["post"],
url_path="start-subtitle",
permission_classes=[
permissions.HasLiveKitRoomAccess,
],
authentication_classes=[LiveKitTokenAuthentication],
)
@FeatureFlag.require("subtitle")
def start_subtitle(self, request, pk=None): # pylint: disable=unused-argument
"""Start realtime transcription for the room.
Requires valid LiveKit token for room authorization.
Anonymous users can start subtitles if they have room access tokens.
"""
room = self.get_object()
try:
SubtitleService().start_subtitle(room)
except SubtitleException:
return drf_response.Response(
{"error": f"Subtitles failed to start for room {room.slug}"},
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return drf_response.Response(
{"status": "success"}, status=drf_status.HTTP_200_OK
)
@decorators.action(
detail=True,
methods=["post"],
url_path="mute-participant",
url_name="mute-participant",
permission_classes=[permissions.HasPrivilegesOnRoom],
)
def mute_participant(self, request, pk=None): # pylint: disable=unused-argument
"""Mute a specific track for a participant in the room."""
room = self.get_object()
serializer = serializers.MuteParticipantSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
ParticipantsManagement().mute(
room_name=str(room.pk),
identity=str(serializer.validated_data["participant_identity"]),
track_sid=serializer.validated_data["track_sid"],
)
except ParticipantsManagementException:
return drf_response.Response(
{"error": "Failed to mute participant"},
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return drf_response.Response(
{
"status": "success",
},
status=drf_status.HTTP_200_OK,
)
@decorators.action(
detail=True,
methods=["post"],
url_path="update-participant",
url_name="update-participant",
permission_classes=[permissions.HasPrivilegesOnRoom],
)
def update_participant(self, request, pk=None): # pylint: disable=unused-argument
"""Update participant attributes, permissions, or metadata."""
room = self.get_object()
serializer = serializers.UpdateParticipantSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
ParticipantsManagement().update(
room_name=str(room.pk),
identity=str(serializer.validated_data["participant_identity"]),
metadata=serializer.validated_data.get("metadata"),
attributes=serializer.validated_data.get("attributes"),
permission=serializer.validated_data.get("permission"),
name=serializer.validated_data.get("name"),
)
except ParticipantsManagementException:
return drf_response.Response(
{"error": "Failed to update participant"},
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return drf_response.Response(
{
"status": "success",
},
status=drf_status.HTTP_200_OK,
)
@decorators.action(
detail=True,
methods=["post"],
url_path="remove-participant",
url_name="remove-participant",
permission_classes=[permissions.HasPrivilegesOnRoom],
)
def remove_participant(self, request, pk=None): # pylint: disable=unused-argument
"""Remove a participant from the room."""
room = self.get_object()
serializer = serializers.BaseParticipantsManagementSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
ParticipantsManagement().remove(
room_name=str(room.pk),
identity=str(serializer.validated_data["participant_identity"]),
)
except ParticipantsManagementException:
return drf_response.Response(
{"error": "Failed to remove participant"},
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return drf_response.Response(
{"status": "success"}, status=drf_status.HTTP_200_OK
)
class ResourceAccessViewSet(
mixins.CreateModelMixin,
@@ -733,8 +596,8 @@ class RecordingViewSet(
methods=["post"],
url_path="storage-hook",
authentication_classes=[StorageEventAuthentication],
permission_classes=[permissions.IsStorageEventEnabled],
)
@FeatureFlag.require("storage_event")
def on_storage_event_received(self, request, pk=None): # pylint: disable=unused-argument
"""Handle incoming storage hook events for recordings."""
+11
View File
@@ -0,0 +1,11 @@
"""Meet Core application"""
# from django.apps import AppConfig
# from django.utils.translation import gettext_lazy as _
# class CoreConfig(AppConfig):
# """Configuration class for the Meet core app."""
# name = "core"
# app_label = "core"
# verbose_name = _("meet core application")
+1 -1
View File
@@ -93,6 +93,6 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
pass
except User.MultipleObjectsReturned as e:
raise SuspiciousOperation(
"Multiple user accounts share a common email."
_("Multiple user accounts share a common email.")
) from e
return None
@@ -1,42 +0,0 @@
"""Authentication using LiveKit token for the Meet core app."""
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from livekit.api import TokenVerifier
from rest_framework import authentication, exceptions
UserModel = get_user_model()
class LiveKitTokenAuthentication(authentication.BaseAuthentication):
"""Authenticate using LiveKit token and load the associated Django user."""
def authenticate(self, request):
token = request.data.get("token")
if not token:
return None # No authentication attempted
try:
verifier = TokenVerifier(
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
api_secret=settings.LIVEKIT_CONFIGURATION["api_secret"],
)
claims = verifier.verify(token)
user_id = claims.identity
if not user_id:
raise exceptions.AuthenticationFailed("Token missing user identity")
try:
user = UserModel.objects.get(id=user_id)
except UserModel.DoesNotExist:
user = AnonymousUser()
return (user, claims)
except Exception as e:
raise exceptions.AuthenticationFailed(
f"Invalid LiveKit token: {str(e)}"
) from e
+1
View File
@@ -1,3 +1,4 @@
# ruff: noqa: S311
"""
Core application factories
"""
@@ -1,45 +0,0 @@
# Generated by Django 5.1.9 on 2025-05-13 08:22
import secrets
from django.db import migrations, models
def generate_pin_for_rooms(apps, schema_editor):
"""Generate unique 10-digit PIN codes for existing rooms.
The PIN code is required for SIP telephony features.
"""
Room = apps.get_model('core', 'Room')
rooms_without_pin_code = Room.objects.filter(pin_code__isnull=True)
existing_pins = set(Room.objects.values_list('pin_code', flat=True))
length = 10
def generate_pin_code():
while True:
pin_code = str(secrets.randbelow(10 ** length)).zfill(length)
if pin_code not in existing_pins:
return pin_code
for room in rooms_without_pin_code:
room.pin_code = generate_pin_code()
room.save()
class Migration(migrations.Migration):
dependencies = [
('core', '0013_alter_user_language'),
]
operations = [
migrations.AddField(
model_name='room',
name='pin_code',
field=models.CharField(blank=True, help_text='Unique n-digit code that identifies this room in telephony mode.', max_length=None, null=True, unique=True, verbose_name='Room PIN code'),
),
migrations.RunPython(
generate_pin_for_rooms,
reverse_code=migrations.RunPython.noop
),
]
+9 -51
View File
@@ -2,7 +2,6 @@
Declare and configure the models for the Meet core application
"""
import secrets
import uuid
from datetime import datetime, timedelta
from logging import getLogger
@@ -15,6 +14,7 @@ from django.core import mail, validators
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import models
from django.utils import timezone
from django.utils.functional import lazy
from django.utils.text import capfirst, slugify
from django.utils.translation import gettext_lazy as _
@@ -35,7 +35,7 @@ class RoleChoices(models.TextChoices):
@classmethod
def check_administrator_role(cls, role):
"""Check if a role is administrator."""
return role == cls.ADMIN
return role in [cls.ADMIN, cls.OWNER]
@classmethod
def check_owner_role(cls, role):
@@ -164,7 +164,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
)
language = models.CharField(
max_length=10,
choices=settings.LANGUAGES,
choices=lazy(lambda: settings.LANGUAGES, tuple)(),
default=settings.LANGUAGE_CODE,
verbose_name=_("language"),
help_text=_("The language in which the user wants to see the interface."),
@@ -288,13 +288,13 @@ class Resource(BaseModel):
role = RoleChoices.MEMBER
return role
def is_administrator_or_owner(self, user):
def is_administrator(self, user):
"""
Check if a user is administrator or owner of the resource."""
role = self.get_role(user)
return RoleChoices.check_administrator_role(
role
) or RoleChoices.check_owner_role(role)
Check if a user is administrator of the resource.
Users carrying the "owner" role are considered as administrators a fortiori.
"""
return RoleChoices.check_administrator_role(self.get_role(user))
def is_owner(self, user):
"""Check if a user is owner of the resource."""
@@ -383,14 +383,6 @@ class Room(Resource):
verbose_name=_("Visio room configuration"),
help_text=_("Values for Visio parameters to configure the room."),
)
pin_code = models.CharField(
max_length=None,
unique=True,
blank=True,
null=True,
verbose_name=_("Room PIN code"),
help_text=_("Unique n-digit code that identifies this room in telephony mode."),
)
class Meta:
db_table = "meet_room"
@@ -401,14 +393,6 @@ class Room(Resource):
def __str__(self):
return capfirst(self.name)
def save(self, *args, **kwargs):
"""Generate a unique n-digit pin code for new rooms."""
if settings.ROOM_TELEPHONY_ENABLED and not self.pk and not self.pin_code:
self.pin_code = self.generate_unique_pin_code(
length=settings.ROOM_TELEPHONY_PIN_LENGTH
)
super().save(*args, **kwargs)
def clean_fields(self, exclude=None):
"""
Automatically generate the slug from the name and make sure it does not look like a UUID.
@@ -423,7 +407,6 @@ class Room(Resource):
pass
else:
raise ValidationError({"name": f'Room name "{self.name:s}" is reserved.'})
super().clean_fields(exclude=exclude)
@property
@@ -431,31 +414,6 @@ class Room(Resource):
"""Check if a room is public"""
return self.access_level == RoomAccessLevel.PUBLIC
@staticmethod
def generate_unique_pin_code(length):
"""Generate a unique n-digit PIN code"""
if length < 4:
raise ValueError(
"PIN code length must be at least 4 digits for minimal security"
)
max_value = 10**length
for _ in range(settings.ROOM_TELEPHONY_PIN_MAX_RETRIES):
pin_code = str(secrets.randbelow(max_value)).zfill(length)
if not Room.objects.filter(pin_code=pin_code).exists():
return pin_code
# Log a warning as a temporary measure until backend observability is implemented.
logger.warning(
"Failed to generate unique PIN code of length %s after %s attempts",
length,
settings.ROOM_TELEPHONY_PIN_MAX_RETRIES,
)
return None
class BaseAccessManager(models.Manager):
"""Base manager for handling resource access control."""
@@ -4,6 +4,7 @@ import logging
import secrets
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
@@ -54,7 +55,7 @@ class StorageEventAuthentication(BaseAuthentication):
if not required_token:
if settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
raise AuthenticationFailed(
"Authentication is enabled but token is not configured."
_("Authentication is enabled but token is not configured.")
)
return MachineUser(), None
@@ -66,7 +67,7 @@ class StorageEventAuthentication(BaseAuthentication):
"Authentication failed: Missing Authorization header (ip: %s)",
request.META.get("REMOTE_ADDR"),
)
raise AuthenticationFailed("Authorization header is required")
raise AuthenticationFailed(_("Authorization header is required"))
auth_parts = auth_header.split(" ")
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
@@ -74,7 +75,7 @@ class StorageEventAuthentication(BaseAuthentication):
"Authentication failed: Invalid authorization header (ip: %s)",
request.META.get("REMOTE_ADDR"),
)
raise AuthenticationFailed("Invalid authorization header.")
raise AuthenticationFailed(_("Invalid authorization header."))
token = auth_parts[1]
@@ -84,7 +85,7 @@ class StorageEventAuthentication(BaseAuthentication):
"Authentication failed: Invalid token (ip: %s)",
request.META.get("REMOTE_ADDR"),
)
raise AuthenticationFailed("Invalid token")
raise AuthenticationFailed(_("Invalid token"))
return MachineUser(), token
@@ -131,18 +131,11 @@ class NotificationService:
if not owner_access:
logger.error("No owner found for recording %s", recording.id)
return False
payload = {
"owner_id": str(owner_access.user.id),
"filename": recording.key,
"email": owner_access.user.email,
"sub": owner_access.user.sub,
"room": recording.room.name,
"recording_date": recording.created_at.astimezone(
owner_access.user.timezone
).strftime("%Y-%m-%d"),
"recording_time": recording.created_at.astimezone(
owner_access.user.timezone
).strftime("%H:%M"),
}
headers = {
+2 -2
View File
@@ -86,7 +86,7 @@ class MinioParser:
# pylint: disable=line-too-long
self._filepath_regex = re.compile(
r"(?P<url_encoded_folder_path>(?:[^%]+%2F)+)?(?P<recording_id>[0-9a-fA-F\-]{36})\.(?P<extension>[a-zA-Z0-9]+)"
r"(?P<url_encoded_folder_path>(?:[^%]+%2F)*)?(?P<recording_id>[0-9a-fA-F\-]{36})\.(?P<extension>[a-zA-Z0-9]+)"
)
@staticmethod
@@ -123,7 +123,7 @@ class MinioParser:
f"Invalid bucket: expected {self._bucket_name}, got {event_data.bucket_name}"
)
if event_data.filetype not in self._allowed_filetypes:
if not event_data.filetype in self._allowed_filetypes:
raise InvalidFileTypeError(
f"Invalid file type, expected {self._allowed_filetypes},"
f"got '{event_data.filetype}'"
@@ -1,49 +0,0 @@
"""Recording-related LiveKit Events Service"""
from logging import getLogger
from core import models, utils
logger = getLogger(__name__)
class RecordingEventsError(Exception):
"""Recording event handling fails."""
class RecordingEventsService:
"""Handles recording-related Livekit webhook events."""
@staticmethod
def handle_limit_reached(recording):
"""Stop recording and notify participants when limit is reached."""
recording.status = models.RecordingStatusChoices.STOPPED
recording.save()
notification_mapping = {
models.RecordingModeChoices.SCREEN_RECORDING: "screenRecordingLimitReached",
models.RecordingModeChoices.TRANSCRIPT: "transcriptionLimitReached",
}
notification_type = notification_mapping.get(recording.mode)
if not notification_type:
return
try:
utils.notify_participants(
room_name=str(recording.room.id),
notification_data={"type": notification_type},
)
except utils.NotificationError as e:
logger.exception(
"Failed to notify participants about recording limit reached: "
"room=%s, recording_id=%s, mode=%s",
recording.room.id,
recording.id,
recording.mode,
)
raise RecordingEventsError(
f"Failed to notify participants in room '{recording.room.id}' about "
f"recording limit reached (recording_id={recording.id})"
) from e
+1 -82
View File
@@ -1,25 +1,13 @@
"""LiveKit Events Service"""
# pylint: disable=no-member
import uuid
from enum import Enum
from logging import getLogger
from django.conf import settings
from livekit import api
from core import models
from core.recording.services.recording_events import (
RecordingEventsError,
RecordingEventsService,
)
from .lobby import LobbyService
from .telephony import TelephonyException, TelephonyService
logger = getLogger(__name__)
class LiveKitWebhookError(Exception):
@@ -89,8 +77,6 @@ class LiveKitEventsService:
)
self.webhook_receiver = api.WebhookReceiver(token_verifier)
self.lobby_service = LobbyService()
self.telephony_service = TelephonyService()
self.recording_events = RecordingEventsService()
def receive(self, request):
"""Process webhook and route to appropriate handler."""
@@ -122,77 +108,10 @@ class LiveKitEventsService:
# pylint: disable=not-callable
handler(data)
def _handle_egress_ended(self, data):
"""Handle 'egress_ended' event."""
try:
recording = models.Recording.objects.get(
worker_id=data.egress_info.egress_id
)
except models.Recording.DoesNotExist as err:
raise ActionFailedError(
f"Recording with worker ID {data.egress_info.egress_id} does not exist"
) from err
if (
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
def _handle_room_started(self, data):
"""Handle 'room_started' event."""
try:
room_id = uuid.UUID(data.room.name)
except ValueError as e:
logger.warning(
"Ignoring room event: room name '%s' is not a valid UUID format.",
data.room.name,
)
raise ActionFailedError("Failed to process room started event") from e
try:
room = models.Room.objects.get(id=room_id)
except models.Room.DoesNotExist as err:
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
if settings.ROOM_TELEPHONY_ENABLED:
try:
self.telephony_service.create_dispatch_rule(room)
except TelephonyException as e:
raise ActionFailedError(
f"Failed to create telephony dispatch rule for room {room_id}"
) from e
def _handle_room_finished(self, data):
"""Handle 'room_finished' event."""
try:
room_id = uuid.UUID(data.room.name)
except ValueError as e:
logger.warning(
"Ignoring room event: room name '%s' is not a valid UUID format.",
data.room.name,
)
raise ActionFailedError("Failed to process room finished event") from e
if settings.ROOM_TELEPHONY_ENABLED:
try:
self.telephony_service.delete_dispatch_rule(room_id)
except TelephonyException as e:
raise ActionFailedError(
f"Failed to delete telephony dispatch rule for room {room_id}"
) from e
try:
self.lobby_service.clear_room_cache(room_id)
except Exception as e:
raise ActionFailedError(
f"Failed to clear room cache for room {room_id}"
) from e
raise ActionFailedError("Failed to process room finished event") from e
+59 -25
View File
@@ -1,5 +1,6 @@
"""Lobby Service"""
import json
import logging
import uuid
from dataclasses import dataclass
@@ -10,6 +11,13 @@ from uuid import UUID
from django.conf import settings
from django.core.cache import cache
from asgiref.sync import async_to_sync
from livekit.api import ( # pylint: disable=E0611
ListRoomsRequest,
SendDataRequest,
TwirpError,
)
from core import models, utils
logger = logging.getLogger(__name__)
@@ -38,6 +46,10 @@ class LobbyParticipantNotFound(LobbyError):
"""Raised when participant is not found."""
class LobbyNotificationError(LobbyError):
"""Raised when LiveKit notification fails."""
@dataclass
class LobbyParticipant:
"""Participant in a lobby system."""
@@ -89,7 +101,7 @@ class LobbyService:
@staticmethod
def _get_or_create_participant_id(request) -> str:
"""Extract unique participant identifier from the request."""
return request.COOKIES.get(settings.LOBBY_COOKIE_NAME, str(uuid.uuid4()))
return request.COOKIES.get(settings.LOBBY_COOKIE_NAME, uuid.uuid4().hex)
@staticmethod
def prepare_response(response, participant_id):
@@ -143,8 +155,6 @@ class LobbyService:
participant_id = self._get_or_create_participant_id(request)
participant = self._get_participant(room.id, participant_id)
room_id = str(room.id)
if self.can_bypass_lobby(room=room, user=request.user):
if participant is None:
participant = LobbyParticipant(
@@ -157,13 +167,10 @@ class LobbyService:
participant.status = LobbyParticipantStatus.ACCEPTED
livekit_config = utils.generate_livekit_config(
room_id=room_id,
room_id=str(room.id),
user=request.user,
username=username,
color=participant.color,
configuration=room.configuration,
is_admin_or_owner=False,
participant_id=participant_id,
)
return participant, livekit_config
@@ -178,13 +185,10 @@ class LobbyService:
elif participant.status == LobbyParticipantStatus.ACCEPTED:
# wrongly named, contains access token to join a room
livekit_config = utils.generate_livekit_config(
room_id=room_id,
room_id=str(room.id),
user=request.user,
username=username,
color=participant.color,
configuration=room.configuration,
is_admin_or_owner=False,
participant_id=participant_id,
)
return participant, livekit_config
@@ -207,6 +211,9 @@ class LobbyService:
Create a new participant entry in waiting status and notify room
participants of the new entry request.
Raises:
LobbyNotificationError: If room notification fails
"""
color = utils.generate_color(participant_id)
@@ -219,15 +226,10 @@ class LobbyService:
)
try:
utils.notify_participants(
room_name=str(room_id),
notification_data={
"type": settings.LOBBY_NOTIFICATION_TYPE,
},
)
except utils.NotificationError:
self.notify_participants(room_id=room_id)
except LobbyNotificationError:
# If room not created yet, there is no participants to notify
logger.exception("Failed to notify room participants")
pass
cache_key = self._get_cache_key(room_id, participant_id)
cache.set(
@@ -332,6 +334,44 @@ class LobbyService:
participant.status = status
cache.set(cache_key, participant.to_dict(), timeout=timeout)
@async_to_sync
async def notify_participants(self, room_id: UUID):
"""Notify room participants about a new waiting participant using LiveKit.
Raises:
LobbyNotificationError: If notification fails to send
"""
notification_data = {
"type": settings.LOBBY_NOTIFICATION_TYPE,
}
lkapi = utils.create_livekit_client()
try:
room_response = await lkapi.room.list_rooms(
ListRoomsRequest(
names=[str(room_id)],
)
)
# Check if the room exists
if not room_response.rooms:
return
await lkapi.room.send_data(
SendDataRequest(
room=str(room_id),
data=json.dumps(notification_data).encode("utf-8"),
kind="RELIABLE",
)
)
except TwirpError as e:
logger.exception("Failed to notify room participants")
raise LobbyNotificationError("Failed to notify room participants") from e
finally:
await lkapi.aclose()
def clear_room_cache(self, room_id: UUID) -> None:
"""Clear all participant entries from the cache for a specific room."""
@@ -342,9 +382,3 @@ class LobbyService:
return
cache.delete_many(keys)
def clear_participant_cache(self, room_id: UUID, participant_id: str) -> None:
"""Clear a given participant entry from the cache for a specific room."""
cache_key = self._get_cache_key(room_id, participant_id)
cache.delete(cache_key)
+2 -6
View File
@@ -10,7 +10,6 @@ from django.core.exceptions import ImproperlyConfigured
from django.utils.module_loading import import_string
import brevo_python
import urllib3
logger = logging.getLogger(__name__)
@@ -121,11 +120,8 @@ class BrevoMarketingService:
try:
response = contact_api.create_contact(contact, **api_configurations)
except (
brevo_python.rest.ApiException,
urllib3.exceptions.ReadTimeoutError,
) as err:
logger.warning("Failed to create contact in Brevo", exc_info=True)
except brevo_python.rest.ApiException as err:
logger.exception("Failed to create contact in Brevo")
raise ContactCreationError("Failed to create contact in Brevo") from err
return response
@@ -1,128 +0,0 @@
"""Participants management service for LiveKit rooms."""
# pylint: disable=too-many-arguments,no-name-in-module,too-many-positional-arguments
# ruff: noqa:PLR0913
import json
import uuid
from logging import getLogger
from typing import Dict, Optional
from asgiref.sync import async_to_sync
from livekit.api import (
MuteRoomTrackRequest,
RoomParticipantIdentity,
TwirpError,
UpdateParticipantRequest,
)
from core import utils
from .lobby import LobbyService
logger = getLogger(__name__)
class ParticipantsManagementException(Exception):
"""Exception raised when a participant management operations fail."""
class ParticipantsManagement:
"""Service for managing participants."""
@async_to_sync
async def mute(self, room_name: str, identity: str, track_sid: str):
"""Mute a specific audio or video track for a participant in a room."""
lkapi = utils.create_livekit_client()
try:
await lkapi.room.mute_published_track(
MuteRoomTrackRequest(
room=room_name,
identity=identity,
track_sid=track_sid,
muted=True,
)
)
except TwirpError as e:
logger.exception(
"Unexpected error muting participant %s for room %s",
identity,
room_name,
)
raise ParticipantsManagementException("Could not mute participant") from e
finally:
await lkapi.aclose()
@async_to_sync
async def remove(self, room_name: str, identity: str):
"""Remove a participant from a room and clear their lobby cache."""
try:
LobbyService().clear_participant_cache(
room_id=uuid.UUID(room_name), participant_id=identity
)
except (ValueError, TypeError) as exc:
logger.warning(
"participants_management.remove: room_name '%s' is not a UUID; "
"skipping lobby cache clear",
room_name,
exc_info=exc,
)
lkapi = utils.create_livekit_client()
try:
await lkapi.room.remove_participant(
RoomParticipantIdentity(room=room_name, identity=identity)
)
except TwirpError as e:
logger.exception(
"Unexpected error removing participant %s for room %s",
identity,
room_name,
)
raise ParticipantsManagementException("Could not remove participant") from e
finally:
await lkapi.aclose()
@async_to_sync
async def update(
self,
room_name: str,
identity: str,
metadata: Optional[Dict] = None,
attributes: Optional[Dict] = None,
permission: Optional[Dict] = None,
name: Optional[str] = None,
):
"""Update participant properties such as metadata, attributes, permissions, or name."""
lkapi = utils.create_livekit_client()
try:
await lkapi.room.update_participant(
UpdateParticipantRequest(
room=room_name,
identity=identity,
metadata=json.dumps(metadata),
permission=permission,
attributes=attributes,
name=name,
)
)
except TwirpError as e:
logger.exception(
"Unexpected error updating participant %s for room %s",
identity,
room_name,
)
raise ParticipantsManagementException("Could not update participant") from e
finally:
await lkapi.aclose()
-47
View File
@@ -1,47 +0,0 @@
"""Service for managing subtitle agents in LiveKit rooms."""
from logging import getLogger
from django.conf import settings
from asgiref.sync import async_to_sync
from livekit.protocol.agent_dispatch import CreateAgentDispatchRequest
from core import utils
logger = getLogger(__name__)
class SubtitleException(Exception):
"""Exception raised when subtitle operations fail."""
class SubtitleService:
"""Service for managing subtitle agents in LiveKit rooms."""
@async_to_sync
async def start_subtitle(self, room):
"""Start subtitle agent for the specified room."""
lkapi = utils.create_livekit_client()
try:
# Transcriber agent prevents duplicate subtitle agents per room
# No error is raised if agent already exists
await lkapi.agent_dispatch.create_dispatch(
CreateAgentDispatchRequest(
agent_name=settings.ROOM_SUBTITLE_AGENT_NAME, room=str(room.id)
)
)
except Exception as e:
logger.exception("Failed to create agent dispatch for room %s", room.id)
raise SubtitleException("Failed to create subtitle agent") from e
finally:
await lkapi.aclose()
@async_to_sync
async def stop_subtitle(self, room) -> None:
"""Stop subtitle agent for the specified room."""
raise NotImplementedError("Subtitle agent stopping not yet implemented")
-124
View File
@@ -1,124 +0,0 @@
"""Telephony service for managing SIP dispatch rules for room access."""
from logging import getLogger
from asgiref.sync import async_to_sync
from livekit.api import TwirpError
from livekit.protocol.sip import (
CreateSIPDispatchRuleRequest,
DeleteSIPDispatchRuleRequest,
ListSIPDispatchRuleRequest,
SIPDispatchRule,
SIPDispatchRuleDirect,
)
from core import utils
logger = getLogger(__name__)
class TelephonyException(Exception):
"""Exception raised when telephony operations fail."""
class TelephonyService:
"""Service for managing participant access through the telephony system (SIP)."""
def _rule_name(self, room_id):
"""Generate the rule name for a room based on its ID."""
return f"SIP_{str(room_id)}"
@async_to_sync
async def create_dispatch_rule(self, room):
"""Create a SIP inbound dispatch rule for direct room routing.
Configures telephony to route incoming SIP calls directly to the specified room
using the room's ID and PIN code for authentication.
"""
direct_rule = SIPDispatchRule(
dispatch_rule_direct=SIPDispatchRuleDirect(
room_name=str(room.pk), pin=str(room.pin_code)
)
)
request = CreateSIPDispatchRuleRequest(
rule=direct_rule, name=self._rule_name(room.pk)
)
lkapi = utils.create_livekit_client()
try:
await lkapi.sip.create_sip_dispatch_rule(create=request)
except TwirpError as e:
logger.exception(
"Unexpected error creating dispatch rule for room %s", room.id
)
raise TelephonyException("Could not create dispatch rule") from e
finally:
await lkapi.aclose()
async def _list_dispatch_rules_ids(self, room_id):
"""List SIP dispatch rule IDs for a specific room.
Fetches all existing SIP dispatch rules and filters them by room name
since LiveKit API doesn't support server-side filtering by 'room_name'.
This approach is acceptable for moderate scale but may need refactoring
for high-volume scenarios.
Note:
Feature request for server-side filtering: livekit/sip#405
"""
lkapi = utils.create_livekit_client()
try:
existing_rules = await lkapi.sip.list_sip_dispatch_rule(
list=ListSIPDispatchRuleRequest()
)
except TwirpError as e:
logger.exception("Failed to list dispatch rules for room %s", room_id)
raise TelephonyException("Could not list dispatch rules") from e
finally:
await lkapi.aclose()
if not existing_rules or not existing_rules.items:
return []
rule_name = self._rule_name(room_id)
return [
existing_rule.sip_dispatch_rule_id
for existing_rule in existing_rules.items
if existing_rule.name == rule_name
]
@async_to_sync
async def delete_dispatch_rule(self, room_id):
"""Delete all SIP inbound dispatch rules associated with a specific room."""
rules_ids = await self._list_dispatch_rules_ids(room_id)
if not rules_ids:
logger.info("No dispatch rules found for room %s", room_id)
return False
if len(rules_ids) > 1:
logger.error("Multiple dispatch rules found for room %s", room_id)
lkapi = utils.create_livekit_client()
try:
for rule_id in rules_ids:
await lkapi.sip.delete_sip_dispatch_rule(
delete=DeleteSIPDispatchRuleRequest(sip_dispatch_rule_id=rule_id)
)
return True
except TwirpError as e:
logger.exception("Failed to delete dispatch rules for room %s", room_id)
raise TelephonyException("Could not delete dispatch rules") from e
finally:
await lkapi.aclose()
@@ -287,7 +287,9 @@ def test_finds_user_whitespace_email(django_assert_num_queries, settings):
[
"john.doe@xample.com", # Fullwidth character in domain
"john.doe@еxample.com", # Cyrillic 'е' in domain
"JOHN.DOe@exam𝔭le.com", # Mixed Gothic '𝔭' in domain
"john.doe@exаmple.com", # Cyrillic 'а' (a) in domain
"john.doe@e𝓧𝓪𝓶𝓹𝓵𝓮.com", # Mixed fullwidth and cursive in domain
],
)
def test_authentication_getter_existing_user_email_tricky(email, monkeypatch, settings):
@@ -2,7 +2,7 @@
Test event authentication.
"""
# pylint: disable=assignment-from-no-return
# pylint: disable=E1128
from django.test import RequestFactory
@@ -2,7 +2,7 @@
Test event notification.
"""
# pylint: disable=assignment-from-no-return,redefined-outer-name,unused-argument,protected-access
# pylint: disable=E1128,W0621,W0613,W0212
import datetime
import smtplib
@@ -2,7 +2,7 @@
Test event parsers.
"""
# pylint: disable=protected-access,redefined-outer-name,unused-argument
# pylint: disable=W0212,W0621,W0613
from unittest import mock
@@ -129,11 +129,8 @@ def test_validate_invalid_filetype(minio_parser):
@pytest.mark.parametrize(
"invalid_filepath",
[
"invalid_filepath", # totally invalid string
"invalid_filepath",
"recording/46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
"recording/46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing extension
"46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing url_encoded_folder_path and extension
"", # empty string
"recording%2F46d1a1212426484d8fb309b5d886f7a8.ogg",
],
)
@@ -1,72 +0,0 @@
"""
Test RecordingEventsService service.
"""
# pylint: disable=redefined-outer-name
from unittest import mock
import pytest
from core.factories import RecordingFactory
from core.recording.services.recording_events import (
RecordingEventsError,
RecordingEventsService,
)
from core.utils import NotificationError
pytestmark = pytest.mark.django_db
@pytest.fixture
def service():
"""Initialize RecordingEventsService."""
return RecordingEventsService()
@pytest.mark.parametrize(
("mode", "notification_type"),
(
("screen_recording", "screenRecordingLimitReached"),
("transcript", "transcriptionLimitReached"),
),
)
@mock.patch("core.utils.notify_participants")
def test_handle_limit_reached_success(mock_notify, mode, notification_type, service):
"""Test handle_limit_reached stops recording and notifies participants."""
recording = RecordingFactory(status="active", mode=mode)
service.handle_limit_reached(recording)
assert recording.status == "stopped"
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", "screenRecordingLimitReached"),
("transcript", "transcriptionLimitReached"),
),
)
@mock.patch("core.utils.notify_participants")
def test_handle_limit_reached_error(mock_notify, mode, notification_type, service):
"""Test handle_limit_reached raises RecordingEventsError when notification fails."""
mock_notify.side_effect = NotificationError("Error notifying")
recording = RecordingFactory(status="active", mode=mode)
with pytest.raises(
RecordingEventsError,
match=r"Failed to notify participants in room '.+' "
r"about recording limit reached \(recording_id=.+\)",
):
service.handle_limit_reached(recording)
assert recording.status == "stopped"
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
@@ -2,7 +2,7 @@
Test recordings API endpoints in the Meet core app: save recording.
"""
# pylint: disable=redefined-outer-name,unused-argument
# pylint: disable=W0621,W0613
import uuid
from unittest import mock
@@ -77,8 +77,7 @@ def test_save_recording_permission_needed(settings, client):
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.status_code == 403
def test_save_recording_parsing_error(recording_settings, mock_get_parser, client):
@@ -2,7 +2,7 @@
Test worker service factories.
"""
# pylint: disable=protected-access,redefined-outer-name,unused-argument
# pylint: disable=W0212,W0621,W0613
from dataclasses import FrozenInstanceError
from unittest.mock import Mock
@@ -1,6 +1,6 @@
"""Test WorkerServiceMediator class."""
# pylint: disable=redefined-outer-name,unused-argument
# pylint: disable=W0621,W0613
from unittest.mock import Mock
@@ -2,7 +2,7 @@
Test worker service classes.
"""
# pylint: disable=protected-access,redefined-outer-name,unused-argument,no-member
# pylint: disable=W0212,W0621,W0613,E1101
from unittest.mock import AsyncMock, Mock, patch
@@ -2,7 +2,7 @@
Test rooms API endpoints in the Meet core app: create.
"""
# pylint: disable=redefined-outer-name,unused-argument
# pylint: disable=W0621,W0613
from django.core.cache import cache
import pytest
@@ -2,7 +2,7 @@
Test rooms API endpoints in the Meet core app: creation callback functionality.
"""
# pylint: disable=redefined-outer-name,unused-argument
# pylint: disable=W0621,W0613
from django.core.cache import cache
import pytest
@@ -2,7 +2,7 @@
Test rooms API endpoints in the Meet core app: invite.
"""
# pylint: disable=redefined-outer-name,unused-argument
# pylint: disable=W0621,W0613
import json
import random
@@ -230,14 +230,15 @@ def test_api_rooms_invite_error(mock_invite_to_room):
data = {"emails": ["toto@yopmail.com", "toto@yopmail.com"]}
with pytest.raises(InvitationError):
with pytest.raises(InvitationError) as excinfo:
client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
mock_invite_to_room.assert_called_once()
mock_invite_to_room.assert_called_once()
assert "Could not send invitation" in str(excinfo.value)
@mock.patch("core.services.invitation.send_mail")
@@ -37,7 +37,7 @@ def test_request_entry_anonymous(settings):
assert not lobby_keys
with (
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(LobbyService, "notify_participants", return_value=None),
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
):
response = client.post(
@@ -86,7 +86,7 @@ def test_request_entry_authenticated_user(settings):
assert not lobby_keys
with (
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(LobbyService, "notify_participants", return_value=None),
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
):
response = client.post(
@@ -132,18 +132,18 @@ def test_request_entry_with_existing_participants(settings):
# Add two participants already waiting in the lobby
cache.set(
f"mocked-cache-prefix_{room.id}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
f"mocked-cache-prefix_{room.id}_participant1",
{
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"id": "participant1",
"username": "user1",
"status": "waiting",
"color": "#123456",
},
)
cache.set(
f"mocked-cache-prefix_{room.id}_f4ca3ab8a6c04ad88097b8da33f60f10",
f"mocked-cache-prefix_{room.id}_participant2",
{
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
"id": "participant2",
"username": "user2",
"status": "accepted",
"color": "#654321",
@@ -156,7 +156,7 @@ def test_request_entry_with_existing_participants(settings):
# Mock external service calls to isolate the test
with (
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(LobbyService, "notify_participants", return_value=None),
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
):
# Make request as a new anonymous user
@@ -205,7 +205,7 @@ def test_request_entry_public_room(settings):
assert not lobby_keys
with (
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(LobbyService, "notify_participants", return_value=None),
mock.patch.object(
LobbyService, "_get_or_create_participant_id", return_value="123"
),
@@ -255,11 +255,9 @@ def test_request_entry_authenticated_user_public_room(settings):
assert not lobby_keys
with (
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(LobbyService, "notify_participants", return_value=None),
mock.patch.object(
LobbyService,
"_get_or_create_participant_id",
return_value="2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
LobbyService, "_get_or_create_participant_id", return_value="123"
),
mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"}
@@ -276,11 +274,11 @@ def test_request_entry_authenticated_user_public_room(settings):
# Verify the lobby cookie was set
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
assert cookie.value == "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
assert cookie.value == "123"
# Verify response content matches expected structure and values
assert response.json() == {
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"id": "123",
"username": "test_user",
"status": "accepted",
"color": "mocked-color",
@@ -302,9 +300,9 @@ def test_request_entry_waiting_participant_public_room(settings):
# Add a waiting participant to the room's lobby cache
cache.set(
f"mocked-cache-prefix_{room.id}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
f"mocked-cache-prefix_{room.id}_participant1",
{
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"id": "participant1",
"username": "user1",
"status": "waiting",
"color": "#123456",
@@ -312,10 +310,10 @@ def test_request_entry_waiting_participant_public_room(settings):
)
# Simulate a browser with existing participant cookie
client.cookies.load({"mocked-cookie": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"})
client.cookies.load({"mocked-cookie": "participant1"})
with (
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(LobbyService, "notify_participants", return_value=None),
mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"}
),
@@ -330,11 +328,11 @@ def test_request_entry_waiting_participant_public_room(settings):
# Verify the lobby cookie was set
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
assert cookie.value == "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
assert cookie.value == "participant1"
# Verify response content matches expected structure and values
assert response.json() == {
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"id": "participant1",
"username": "user1",
"status": "accepted",
"color": "#123456",
@@ -381,7 +379,7 @@ def test_allow_participant_to_enter_anonymous():
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True},
{"participant_id": "test-id", "allow_entry": True},
)
assert response.status_code == 401
@@ -396,7 +394,7 @@ def test_allow_participant_to_enter_non_owner():
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True},
{"participant_id": "test-id", "allow_entry": True},
)
assert response.status_code == 403
@@ -414,7 +412,7 @@ def test_allow_participant_to_enter_public_room():
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True},
{"participant_id": "test-id", "allow_entry": True},
)
assert response.status_code == 404
@@ -437,9 +435,9 @@ def test_allow_participant_to_enter_success(settings, allow_entry, updated_statu
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
cache.set(
f"mocked-cache-prefix_{room.id!s}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
f"mocked-cache-prefix_{room.id!s}_participant1",
{
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"id": "test-id",
"status": "waiting",
"username": "foo",
"color": "123",
@@ -448,18 +446,13 @@ def test_allow_participant_to_enter_success(settings, allow_entry, updated_statu
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{
"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"allow_entry": allow_entry,
},
{"participant_id": "participant1", "allow_entry": allow_entry},
)
assert response.status_code == 200
assert response.json() == {"message": "Participant was updated."}
participant_data = cache.get(
f"mocked-cache-prefix_{room.id!s}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
)
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_participant1")
assert participant_data.get("status") == updated_status
@@ -475,14 +468,12 @@ def test_allow_participant_to_enter_participant_not_found(settings):
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
participant_data = cache.get(
f"mocked-cache-prefix_{room.id!s}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
)
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_test-id")
assert participant_data is None
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True},
{"participant_id": "test-id", "allow_entry": True},
)
assert response.status_code == 404
@@ -572,18 +563,18 @@ def test_list_waiting_participants_success(settings):
# Add participants in the lobby
cache.set(
f"mocked-cache-prefix_{room.id}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
f"mocked-cache-prefix_{room.id}_participant1",
{
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"id": "participant1",
"username": "user1",
"status": "waiting",
"color": "#123456",
},
)
cache.set(
f"mocked-cache-prefix_{room.id}_f4ca3ab8a6c04ad88097b8da33f60f10",
f"mocked-cache-prefix_{room.id}_participant2",
{
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
"id": "participant2",
"username": "user2",
"status": "waiting",
"color": "#654321",
@@ -597,13 +588,13 @@ def test_list_waiting_participants_success(settings):
participants = response.json().get("participants")
assert sorted(participants, key=lambda p: p["id"]) == [
{
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"id": "participant1",
"username": "user1",
"status": "waiting",
"color": "#123456",
},
{
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
"id": "participant2",
"username": "user2",
"status": "waiting",
"color": "#654321",
@@ -1,417 +0,0 @@
"""
Test rooms API endpoints in the Meet core app: participants management.
"""
# pylint: disable=redefined-outer-name,unused-argument,protected-access
import random
from unittest import mock
from uuid import uuid4
from django.urls import reverse
import pytest
from livekit.api import TwirpError
from rest_framework import status
from rest_framework.test import APIClient
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
from core.services.lobby import LobbyService
pytestmark = pytest.mark.django_db
@pytest.fixture
def mock_livekit_client():
"""Mock LiveKit API client."""
with mock.patch("core.utils.create_livekit_client") as mock_create:
mock_client = mock.AsyncMock()
mock_create.return_value = mock_client
yield mock_client
def test_mute_participant_success(mock_livekit_client):
"""Test successful participant muting."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.mute_published_track.assert_called_once()
mock_livekit_client.aclose.assert_called_once()
def test_mute_participant_forbidden_without_access():
"""Test mute participant returns 403 when user lacks room privileges."""
client = APIClient()
room = RoomFactory()
user = UserFactory() # User without UserResourceAccess
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_mute_participant_invalid_payload():
"""Test mute participant with invalid payload."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {"participant_identity": "invalid-uuid", "track_sid": ""}
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_mute_participant_unexpected_twirp_error(mock_livekit_client):
"""Test mute participant when LiveKit API raises TwirpError."""
client = APIClient()
mock_livekit_client.room.mute_published_track.side_effect = TwirpError(
msg="Internal server error", code=500, status=500
)
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert response.data == {"error": "Failed to mute participant"}
mock_livekit_client.aclose.assert_called_once()
def test_update_participant_success(mock_livekit_client):
"""Test successful participant update."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4()),
"metadata": {"role": "presenter"},
"permission": {
"can_subscribe": True,
"can_publish": True,
"can_publish_data": True,
"can_publish_sources": [
1,
2,
], # [TrackSource.CAMERA, TrackSource.MICROPHONE]
"hidden": False,
"recorder": False,
"can_update_metadata": True,
"agent": False,
"can_subscribe_metrics": False,
},
"name": "John Doe",
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.update_participant.assert_called_once()
mock_livekit_client.aclose.assert_called_once()
def test_update_participant_forbidden_without_access():
"""Test update participant returns 403 when user lacks room privileges."""
client = APIClient()
room = RoomFactory()
user = UserFactory() # User without UserResourceAccess
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4()), "name": "Test User"}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_update_participant_invalid_payload():
"""Test update participant with invalid payload."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {"participant_identity": "invalid-uuid"}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "Must be a valid UUID." in str(response.data)
def test_update_participant_no_update_fields():
"""Test update participant with no update fields provided."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4())
# No metadata, attributes, permission, or name
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "At least one of the following fields must be provided" in str(response.data)
def test_update_participant_invalid_permission():
"""Test update participant with wrong permission object."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4()),
"permission": {"invalid-attributes": True},
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "Invalid permission" in str(response.data)
def test_update_participant_wrong_metadata_attributes():
"""Test update participant with wrong metadata or attributes provided."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4()),
"metadata": "wrong string",
"attributes": "wrong string",
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "metadata" in response.data or "attributes" in response.data
def test_update_participant_unexpected_twirp_error(mock_livekit_client):
"""Test update participant when LiveKit API raises TwirpError."""
client = APIClient()
mock_livekit_client.room.update_participant.side_effect = TwirpError(
msg="Internal server error", code=500, status=500
)
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4()), "name": "Test User"}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert response.data == {"error": "Failed to update participant"}
mock_livekit_client.aclose.assert_called_once()
def test_remove_participant_success_lobby_cache(mock_livekit_client):
"""Test successful participant removal.
The lobby cache cleanup is crucial for security - without it, removed
participants could potentially re-enter the room using their cached
lobby session.
"""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
participant_identity = str(uuid4())
# Create participant in lobby cache first
LobbyService().enter(room.id, participant_identity, "John doe")
# Accept participant
LobbyService().handle_participant_entry(room.id, participant_identity, True)
payload = {"participant_identity": participant_identity}
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.remove_participant.assert_called_once()
# called twice: once for Lobby, once for ParticipantManagement
mock_livekit_client.aclose.assert_called()
# Verify lobby cache was cleared - participant should no longer exist
participant = LobbyService()._get_participant(room.id, participant_identity)
assert participant is None
def test_remove_participant_success(mock_livekit_client):
"""Test successful participant removal."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4())}
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.remove_participant.assert_called_once()
mock_livekit_client.aclose.assert_called_once()
def test_remove_participant_forbidden_without_access():
"""Test remove participant returns 403 when user lacks room privileges."""
client = APIClient()
room = RoomFactory()
user = UserFactory() # User without UserResourceAccess
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4())}
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_remove_participant_invalid_payload():
"""Test remove participant with invalid payload."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {"participant_identity": "invalid-uuid"}
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_remove_participant_missing_identity():
"""Test remove participant with missing participant_identity."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {} # Missing participant_identity
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_remove_participant_unexpected_twirp_error(mock_livekit_client):
"""Test remove participant when LiveKit API raises TwirpError."""
client = APIClient()
mock_livekit_client.room.remove_participant.side_effect = TwirpError(
msg="Internal server error", code=500, status=500
)
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4())}
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert response.data == {"error": "Failed to remove participant"}
mock_livekit_client.aclose.assert_called_once()
@@ -32,7 +32,6 @@ def test_api_rooms_retrieve_anonymous_private_pk():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -52,7 +51,6 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -71,7 +69,6 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -88,7 +85,6 @@ def test_api_rooms_retrieve_anonymous_private_slug():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -105,7 +101,6 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -214,7 +209,6 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
"token": "foo",
},
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -235,10 +229,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
which they are not related, provided the room is public.
They should not see related users.
"""
room = RoomFactory(
access_level=RoomAccessLevel.PUBLIC,
configuration={"can_publish_sources": ["mock-source"]},
)
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
user = UserFactory()
client = APIClient()
@@ -260,18 +251,11 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
"token": "foo",
},
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
mock_token.assert_called_once_with(
room=expected_name,
user=user,
username=None,
color=None,
sources=["mock-source"],
is_admin_or_owner=False,
participant_id=None,
room=expected_name, user=user, username=None, color=None
)
@@ -311,18 +295,11 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
"token": "foo",
},
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
mock_token.assert_called_once_with(
room=expected_name,
user=user,
username=None,
color=None,
sources=None,
is_admin_or_owner=False,
participant_id=None,
room=expected_name, user=user, username=None, color=None
)
@@ -347,7 +324,6 @@ def test_api_rooms_retrieve_authenticated():
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -368,9 +344,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
user = UserFactory()
other_user = UserFactory()
room = RoomFactory(
configuration={"can_publish_sources": ["mock-source"]},
)
room = RoomFactory()
UserResourceAccessFactory(resource=room, user=user, role="member")
UserResourceAccessFactory(resource=room, user=other_user, role="member")
@@ -398,18 +372,11 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
"token": "foo",
},
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
mock_token.assert_called_once_with(
room=expected_name,
user=user,
username=None,
color=None,
sources=["mock-source"],
is_admin_or_owner=False,
participant_id=None,
room=expected_name, user=user, username=None, color=None
)
@@ -491,16 +458,9 @@ def test_api_rooms_retrieve_administrators(
"token": "foo",
},
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
mock_token.assert_called_once_with(
room=expected_name,
user=user,
username=None,
color=None,
sources=None,
is_admin_or_owner=True,
participant_id=None,
room=expected_name, user=user, username=None, color=None
)
@@ -2,7 +2,7 @@
Test rooms API endpoints in the Meet core app: start recording.
"""
# pylint: disable=redefined-outer-name,unused-argument
# pylint: disable=W0621,W0613
from unittest import mock
@@ -55,9 +55,8 @@ def test_start_recording_anonymous():
assert Recording.objects.count() == 0
def test_start_recording_non_owner_and_non_administrator(settings):
def test_start_recording_non_owner_and_non_administrator():
"""Non-owner and Non-Administrator users should not be allowed to start room recordings."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
client = APIClient()
@@ -89,8 +88,8 @@ def test_start_recording_recording_disabled(settings):
{"mode": "screen_recording"},
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.status_code == 403
assert response.json() == {"detail": "Access denied, recording is disabled."}
assert Recording.objects.count() == 0
@@ -2,7 +2,7 @@
Test rooms API endpoints in the Meet core app: stop recording.
"""
# pylint: disable=redefined-outer-name,unused-argument
# pylint: disable=W0621,W0613
from unittest import mock
@@ -54,9 +54,8 @@ def test_stop_recording_anonymous():
assert Recording.objects.filter(status=RecordingStatusChoices.ACTIVE).count() == 1
def test_stop_recording_non_owner_and_non_administrator(settings):
def test_stop_recording_non_owner_and_non_administrator():
"""Non-owner and Non-Administrator users should not be allowed to stop room recordings."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
@@ -85,8 +84,8 @@ def test_stop_recording_recording_disabled(settings):
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.status_code == 403
assert response.json() == {"detail": "Access denied, recording is disabled."}
# Verify no recording exists
assert Recording.objects.count() == 0
@@ -1,221 +0,0 @@
"""
Test rooms API endpoints in the Meet core app: start subtitle.
"""
# pylint: disable=W0621
import uuid
from unittest import mock
from django.conf import settings
import pytest
from livekit.api import AccessToken, TwirpError, VideoGrants
from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory
pytestmark = pytest.mark.django_db
@pytest.fixture
def mock_room_id() -> str:
"""Mock room's id."""
return "d2aeb774-1ecd-4d73-a3ac-3d3530cad7ff"
@pytest.fixture
def mock_livekit_token(mock_room_id):
"""Mock LiveKit JWT token."""
video_grants = VideoGrants(
room=mock_room_id,
room_join=True,
room_admin=True,
can_update_own_metadata=True,
can_publish_sources=[
"camera",
"microphone",
"screen_share",
"screen_share_audio",
],
)
token = (
AccessToken(
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
api_secret=settings.LIVEKIT_CONFIGURATION["api_secret"],
)
.with_grants(video_grants)
.with_identity(str(uuid.uuid4()))
)
return token.to_jwt()
@pytest.fixture
def mock_livekit_client():
"""Mock LiveKit API client."""
with mock.patch("core.utils.create_livekit_client") as mock_create:
mock_client = mock.AsyncMock()
mock_create.return_value = mock_client
yield mock_client
def test_start_subtitle_missing_token_anonymous(settings):
"""Test that anonymous users cannot start subtitles without a valid LiveKit token."""
settings.ROOM_SUBTITLE_ENABLED = True
room = RoomFactory()
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_start_subtitle_missing_token_authenticated(settings):
"""Test that authenticated users still need a valid LiveKit token to start subtitles."""
settings.ROOM_SUBTITLE_ENABLED = True
room = RoomFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_start_subtitle_invalid_token():
"""Test that malformed or invalid LiveKit tokens are rejected."""
room = RoomFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/", {"token": "invalid-token"}
)
assert response.status_code == 403
assert response.json() == {"detail": "Invalid LiveKit token: Not enough segments"}
def test_start_subtitle_disabled_by_default(mock_livekit_token):
"""Test that subtitle functionality is disabled when feature flag is off."""
room = RoomFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{"token": mock_livekit_token},
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
def test_start_subtitle_valid_token(
settings, mock_livekit_client, mock_livekit_token, mock_room_id
):
"""Test successful subtitle initiation with valid token and enabled feature."""
settings.ROOM_SUBTITLE_ENABLED = True
room = RoomFactory(id=mock_room_id)
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{"token": mock_livekit_token},
)
assert response.status_code == 200
assert response.json() == {"status": "success"}
mock_livekit_client.agent_dispatch.create_dispatch.assert_called_once()
call_args = mock_livekit_client.agent_dispatch.create_dispatch.call_args[0][0]
assert call_args.agent_name == "multi-user-transcriber"
assert call_args.room == "d2aeb774-1ecd-4d73-a3ac-3d3530cad7ff"
def test_start_subtitle_twirp_error(
settings, mock_livekit_client, mock_livekit_token, mock_room_id
):
"""Test handling of LiveKit service errors during subtitle initiation."""
settings.ROOM_SUBTITLE_ENABLED = True
room = RoomFactory(id=mock_room_id)
client = APIClient()
mock_livekit_client.agent_dispatch.create_dispatch.side_effect = TwirpError(
msg="Internal server error", code=500, status=500
)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{"token": mock_livekit_token},
)
assert response.status_code == 500
assert response.json() == {
"error": f"Subtitles failed to start for room {room.slug}"
}
def test_start_subtitle_wrong_room(settings, mock_livekit_token):
"""Test that tokens are validated against the correct room ID."""
settings.ROOM_SUBTITLE_ENABLED = True
room = RoomFactory()
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{"token": mock_livekit_token},
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_start_subtitle_wrong_signature(settings, mock_livekit_token):
"""Test that tokens signed with incorrect signature are rejected."""
settings.ROOM_SUBTITLE_ENABLED = True
settings.LIVEKIT_CONFIGURATION["api_secret"] = "wrong-secret"
room = RoomFactory()
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{"token": mock_livekit_token},
)
assert response.status_code == 403
assert response.json() == {
"detail": "Invalid LiveKit token: Signature verification failed"
}
@@ -2,7 +2,8 @@
Test LiveKit webhook endpoint on the rooms API.
"""
# pylint: disable=too-many-arguments,redefined-outer-name,too-many-positional-arguments,unused-argument
# ruff: noqa: PLR0913
# pylint: disable=R0913,W0621,R0917,W0613
import base64
import hashlib
import json
@@ -24,7 +25,7 @@ def webhook_event_data():
"name": "00000000-0000-0000-0000-000000000000",
"emptyTimeout": 300,
"creationTime": "1692627281",
"turnPassword": "fake-turn-password",
"turnPassword": "2Pvdj+/WV1xV4EkB8klJ9xkXDWY=",
"enabledCodecs": [
{"mime": "audio/opus"},
{"mime": "video/H264"},
@@ -143,7 +144,7 @@ def test_handled_event_type(
def test_unhandled_event_type(client, mock_livekit_config):
"""Should return 200 for event types that have no handler."""
event_data = json.dumps({"event": "participant_joined"})
event_data = json.dumps({"event": "room_started"})
hash64 = base64.b64encode(hashlib.sha256(event_data.encode()).digest()).decode()
token = api.AccessToken(
@@ -1,16 +1,13 @@
"""
Test LiveKitEvents service.
"""
# pylint: disable=W0621,W0613, W0212, E0611
# pylint: disable=W0621,W0613, W0212
import uuid
from unittest import mock
import pytest
from livekit.api import EgressStatus
from core.factories import RecordingFactory, RoomFactory
from core.recording.services.recording_events import RecordingEventsService
from core.services.livekit_events import (
ActionFailedError,
AuthenticationError,
@@ -20,8 +17,6 @@ from core.services.livekit_events import (
api,
)
from core.services.lobby import LobbyService
from core.services.telephony import TelephonyException, TelephonyService
from core.utils import NotificationError
pytestmark = pytest.mark.django_db
@@ -58,251 +53,43 @@ def test_initialization(
mock_token_verifier.assert_called_once_with(api_key, api_secret)
mock_webhook_receiver.assert_called_once_with(mock_token_verifier.return_value)
assert isinstance(service.lobby_service, LobbyService)
assert isinstance(service.telephony_service, TelephonyService)
assert isinstance(service.recording_events, RecordingEventsService)
@pytest.mark.parametrize(
("mode", "notification_type"),
(
("screen_recording", "screenRecordingLimitReached"),
("transcript", "transcriptionLimitReached"),
),
)
@mock.patch("core.utils.notify_participants")
def test_handle_egress_ended_success(mock_notify, mode, notification_type, service):
"""Should successfully stop recording and notifies all participant."""
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
service._handle_egress_ended(mock_data)
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
recording.refresh_from_db()
assert recording.status == "stopped"
@mock.patch("core.utils.notify_participants")
def test_handle_egress_ended_notification_fails(mock_notify, service):
"""Should raise ActionFailedError when notification fails but still stop recording."""
recording = RecordingFactory(worker_id="worker-1", status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
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.patch("core.utils.notify_participants")
def test_handle_egress_ended_recording_not_found(mock_notify, service):
"""Should raise ActionFailedError when recording doesn't exist."""
recording = RecordingFactory(worker_id="worker-1", status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = "worker-2"
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
with pytest.raises(
ActionFailedError, match=r"Recording with worker ID .+ does not exist"
):
service._handle_egress_ended(mock_data)
mock_notify.assert_not_called()
recording.refresh_from_db()
assert recording.status == "active"
@mock.patch("core.utils.notify_participants")
def test_handle_egress_ended_recording_not_active(mock_notify, service):
"""Should ignore non-active recordings."""
recording = RecordingFactory(worker_id="worker-1", status="failed_to_stop")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = "worker-1"
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
service._handle_egress_ended(mock_data)
mock_notify.assert_not_called()
recording.refresh_from_db()
assert recording.status == "failed_to_stop"
@mock.patch("core.utils.notify_participants")
def test_handle_egress_ended_recording_not_limit_reached(mock_notify, service):
"""Should ignore egress non-limit-reached statuses."""
recording = RecordingFactory(worker_id="worker-1", status="stopped")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = "worker-1"
mock_data.egress_info.status = EgressStatus.EGRESS_COMPLETE
service._handle_egress_ended(mock_data)
mock_notify.assert_not_called()
assert recording.status == "stopped"
@mock.patch.object(LobbyService, "clear_room_cache")
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
mock_delete_dispatch_rule, mock_clear_cache, service, settings
):
"""Should clear lobby cache and delete telephony dispatch rule when room finishes."""
settings.ROOM_TELEPHONY_ENABLED = True
def test_handle_room_finished(mock_clear_cache, service):
"""Should clear lobby cache when room is finished."""
mock_room_name = uuid.uuid4()
mock_data = mock.MagicMock()
mock_data.room.name = str(mock_room_name)
service._handle_room_finished(mock_data)
mock_delete_dispatch_rule.assert_called_once_with(mock_room_name)
mock_clear_cache.assert_called_once_with(mock_room_name)
@mock.patch.object(LobbyService, "clear_room_cache")
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
def test_handle_room_finished_skips_telephony_when_disabled(
mock_delete_dispatch_rule, mock_clear_cache, service, settings
):
"""Should clear lobby cache but skip dispatch rule deletion when telephony is disabled."""
settings.ROOM_TELEPHONY_ENABLED = False
mock_room_name = uuid.uuid4()
mock_data = mock.MagicMock()
mock_data.room.name = str(mock_room_name)
service._handle_room_finished(mock_data)
mock_delete_dispatch_rule.assert_not_called()
mock_clear_cache.assert_called_once_with(mock_room_name)
@mock.patch.object(
LobbyService, "clear_room_cache", side_effect=Exception("Test error")
)
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
def test_handle_room_finished_raises_error_when_cache_clearing_fails(
mock_delete_dispatch_rule, mock_clear_cache, service, settings
):
"""Should raise ActionFailedError when lobby cache clearing fails when room finishes."""
settings.ROOM_TELEPHONY_ENABLED = True
def test_handle_room_finished_error(mock_clear_cache, service):
"""Should raise ActionFailedError when processing fails."""
mock_data = mock.MagicMock()
mock_data.room.name = "00000000-0000-0000-0000-000000000000"
expected_error = (
"Failed to clear room cache for room 00000000-0000-0000-0000-000000000000"
)
with pytest.raises(ActionFailedError, match=expected_error):
service._handle_room_finished(mock_data)
mock_delete_dispatch_rule.assert_called_once_with(
uuid.UUID("00000000-0000-0000-0000-000000000000")
)
@mock.patch.object(LobbyService, "clear_room_cache")
@mock.patch.object(
TelephonyService,
"delete_dispatch_rule",
side_effect=TelephonyException("Test error"),
)
def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
mock_delete_dispatch_rule, mock_clear_cache, service, settings
):
"""Should raise ActionFailedError when dispatch rule deletion fails when room finishes."""
settings.ROOM_TELEPHONY_ENABLED = True
mock_data = mock.MagicMock()
mock_data.room.name = "00000000-0000-0000-0000-000000000000"
expected_error = (
"Failed to delete telephony dispatch rule for room "
"00000000-0000-0000-0000-000000000000"
)
with pytest.raises(ActionFailedError, match=expected_error):
service._handle_room_finished(mock_data)
mock_clear_cache.assert_not_called()
def test_handle_room_finished_raises_error_for_invalid_room_name(service):
"""Should raise ActionFailedError when room name format is invalid when room finishes."""
mock_data = mock.MagicMock()
mock_data.room.name = "invalid"
with pytest.raises(
ActionFailedError, match="Failed to process room finished event"
):
service._handle_room_finished(mock_data)
@mock.patch.object(TelephonyService, "create_dispatch_rule")
def test_handle_room_started_creates_dispatch_rule_successfully(
mock_create_dispatch_rule, service, settings
):
"""Should create telephony dispatch rule when room starts successfully."""
settings.ROOM_TELEPHONY_ENABLED = True
room = RoomFactory()
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
service._handle_room_started(mock_data)
mock_create_dispatch_rule.assert_called_once_with(room)
@mock.patch.object(TelephonyService, "create_dispatch_rule")
def test_handle_room_started_skips_dispatch_rule_when_telephony_disabled(
mock_create_dispatch_rule, service, settings
):
"""Should skip creating telephony dispatch rule when telephony is disabled during room start."""
settings.ROOM_TELEPHONY_ENABLED = False
room = RoomFactory()
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
service._handle_room_started(mock_data)
mock_create_dispatch_rule.assert_not_called()
def test_handle_room_started_raises_error_for_invalid_room_name(service):
"""Should raise ActionFailedError when room name format is invalid when room starts."""
def test_handle_room_finished_invalid_room_name(service):
"""Should raise ActionFailedError when processing fails."""
mock_data = mock.MagicMock()
mock_data.room.name = "invalid"
with pytest.raises(ActionFailedError, match="Failed to process room started event"):
service._handle_room_started(mock_data)
def test_handle_room_started_raises_error_for_nonexistent_room(service):
"""Should raise ActionFailedError when a room starts that doesn't exist in the database."""
mock_data = mock.MagicMock()
mock_data.room.name = str(uuid.uuid4())
expected_error = f"Room with ID {mock_data.room.name} does not exist"
with pytest.raises(ActionFailedError, match=expected_error):
service._handle_room_started(mock_data)
with pytest.raises(
ActionFailedError, match="Failed to process room finished event"
):
service._handle_room_finished(mock_data)
@mock.patch.object(
+119 -49
View File
@@ -5,6 +5,7 @@ Test lobby service.
# pylint: disable=W0621,W0613, W0212, R0913
# ruff: noqa: PLR0913
import json
import uuid
from unittest import mock
@@ -13,17 +14,18 @@ from django.core.cache import cache
from django.http import HttpResponse
import pytest
from livekit.api import TwirpError
from core.factories import RoomFactory
from core.models import RoomAccessLevel
from core.services.lobby import (
LobbyNotificationError,
LobbyParticipant,
LobbyParticipantNotFound,
LobbyParticipantParsingError,
LobbyParticipantStatus,
LobbyService,
)
from core.utils import NotificationError
pytestmark = pytest.mark.django_db
@@ -144,9 +146,10 @@ def test_get_or_create_participant_id_from_cookie(lobby_service):
assert participant_id == "existing-id"
@mock.patch.object(uuid, "uuid4", return_value="generated-id")
@mock.patch("uuid.uuid4")
def test_get_or_create_participant_id_new(mock_uuid4, lobby_service):
"""Test creating new participant ID when cookie is missing."""
mock_uuid4.return_value = mock.Mock(hex="generated-id")
request = mock.Mock()
request.COOKIES = {}
@@ -265,9 +268,6 @@ def test_request_entry_public_room(
user=request.user,
username=username,
color=participant.color,
configuration=room.configuration,
is_admin_or_owner=False,
participant_id="test-participant-id",
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -304,9 +304,6 @@ def test_request_entry_trusted_room(
user=request.user,
username=username,
color=participant.color,
configuration=room.configuration,
is_admin_or_owner=False,
participant_id="test-participant-id",
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -399,9 +396,6 @@ def test_request_entry_accepted_participant(
user=request.user,
username=username,
color="#123456",
configuration=room.configuration,
is_admin_or_owner=False,
participant_id="test-participant-id",
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@@ -420,7 +414,7 @@ def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
# pylint: disable=R0917
@mock.patch("core.services.lobby.cache")
@mock.patch("core.utils.generate_color")
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.lobby.LobbyService.notify_participants")
def test_enter_success(
mock_notify,
mock_generate_color,
@@ -449,15 +443,13 @@ def test_enter_success(
participant.to_dict(),
timeout=settings.LOBBY_WAITING_TIMEOUT,
)
mock_notify.assert_called_once_with(
room_name=str(room.pk), notification_data={"type": "participantWaiting"}
)
mock_notify.assert_called_once_with(room_id=room.id)
# pylint: disable=R0917
@mock.patch("core.services.lobby.cache")
@mock.patch("core.utils.generate_color")
@mock.patch("core.utils.notify_participants")
@mock.patch("core.services.lobby.LobbyService.notify_participants")
def test_enter_with_notification_error(
mock_notify,
mock_generate_color,
@@ -468,7 +460,7 @@ def test_enter_with_notification_error(
):
"""Test participant entry with notification error."""
mock_generate_color.return_value = "#123456"
mock_notify.side_effect = NotificationError("Error notifying")
mock_notify.side_effect = LobbyNotificationError("Error notifying")
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
@@ -784,6 +776,116 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_success_no_room(mock_create_livekit_client, lobby_service):
"""Test the notify_participants method when the LiveKit room doesn't exist yet."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
# Set up the mock LiveKitAPI and its behavior
mock_api_instance = mock.Mock()
mock_api_instance.room = mock.Mock()
mock_api_instance.room.send_data = mock.AsyncMock()
# Create a proper response object with an empty rooms list
class MockResponse:
"""LiveKit API response mock with empty rooms list."""
rooms = []
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api_instance
# Act
lobby_service.notify_participants(room.id)
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# Verify the send_data method was not called since no room exists
mock_api_instance.room.send_data.assert_not_called()
# Verify the connection was properly closed
mock_api_instance.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_success(mock_create_livekit_client, lobby_service):
"""Test successful participant notification."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
# Set up the mock LiveKitAPI and its behavior
mock_api_instance = mock.Mock()
mock_api_instance.room = mock.Mock()
mock_api_instance.room.send_data = mock.AsyncMock()
class MockResponse:
"""LiveKit API response mock with non-empty rooms list."""
rooms = ["room-1"]
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api_instance
# Call the function
lobby_service.notify_participants(room.id)
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# Verify the send_data method was called
mock_api_instance.room.send_data.assert_called_once()
send_data_request = mock_api_instance.room.send_data.call_args[0][0]
assert send_data_request.room == str(room.id)
assert (
json.loads(send_data_request.data.decode("utf-8"))["type"]
== settings.LOBBY_NOTIFICATION_TYPE
)
assert send_data_request.kind == 0 # RELIABLE mode in Livekit protocol
# Verify aclose was called
mock_api_instance.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_error(mock_create_livekit_client, lobby_service):
"""Test participant notification with API error."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
# Set up the mock LiveKitAPI and its behavior
mock_api_instance = mock.Mock()
mock_api_instance.room = mock.Mock()
mock_api_instance.room.send_data = mock.AsyncMock(
side_effect=TwirpError(msg="test error", code=123, status=123)
)
class MockResponse:
"""LiveKit API response mock with non-empty rooms list."""
rooms = ["room-1"]
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api_instance
# Call the function and expect an exception
with pytest.raises(
LobbyNotificationError, match="Failed to notify room participants"
):
lobby_service.notify_participants(room.id)
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# Verify send_data was called
mock_api_instance.room.send_data.assert_called_once()
# Verify aclose was still called after the exception
mock_api_instance.aclose.assert_called_once()
def test_clear_room_cache(settings, lobby_service):
"""Test clearing room cache actually removes entries from cache."""
@@ -839,35 +941,3 @@ def test_clear_room_empty(settings, lobby_service):
assert cache.keys(f"test-lobby_{room_id!s}_*") == []
lobby_service.clear_room_cache(room_id)
assert cache.keys(f"test-lobby_{room_id!s}_*") == []
def test_clear_participant_cache(lobby_service):
"""Test clearing a specific participant entry from cache."""
room_id = uuid.uuid4()
participant_id = "test-participant-id"
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
participant_data = {
"status": "waiting",
"username": "test-username",
"id": participant_id,
"color": "#123456",
}
cache.set(cache_key, participant_data, timeout=settings.LOBBY_WAITING_TIMEOUT)
assert cache.get(cache_key) is not None
lobby_service.clear_participant_cache(room_id, participant_id)
assert cache.get(cache_key) is None
def test_clear_participant_cache_nonexistent(lobby_service):
"""Test clearing a participant that doesn't exist in cache."""
room_id = uuid.uuid4()
participant_id = "nonexistent-participant"
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
assert cache.get(cache_key) is None
lobby_service.clear_participant_cache(room_id, participant_id)
assert cache.get(cache_key) is None
@@ -11,7 +11,6 @@ from django.core.exceptions import ImproperlyConfigured
import brevo_python
import pytest
import urllib3
from core.services.marketing import (
BrevoMarketingService,
@@ -134,34 +133,6 @@ def test_create_contact_api_error(mock_contact_api):
brevo_service.create_contact(valid_contact_data)
@mock.patch("brevo_python.ContactsApi")
def test_create_contact_timeout_error(mock_contact_api):
"""Test contact creation timeout error handling."""
mock_api = mock_contact_api.return_value
settings.BREVO_API_KEY = "test-api-key"
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
valid_contact_data = ContactData(
email="test@example.com",
attributes={"first_name": "Test"},
list_ids=[1, 2],
update_enabled=True,
)
brevo_service = BrevoMarketingService()
mock_api.create_contact.side_effect = urllib3.exceptions.ReadTimeoutError(
pool=mock.Mock(),
url="https://api.brevo.com/v3/endpoint",
message="HTTPSConnectionPool(host='api.brevo.com', port=443): Read timed out.",
)
with pytest.raises(ContactCreationError, match="Failed to create contact in Brevo"):
brevo_service.create_contact(valid_contact_data)
@pytest.fixture
def clear_marketing_cache():
"""Clear marketing service cache between tests."""
@@ -1,48 +0,0 @@
"""
Test subtitle service.
"""
# pylint: disable=W0621
from unittest import mock
import pytest
from core.factories import RoomFactory
from core.services.subtitle import SubtitleService
pytestmark = pytest.mark.django_db
@pytest.fixture
def mock_livekit_client():
"""Mock LiveKit API client."""
with mock.patch("core.utils.create_livekit_client") as mock_create:
mock_client = mock.AsyncMock()
mock_create.return_value = mock_client
yield mock_client
def test_start_subtitle_settings(mock_livekit_client, settings):
"""Test that start_subtitle uses the configured agent name from Django settings."""
settings.ROOM_SUBTITLE_AGENT_NAME = "fake-subtitle-agent-name"
room = RoomFactory(name="my room")
SubtitleService().start_subtitle(room)
mock_livekit_client.agent_dispatch.create_dispatch.assert_called_once()
call_args = mock_livekit_client.agent_dispatch.create_dispatch.call_args[0][0]
assert call_args.agent_name == "fake-subtitle-agent-name"
assert call_args.room == str(room.id)
def test_stop_subtitle_not_implemented():
"""Test that stop_subtitle raises NotImplementedError."""
room = RoomFactory(name="my room")
with pytest.raises(
NotImplementedError, match="Subtitle agent stopping not yet implemented"
):
SubtitleService().stop_subtitle(room)
@@ -1,305 +0,0 @@
"""
Test telephony service.
"""
# pylint: disable=W0212
from unittest import mock
import pytest
from asgiref.sync import async_to_sync
from livekit.api import TwirpError
from livekit.protocol.sip import (
CreateSIPDispatchRuleRequest,
DeleteSIPDispatchRuleRequest,
ListSIPDispatchRuleRequest,
ListSIPDispatchRuleResponse,
SIPDispatchRule,
SIPDispatchRuleInfo,
)
from core.factories import RoomFactory
from core.models import RoomAccessLevel
from core.services.telephony import TelephonyException, TelephonyService
pytestmark = pytest.mark.django_db
def create_mock_livekit_client():
"""Factory for creating LiveKit client mock."""
mock_api = mock.Mock()
mock_api.sip = mock.Mock()
mock_api.aclose = mock.AsyncMock()
return mock_api
def test_rule_name():
"""Test rule name generation."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
rule_name = telephony_service._rule_name(room.id)
assert rule_name == f"SIP_{str(room.id)}"
@mock.patch("core.utils.create_livekit_client")
def test_create_dispatch_rule_success(mock_client_factory):
"""Test successful dispatch rule creation."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock()
mock_client_factory.return_value = mock_api
telephony_service.create_dispatch_rule(room)
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
create_request = mock_api.sip.create_sip_dispatch_rule.call_args[1]["create"]
assert isinstance(create_request, CreateSIPDispatchRuleRequest)
assert create_request.name == f"SIP_{str(room.id)}"
assert create_request.rule.dispatch_rule_direct.room_name == str(room.id)
assert create_request.rule.dispatch_rule_direct.pin == str(room.pin_code)
mock_api.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_create_dispatch_rule_api_failure(mock_client_factory):
"""Test dispatch rule creation when API fails."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock(
side_effect=TwirpError(msg="Internal server error", code=500, status=500)
)
mock_client_factory.return_value = mock_api
with pytest.raises(TelephonyException, match="Could not create dispatch rule"):
telephony_service.create_dispatch_rule(room)
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
mock_api.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_list_dispatch_rules_ids_success(mock_client_factory):
"""Test successful listing of dispatch rule IDs."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_rules = [
SIPDispatchRuleInfo(
sip_dispatch_rule_id="rule-1",
name=f"SIP_{str(room.id)}",
rule=SIPDispatchRule(),
),
SIPDispatchRuleInfo(
sip_dispatch_rule_id="rule-2", name="OTHER_RULE", rule=SIPDispatchRule()
),
SIPDispatchRuleInfo(
sip_dispatch_rule_id="rule-3",
name=f"SIP_{str(room.id)}",
rule=SIPDispatchRule(),
),
]
mock_api = create_mock_livekit_client()
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
return_value=ListSIPDispatchRuleResponse(items=mock_rules)
)
mock_client_factory.return_value = mock_api
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
assert len(result) == 2
assert "rule-1" in result
assert "rule-3" in result
assert "rule-2" not in result
mock_api.sip.list_sip_dispatch_rule.assert_called_once()
list_request = mock_api.sip.list_sip_dispatch_rule.call_args[1]["list"]
assert isinstance(list_request, ListSIPDispatchRuleRequest)
mock_api.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
"""Test listing dispatch rule IDs when no rules exist."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
return_value=ListSIPDispatchRuleResponse(items=[])
)
mock_client_factory.return_value = mock_api
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
assert result == []
mock_api.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
"""Test listing dispatch rule IDs when no rules match the room."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_rules = [
SIPDispatchRuleInfo(
sip_dispatch_rule_id="rule-1", name="OTHER_RULE_1", rule=SIPDispatchRule()
),
SIPDispatchRuleInfo(
sip_dispatch_rule_id="rule-2", name="OTHER_RULE_2", rule=SIPDispatchRule()
),
]
mock_api = create_mock_livekit_client()
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
return_value=ListSIPDispatchRuleResponse(items=mock_rules)
)
mock_client_factory.return_value = mock_api
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
assert result == []
mock_api.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_list_dispatch_rules_ids_api_failure(mock_client_factory):
"""Test listing dispatch rule IDs when API fails."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
side_effect=TwirpError(msg="Internal server error", code=500, status=500)
)
mock_client_factory.return_value = mock_api
with pytest.raises(TelephonyException, match="Could not list dispatch rules"):
async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
mock_api.sip.list_sip_dispatch_rule.assert_called_once()
mock_api.aclose.assert_called_once()
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_no_rules(mock_client_factory, mock_list_rules):
"""Test deleting dispatch rules when no rules exist."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = []
result = telephony_service.delete_dispatch_rule(room.id)
assert result is False
mock_list_rules.assert_called_once_with(room.id)
mock_client_factory.assert_not_called()
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
"""Test deleting a single dispatch rule."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1"]
mock_api = create_mock_livekit_client()
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
mock_client_factory.return_value = mock_api
result = telephony_service.delete_dispatch_rule(room.id)
assert result is True
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
delete_request = mock_api.sip.delete_sip_dispatch_rule.call_args[1]["delete"]
assert isinstance(delete_request, DeleteSIPDispatchRuleRequest)
assert delete_request.sip_dispatch_rule_id == "rule-1"
mock_api.aclose.assert_called_once()
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rules):
"""Test deleting multiple dispatch rules."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
mock_api = create_mock_livekit_client()
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
mock_client_factory.return_value = mock_api
result = telephony_service.delete_dispatch_rule(room.id)
assert result is True
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 3
deleted_rule_ids = [
call_args[1]["delete"].sip_dispatch_rule_id
for call_args in mock_api.sip.delete_sip_dispatch_rule.call_args_list
]
assert all(
rule_id in deleted_rule_ids for rule_id in ["rule-1", "rule-2", "rule-3"]
)
mock_api.aclose.assert_called_once()
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rules):
"""Test deleting multiple dispatch rules when one deletion fails."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
mock_api = create_mock_livekit_client()
call_count = 0
def delete_side_effect(*args, **kwargs):
nonlocal call_count
if call_count == 0:
call_count += 1
return None
raise TwirpError(msg="Deletion failed", code=500, status=500)
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock(
side_effect=delete_side_effect
)
mock_client_factory.return_value = mock_api
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"):
telephony_service.delete_dispatch_rule(room.id)
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 2
mock_api.aclose.assert_called_once()
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules):
"""Test deleting dispatch rules when API fails immediately."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1"]
mock_api = create_mock_livekit_client()
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock(
side_effect=TwirpError(msg="Internal server error", code=500, status=500)
)
mock_client_factory.return_value = mock_api
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"):
telephony_service.delete_dispatch_rule(room.id)
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
mock_api.aclose.assert_called_once()
+6 -178
View File
@@ -2,12 +2,6 @@
Unit tests for the Room model
"""
# pylint: disable=W0613
import secrets
from logging import Logger
from unittest import mock
from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ValidationError
@@ -102,7 +96,7 @@ def test_models_rooms_access_rights_none(django_assert_num_queries):
with django_assert_num_queries(0):
assert room.get_role(None) is None
with django_assert_num_queries(0):
assert room.is_administrator_or_owner(None) is False
assert room.is_administrator(None) is False
with django_assert_num_queries(0):
assert room.is_owner(None) is False
@@ -115,7 +109,7 @@ def test_models_rooms_access_rights_anonymous(django_assert_num_queries):
with django_assert_num_queries(0):
assert room.get_role(user) is None
with django_assert_num_queries(0):
assert room.is_administrator_or_owner(user) is False
assert room.is_administrator(user) is False
with django_assert_num_queries(0):
assert room.is_owner(user) is False
@@ -128,7 +122,7 @@ def test_models_rooms_access_rights_authenticated(django_assert_num_queries):
with django_assert_num_queries(1):
assert room.get_role(user) is None
with django_assert_num_queries(1):
assert room.is_administrator_or_owner(user) is False
assert room.is_administrator(user) is False
with django_assert_num_queries(1):
assert room.is_owner(user) is False
@@ -141,7 +135,7 @@ def test_models_rooms_access_rights_member_direct(django_assert_num_queries):
with django_assert_num_queries(1):
assert room.get_role(user) == "member"
with django_assert_num_queries(1):
assert room.is_administrator_or_owner(user) is False
assert room.is_administrator(user) is False
with django_assert_num_queries(1):
assert room.is_owner(user) is False
@@ -154,7 +148,7 @@ def test_models_rooms_access_rights_administrator_direct(django_assert_num_queri
with django_assert_num_queries(1):
assert room.get_role(user) == "administrator"
with django_assert_num_queries(1):
assert room.is_administrator_or_owner(user) is True
assert room.is_administrator(user) is True
with django_assert_num_queries(1):
assert room.is_owner(user) is False
@@ -167,7 +161,7 @@ def test_models_rooms_access_rights_owner_direct(django_assert_num_queries):
with django_assert_num_queries(1):
assert room.get_role(user) == "owner"
with django_assert_num_queries(1):
assert room.is_administrator_or_owner(user) is True
assert room.is_administrator(user) is True
with django_assert_num_queries(1):
assert room.is_owner(user) is True
@@ -181,169 +175,3 @@ def test_models_rooms_is_public_property():
# Test non-public room
private_room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
assert private_room.is_public is False
@mock.patch.object(Room, "generate_unique_pin_code")
def test_telephony_disabled_skips_pin_generation(
mock_generate_unique_pin_code, settings
):
"""Telephony disabled should not generate pin codes."""
settings.ROOM_TELEPHONY_ENABLED = False
room = RoomFactory()
mock_generate_unique_pin_code.assert_not_called()
assert room.pin_code is None
def test_default_and_custom_pin_length(settings):
"""Pin codes should be created with correct configured length."""
settings.ROOM_TELEPHONY_ENABLED = True
room = RoomFactory()
# Assert default value is 10 for collision reasons
assert len(room.pin_code) == 10
settings.ROOM_TELEPHONY_PIN_LENGTH = 5
room = RoomFactory()
# Assert custom size
assert len(room.pin_code) == 5
def test_room_updates_preserve_pin_code(settings):
"""Room updates should preserve existing pin code."""
settings.ROOM_TELEPHONY_ENABLED = True
room = RoomFactory()
# Store the original pin code to compare after updates
previous_pin_code = room.pin_code
# If this method is called, it would indicate the pin is being regenerated unnecessarily
with mock.patch.object(
Room, "generate_unique_pin_code"
) as mock_generate_unique_pin_code:
# Explicitly call save to persist the changes to the room
room.slug = "aaa-aaaa-aaa"
room.save()
assert room.pin_code == previous_pin_code
mock_generate_unique_pin_code.assert_not_called()
@pytest.mark.parametrize("is_telephony_enabled", [True, False])
def test_manual_pin_code_updates(is_telephony_enabled, settings):
"""Manual pin code changes should persist regardless of telephony setting."""
settings.ROOM_TELEPHONY_ENABLED = is_telephony_enabled
settings.ROOM_TELEPHONY_PIN_LENGTH = 5
room = RoomFactory()
assert room.pin_code != "12345"
room.pin_code = "12345"
room.save()
assert room.pin_code == "12345"
# No data validation when manual updates are made
room.pin_code = "123"
room.save()
assert room.pin_code == "123"
def test_pin_code_uniqueness(settings):
"""Duplicate pin codes should raise validation error."""
settings.ROOM_TELEPHONY_ENABLED = True
settings.ROOM_TELEPHONY_PIN_LENGTH = 5
RoomFactory(pin_code="12345")
with pytest.raises(ValidationError) as excinfo:
RoomFactory(pin_code="12345")
assert "Room with this Room PIN code already exists." in str(excinfo.value)
@pytest.mark.parametrize("invalid_length", [0, 1, 2, 3])
def test_pin_code_minimum_length(invalid_length, settings):
"""Pin codes should enforce minimum length for security."""
settings.ROOM_TELEPHONY_ENABLED = True
settings.ROOM_TELEPHONY_PIN_LENGTH = 4
# Assert no exception is raised with a valid length
RoomFactory()
settings.ROOM_TELEPHONY_PIN_LENGTH = invalid_length
with pytest.raises(ValueError) as excinfo:
RoomFactory()
assert "PIN code length must be at least 4 digits for minimal security" in str(
excinfo.value
)
@mock.patch.object(Logger, "warning")
@mock.patch.object(secrets, "randbelow", return_value=12345)
def test_pin_generation_max_retries(mock_randbelow, mock_logger, settings):
"""Pin generation should give up after max retries."""
settings.ROOM_TELEPHONY_ENABLED = True
settings.ROOM_TELEPHONY_PIN_LENGTH = 5
RoomFactory(pin_code="12345")
# Assert default max retries is low, 5
room1 = RoomFactory()
assert mock_randbelow.call_count == 5
assert room1.pin_code is None
mock_logger.assert_called_once_with(
"Failed to generate unique PIN code of length %s after %s attempts", 5, 5
)
mock_logger.reset_mock()
mock_randbelow.reset_mock()
settings.ROOM_TELEPHONY_PIN_MAX_RETRIES = 3
room2 = RoomFactory()
assert mock_randbelow.call_count == 3
assert room2.pin_code is None
mock_logger.assert_called_once_with(
"Failed to generate unique PIN code of length %s after %s attempts", 5, 3
)
@mock.patch.object(secrets, "randbelow", return_value=12345)
def test_pin_code_zero_padding(mock_randbelow, settings):
"""Pin codes should be zero-padded to meet required length."""
settings.ROOM_TELEPHONY_ENABLED = True
settings.ROOM_TELEPHONY_PIN_LENGTH = 10
room = RoomFactory()
assert room.pin_code == "0000012345"
@mock.patch.object(secrets, "randbelow", return_value=12345)
def test_pin_generation_upper_bound(mock_randbelow, settings):
"""Random number generator should use correct upper bound based on pin length."""
settings.ROOM_TELEPHONY_ENABLED = False
room = RoomFactory()
room.generate_unique_pin_code(length=5)
# Assert called with the right exclusive upper bound, 10^5
mock_randbelow.assert_called_with(100000)
+1 -107
View File
@@ -2,13 +2,9 @@
Test utils functions
"""
import json
from unittest import mock
import pytest
from livekit.api import TwirpError
from core.utils import NotificationError, create_livekit_client, notify_participants
from core.utils import create_livekit_client
@mock.patch("asyncio.get_running_loop")
@@ -64,105 +60,3 @@ def test_create_livekit_client_custom_configuration(
create_livekit_client(custom_configuration)
mock_livekit_api.assert_called_once_with(**custom_configuration, session=None)
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_error(mock_create_livekit_client):
"""Test participant notification with API error."""
# Set up the mock LiveKitAPI and its behavior
mock_api_instance = mock.Mock()
mock_api_instance.room = mock.Mock()
mock_api_instance.room.send_data = mock.AsyncMock(
side_effect=TwirpError(msg="test error", code=123, status=123)
)
class MockResponse:
"""LiveKit API response mock with non-empty rooms list."""
rooms = ["room-1"]
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api_instance
# Call the function and expect an exception
with pytest.raises(NotificationError, match="Failed to notify room participants"):
notify_participants(room_name="room-number-1", notification_data={"foo": "foo"})
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# Verify send_data was called
mock_api_instance.room.send_data.assert_called_once()
# Verify aclose was still called after the exception
mock_api_instance.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_success_no_room(mock_create_livekit_client):
"""Test the notify_participants function when the LiveKit room doesn't exist."""
# Set up the mock LiveKitAPI and its behavior
mock_api_instance = mock.Mock()
mock_api_instance.room = mock.Mock()
mock_api_instance.room.send_data = mock.AsyncMock()
# Create a proper response object with an empty rooms list
class MockResponse:
"""LiveKit API response mock with empty rooms list."""
rooms = []
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api_instance
notify_participants(room_name="room-number-1", notification_data={"foo": "foo"})
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# Verify the send_data method was not called since no room exists
mock_api_instance.room.send_data.assert_not_called()
# Verify the connection was properly closed
mock_api_instance.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_success(mock_create_livekit_client):
"""Test successful participant notification."""
# Set up the mock LiveKitAPI and its behavior
mock_api_instance = mock.Mock()
mock_api_instance.room = mock.Mock()
mock_api_instance.room.send_data = mock.AsyncMock()
class MockResponse:
"""LiveKit API response mock with non-empty rooms list."""
rooms = ["room-1"]
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api_instance
# Call the function
notify_participants(room_name="room-number-1", notification_data={"foo": "foo"})
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# Verify the send_data method was called
mock_api_instance.room.send_data.assert_called_once()
send_data_request = mock_api_instance.room.send_data.call_args[0][0]
assert send_data_request.room == "room-number-1"
assert json.loads(send_data_request.data.decode("utf-8")) == {"foo": "foo"}
assert send_data_request.kind == 0 # RELIABLE mode in Livekit protocol
# Verify aclose was called
mock_api_instance.aclose.assert_called_once()
+15 -97
View File
@@ -2,13 +2,12 @@
Utils functions used in the core app
"""
# pylint: disable=R0913, R0917
# ruff: noqa:S311, PLR0913
# ruff: noqa:S311
import hashlib
import json
import random
from typing import List, Optional
from typing import Optional
from uuid import uuid4
from django.conf import settings
@@ -16,15 +15,7 @@ from django.core.files.storage import default_storage
import aiohttp
import botocore
from asgiref.sync import async_to_sync
from livekit.api import ( # pylint: disable=E0611
AccessToken,
ListRoomsRequest,
LiveKitAPI,
SendDataRequest,
TwirpError,
VideoGrants,
)
from livekit.api import AccessToken, LiveKitAPI, VideoGrants
def generate_color(identity: str) -> str:
@@ -50,13 +41,7 @@ def generate_color(identity: str) -> str:
def generate_token(
room: str,
user,
username: Optional[str] = None,
color: Optional[str] = None,
sources: Optional[List[str]] = None,
is_admin_or_owner: bool = False,
participant_id: Optional[str] = None,
room: str, user, username: Optional[str] = None, color: Optional[str] = None
) -> str:
"""Generate a LiveKit access token for a user in a specific room.
@@ -67,34 +52,25 @@ def generate_token(
If none, a default value will be used.
color (Optional[str]): The color to be displayed in the room.
If none, a value will be generated
sources: (Optional[List[str]]): List of media sources the user can publish
If none, defaults to LIVEKIT_DEFAULT_SOURCES.
is_admin_or_owner (bool): Whether user has admin privileges
participant_id (Optional[str]): Stable identifier for anonymous users;
used as identity when user.is_anonymous.
Returns:
str: The LiveKit JWT access token.
"""
if is_admin_or_owner:
sources = settings.LIVEKIT_DEFAULT_SOURCES
if sources is None:
sources = settings.LIVEKIT_DEFAULT_SOURCES
video_grants = VideoGrants(
room=room,
room_join=True,
room_admin=is_admin_or_owner,
room_admin=True,
can_update_own_metadata=True,
can_publish=bool(sources),
can_publish_sources=sources,
can_subscribe=True,
can_publish_sources=[
"camera",
"microphone",
"screen_share",
"screen_share_audio",
],
)
if user.is_anonymous:
identity = participant_id or str(uuid4())
identity = str(uuid4())
default_username = "Anonymous"
else:
identity = str(user.sub)
@@ -111,22 +87,14 @@ def generate_token(
.with_grants(video_grants)
.with_identity(identity)
.with_name(username or default_username)
.with_attributes(
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
)
.with_metadata(json.dumps({"color": color}))
)
return token.to_jwt()
def generate_livekit_config(
room_id: str,
user,
username: str,
is_admin_or_owner: bool,
color: Optional[str] = None,
configuration: Optional[dict] = None,
participant_id: Optional[str] = None,
room_id: str, user, username: str, color: Optional[str] = None
) -> dict:
"""Generate LiveKit configuration for room access.
@@ -134,31 +102,15 @@ def generate_livekit_config(
room_id: Room identifier
user: User instance requesting access
username: Display name in room
is_admin_or_owner (bool): Whether the user has admin/owner privileges for this room.
color (Optional[str]): Optional color to associate with the participant.
configuration (Optional[dict]): Room configuration dict that can override default settings.
participant_id (Optional[str]): Stable identifier for anonymous users;
used as identity when user.is_anonymous.
Returns:
dict: LiveKit configuration with URL, room and access token
"""
sources = None
if configuration is not None:
sources = configuration.get("can_publish_sources", None)
return {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"room": room_id,
"token": generate_token(
room=room_id,
user=user,
username=username,
color=color,
sources=sources,
is_admin_or_owner=is_admin_or_owner,
participant_id=participant_id,
room=room_id, user=user, username=username, color=color
),
}
@@ -206,37 +158,3 @@ def create_livekit_client(custom_configuration=None):
configuration = custom_configuration or settings.LIVEKIT_CONFIGURATION
return LiveKitAPI(session=custom_session, **configuration)
class NotificationError(Exception):
"""Notification delivery to room participants fails."""
@async_to_sync
async def notify_participants(room_name: str, notification_data: dict):
"""Send notification data to all participants in a LiveKit room."""
lkapi = create_livekit_client()
try:
room_response = await lkapi.room.list_rooms(
ListRoomsRequest(
names=[room_name],
)
)
# Check if the room exists
if not room_response.rooms:
return
await lkapi.room.send_data(
SendDataRequest(
room=room_name,
data=json.dumps(notification_data).encode("utf-8"),
kind="RELIABLE",
)
)
except TwirpError as e:
raise NotificationError("Failed to notify room participants") from e
finally:
await lkapi.aclose()
Binary file not shown.
@@ -1,455 +0,0 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-07-11 11:33+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
#: core/admin.py:26
msgid "Personal info"
msgstr "Persönliche Informationen"
#: core/admin.py:39
msgid "Permissions"
msgstr "Berechtigungen"
#: core/admin.py:51
msgid "Important dates"
msgstr "Wichtige Daten"
#: core/admin.py:147
msgid "No owner"
msgstr "Kein Eigentümer"
#: core/admin.py:150
msgid "Multiple owners"
msgstr "Mehrere Eigentümer"
#: core/api/serializers.py:67
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
"hinzuzufügen."
#: core/models.py:31
msgid "Member"
msgstr "Mitglied"
#: core/models.py:32
msgid "Administrator"
msgstr "Administrator"
#: core/models.py:33
msgid "Owner"
msgstr "Eigentümer"
#: core/models.py:49
msgid "Initiated"
msgstr "Gestartet"
#: core/models.py:50
msgid "Active"
msgstr "Aktiv"
#: core/models.py:51
msgid "Stopped"
msgstr "Beendet"
#: core/models.py:52
msgid "Saved"
msgstr "Gespeichert"
#: core/models.py:53
msgid "Aborted"
msgstr "Abgebrochen"
#: core/models.py:54
msgid "Failed to Start"
msgstr "Start fehlgeschlagen"
#: core/models.py:55
msgid "Failed to Stop"
msgstr "Stopp fehlgeschlagen"
#: core/models.py:56
msgid "Notification succeeded"
msgstr "Benachrichtigung erfolgreich"
#: core/models.py:83
msgid "SCREEN_RECORDING"
msgstr "BILDSCHIRMAUFZEICHNUNG"
#: core/models.py:84
msgid "TRANSCRIPT"
msgstr "TRANSKRIPT"
#: core/models.py:90
msgid "Public Access"
msgstr "Öffentlicher Zugriff"
#: core/models.py:91
msgid "Trusted Access"
msgstr "Vertrauenswürdiger Zugriff"
#: core/models.py:92
msgid "Restricted Access"
msgstr "Eingeschränkter Zugriff"
#: core/models.py:104
msgid "id"
msgstr "ID"
#: core/models.py:105
msgid "primary key for the record as UUID"
msgstr "Primärschlüssel des Eintrags als UUID"
#: core/models.py:111
msgid "created on"
msgstr "erstellt am"
#: core/models.py:112
msgid "date and time at which a record was created"
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
#: core/models.py:117
msgid "updated on"
msgstr "aktualisiert am"
#: core/models.py:118
msgid "date and time at which a record was last updated"
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
#: core/models.py:138
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:144
msgid "sub"
msgstr "Sub"
#: core/models.py:146
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
"Erforderlich. Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ sind "
"erlaubt."
#: core/models.py:154
msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse"
#: core/models.py:159
msgid "admin email address"
msgstr "Administrator-E-Mail-Adresse"
#: core/models.py:161
msgid "full name"
msgstr "Vollständiger Name"
#: core/models.py:163
msgid "short name"
msgstr "Kurzname"
#: core/models.py:169
msgid "language"
msgstr "Sprache"
#: core/models.py:170
msgid "The language in which the user wants to see the interface."
msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
#: core/models.py:176
msgid "The timezone in which the user wants to see times."
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
#: core/models.py:179
msgid "device"
msgstr "Gerät"
#: core/models.py:181
msgid "Whether the user is a device or a real user."
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
#: core/models.py:184
msgid "staff status"
msgstr "Mitarbeiterstatus"
#: core/models.py:186
msgid "Whether the user can log into this admin site."
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
#: core/models.py:189
msgid "active"
msgstr "aktiv"
#: core/models.py:192
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
msgstr ""
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
"anstelle des Löschens des Kontos."
#: core/models.py:205
msgid "user"
msgstr "Benutzer"
#: core/models.py:206
msgid "users"
msgstr "Benutzer"
#: core/models.py:265
msgid "Resource"
msgstr "Ressource"
#: core/models.py:266
msgid "Resources"
msgstr "Ressourcen"
#: core/models.py:320
msgid "Resource access"
msgstr "Ressourcenzugriff"
#: core/models.py:321
msgid "Resource accesses"
msgstr "Ressourcenzugriffe"
#: core/models.py:327
msgid "Resource access with this User and Resource already exists."
msgstr ""
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
"bereits."
#: core/models.py:383
msgid "Visio room configuration"
msgstr "Visio-Raumkonfiguration"
#: core/models.py:384
msgid "Values for Visio parameters to configure the room."
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
#: core/models.py:391
msgid "Room PIN code"
msgstr "PIN-Code für den Raum"
#: core/models.py:392
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
#: core/models.py:398 core/models.py:552
msgid "Room"
msgstr "Raum"
#: core/models.py:399
msgid "Rooms"
msgstr "Räume"
#: core/models.py:563
msgid "Worker ID"
msgstr "Worker-ID"
#: core/models.py:565
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
msgstr ""
"Geben Sie eine ID für die Aufzeichnung des Workers ein. Diese ID bleibt "
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
"ermöglicht."
#: core/models.py:573
msgid "Recording mode"
msgstr "Aufzeichnungsmodus"
#: core/models.py:574
msgid "Defines the mode of recording being called."
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
#: core/models.py:580
msgid "Recording"
msgstr "Aufzeichnung"
#: core/models.py:581
msgid "Recordings"
msgstr "Aufzeichnungen"
#: core/models.py:689
msgid "Recording/user relation"
msgstr "Beziehung Aufzeichnung/Benutzer"
#: core/models.py:690
msgid "Recording/user relations"
msgstr "Beziehungen Aufzeichnung/Benutzer"
#: core/models.py:696
msgid "This user is already in this recording."
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
#: core/models.py:702
msgid "This team is already in this recording."
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
#: core/models.py:708
msgid "Either user or team must be set, not both."
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Ihre Aufzeichnung ist bereit"
#: core/services/invitation.py:44
#, python-brace-format
msgid "Video call in progress: {sender.email} is waiting for you to connect"
msgstr "Videoanruf läuft: {sender.email} wartet auf Ihre Teilnahme"
#: core/templates/mail/html/invitation.html:159
#: core/templates/mail/html/screen_recording.html:159
#: core/templates/mail/text/invitation.txt:3
#: core/templates/mail/text/screen_recording.txt:3
msgid "Logo email"
msgstr "Logo-E-Mail"
#: core/templates/mail/html/invitation.html:189
#: core/templates/mail/text/invitation.txt:5
msgid "invites you to join an ongoing video call"
msgstr "lädt Sie zu einem laufenden Videoanruf ein"
#: core/templates/mail/html/invitation.html:200
#: core/templates/mail/text/invitation.txt:7
msgid "JOIN THE CALL"
msgstr "AM ANRUF TEILNEHMEN"
#: core/templates/mail/html/invitation.html:227
#: core/templates/mail/text/invitation.txt:13
msgid ""
"If you can't click the button, copy and paste the URL into your browser to "
"join the call."
msgstr ""
"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."
#: core/templates/mail/html/invitation.html:235
#: core/templates/mail/text/invitation.txt:15
msgid "Tips for a better experience:"
msgstr "Tipps für ein besseres Erlebnis:"
#: core/templates/mail/html/invitation.html:237
#: core/templates/mail/text/invitation.txt:17
msgid "Use Chrome or Firefox for better call quality"
msgstr "Verwenden Sie Chrome oder Firefox für eine bessere Anrufqualität"
#: core/templates/mail/html/invitation.html:238
#: core/templates/mail/text/invitation.txt:18
msgid "Test your microphone and camera before joining"
msgstr "Testen Sie Ihr Mikrofon und Ihre Kamera vor dem Beitritt"
#: core/templates/mail/html/invitation.html:239
#: core/templates/mail/text/invitation.txt:19
msgid "Make sure you have a stable internet connection"
msgstr "Stellen Sie sicher, dass Sie eine stabile Internetverbindung haben"
#: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:245
#: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:23
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Vielen Dank für die Nutzung von %(brandname)s. "
#: core/templates/mail/html/screen_recording.html:188
#: core/templates/mail/text/screen_recording.txt:6
msgid "Your recording is ready!"
msgstr "Ihre Aufzeichnung ist fertig!"
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, python-format
msgid ""
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
"%(recording_time)s is now ready to download. "
msgstr ""
" Ihre Aufzeichnung von \"%(room_name)s\" am %(recording_date)s um "
"%(recording_time)s steht nun zum Herunterladen bereit. "
#: core/templates/mail/html/screen_recording.html:195
#: core/templates/mail/text/screen_recording.txt:8
#, python-format
msgid " The recording will expire in %(days)s days. "
msgstr " Die Aufzeichnung wird in %(days)s Tagen ablaufen. "
#: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/text/screen_recording.txt:9
msgid ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
msgstr ""
" Die Freigabe der Aufzeichnung per Link ist noch nicht verfügbar. Nur Organisatoren können sie herunterladen. "
#: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:11
msgid "To keep this recording permanently:"
msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13
msgid "Click the \"Open\" button below "
msgstr "Klicken Sie auf den Button „Öffnen“ unten "
#: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:14
msgid "Use the \"Download\" button in the interface "
msgstr "Verwenden Sie den Button „Herunterladen“ in der Oberfläche "
#: core/templates/mail/html/screen_recording.html:210
#: core/templates/mail/text/screen_recording.txt:15
msgid "Save the file to your preferred location"
msgstr "Speichern Sie die Datei an einem gewünschten Ort"
#: core/templates/mail/html/screen_recording.html:221
#: core/templates/mail/text/screen_recording.txt:17
msgid "Open"
msgstr "Öffnen"
#: core/templates/mail/html/screen_recording.html:230
#: core/templates/mail/text/screen_recording.txt:19
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
msgstr ""
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
"an unser Support-Team unter %(support_email)s. "
#: meet/settings.py:163
msgid "English"
msgstr "Englisch"
#: meet/settings.py:164
msgid "French"
msgstr "Französisch"
#: meet/settings.py:165
msgid "Dutch"
msgstr "Niederländisch"
#: meet/settings.py:166
msgid "German"
msgstr "Deutsch"
Binary file not shown.
+107 -108
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-07-11 11:33+0000\n"
"POT-Creation-Date: 2025-04-23 14:04+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -29,107 +29,111 @@ msgstr "Permissions"
msgid "Important dates"
msgstr "Important dates"
#: core/admin.py:147
msgid "No owner"
msgstr "No owner"
#: core/admin.py:150
msgid "Multiple owners"
msgstr "Multiple owners"
#: core/api/serializers.py:67
#: core/api/serializers.py:63
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr "You must be administrator or owner of a room to add accesses to it."
#: core/models.py:31
#: core/authentication/backends.py:77
msgid "User info contained no recognizable user identification"
msgstr "User info contained no recognizable user identification"
#: core/authentication/backends.py:102
msgid "User account is disabled"
msgstr "User account is disabled"
#: core/authentication/backends.py:142
msgid "Multiple user accounts share a common email."
msgstr "Multiple user accounts share a common email."
#: core/models.py:30
msgid "Member"
msgstr "Member"
#: core/models.py:32
#: core/models.py:31
msgid "Administrator"
msgstr "Administrator"
#: core/models.py:33
#: core/models.py:32
msgid "Owner"
msgstr "Owner"
#: core/models.py:49
#: core/models.py:48
msgid "Initiated"
msgstr "Initiated"
#: core/models.py:50
#: core/models.py:49
msgid "Active"
msgstr "Active"
#: core/models.py:51
#: core/models.py:50
msgid "Stopped"
msgstr "Stopped"
#: core/models.py:52
#: core/models.py:51
msgid "Saved"
msgstr "Saved"
#: core/models.py:53
#: core/models.py:52
msgid "Aborted"
msgstr "Aborted"
#: core/models.py:54
#: core/models.py:53
msgid "Failed to Start"
msgstr "Failed to Start"
#: core/models.py:55
#: core/models.py:54
msgid "Failed to Stop"
msgstr "Failed to Stop"
#: core/models.py:56
#: core/models.py:55
msgid "Notification succeeded"
msgstr "Notification succeeded"
#: core/models.py:83
#: core/models.py:82
msgid "SCREEN_RECORDING"
msgstr "SCREEN_RECORDING"
#: core/models.py:84
#: core/models.py:83
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:90
#: core/models.py:89
msgid "Public Access"
msgstr "Public Access"
#: core/models.py:91
#: core/models.py:90
msgid "Trusted Access"
msgstr "Trusted Access"
#: core/models.py:92
#: core/models.py:91
msgid "Restricted Access"
msgstr "Restricted Access"
#: core/models.py:104
#: core/models.py:103
msgid "id"
msgstr "id"
#: core/models.py:105
#: core/models.py:104
msgid "primary key for the record as UUID"
msgstr "primary key for the record as UUID"
#: core/models.py:111
#: core/models.py:110
msgid "created on"
msgstr "created on"
#: core/models.py:112
#: core/models.py:111
msgid "date and time at which a record was created"
msgstr "date and time at which a record was created"
#: core/models.py:117
#: core/models.py:116
msgid "updated on"
msgstr "updated on"
#: core/models.py:118
#: core/models.py:117
msgid "date and time at which a record was last updated"
msgstr "date and time at which a record was last updated"
#: core/models.py:138
#: core/models.py:137
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -137,11 +141,11 @@ msgstr ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
#: core/models.py:144
#: core/models.py:143
msgid "sub"
msgstr "sub"
#: core/models.py:146
#: core/models.py:145
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
@@ -149,55 +153,55 @@ msgstr ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
#: core/models.py:154
#: core/models.py:153
msgid "identity email address"
msgstr "identity email address"
#: core/models.py:159
#: core/models.py:158
msgid "admin email address"
msgstr "admin email address"
#: core/models.py:161
#: core/models.py:160
msgid "full name"
msgstr "full name"
#: core/models.py:163
#: core/models.py:162
msgid "short name"
msgstr "short name"
#: core/models.py:169
#: core/models.py:168
msgid "language"
msgstr "language"
#: core/models.py:170
#: core/models.py:169
msgid "The language in which the user wants to see the interface."
msgstr "The language in which the user wants to see the interface."
#: core/models.py:176
#: core/models.py:175
msgid "The timezone in which the user wants to see times."
msgstr "The timezone in which the user wants to see times."
#: core/models.py:179
#: core/models.py:178
msgid "device"
msgstr "device"
#: core/models.py:181
#: core/models.py:180
msgid "Whether the user is a device or a real user."
msgstr "Whether the user is a device or a real user."
#: core/models.py:184
#: core/models.py:183
msgid "staff status"
msgstr "staff status"
#: core/models.py:186
#: core/models.py:185
msgid "Whether the user can log into this admin site."
msgstr "Whether the user can log into this admin site."
#: core/models.py:189
#: core/models.py:188
msgid "active"
msgstr "active"
#: core/models.py:192
#: core/models.py:191
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -205,63 +209,55 @@ msgstr ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
#: core/models.py:205
#: core/models.py:204
msgid "user"
msgstr "user"
#: core/models.py:206
#: core/models.py:205
msgid "users"
msgstr "users"
#: core/models.py:265
#: core/models.py:264
msgid "Resource"
msgstr "Resource"
#: core/models.py:266
#: core/models.py:265
msgid "Resources"
msgstr "Resources"
#: core/models.py:320
#: core/models.py:319
msgid "Resource access"
msgstr "Resource access"
#: core/models.py:321
#: core/models.py:320
msgid "Resource accesses"
msgstr "Resource accesses"
#: core/models.py:327
#: core/models.py:326
msgid "Resource access with this User and Resource already exists."
msgstr "Resource access with this User and Resource already exists."
#: core/models.py:383
#: core/models.py:382
msgid "Visio room configuration"
msgstr "Visio room configuration"
#: core/models.py:384
#: core/models.py:383
msgid "Values for Visio parameters to configure the room."
msgstr "Values for Visio parameters to configure the room."
#: core/models.py:391
msgid "Room PIN code"
msgstr "Room PIN code"
#: core/models.py:392
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Unique n-digit code that identifies this room in telephony mode."
#: core/models.py:398 core/models.py:552
#: core/models.py:389 core/models.py:509
msgid "Room"
msgstr "Room"
#: core/models.py:399
#: core/models.py:390
msgid "Rooms"
msgstr "Rooms"
#: core/models.py:563
#: core/models.py:520
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:565
#: core/models.py:522
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -269,42 +265,58 @@ msgstr ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
#: core/models.py:573
#: core/models.py:530
msgid "Recording mode"
msgstr "Recording mode"
#: core/models.py:574
#: core/models.py:531
msgid "Defines the mode of recording being called."
msgstr "Defines the mode of recording being called."
#: core/models.py:580
#: core/models.py:537
msgid "Recording"
msgstr "Recording"
#: core/models.py:581
#: core/models.py:538
msgid "Recordings"
msgstr "Recordings"
#: core/models.py:689
#: core/models.py:646
msgid "Recording/user relation"
msgstr "Recording/user relation"
#: core/models.py:690
#: core/models.py:647
msgid "Recording/user relations"
msgstr "Recording/user relations"
#: core/models.py:696
#: core/models.py:653
msgid "This user is already in this recording."
msgstr "This user is already in this recording."
#: core/models.py:702
#: core/models.py:659
msgid "This team is already in this recording."
msgstr "This team is already in this recording."
#: core/models.py:708
#: core/models.py:665
msgid "Either user or team must be set, not both."
msgstr "Either user or team must be set, not both."
#: core/recording/event/authentication.py:58
msgid "Authentication is enabled but token is not configured."
msgstr "Authentication is enabled but token is not configured."
#: core/recording/event/authentication.py:70
msgid "Authorization header is required"
msgstr "Authorization header is required"
#: core/recording/event/authentication.py:78
msgid "Invalid authorization header."
msgstr "Invalid authorization header."
#: core/recording/event/authentication.py:88
msgid "Invalid token"
msgstr "Invalid token"
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Your recording is ready"
@@ -361,9 +373,9 @@ msgid "Make sure you have a stable internet connection"
msgstr "Make sure you have a stable internet connection"
#: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:245
#: core/templates/mail/html/screen_recording.html:240
#: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:23
#: core/templates/mail/text/screen_recording.txt:22
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Thank you for using %(brandname)s. "
@@ -389,42 +401,33 @@ msgstr ""
msgid " The recording will expire in %(days)s days. "
msgstr " The recording will expire in %(days)s days. "
#: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/text/screen_recording.txt:9
msgid ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
msgstr ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
#: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:11
#: core/templates/mail/html/screen_recording.html:201
#: core/templates/mail/text/screen_recording.txt:10
msgid "To keep this recording permanently:"
msgstr "To keep this recording permanently:"
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13
#: core/templates/mail/html/screen_recording.html:203
#: core/templates/mail/text/screen_recording.txt:12
msgid "Click the \"Open\" button below "
msgstr "Click the \"Open\" button below "
#: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:14
#: core/templates/mail/html/screen_recording.html:204
#: core/templates/mail/text/screen_recording.txt:13
msgid "Use the \"Download\" button in the interface "
msgstr "Use the \"Download\" button in the interface "
#: core/templates/mail/html/screen_recording.html:210
#: core/templates/mail/text/screen_recording.txt:15
#: core/templates/mail/html/screen_recording.html:205
#: core/templates/mail/text/screen_recording.txt:14
msgid "Save the file to your preferred location"
msgstr "Save the file to your preferred location"
#: core/templates/mail/html/screen_recording.html:221
#: core/templates/mail/text/screen_recording.txt:17
#: core/templates/mail/html/screen_recording.html:216
#: core/templates/mail/text/screen_recording.txt:16
msgid "Open"
msgstr "Open"
#: core/templates/mail/html/screen_recording.html:230
#: core/templates/mail/text/screen_recording.txt:19
#: core/templates/mail/html/screen_recording.html:225
#: core/templates/mail/text/screen_recording.txt:18
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
@@ -433,18 +436,14 @@ msgstr ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
#: meet/settings.py:163
#: meet/settings.py:162
msgid "English"
msgstr "English"
#: meet/settings.py:164
#: meet/settings.py:163
msgid "French"
msgstr "French"
#: meet/settings.py:165
#: meet/settings.py:164
msgid "Dutch"
msgstr "Dutch"
#: meet/settings.py:166
msgid "German"
msgstr "German"
Binary file not shown.
+115 -113
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-07-11 11:33+0000\n"
"POT-Creation-Date: 2025-04-23 14:04+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -29,123 +29,129 @@ msgstr "Permissions"
msgid "Important dates"
msgstr "Dates importantes"
#: core/admin.py:147
msgid "No owner"
msgstr "Pas de propriétaire"
#: core/admin.py:150
msgid "Multiple owners"
msgstr "Plusieurs propriétaires"
#: core/api/serializers.py:67
#: core/api/serializers.py:63
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
"des accès."
#: core/models.py:31
#: core/authentication/backends.py:77
msgid "User info contained no recognizable user identification"
msgstr ""
"Les informations utilisateur ne contiennent aucune identification "
"utilisateur reconnaissable"
#: core/authentication/backends.py:102
msgid "User account is disabled"
msgstr "Le compte utilisateur est désactivé"
#: core/authentication/backends.py:142
msgid "Multiple user accounts share a common email."
msgstr "Plusieurs comptes utilisateur partagent une adresse e-mail commune."
#: core/models.py:30
msgid "Member"
msgstr "Membre"
#: core/models.py:32
#: core/models.py:31
msgid "Administrator"
msgstr "Administrateur"
#: core/models.py:33
#: core/models.py:32
msgid "Owner"
msgstr "Propriétaire"
#: core/models.py:49
#: core/models.py:48
msgid "Initiated"
msgstr "Initié"
#: core/models.py:50
#: core/models.py:49
msgid "Active"
msgstr "Actif"
#: core/models.py:51
#: core/models.py:50
msgid "Stopped"
msgstr "Arrêté"
#: core/models.py:52
#: core/models.py:51
msgid "Saved"
msgstr "Enregistré"
#: core/models.py:53
#: core/models.py:52
msgid "Aborted"
msgstr "Abandonné"
#: core/models.py:54
#: core/models.py:53
msgid "Failed to Start"
msgstr "Échec au démarrage"
#: core/models.py:55
#: core/models.py:54
msgid "Failed to Stop"
msgstr "Échec à l'arrêt"
#: core/models.py:56
#: core/models.py:55
msgid "Notification succeeded"
msgstr "Notification réussie"
#: core/models.py:83
#: core/models.py:82
msgid "SCREEN_RECORDING"
msgstr "ENREGISTREMENT_ÉCRAN"
#: core/models.py:84
#: core/models.py:83
msgid "TRANSCRIPT"
msgstr "TRANSCRIPTION"
#: core/models.py:90
#: core/models.py:89
msgid "Public Access"
msgstr "Accès public"
#: core/models.py:91
#: core/models.py:90
msgid "Trusted Access"
msgstr "Accès de confiance"
#: core/models.py:92
#: core/models.py:91
msgid "Restricted Access"
msgstr "Accès restreint"
#: core/models.py:104
#: core/models.py:103
msgid "id"
msgstr "id"
#: core/models.py:105
#: core/models.py:104
msgid "primary key for the record as UUID"
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
#: core/models.py:111
#: core/models.py:110
msgid "created on"
msgstr "créé le"
#: core/models.py:112
#: core/models.py:111
msgid "date and time at which a record was created"
msgstr "date et heure auxquelles un enregistrement a été créé"
#: core/models.py:117
#: core/models.py:116
msgid "updated on"
msgstr "mis à jour le"
#: core/models.py:118
#: core/models.py:117
msgid "date and time at which a record was last updated"
msgstr ""
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
"fois"
#: core/models.py:138
#: core/models.py:137
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 @/./+/-/_."
"Entrez un identifiant valide. Cette valeur ne peut contenir que des lettres, "
"des chiffres et les caractères @/./+/-/_."
#: core/models.py:144
#: core/models.py:143
msgid "sub"
msgstr "sub"
msgstr "identifiant"
#: core/models.py:146
#: core/models.py:145
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
@@ -153,55 +159,55 @@ msgstr ""
"Obligatoire. 255 caractères ou moins. Lettres, chiffres et caractères @/./"
"+/-/_ uniquement."
#: core/models.py:154
#: core/models.py:153
msgid "identity email address"
msgstr "adresse e-mail d'identité"
#: core/models.py:159
#: core/models.py:158
msgid "admin email address"
msgstr "adresse e-mail d'administrateur"
#: core/models.py:161
#: core/models.py:160
msgid "full name"
msgstr "nom complet"
#: core/models.py:163
#: core/models.py:162
msgid "short name"
msgstr "nom court"
#: core/models.py:169
#: core/models.py:168
msgid "language"
msgstr "langue"
#: core/models.py:170
#: core/models.py:169
msgid "The language in which the user wants to see the interface."
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
#: core/models.py:176
#: core/models.py:175
msgid "The timezone in which the user wants to see times."
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: core/models.py:179
#: core/models.py:178
msgid "device"
msgstr "appareil"
#: core/models.py:181
#: core/models.py:180
msgid "Whether the user is a device or a real user."
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: core/models.py:184
#: core/models.py:183
msgid "staff status"
msgstr "statut du personnel"
#: core/models.py:186
#: core/models.py:185
msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: core/models.py:189
#: core/models.py:188
msgid "active"
msgstr "actif"
#: core/models.py:192
#: core/models.py:191
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -209,108 +215,116 @@ msgstr ""
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
"au lieu de supprimer des comptes."
#: core/models.py:205
#: core/models.py:204
msgid "user"
msgstr "utilisateur"
#: core/models.py:206
#: core/models.py:205
msgid "users"
msgstr "utilisateurs"
#: core/models.py:265
#: core/models.py:264
msgid "Resource"
msgstr "Ressource"
#: core/models.py:266
#: core/models.py:265
msgid "Resources"
msgstr "Ressources"
#: core/models.py:320
#: core/models.py:319
msgid "Resource access"
msgstr "Accès aux ressources"
#: core/models.py:321
#: core/models.py:320
msgid "Resource accesses"
msgstr "Accès aux ressources"
#: core/models.py:327
#: core/models.py:326
msgid "Resource access with this User and Resource already exists."
msgstr ""
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
#: core/models.py:383
#: core/models.py:382
msgid "Visio room configuration"
msgstr "Configuration de la salle de visioconférence"
#: core/models.py:384
#: core/models.py:383
msgid "Values for Visio parameters to configure the room."
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
#: core/models.py:391
msgid "Room PIN code"
msgstr "Code PIN de la salle"
#: core/models.py:392
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Code unique à n chiffres qui identifie cette salle en mode téléphonique."
#: core/models.py:398 core/models.py:552
#: core/models.py:389 core/models.py:509
msgid "Room"
msgstr "Salle"
#: core/models.py:399
#: core/models.py:390
msgid "Rooms"
msgstr "Salles"
#: core/models.py:563
#: core/models.py:520
msgid "Worker ID"
msgstr "ID du Worker"
#: core/models.py:565
#: core/models.py:522
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
msgstr ""
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
"conservé même lorsque le Worker s'arrête, permettant un suivi facile."
"Entrez un identifiant pour l'enregistrement du travailleur. Cet identifiant "
"est conservé même lorsque le travailleur s'arrête, permettant un suivi "
"facile."
#: core/models.py:573
#: core/models.py:530
msgid "Recording mode"
msgstr "Mode d'enregistrement"
#: core/models.py:574
#: core/models.py:531
msgid "Defines the mode of recording being called."
msgstr "Définit le mode d'enregistrement appelé."
#: core/models.py:580
#: core/models.py:537
msgid "Recording"
msgstr "Enregistrement"
#: core/models.py:581
#: core/models.py:538
msgid "Recordings"
msgstr "Enregistrements"
#: core/models.py:689
#: core/models.py:646
msgid "Recording/user relation"
msgstr "Relation enregistrement/utilisateur"
#: core/models.py:690
#: core/models.py:647
msgid "Recording/user relations"
msgstr "Relations enregistrement/utilisateur"
#: core/models.py:696
#: core/models.py:653
msgid "This user is already in this recording."
msgstr "Cet utilisateur est déjà dans cet enregistrement."
#: core/models.py:702
#: core/models.py:659
msgid "This team is already in this recording."
msgstr "Cette équipe est déjà dans cet enregistrement."
#: core/models.py:708
#: core/models.py:665
msgid "Either user or team must be set, not both."
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
#: core/recording/event/authentication.py:58
msgid "Authentication is enabled but token is not configured."
msgstr "L'authentification est activée mais le jeton n'est pas configuré."
#: core/recording/event/authentication.py:70
msgid "Authorization header is required"
msgstr "L'en-tête d'autorisation est requis"
#: core/recording/event/authentication.py:78
msgid "Invalid authorization header."
msgstr "En-tête d'autorisation invalide."
#: core/recording/event/authentication.py:88
msgid "Invalid token"
msgstr "Jeton invalide"
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Votre enregistrement est prêt"
@@ -367,9 +381,9 @@ msgid "Make sure you have a stable internet connection"
msgstr "Assurez-vous d'avoir une connexion Internet stable"
#: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:245
#: core/templates/mail/html/screen_recording.html:240
#: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:23
#: core/templates/mail/text/screen_recording.txt:22
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Merci d'utiliser %(brandname)s. "
@@ -395,41 +409,33 @@ msgstr ""
msgid " The recording will expire in %(days)s days. "
msgstr " L'enregistrement expirera dans %(days)s jours. "
#: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/text/screen_recording.txt:9
msgid ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
msgstr ""
"Le partage de l'enregistrement via lien n'est pas encore disponible. Seuls les organisateurs peuvent le télécharger."
#: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:11
#: core/templates/mail/html/screen_recording.html:201
#: core/templates/mail/text/screen_recording.txt:10
msgid "To keep this recording permanently:"
msgstr "Pour conserver cet enregistrement de façon permanente :"
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13
#: core/templates/mail/html/screen_recording.html:203
#: core/templates/mail/text/screen_recording.txt:12
msgid "Click the \"Open\" button below "
msgstr "Cliquez sur le bouton \"Ouvrir\" ci-dessous "
#: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:14
#: core/templates/mail/html/screen_recording.html:204
#: core/templates/mail/text/screen_recording.txt:13
msgid "Use the \"Download\" button in the interface "
msgstr "Utilisez le bouton \"Télécharger\" dans l'interface "
#: core/templates/mail/html/screen_recording.html:210
#: core/templates/mail/text/screen_recording.txt:15
#: core/templates/mail/html/screen_recording.html:205
#: core/templates/mail/text/screen_recording.txt:14
msgid "Save the file to your preferred location"
msgstr "Enregistrez le fichier à l'emplacement de votre choix"
#: core/templates/mail/html/screen_recording.html:221
#: core/templates/mail/text/screen_recording.txt:17
#: core/templates/mail/html/screen_recording.html:216
#: core/templates/mail/text/screen_recording.txt:16
msgid "Open"
msgstr "Ouvrir"
#: core/templates/mail/html/screen_recording.html:230
#: core/templates/mail/text/screen_recording.txt:19
#: core/templates/mail/html/screen_recording.html:225
#: core/templates/mail/text/screen_recording.txt:18
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
@@ -438,18 +444,14 @@ msgstr ""
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
"équipe d'assistance à %(support_email)s. "
#: meet/settings.py:163
#: meet/settings.py:162
msgid "English"
msgstr "Anglais"
#: meet/settings.py:164
#: meet/settings.py:163
msgid "French"
msgstr "Français"
#: meet/settings.py:165
#: meet/settings.py:164
msgid "Dutch"
msgstr "Néerlandais"
#: meet/settings.py:166
msgid "German"
msgstr "Allemand"
Binary file not shown.
+107 -108
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-07-11 11:33+0000\n"
"POT-Creation-Date: 2025-04-23 14:04+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -29,108 +29,112 @@ msgstr "Rechten"
msgid "Important dates"
msgstr "Belangrijke datums"
#: core/admin.py:147
msgid "No owner"
msgstr "Geen eigenaar"
#: core/admin.py:150
msgid "Multiple owners"
msgstr "Meerdere eigenaren"
#: core/api/serializers.py:67
#: core/api/serializers.py:63
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
#: core/models.py:31
#: core/authentication/backends.py:77
msgid "User info contained no recognizable user identification"
msgstr "Gebruikersinformatie bevatte geen herkenbare gebruikersidentificatie"
#: core/authentication/backends.py:102
msgid "User account is disabled"
msgstr "Gebruikersaccount is uitgeschakeld"
#: core/authentication/backends.py:142
msgid "Multiple user accounts share a common email."
msgstr "Meerdere gebruikersaccounts delen een gemeenschappelijk e-mailadres."
#: core/models.py:30
msgid "Member"
msgstr "Lid"
#: core/models.py:32
#: core/models.py:31
msgid "Administrator"
msgstr "Beheerder"
#: core/models.py:33
#: core/models.py:32
msgid "Owner"
msgstr "Eigenaar"
#: core/models.py:49
#: core/models.py:48
msgid "Initiated"
msgstr "Gestart"
#: core/models.py:50
#: core/models.py:49
msgid "Active"
msgstr "Actief"
#: core/models.py:51
#: core/models.py:50
msgid "Stopped"
msgstr "Gestopt"
#: core/models.py:52
#: core/models.py:51
msgid "Saved"
msgstr "Opgeslagen"
#: core/models.py:53
#: core/models.py:52
msgid "Aborted"
msgstr "Afgebroken"
#: core/models.py:54
#: core/models.py:53
msgid "Failed to Start"
msgstr "Starten mislukt"
#: core/models.py:55
#: core/models.py:54
msgid "Failed to Stop"
msgstr "Stoppen mislukt"
#: core/models.py:56
#: core/models.py:55
msgid "Notification succeeded"
msgstr "Notificatie geslaagd"
#: core/models.py:83
#: core/models.py:82
msgid "SCREEN_RECORDING"
msgstr "SCHERM_OPNAME"
#: core/models.py:84
#: core/models.py:83
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:90
#: core/models.py:89
msgid "Public Access"
msgstr "Openbare toegang"
#: core/models.py:91
#: core/models.py:90
msgid "Trusted Access"
msgstr "Vertrouwde toegang"
#: core/models.py:92
#: core/models.py:91
msgid "Restricted Access"
msgstr "Beperkte toegang"
#: core/models.py:104
#: core/models.py:103
msgid "id"
msgstr "id"
#: core/models.py:105
#: core/models.py:104
msgid "primary key for the record as UUID"
msgstr "primaire sleutel voor het record als UUID"
#: core/models.py:111
#: core/models.py:110
msgid "created on"
msgstr "aangemaakt op"
#: core/models.py:112
#: core/models.py:111
msgid "date and time at which a record was created"
msgstr "datum en tijd waarop een record werd aangemaakt"
#: core/models.py:117
#: core/models.py:116
msgid "updated on"
msgstr "bijgewerkt op"
#: core/models.py:118
#: core/models.py:117
msgid "date and time at which a record was last updated"
msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt"
#: core/models.py:138
#: core/models.py:137
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -138,66 +142,66 @@ msgstr ""
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
"_ tekens bevatten."
#: core/models.py:144
#: core/models.py:143
msgid "sub"
msgstr "sub"
#: core/models.py:146
#: core/models.py:145
msgid ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
msgstr ""
"Vereist. 255 tekens of minder. Alleen letters, cijfers en @/./+/-/_ tekens."
#: core/models.py:154
#: core/models.py:153
msgid "identity email address"
msgstr "identiteit e-mailadres"
#: core/models.py:159
#: core/models.py:158
msgid "admin email address"
msgstr "beheerder e-mailadres"
#: core/models.py:161
#: core/models.py:160
msgid "full name"
msgstr "volledige naam"
#: core/models.py:163
#: core/models.py:162
msgid "short name"
msgstr "korte naam"
#: core/models.py:169
#: core/models.py:168
msgid "language"
msgstr "taal"
#: core/models.py:170
#: core/models.py:169
msgid "The language in which the user wants to see the interface."
msgstr "De taal waarin de gebruiker de interface wil zien."
#: core/models.py:176
#: core/models.py:175
msgid "The timezone in which the user wants to see times."
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
#: core/models.py:179
#: core/models.py:178
msgid "device"
msgstr "apparaat"
#: core/models.py:181
#: core/models.py:180
msgid "Whether the user is a device or a real user."
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
#: core/models.py:184
#: core/models.py:183
msgid "staff status"
msgstr "personeelsstatus"
#: core/models.py:186
#: core/models.py:185
msgid "Whether the user can log into this admin site."
msgstr "Of de gebruiker kan inloggen op deze beheersite."
#: core/models.py:189
#: core/models.py:188
msgid "active"
msgstr "actief"
#: core/models.py:192
#: core/models.py:191
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -205,64 +209,55 @@ msgstr ""
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
"plaats van accounts te verwijderen."
#: core/models.py:205
#: core/models.py:204
msgid "user"
msgstr "gebruiker"
#: core/models.py:206
#: core/models.py:205
msgid "users"
msgstr "gebruikers"
#: core/models.py:265
#: core/models.py:264
msgid "Resource"
msgstr "Bron"
#: core/models.py:266
#: core/models.py:265
msgid "Resources"
msgstr "Bronnen"
#: core/models.py:320
#: core/models.py:319
msgid "Resource access"
msgstr "Brontoegang"
#: core/models.py:321
#: core/models.py:320
msgid "Resource accesses"
msgstr "Brontoegangsrechten"
#: core/models.py:327
#: core/models.py:326
msgid "Resource access with this User and Resource already exists."
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
#: core/models.py:383
#: core/models.py:382
msgid "Visio room configuration"
msgstr "Visio-ruimteconfiguratie"
#: core/models.py:384
#: core/models.py:383
msgid "Values for Visio parameters to configure the room."
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
#: core/models.py:391
msgid "Room PIN code"
msgstr "Pincode van de kamer"
#: core/models.py:392
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
#: core/models.py:398 core/models.py:552
#: core/models.py:389 core/models.py:509
msgid "Room"
msgstr "Ruimte"
#: core/models.py:399
#: core/models.py:390
msgid "Rooms"
msgstr "Ruimtes"
#: core/models.py:563
#: core/models.py:520
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:565
#: core/models.py:522
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -270,42 +265,58 @@ msgstr ""
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
#: core/models.py:573
#: core/models.py:530
msgid "Recording mode"
msgstr "Opnamemodus"
#: core/models.py:574
#: core/models.py:531
msgid "Defines the mode of recording being called."
msgstr "Definieert de modus van opname die wordt aangeroepen."
#: core/models.py:580
#: core/models.py:537
msgid "Recording"
msgstr "Opname"
#: core/models.py:581
#: core/models.py:538
msgid "Recordings"
msgstr "Opnames"
#: core/models.py:689
#: core/models.py:646
msgid "Recording/user relation"
msgstr "Opname/gebruiker-relatie"
#: core/models.py:690
#: core/models.py:647
msgid "Recording/user relations"
msgstr "Opname/gebruiker-relaties"
#: core/models.py:696
#: core/models.py:653
msgid "This user is already in this recording."
msgstr "Deze gebruiker is al in deze opname."
#: core/models.py:702
#: core/models.py:659
msgid "This team is already in this recording."
msgstr "Dit team is al in deze opname."
#: core/models.py:708
#: core/models.py:665
msgid "Either user or team must be set, not both."
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
#: core/recording/event/authentication.py:58
msgid "Authentication is enabled but token is not configured."
msgstr "Authenticatie is ingeschakeld maar token is niet geconfigureerd."
#: core/recording/event/authentication.py:70
msgid "Authorization header is required"
msgstr "Autorisatie-header is vereist"
#: core/recording/event/authentication.py:78
msgid "Invalid authorization header."
msgstr "Ongeldige autorisatie-header."
#: core/recording/event/authentication.py:88
msgid "Invalid token"
msgstr "Ongeldig token"
#: core/recording/event/notification.py:94
msgid "Your recording is ready"
msgstr "Je opname is klaar"
@@ -362,9 +373,9 @@ msgid "Make sure you have a stable internet connection"
msgstr "Zorg ervoor dat je een stabiele internetverbinding hebt"
#: core/templates/mail/html/invitation.html:248
#: core/templates/mail/html/screen_recording.html:245
#: core/templates/mail/html/screen_recording.html:240
#: core/templates/mail/text/invitation.txt:21
#: core/templates/mail/text/screen_recording.txt:23
#: core/templates/mail/text/screen_recording.txt:22
#, python-format
msgid " Thank you for using %(brandname)s. "
msgstr " Bedankt voor het gebruik van %(brandname)s. "
@@ -390,41 +401,33 @@ msgstr ""
msgid " The recording will expire in %(days)s days. "
msgstr " De opname verloopt over %(days)s dagen. "
#: core/templates/mail/html/screen_recording.html:200
#: core/templates/mail/text/screen_recording.txt:9
msgid ""
" Sharing the recording via link is not yet available. Only organizers can "
"download it. "
msgstr ""
"Het delen van de opname via een link is nog niet beschikbaar. Alleen organisatoren kunnen deze downloaden."
#: core/templates/mail/html/screen_recording.html:206
#: core/templates/mail/text/screen_recording.txt:11
#: core/templates/mail/html/screen_recording.html:201
#: core/templates/mail/text/screen_recording.txt:10
msgid "To keep this recording permanently:"
msgstr "Om deze opname permanent te bewaren:"
#: core/templates/mail/html/screen_recording.html:208
#: core/templates/mail/text/screen_recording.txt:13
#: core/templates/mail/html/screen_recording.html:203
#: core/templates/mail/text/screen_recording.txt:12
msgid "Click the \"Open\" button below "
msgstr "Klik op de \"Openen\"-knop hieronder "
#: core/templates/mail/html/screen_recording.html:209
#: core/templates/mail/text/screen_recording.txt:14
#: core/templates/mail/html/screen_recording.html:204
#: core/templates/mail/text/screen_recording.txt:13
msgid "Use the \"Download\" button in the interface "
msgstr "Gebruik de \"Download\"-knop in de interface "
#: core/templates/mail/html/screen_recording.html:210
#: core/templates/mail/text/screen_recording.txt:15
#: core/templates/mail/html/screen_recording.html:205
#: core/templates/mail/text/screen_recording.txt:14
msgid "Save the file to your preferred location"
msgstr "Sla het bestand op naar je gewenste locatie"
#: core/templates/mail/html/screen_recording.html:221
#: core/templates/mail/text/screen_recording.txt:17
#: core/templates/mail/html/screen_recording.html:216
#: core/templates/mail/text/screen_recording.txt:16
msgid "Open"
msgstr "Openen"
#: core/templates/mail/html/screen_recording.html:230
#: core/templates/mail/text/screen_recording.txt:19
#: core/templates/mail/html/screen_recording.html:225
#: core/templates/mail/text/screen_recording.txt:18
#, python-format
msgid ""
" If you have any questions or need assistance, please contact our support "
@@ -433,18 +436,14 @@ msgstr ""
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
"team via %(support_email)s. "
#: meet/settings.py:163
#: meet/settings.py:162
msgid "English"
msgstr "Engels"
#: meet/settings.py:164
#: meet/settings.py:163
msgid "French"
msgstr "Frans"
#: meet/settings.py:165
#: meet/settings.py:164
msgid "Dutch"
msgstr "Nederlands"
#: meet/settings.py:166
msgid "German"
msgstr "Duits"
+3 -3
View File
@@ -3,12 +3,12 @@
meet's sandbox management script.
"""
import os
import sys
from os import environ
if __name__ == "__main__":
environ.setdefault("DJANGO_SETTINGS_MODULE", "meet.settings")
environ.setdefault("DJANGO_CONFIGURATION", "Development")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "meet.settings")
os.environ.setdefault("DJANGO_CONFIGURATION", "Development")
from configurations.management import execute_from_command_line
+3 -3
View File
@@ -1,13 +1,13 @@
"""Meet celery configuration file."""
from os import environ
import os
from celery import Celery
from configurations.importer import install
# Set the default Django settings module for the 'celery' program.
environ.setdefault("DJANGO_SETTINGS_MODULE", "meet.settings")
environ.setdefault("DJANGO_CONFIGURATION", "Development")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "meet.settings")
os.environ.setdefault("DJANGO_CONFIGURATION", "Development")
install(check_options=True)
+26 -123
View File
@@ -11,19 +11,18 @@ https://docs.djangoproject.com/en/3.1/ref/settings/
"""
import json
from os import path
import os
from socket import gethostbyname, gethostname
from django.utils.translation import gettext_lazy as _
import sentry_sdk
from configurations import Configuration, values
from lasuite.configuration.values import SecretFileValue
from sentry_sdk.integrations.django import DjangoIntegration
from sentry_sdk.integrations.logging import ignore_logger
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = path.dirname(path.dirname(path.abspath(__file__)))
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def get_release():
@@ -39,7 +38,7 @@ def get_release():
# Try to get the current release from the version.json file generated by the
# CI during the Docker image build
try:
with open(path.join(BASE_DIR, "version.json"), encoding="utf8") as version:
with open(os.path.join(BASE_DIR, "version.json"), encoding="utf8") as version:
return json.load(version)["version"]
except FileNotFoundError:
return "NA" # Default: not available
@@ -70,11 +69,11 @@ class Base(Configuration):
API_VERSION = "v1.0"
DATA_DIR = values.Value(path.join("/", "data"), environ_name="DATA_DIR")
DATA_DIR = values.Value(os.path.join("/", "data"), environ_name="DATA_DIR")
# Security
ALLOWED_HOSTS = values.ListValue([])
SECRET_KEY = SecretFileValue(None)
SECRET_KEY = values.Value(None)
SILENCED_SYSTEM_CHECKS = values.ListValue([])
ALLOW_UNSECURE_USER_LISTING = values.BooleanValue(
False, environ_name="ALLOW_UNSECURE_USER_LISTING", environ_prefix=None
@@ -94,7 +93,7 @@ class Base(Configuration):
),
"NAME": values.Value("meet", environ_name="DB_NAME", environ_prefix=None),
"USER": values.Value("dinum", environ_name="DB_USER", environ_prefix=None),
"PASSWORD": SecretFileValue(
"PASSWORD": values.Value(
"pass", environ_name="DB_PASSWORD", environ_prefix=None
),
"HOST": values.Value(
@@ -107,9 +106,9 @@ class Base(Configuration):
# Static files (CSS, JavaScript, Images)
STATIC_URL = "/static/"
STATIC_ROOT = path.join(DATA_DIR, "static")
STATIC_ROOT = os.path.join(DATA_DIR, "static")
MEDIA_URL = "/media/"
MEDIA_ROOT = path.join(DATA_DIR, "media")
MEDIA_ROOT = os.path.join(DATA_DIR, "media")
SITE_ID = 1
@@ -129,10 +128,10 @@ class Base(Configuration):
AWS_S3_ENDPOINT_URL = values.Value(
environ_name="AWS_S3_ENDPOINT_URL", environ_prefix=None
)
AWS_S3_ACCESS_KEY_ID = SecretFileValue(
AWS_S3_ACCESS_KEY_ID = values.Value(
environ_name="AWS_S3_ACCESS_KEY_ID", environ_prefix=None
)
AWS_S3_SECRET_ACCESS_KEY = SecretFileValue(
AWS_S3_SECRET_ACCESS_KEY = values.Value(
environ_name="AWS_S3_SECRET_ACCESS_KEY", environ_prefix=None
)
AWS_S3_REGION_NAME = values.Value(
@@ -163,11 +162,10 @@ class Base(Configuration):
("en-us", _("English")),
("fr-fr", _("French")),
("nl-nl", _("Dutch")),
("de-de", _("German")),
)
)
LOCALE_PATHS = (path.join(BASE_DIR, "locale"),)
LOCALE_PATHS = (os.path.join(BASE_DIR, "locale"),)
TIME_ZONE = "UTC"
USE_I18N = True
@@ -177,7 +175,7 @@ class Base(Configuration):
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [path.join(BASE_DIR, "templates")],
"DIRS": [os.path.join(BASE_DIR, "templates")],
"OPTIONS": {
"context_processors": [
"django.contrib.auth.context_processors.auth",
@@ -305,11 +303,6 @@ class Base(Configuration):
# Frontend
FRONTEND_CONFIGURATION = {
# If set, a <link> tag with this URL as href is added to the <head> of the frontend app.
# This is useful if you want to change CSS variables to customize the look of the app.
"custom_css_url": values.Value(
None, environ_name="FRONTEND_CUSTOM_CSS_URL", environ_prefix=None
),
"analytics": values.DictValue(
{}, environ_name="FRONTEND_ANALYTICS", environ_prefix=None
),
@@ -320,30 +313,18 @@ class Base(Configuration):
False, environ_name="FRONTEND_SILENCE_LIVEKIT_DEBUG", environ_prefix=None
),
"is_silent_login_enabled": values.BooleanValue(
True, environ_name="FRONTEND_IS_SILENT_LOGIN_ENABLED", environ_prefix=None
True, environ_name="FRONTEND_IS_SILENT_LOGING_ENABLED", environ_prefix=None
),
"feedback": values.DictValue(
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
),
"use_french_gov_footer": values.BooleanValue(
False, environ_name="FRONTEND_USE_FRENCH_GOV_FOOTER", environ_prefix=None
),
"use_proconnect_button": values.BooleanValue(
False, environ_name="FRONTEND_USE_PROCONNECT_BUTTON", environ_prefix=None
),
"transcript": values.DictValue(
{}, environ_name="FRONTEND_TRANSCRIPT", environ_prefix=None
),
"manifest_link": values.Value(
None, environ_name="FRONTEND_MANIFEST_LINK", environ_prefix=None
),
}
# Mail
EMAIL_BACKEND = values.Value("django.core.mail.backends.smtp.EmailBackend")
EMAIL_HOST = values.Value(None)
EMAIL_HOST_USER = values.Value(None)
EMAIL_HOST_PASSWORD = SecretFileValue(None)
EMAIL_HOST_PASSWORD = values.Value(None)
EMAIL_PORT = values.PositiveIntegerValue(None)
EMAIL_USE_TLS = values.BooleanValue(False)
EMAIL_USE_SSL = values.BooleanValue(False)
@@ -358,7 +339,7 @@ class Base(Configuration):
# CORS
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_ALL_ORIGINS = values.BooleanValue(False)
CORS_ALLOW_ALL_ORIGINS = values.BooleanValue(True)
CORS_ALLOWED_ORIGINS = values.ListValue([])
CORS_ALLOWED_ORIGIN_REGEXES = values.ListValue([])
@@ -378,9 +359,7 @@ class Base(Configuration):
# Session
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "default"
SESSION_COOKIE_AGE = values.PositiveIntegerValue(
default=60 * 60 * 12, environ_name="SESSION_COOKIE_AGE", environ_prefix=None
)
SESSION_COOKIE_AGE = 60 * 60 * 12
# OIDC - Authorization Code Flow
OIDC_AUTHENTICATE_CLASS = "lasuite.oidc_login.views.OIDCAuthenticationRequestView"
@@ -401,7 +380,7 @@ class Base(Configuration):
OIDC_RP_CLIENT_ID = values.Value(
"meet", environ_name="OIDC_RP_CLIENT_ID", environ_prefix=None
)
OIDC_RP_CLIENT_SECRET = SecretFileValue(
OIDC_RP_CLIENT_SECRET = values.Value(
None,
environ_name="OIDC_RP_CLIENT_SECRET",
environ_prefix=None,
@@ -430,17 +409,6 @@ class Base(Configuration):
OIDC_RP_SCOPES = values.Value(
"openid email", environ_name="OIDC_RP_SCOPES", environ_prefix=None
)
OIDC_USE_PKCE = values.BooleanValue(
default=False, environ_name="OIDC_USE_PKCE", environ_prefix=None
)
OIDC_PKCE_CODE_CHALLENGE_METHOD = values.Value(
default="S256",
environ_name="OIDC_PKCE_CODE_CHALLENGE_METHOD",
environ_prefix=None,
)
OIDC_PKCE_CODE_VERIFIER_SIZE = values.IntegerValue(
default=64, environ_name="OIDC_PKCE_CODE_VERIFIER_SIZE", environ_prefix=None
)
LOGIN_REDIRECT_URL = values.Value(
None, environ_name="LOGIN_REDIRECT_URL", environ_prefix=None
)
@@ -486,30 +454,12 @@ class Base(Configuration):
# Video conference configuration
LIVEKIT_CONFIGURATION = {
"api_key": SecretFileValue(environ_name="LIVEKIT_API_KEY", environ_prefix=None),
"api_secret": SecretFileValue(
"api_key": values.Value(environ_name="LIVEKIT_API_KEY", environ_prefix=None),
"api_secret": values.Value(
environ_name="LIVEKIT_API_SECRET", environ_prefix=None
),
"url": values.Value(environ_name="LIVEKIT_API_URL", environ_prefix=None),
}
LIVEKIT_FORCE_WSS_PROTOCOL = values.BooleanValue(
False, environ_name="LIVEKIT_FORCE_WSS_PROTOCOL", environ_prefix=None
)
LIVEKIT_DEFAULT_SOURCES = values.ListValue(
[
"camera",
"microphone",
"screen_share",
"screen_share_audio",
],
environ_name="LIVEKIT_DEFAULT_SOURCES",
environ_prefix=None,
)
LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND = values.BooleanValue(
environ_name="LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND",
environ_prefix=None,
default=False,
)
LIVEKIT_VERIFY_SSL = values.BooleanValue(
True, environ_name="LIVEKIT_VERIFY_SSL", environ_prefix=None
)
@@ -546,7 +496,7 @@ class Base(Configuration):
RECORDING_STORAGE_EVENT_ENABLE = values.BooleanValue(
False, environ_name="RECORDING_STORAGE_EVENT_ENABLE", environ_prefix=None
)
RECORDING_STORAGE_EVENT_TOKEN = SecretFileValue(
RECORDING_STORAGE_EVENT_TOKEN = values.Value(
None, environ_name="RECORDING_STORAGE_EVENT_TOKEN", environ_prefix=None
)
# Number of days before recordings expire - must be synced with bucket lifecycle policy
@@ -554,15 +504,10 @@ class Base(Configuration):
RECORDING_EXPIRATION_DAYS = values.IntegerValue(
None, environ_name="RECORDING_EXPIRATION_DAYS", environ_prefix=None
)
# Recording max duration in milliseconds - must be synced with LiveKit Egress configuration
# Set to None for no max duration
RECORDING_MAX_DURATION = values.IntegerValue(
None, environ_name="RECORDING_MAX_DURATION", environ_prefix=None
)
SUMMARY_SERVICE_ENDPOINT = values.Value(
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
)
SUMMARY_SERVICE_API_TOKEN = SecretFileValue(
SUMMARY_SERVICE_API_TOKEN = values.Value(
None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None
)
SCREEN_RECORDING_BASE_URL = values.Value(
@@ -580,7 +525,7 @@ class Base(Configuration):
environ_name="MARKETING_SERVICE_CLASS",
environ_prefix=None,
)
BREVO_API_KEY = SecretFileValue(
BREVO_API_KEY = values.Value(
None, environ_name="BREVO_API_KEY", environ_prefix=None
)
BREVO_API_CONTACT_LIST_IDS = values.ListValue(
@@ -627,43 +572,6 @@ class Base(Configuration):
environ_prefix=None,
)
# SIP Telephony
ROOM_TELEPHONY_ENABLED = values.BooleanValue(
False,
environ_name="ROOM_TELEPHONY_ENABLED",
environ_prefix=None,
)
ROOM_TELEPHONY_PIN_LENGTH = values.PositiveIntegerValue(
10,
environ_name="ROOM_TELEPHONY_PIN_LENGTH",
environ_prefix=None,
)
ROOM_TELEPHONY_PIN_MAX_RETRIES = values.PositiveIntegerValue(
5,
environ_name="ROOM_TELEPHONY_PIN_MAX_RETRIES",
environ_prefix=None,
)
ROOM_TELEPHONY_PHONE_NUMBER = values.Value(
None,
environ_name="ROOM_TELEPHONY_PHONE_NUMBER",
environ_prefix=None,
)
ROOM_TELEPHONY_DEFAULT_COUNTRY = values.Value(
"US",
environ_name="ROOM_TELEPHONY_DEFAULT_COUNTRY",
environ_prefix=None,
)
# Subtitles settings
ROOM_SUBTITLE_ENABLED = values.BooleanValue(
False, environ_name="ROOM_SUBTITLE_ENABLED", environ_prefix=None
)
ROOM_SUBTITLE_AGENT_NAME = values.Value(
"multi-user-transcriber",
environ_name="ROOM_SUBTITLE_AGENT_NAME",
environ_prefix=None,
)
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):
@@ -710,7 +618,10 @@ class Base(Configuration):
release=get_release(),
integrations=[DjangoIntegration()],
)
sentry_sdk.set_tag("application", "backend")
# Add the application name to the Sentry scope
scope = sentry_sdk.get_global_scope()
scope.set_tag("application", "backend")
# Ignore the logs added by the DockerflowMiddleware
ignore_logger("request.summary")
@@ -874,14 +785,6 @@ class Staging(Production):
"""
class Sandbox(Production):
"""
Sandbox environment settings
nota bene: it should inherit from the Production environment.
"""
class PreProduction(Production):
"""
Pre-production environment settings
+3 -3
View File
@@ -7,11 +7,11 @@ For more information on this file, see
https://docs.djangoproject.com/en/3.1/howto/deployment/wsgi/
"""
from os import environ
import os
from configurations.wsgi import get_wsgi_application
environ.setdefault("DJANGO_SETTINGS_MODULE", "meet.settings")
environ.setdefault("DJANGO_CONFIGURATION", "Development")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "meet.settings")
os.environ.setdefault("DJANGO_CONFIGURATION", "Development")
application = get_wsgi_application()
+32 -31
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.38"
version = "0.1.21"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,64 +25,65 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"boto3==1.38.42",
"boto3==1.37.24",
"Brotli==1.1.0",
"brevo-python==1.1.2",
"celery[redis]==5.5.3",
"celery[redis]==5.4.0",
"django-configurations==2.5.1",
"django-cors-headers==4.7.0",
"django-countries==7.6.1",
"django-lasuite[all]==0.0.10",
"django-lasuite==0.0.7",
"django-parler==2.3",
"redis==5.2.1",
"django-redis==6.0.0",
"django-storages[s3]==1.14.6",
"django-redis==5.4.0",
"django-storages[s3]==1.14.5",
"django-timezone-field>=5.1",
"django==5.2.6",
"djangorestframework==3.16.0",
"django==5.1.8",
"djangorestframework==3.15.2",
"drf_spectacular==0.28.0",
"dockerflow==2024.4.2",
"easy_thumbnails==2.10",
"factory_boy==3.3.3",
"gunicorn==23.0.0",
"jsonschema==4.24.0",
"markdown==3.8.2",
"jsonschema==4.23.0",
"june-analytics-python==2.3.0",
"markdown==3.7",
"nested-multipart-parser==1.5.0",
"psycopg[binary]==3.2.9",
"psycopg[binary]==3.2.6",
"PyJWT==2.10.1",
"python-frontmatter==1.1.0",
"requests==2.32.4",
"sentry-sdk==2.30.0",
"requests==2.32.3",
"sentry-sdk==2.24.1",
"whitenoise==6.9.0",
"mozilla-django-oidc==4.0.1",
"livekit-api==1.0.3",
"aiohttp==3.12.13",
"livekit-api==1.0.2",
"aiohttp==3.11.14",
]
[project.urls]
"Bug Tracker" = "https://github.com/suitenumerique/meet/issues/new"
"Changelog" = "https://github.com/suitenumerique/meet/blob/main/CHANGELOG.md"
"Homepage" = "https://github.com/suitenumerique/meet"
"Repository" = "https://github.com/suitenumerique/meet"
"Bug Tracker" = "https://github.com/numerique-gouv/meet/issues/new"
"Changelog" = "https://github.com/numerique-gouv/meet/blob/main/CHANGELOG.md"
"Homepage" = "https://github.com/numerique-gouv/meet"
"Repository" = "https://github.com/numerique-gouv/meet"
[project.optional-dependencies]
dev = [
"django-extensions==4.1",
"drf-spectacular-sidecar==2025.6.1",
"freezegun==1.5.2",
"django-extensions==3.2.3",
"drf-spectacular-sidecar==2025.3.1",
"freezegun==1.5.1",
"ipdb==0.13.13",
"ipython==9.3.0",
"pyfakefs==5.9.1",
"ipython==9.0.2",
"pyfakefs==5.8.0",
"pylint-django==2.6.1",
"pylint==3.3.7",
"pytest-cov==6.2.1",
"pytest-django==4.11.1",
"pytest==8.4.1",
"pylint==3.3.6",
"pytest-cov==6.0.0",
"pytest-django==4.10.0",
"pytest==8.3.5",
"pytest-icdiff==0.9",
"pytest-xdist==3.7.0",
"pytest-xdist==3.6.1",
"responses==0.25.7",
"ruff==0.12.0",
"types-requests==2.32.4.20250611",
"ruff==0.11.2",
"types-requests==2.32.0.20250306",
]
[tool.setuptools]
-1
View File
@@ -1,2 +1 @@
VITE_API_BASE_URL=http://localhost:8071/
VITE_APP_TITLE=LaSuite Meet
-1
View File
@@ -1 +0,0 @@
VITE_APP_TITLE=LaSuite Meet
+1 -1
View File
@@ -35,7 +35,7 @@ ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
RUN npm run build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
FROM nginxinc/nginx-unprivileged:1.26-alpine AS frontend-production
USER root
RUN apk update && apk upgrade libssl3 libcrypto3 libxml2>=2.12.7-r2 libxslt>=1.1.39-r2
-1
View File
@@ -1,7 +1,6 @@
server {
listen 8080;
server_name localhost;
server_tokens off;
root /usr/share/nginx/html;
+2 -5
View File
@@ -2,12 +2,9 @@
<html lang="en" data-lk-theme="visio-light">
<head>
<meta charset="UTF-8" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<link rel="icon" type="image/svg+xml" href="/assets/icon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>%VITE_APP_TITLE%</title>
<title>Visio</title>
</head>
<body>
<div id="root"></div>
+1524 -1717
View File
File diff suppressed because it is too large Load Diff
+27 -33
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "0.1.38",
"version": "0.1.21",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -13,54 +13,48 @@
"check": "prettier --check ./src"
},
"dependencies": {
"@livekit/components-react": "2.9.13",
"@livekit/components-styles": "1.1.6",
"@livekit/track-processors": "0.6.1",
"@pandacss/preset-panda": "0.54.0",
"@react-aria/toast": "3.0.5",
"@react-types/overlays": "3.9.0",
"@livekit/components-react": "2.8.1",
"@livekit/components-styles": "1.1.4",
"@livekit/track-processors": "0.3.3",
"@pandacss/preset-panda": "0.53.3",
"@react-aria/toast": "3.0.1",
"@remixicon/react": "4.6.0",
"@tanstack/react-query": "5.81.5",
"@timephy/rnnoise-wasm": "1.0.0",
"@tanstack/react-query": "5.69.0",
"crisp-sdk-web": "1.0.25",
"derive-valtio": "0.2.0",
"hoofd": "1.7.3",
"humanize-duration": "3.33.0",
"i18next": "25.3.1",
"i18next-browser-languagedetector": "8.2.0",
"i18next": "24.2.3",
"i18next-browser-languagedetector": "8.0.4",
"i18next-parser": "9.3.0",
"i18next-resources-to-backend": "1.2.1",
"libphonenumber-js": "1.12.10",
"livekit-client": "2.15.7",
"posthog-js": "1.256.2",
"livekit-client": "2.9.8",
"posthog-js": "1.232.6",
"react": "18.3.1",
"react-aria-components": "1.10.1",
"react-aria-components": "1.7.1",
"react-dom": "18.3.1",
"react-i18next": "15.1.1",
"use-sound": "5.0.0",
"valtio": "2.1.5",
"wouter": "3.7.1"
"valtio": "2.1.4",
"wouter": "3.6.0"
},
"devDependencies": {
"@pandacss/dev": "0.54.0",
"@tanstack/eslint-plugin-query": "5.81.2",
"@tanstack/react-query-devtools": "5.81.5",
"@types/humanize-duration": "3.27.4",
"@types/node": "22.16.0",
"@pandacss/dev": "0.53.3",
"@tanstack/eslint-plugin-query": "5.68.0",
"@tanstack/react-query-devtools": "5.69.0",
"@types/node": "22.13.13",
"@types/react": "18.3.12",
"@types/react-dom": "18.3.1",
"@typescript-eslint/eslint-plugin": "8.35.1",
"@typescript-eslint/parser": "8.35.1",
"@vitejs/plugin-react": "4.6.0",
"@typescript-eslint/eslint-plugin": "8.28.0",
"@typescript-eslint/parser": "8.28.0",
"@vitejs/plugin-react": "4.3.4",
"eslint": "8.57.0",
"eslint-config-prettier": "10.1.5",
"eslint-config-prettier": "10.1.1",
"eslint-plugin-jsx-a11y": "6.10.2",
"eslint-plugin-react-hooks": "5.2.0",
"eslint-plugin-react-refresh": "0.4.20",
"postcss": "8.5.6",
"prettier": "3.6.2",
"typescript": "5.8.3",
"vite": "7.0.2",
"eslint-plugin-react-refresh": "0.4.19",
"postcss": "8.5.3",
"prettier": "3.5.3",
"typescript": "5.8.2",
"vite": "6.2.6",
"vite-tsconfig-paths": "5.1.4"
}
}
+3 -5
View File
@@ -124,7 +124,6 @@ const config: Config = {
...pandaPreset.theme.tokens.colors,
primaryDark: {
50: { value: '#161622' },
75: { value: '#222234' },
100: { value: '#2D2D46' },
200: { value: '#43436A' },
300: { value: '#5A5A8F' },
@@ -191,6 +190,9 @@ const config: Config = {
fonts: {
sans: {
value: [
'Marianne',
'Source Sans',
'Source Sans fallback',
'ui-sans-serif',
'system-ui',
'-apple-system',
@@ -316,10 +318,6 @@ const config: Config = {
'subtle-text': { value: '{colors.red.700}' },
...pandaPreset.theme.tokens.colors.red,
},
alert: {
DEFAULT: { value: '{colors.blue.700}' },
notification: { value: '{colors.red.600}' },
},
success: {
DEFAULT: { value: '{colors.green.700}' },
hover: { value: '{colors.green.800}' },
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

@@ -1,109 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Calque_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 1080 1080">
<defs>
<style>
.st0, .st1, .st2, .st3, .st4, .st5 {
fill: none;
}
.st6 {
fill: #6dd58c;
}
.st6, .st1, .st2, .st7, .st8, .st9, .st10 {
stroke-linecap: round;
stroke-linejoin: round;
}
.st6, .st1, .st2, .st7, .st8, .st10 {
stroke-width: 3px;
}
.st6, .st1, .st7, .st8, .st4, .st5, .st10 {
stroke: #191c1e;
}
.st11, .st7, .st12, .st9 {
fill: #fff;
}
.st11, .st3 {
stroke: #000;
stroke-miterlimit: 10;
}
.st11, .st3, .st9 {
stroke-width: 5px;
}
.st2, .st9 {
stroke: #898989;
}
.st8 {
fill: #ff8669;
}
.st13 {
fill: #e3e3fb;
}
.st14 {
fill: #f7f7f7;
}
.st4, .st5 {
stroke-width: 4px;
}
.st5 {
stroke-linecap: square;
}
.st15 {
fill: #ca3632;
}
.st10 {
fill: #ffb929;
}
</style>
</defs>
<rect class="st12" x=".53" y=".53" width="1078.61" height="1080.36"/>
<path class="st12" d="M120.5,942.36V210.83c0-39.94,32.37-72.32,72.31-72.33h767.27"/>
<path class="st1" d="M120.5,942.36V210.83c0-39.94,32.37-72.32,72.31-72.33h767.27"/>
<path class="st12" d="M960.08,256.93h-110c-12.94,0-23.43-10.49-23.43-23.43v-71.56c0-12.94-10.48-23.43-23.42-23.44h-266.94c-12.94,0-23.43,10.49-23.43,23.44h0v71.56c0,12.94-10.49,23.43-23.43,23.43H120.5v137.62h839.58"/>
<path class="st8" d="M210.81,223.85c10.42,0,18.86-8.44,18.86-18.86s-8.44-18.86-18.86-18.86-18.86,8.44-18.86,18.86,8.44,18.86,18.86,18.86h0Z"/>
<path class="st10" d="M274.32,223.85c10.42,0,18.86-8.44,18.86-18.86s-8.44-18.86-18.86-18.86-18.86,8.44-18.86,18.86,8.44,18.86,18.86,18.86h0Z"/>
<path class="st6" d="M337.84,223.85c10.42,0,18.86-8.44,18.86-18.86s-8.44-18.86-18.86-18.86-18.86,8.44-18.86,18.86,8.44,18.86,18.86,18.86h0Z"/>
<path class="st1" d="M236.18,329.44h-63.69M201.62,358.57l-29.13-29.13,29.13-29.12M274.32,329.44h63.69M308.88,358.57l29.13-29.13-29.13-29.12"/>
<path class="st7" d="M625.31,322.08h-27.15c-4.35,0-7.88,3.53-7.88,7.88h0v17.19c0,4.35,3.53,7.88,7.88,7.88h27.15c4.35,0,7.88-3.53,7.88-7.88h0v-17.19c0-4.35-3.53-7.88-7.88-7.88h0Z"/>
<path class="st12" d="M595.13,322.09v-12.86c-.02-9.26,7.47-16.78,16.73-16.8h.03c9.26,0,16.76,7.5,16.76,16.76v12.86"/>
<path class="st1" d="M595.13,322.09v-12.86c-.02-9.26,7.47-16.78,16.73-16.8h.03c9.26,0,16.76,7.5,16.76,16.76v12.86"/>
<g>
<ellipse class="st4" cx="707.63" cy="299.5" rx="18.37" ry="18.5"/>
<path class="st5" d="M624,299h63"/>
<circle class="st4" cx="643.5" cy="339.5" r="18.5"/>
<path class="st5" d="M728,339h-63"/>
</g>
<path class="st14" d="M192.29,138.5h767.79v118.43H120.5v-46.64c0-39.62,32.17-71.79,71.79-71.79h0Z"/>
<path class="st0" d="M120.5,942.36V210.83c0-39.94,32.37-72.32,72.31-72.33h767.27"/>
<path class="st2" d="M120.5,942.36V210.83c0-39.94,32.37-72.32,72.31-72.33h767.27"/>
<path class="st2" d="M210.81,223.85c10.42,0,18.86-8.44,18.86-18.86s-8.44-18.86-18.86-18.86-18.86,8.44-18.86,18.86,8.44,18.86,18.86,18.86h0Z"/>
<path class="st2" d="M274.32,223.85c10.42,0,18.86-8.44,18.86-18.86s-8.44-18.86-18.86-18.86-18.86,8.44-18.86,18.86,8.44,18.86,18.86,18.86h0Z"/>
<path class="st2" d="M337.84,223.85c10.42,0,18.86-8.44,18.86-18.86s-8.44-18.86-18.86-18.86-18.86,8.44-18.86,18.86,8.44,18.86,18.86,18.86h0Z"/>
<path class="st2" d="M236.18,329.44h-63.69M201.62,358.57l-29.13-29.13,29.13-29.12M274.32,329.44h63.69M308.88,358.57l29.13-29.13-29.13-29.12"/>
<path class="st15" d="M382.41,636.74l39.92,39.92,13.47-13.47-188.53-188.53-13.47,13.47,11.35,11.35h-5.58c-5.26,0-9.52,4.26-9.52,9.52v133.31c0,5.26,4.26,9.52,9.52,9.52h133.31c5.26,0,9.52-4.26,9.52-9.52v-5.58h.01ZM363.37,617.69v15.1h-114.27v-114.27h15.1l99.17,99.17h0ZM439.55,633.17c0,2.02-1.19,3.6-2.78,4.33l-16.27-16.27v-75.65l-38.09,26.66v10.9l-19.04-19.04v-45.57h-45.57l-19.04-19.04h74.14c5.26,0,9.52,4.26,9.52,9.52v39.99l49.64-34.75c3.16-2.21,7.49.05,7.49,3.9v115.02h0Z"/>
<path class="st15" d="M375.25,866.34l43.58,43.58,12.93-12.93-180.97-180.97-12.93,12.93,51.25,51.25v14.49c0,25.24,20.46,45.7,45.7,45.7,4.41,0,8.67-.62,12.7-1.79l14.17,14.17c-8.17,3.79-17.28,5.9-26.87,5.9-32.23,0-58.9-23.84-63.33-54.84h-18.43c4.21,38.13,34.49,68.4,72.62,72.62v37.06h18.28v-37.06c11.28-1.25,21.87-4.77,31.3-10.11h0ZM330.71,821.81c-11.87-1.78-21.25-11.16-23.03-23.03l23.03,23.03ZM402.21,841.85l-13.18-13.18c4.65-7.4,7.82-15.82,9.11-24.84h18.43c-1.55,14.04-6.64,27.02-14.35,38.03h0ZM375.62,815.27l-14.15-14.15c.5-2.06.76-4.21.76-6.43v-36.56c0-15.14-12.28-27.42-27.42-27.42-11.83,0-21.91,7.49-25.76,17.99l-13.68-13.68c7.94-13.52,22.63-22.59,39.44-22.59,25.24,0,45.7,20.46,45.7,45.7v36.56c0,7.4-1.76,14.39-4.88,20.58h-.01Z"/>
<path d="M597.6,312.73c0-2.65,2.15-4.79,4.79-4.79s4.79,2.15,4.79,4.79-2.15,4.79-4.79,4.79-4.79-2.15-4.79-4.79ZM602.4,301.55c-6.18,0-11.19,5.01-11.19,11.19s5.01,11.19,11.19,11.19,11.19-5.01,11.19-11.19-5.01-11.19-11.19-11.19ZM619.98,315.93h25.57v-6.39h-25.57v6.39ZM632.76,344.69c0-2.65,2.15-4.79,4.79-4.79s4.79,2.15,4.79,4.79-2.15,4.79-4.79,4.79-4.79-2.15-4.79-4.79ZM637.56,333.51c-6.18,0-11.19,5.01-11.19,11.19s5.01,11.19,11.19,11.19,11.19-5.01,11.19-11.19-5.01-11.19-11.19-11.19ZM594.41,341.5v6.39h25.57v-6.39h-25.57Z"/>
<line class="st2" x1="960.08" y1="394.55" x2="836.91" y2="394.55"/>
<path class="st9" d="M527.76,394.55H120.5v-137.62h368.93c12.94,0,23.43-10.49,23.43-23.43v-71.56c0-12.95,10.49-23.44,23.43-23.44h266.94c12.94,0,23.43,10.5,23.42,23.44v71.56c0,2.09.27,4.12.79,6.05,1,3.77,2.92,7.17,5.51,9.94,4.28,4.58,10.37,7.44,17.13,7.44h110"/>
<path class="st13" d="M645.52,274.8h314.56v101.2h-314.56c-23.81,0-43.12-22.66-43.12-50.61h0c.01-27.94,19.31-50.59,43.12-50.59h0Z"/>
<g>
<path class="st13" d="M851.32,326.18c0,28.02-6.82,54.45-18.9,77.71h-181.11c-43.65,0-79.03-35.38-79.03-79.03,0-21.82,8.86-41.56,23.16-55.86,14.3-14.29,34.06-23.13,55.87-23.13h179.74c12.93,23.89,20.27,51.24,20.27,80.31Z"/>
<path d="M656.78,350.02v9.98h39.93v-9.98h-39.93ZM724.16,337.54c-9.65,0-17.47,7.82-17.47,17.47s7.82,17.46,17.47,17.46,17.47-7.82,17.47-17.46-7.82-17.47-17.47-17.47ZM724.16,362.49c-4.13,0-7.49-3.35-7.49-7.48s3.36-7.49,7.49-7.49,7.49,3.35,7.49,7.49-3.36,7.48-7.49,7.48ZM696.71,300.11v9.98h39.93v-9.98h-39.93ZM669.26,287.63c-9.65,0-17.47,7.82-17.47,17.47s7.82,17.47,17.47,17.47,17.47-7.82,17.47-17.47-7.82-17.47-17.47-17.47ZM669.26,312.59c-4.13,0-7.49-3.36-7.49-7.49s3.36-7.49,7.49-7.49,7.49,3.35,7.49,7.49-3.35,7.49-7.49,7.49Z"/>
</g>
<line class="st2" x1="813.4" y1="432.85" x2="551.26" y2="432.85"/>
<path class="st3" d="M851.32,326.18c0,28.02-6.82,54.45-18.9,77.71-5.35,10.32-11.74,20.02-19.02,28.96-30.98,38.03-78.19,62.32-131.07,62.32s-100.09-24.29-131.07-62.32c-23.7-29.09-37.92-66.22-37.92-106.67,0-93.33,75.66-168.99,168.99-168.99,64.26,0,120.15,35.87,148.72,88.68,12.93,23.89,20.27,51.24,20.27,80.31h0Z"/>
<polygon class="st11" points="705.12 406.95 681.79 637.91 746.21 597.51 802.28 751.56 852.2 733.39 796.14 579.34 871.46 568.88 705.12 406.95"/>
</svg>

Before

Width:  |  Height:  |  Size: 7.1 KiB

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

+5
View File
@@ -0,0 +1,5 @@
<svg width="160" height="160" viewBox="0 0 160 160" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.61135 59.6951C5 62.832 5 66.6949 5 73.1656V88.7307C5 96.7588 5 100.773 6.16756 104.366C7.20062 107.546 8.89041 110.472 11.1274 112.957C13.6555 115.765 17.1318 117.772 24.0842 121.786L37.564 129.568C43.7169 133.121 47.1472 135.101 50.4165 136.054V88.0288C50.4165 85.6281 49.1052 83.4192 46.9976 82.2696L5.61135 59.6951Z" fill="#C9191E"/>
<path d="M118.313 66.7489V91.7898C118.313 95.2521 119.818 98.5435 122.436 100.809L140.459 116.406C141.513 117.298 142.608 118.028 143.744 118.596C144.92 119.123 146.056 119.387 147.151 119.387C149.503 119.387 151.389 118.616 152.809 117.075C154.269 115.493 154.999 113.445 154.999 110.93V47.6577C154.999 45.1431 154.269 43.1151 152.809 41.5738C151.389 39.992 149.503 39.2011 147.151 39.2011C146.056 39.2011 144.92 39.4647 143.744 39.992C142.608 40.5193 141.513 41.2494 140.459 42.1822L122.45 57.7172C119.823 59.983 118.313 63.28 118.313 66.7489Z" fill="#000091"/>
<path d="M11.6345 50.7522C11.1333 50.4788 10.6078 50.2937 10.0757 50.1915C10.4114 49.7628 10.7622 49.3452 11.1276 48.9394C13.6558 46.1315 17.132 44.1245 24.0845 40.1105L37.5643 32.3279C44.5167 28.3139 47.993 26.3069 51.6887 25.5213C54.9587 24.8262 58.3383 24.8262 61.6083 25.5213C65.304 26.3069 68.7803 28.3139 75.7327 32.3279L89.0535 40.0187C89.1066 40.0492 89.1597 40.0798 89.2129 40.1105C96.1653 44.1245 99.6416 46.1316 102.17 48.9394C104.407 51.4238 106.096 54.3506 107.13 57.5301C108.297 61.1235 108.297 65.1375 108.297 73.1656V88.7307C108.297 96.7588 108.297 100.773 107.13 104.366C106.096 107.546 104.407 110.473 102.17 112.957C99.6416 115.765 96.1655 117.772 89.2133 121.785C89.1537 121.82 89.0936 121.855 89.0341 121.889L75.7327 129.568C68.7803 133.582 65.304 135.589 61.6083 136.375C61.4564 136.407 61.3043 136.438 61.1519 136.467L61.1519 88.0288C61.1519 81.6997 57.6948 75.8761 52.1386 72.8455L11.6345 50.7522Z" fill="#000091"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

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