mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-30 05:38:59 +00:00
Compare commits
191 Commits
v1.21.0
...
drive-interop
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b92ae8f73 | |||
| ac2b5bd4f3 | |||
| 7c92f6054b | |||
| 89f8480e0b | |||
| d9bf6efa2a | |||
| 4cb7412194 | |||
| e8df597055 | |||
| 05dfcd11ca | |||
| b018832fcf | |||
| a269160f6f | |||
| c3afa84d9b | |||
| 8c6752a29f | |||
| 4c57432a03 | |||
| 3b7bfd999c | |||
| 7f386b2e2f | |||
| c55d8235fd | |||
| c7b23abd68 | |||
| 5a641a4366 | |||
| f043ad6f98 | |||
| 1141c1cecd | |||
| 33792b050a | |||
| f4569c64e5 | |||
| 6a00d3d087 | |||
| 2e975e2643 | |||
| 4c63aa827f | |||
| 67e9bf2fef | |||
| 7124167947 | |||
| 759388c72f | |||
| a663b4dc76 | |||
| 73aa162dc8 | |||
| a3851842e9 | |||
| 77964c6a74 | |||
| 72b863e794 | |||
| 0e3c978af2 | |||
| 13c9d3635c | |||
| eced9891c6 | |||
| ef05c0ab19 | |||
| 932332c858 | |||
| 7343560a9b | |||
| fca7c3b8b8 | |||
| 29672cda6b | |||
| c3c1e918b0 | |||
| d1dde71bea | |||
| 64b4200fc3 | |||
| 4b393e28ca | |||
| 8dc1bacc38 | |||
| 97a9735c0b | |||
| d2e13cc826 | |||
| 183b0fc697 | |||
| b7be0eabb0 | |||
| f21c4c91ed | |||
| 5f52fa8d8d | |||
| 9420fed563 | |||
| 4598aafa23 | |||
| 94abdfc0f9 | |||
| 56a8f72e39 | |||
| 86fcc2c7fe | |||
| 0a0306b0a2 | |||
| c00fcc78e1 | |||
| 39a59d1b35 | |||
| f4493d412f | |||
| e1457604ee | |||
| bf6351e838 | |||
| aba5fca655 | |||
| acb583b14f | |||
| fb3a54a224 | |||
| 8cd6496b5b | |||
| 63a7de402b | |||
| f842cc340e | |||
| 8c235571eb | |||
| d6f39e602d | |||
| 0c0b2f2616 | |||
| a5b79afde1 | |||
| 052d3c1b22 | |||
| dff5aa9575 | |||
| ded93bf24f | |||
| 5ba1885411 | |||
| 273af221e6 | |||
| e8d6aba306 | |||
| 449503208a | |||
| 7eb3553bea | |||
| f0356af365 | |||
| 9921ef9d09 | |||
| 68e999a037 | |||
| 34103bf326 | |||
| e273ac9e43 | |||
| adcdbc0695 | |||
| 8f9008f1e0 | |||
| 9320a1af0c | |||
| e3827970c8 | |||
| ab707d866f | |||
| dac0b9c000 | |||
| 53cc8642eb | |||
| c8d9d2fea8 | |||
| e910b1f0b7 | |||
| 581e115c03 | |||
| 8118ef0612 | |||
| 85f886d9c5 | |||
| b66f82e4b7 | |||
| aabb7d629c | |||
| 1e5f5c4fe9 | |||
| f55fa0c42b | |||
| 2bc5e47c75 | |||
| 0d5136206f | |||
| 05a67320b1 | |||
| 2fbaa49089 | |||
| 76542b2235 | |||
| 10231b0333 | |||
| 9b9e6578ee | |||
| 0c81d29ee7 | |||
| 839a8f1c71 | |||
| 40ef420cda | |||
| d995bd9041 | |||
| 8ac4c2409d | |||
| e47ad42656 | |||
| 2509452c52 | |||
| 1022780027 | |||
| 4a1a04f86e | |||
| 8702638cf3 | |||
| f88c0307ea | |||
| e7f15b50ff | |||
| 75ba2ff146 | |||
| bbe2a32efc | |||
| 86fff16eed | |||
| 195d2b5006 | |||
| 30cf264276 | |||
| 945d779b45 | |||
| d43b335546 | |||
| e6f0ff5112 | |||
| 224d6ce358 | |||
| 20718f2d5a | |||
| 8edcb6e973 | |||
| b00c4e5d6d | |||
| 3c0ba03976 | |||
| 7fa172d100 | |||
| ecf8f0fe3f | |||
| 27da57aff5 | |||
| e327a5e35f | |||
| c3abd0441f | |||
| 79245389ce | |||
| 3b3f992834 | |||
| a98dc1484a | |||
| dca24a1b25 | |||
| 74b791e207 | |||
| df1495c97b | |||
| c95e1c67bd | |||
| f115c83752 | |||
| ebfcb42a7d | |||
| c86a47f736 | |||
| dd6b4512c8 | |||
| f82fd4bece | |||
| edab18d94a | |||
| 636c2168be | |||
| d54e9c2ad0 | |||
| 657712d7cb | |||
| d2bfbee389 | |||
| 9ba97fd14f | |||
| be0d0927d4 | |||
| 27dce44d40 | |||
| 78acaf395e | |||
| 4e5e648730 | |||
| 924fe95d94 | |||
| e3e33c7d0a | |||
| 16ee575ff8 | |||
| e42b083f20 | |||
| 6d2c31eb0a | |||
| e9bdf173de | |||
| dd23ce817a | |||
| 1ca8f6e5ea | |||
| f98e884067 | |||
| b498926353 | |||
| 3d4dc2d631 | |||
| 1523e6aec9 | |||
| 99510c9c6a | |||
| 9f003e95f3 | |||
| 46c30b6fcd | |||
| 1533ae8a3c | |||
| 526e96797e | |||
| 8903a55008 | |||
| 2a60b49086 | |||
| aee1847303 | |||
| 6b7cd8ab2e | |||
| cc2a0ad322 | |||
| 3300226bbe | |||
| 8efb11b3ae | |||
| 8cf15cabaf | |||
| 9b7c449ca5 | |||
| 5c27aba00f | |||
| 7256b87511 | |||
| 7b7951a0e0 | |||
| 5c15140783 |
@@ -46,7 +46,7 @@ jobs:
|
||||
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend'
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
target: backend-production
|
||||
platforms: ${{ env.BUILD_PLATFORMS }}
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend'
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
@@ -112,7 +112,7 @@ jobs:
|
||||
target: frontend-production
|
||||
platforms: ${{ env.BUILD_PLATFORMS }}
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -139,7 +139,7 @@ jobs:
|
||||
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum'
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
@@ -159,7 +159,7 @@ jobs:
|
||||
target: frontend-production
|
||||
platforms: ${{ env.BUILD_PLATFORMS }}
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -186,7 +186,7 @@ jobs:
|
||||
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary'
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
@@ -208,7 +208,7 @@ jobs:
|
||||
target: production
|
||||
platforms: ${{ env.BUILD_PLATFORMS }}
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
@@ -235,7 +235,7 @@ jobs:
|
||||
images: lasuite/meet-agents
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
@@ -257,7 +257,7 @@ jobs:
|
||||
target: production
|
||||
platforms: ${{ env.BUILD_PLATFORMS }}
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "18"
|
||||
node-version: "22"
|
||||
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@v5
|
||||
@@ -305,7 +305,6 @@ jobs:
|
||||
working-directory: src/summary
|
||||
|
||||
env:
|
||||
V1_TENANT_ID: 'test-tenant'
|
||||
AUTHORIZED_TENANTS: '[{"id": "test-tenant", "api_key": "test-api-token", "webhook_url": "https://example.com/webhook", "webhook_api_key": "test-webhook-api-key"}]'
|
||||
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
|
||||
AWS_S3_ENDPOINT_URL: "minio:9000"
|
||||
|
||||
+120
@@ -8,6 +8,126 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) push recordings to the owner's Drive (POC)
|
||||
|
||||
## Fixed
|
||||
|
||||
- ✨(summary) report exception type in failure analytics
|
||||
- ✨(frontend) add configurable documentation menu item
|
||||
- ✨(frontend) allow promoting authenticated participants
|
||||
- ✨(frontend) introduce an "unauthenticated" participant badge
|
||||
|
||||
### Changed
|
||||
|
||||
- ⬆️(frontend) upgrade @mediapipe/tasks-vision from 0.10.14 to 0.10.35
|
||||
- ⬆️(frontend) upgrade i18next from 26.3.1 to 26.3.4
|
||||
- ⬆️(frontend) upgrade posthog-js from 1.391.2 to 1.395.0
|
||||
- ⬆️(frontend) upgrade @tanstack/react-query from 5.101.0 to 5.101.1
|
||||
- ⬆️(frontend) upgrade livekit-client from 2.19.2 to 2.20.0
|
||||
- ⚡️(frontend) limit unnecessary re-renders #1510
|
||||
- 📝(legal) update terms of service
|
||||
- 💄(frontend) render Avatar initials in uppercase
|
||||
- 💄(frontend) improve participant name rendering in the list
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(transcription) fix silent bug in speaker assignment
|
||||
- 🐛(summary) extend tasks auto retry logic
|
||||
- 🐛(summary) properly detect when failure webhook should be sent
|
||||
- 🐛(backend) preserve recording metadata when updating room access
|
||||
- 🐛(backend) allow any string as sub in the API serializer
|
||||
- 🐛(frontend) fall back to user.full_name on request-entry
|
||||
- 🚸(frontend) show two initials in the Avatar when possible
|
||||
|
||||
## [1.24.0] - 2026-07-21
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) allow searching the recording admin table by owner email
|
||||
- ✨(frontend) add participant color gradient when camera is off #1490
|
||||
- ✨(all) allow forcing SSO display name for authenticated users
|
||||
- ➕(frontend) install vite-plugin-static-copy for MediaPipe WASM assets
|
||||
- ✨(addon) show add-in tools when creating meetings in shared calendars
|
||||
|
||||
### Changed
|
||||
|
||||
- 🗑️(settings) deprecate SUMMARY_SERVICE_VERSION=1
|
||||
- ⬆️(mail) update mjml to v5 and @html-to/text-cli
|
||||
- 🚸(frontend) initialize the join input name with the persisted full name
|
||||
- ♻️(frontend) refactor background processors to use the new API
|
||||
- ♻️(frontend) inline model weights to avoid loading them from remote
|
||||
- ♻️(frontend) inline MediaPipe WASM modules to avoid loading from remote
|
||||
- ⬆️(frontend) upgrade posthog-js from 1.387.0 to 1.391.2
|
||||
- ⬆️(frontend) upgrade react-stately from 3.47.0 to 3.48.0
|
||||
- ⬆️(frontend) upgrade react-aria from 3.49.0 to 3.50.0
|
||||
- ⬆️(frontend) upgrade react-aria-components from 1.18.0 to 1.19.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🩹(backend) identify externally provisioned users to PostHog
|
||||
- 🐛(backend) fix info panel crash for unregistered rooms
|
||||
- ♿️(frontend) focus side panel container on open #1452
|
||||
- 🐛(summary) whisper call error handling
|
||||
|
||||
## [1.23.0] - 2026-07-08
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) extend analytics module to support feature flags
|
||||
- ✨(backend) implement feature flags in Posthog analytics backend
|
||||
- ✨(agents) report errors to Sentry for all LiveKit agents
|
||||
|
||||
### Changed
|
||||
|
||||
- ⬆️(agents) upgrade to python 3.14 slim
|
||||
- ⬆️(dependencies) update python dependencies
|
||||
- 💥(summary) remove v1 related code #1362
|
||||
- ✨(meet) use compatible with summary v2 #1362
|
||||
- ♻️(backend) refactor analytics backend from Protocol to abstract class
|
||||
- 🔥(summary) remove call to summary enabled feature flag
|
||||
- ♻️(frontend) wrap MuteEveryoneButton with AdminOrOwnerOnly
|
||||
- ⬆️(frontend) upgrade livekit-client from 2.19.0 to 2.19.2
|
||||
- ⬆️(frontend) upgrade posthog-js from 1.386.5 to 1.387.0
|
||||
- ⬆️(frontend) upgrade @tanstack/react-query from 5.100.14 to 5.101.0
|
||||
- ⬆️(frontend) update the frontend build image to Node 22
|
||||
- 🔒️(frontend) update docker image to nginx-unprivileged:1.30.3-alpine3.23
|
||||
- ✨(summary) more precise analytics events
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🚀(front) fix frontend build failure
|
||||
- 🐛(makefile) fix args in make test
|
||||
- 🩹(backend) fix case-insensitive email deduplication in merge command
|
||||
- 🐛(summary) support media files with bad streams #1478
|
||||
|
||||
## [1.22.0] - 2026-07-03
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(frontend) cap and paginate tiles in picture-in-picture #1383
|
||||
- 📝(docs) document rebranding the favicon via a volume mount #1443
|
||||
- ✨(backend) add command to clean pending and deleted files
|
||||
- 🧱(helm) run clean files command as cronjob
|
||||
- ✨(backend) add fallback to save recordings without S3/MinIO webhooks
|
||||
- 🩹(frontend) enable screen share button in PiP #1458
|
||||
- 🐛(backend) support unencoded S3 notification object keys #1455
|
||||
- ✨(frontend) prioritize screen share in picture-in-picture layout #1467
|
||||
|
||||
### Changed
|
||||
|
||||
- ✨(summary) generalized stt api call #1420
|
||||
- ♻️(env) refactor env variables handling
|
||||
- 🚸(frontend) use "Advanced" instead of "Premium" in the sidepanel
|
||||
- ♿️(frontend) make fullscreen share warning keyboard accessible #1459
|
||||
- ⬆️(summary) update docker alpine to 3.24 & ffmpeg to 8.1.2 #1471
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🛂(backend) reject user access tokens on the API
|
||||
- 🩹(helm) fix Helm ingress rendering when passing multiple hosts
|
||||
|
||||
## [1.21.0] - 2026-06-15
|
||||
|
||||
### Added
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked --no-dev
|
||||
|
||||
# ---- mails ----
|
||||
FROM node:20 AS mail-builder
|
||||
FROM node:22 AS mail-builder
|
||||
|
||||
COPY ./src/mail /mail/app
|
||||
|
||||
|
||||
@@ -75,7 +75,8 @@ create-env-files: \
|
||||
env.d/development/kc_postgresql \
|
||||
env.d/development/summary \
|
||||
env.d/development/kube-secret \
|
||||
env.d/development/multi_user_transcriber
|
||||
env.d/development/multi_user_transcriber \
|
||||
env.d/development/metadata_collector
|
||||
.PHONY: create-env-files
|
||||
|
||||
bootstrap: ## Prepare Docker images for the project
|
||||
@@ -83,6 +84,7 @@ bootstrap: \
|
||||
data/media \
|
||||
data/static \
|
||||
create-env-files \
|
||||
create-docker-network \
|
||||
build \
|
||||
migrate \
|
||||
demo \
|
||||
@@ -116,11 +118,16 @@ down: ## stop and remove containers, networks, images, and volumes
|
||||
@$(COMPOSE) down
|
||||
.PHONY: down
|
||||
|
||||
create-docker-network: ## create the shared lasuite-network if it doesn't exist
|
||||
@docker network create lasuite-network || true
|
||||
.PHONY: create-docker-network
|
||||
|
||||
logs: ## display app-dev logs (follow mode)
|
||||
@$(COMPOSE) logs -f app-dev
|
||||
.PHONY: logs
|
||||
|
||||
run-backend: ## start only the backend application and all needed services
|
||||
@$(MAKE) create-docker-network
|
||||
@$(COMPOSE) up --force-recreate -d celery-dev --remove-orphans
|
||||
@$(COMPOSE) up --force-recreate -d nginx
|
||||
@echo "Wait for postgresql to be up..."
|
||||
@@ -210,24 +217,25 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
|
||||
@$(COMPOSE_RUN_APP) pylint meet demo core
|
||||
.PHONY: lint-pylint
|
||||
|
||||
test: ## run project tests
|
||||
@$(MAKE) test-back-parallel
|
||||
@$(MAKE) test-summary
|
||||
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"`
|
||||
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
|
||||
$(MAKE) test-back-parallel ARGS="$${args}" && \
|
||||
$(MAKE) test-summary ARGS="$${args}"
|
||||
.PHONY: test
|
||||
|
||||
test-back: ## run back-end tests
|
||||
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
|
||||
bin/pytest $${args:-${1}}
|
||||
test-back: ## run back-end tests (pass extra pytest args via ARGS)
|
||||
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
|
||||
bin/pytest $${args}
|
||||
.PHONY: test-back
|
||||
|
||||
test-back-parallel: ## run all back-end tests in parallel
|
||||
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
|
||||
bin/pytest -n auto $${args:-${1}}
|
||||
test-back-parallel: ## run all back-end tests in parallel (pass extra pytest args via ARGS)
|
||||
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
|
||||
bin/pytest -n auto $${args}
|
||||
.PHONY: test-back-parallel
|
||||
|
||||
test-summary: ## run summary tests
|
||||
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
|
||||
bin/pytest-summary $${args:-${1}}
|
||||
test-summary: ## run summary tests (pass extra pytest args via ARGS)
|
||||
@args="$(ARGS) $(filter-out $@,$(MAKECMDGOALS))" && \
|
||||
bin/pytest-summary $${args}
|
||||
.PHONY: test-summary
|
||||
|
||||
makemigrations: ## run django makemigrations for the Meet project.
|
||||
@@ -292,6 +300,9 @@ env.d/development/kube-secret:
|
||||
env.d/development/multi_user_transcriber:
|
||||
cp -n env.d/development/multi_user_transcriber.dist env.d/development/multi_user_transcriber
|
||||
|
||||
env.d/development/metadata_collector:
|
||||
cp -n env.d/development/metadata_collector.dist env.d/development/metadata_collector
|
||||
|
||||
# -- Internationalization
|
||||
|
||||
env.d/development/crowdin:
|
||||
|
||||
@@ -12,7 +12,10 @@
|
||||
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/meet"/>
|
||||
<a href="https://github.com/suitenumerique/meet/blob/main/LICENSE">
|
||||
<img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/meet"/>
|
||||
</a>
|
||||
</a>
|
||||
<a href="https://digitalpublicgoods.net/r/la-suite-meet-simple-video-conferencing">
|
||||
<img src="https://img.shields.io/badge/Verified-DPG-3333AB?logo=data:image/svg%2bxml;base64,PHN2ZyB3aWR0aD0iMzEiIGhlaWdodD0iMzMiIHZpZXdCb3g9IjAgMCAzMSAzMyIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTE0LjIwMDggMjEuMzY3OEwxMC4xNzM2IDE4LjAxMjRMMTEuNTIxOSAxNi40MDAzTDEzLjk5MjggMTguNDU5TDE5LjYyNjkgMTIuMjExMUwyMS4xOTA5IDEzLjYxNkwxNC4yMDA4IDIxLjM2NzhaTTI0LjYyNDEgOS4zNTEyN0wyNC44MDcxIDMuMDcyOTdMMTguODgxIDUuMTg2NjJMMTUuMzMxNCAtMi4zMzA4MmUtMDVMMTEuNzgyMSA1LjE4NjYyTDUuODU2MDEgMy4wNzI5N0w2LjAzOTA2IDkuMzUxMjdMMCAxMS4xMTc3TDMuODQ1MjEgMTYuMDg5NUwwIDIxLjA2MTJMNi4wMzkwNiAyMi44Mjc3TDUuODU2MDEgMjkuMTA2TDExLjc4MjEgMjYuOTkyM0wxNS4zMzE0IDMyLjE3OUwxOC44ODEgMjYuOTkyM0wyNC44MDcxIDI5LjEwNkwyNC42MjQxIDIyLjgyNzdMMzAuNjYzMSAyMS4wNjEyTDI2LjgxNzYgMTYuMDg5NUwzMC42NjMxIDExLjExNzdMMjQuNjI0MSA5LjM1MTI3WiIgZmlsbD0id2hpdGUiLz4KPC9zdmc+Cg==" alt="DPG Badge"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -28,6 +31,14 @@
|
||||
## La Suite Meet: Simple Video Conferencing
|
||||
|
||||
Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level performance with high-quality video and audio. No installation required—simply join calls directly from your browser. Check out LiveKit's impressive optimizations in their [blog post](https://blog.livekit.io/livekit-one-dot-zero/).
|
||||
|
||||
> [!TIP]
|
||||
> New here? Start by introducing yourself in our Matrix channel:
|
||||
> **https://matrix.to/#/#meet-official:matrix.org**
|
||||
>
|
||||
> We’re happy to discuss ideas, answer questions, and help to deploy LaSuite Meet.
|
||||
|
||||
|
||||
### Features
|
||||
- Optimized for stability in large meetings (+100 p.)
|
||||
- Support for multiple screen sharing streams
|
||||
@@ -52,7 +63,7 @@ We’re continuously adding new features to enhance your experience, with the la
|
||||
|
||||
### 🚀 Major roll out to all French public servants
|
||||
|
||||
On the 25th of January 2026, David Amiel, France’s Minister for Civil Service and State Reform, announced the full deployment of Visio—the French government’s dedicated Meet platform—to all public servants. ([Source in French](https://www.latribune.fr/article/la-tribune-dimanche/politique/73157688099661/david-amiel-ministre-delegue-de-la-fonction-publique-nous-allons-sortir-de-la-dependance-aux-outils-americains))
|
||||
On the 29th of January 2026, Prime Minister Sébastien Lecornu, announced the full deployment of Visio—the French government’s dedicated Meet platform—to all public servants. ([Source in English](https://www.nytimes.com/2026/01/29/world/europe/france-zoom-alternative-visio.html))
|
||||
|
||||
## Table of Contents
|
||||
|
||||
@@ -84,22 +95,57 @@ We use Kubernetes for our [production instance](https://visio.numerique.gouv.fr/
|
||||
#### Known instances
|
||||
We hope to see many more, here is an incomplete list of public La Suite Meet instances. Feel free to make a PR to add ones that are not listed below🙏
|
||||
|
||||
| Url | Org | Access |
|
||||
|---------------------------------------------------------------| --- | ------- |
|
||||
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
|
||||
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
|
||||
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
|
||||
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
|
||||
| Url | Org | Access |
|
||||
|---------------------------------------------------------------|--------------|-----------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up |
|
||||
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up |
|
||||
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
|
||||
| [mosacloud.cloud](https://mosa.cloud/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
|
||||
| [Clever Cloud](https://www.clever.cloud/product/visio/) | clever cloud | Openvisio is a sovereign video conferencing solution based on LaSuite Meet offered by [Clever Cloud](https://www.clever.cloud/). |
|
||||
| [Email.eu](https://email.eu/) | Email.eu | Sovereign business workspace. |
|
||||
|
||||
|
||||
## Contributing
|
||||
# Contributing
|
||||
|
||||
We <3 contributions of any kind, big and small:
|
||||
We <3 contributions of all kinds **big or small** and we’re genuinely glad you’re here. 🌱
|
||||
|
||||
- Vote on features or get early access to beta functionality in our [roadmap](https://github.com/orgs/suitenumerique/projects/11/views/4)
|
||||
- Open a PR (see our instructions on [developing La Suite Meet locally](https://github.com/suitenumerique/meet/blob/main/docs/developping_locally.md))
|
||||
- Submit a [feature request](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=enhancement&template=Feature_request.md) or [bug report](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md)
|
||||
### Start by saying hi
|
||||
|
||||
**The best first contribution is simply to come say hi.**
|
||||
|
||||
Before opening a PR, especially a larger one, or one written with the help of AI, we encourage you to reach out to a maintainer on our [Matrix channel](https://matrix.to/#/#meet-official:matrix.org) (@antoine.lebaud:matrix.org).
|
||||
|
||||
Getting in touch early helps us align on goals, avoid duplicated or wasted effort, and build a community that stays active, welcoming, and fun to be part of. There are no silly questions here: whether you’ve shipped hundreds of PRs or you’re just getting started, you’re welcome.
|
||||
|
||||
### AI contributions
|
||||
|
||||
AI-assisted contributions are welcome. But code is never the end goal. What matters most is building relationships, sharing knowledge, and growing a sustainable community over time.
|
||||
|
||||
If your contribution has been heavily generated with AI, please be transparent about it. This helps maintainers review it with the right context and respects the time they invest in the project.
|
||||
|
||||
Using AI does not transfer ownership of the contribution: you should still fully understand the code, the problem it solves, and the reasoning behind the approach you propose. In short, even if AI helped write it, the why should still be yours.
|
||||
|
||||
### Contributions beyond code
|
||||
|
||||
**Not technical? We need you too.**
|
||||
Open source is much more than code. Writing documentation, improving onboarding, translating content, answering questions, reporting bugs, or simply helping others feel welcome all make a huge difference.
|
||||
|
||||
### Ways to contribute
|
||||
|
||||
When you’re ready, here are a few ways to get involved:
|
||||
|
||||
* 👋 **Say hello** and share your ideas with the community and maintainers on our [Matrix channel](https://matrix.to/#/#meet-official:matrix.org)
|
||||
* 🛠️ **Open a PR** by following our guide to [develop La Suite Meet locally](https://github.com/suitenumerique/meet/blob/main/docs/developping_locally.md)
|
||||
* 💡 **Suggest an idea** by opening a [feature request](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=enhancement&template=Feature_request.md)
|
||||
* 🐛 **Report a bug** by opening a [bug report](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md)
|
||||
|
||||
Thank you for helping build something open, useful, and human. 💙
|
||||
|
||||
### Community call
|
||||
|
||||
We host a community call on the first Friday of every month to share updates, discuss ideas, and connect with contributors.
|
||||
|
||||
Whether you’re actively contributing or just curious about the project, you’re welcome to join. More details are shared on the [Matrix channel](https://matrix.to/#/#meet-official:matrix.org).
|
||||
|
||||
## Philosophy
|
||||
|
||||
|
||||
+12
@@ -15,3 +15,15 @@ the following command inside your docker container:
|
||||
(Note : in your development environment, you can `make migrate`.)
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## v1.23.0
|
||||
|
||||
As part of the 1.23.0 release, the legacy `api/v1` implementation has been removed from the _experimental_ Summary service and Meet has been migrated to the new `api/v2`.
|
||||
|
||||
**To avoid a breaking change, the Meet backend continues to use the Summary service's v1-compatible API format by default (`SUMMARY_SERVICE_VERSION` setting defaults to `1`).**
|
||||
|
||||
If you are deploying both Meet and Summary from this repository, you must configure the Meet backend to use the v2 API by setting the following environment variable `SUMMARY_SERVICE_VERSION=2`.
|
||||
|
||||
If you are upgrading only the Meet deployment while keeping an older Summary v1 compatible deployment, no action is required, as the v1-compatible API remains the default.
|
||||
|
||||
Note that we plan on removing the legacy `v1` summary compatibility in a future major version. If you have your own implementation for the summary service, we recommend updating its API contract and setting `SUMMARY_SERVICE_VERSION=2`.
|
||||
|
||||
@@ -113,6 +113,12 @@ update_python_version "summary"
|
||||
# Update agents pyproject.toml
|
||||
update_python_version "agents"
|
||||
|
||||
# Run uv lock in agents
|
||||
print_info "Running uv lock in agents..."
|
||||
cd "src/agents"
|
||||
uv lock
|
||||
cd -
|
||||
|
||||
# Update CHANGELOG
|
||||
print_info "Updating CHANGELOG..."
|
||||
|
||||
@@ -158,6 +164,7 @@ echo " - src/backend/pyproject.toml"
|
||||
echo " - src/backend/uv.lock"
|
||||
echo " - src/summary/pyproject.toml"
|
||||
echo " - src/agents/pyproject.toml"
|
||||
echo " - src/agents/uv.lock"
|
||||
echo " - CHANGELOG.md"
|
||||
echo ""
|
||||
print_warning "Next steps:"
|
||||
|
||||
+38
-28
@@ -14,6 +14,9 @@ services:
|
||||
image: sj26/mailcatcher:latest
|
||||
ports:
|
||||
- "1081:1080"
|
||||
networks:
|
||||
- default
|
||||
- lasuite
|
||||
|
||||
minio:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
@@ -33,6 +36,13 @@ services:
|
||||
command: minio server --console-address :9001 /data
|
||||
volumes:
|
||||
- ./data/media:/data
|
||||
networks:
|
||||
default:
|
||||
# The backend containers also sit on lasuite-network, where Drive's own
|
||||
# minio answers to "minio" as well. They address this one by an alias no
|
||||
# other stack uses, so the two can never race in DNS.
|
||||
aliases:
|
||||
- meet-minio
|
||||
|
||||
createbuckets:
|
||||
image: minio/mc
|
||||
@@ -93,7 +103,8 @@ services:
|
||||
networks:
|
||||
- resource-server
|
||||
- default
|
||||
|
||||
- lasuite
|
||||
|
||||
celery-dev:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: meet:backend-development
|
||||
@@ -109,6 +120,9 @@ services:
|
||||
- /app/.venv
|
||||
depends_on:
|
||||
- app-dev
|
||||
networks:
|
||||
- default
|
||||
- lasuite
|
||||
|
||||
app:
|
||||
build:
|
||||
@@ -196,32 +210,32 @@ services:
|
||||
- env.d/development/kc_postgresql
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:20.0.1
|
||||
image: quay.io/keycloak/keycloak:26.3.2
|
||||
volumes:
|
||||
- ./docker/auth/realm.json:/opt/keycloak/data/import/realm.json
|
||||
command:
|
||||
- start-dev
|
||||
- --features=preview
|
||||
- --import-realm
|
||||
- --proxy=edge
|
||||
- --hostname-url=http://localhost:8083
|
||||
- --hostname-admin-url=http://localhost:8083/
|
||||
- --proxy-headers=xforwarded
|
||||
- --hostname=http://localhost:8083
|
||||
- --hostname-strict=false
|
||||
- --hostname-strict-https=false
|
||||
environment:
|
||||
KEYCLOAK_ADMIN: admin
|
||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME: admin
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
|
||||
KC_DB: postgres
|
||||
KC_DB_URL_HOST: kc_postgresql
|
||||
KC_DB_URL_DATABASE: keycloak
|
||||
KC_DB_PASSWORD: pass
|
||||
KC_DB_USERNAME: meet
|
||||
KC_DB_SCHEMA: public
|
||||
PROXY_ADDRESS_FORWARDING: 'true'
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
- kc_postgresql
|
||||
networks:
|
||||
- default
|
||||
- lasuite
|
||||
|
||||
livekit:
|
||||
image: livekit/livekit-server
|
||||
@@ -237,29 +251,22 @@ services:
|
||||
- livekit-egress
|
||||
|
||||
livekit-egress:
|
||||
image: livekit/egress:v1.11.0
|
||||
environment:
|
||||
EGRESS_CONFIG_FILE: ./livekit-egress.yaml
|
||||
volumes:
|
||||
- ./docker/livekit/config/livekit-egress.yaml:/livekit-egress.yaml
|
||||
- ./docker/livekit/out:/out
|
||||
depends_on:
|
||||
- redis
|
||||
image: livekit/egress:v1.11.0
|
||||
environment:
|
||||
EGRESS_CONFIG_FILE: ./livekit-egress.yaml
|
||||
volumes:
|
||||
- ./docker/livekit/config/livekit-egress.yaml:/livekit-egress.yaml
|
||||
- ./docker/livekit/out:/out
|
||||
depends_on:
|
||||
- redis
|
||||
|
||||
metadata-collector-dev:
|
||||
build:
|
||||
context: ./src/agents
|
||||
target: development
|
||||
command: ["python", "metadata_collector.py", "dev"]
|
||||
environment:
|
||||
- LIVEKIT_URL=ws://livekit:7880
|
||||
- LIVEKIT_API_KEY=devkey
|
||||
- LIVEKIT_API_SECRET=secret
|
||||
- AWS_S3_ENDPOINT_URL=minio:9000
|
||||
- AWS_S3_ACCESS_KEY_ID=meet
|
||||
- AWS_S3_SECRET_ACCESS_KEY=password
|
||||
- AWS_STORAGE_BUCKET_NAME=meet-media-storage
|
||||
- AWS_S3_SECURE_ACCESS=False
|
||||
env_file:
|
||||
- env.d/development/metadata_collector
|
||||
volumes:
|
||||
- ./src/agents:/app
|
||||
- /app/.venv
|
||||
@@ -308,7 +315,7 @@ services:
|
||||
context: ./src/summary
|
||||
dockerfile: Dockerfile
|
||||
target: production
|
||||
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q transcribe-queue
|
||||
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q transcribe-queue-v2
|
||||
env_file:
|
||||
- env.d/development/summary
|
||||
volumes:
|
||||
@@ -328,7 +335,7 @@ services:
|
||||
context: ./src/summary
|
||||
dockerfile: Dockerfile
|
||||
target: production
|
||||
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q summarize-queue
|
||||
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q summarize-queue-v2
|
||||
env_file:
|
||||
- env.d/development/summary
|
||||
volumes:
|
||||
@@ -345,3 +352,6 @@ services:
|
||||
networks:
|
||||
default:
|
||||
resource-server:
|
||||
lasuite:
|
||||
name: lasuite-network
|
||||
external: true
|
||||
|
||||
+237
-32
@@ -56,7 +56,9 @@
|
||||
"value": "meet"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
"realmRoles": [
|
||||
"user"
|
||||
]
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-chromium",
|
||||
@@ -70,7 +72,9 @@
|
||||
"value": "password-e2e-chromium"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
"realmRoles": [
|
||||
"user"
|
||||
]
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-webkit",
|
||||
@@ -84,7 +88,9 @@
|
||||
"value": "password-e2e-webkit"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
"realmRoles": [
|
||||
"user"
|
||||
]
|
||||
},
|
||||
{
|
||||
"username": "user-e2e-firefox",
|
||||
@@ -98,7 +104,9 @@
|
||||
"value": "password-e2e-firefox"
|
||||
}
|
||||
],
|
||||
"realmRoles": ["user"]
|
||||
"realmRoles": [
|
||||
"user"
|
||||
]
|
||||
}
|
||||
],
|
||||
"roles": {
|
||||
@@ -118,9 +126,15 @@
|
||||
"description": "${role_default-roles}",
|
||||
"composite": "true",
|
||||
"composites": {
|
||||
"realm": ["offline_access", "uma_authorization"],
|
||||
"realm": [
|
||||
"offline_access",
|
||||
"uma_authorization"
|
||||
],
|
||||
"client": {
|
||||
"account": ["view-profile", "manage-account"]
|
||||
"account": [
|
||||
"view-profile",
|
||||
"manage-account"
|
||||
]
|
||||
}
|
||||
},
|
||||
"clientRole": "false",
|
||||
@@ -269,7 +283,9 @@
|
||||
"composite": "true",
|
||||
"composites": {
|
||||
"client": {
|
||||
"realm-management": ["query-clients"]
|
||||
"realm-management": [
|
||||
"query-clients"
|
||||
]
|
||||
}
|
||||
},
|
||||
"clientRole": "true",
|
||||
@@ -292,7 +308,10 @@
|
||||
"composite": "true",
|
||||
"composites": {
|
||||
"client": {
|
||||
"realm-management": ["query-users", "query-groups"]
|
||||
"realm-management": [
|
||||
"query-users",
|
||||
"query-groups"
|
||||
]
|
||||
}
|
||||
},
|
||||
"clientRole": "true",
|
||||
@@ -368,7 +387,9 @@
|
||||
"composite": "true",
|
||||
"composites": {
|
||||
"client": {
|
||||
"account": ["view-consent"]
|
||||
"account": [
|
||||
"view-consent"
|
||||
]
|
||||
}
|
||||
},
|
||||
"clientRole": "true",
|
||||
@@ -400,7 +421,9 @@
|
||||
"composite": "true",
|
||||
"composites": {
|
||||
"client": {
|
||||
"account": ["manage-account-links"]
|
||||
"account": [
|
||||
"manage-account-links"
|
||||
]
|
||||
}
|
||||
},
|
||||
"clientRole": "true",
|
||||
@@ -455,7 +478,9 @@
|
||||
"clientRole": "false",
|
||||
"containerId": "ccf4fd40-4286-474d-854a-4714282a8bec"
|
||||
},
|
||||
"requiredCredentials": ["password"],
|
||||
"requiredCredentials": [
|
||||
"password"
|
||||
],
|
||||
"otpPolicyType": "totp",
|
||||
"otpPolicyAlgorithm": "HmacSHA1",
|
||||
"otpPolicyInitialCounter": 0,
|
||||
@@ -463,9 +488,14 @@
|
||||
"otpPolicyLookAheadWindow": 1,
|
||||
"otpPolicyPeriod": 30,
|
||||
"otpPolicyCodeReusable": "false",
|
||||
"otpSupportedApplications": ["totpAppGoogleName", "totpAppFreeOTPName"],
|
||||
"otpSupportedApplications": [
|
||||
"totpAppGoogleName",
|
||||
"totpAppFreeOTPName"
|
||||
],
|
||||
"webAuthnPolicyRpEntityName": "keycloak",
|
||||
"webAuthnPolicySignatureAlgorithms": ["ES256"],
|
||||
"webAuthnPolicySignatureAlgorithms": [
|
||||
"ES256"
|
||||
],
|
||||
"webAuthnPolicyRpId": "",
|
||||
"webAuthnPolicyAttestationConveyancePreference": "not specified",
|
||||
"webAuthnPolicyAuthenticatorAttachment": "not specified",
|
||||
@@ -475,7 +505,9 @@
|
||||
"webAuthnPolicyAvoidSameAuthenticatorRegister": "false",
|
||||
"webAuthnPolicyAcceptableAaguids": [],
|
||||
"webAuthnPolicyPasswordlessRpEntityName": "keycloak",
|
||||
"webAuthnPolicyPasswordlessSignatureAlgorithms": ["ES256"],
|
||||
"webAuthnPolicyPasswordlessSignatureAlgorithms": [
|
||||
"ES256"
|
||||
],
|
||||
"webAuthnPolicyPasswordlessRpId": "",
|
||||
"webAuthnPolicyPasswordlessAttestationConveyancePreference": "not specified",
|
||||
"webAuthnPolicyPasswordlessAuthenticatorAttachment": "not specified",
|
||||
@@ -487,14 +519,19 @@
|
||||
"scopeMappings": [
|
||||
{
|
||||
"clientScope": "offline_access",
|
||||
"roles": ["offline_access"]
|
||||
"roles": [
|
||||
"offline_access"
|
||||
]
|
||||
}
|
||||
],
|
||||
"clientScopeMappings": {
|
||||
"account": [
|
||||
{
|
||||
"client": "account-console",
|
||||
"roles": ["manage-account", "view-groups"]
|
||||
"roles": [
|
||||
"manage-account",
|
||||
"view-groups"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -509,7 +546,9 @@
|
||||
"enabled": "true",
|
||||
"alwaysDisplayInConsole": "false",
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"redirectUris": ["/realms/meet/account/*"],
|
||||
"redirectUris": [
|
||||
"/realms/meet/account/*"
|
||||
],
|
||||
"webOrigins": [],
|
||||
"notBefore": 0,
|
||||
"bearerOnly": "false",
|
||||
@@ -551,7 +590,9 @@
|
||||
"enabled": "true",
|
||||
"alwaysDisplayInConsole": "false",
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"redirectUris": ["/realms/meet/account/*"],
|
||||
"redirectUris": [
|
||||
"/realms/meet/account/*"
|
||||
],
|
||||
"webOrigins": [],
|
||||
"notBefore": 0,
|
||||
"bearerOnly": "false",
|
||||
@@ -796,8 +837,12 @@
|
||||
"enabled": "true",
|
||||
"alwaysDisplayInConsole": "false",
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"redirectUris": ["/admin/meet/console/*"],
|
||||
"webOrigins": ["+"],
|
||||
"redirectUris": [
|
||||
"/admin/meet/console/*"
|
||||
],
|
||||
"webOrigins": [
|
||||
"+"
|
||||
],
|
||||
"notBefore": 0,
|
||||
"bearerOnly": "false",
|
||||
"consentRequired": "false",
|
||||
@@ -845,6 +890,142 @@
|
||||
"offline_access",
|
||||
"microprofile-jwt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"clientId": "drive",
|
||||
"name": "",
|
||||
"description": "",
|
||||
"rootUrl": "",
|
||||
"adminUrl": "",
|
||||
"baseUrl": "",
|
||||
"surrogateAuthRequired": false,
|
||||
"enabled": true,
|
||||
"alwaysDisplayInConsole": false,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"secret": "ThisIsAnExampleKeyForDevPurposeOnly",
|
||||
"redirectUris": [
|
||||
"http://localhost:3100/*",
|
||||
"http://localhost:8171/*",
|
||||
"http://localhost:8085/*"
|
||||
],
|
||||
"webOrigins": [
|
||||
"http://localhost:3100",
|
||||
"http://localhost:8171",
|
||||
"http://localhost:8085"
|
||||
],
|
||||
"notBefore": 0,
|
||||
"bearerOnly": false,
|
||||
"consentRequired": false,
|
||||
"standardFlowEnabled": true,
|
||||
"implicitFlowEnabled": false,
|
||||
"directAccessGrantsEnabled": false,
|
||||
"serviceAccountsEnabled": false,
|
||||
"publicClient": false,
|
||||
"frontchannelLogout": true,
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"access.token.lifespan": "-1",
|
||||
"client.secret.creation.time": "1707820779",
|
||||
"user.info.response.signature.alg": "RS256",
|
||||
"post.logout.redirect.uris": "http://localhost:3100/*##http://localhost:8171/*##http://localhost:8085/*",
|
||||
"oauth2.device.authorization.grant.enabled": "false",
|
||||
"use.jwks.url": "false",
|
||||
"backchannel.logout.revoke.offline.tokens": "false",
|
||||
"use.refresh.tokens": "true",
|
||||
"tls-client-certificate-bound-access-tokens": "false",
|
||||
"oidc.ciba.grant.enabled": "false",
|
||||
"backchannel.logout.session.required": "true",
|
||||
"client_credentials.use_refresh_token": "false",
|
||||
"acr.loa.map": "{}",
|
||||
"require.pushed.authorization.requests": "false",
|
||||
"display.on.consent.screen": "false",
|
||||
"client.session.idle.timeout": "-1",
|
||||
"token.response.type.bearer.lower-case": "false"
|
||||
},
|
||||
"authenticationFlowBindingOverrides": {},
|
||||
"fullScopeAllowed": true,
|
||||
"nodeReRegistrationTimeout": -1,
|
||||
"defaultClientScopes": [
|
||||
"web-origins",
|
||||
"acr",
|
||||
"roles",
|
||||
"profile",
|
||||
"email"
|
||||
],
|
||||
"optionalClientScopes": [
|
||||
"address",
|
||||
"phone",
|
||||
"offline_access",
|
||||
"microprofile-jwt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"clientId": "deploycenter",
|
||||
"name": "",
|
||||
"description": "",
|
||||
"rootUrl": "",
|
||||
"adminUrl": "",
|
||||
"baseUrl": "",
|
||||
"surrogateAuthRequired": false,
|
||||
"enabled": true,
|
||||
"alwaysDisplayInConsole": false,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"secret": "ThisIsAnExampleKeyForDevPurposeOnly",
|
||||
"redirectUris": [
|
||||
"http://localhost:3100/*",
|
||||
"http://localhost:8171/*",
|
||||
"http://localhost:8085/*"
|
||||
],
|
||||
"webOrigins": [
|
||||
"http://localhost:3100",
|
||||
"http://localhost:8171",
|
||||
"http://localhost:8085"
|
||||
],
|
||||
"notBefore": 0,
|
||||
"bearerOnly": false,
|
||||
"consentRequired": false,
|
||||
"standardFlowEnabled": true,
|
||||
"implicitFlowEnabled": false,
|
||||
"directAccessGrantsEnabled": false,
|
||||
"serviceAccountsEnabled": false,
|
||||
"publicClient": false,
|
||||
"frontchannelLogout": true,
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"access.token.lifespan": "-1",
|
||||
"client.secret.creation.time": "1707820779",
|
||||
"user.info.response.signature.alg": "RS256",
|
||||
"post.logout.redirect.uris": "http://localhost:3100/*##http://localhost:8171/*##http://localhost:8085/*",
|
||||
"oauth2.device.authorization.grant.enabled": "false",
|
||||
"use.jwks.url": "false",
|
||||
"backchannel.logout.revoke.offline.tokens": "false",
|
||||
"use.refresh.tokens": "true",
|
||||
"tls-client-certificate-bound-access-tokens": "false",
|
||||
"oidc.ciba.grant.enabled": "false",
|
||||
"backchannel.logout.session.required": "true",
|
||||
"client_credentials.use_refresh_token": "false",
|
||||
"acr.loa.map": "{}",
|
||||
"require.pushed.authorization.requests": "false",
|
||||
"display.on.consent.screen": "false",
|
||||
"client.session.idle.timeout": "-1",
|
||||
"token.response.type.bearer.lower-case": "false"
|
||||
},
|
||||
"authenticationFlowBindingOverrides": {},
|
||||
"fullScopeAllowed": true,
|
||||
"nodeReRegistrationTimeout": -1,
|
||||
"defaultClientScopes": [
|
||||
"web-origins",
|
||||
"acr",
|
||||
"roles",
|
||||
"profile",
|
||||
"email"
|
||||
],
|
||||
"optionalClientScopes": [
|
||||
"address",
|
||||
"phone",
|
||||
"offline_access",
|
||||
"microprofile-jwt"
|
||||
]
|
||||
}
|
||||
],
|
||||
"clientScopes": [
|
||||
@@ -1382,7 +1563,9 @@
|
||||
},
|
||||
"smtpServer": {},
|
||||
"eventsEnabled": "false",
|
||||
"eventsListeners": ["jboss-logging"],
|
||||
"eventsListeners": [
|
||||
"jboss-logging"
|
||||
],
|
||||
"enabledEventTypes": [],
|
||||
"adminEventsEnabled": "false",
|
||||
"adminEventsDetailsEnabled": "false",
|
||||
@@ -1405,7 +1588,9 @@
|
||||
"subType": "anonymous",
|
||||
"subComponents": {},
|
||||
"config": {
|
||||
"allow-default-scopes": ["true"]
|
||||
"allow-default-scopes": [
|
||||
"true"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1415,7 +1600,9 @@
|
||||
"subType": "anonymous",
|
||||
"subComponents": {},
|
||||
"config": {
|
||||
"max-clients": ["200"]
|
||||
"max-clients": [
|
||||
"200"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1425,7 +1612,9 @@
|
||||
"subType": "authenticated",
|
||||
"subComponents": {},
|
||||
"config": {
|
||||
"allow-default-scopes": ["true"]
|
||||
"allow-default-scopes": [
|
||||
"true"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1481,8 +1670,12 @@
|
||||
"subType": "anonymous",
|
||||
"subComponents": {},
|
||||
"config": {
|
||||
"host-sending-registration-request-must-match": ["true"],
|
||||
"client-uris-must-match": ["true"]
|
||||
"host-sending-registration-request-must-match": [
|
||||
"true"
|
||||
],
|
||||
"client-uris-must-match": [
|
||||
"true"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -1501,7 +1694,9 @@
|
||||
"providerId": "aes-generated",
|
||||
"subComponents": {},
|
||||
"config": {
|
||||
"priority": ["100"]
|
||||
"priority": [
|
||||
"100"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1510,8 +1705,12 @@
|
||||
"providerId": "hmac-generated",
|
||||
"subComponents": {},
|
||||
"config": {
|
||||
"priority": ["100"],
|
||||
"algorithm": ["HS256"]
|
||||
"priority": [
|
||||
"100"
|
||||
],
|
||||
"algorithm": [
|
||||
"HS256"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1520,8 +1719,12 @@
|
||||
"providerId": "rsa-enc-generated",
|
||||
"subComponents": {},
|
||||
"config": {
|
||||
"priority": ["100"],
|
||||
"algorithm": ["RSA-OAEP"]
|
||||
"priority": [
|
||||
"100"
|
||||
],
|
||||
"algorithm": [
|
||||
"RSA-OAEP"
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -1530,7 +1733,9 @@
|
||||
"providerId": "rsa-generated",
|
||||
"subComponents": {},
|
||||
"config": {
|
||||
"priority": ["100"]
|
||||
"priority": [
|
||||
"100"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# ---- Front-end image ----
|
||||
FROM node:20-alpine AS frontend-deps
|
||||
FROM node:22-alpine AS frontend-deps
|
||||
|
||||
WORKDIR /home/frontend/
|
||||
|
||||
@@ -54,7 +54,7 @@ RUN npx webpack --mode production
|
||||
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:alpine3.23 AS frontend-production
|
||||
FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
|
||||
|
||||
USER root
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ sequenceDiagram
|
||||
| **RECORDING_WORKER_CLASSES** | Dict | `{ "screen_recording": "core.recording.worker.services.VideoCompositeEgressService", "transcript": "core.recording.worker.services.AudioCompositeEgressService" }` | Maps recording types to their worker service classes. |
|
||||
| **RECORDING_EVENT_PARSER_CLASS** | String | `"core.recording.event.parsers.MinioParser"` | Class responsible for parsing storage events and updating the backend. |
|
||||
| **RECORDING_ENABLE_STORAGE_EVENT_AUTH** | Boolean | `True` | Enable authentication for storage event webhook requests. |
|
||||
| **RECORDING_STORAGE_EVENT_ENABLE** | Boolean | `False` | Enable handling of storage events (must configure webhook in storage). |
|
||||
| **RECORDING_STORAGE_EVENT_ENABLE** | Boolean | `False` | Enable handling of storage events (must configure webhook in storage). If `False`, fallback to LiveKit egress complete webhook. |
|
||||
| **RECORDING_STORAGE_EVENT_TOKEN** | Secret/File | `None` | Token used to authenticate storage webhook requests, if `RECORDING_ENABLE_STORAGE_EVENT_AUTH` is enabled. |
|
||||
| **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. |
|
||||
| **RECORDING_MAX_DURATION** | Integer | `None` | Maximum duration of a recording in milliseconds. Must be synced with the LiveKit Egress configuration. Set to None for unlimited duration. When the maximum duration is reached, the recording is automatically stopped and saved, and the user is prompted in the frontend with an alert message. |
|
||||
@@ -126,6 +126,90 @@ RECORDING_STORAGE_EVENT_TOKEN = <token>
|
||||
> Questions? Open an issue on [GitHub](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) or join our [Matrix community](https://matrix.to/#/#meet-official:matrix.org).
|
||||
|
||||
|
||||
## Push recordings to Drive
|
||||
|
||||
Once a recording is over, it can be pushed to the main workspace of the user who
|
||||
started it in [Drive](https://github.com/suitenumerique/drive), on top of staying
|
||||
in the object storage. The file is streamed from the object storage to Drive: it
|
||||
is never fully held in the worker's memory nor written to its disk.
|
||||
|
||||
Drive is called as an OIDC resource server, following its
|
||||
[resource server documentation](https://github.com/suitenumerique/drive/blob/main/docs/resource_server.md):
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Backend as Django Backend
|
||||
participant Worker as Celery Worker
|
||||
participant Storage as Object Storage
|
||||
participant Drive
|
||||
|
||||
User->>Backend: POST /api/v1.0/rooms/{id}/start-recording/
|
||||
Backend->>Backend: Park the user's OIDC access token (encrypted)
|
||||
|
||||
Note over Backend: Recording in progress...
|
||||
|
||||
Storage->>Backend: Storage event notification
|
||||
Backend->>Worker: Schedule push_recording
|
||||
|
||||
Worker->>Drive: GET /items/ (as the user)
|
||||
Drive-->>Worker: Main workspace
|
||||
Worker->>Drive: POST /items/{workspace}/children/
|
||||
Drive-->>Worker: Item + presigned upload URL
|
||||
Worker->>Storage: GET recording (streamed)
|
||||
Worker->>Drive: PUT presigned URL (relayed chunk by chunk)
|
||||
Worker->>Drive: POST /items/{item}/upload-ended/
|
||||
Worker->>Backend: Drop the parked access token
|
||||
```
|
||||
|
||||
### Special requirements
|
||||
|
||||
- Drive configured as an OIDC resource server, accepting Meet's audience
|
||||
(`OIDC_RS_ALLOWED_AUDIENCES` must contain Meet's client id), with the `items`
|
||||
endpoint allowing the `list`, `children` and `upload_ended` actions.
|
||||
- `OIDC_STORE_ACCESS_TOKEN` enabled on Meet, along with
|
||||
`OIDC_STORE_REFRESH_TOKEN_KEY`, the Fernet key encrypting the parked token.
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a proof of concept: the access token is captured when the recording
|
||||
> starts and assumed to still be valid when the recording ends. Long recordings
|
||||
> may therefore fail to be pushed. Exchanging it for a long-lived, narrowly
|
||||
> scoped token ([RFC 8693](https://datatracker.ietf.org/doc/html/rfc8693)) is the
|
||||
> intended follow-up.
|
||||
|
||||
### Configuration options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ----------------------------------------------------- | ----------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **RECORDING_PUSH_TO_DRIVE_ENABLED** | Boolean | `False` | Enable pushing recordings to the owner's Drive. |
|
||||
| **DRIVE_API_BASE_URL** | String | `None` | Base URL of Drive's external API, e.g. `https://drive.example.com/external_api/v1.0`. |
|
||||
| **RECORDING_PUSH_TO_DRIVE_SIGNED_URL_EXPIRY_SECONDS** | Integer | `3600` | Lifetime of the signed URL the worker downloads the recording from. |
|
||||
| **OIDC_STORE_ACCESS_TOKEN** | Boolean | `False` | Keep the user's access token in the session, required to call Drive on their behalf. |
|
||||
| **OIDC_STORE_REFRESH_TOKEN_KEY** | Secret/File | `None` | Fernet key encrypting OIDC tokens at rest. Generate one with `Fernet.generate_key()`. |
|
||||
| **DRIVE_UPLOAD_STORAGE_NETLOC** | String | `None` | Development only: `host:port` to reach Drive's object storage at, when the domain Drive signs its upload URLs with only resolves from a browser. |
|
||||
|
||||
### Local development
|
||||
|
||||
Meet and Drive run as two separate compose projects, joined by the external
|
||||
`lasuite-network` (`make create-docker-network`). Meet's backend containers reach
|
||||
Drive's nginx at `drive-nginx:8083` and its object storage at `drive-minio:9000`.
|
||||
|
||||
On the Drive side:
|
||||
|
||||
```bash
|
||||
OIDC_RESOURCE_SERVER_ENABLED=True
|
||||
OIDC_RS_CLIENT_ID=drive
|
||||
OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_RS_AUDIENCE_CLAIM=client_id
|
||||
OIDC_RS_ALLOWED_AUDIENCES=meet
|
||||
```
|
||||
|
||||
`DRIVE_UPLOAD_STORAGE_NETLOC` is needed there because Drive signs its upload URLs
|
||||
with `localhost:9100`, which does not resolve from Meet's containers. The
|
||||
presigned signature covers the `Host` header, so the backend keeps announcing the
|
||||
signed host and only swaps the address it connects to.
|
||||
|
||||
|
||||
## LiveKit Egress
|
||||
|
||||
La Suite Meet uses LiveKit Egress to record room sessions. For reference, see the [LiveKit Egress repository](https://github.com/livekit/egress) and the [official documentation](https://docs.livekit.io/home/egress/overview/).
|
||||
|
||||
@@ -80,7 +80,6 @@ sequenceDiagram
|
||||
| whisperx_api_key | Secret | — | API key for accessing WhisperX. |
|
||||
| whisperx_base_url | String | `"https://api.whisperx.com/v1"` | Base URL for the WhisperX API. |
|
||||
| whisperx_asr_model | String | `"whisper-1"` | ASR model used for transcription. |
|
||||
| whisperx_max_retries | Integer | `0` | Maximum number of retries for WhisperX API requests. |
|
||||
| webhook_max_retries | Integer | `2` | Maximum retries for webhook requests. |
|
||||
| webhook_status_forcelist | List[Int] | `[502, 503, 504]` | HTTP status codes triggering webhook retry. |
|
||||
| webhook_backoff_factor | Float | `0.1` | Exponential backoff factor for webhook retries. |
|
||||
|
||||
@@ -244,6 +244,69 @@ meet-admin <none> meet.127.0.0.1.nip.io localhost 80, 44
|
||||
|
||||
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.
|
||||
|
||||
## Rebranding the favicon
|
||||
|
||||
The favicon is bundled into the frontend image and served as a set of static
|
||||
files from `/usr/share/nginx/html` (`favicon.ico`, `favicon-16x16.png`,
|
||||
`favicon-32x32.png`, `apple-touch-icon.png`, `android-chrome-192x192.png`,
|
||||
`android-chrome-512x512.png`, `icon.png`). To rebrand without forking and
|
||||
rebuilding the image, overlay your own icons onto those paths with a volume —
|
||||
this serves the right icon from the first byte (no rebuild, no flash) and
|
||||
covers every variant, including the iOS home-screen and Android/PWA icons.
|
||||
|
||||
Put your icons in a `ConfigMap` (`binaryData` keeps the PNGs intact)…
|
||||
|
||||
```yaml
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: meet-favicon
|
||||
binaryData:
|
||||
# base64 of each replacement icon
|
||||
favicon.ico: <base64…>
|
||||
favicon-16x16.png: <base64…>
|
||||
favicon-32x32.png: <base64…>
|
||||
apple-touch-icon.png: <base64…>
|
||||
android-chrome-192x192.png: <base64…>
|
||||
android-chrome-512x512.png: <base64…>
|
||||
```
|
||||
|
||||
```bash
|
||||
# e.g. build the ConfigMap straight from a directory of icons
|
||||
$ kubectl create configmap meet-favicon --from-file=./my-icons/
|
||||
```
|
||||
|
||||
…then mount each file over the bundled one via the chart's
|
||||
`frontend.extraVolumes` / `frontend.extraVolumeMounts` (the `subPath` mounts
|
||||
the single file without hiding the rest of `html/`):
|
||||
|
||||
```yaml
|
||||
frontend:
|
||||
extraVolumes:
|
||||
- name: favicon
|
||||
configMap:
|
||||
name: meet-favicon
|
||||
extraVolumeMounts:
|
||||
- name: favicon
|
||||
mountPath: /usr/share/nginx/html/favicon.ico
|
||||
subPath: favicon.ico
|
||||
- name: favicon
|
||||
mountPath: /usr/share/nginx/html/favicon-16x16.png
|
||||
subPath: favicon-16x16.png
|
||||
- name: favicon
|
||||
mountPath: /usr/share/nginx/html/favicon-32x32.png
|
||||
subPath: favicon-32x32.png
|
||||
- name: favicon
|
||||
mountPath: /usr/share/nginx/html/apple-touch-icon.png
|
||||
subPath: apple-touch-icon.png
|
||||
- name: favicon
|
||||
mountPath: /usr/share/nginx/html/android-chrome-192x192.png
|
||||
subPath: android-chrome-192x192.png
|
||||
- name: favicon
|
||||
mountPath: /usr/share/nginx/html/android-chrome-512x512.png
|
||||
subPath: android-chrome-512x512.png
|
||||
```
|
||||
|
||||
## All options
|
||||
|
||||
These are the environmental options available on meet backend.
|
||||
@@ -281,6 +344,7 @@ These are the environmental options available on meet backend.
|
||||
| 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_DOCUMENTATION_URL | URL of the documentation opened from the room options menu. If unset, the documentation menu item is hidden | |
|
||||
| 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 |
|
||||
@@ -344,7 +408,7 @@ These are the environmental options available on meet backend.
|
||||
| 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_ENABLE | Enable recording storage events. If false, fallback to egress webhook. | 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. | |
|
||||
|
||||
+4
-4
@@ -11,14 +11,14 @@ There are two ways to customize LaSuite Meet:
|
||||
|
||||
### How to Use
|
||||
|
||||
To use this feature, simply set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. For example:
|
||||
To use this feature, simply set the `FRONTEND_CUSTOM_CSS_URL` environment variable (of the **backend** service) to the URL of your custom CSS file. For example:
|
||||
|
||||
```javascript
|
||||
FRONTEND_CSS_URL=https://example.com/custom-style.css
|
||||
FRONTEND_CUSTOM_CSS_URL=https://example.com/custom-style.css
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> If you serve your CSS file on the same domain as LaSuite Meet, paths are supported, i.e. `FRONTEND_CSS_URL=/custom/style.css` will load `https://your-domain.com/custom/style.css`.
|
||||
> If you serve your CSS file on the same domain as LaSuite Meet, paths are supported, i.e. `FRONTEND_CUSTOM_CSS_URL=/custom/style.css` will load `https://your-domain.com/custom/style.css`.
|
||||
|
||||
Setting this variable makes the app load your CSS at runtime, adding a `<link>` to `<head>` so you can override CSS variables and customize the frontend without rebuilding.
|
||||
|
||||
@@ -37,7 +37,7 @@ Let's say you want to change the font of our application to a custom font. You c
|
||||
}
|
||||
```
|
||||
|
||||
Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the default font to the one you specified.
|
||||
Then, set the `FRONTEND_CUSTOM_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the default font to the one you specified.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> You can override any CSS token—semantic or palette. See [panda.config.ts](../src/frontend/panda.config.ts) for all defined semantic tokens.
|
||||
|
||||
@@ -57,6 +57,7 @@ OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
LIVEKIT_API_SECRET=secret
|
||||
LIVEKIT_API_KEY=devkey
|
||||
LIVEKIT_API_URL=http://127.0.0.1.nip.io:7880
|
||||
LIVEKIT_INTERNAL_URL=http://livekit:7880
|
||||
LIVEKIT_VERIFY_SSL=False
|
||||
ALLOW_UNREGISTERED_ROOMS=False
|
||||
|
||||
@@ -64,8 +65,9 @@ ALLOW_UNREGISTERED_ROOMS=False
|
||||
RECORDING_ENABLE=True
|
||||
RECORDING_STORAGE_EVENT_ENABLE=True
|
||||
RECORDING_STORAGE_EVENT_TOKEN=password
|
||||
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v1/tasks/
|
||||
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe/
|
||||
SUMMARY_SERVICE_API_TOKEN=password
|
||||
SUMMARY_SERVICE_WEBHOOK_API_TOKEN=webhook-password
|
||||
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
|
||||
|
||||
# Recording encoding (LiveKit Egress advanced options).
|
||||
@@ -86,6 +88,9 @@ ROOM_TELEPHONY_ENABLED=True
|
||||
# Metadata
|
||||
METADATA_COLLECTOR_ENABLED=True
|
||||
|
||||
# Subtitle
|
||||
ROOM_SUBTITLE_ENABLED=False
|
||||
|
||||
FRONTEND_USE_FRENCH_GOV_FOOTER=False
|
||||
FRONTEND_USE_PROCONNECT_BUTTON=False
|
||||
|
||||
@@ -94,3 +99,4 @@ EXTERNAL_API_ENABLED=True
|
||||
APPLICATION_JWT_AUDIENCE=http://localhost:8071/external-api/v1.0/
|
||||
APPLICATION_JWT_SECRET_KEY=devKey
|
||||
APPLICATION_BASE_URL=http://localhost:3000
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
LIVEKIT_URL=ws://livekit:7880
|
||||
LIVEKIT_API_KEY=devkey
|
||||
LIVEKIT_API_SECRET=secret
|
||||
|
||||
AWS_S3_ENDPOINT_URL=minio:9000
|
||||
AWS_S3_ACCESS_KEY_ID=meet
|
||||
AWS_S3_SECRET_ACCESS_KEY=password
|
||||
AWS_STORAGE_BUCKET_NAME=meet-media-storage
|
||||
AWS_S3_SECURE_ACCESS=False
|
||||
@@ -2,8 +2,13 @@ LIVEKIT_URL=ws://livekit:7880
|
||||
LIVEKIT_API_KEY=devkey
|
||||
LIVEKIT_API_SECRET=secret
|
||||
|
||||
STT_PROVIDER=kyutai
|
||||
STT_PROVIDER=kyutai # kyutai, deepgram
|
||||
ENABLE_SILERO_VAD=False
|
||||
|
||||
DEEPGRAM_API_KEY=
|
||||
|
||||
KYUTAI_STT_BASE_URL=
|
||||
KYUTAI_API_KEY=
|
||||
|
||||
SENTRY_DSN=
|
||||
SENTRY_ENVIRONMENT=
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
APP_NAME="meet-app-summary-dev"
|
||||
APP_API_TOKEN="password"
|
||||
|
||||
AWS_STORAGE_BUCKET_NAME="http://meet-media-storage"
|
||||
AWS_STORAGE_BUCKET_NAME="meet-media-storage"
|
||||
AWS_S3_ENDPOINT_URL="minio:9000"
|
||||
AWS_S3_SECURE_ACCESS=false
|
||||
|
||||
@@ -20,8 +20,14 @@ LLM_MODEL="albert-large"
|
||||
WEBHOOK_API_TOKEN="secret"
|
||||
WEBHOOK_URL="https://configure-your-url.com"
|
||||
|
||||
IS_RESOLVE_SPEAKER_IDENTITIES_ENABLED=true
|
||||
RESOLVE_SPEAKER_IDENTITIES_DEFAULT_OVERLAP=0.5
|
||||
RESOLVE_SPEAKER_ENABLE_SPLIT_ON_WORDS=true
|
||||
RESOLVE_SPEAKER_MAX_WORD_DURATION=1
|
||||
|
||||
POSTHOG_API_KEY="your-posthog-key"
|
||||
POSTHOG_ENABLED="False"
|
||||
|
||||
# Transcription
|
||||
TRANSCRIPTION_SATISFACTION_FORM_BASE_URL=
|
||||
AUTHORIZED_TENANTS='[{"id": "meet","api_key": "password","webhook_url": "https://configure-your-url.com/api/v1.0/recordings/external-process-hook/","webhook_api_key": "webhook-password","allowed_push_to_docs": true}]'
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
publiccodeYmlVersion: 0.5.0
|
||||
name: LaSuite Meet
|
||||
applicationSuite: LaSuite
|
||||
url: https://github.com/suitenumerique/meet
|
||||
releaseDate: 2026-07-22
|
||||
platforms:
|
||||
- web
|
||||
organisation:
|
||||
name: DINUM
|
||||
uri: https://numerique.gouv.fr
|
||||
fundedBy:
|
||||
- name: Direction interministérielle du numérique (DINUM)
|
||||
uri: https://www.numerique.gouv.fr
|
||||
developmentStatus: stable
|
||||
softwareType: standalone/web
|
||||
intendedAudience:
|
||||
countries:
|
||||
- FR
|
||||
description:
|
||||
en:
|
||||
localisedName: LaSuite Meet
|
||||
shortDescription: "Open Source video conference solution, based on LiveKit"
|
||||
longDescription: "Open Source video conference application, based on LiveKit,
|
||||
Django and React. It is the official web video conference application of
|
||||
French Ministries."
|
||||
features:
|
||||
- Optimized for stability in large meetings (+100 p.)
|
||||
- Support for multiple screen sharing streams
|
||||
- Non-persistent, secure chat
|
||||
- Meeting recording
|
||||
- Meeting transcription & Summary
|
||||
- Telephony integration
|
||||
- Secure participation with robust authentication and access control
|
||||
- Customizable frontend style
|
||||
legal:
|
||||
license: MIT
|
||||
maintenance:
|
||||
type: internal
|
||||
contacts:
|
||||
- name: "Samuel Paccoud"
|
||||
email: samuel.paccoud@numerique.gouv.fr
|
||||
affiliation: DINUM
|
||||
- name: "Antoine Lebaud"
|
||||
email: antoine.lebaud.ext@numerique.gouv.fr
|
||||
affiliation: DINUM
|
||||
localisation:
|
||||
localisationReady: true
|
||||
availableLanguages:
|
||||
- fr
|
||||
- de
|
||||
- en
|
||||
- nl
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:mailappor="http://schemas.microsoft.com/office/mailappversionoverrides/1.0" xsi:type="MailApp">
|
||||
<Id>a025f0f6-757a-4790-97f3-99c66c4a5795</Id>
|
||||
<Version>0.0.2.0</Version>
|
||||
<Version>1.0.0.0</Version>
|
||||
<ProviderName>__APP_NAME__</ProviderName>
|
||||
<DefaultLocale>fr-FR</DefaultLocale>
|
||||
<DisplayName DefaultValue="__APP_NAME__"/>
|
||||
@@ -205,5 +205,174 @@
|
||||
</bt:String>
|
||||
</bt:LongStrings>
|
||||
</Resources>
|
||||
|
||||
<!-- ─── V1.1 override: required for shared folder / delegate support ─── -->
|
||||
<VersionOverrides xmlns="http://schemas.microsoft.com/office/mailappversionoverrides/1.1" xsi:type="VersionOverridesV1_1">
|
||||
<Requirements>
|
||||
<bt:Sets DefaultMinVersion="1.8">
|
||||
<bt:Set Name="Mailbox"/>
|
||||
</bt:Sets>
|
||||
</Requirements>
|
||||
<Hosts>
|
||||
<Host xsi:type="MailHost">
|
||||
<DesktopFormFactor>
|
||||
<FunctionFile resid="Commands.Url"/>
|
||||
<SupportsSharedFolders>true</SupportsSharedFolders>
|
||||
|
||||
<!-- ─── Mail: Read ─────────────────────────────────────────── -->
|
||||
<ExtensionPoint xsi:type="MessageReadCommandSurface">
|
||||
<OfficeTab id="TabDefault">
|
||||
<Group id="msgReadGroup">
|
||||
<Label resid="GroupLabel"/>
|
||||
<Control xsi:type="Button" id="msgReadOpenPaneButton">
|
||||
<Label resid="TaskpaneButton.Label"/>
|
||||
<Supertip>
|
||||
<Title resid="TaskpaneButton.Label"/>
|
||||
<Description resid="TaskpaneButton.Tooltip"/>
|
||||
</Supertip>
|
||||
<Icon>
|
||||
<bt:Image size="16" resid="Icon.16x16"/>
|
||||
<bt:Image size="32" resid="Icon.32x32"/>
|
||||
<bt:Image size="80" resid="Icon.80x80"/>
|
||||
</Icon>
|
||||
<Action xsi:type="ShowTaskpane">
|
||||
<SourceLocation resid="Taskpane.Url"/>
|
||||
</Action>
|
||||
</Control>
|
||||
</Group>
|
||||
</OfficeTab>
|
||||
</ExtensionPoint>
|
||||
|
||||
<!-- ─── Mail: Compose ─────────────────────────────────────── -->
|
||||
<ExtensionPoint xsi:type="MessageComposeCommandSurface">
|
||||
<OfficeTab id="TabDefault">
|
||||
<Group id="msgComposeGroup">
|
||||
<Label resid="GroupLabel"/>
|
||||
<Control xsi:type="Button" id="msgComposeGenerateLinkButton">
|
||||
<Label resid="GenerateLink.Label"/>
|
||||
<Supertip>
|
||||
<Title resid="GenerateLink.Label"/>
|
||||
<Description resid="GenerateLink.Tooltip"/>
|
||||
</Supertip>
|
||||
<Icon>
|
||||
<bt:Image size="16" resid="Icon.16x16"/>
|
||||
<bt:Image size="32" resid="Icon.32x32"/>
|
||||
<bt:Image size="80" resid="Icon.80x80"/>
|
||||
</Icon>
|
||||
<Action xsi:type="ExecuteFunction">
|
||||
<FunctionName>generateMeetingLinkFromMail</FunctionName>
|
||||
</Action>
|
||||
</Control>
|
||||
<Control xsi:type="Button" id="msgComposeOpenPaneButton">
|
||||
<Label resid="TaskpaneButton.Label"/>
|
||||
<Supertip>
|
||||
<Title resid="TaskpaneButton.Label"/>
|
||||
<Description resid="TaskpaneButton.Tooltip"/>
|
||||
</Supertip>
|
||||
<Icon>
|
||||
<bt:Image size="16" resid="Settings.16x16"/>
|
||||
<bt:Image size="32" resid="Settings.32x32"/>
|
||||
<bt:Image size="80" resid="Settings.80x80"/>
|
||||
</Icon>
|
||||
<Action xsi:type="ShowTaskpane">
|
||||
<SourceLocation resid="Taskpane.Url"/>
|
||||
</Action>
|
||||
</Control>
|
||||
</Group>
|
||||
</OfficeTab>
|
||||
</ExtensionPoint>
|
||||
|
||||
<!-- ─── Calendar: Compose (New/Edit appointment) ──────────── -->
|
||||
<ExtensionPoint xsi:type="AppointmentOrganizerCommandSurface">
|
||||
<OfficeTab id="TabDefault">
|
||||
<Group id="apptComposeGroup">
|
||||
<Label resid="GroupLabel"/>
|
||||
<Control xsi:type="Button" id="apptGenerateLinkButton">
|
||||
<Label resid="GenerateLink.Label"/>
|
||||
<Supertip>
|
||||
<Title resid="GenerateLink.Label"/>
|
||||
<Description resid="GenerateLink.Tooltip"/>
|
||||
</Supertip>
|
||||
<Icon>
|
||||
<bt:Image size="16" resid="Icon.16x16"/>
|
||||
<bt:Image size="32" resid="Icon.32x32"/>
|
||||
<bt:Image size="80" resid="Icon.80x80"/>
|
||||
</Icon>
|
||||
<Action xsi:type="ExecuteFunction">
|
||||
<FunctionName>generateMeetingLinkFromCalendar</FunctionName>
|
||||
</Action>
|
||||
</Control>
|
||||
|
||||
<Control xsi:type="Button" id="apptOpenSettingsButton">
|
||||
<Label resid="OpenSettings.Label"/>
|
||||
<Supertip>
|
||||
<Title resid="OpenSettings.Label"/>
|
||||
<Description resid="OpenSettings.Tooltip"/>
|
||||
</Supertip>
|
||||
<Icon>
|
||||
<bt:Image size="16" resid="Settings.16x16"/>
|
||||
<bt:Image size="32" resid="Settings.32x32"/>
|
||||
<bt:Image size="80" resid="Settings.80x80"/>
|
||||
</Icon>
|
||||
<Action xsi:type="ShowTaskpane">
|
||||
<SourceLocation resid="Taskpane.Url"/>
|
||||
</Action>
|
||||
</Control>
|
||||
|
||||
</Group>
|
||||
</OfficeTab>
|
||||
</ExtensionPoint>
|
||||
|
||||
</DesktopFormFactor>
|
||||
</Host>
|
||||
</Hosts>
|
||||
<Resources>
|
||||
<bt:Images>
|
||||
<bt:Image id="Settings.16x16" DefaultValue="https://localhost:3000/addons/outlook/assets/settings-16.png"/>
|
||||
<bt:Image id="Settings.32x32" DefaultValue="https://localhost:3000/addons/outlook/assets/settings-32.png"/>
|
||||
<bt:Image id="Settings.80x80" DefaultValue="https://localhost:3000/addons/outlook/assets/settings-80.png"/>
|
||||
<bt:Image id="Add.16x16" DefaultValue="https://localhost:3000/addons/outlook/assets/add-16.png"/>
|
||||
<bt:Image id="Add.32x32" DefaultValue="https://localhost:3000/addons/outlook/assets/add-32.png"/>
|
||||
<bt:Image id="Add.80x80" DefaultValue="https://localhost:3000/addons/outlook/assets/add-80.png"/>
|
||||
<bt:Image id="Icon.16x16" DefaultValue="https://localhost:3000/addons/outlook/assets/icon-16.png"/>
|
||||
<bt:Image id="Icon.32x32" DefaultValue="https://localhost:3000/addons/outlook/assets/icon-32.png"/>
|
||||
<bt:Image id="Icon.80x80" DefaultValue="https://localhost:3000/addons/outlook/assets/icon-80.png"/>
|
||||
</bt:Images>
|
||||
<bt:Urls>
|
||||
<bt:Url id="Commands.Url" DefaultValue="https://localhost:3000/addons/outlook/commands.html"/>
|
||||
<bt:Url id="Taskpane.Url" DefaultValue="https://localhost:3000/addons/outlook/taskpane.html"/>
|
||||
</bt:Urls>
|
||||
<bt:ShortStrings>
|
||||
<!-- Default (French) -->
|
||||
<bt:String id="GroupLabel" DefaultValue="__APP_NAME__"/>
|
||||
<bt:String id="GenerateLink.Label" DefaultValue="Ajouter un lien __APP_NAME__">
|
||||
<bt:Override Locale="en-US" Value="Add a __APP_NAME__ link"/>
|
||||
<bt:Override Locale="de-DE" Value="__APP_NAME__-Link hinzufügen"/>
|
||||
</bt:String>
|
||||
<bt:String id="TaskpaneButton.Label" DefaultValue="Ouvrir les paramètres">
|
||||
<bt:Override Locale="en-US" Value="Open settings"/>
|
||||
<bt:Override Locale="de-DE" Value="Einstellungen öffnen"/>
|
||||
</bt:String>
|
||||
<bt:String id="OpenSettings.Label" DefaultValue="Paramètres">
|
||||
<bt:Override Locale="en-US" Value="Settings"/>
|
||||
<bt:Override Locale="de-DE" Value="Einstellungen"/>
|
||||
</bt:String>
|
||||
</bt:ShortStrings>
|
||||
<bt:LongStrings>
|
||||
<bt:String id="GenerateLink.Tooltip" DefaultValue="Génère un lien de réunion __APP_NAME__ et l'insère dans l'événement.">
|
||||
<bt:Override Locale="de-DE" Value="Generiert einen __APP_NAME__-Besprechungslink und fügt ihn in den Termin ein."/>
|
||||
<bt:Override Locale="en-US" Value="Generates a __APP_NAME__ meeting link and inserts it into the item."/>
|
||||
</bt:String>
|
||||
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__.">
|
||||
<bt:Override Locale="de-DE" Value="Öffnet die __APP_NAME__-Verbindungseinstellungen."/>
|
||||
<bt:Override Locale="en-US" Value="Opens the __APP_NAME__ connection settings."/>
|
||||
</bt:String>
|
||||
<bt:String id="OpenSettings.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__.">
|
||||
<bt:Override Locale="de-DE" Value="Öffnet die __APP_NAME__-Verbindungseinstellungen."/>
|
||||
<bt:Override Locale="en-US" Value="Opens the __APP_NAME__ connection settings."/>
|
||||
</bt:String>
|
||||
</bt:LongStrings>
|
||||
</Resources>
|
||||
</VersionOverrides>
|
||||
</VersionOverrides>
|
||||
</OfficeApp>
|
||||
|
||||
Generated
+4
-4
@@ -10,7 +10,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "3.49.0",
|
||||
"i18next": "26.3.1",
|
||||
"i18next": "^26.3.4",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"regenerator-runtime": "0.14.1"
|
||||
},
|
||||
@@ -9364,9 +9364,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
|
||||
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
|
||||
"version": "26.3.4",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.4.tgz",
|
||||
"integrity": "sha512-pa7m0d7pBDqGHZxljT+WPFeyFgQ7P7SciPPo1tTqYuO0z4sqADYhwnBESmmGp/wEof1inwdls/k8ZgTg8rxFHA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"core-js": "3.49.0",
|
||||
"i18next": "26.3.1",
|
||||
"i18next": "26.3.4",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"regenerator-runtime": "0.14.1"
|
||||
},
|
||||
|
||||
@@ -57,8 +57,7 @@
|
||||
data-i18n="footer.feedback"
|
||||
></a>
|
||||
<div id="footer-right">
|
||||
<span class="version-badge">beta</span>
|
||||
<span class="version-number">0.0.2</span>
|
||||
<span class="version-number">1.0.0</span>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM python:3.13.13-slim AS base
|
||||
FROM python:3.14.6-slim AS base
|
||||
|
||||
# Install system dependencies required by LiveKit
|
||||
RUN apt-get update && apt-get install -y \
|
||||
|
||||
@@ -32,6 +32,8 @@ from minio import Minio
|
||||
from minio.error import S3Error
|
||||
|
||||
from exceptions import MissingConfigError
|
||||
from observability import configure_sentry, set_job_context
|
||||
from tasks import done_callback
|
||||
|
||||
load_dotenv()
|
||||
|
||||
@@ -42,6 +44,7 @@ AGENT_NAME = os.getenv("METADATA_COLLECTOR_AGENT_NAME", "metadata-collector")
|
||||
|
||||
def prewarm(proc: JobProcess):
|
||||
"""Preload voice activity detection model."""
|
||||
configure_sentry(AGENT_NAME)
|
||||
proc.userdata["vad"] = silero.VAD.load()
|
||||
|
||||
|
||||
@@ -174,7 +177,13 @@ class MetadataCollector:
|
||||
self.on_chat_message_received(reader, participant_identity)
|
||||
)
|
||||
self._tasks.add(task)
|
||||
task.add_done_callback(lambda _: self._tasks.remove(task))
|
||||
task.add_done_callback(
|
||||
done_callback(
|
||||
logger,
|
||||
self._tasks,
|
||||
f"process chat stream from {participant_identity}",
|
||||
)
|
||||
)
|
||||
|
||||
def save(self):
|
||||
"""Serialize collected events and upload as JSON to S3."""
|
||||
@@ -270,16 +279,18 @@ class MetadataCollector:
|
||||
logger.info("Participant disconnected: %s", participant.identity)
|
||||
task = asyncio.create_task(self._close_session(session))
|
||||
self._tasks.add(task)
|
||||
|
||||
def on_close_done(_):
|
||||
self._tasks.discard(task)
|
||||
logger.info(
|
||||
"VAD session closed for %s (remaining sessions: %d)",
|
||||
participant.identity,
|
||||
len(self._sessions),
|
||||
task.add_done_callback(
|
||||
done_callback(
|
||||
logger,
|
||||
self._tasks,
|
||||
f"close VAD session for {participant.identity}",
|
||||
on_success=lambda _: logger.info(
|
||||
"VAD session closed for %s (remaining sessions: %d)",
|
||||
participant.identity,
|
||||
len(self._sessions),
|
||||
),
|
||||
)
|
||||
|
||||
task.add_done_callback(on_close_done)
|
||||
)
|
||||
|
||||
def on_participant_name_changed(self, participant: rtc.RemoteParticipant):
|
||||
"""Update stored participant name when it changes."""
|
||||
@@ -360,6 +371,8 @@ async def handle_job_request(job_req: JobRequest) -> None:
|
||||
@server.rtc_session(agent_name=AGENT_NAME, on_request=handle_job_request)
|
||||
async def entrypoint(ctx: JobContext):
|
||||
"""Initialize and run the metadata collector."""
|
||||
set_job_context(room=ctx.room.name, job_id=ctx.job.id)
|
||||
|
||||
logger.info("Starting metadata agent in room: %s", ctx.room.name)
|
||||
recording_id = ctx.job.metadata
|
||||
metadata_collector = MetadataCollector(ctx, recording_id)
|
||||
@@ -377,4 +390,7 @@ async def entrypoint(ctx: JobContext):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Initialize Sentry for the worker process. Each job runs in its own
|
||||
# (forked) process and re-initializes Sentry via prewarm().
|
||||
configure_sentry(AGENT_NAME)
|
||||
cli.run_app(server)
|
||||
|
||||
@@ -25,6 +25,9 @@ from livekit.agents import (
|
||||
)
|
||||
from livekit.plugins import deepgram, silero
|
||||
|
||||
from observability import configure_sentry, set_job_context
|
||||
from tasks import done_callback
|
||||
|
||||
load_dotenv()
|
||||
|
||||
logger = logging.getLogger("transcriber")
|
||||
@@ -99,24 +102,29 @@ class MultiUserTranscriber:
|
||||
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)
|
||||
task.add_done_callback(
|
||||
done_callback(
|
||||
logger,
|
||||
self._tasks,
|
||||
f"start transcription session for {participant.identity}",
|
||||
)
|
||||
)
|
||||
|
||||
def on_participant_disconnected(self, participant: rtc.RemoteParticipant):
|
||||
"""Handle participant disconnection by closing transcription session."""
|
||||
if (session := self._sessions.pop(participant.identity)) is None:
|
||||
if (session := self._sessions.pop(participant.identity, None)) 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))
|
||||
task.add_done_callback(
|
||||
done_callback(
|
||||
logger,
|
||||
self._tasks,
|
||||
f"close transcription session for {participant.identity}",
|
||||
)
|
||||
)
|
||||
|
||||
async def _start_session(self, participant: rtc.RemoteParticipant) -> AgentSession:
|
||||
"""Create and start transcription session for participant."""
|
||||
@@ -139,6 +147,7 @@ class MultiUserTranscriber:
|
||||
participant_identity=participant.identity,
|
||||
)
|
||||
)
|
||||
self._sessions[participant.identity] = session
|
||||
return session
|
||||
|
||||
async def _close_session(self, sess: AgentSession) -> None:
|
||||
@@ -149,6 +158,8 @@ class MultiUserTranscriber:
|
||||
|
||||
async def entrypoint(ctx: JobContext):
|
||||
"""Initialize and run the multi-user transcriber."""
|
||||
set_job_context(room=ctx.room.name, job_id=ctx.job.id)
|
||||
|
||||
transcriber = MultiUserTranscriber(ctx)
|
||||
transcriber.start()
|
||||
|
||||
@@ -193,11 +204,15 @@ async def handle_transcriber_job_request(job_req: JobRequest) -> None:
|
||||
|
||||
def prewarm(proc: JobProcess):
|
||||
"""Preload voice activity detection model."""
|
||||
configure_sentry(TRANSCRIBER_AGENT_NAME)
|
||||
if ENABLE_SILERO_VAD:
|
||||
proc.userdata["vad"] = silero.VAD.load()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Initialize Sentry for the worker process. Each job runs in its own
|
||||
# (forked) process and re-initializes Sentry via prewarm().
|
||||
configure_sentry(TRANSCRIBER_AGENT_NAME)
|
||||
cli.run_app(
|
||||
WorkerOptions(
|
||||
entrypoint_fnc=entrypoint,
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Sentry helpers for the LiveKit agents."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tomllib
|
||||
from os import path
|
||||
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.logging import LoggingIntegration
|
||||
|
||||
logger = logging.getLogger("observability")
|
||||
|
||||
BASE_DIR = path.dirname(path.abspath(__file__))
|
||||
|
||||
|
||||
def get_release():
|
||||
"""Get the current release of the application.
|
||||
|
||||
By release, we mean the ``version`` declared in ``pyproject.toml``.
|
||||
If the file cannot be read or declares no version, it defaults to "NA".
|
||||
"""
|
||||
try:
|
||||
with open(path.join(BASE_DIR, "pyproject.toml"), "rb") as pyproject:
|
||||
return tomllib.load(pyproject)["project"]["version"]
|
||||
except (FileNotFoundError, KeyError, tomllib.TOMLDecodeError):
|
||||
return "NA" # Default: not available
|
||||
|
||||
|
||||
def configure_sentry(agent_name: str) -> None:
|
||||
"""Initialize Sentry for the current agent process.
|
||||
|
||||
No-op if ``SENTRY_DSN`` is not configured. Otherwise (re)initializes Sentry
|
||||
unconditionally so the calling process gets its own live transport.
|
||||
|
||||
Must be called once per process: in the worker entrypoint and again in the
|
||||
per-job ``prewarm``/``setup_fnc`` hook, because LiveKit runs each job in a
|
||||
forked process. A forked child inherits the parent's initialized Sentry
|
||||
client but not its background transport thread (threads do not survive
|
||||
``fork()``), so it must re-init to get a working transport. For that reason,
|
||||
do NOT guard this with ``sentry_sdk.is_initialized()``: the child inherits it
|
||||
as ``True`` and would skip init, silently dropping every event.
|
||||
|
||||
Args:
|
||||
agent_name: Identifier of the agent, attached as a tag to Sentry issues
|
||||
"""
|
||||
# Read the DSN at call time so it picks up variables that load_dotenv()
|
||||
# populated after this module was first imported.
|
||||
sentry_dsn = os.getenv("SENTRY_DSN")
|
||||
if not sentry_dsn:
|
||||
logger.debug("SENTRY_DSN not defined for agent '%s'", agent_name)
|
||||
return
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn=sentry_dsn,
|
||||
environment=os.getenv("SENTRY_ENVIRONMENT"),
|
||||
release=get_release(),
|
||||
debug=False,
|
||||
integrations=[
|
||||
# Capture log records emitted at ERROR and above as Sentry events.
|
||||
# This covers the agents' explicit logger.exception(...) calls as
|
||||
# well as asyncio's "Exception in callback" / "Task exception was
|
||||
# never retrieved" records, so unhandled task failures surface too.
|
||||
LoggingIntegration(level=logging.INFO, event_level=logging.ERROR),
|
||||
],
|
||||
)
|
||||
sentry_sdk.set_tag("application", "agents")
|
||||
sentry_sdk.set_tag("agent", agent_name)
|
||||
|
||||
logger.info("Sentry initialized for agent '%s' (pid %d)", agent_name, os.getpid())
|
||||
|
||||
|
||||
def set_job_context(*, room: str | None = None, job_id: str | None = None) -> None:
|
||||
"""Tag the current Sentry scope with the LiveKit job being handled.
|
||||
|
||||
Args:
|
||||
room: Name of the room the job is serving.
|
||||
job_id: LiveKit job identifier.
|
||||
"""
|
||||
scope = sentry_sdk.get_current_scope()
|
||||
if room is not None:
|
||||
scope.set_tag("room", room)
|
||||
if job_id is not None:
|
||||
scope.set_tag("job_id", job_id)
|
||||
@@ -1,21 +1,22 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.21.0"
|
||||
version = "1.24.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.5.13",
|
||||
"livekit-plugins-deepgram==1.5.13",
|
||||
"livekit-plugins-silero==1.5.13",
|
||||
"livekit-agents==1.6.4",
|
||||
"livekit-plugins-deepgram==1.6.4",
|
||||
"livekit-plugins-silero==1.6.4",
|
||||
"livekit-plugins-kyutai-lasuite==0.0.6",
|
||||
"python-dotenv==1.2.2",
|
||||
"protobuf==6.33.6",
|
||||
"minio==7.2.20"
|
||||
"minio==7.2.20",
|
||||
"sentry-sdk==2.60.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"ruff==0.15.14",
|
||||
"ruff==0.15.19",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Helpers for managing asyncio tasks."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
|
||||
def done_callback(
|
||||
logger: logging.Logger,
|
||||
tasks: set[asyncio.Task],
|
||||
description: str,
|
||||
*,
|
||||
on_success: Callable[[Any], None] | None = None,
|
||||
) -> Callable[[asyncio.Task], None]:
|
||||
"""Build a done-callback for a background task.
|
||||
|
||||
Meant to be passed to `asyncio.Task.add_done_callback`.
|
||||
|
||||
Args:
|
||||
logger: Logger used to report failures, so records keep the caller's
|
||||
logger name.
|
||||
tasks: Set the task was registered in; the task is discarded from it.
|
||||
description: Human-readable intended action
|
||||
on_success: Optional callback invoked with the task's result when it
|
||||
completes without error.
|
||||
|
||||
Returns:
|
||||
A callback suitable for ``task.add_done_callback(...)``.
|
||||
"""
|
||||
|
||||
def _finalize(task: asyncio.Task) -> None:
|
||||
tasks.discard(task)
|
||||
if task.cancelled():
|
||||
return
|
||||
if (exc := task.exception()) is not None:
|
||||
logger.exception("failed to %s", description, exc_info=exc)
|
||||
return
|
||||
if on_success is not None:
|
||||
on_success(task.result())
|
||||
|
||||
return _finalize
|
||||
Generated
+936
-832
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ from core.recording.event import notification
|
||||
|
||||
from . import models
|
||||
from .tasks.file import process_file_deletion
|
||||
from .utils import generate_download_file_url
|
||||
from .utils import generate_download_s3_url
|
||||
|
||||
|
||||
def hard_delete_file(file):
|
||||
@@ -242,7 +242,7 @@ class FileAdmin(admin.ModelAdmin):
|
||||
"""Return a clickable preview URL for the file."""
|
||||
if not obj.is_ready:
|
||||
return "-"
|
||||
url = generate_download_file_url(obj, expires_in=60 * 60)
|
||||
url = generate_download_s3_url(obj.key, expires_in=60 * 60)
|
||||
|
||||
return format_html(
|
||||
'<a href="{}" target="_blank" rel="noopener noreferrer">Open File</a>', url
|
||||
@@ -402,7 +402,14 @@ class RecordingAdmin(admin.ModelAdmin):
|
||||
"""Recording admin interface declaration."""
|
||||
|
||||
inlines = (RecordingAccessInline,)
|
||||
search_fields = ["status", "=id", "worker_id", "room__slug", "=room__id"]
|
||||
search_fields = [
|
||||
"status",
|
||||
"=id",
|
||||
"worker_id",
|
||||
"room__slug",
|
||||
"=room__id",
|
||||
"accesses__user__email",
|
||||
]
|
||||
list_display = (
|
||||
"id",
|
||||
"status",
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Pluggable analytics.
|
||||
|
||||
Usage anywhere in the codebase:
|
||||
|
||||
from core import analytics
|
||||
|
||||
analytics.capture(request.user, "room_created", {"room_id": str(room.pk)})
|
||||
|
||||
The concrete backend is resolved lazily from Django settings, so swapping
|
||||
PostHog for anything else is a configuration change, not a code change.
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
from .base import AnalyticsBackend, NoOpAnalytics
|
||||
from .events import AnalyticsEvent
|
||||
from .user_feature_flags import UserFeatureFlag
|
||||
|
||||
__all__ = [
|
||||
"get_analytics",
|
||||
"identify",
|
||||
"capture",
|
||||
"AnalyticsBackend",
|
||||
"AnalyticsEvent",
|
||||
"is_user_feature_flag_enabled",
|
||||
"UserFeatureFlag",
|
||||
]
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_analytics() -> AnalyticsBackend:
|
||||
"""Instantiate the configured backend once per process."""
|
||||
dotted_path = getattr(settings, "ANALYTICS_BACKEND", None)
|
||||
options = getattr(settings, "ANALYTICS_BACKEND_SETTINGS", {}) or {}
|
||||
|
||||
if not dotted_path:
|
||||
return NoOpAnalytics()
|
||||
|
||||
backend_class = import_string(dotted_path)
|
||||
return backend_class(**options)
|
||||
|
||||
|
||||
# Convenience module-level shortcuts
|
||||
|
||||
analytics_instance = get_analytics()
|
||||
|
||||
|
||||
def identify(user, properties: dict[str, Any] | None = None) -> None:
|
||||
"""Associate traits with an identified user."""
|
||||
analytics_instance.identify(user, properties)
|
||||
|
||||
|
||||
def capture(
|
||||
user, event: AnalyticsEvent, properties: dict[str, Any] | None = None
|
||||
) -> None:
|
||||
"""Record an event performed by an identified user."""
|
||||
analytics_instance.capture(user, event, properties)
|
||||
|
||||
|
||||
def is_user_feature_flag_enabled(user, feature_name: UserFeatureFlag) -> bool:
|
||||
"""Check if a feature is enabled at the user level."""
|
||||
return analytics_instance.is_user_feature_enabled(user, feature_name)
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Analytics backend protocol and default no-op implementation."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Mapping
|
||||
|
||||
from ..models import User
|
||||
from .events import AnalyticsEvent
|
||||
from .user_feature_flags import UserFeatureFlag
|
||||
|
||||
|
||||
class AnalyticsBackend(ABC):
|
||||
"""
|
||||
Interface every analytics backend must implement.
|
||||
|
||||
Backends are instantiated once (singleton) with the kwargs declared in
|
||||
settings.ANALYTICS_BACKEND_SETTINGS, e.g.:
|
||||
|
||||
ANALYTICS_BACKEND = "core.analytics.posthog.PostHogAnalytics"
|
||||
ANALYTICS_BACKEND_SETTINGS = {"api_key": "...", "host": "..."}
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def identify(self, user: User, properties: dict[str, Any] | None = None) -> None:
|
||||
"""Associate traits (email, name, ...) with an identified user."""
|
||||
|
||||
@abstractmethod
|
||||
def capture(
|
||||
self,
|
||||
user: User,
|
||||
event: AnalyticsEvent,
|
||||
properties: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Record an event performed by an identified user."""
|
||||
|
||||
@abstractmethod
|
||||
def shutdown(self) -> None:
|
||||
"""Flush pending events. Called on process exit."""
|
||||
|
||||
def get_user_feature_flags(
|
||||
self,
|
||||
user: User, # pylint: disable=unused-argument
|
||||
) -> Mapping[UserFeatureFlag, bool | str | None]:
|
||||
"""Return a dict of feature flags for the given user."""
|
||||
# We return an empty dict here by default to avoid a breaking change
|
||||
# By making this method abstract.
|
||||
return {}
|
||||
|
||||
def is_user_feature_enabled(
|
||||
self, user: User, feature_name: UserFeatureFlag
|
||||
) -> bool:
|
||||
"""Check if a feature is enabled at the user level."""
|
||||
return self.get_user_feature_flags(user).get(feature_name, False) is True
|
||||
|
||||
|
||||
class NoOpAnalytics(AnalyticsBackend):
|
||||
"""Default backend: silently discards everything."""
|
||||
|
||||
def identify(self, user: User, properties=None) -> None:
|
||||
"""No-op: discards identify calls."""
|
||||
|
||||
def capture(self, user, event, properties=None) -> None:
|
||||
"""No-op: discards captured events."""
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""No-op: nothing to flush."""
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Catalog of all analytics events emitted by the backend."""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class AnalyticsEvent(StrEnum):
|
||||
"""All trackable events. Values are the wire names sent to the provider."""
|
||||
|
||||
# Rooms
|
||||
ROOM_CREATED = "room_created"
|
||||
@@ -0,0 +1,116 @@
|
||||
"""PostHog implementation of the analytics backend protocol."""
|
||||
|
||||
import logging
|
||||
from typing import Any, Mapping
|
||||
|
||||
from django.core.cache import cache
|
||||
|
||||
from posthog import Posthog
|
||||
|
||||
from ..models import User
|
||||
from .base import AnalyticsBackend
|
||||
from .events import AnalyticsEvent
|
||||
from .user_feature_flags import UserFeatureFlag
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PostHogAnalytics(AnalyticsBackend):
|
||||
"""Send events to PostHog, keyed on the user's primary key (UUID)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
api_key: str,
|
||||
host: str = "https://eu.i.posthog.com",
|
||||
feature_flags_cache_ttl: int = 60,
|
||||
feature_flags_cache_prefix: str = "user_feature_flags:",
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
|
||||
# The SDK batches and sends in a background thread by default,
|
||||
# so calls below never block the request/response cycle.
|
||||
self._client = Posthog(
|
||||
project_api_key=api_key,
|
||||
host=host,
|
||||
**kwargs,
|
||||
)
|
||||
self._feature_flags_cache_ttl = feature_flags_cache_ttl
|
||||
self._feature_flags_cache_prefix = feature_flags_cache_prefix
|
||||
|
||||
@staticmethod
|
||||
def _distinct_id(user: User) -> str | None:
|
||||
"""Return the PostHog distinct_id for a user, or None if anonymous."""
|
||||
if user is None or not getattr(user, "is_authenticated", False):
|
||||
return None
|
||||
return str(user.pk)
|
||||
|
||||
def identify(self, user: User, properties: dict[str, Any] | None = None) -> None:
|
||||
"""Associate traits (email, name, ...) with an identified user."""
|
||||
distinct_id = self._distinct_id(user)
|
||||
if distinct_id is None:
|
||||
return
|
||||
try:
|
||||
self._client.set(
|
||||
distinct_id=distinct_id,
|
||||
properties=properties or {},
|
||||
)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
logger.exception("PostHog identify failed")
|
||||
|
||||
def capture(
|
||||
self,
|
||||
user: User,
|
||||
event: AnalyticsEvent,
|
||||
properties: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Record an event performed by an identified user."""
|
||||
distinct_id = self._distinct_id(user)
|
||||
if distinct_id is None:
|
||||
return
|
||||
try:
|
||||
self._client.capture(
|
||||
distinct_id=distinct_id,
|
||||
event=str(event),
|
||||
properties=properties or {},
|
||||
)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
logger.exception("PostHog capture failed for event %s", event)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""Flush pending events. Called on process exit."""
|
||||
self._client.shutdown()
|
||||
|
||||
def _fetch_user_feature_flags(
|
||||
self, user: User
|
||||
) -> Mapping[UserFeatureFlag, bool | str | None]:
|
||||
"""Compute feature flags for a user."""
|
||||
|
||||
distinct_id = self._distinct_id(user)
|
||||
if distinct_id is None:
|
||||
return {}
|
||||
|
||||
flags = self._client.evaluate_flags(distinct_id)
|
||||
out: dict[UserFeatureFlag, bool | str | None] = {}
|
||||
for flag_key in UserFeatureFlag:
|
||||
out[flag_key] = flags.get_flag(flag_key.value)
|
||||
|
||||
return out
|
||||
|
||||
def get_user_feature_flags(
|
||||
self, user: User
|
||||
) -> Mapping[UserFeatureFlag, bool | str | None]:
|
||||
"""Get feature flags for a user. Caches the result for a short time."""
|
||||
distinct_id = self._distinct_id(user)
|
||||
if distinct_id is None:
|
||||
return {}
|
||||
|
||||
try:
|
||||
return cache.get_or_set(
|
||||
f"{self._feature_flags_cache_prefix}{distinct_id}",
|
||||
default=lambda: self._fetch_user_feature_flags(user),
|
||||
timeout=self._feature_flags_cache_ttl,
|
||||
)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
logger.exception("Failed to get feature flags for user %s", user.pk)
|
||||
return {}
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Catalog of all analytics feature flags used by the backend."""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class UserFeatureFlag(StrEnum):
|
||||
"""All feature flags configured in the app."""
|
||||
|
||||
TRANSCRIPT_SUMMARY_ENABLED = "summary-enabled"
|
||||
@@ -68,6 +68,9 @@ def get_frontend_configuration(request):
|
||||
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
|
||||
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
||||
},
|
||||
"authenticated_users_can_edit_display_name": (
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
|
||||
),
|
||||
}
|
||||
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
||||
return Response(frontend_configuration)
|
||||
|
||||
@@ -6,6 +6,11 @@ from django.http import Http404
|
||||
from rest_framework import permissions
|
||||
|
||||
from ..models import RoleChoices
|
||||
from ..services.participants_management import (
|
||||
ParticipantNotFoundException,
|
||||
ParticipantsManagement,
|
||||
ParticipantsManagementException,
|
||||
)
|
||||
|
||||
ACTION_FOR_METHOD_TO_PERMISSION = {
|
||||
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
|
||||
@@ -166,3 +171,30 @@ class CanMuteParticipant(permissions.BasePermission):
|
||||
|
||||
# LiveKit token scoped to this room
|
||||
return request.auth.video.room == str(obj.id)
|
||||
|
||||
|
||||
class IsPresentInMeeting(permissions.BasePermission):
|
||||
"""Check that the requesting user is currently connected to the meeting.
|
||||
|
||||
The requester must be session-authenticated (their DB identity is needed
|
||||
to check privileges); presence is verified against LiveKit using their
|
||||
`sub` as participant identity. Fails closed on LiveKit errors.
|
||||
"""
|
||||
|
||||
message = "You must be connected to the meeting to perform this action."
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Verify the requester's identity is a participant of the room."""
|
||||
user = request.user
|
||||
|
||||
if not user or not user.is_authenticated:
|
||||
return False
|
||||
|
||||
try:
|
||||
return ParticipantsManagement().check_if_in_meeting(
|
||||
room_name=str(obj.pk), identity=str(user.sub)
|
||||
)
|
||||
except ParticipantNotFoundException:
|
||||
return False
|
||||
except ParticipantsManagementException:
|
||||
return False
|
||||
|
||||
@@ -183,13 +183,11 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
user=request.user,
|
||||
username=username,
|
||||
configuration=output["configuration"],
|
||||
is_admin_or_owner=is_admin_or_owner,
|
||||
role=role,
|
||||
)
|
||||
else:
|
||||
del output["pin_code"]
|
||||
|
||||
output["is_administrable"] = is_admin_or_owner
|
||||
|
||||
return output
|
||||
|
||||
|
||||
@@ -299,8 +297,8 @@ class RoomInviteSerializer(serializers.Serializer):
|
||||
class BaseParticipantsManagementSerializer(BaseValidationOnlySerializer):
|
||||
"""Base serializer for participant management operations."""
|
||||
|
||||
participant_identity = serializers.UUIDField(
|
||||
help_text="LiveKit participant identity (UUID format)"
|
||||
participant_identity = serializers.CharField(
|
||||
help_text="LiveKit participant identity (matching the user's sub format)"
|
||||
)
|
||||
|
||||
|
||||
@@ -312,6 +310,15 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
|
||||
)
|
||||
|
||||
|
||||
class ParticipantRoleSerializer(BaseParticipantsManagementSerializer):
|
||||
"""Validate an in-meeting role change (promotion/demotion) request."""
|
||||
|
||||
role = serializers.ChoiceField(
|
||||
choices=[models.RoleChoices.MEMBER, models.RoleChoices.ADMIN],
|
||||
help_text="Target role. Ownership cannot be granted this way.",
|
||||
)
|
||||
|
||||
|
||||
TrackSource = Literal["camera", "microphone", "screen_share", "screen_share_audio"]
|
||||
|
||||
|
||||
@@ -563,3 +570,13 @@ class RenameParticipantSerializer(BaseValidationOnlySerializer):
|
||||
"""Serializer for renaming a participant in a room."""
|
||||
|
||||
name = serializers.CharField(min_length=1, max_length=255, allow_blank=False)
|
||||
|
||||
|
||||
class ExternalProcessEventSerializer(BaseValidationOnlySerializer):
|
||||
"""Validate external process event data."""
|
||||
|
||||
job_id = serializers.CharField(required=True)
|
||||
# We are not strict on purpose on those fields to avoid
|
||||
# useless bad requests
|
||||
type = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||
status = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||
|
||||
@@ -35,23 +35,29 @@ from rest_framework import (
|
||||
)
|
||||
from rest_framework.settings import api_settings
|
||||
|
||||
from core import enums, models, utils
|
||||
from core import analytics, enums, models, utils
|
||||
from core.api.filters import ListFileFilter
|
||||
from core.enums import MEDIA_STORAGE_URL_PATTERN
|
||||
from core.recording.enums import FileExtension
|
||||
from core.recording.event.authentication import StorageEventAuthentication
|
||||
from core.recording.event.authentication import (
|
||||
RecordingProcessWebhookAuthentication,
|
||||
StorageEventAuthentication,
|
||||
)
|
||||
from core.recording.event.exceptions import (
|
||||
InvalidBucketError,
|
||||
InvalidFilepathError,
|
||||
InvalidFileTypeError,
|
||||
ParsingEventDataError,
|
||||
)
|
||||
from core.recording.event.notification import notification_service
|
||||
from core.recording.event.parsers import get_parser
|
||||
from core.recording.services.metadata_collector import (
|
||||
MetadataCollectorException,
|
||||
MetadataCollectorService,
|
||||
)
|
||||
from core.recording.services.recording_events import (
|
||||
RecordingEventsService,
|
||||
RecordingNotSavableError,
|
||||
)
|
||||
from core.recording.worker.exceptions import (
|
||||
RecordingStartError,
|
||||
RecordingStopError,
|
||||
@@ -82,10 +88,15 @@ from core.services.room_management import (
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
from core.services.room_roles import (
|
||||
RoomRoleError,
|
||||
RoomRoleService,
|
||||
)
|
||||
from core.services.subtitle import SubtitleException, SubtitleService
|
||||
from core.tasks.file import process_file_deletion
|
||||
|
||||
from ..authentication.livekit import LiveKitTokenAuthentication
|
||||
from ..models import RoomAccessLevel
|
||||
from . import permissions, serializers, throttling
|
||||
from .feature_flag import FeatureFlag
|
||||
|
||||
@@ -261,6 +272,9 @@ class RoomViewSet(
|
||||
username = request.query_params.get("username", None)
|
||||
data = {
|
||||
"id": None,
|
||||
"slug": slug,
|
||||
"is_administrable": False,
|
||||
"access_level": RoomAccessLevel.PUBLIC,
|
||||
"livekit": {
|
||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||
"room": slug,
|
||||
@@ -305,6 +319,16 @@ class RoomViewSet(
|
||||
if callback_id := self.request.data.get("callback_id"):
|
||||
RoomCreation().persist_callback_state(callback_id, room)
|
||||
|
||||
analytics.capture(
|
||||
self.request.user,
|
||||
analytics.AnalyticsEvent.ROOM_CREATED,
|
||||
{
|
||||
"room_id": str(room.pk),
|
||||
"access_level": room.access_level,
|
||||
"from_callback": bool(self.request.data.get("callback_id")),
|
||||
},
|
||||
)
|
||||
|
||||
def perform_update(self, serializer):
|
||||
"""Persist the room update, then sync metadata to LiveKit."""
|
||||
|
||||
@@ -340,6 +364,33 @@ class RoomViewSet(
|
||||
room.id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _park_drive_credentials(request, recording):
|
||||
"""Keep the OIDC access token needed to push the recording to Drive later.
|
||||
|
||||
Pushing happens long after this request, when the egress is over and the
|
||||
user is gone, so the token has to be parked now.
|
||||
|
||||
POC limitation: we assume the token is still valid by then. The target
|
||||
design is a token exchange (RFC 8693) performed here, to get a long-lived
|
||||
token narrowly scoped to that upload.
|
||||
"""
|
||||
|
||||
if not settings.RECORDING_PUSH_TO_DRIVE_ENABLED:
|
||||
return
|
||||
|
||||
access_token = request.session.get("oidc_access_token")
|
||||
|
||||
if not access_token:
|
||||
logger.warning(
|
||||
"No OIDC access token in session, recording %s will not be pushed "
|
||||
"to Drive. Is OIDC_STORE_ACCESS_TOKEN enabled?",
|
||||
recording.id,
|
||||
)
|
||||
return
|
||||
|
||||
recording.set_owner_access_token(access_token)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
@@ -375,6 +426,7 @@ class RoomViewSet(
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording=recording,
|
||||
)
|
||||
self._park_drive_credentials(request, recording)
|
||||
|
||||
except (DjangoValidationError, IntegrityError):
|
||||
# DjangoValidationError covers the Python-level check (full_clean);
|
||||
@@ -619,6 +671,53 @@ class RoomViewSet(
|
||||
status=drf_status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
url_path="update-participant-role",
|
||||
permission_classes=[
|
||||
permissions.HasPrivilegesOnRoom,
|
||||
permissions.IsPresentInMeeting,
|
||||
],
|
||||
)
|
||||
def update_participant_role(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Promote or demote a participant currently connected to the meeting.
|
||||
|
||||
Requires the requester to be session-authenticated, have privileges
|
||||
(admin/owner) on the room, and be connected to the meeting.
|
||||
|
||||
If the target participant has a user account, the role is persisted
|
||||
(`ResourceAccess`) then mirrored to their LiveKit attributes.
|
||||
|
||||
If the participant is anonymous, the promotion will fail.
|
||||
"""
|
||||
|
||||
room = self.get_object()
|
||||
|
||||
serializer = serializers.ParticipantRoleSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
participant_identity = serializer.validated_data["participant_identity"]
|
||||
role = serializer.validated_data["role"]
|
||||
|
||||
if str(request.user.sub) == str(participant_identity):
|
||||
return drf_response.Response(
|
||||
{"error": "You cannot change your own role."},
|
||||
status=drf_status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
try:
|
||||
result = RoomRoleService().set_participant_role(
|
||||
room=room,
|
||||
participant_identity=participant_identity,
|
||||
role=role,
|
||||
actor=request.user,
|
||||
)
|
||||
except RoomRoleError as e:
|
||||
return drf_response.Response({"error": str(e)}, status=e.status_code)
|
||||
|
||||
return drf_response.Response(result, status=drf_status.HTTP_200_OK)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
@@ -972,29 +1071,74 @@ class RecordingViewSet(
|
||||
except models.Recording.DoesNotExist as e:
|
||||
raise drf_exceptions.NotFound("No recording found for this event.") from e
|
||||
|
||||
if not recording.is_savable():
|
||||
# Save recording
|
||||
recording_events_service = RecordingEventsService()
|
||||
try:
|
||||
recording_events_service.handle_complete(recording)
|
||||
except RecordingNotSavableError:
|
||||
raise drf_exceptions.PermissionDenied(
|
||||
f"Recording with ID {recording_id} cannot be saved because it is either,"
|
||||
" in an error state or has already been saved."
|
||||
)
|
||||
|
||||
# Attempt to notify external services about the recording
|
||||
# This is a non-blocking operation - failures are logged but don't interrupt the flow
|
||||
notification_succeeded = notification_service.notify_external_services(
|
||||
recording
|
||||
)
|
||||
|
||||
recording.status = (
|
||||
models.RecordingStatusChoices.NOTIFICATION_SUCCEEDED
|
||||
if notification_succeeded
|
||||
else models.RecordingStatusChoices.SAVED
|
||||
)
|
||||
recording.save()
|
||||
) from None
|
||||
|
||||
return drf_response.Response(
|
||||
{"message": "Event processed."},
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["post"],
|
||||
url_path="external-process-hook",
|
||||
authentication_classes=[RecordingProcessWebhookAuthentication],
|
||||
serializer_class=serializers.ExternalProcessEventSerializer,
|
||||
)
|
||||
def on_external_process_event_received(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Handle incoming external process events for recordings."""
|
||||
logger.debug("Processing external process event %s", request.data)
|
||||
serializer = self.get_serializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
ok_response = drf_response.Response(
|
||||
{"message": "Event processed."},
|
||||
)
|
||||
|
||||
validated_data = serializer.validated_data
|
||||
job_id = validated_data["job_id"]
|
||||
try:
|
||||
recording = models.Recording.objects.get(external_process_id=job_id)
|
||||
except models.Recording.DoesNotExist as e:
|
||||
logger.warning("No recording found for job_id %s: %s", job_id, e)
|
||||
return ok_response
|
||||
|
||||
if validated_data.get("type") == "transcript":
|
||||
if validated_data.get("status") == "success":
|
||||
logger.info(
|
||||
"External process transcript success received for recording %s",
|
||||
job_id,
|
||||
)
|
||||
recording.status = (
|
||||
models.RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL
|
||||
)
|
||||
recording.save()
|
||||
return ok_response
|
||||
|
||||
if validated_data.get("status") == "failure":
|
||||
logger.info(
|
||||
"External process transcript failure received for recording %s",
|
||||
job_id,
|
||||
)
|
||||
recording.status = models.RecordingStatusChoices.EXTERNAL_PROCESS_FAILED
|
||||
recording.save()
|
||||
return ok_response
|
||||
|
||||
logger.info(
|
||||
"No changes to save for external process id %s and payload %s",
|
||||
job_id,
|
||||
validated_data,
|
||||
)
|
||||
|
||||
return ok_response
|
||||
|
||||
def _auth_get_original_url(self, request):
|
||||
"""
|
||||
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
|
||||
@@ -1187,7 +1331,10 @@ class FileViewSet(
|
||||
serializer.save(creator=self.request.user)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Override to implement a soft delete instead of dumping the record in database."""
|
||||
"""Override to implement a soft delete instead of dumping the record in database.
|
||||
|
||||
Files are actually purged by commands that should run periodically.
|
||||
"""
|
||||
instance.soft_delete()
|
||||
|
||||
@decorators.action(detail=True, methods=["post"], url_path="upload-ended")
|
||||
|
||||
@@ -9,6 +9,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from lasuite.oidc_login.backends import (
|
||||
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
|
||||
)
|
||||
from rest_framework.authentication import SessionAuthentication
|
||||
|
||||
from core.models import User
|
||||
from core.services.marketing import (
|
||||
@@ -96,3 +97,17 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
"Multiple user accounts share a common email."
|
||||
) from e
|
||||
return None
|
||||
|
||||
|
||||
class SessionAuthenticationWith401(SessionAuthentication):
|
||||
"""
|
||||
Identical to DRF's SessionAuthentication, but returns a WWW-Authenticate
|
||||
header so unauthenticated requests get a 401 instead of a 403.
|
||||
|
||||
The scheme is deliberately NOT 'Basic' — that would trigger the browser's
|
||||
native login popup. 'Session' is ignored by the browser's auth UI but is
|
||||
still truthy, so DRF keeps the status at 401.
|
||||
"""
|
||||
|
||||
def authenticate_header(self, request):
|
||||
return "Session"
|
||||
|
||||
@@ -19,7 +19,7 @@ from rest_framework import (
|
||||
status as drf_status,
|
||||
)
|
||||
|
||||
from core import api, models
|
||||
from core import analytics, api, models
|
||||
from core.api.feature_flag import FeatureFlag
|
||||
from core.services.jwt_token import JwtTokenService
|
||||
|
||||
@@ -194,10 +194,27 @@ class RoomViewSet(
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
auth_method = type(self.request.successful_authenticator).__name__
|
||||
client_id = (self.request.auth or {}).get("client_id", "unknown")
|
||||
|
||||
# Log for auditing
|
||||
logger.info(
|
||||
"Room created via application: room_id=%s, user_id=%s, client_id=%s",
|
||||
"Room created via application: room_id=%s, user_id=%s, client_id=%s, auth_method=%s",
|
||||
room.id,
|
||||
self.request.user.id,
|
||||
getattr(self.request.auth, "client_id", "unknown"),
|
||||
client_id,
|
||||
auth_method,
|
||||
)
|
||||
|
||||
analytics.capture(
|
||||
self.request.user,
|
||||
analytics.AnalyticsEvent.ROOM_CREATED,
|
||||
{
|
||||
"room_id": str(room.pk),
|
||||
"access_level": room.access_level,
|
||||
"client_id": client_id,
|
||||
"external_api": True,
|
||||
"auth_method": auth_method,
|
||||
"$set": {"email": self.request.user.email},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Clean stale pending files that were never fully uploaded."""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.utils import timezone
|
||||
|
||||
from core.models import File, FileUploadStateChoices
|
||||
from core.tasks.file import process_file_deletion
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Remove pending files older than a given threshold."""
|
||||
|
||||
help = "Delete pending files that have been stuck for too long"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--hours",
|
||||
type=int,
|
||||
default=24,
|
||||
help="Age threshold in hours (default: 24)",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
hours = options["hours"]
|
||||
if hours < 0:
|
||||
raise CommandError("Hours must be greater than 0")
|
||||
|
||||
threshold = timezone.now() - timedelta(hours=hours)
|
||||
|
||||
files = File.objects.filter(
|
||||
upload_state=FileUploadStateChoices.PENDING,
|
||||
created_at__lt=threshold,
|
||||
hard_deleted_at__isnull=True,
|
||||
)
|
||||
|
||||
count = 0
|
||||
for file in files.iterator():
|
||||
# This check shouldn't happen, but just in case we do it to avoid an error
|
||||
if not file.deleted_at:
|
||||
file.soft_delete()
|
||||
file.hard_delete()
|
||||
process_file_deletion(file.id)
|
||||
count += 1
|
||||
|
||||
self.stdout.write(f"Cleaned {count} stale pending file(s).")
|
||||
@@ -6,6 +6,7 @@ from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db import transaction
|
||||
from django.db.models import Count
|
||||
from django.db.models.functions import Lower
|
||||
|
||||
from core.models import File, RecordingAccess, ResourceAccess, RoleChoices
|
||||
|
||||
@@ -20,11 +21,14 @@ ROLE_PRIORITY = {
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Merge duplicate users sharing the same email into the most recently created one.
|
||||
Merge duplicate users sharing the same email (case-insensitive) into the
|
||||
most recently created one.
|
||||
|
||||
The KEPT user is the most recently created. All room memberships, recording
|
||||
accesses and files are transferred to it. When a conflict exists, the
|
||||
higher-privilege role wins. Stale users are then deleted.
|
||||
Emails are compared case-insensitively, so 'John@Example.com' and
|
||||
'john@example.com' are treated as duplicates. The KEPT user is the most
|
||||
recently created. All room memberships, recording accesses and files are
|
||||
transferred to it. When a conflict exists, the higher-privilege role wins.
|
||||
Stale users are then deleted.
|
||||
Each email group is processed inside a single database transaction.
|
||||
"""
|
||||
|
||||
@@ -56,13 +60,16 @@ class Command(BaseCommand):
|
||||
users_qs = users_qs.filter(email__icontains=email_filter)
|
||||
self.stdout.write(f"[INFO] Filtering emails containing '{email_filter}'.\n")
|
||||
|
||||
# Group emails case-insensitively so 'John@X.com' and 'john@x.com'
|
||||
# are detected as duplicates of each other.
|
||||
duplicate_emails = (
|
||||
users_qs.exclude(email__isnull=True)
|
||||
.exclude(email="")
|
||||
.values("email")
|
||||
.annotate(email_lower=Lower("email"))
|
||||
.values("email_lower")
|
||||
.annotate(cnt=Count("id"))
|
||||
.filter(cnt__gt=1)
|
||||
.values_list("email", flat=True)
|
||||
.values_list("email_lower", flat=True)
|
||||
)
|
||||
|
||||
if not duplicate_emails:
|
||||
@@ -78,9 +85,12 @@ class Command(BaseCommand):
|
||||
failed_emails = []
|
||||
|
||||
for email in duplicate_emails:
|
||||
# Case-insensitive lookup to fetch every casing variant of the email.
|
||||
# Secondary sort by id ensures a stable, deterministic order when
|
||||
# created_at timestamps are equal (common in tests and bulk imports).
|
||||
users = list(User.objects.filter(email=email).order_by("created_at", "id"))
|
||||
users = list(
|
||||
User.objects.filter(email__iexact=email).order_by("created_at", "id")
|
||||
)
|
||||
kept_user = users[-1]
|
||||
stale_users = users[:-1]
|
||||
|
||||
@@ -120,6 +130,10 @@ class Command(BaseCommand):
|
||||
failed_emails.append(email)
|
||||
self.stderr.write(f"[ERROR] Failed to merge '{email}': {exc}")
|
||||
|
||||
if not kept_user.email.islower():
|
||||
kept_user.email = kept_user.email.lower()
|
||||
kept_user.save(update_fields=["email"])
|
||||
|
||||
if failed_emails:
|
||||
raise CommandError(
|
||||
f"Failed to merge {len(failed_emails)} email group(s): {', '.join(failed_emails)}"
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Purge deleted files."""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from core.models import File
|
||||
from core.tasks.file import process_file_deletion
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Purge deleted files (object storage and database object):
|
||||
- files marked as hard deleted in database
|
||||
- files marked as soft deleted and for which the trashbin retention period has expired
|
||||
"""
|
||||
|
||||
help = "Purge deleted files"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Browse purgeable files and queue them through the file deletion task."""
|
||||
|
||||
is_hard_deleted = Q(hard_deleted_at__isnull=False)
|
||||
is_purgeable = Q(
|
||||
deleted_at__lte=timezone.now()
|
||||
- timedelta(days=settings.FILE_PURGE_GRACE_DAYS)
|
||||
)
|
||||
|
||||
count = 0
|
||||
for file in File.objects.filter(is_hard_deleted | is_purgeable).iterator():
|
||||
if file.hard_deleted_at is None:
|
||||
file.hard_delete()
|
||||
|
||||
process_file_deletion.delay(file.id)
|
||||
count += 1
|
||||
|
||||
self.stdout.write(f"Purged {count} deleted file(s).")
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.14 on 2026-06-22 08:26
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0020_alter_file_upload_state'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='recording',
|
||||
name='external_process_id',
|
||||
field=models.CharField(blank=True, help_text='ID of the external process associated with the recording.', max_length=255, null=True, unique=True, verbose_name='External Process ID'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='recording',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('initiated', 'Initiated'), ('active', 'Active'), ('stopped', 'Stopped'), ('saved', 'Saved'), ('aborted', 'Aborted'), ('failed_to_start', 'Failed to Start'), ('failed_to_stop', 'Failed to Stop'), ('notification_succeeded', 'Notification succeeded'), ('external_process_successful', 'External process successful'), ('external_process_failed', 'External process failed')], default='initiated', max_length=50),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.14 on 2026-07-20 00:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0021_recording_external_process_id_alter_recording_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='recording',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('initiated', 'Initiated'), ('active', 'Active'), ('stopped', 'Stopped'), ('saved', 'Saved'), ('aborted', 'Aborted'), ('failed', 'Failed'), ('failed_to_start', 'Failed to Start'), ('failed_to_stop', 'Failed to Stop'), ('notification_succeeded', 'Notification succeeded'), ('external_process_successful', 'External process successful'), ('external_process_failed', 'External process failed')], default='initiated', max_length=50),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
# Generated by Django 5.2.14 on 2026-07-29 00:00
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("core", "0022_alter_recording_status"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="recording",
|
||||
name="owner_access_token",
|
||||
field=models.TextField(
|
||||
blank=True,
|
||||
editable=False,
|
||||
help_text="Encrypted OIDC access token of the user who started the recording, used to push the recording to their Drive on their behalf. Dropped as soon as the push has been attempted.",
|
||||
null=True,
|
||||
verbose_name="Owner access token",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -60,6 +60,11 @@ class RecordingStatusChoices(models.TextChoices):
|
||||
FAILED_TO_START = "failed_to_start", _("Failed to Start")
|
||||
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
|
||||
NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded")
|
||||
EXTERNAL_PROCESS_SUCCESSFUL = (
|
||||
"external_process_successful",
|
||||
_("External process successful"),
|
||||
)
|
||||
EXTERNAL_PROCESS_FAILED = "external_process_failed", _("External process failed")
|
||||
|
||||
@classmethod
|
||||
def is_final(cls, status):
|
||||
@@ -73,6 +78,8 @@ class RecordingStatusChoices(models.TextChoices):
|
||||
cls.STOPPED,
|
||||
cls.SAVED,
|
||||
cls.ABORTED,
|
||||
cls.EXTERNAL_PROCESS_SUCCESSFUL,
|
||||
cls.EXTERNAL_PROCESS_FAILED,
|
||||
cls.FAILED_TO_START,
|
||||
cls.FAILED_TO_STOP,
|
||||
}
|
||||
@@ -598,6 +605,25 @@ class Recording(BaseModel):
|
||||
verbose_name=_("Recording options"),
|
||||
help_text=_("Recording options"),
|
||||
)
|
||||
external_process_id = models.CharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
blank=True,
|
||||
unique=True,
|
||||
verbose_name=_("External Process ID"),
|
||||
help_text=_("ID of the external process associated with the recording."),
|
||||
)
|
||||
owner_access_token = models.TextField(
|
||||
null=True,
|
||||
blank=True,
|
||||
editable=False,
|
||||
verbose_name=_("Owner access token"),
|
||||
help_text=_(
|
||||
"Encrypted OIDC access token of the user who started the recording, "
|
||||
"used to push the recording to their Drive on their behalf. "
|
||||
"Dropped as soon as the push has been attempted."
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "meet_recording"
|
||||
@@ -653,6 +679,8 @@ class Recording(BaseModel):
|
||||
return self.status in {
|
||||
RecordingStatusChoices.NOTIFICATION_SUCCEEDED,
|
||||
RecordingStatusChoices.SAVED,
|
||||
RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL,
|
||||
RecordingStatusChoices.EXTERNAL_PROCESS_FAILED,
|
||||
}
|
||||
|
||||
@property
|
||||
@@ -698,6 +726,39 @@ class Recording(BaseModel):
|
||||
|
||||
return self.expired_at < timezone.now()
|
||||
|
||||
def set_owner_access_token(self, access_token: str) -> None:
|
||||
"""Park the OIDC access token of the user who started the recording.
|
||||
|
||||
It is stored encrypted, and only long enough for the worker to push the
|
||||
recording to that user's Drive once the recording is over.
|
||||
"""
|
||||
|
||||
self.owner_access_token = utils.encrypt_secret(access_token)
|
||||
self.save(update_fields=["owner_access_token", "updated_at"])
|
||||
|
||||
def get_owner_access_token(self) -> Optional[str]:
|
||||
"""Return the parked OIDC access token, or None if there is none left."""
|
||||
|
||||
if not self.owner_access_token:
|
||||
return None
|
||||
|
||||
try:
|
||||
return utils.decrypt_secret(self.owner_access_token)
|
||||
except utils.SecretDecryptionError:
|
||||
logger.exception(
|
||||
"Could not decrypt the access token of recording %s", self.id
|
||||
)
|
||||
return None
|
||||
|
||||
def clear_owner_access_token(self) -> None:
|
||||
"""Drop the parked access token, it is a user credential."""
|
||||
|
||||
if self.owner_access_token is None:
|
||||
return
|
||||
|
||||
self.owner_access_token = None
|
||||
self.save(update_fields=["owner_access_token", "updated_at"])
|
||||
|
||||
|
||||
class RecordingAccess(BaseAccess):
|
||||
"""Relation model to give access to a recording for a user or a team with a role."""
|
||||
|
||||
@@ -14,9 +14,9 @@ logger = logging.getLogger(__name__)
|
||||
class MachineUser:
|
||||
"""Represent a non-interactive system user for automated storage operations."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, username: str = "storage_event_user") -> None:
|
||||
self.pk = None
|
||||
self.username = "storage_event_user"
|
||||
self.username = username
|
||||
self.is_active = True
|
||||
|
||||
@property
|
||||
@@ -34,33 +34,33 @@ class MachineUser:
|
||||
return self.username
|
||||
|
||||
|
||||
class StorageEventAuthentication(BaseAuthentication):
|
||||
"""Authenticate requests using a Bearer token for storage event integration.
|
||||
This class validates Bearer tokens for storage events that don't map to database users.
|
||||
It's designed for S3-compatible storage integrations and similar use cases.
|
||||
Events are submitted when a webhook is configured on some bucket's events.
|
||||
"""
|
||||
class HeaderBasedAuthentication(BaseAuthentication):
|
||||
"""Authenticate requests using a header with a secret key."""
|
||||
|
||||
AUTH_HEADER = "Authorization"
|
||||
TOKEN_TYPE = "Bearer" # noqa S105
|
||||
REALM = ""
|
||||
|
||||
IS_ENFORCED_SETTINGS_KEY = None
|
||||
EXPECTED_TOKEN_SETTINGS_KEY = None
|
||||
|
||||
def authenticate(self, request):
|
||||
"""Validate the Bearer token from the Authorization header."""
|
||||
|
||||
if not settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
|
||||
return MachineUser(), None
|
||||
if self.IS_ENFORCED_SETTINGS_KEY is not None:
|
||||
if not getattr(settings, self.IS_ENFORCED_SETTINGS_KEY):
|
||||
return MachineUser(), None
|
||||
|
||||
required_token = settings.RECORDING_STORAGE_EVENT_TOKEN
|
||||
if not required_token:
|
||||
if settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
|
||||
raise AuthenticationFailed(
|
||||
"Authentication is enabled but token is not configured."
|
||||
)
|
||||
|
||||
return MachineUser(), None
|
||||
if (
|
||||
self.EXPECTED_TOKEN_SETTINGS_KEY is None
|
||||
or (required_token := getattr(settings, self.EXPECTED_TOKEN_SETTINGS_KEY))
|
||||
is None
|
||||
):
|
||||
raise AuthenticationFailed(
|
||||
"Authentication is enabled but token is not configured."
|
||||
)
|
||||
|
||||
auth_header = request.headers.get(self.AUTH_HEADER)
|
||||
|
||||
if not auth_header:
|
||||
logger.warning(
|
||||
"Authentication failed: Missing Authorization header (ip: %s)",
|
||||
@@ -68,15 +68,10 @@ class StorageEventAuthentication(BaseAuthentication):
|
||||
)
|
||||
raise AuthenticationFailed("Authorization header is required")
|
||||
|
||||
auth_parts = auth_header.split(" ")
|
||||
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
|
||||
logger.warning(
|
||||
"Authentication failed: Invalid authorization header (ip: %s)",
|
||||
request.META.get("REMOTE_ADDR"),
|
||||
)
|
||||
raise AuthenticationFailed("Invalid authorization header.")
|
||||
|
||||
token = auth_parts[1]
|
||||
scheme, _, token = auth_header.partition(" ")
|
||||
if scheme.lower() != self.TOKEN_TYPE.lower() or not token.strip():
|
||||
raise AuthenticationFailed("Invalid authorization header format.")
|
||||
token = token.strip()
|
||||
|
||||
# Use constant-time comparison to prevent timing attacks
|
||||
if not secrets.compare_digest(token.encode(), required_token.encode()):
|
||||
@@ -90,4 +85,26 @@ class StorageEventAuthentication(BaseAuthentication):
|
||||
|
||||
def authenticate_header(self, request):
|
||||
"""Return the WWW-Authenticate header value."""
|
||||
return f"{self.TOKEN_TYPE} realm='Storage event API'"
|
||||
return f"{self.TOKEN_TYPE} realm='{self.REALM}'"
|
||||
|
||||
|
||||
class StorageEventAuthentication(HeaderBasedAuthentication):
|
||||
"""Authenticate requests using a Bearer token for storage event integration.
|
||||
This class validates Bearer tokens for storage events that don't map to database users.
|
||||
It's designed for S3-compatible storage integrations and similar use cases.
|
||||
Events are submitted when a webhook is configured on some bucket's events.
|
||||
"""
|
||||
|
||||
REALM = "Storage event API"
|
||||
IS_ENFORCED_SETTINGS_KEY = "RECORDING_ENABLE_STORAGE_EVENT_AUTH"
|
||||
EXPECTED_TOKEN_SETTINGS_KEY = "RECORDING_STORAGE_EVENT_TOKEN" # noqa S105
|
||||
|
||||
|
||||
class RecordingProcessWebhookAuthentication(HeaderBasedAuthentication):
|
||||
"""
|
||||
Custom authentication class for recording process webhook requests.
|
||||
Validates the API key in the Authorization header.
|
||||
"""
|
||||
|
||||
REALM = "External process webhook API"
|
||||
EXPECTED_TOKEN_SETTINGS_KEY = "SUMMARY_SERVICE_WEBHOOK_API_TOKEN" # noqa S105
|
||||
|
||||
@@ -4,6 +4,7 @@ import asyncio
|
||||
import logging
|
||||
import smtplib
|
||||
from datetime import datetime, timezone
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail import send_mail
|
||||
@@ -17,6 +18,9 @@ from asgiref.sync import async_to_sync
|
||||
from livekit import api as livekit_api
|
||||
|
||||
from core import models, utils
|
||||
from core.analytics import UserFeatureFlag, is_user_feature_flag_enabled
|
||||
from core.tasks.push_recording import push_recording
|
||||
from core.utils import generate_download_s3_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -42,7 +46,17 @@ class NotificationService:
|
||||
"""Service for processing recordings and notifying external services."""
|
||||
|
||||
def notify_external_services(self, recording):
|
||||
"""Process a recording based on its mode."""
|
||||
"""Process a recording, then push it to the owner's Drive."""
|
||||
|
||||
try:
|
||||
return self._notify_by_mode(recording)
|
||||
finally:
|
||||
# Independent from the mode: the file itself is pushed to the owner's
|
||||
# Drive, whether it is a screen recording or a transcript's audio.
|
||||
self._push_recording_to_drive(recording)
|
||||
|
||||
def _notify_by_mode(self, recording):
|
||||
"""Route a recording to the services its mode calls for."""
|
||||
|
||||
if recording.mode == models.RecordingModeChoices.TRANSCRIPT:
|
||||
return self._notify_summary_service(recording)
|
||||
@@ -178,8 +192,74 @@ class NotificationService:
|
||||
|
||||
return _ns_to_utc(file_result.started_at), _ns_to_utc(file_result.ended_at)
|
||||
|
||||
@staticmethod
|
||||
def _generate_title(
|
||||
*,
|
||||
locale: str,
|
||||
room: str,
|
||||
recording_datetime: datetime | None,
|
||||
owner_timezone: str | None,
|
||||
) -> str:
|
||||
"""Generate title from context or return default."""
|
||||
if recording_datetime is None:
|
||||
with override(locale):
|
||||
return _("Transcription")
|
||||
|
||||
dt = recording_datetime
|
||||
if owner_timezone:
|
||||
try:
|
||||
dt = recording_datetime.astimezone(ZoneInfo(owner_timezone))
|
||||
except (KeyError, ZoneInfoNotFoundError):
|
||||
pass # Keep the original UTC datetime
|
||||
|
||||
with override(locale):
|
||||
translated_template = _(
|
||||
'Meeting "{room}" on {room_recording_date} at {room_recording_time}'
|
||||
)
|
||||
return translated_template.format(
|
||||
room=room,
|
||||
room_recording_date=dt.strftime("%Y-%m-%d"),
|
||||
room_recording_time=dt.strftime("%H:%M"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service(recording: models.Recording):
|
||||
if settings.SUMMARY_SERVICE_VERSION == 1:
|
||||
return NotificationService._notify_summary_service_v1(recording)
|
||||
if settings.SUMMARY_SERVICE_VERSION == 2:
|
||||
return NotificationService._notify_summary_service_v2(recording)
|
||||
|
||||
raise NotImplementedError(
|
||||
f"Unknown summary service version: {settings.SUMMARY_SERVICE_VERSION}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _push_recording_to_drive(recording: models.Recording):
|
||||
"""Hand the recording over to the task pushing it to the owner's Drive.
|
||||
|
||||
Best effort: a failure here must not compromise the rest of the
|
||||
notification flow, the recording itself is safe in object storage.
|
||||
"""
|
||||
|
||||
if not settings.RECORDING_PUSH_TO_DRIVE_ENABLED:
|
||||
return
|
||||
|
||||
if not recording.owner_access_token:
|
||||
logger.warning(
|
||||
"No access token parked for recording %s, skipping the Drive push",
|
||||
recording.id,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
push_recording.delay(str(recording.id))
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception(
|
||||
"Could not schedule the Drive push of recording %s", recording.id
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service_v1(recording: models.Recording):
|
||||
"""Notify summary service about a new recording."""
|
||||
|
||||
if (
|
||||
@@ -253,5 +333,120 @@ class NotificationService:
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service_v2(recording: models.Recording):
|
||||
"""Notify summary service about a new recording."""
|
||||
|
||||
if (
|
||||
not settings.SUMMARY_SERVICE_ENDPOINT
|
||||
or not settings.SUMMARY_SERVICE_API_TOKEN
|
||||
):
|
||||
logger.error("Summary service not configured")
|
||||
return False
|
||||
|
||||
owner_access = (
|
||||
models.RecordingAccess.objects.select_related("user")
|
||||
.filter(
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording_id=recording.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
metadata_filename: None | str = None
|
||||
if settings.METADATA_COLLECTOR_ENABLED and recording.options.get(
|
||||
"collect_metadata", False
|
||||
):
|
||||
output_folder = settings.METADATA_COLLECTOR_OUTPUT_FOLDER
|
||||
metadata_filename = f"{output_folder}/{recording.id}-metadata.json"
|
||||
|
||||
if not owner_access:
|
||||
logger.error("No owner found for recording %s", recording.id)
|
||||
return False
|
||||
|
||||
started_at, ended_at = async_to_sync(
|
||||
NotificationService._get_recording_timestamps
|
||||
)(recording.worker_id)
|
||||
|
||||
form_base_url = settings.TRANSCRIPTION_SATISFACTION_FORM_BASE_URL
|
||||
form_link = (
|
||||
f"{form_base_url}?room_id={recording.room.id}"
|
||||
if (form_base_url and metadata_filename is not None)
|
||||
else None
|
||||
)
|
||||
metadata_payload = None
|
||||
if started_at and ended_at and metadata_filename:
|
||||
metadata_payload = {
|
||||
"cloud_storage_url": generate_download_s3_url(
|
||||
metadata_filename,
|
||||
expires_in=settings.SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS,
|
||||
override_domain=False,
|
||||
),
|
||||
"started_at": started_at.isoformat(),
|
||||
"ended_at": ended_at.isoformat(),
|
||||
}
|
||||
|
||||
payload = {
|
||||
"user_sub": owner_access.user.sub,
|
||||
"user_email": owner_access.user.email,
|
||||
"cloud_storage_url": generate_download_s3_url(
|
||||
recording.key,
|
||||
expires_in=settings.SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS,
|
||||
override_domain=False,
|
||||
),
|
||||
"language": recording.options.get(
|
||||
"language", get_language().split("-")[0].lower()
|
||||
),
|
||||
"context_language": owner_access.user.language,
|
||||
"push_to_docs_config": {
|
||||
"user_email": owner_access.user.email,
|
||||
"title": NotificationService._generate_title(
|
||||
locale=owner_access.user.language
|
||||
or recording.options.get("language", get_language()),
|
||||
room=recording.room.name,
|
||||
recording_datetime=started_at,
|
||||
owner_timezone=str(owner_access.user.timezone),
|
||||
),
|
||||
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
|
||||
"form_link": form_link,
|
||||
"auto_create_summary": is_user_feature_flag_enabled(
|
||||
owner_access.user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||
),
|
||||
},
|
||||
"metadata": metadata_payload,
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {settings.SUMMARY_SERVICE_API_TOKEN}",
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
settings.SUMMARY_SERVICE_ENDPOINT,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
response_json = response.json()
|
||||
# We do not require a job_id to avoid a breaking change
|
||||
job_id = response_json.get("job_id")
|
||||
if not isinstance(job_id, str):
|
||||
raise ValueError("job_id is not a string")
|
||||
|
||||
recording.external_process_id = job_id
|
||||
recording.save()
|
||||
|
||||
except requests.RequestException as exc:
|
||||
logger.exception(
|
||||
"Summary service error for recording %s. URL: %s. Exception: %s",
|
||||
recording.id,
|
||||
settings.SUMMARY_SERVICE_ENDPOINT,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
notification_service = NotificationService()
|
||||
|
||||
@@ -6,6 +6,7 @@ import re
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import Any, Dict, Optional, Protocol
|
||||
from urllib.parse import quote
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.module_loading import import_string
|
||||
@@ -58,7 +59,7 @@ class EventParser(Protocol):
|
||||
def parse(self, data: Dict) -> StorageEvent:
|
||||
"""Extract storage event data from raw dictionary input."""
|
||||
|
||||
def validate(self, data: StorageEvent) -> None:
|
||||
def validate(self, data: StorageEvent) -> str:
|
||||
"""Verify storage event data meets all requirements."""
|
||||
|
||||
def get_recording_id(self, data: Dict) -> str:
|
||||
@@ -164,6 +165,9 @@ class S3Parser(BaseS3Parser):
|
||||
if not filepath:
|
||||
raise ParsingEventDataError("Missing object key name")
|
||||
filetype, _ = mimetypes.guess_type(filepath)
|
||||
# Normalize raw S3-compatible object keys without re-encoding
|
||||
# already encoded AWS S3 notification keys.
|
||||
filepath = quote(filepath, safe="%+")
|
||||
return StorageEvent(
|
||||
filepath=filepath,
|
||||
filetype=filetype,
|
||||
|
||||
@@ -8,6 +8,12 @@ from livekit import api
|
||||
|
||||
from core import models, utils
|
||||
from core.models import Recording
|
||||
from core.recording.event.notification import notification_service
|
||||
from core.services.room_management import (
|
||||
RoomManagement,
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
@@ -16,6 +22,10 @@ class RecordingEventsError(Exception):
|
||||
"""Recording event handling fails."""
|
||||
|
||||
|
||||
class RecordingNotSavableError(Exception):
|
||||
"""Recording cannot be saved because it is either in an error state or has already been saved"""
|
||||
|
||||
|
||||
class RecordingEventsService:
|
||||
"""Handles recording-related LiveKit webhook events."""
|
||||
|
||||
@@ -34,10 +44,15 @@ class RecordingEventsService:
|
||||
recording_status = status_mapping.get(egress_status)
|
||||
if recording_status:
|
||||
try:
|
||||
utils.update_room_metadata(
|
||||
RoomManagement().update_metadata(
|
||||
room_name, {"recording_status": recording_status}
|
||||
)
|
||||
except utils.MetadataUpdateException as e:
|
||||
except RoomNotFoundException:
|
||||
logger.info(
|
||||
"LiveKit room %s no longer exists, skipping metadata update",
|
||||
room_name,
|
||||
)
|
||||
except RoomManagementException as e:
|
||||
logger.exception("Failed to update room's metadata: %s", e)
|
||||
|
||||
@staticmethod
|
||||
@@ -73,3 +88,23 @@ class RecordingEventsService:
|
||||
f"Failed to notify participants in room '{recording.room.id}' about "
|
||||
f"recording limit reached (recording_id={recording.id})"
|
||||
) from e
|
||||
|
||||
@staticmethod
|
||||
def handle_complete(recording: Recording):
|
||||
"""Notify external services and save recording."""
|
||||
|
||||
if not recording.is_savable():
|
||||
raise RecordingNotSavableError
|
||||
|
||||
# Attempt to notify external services about the recording
|
||||
# This is a non-blocking operation - failures are logged but don't interrupt the flow
|
||||
notification_succeeded = notification_service.notify_external_services(
|
||||
recording
|
||||
)
|
||||
|
||||
recording.status = (
|
||||
models.RecordingStatusChoices.NOTIFICATION_SUCCEEDED
|
||||
if notification_succeeded
|
||||
else models.RecordingStatusChoices.SAVED
|
||||
)
|
||||
recording.save()
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
|
||||
import logging
|
||||
|
||||
from core import utils
|
||||
from core.models import Recording, RecordingStatusChoices
|
||||
from core.services.room_management import (
|
||||
RoomManagement,
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
|
||||
from .exceptions import (
|
||||
RecordingStartError,
|
||||
@@ -64,10 +68,15 @@ class WorkerServiceMediator:
|
||||
mode = recording.options.get("original_mode", None) or recording.mode
|
||||
|
||||
try:
|
||||
utils.update_room_metadata(
|
||||
RoomManagement().update_metadata(
|
||||
room_name, {"recording_mode": mode, "recording_status": "starting"}
|
||||
)
|
||||
except utils.MetadataUpdateException as e:
|
||||
except RoomNotFoundException:
|
||||
logger.info(
|
||||
"LiveKit room %s no longer exists, skipping metadata update",
|
||||
room_name,
|
||||
)
|
||||
except RoomManagementException as e:
|
||||
logger.exception("Failed to update room's metadata: %s", e)
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Client for La Suite Drive's external API (OIDC resource server).
|
||||
|
||||
Drive exposes `/external_api/v1.0/*` to applications holding an end user's OIDC
|
||||
access token. Uploading a file is a four step dance, documented in Drive's
|
||||
`docs/resource_server.md`:
|
||||
|
||||
1. `GET /items/` to locate the user's main workspace,
|
||||
2. `POST /items/{workspace_id}/children/` to create the file item, which returns
|
||||
a presigned upload URL (the `policy`),
|
||||
3. `PUT {policy}` to push the bytes to Drive's object storage,
|
||||
4. `POST /items/{item_id}/upload-ended/` to let Drive know the upload is over.
|
||||
|
||||
Drive never fetches a URL on our behalf, so the bytes have to transit through
|
||||
whoever holds the user's token, i.e. us.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# (connect, read) timeouts, in seconds. The upload one is generous: it covers a
|
||||
# whole recording being relayed to Drive's object storage.
|
||||
API_TIMEOUT = (10, 30)
|
||||
UPLOAD_TIMEOUT = (10, 300)
|
||||
|
||||
# Safety net when walking the paginated item list looking for the main workspace.
|
||||
MAX_WORKSPACE_PAGES = 10
|
||||
|
||||
|
||||
class DriveError(Exception):
|
||||
"""Raised when Drive's external API cannot fulfill a request."""
|
||||
|
||||
|
||||
class SizedStream:
|
||||
"""Read-only byte stream of a known size, suitable as a `requests` body.
|
||||
|
||||
`requests` falls back to a chunked transfer encoding when it cannot guess the
|
||||
body size upfront, which presigned S3 uploads reject. Advertising the size
|
||||
through `__len__` makes it send a plain `Content-Length` instead, while the
|
||||
underlying stream is still consumed chunk by chunk.
|
||||
"""
|
||||
|
||||
def __init__(self, stream, length: int):
|
||||
"""Wrap `stream`, whose full content is `length` bytes long."""
|
||||
self._stream = stream
|
||||
self._length = length
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Return the total size of the stream, in bytes."""
|
||||
return self._length
|
||||
|
||||
def __iter__(self):
|
||||
"""Iterate over the stream, required for `requests` to stream the body."""
|
||||
return iter(self._stream)
|
||||
|
||||
def read(self, amt=None) -> bytes:
|
||||
"""Read up to `amt` bytes from the stream."""
|
||||
return self._stream.read(amt)
|
||||
|
||||
|
||||
class DriveClient:
|
||||
"""Talk to Drive's external API on behalf of a user.
|
||||
|
||||
The client is bound to a single user access token: every call is performed
|
||||
as that user, and Drive applies its own permissions accordingly.
|
||||
"""
|
||||
|
||||
def __init__(self, access_token: str, *, base_url: str | None = None):
|
||||
"""Prepare a session authenticated with the user's OIDC access token."""
|
||||
|
||||
self._base_url = (base_url or settings.DRIVE_API_BASE_URL or "").rstrip("/")
|
||||
|
||||
if not self._base_url:
|
||||
raise DriveError(
|
||||
"Drive API is not configured, set DRIVE_API_BASE_URL to enable it."
|
||||
)
|
||||
|
||||
if not access_token:
|
||||
raise DriveError("An access token is required to call Drive.")
|
||||
|
||||
self._session = requests.Session()
|
||||
self._session.headers.update(
|
||||
{
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
)
|
||||
|
||||
def __enter__(self):
|
||||
"""Allow use as a context manager, closing the session on exit."""
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
"""Close the underlying HTTP session."""
|
||||
self.close()
|
||||
|
||||
def close(self):
|
||||
"""Release the underlying HTTP session."""
|
||||
self._session.close()
|
||||
|
||||
def _request(self, method, path, **kwargs):
|
||||
"""Perform an authenticated call to the external API and return its body."""
|
||||
|
||||
url = f"{self._base_url}{path}"
|
||||
kwargs.setdefault("timeout", API_TIMEOUT)
|
||||
|
||||
try:
|
||||
response = self._session.request(method, url, **kwargs)
|
||||
response.raise_for_status()
|
||||
except requests.RequestException as exc:
|
||||
raise DriveError(f"Drive call failed: {method} {url}") from exc
|
||||
|
||||
if not response.content:
|
||||
return None
|
||||
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as exc:
|
||||
raise DriveError(f"Drive returned a non-JSON body for {url}") from exc
|
||||
|
||||
def get_main_workspace(self) -> dict:
|
||||
"""Return the user's main workspace, the default destination for files."""
|
||||
|
||||
path = "/items/"
|
||||
|
||||
for _page in range(MAX_WORKSPACE_PAGES):
|
||||
data = self._request("GET", path) or {}
|
||||
|
||||
for item in data.get("results") or []:
|
||||
if item.get("main_workspace"):
|
||||
return item
|
||||
|
||||
next_url = data.get("next")
|
||||
if not next_url:
|
||||
break
|
||||
|
||||
# `next` is absolute; keep only what follows the API base URL.
|
||||
path = next_url[len(self._base_url) :]
|
||||
|
||||
raise DriveError("No main workspace found for this user.")
|
||||
|
||||
def create_file(self, *, parent_id: str, filename: str) -> dict:
|
||||
"""Create a file item under `parent_id` and return it.
|
||||
|
||||
The returned item carries a `policy`: the presigned URL the content has
|
||||
to be uploaded to.
|
||||
"""
|
||||
|
||||
item = self._request(
|
||||
"POST",
|
||||
f"/items/{parent_id}/children/",
|
||||
json={"type": "file", "filename": filename},
|
||||
)
|
||||
|
||||
if not item or not item.get("policy"):
|
||||
raise DriveError(
|
||||
f"Drive did not return an upload policy for file '{filename}'."
|
||||
)
|
||||
|
||||
return item
|
||||
|
||||
@staticmethod
|
||||
def _resolve_upload_target(policy_url: str) -> tuple[str, str | None]:
|
||||
"""Return the address to connect to, and the `Host` header to send.
|
||||
|
||||
Drive signs its upload URLs with the object storage domain meant for its
|
||||
*browser* clients, which does not necessarily resolve from here — that is
|
||||
the case in the split docker compose development setup. The presigned
|
||||
signature covers the `Host` header, so we may only swap the address we
|
||||
connect to and must keep announcing the original host.
|
||||
"""
|
||||
|
||||
override = settings.DRIVE_UPLOAD_STORAGE_NETLOC
|
||||
|
||||
if not override:
|
||||
return policy_url, None
|
||||
|
||||
parsed = urlparse(policy_url)
|
||||
return urlunparse(parsed._replace(netloc=override)), parsed.netloc
|
||||
|
||||
def upload_content(self, *, policy_url: str, stream, content_length, content_type):
|
||||
"""Push `stream` to the presigned URL, without buffering it as a whole."""
|
||||
|
||||
url, host_header = self._resolve_upload_target(policy_url)
|
||||
|
||||
headers = {
|
||||
"Content-Type": content_type,
|
||||
"Content-Length": str(content_length),
|
||||
"x-amz-acl": "private",
|
||||
}
|
||||
|
||||
if host_header:
|
||||
headers["Host"] = host_header
|
||||
|
||||
try:
|
||||
# A bare `requests.put`, not the authenticated session: the presigned
|
||||
# URL carries its own credentials and the object storage rejects an
|
||||
# extra `Authorization` header.
|
||||
response = requests.put(
|
||||
url,
|
||||
data=SizedStream(stream, content_length),
|
||||
headers=headers,
|
||||
timeout=UPLOAD_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.RequestException as exc:
|
||||
raise DriveError("Upload to Drive's object storage failed.") from exc
|
||||
|
||||
def complete_upload(self, item_id: str) -> None:
|
||||
"""Tell Drive the upload is over, which makes the file available."""
|
||||
|
||||
self._request("POST", f"/items/{item_id}/upload-ended/", json={})
|
||||
@@ -11,7 +11,7 @@ from django.conf import settings
|
||||
|
||||
from livekit import api
|
||||
|
||||
from core import models, utils
|
||||
from core import models
|
||||
from core.recording.services.metadata_collector import (
|
||||
MetadataCollectorException,
|
||||
MetadataCollectorService,
|
||||
@@ -19,9 +19,15 @@ from core.recording.services.metadata_collector import (
|
||||
from core.recording.services.recording_events import (
|
||||
RecordingEventsError,
|
||||
RecordingEventsService,
|
||||
RecordingNotSavableError,
|
||||
)
|
||||
|
||||
from .lobby import LobbyService
|
||||
from .room_management import (
|
||||
RoomManagement,
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
from .telephony import TelephonyException, TelephonyService
|
||||
|
||||
logger = getLogger(__name__)
|
||||
@@ -88,6 +94,13 @@ class LiveKitEventsService:
|
||||
def __init__(self):
|
||||
"""Initialize with required services."""
|
||||
|
||||
self._webhook_handlers = {
|
||||
"egress_updated": self._handle_egress_updated,
|
||||
"egress_ended": self._handle_egress_ended,
|
||||
"room_started": self._handle_room_started,
|
||||
"room_finished": self._handle_room_finished,
|
||||
}
|
||||
|
||||
token_verifier = api.TokenVerifier(
|
||||
settings.LIVEKIT_CONFIGURATION["api_key"],
|
||||
settings.LIVEKIT_CONFIGURATION["api_secret"],
|
||||
@@ -135,14 +148,11 @@ class LiveKitEventsService:
|
||||
f"Unknown webhook type: {data.event}"
|
||||
) from e
|
||||
|
||||
handler_name = f"_handle_{webhook_type.value}"
|
||||
handler = getattr(self, handler_name, None)
|
||||
# Handle according to received webhook type
|
||||
handler = self._webhook_handlers.get(webhook_type.value)
|
||||
|
||||
if not handler or not callable(handler):
|
||||
return
|
||||
|
||||
# pylint: disable=not-callable
|
||||
handler(data)
|
||||
if handler is not None:
|
||||
handler(data)
|
||||
|
||||
def _handle_egress_updated(self, data):
|
||||
"""Handle 'egress_updated' event."""
|
||||
@@ -172,10 +182,15 @@ class LiveKitEventsService:
|
||||
|
||||
try:
|
||||
room_name = str(recording.room.id)
|
||||
utils.update_room_metadata(
|
||||
room_name, {}, ["recording_mode", "recording_status"]
|
||||
RoomManagement().update_metadata(
|
||||
room_name, remove_keys=["recording_mode", "recording_status"]
|
||||
)
|
||||
except utils.MetadataUpdateException as e:
|
||||
except RoomNotFoundException:
|
||||
logger.info(
|
||||
"LiveKit room %s no longer exists, skipping metadata update",
|
||||
room_name,
|
||||
)
|
||||
except RoomManagementException as e:
|
||||
logger.exception("Failed to update room's metadata: %s", e)
|
||||
|
||||
if recording.options.get("metadata_collector_dispatch_id", None) is not None:
|
||||
@@ -195,6 +210,24 @@ class LiveKitEventsService:
|
||||
f"Failed to process limit reached event for recording {recording}"
|
||||
) from e
|
||||
|
||||
# Fallback for completion when no MinIO/S3 webhooks are configured
|
||||
if (
|
||||
not settings.RECORDING_STORAGE_EVENT_ENABLE
|
||||
) and data.egress_info.status in [
|
||||
api.EgressStatus.EGRESS_COMPLETE,
|
||||
api.EgressStatus.EGRESS_LIMIT_REACHED,
|
||||
]:
|
||||
try:
|
||||
self.recording_events.handle_complete(recording)
|
||||
except RecordingNotSavableError:
|
||||
logger.warning(
|
||||
"Recording %s is not savable on egress complete "
|
||||
"(already saved or in an error state); ignoring.",
|
||||
recording.id,
|
||||
)
|
||||
|
||||
# Silently ignoring EGRESS_ABORTED, EGRESS_FAILED
|
||||
|
||||
def _handle_room_started(self, data):
|
||||
"""Handle 'room_started' event."""
|
||||
|
||||
|
||||
@@ -104,21 +104,30 @@ class LobbyService:
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def can_bypass_lobby(room, user) -> bool:
|
||||
def can_bypass_lobby(room, user, role) -> bool:
|
||||
"""Determines if a user can bypass the waiting lobby and join a room directly.
|
||||
|
||||
A user can bypass the lobby if:
|
||||
1. The room is public (open to everyone)
|
||||
2. The room has TRUSTED access level and the user is authenticated
|
||||
2. The room has RESTRICTED access level and the user has any role
|
||||
|
||||
Note: Room access levels can change while participants are waiting in the lobby.
|
||||
This function only checks the current state and should be called each time
|
||||
a participant requests entry to ensure consistent access control, even for
|
||||
participants who have already begun waiting.
|
||||
"""
|
||||
return room.is_public or (
|
||||
room.access_level == models.RoomAccessLevel.TRUSTED
|
||||
and user.is_authenticated
|
||||
return (
|
||||
room.is_public
|
||||
or (
|
||||
room.access_level == models.RoomAccessLevel.TRUSTED
|
||||
and user.is_authenticated
|
||||
)
|
||||
or (
|
||||
room.access_level == models.RoomAccessLevel.RESTRICTED
|
||||
and user.is_authenticated
|
||||
and role is not None
|
||||
)
|
||||
)
|
||||
|
||||
def request_entry(
|
||||
@@ -144,8 +153,9 @@ class LobbyService:
|
||||
participant = self._get_participant(room.id, participant_id)
|
||||
|
||||
room_id = str(room.id)
|
||||
user_role = room.get_role(request.user)
|
||||
|
||||
if self.can_bypass_lobby(room=room, user=request.user):
|
||||
if self.can_bypass_lobby(room=room, user=request.user, role=user_role):
|
||||
if participant is None:
|
||||
participant = LobbyParticipant(
|
||||
status=LobbyParticipantStatus.ACCEPTED,
|
||||
@@ -162,8 +172,8 @@ class LobbyService:
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id=participant_id,
|
||||
role=user_role,
|
||||
)
|
||||
return participant, livekit_config
|
||||
|
||||
@@ -183,8 +193,8 @@ class LobbyService:
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id=participant_id,
|
||||
role=user_role,
|
||||
)
|
||||
|
||||
return participant, livekit_config
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Dict, Optional
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit.api import (
|
||||
ListRoomsRequest,
|
||||
TwirpError,
|
||||
UpdateRoomMetadataRequest,
|
||||
)
|
||||
@@ -29,20 +30,45 @@ class RoomManagement:
|
||||
"""Service for managing LiveKit rooms."""
|
||||
|
||||
@async_to_sync
|
||||
async def update_metadata(self, room_name: str, metadata: Optional[Dict] = None):
|
||||
"""Update a LiveKit room's metadata.
|
||||
async def update_metadata(
|
||||
self,
|
||||
room_name: str,
|
||||
metadata: Optional[Dict] = None,
|
||||
remove_keys: Optional[list[str]] = None,
|
||||
):
|
||||
"""Merge values into a LiveKit room's metadata.
|
||||
|
||||
The `room_name` corresponds to the LiveKit room identifier
|
||||
(i.e. the Room model's UUID as a string).
|
||||
|
||||
Raises:
|
||||
RoomNotFoundException: the room does not exist in LiveKit.
|
||||
RoomManagementException: the metadata update otherwise fails.
|
||||
"""
|
||||
|
||||
lkapi = utils.create_livekit_client()
|
||||
|
||||
try:
|
||||
response = await lkapi.room.list_rooms(ListRoomsRequest(names=[room_name]))
|
||||
|
||||
if not response.rooms:
|
||||
logger.warning(
|
||||
"Room %s not found in LiveKit, skipping metadata update",
|
||||
room_name,
|
||||
)
|
||||
raise RoomNotFoundException("Room does not exist")
|
||||
|
||||
existing_metadata = json.loads(response.rooms[0].metadata or "{}")
|
||||
|
||||
for key in remove_keys or []:
|
||||
existing_metadata.pop(key, None)
|
||||
|
||||
updated_metadata = {**existing_metadata, **(metadata or {})}
|
||||
|
||||
await lkapi.room.update_room_metadata(
|
||||
UpdateRoomMetadataRequest(
|
||||
room=room_name,
|
||||
metadata=json.dumps(metadata) if metadata is not None else "",
|
||||
metadata=json.dumps(updated_metadata),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Room role management service.
|
||||
|
||||
Single entry point for changing a user's role on a room, used by:
|
||||
- the in-meeting endpoint (promote/demote a connected participant)
|
||||
- (more to come soon)
|
||||
|
||||
`ResourceAccess` is the source of truth. The LiveKit `room_role`
|
||||
participant attribute is only a projection of it, synced best-effort.
|
||||
"""
|
||||
|
||||
from logging import getLogger
|
||||
from uuid import UUID
|
||||
|
||||
from core import models
|
||||
from core.services.participants_management import (
|
||||
ParticipantNotFoundException,
|
||||
ParticipantsManagement,
|
||||
ParticipantsManagementException,
|
||||
)
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class RoomRoleError(Exception):
|
||||
"""Base exception for room role management errors."""
|
||||
|
||||
status_code = 400
|
||||
|
||||
|
||||
class SelfActionError(RoomRoleError):
|
||||
"""Raised when a user tries to change their own role."""
|
||||
|
||||
status_code = 403
|
||||
|
||||
|
||||
class OwnerRoleError(RoomRoleError):
|
||||
"""Raised when trying to demote an owner or grant ownership."""
|
||||
|
||||
status_code = 403
|
||||
|
||||
|
||||
class ParticipantNotInMeetingError(RoomRoleError):
|
||||
"""Raised when the target participant is not connected to the meeting."""
|
||||
|
||||
status_code = 404
|
||||
|
||||
|
||||
class UserNotFoundError(RoomRoleError):
|
||||
"""Raised when the target participant has no user account in database."""
|
||||
|
||||
status_code = 404
|
||||
|
||||
|
||||
ASSIGNABLE_ROLES = (models.RoleChoices.MEMBER, models.RoleChoices.ADMIN)
|
||||
|
||||
|
||||
class RoomRoleService:
|
||||
"""Manage promotion and demotion of room co-hosts."""
|
||||
|
||||
def set_role(
|
||||
self, room: models.Room, user: models.User, role: str, actor: models.User
|
||||
):
|
||||
"""Persist `role` for `user` on `room`, idempotently and atomically.
|
||||
|
||||
Returns the up-to-date `ResourceAccess`. Never grants or removes
|
||||
ownership: granting OWNER is refused, and an existing OWNER access
|
||||
is never modified.
|
||||
"""
|
||||
|
||||
if role not in ASSIGNABLE_ROLES:
|
||||
raise OwnerRoleError("Ownership cannot be granted through this action.")
|
||||
|
||||
if actor is not None and user == actor:
|
||||
raise SelfActionError("You cannot change your own role.")
|
||||
|
||||
access, created = models.ResourceAccess.objects.get_or_create(
|
||||
resource=room,
|
||||
user=user,
|
||||
defaults={"role": role},
|
||||
)
|
||||
|
||||
if created:
|
||||
return access
|
||||
|
||||
if access.role == models.RoleChoices.OWNER:
|
||||
raise OwnerRoleError("Room owners cannot be demoted.")
|
||||
|
||||
if access.role != role:
|
||||
access.role = role
|
||||
access.save(update_fields=["role", "updated_at"])
|
||||
|
||||
return access
|
||||
|
||||
def set_participant_role(
|
||||
self,
|
||||
room: models.Room,
|
||||
participant_identity: UUID,
|
||||
role: str,
|
||||
actor: models.User,
|
||||
):
|
||||
"""Change the role of a participant currently connected to the meeting.
|
||||
|
||||
- The participant must be connected (checked against LiveKit).
|
||||
- The participant must map to a user account.
|
||||
- The role is persisted in DB then mirrored to LiveKit.
|
||||
|
||||
Returns a dict: {"role", "livekit_synced"}.
|
||||
"""
|
||||
|
||||
room_name = str(room.pk)
|
||||
participants_management = ParticipantsManagement()
|
||||
|
||||
try:
|
||||
is_in_meeting = participants_management.check_if_in_meeting(
|
||||
room_name=room_name, identity=str(participant_identity)
|
||||
)
|
||||
except ParticipantNotFoundException as e:
|
||||
raise ParticipantNotInMeetingError(
|
||||
"Participant is not connected to this meeting."
|
||||
) from e
|
||||
|
||||
if not is_in_meeting:
|
||||
raise ParticipantNotInMeetingError(
|
||||
"Participant is not connected to this meeting."
|
||||
)
|
||||
|
||||
user = models.User.objects.filter(sub=participant_identity).first()
|
||||
|
||||
if user is None:
|
||||
raise UserNotFoundError(
|
||||
"This participant has no user account and cannot be assigned a role."
|
||||
)
|
||||
|
||||
# Source of truth first: even if the LiveKit sync below fails,
|
||||
# the role is real and any fresh token will carry it.
|
||||
self.set_role(room=room, user=user, role=role, actor=actor)
|
||||
|
||||
livekit_synced = self._sync_livekit_role(
|
||||
room_name=room_name,
|
||||
participant_identity=str(participant_identity),
|
||||
role=str(role),
|
||||
)
|
||||
|
||||
return {
|
||||
"role": role,
|
||||
"livekit_synced": livekit_synced,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _sync_livekit_role(room_name: str, participant_identity: str, role: str):
|
||||
"""Mirror the role to the participant's LiveKit attributes.
|
||||
|
||||
Best-effort: returns False on failure instead of raising, so callers
|
||||
can report a partial success. Re-running the action re-syncs.
|
||||
"""
|
||||
try:
|
||||
ParticipantsManagement().update(
|
||||
room_name=room_name,
|
||||
identity=participant_identity,
|
||||
attributes={"room_role": role},
|
||||
)
|
||||
except ParticipantNotFoundException:
|
||||
# The participant left between the presence check and the update:
|
||||
# harmless, the DB state (if any) remains authoritative.
|
||||
logger.info(
|
||||
"Participant %s left room %s before role sync",
|
||||
participant_identity,
|
||||
room_name,
|
||||
)
|
||||
return False
|
||||
except ParticipantsManagementException:
|
||||
logger.exception(
|
||||
"Could not sync role to LiveKit for participant %s in room %s",
|
||||
participant_identity,
|
||||
room_name,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Asynchronous tasks of the core application.
|
||||
|
||||
Importing the task modules here is what makes Celery's `autodiscover_tasks`
|
||||
register them: it only imports the `core.tasks` package itself, never its
|
||||
submodules.
|
||||
"""
|
||||
|
||||
from core.tasks.file import process_file_deletion
|
||||
from core.tasks.push_recording import push_recording
|
||||
|
||||
__all__ = ["process_file_deletion", "push_recording"]
|
||||
@@ -1,4 +1,7 @@
|
||||
"""Celery-optional task decorator."""
|
||||
|
||||
# ruff: noqa: PLC0415
|
||||
# pylint: disable=import-outside-toplevel
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Task pushing a finished recording to its owner's Drive."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import requests
|
||||
|
||||
from core import models, utils
|
||||
from core.services.drive import API_TIMEOUT, DriveClient, DriveError
|
||||
from core.tasks._task import task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# (connect, read) timeouts for the download leg, in seconds. The read one has to
|
||||
# accommodate a whole recording being relayed.
|
||||
DOWNLOAD_TIMEOUT = (API_TIMEOUT[0], 300)
|
||||
|
||||
|
||||
def _build_filename(recording: models.Recording) -> str:
|
||||
"""Return a human-readable filename for the Drive item."""
|
||||
|
||||
return (
|
||||
f"{recording.room.slug}-"
|
||||
f"{recording.created_at:%Y-%m-%d-%H-%M}."
|
||||
f"{recording.extension}"
|
||||
)
|
||||
|
||||
|
||||
@task
|
||||
def push_recording(recording_id: str) -> bool:
|
||||
"""Push a recording to the main workspace of the user who started it.
|
||||
|
||||
The recording is streamed straight from object storage to Drive's presigned
|
||||
URL: it is never fully downloaded to the worker's disk or memory.
|
||||
|
||||
The access token parked when the recording started is consumed here, and
|
||||
dropped afterwards whatever the outcome — it is a user credential, and a
|
||||
replay would need a fresh one anyway.
|
||||
|
||||
Mostly taken from: https://github.com/suitenumerique/drive/blob/main/docs/resource_server.md
|
||||
"""
|
||||
|
||||
try:
|
||||
recording = models.Recording.objects.select_related("room").get(pk=recording_id)
|
||||
except models.Recording.DoesNotExist:
|
||||
logger.error(
|
||||
"Recording %s does not exist, cannot push it to Drive", recording_id
|
||||
)
|
||||
return False
|
||||
|
||||
access_token = recording.get_owner_access_token()
|
||||
|
||||
if not access_token:
|
||||
logger.error(
|
||||
"No access token stored for recording %s, cannot push it to Drive. "
|
||||
"Was OIDC_STORE_ACCESS_TOKEN enabled when the recording started?",
|
||||
recording_id,
|
||||
)
|
||||
return False
|
||||
|
||||
download_url = utils.generate_download_s3_url(
|
||||
recording.key,
|
||||
expires_in=settings.RECORDING_PUSH_TO_DRIVE_SIGNED_URL_EXPIRY_SECONDS,
|
||||
override_domain=False,
|
||||
)
|
||||
filename = _build_filename(recording)
|
||||
|
||||
try:
|
||||
with DriveClient(access_token) as drive:
|
||||
workspace = drive.get_main_workspace()
|
||||
item = drive.create_file(parent_id=workspace["id"], filename=filename)
|
||||
|
||||
# The bytes are relayed chunk by chunk: the recording is never held
|
||||
# in memory as a whole.
|
||||
with requests.get(
|
||||
download_url, stream=True, timeout=DOWNLOAD_TIMEOUT
|
||||
) as download:
|
||||
download.raise_for_status()
|
||||
|
||||
content_length = download.headers.get("Content-Length")
|
||||
if content_length is None:
|
||||
raise DriveError(
|
||||
"Object storage did not return the recording size, "
|
||||
"cannot stream it to Drive."
|
||||
)
|
||||
|
||||
drive.upload_content(
|
||||
policy_url=item["policy"],
|
||||
stream=download.raw,
|
||||
content_length=int(content_length),
|
||||
content_type=download.headers.get(
|
||||
"Content-Type", "application/octet-stream"
|
||||
),
|
||||
)
|
||||
|
||||
drive.complete_upload(item["id"])
|
||||
|
||||
except (DriveError, requests.RequestException):
|
||||
logger.exception("Failed to push recording %s to Drive", recording_id)
|
||||
return False
|
||||
|
||||
finally:
|
||||
recording.clear_owner_access_token()
|
||||
|
||||
logger.info(
|
||||
"Recording %s pushed to Drive as '%s' (item %s)",
|
||||
recording_id,
|
||||
filename,
|
||||
item["id"],
|
||||
)
|
||||
return True
|
||||
@@ -0,0 +1,318 @@
|
||||
"""
|
||||
Unit tests for PostHogAnalytics.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument,protected-access
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
|
||||
import pytest
|
||||
|
||||
from core.analytics.events import AnalyticsEvent
|
||||
from core.analytics.posthog import PostHogAnalytics
|
||||
from core.analytics.user_feature_flags import UserFeatureFlag
|
||||
from core.factories import UserFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
# ==============================
|
||||
# __init__
|
||||
# ==============================
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_init_constructs_posthog_client_with_api_key_and_host(mock_posthog_cls):
|
||||
"""Should forward api_key and host to the Posthog SDK constructor."""
|
||||
PostHogAnalytics(api_key="my-key", host="https://custom.i.posthog.com")
|
||||
|
||||
mock_posthog_cls.assert_called_once_with(
|
||||
project_api_key="my-key",
|
||||
host="https://custom.i.posthog.com",
|
||||
)
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_init_defaults_to_eu_host(mock_posthog_cls):
|
||||
"""Should default host to the EU PostHog cloud when not specified."""
|
||||
PostHogAnalytics(api_key="my-key")
|
||||
|
||||
_, kwargs = mock_posthog_cls.call_args
|
||||
assert kwargs["host"] == "https://eu.i.posthog.com"
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_init_forwards_extra_kwargs_to_client(mock_posthog_cls):
|
||||
"""Should pass through arbitrary extra kwargs (e.g. debug, disabled) to the SDK."""
|
||||
PostHogAnalytics(api_key="my-key", debug=True, disabled=False)
|
||||
|
||||
_, kwargs = mock_posthog_cls.call_args
|
||||
assert kwargs["debug"] is True
|
||||
assert kwargs["disabled"] is False
|
||||
|
||||
|
||||
# ==============================
|
||||
# _distinct_id
|
||||
# ==============================
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_distinct_id_returns_none_for_none_user(mock_posthog_cls):
|
||||
"""Should return None when user is None."""
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
|
||||
assert backend._distinct_id(None) is None
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_distinct_id_returns_none_for_anonymous_user(mock_posthog_cls):
|
||||
"""Should return None when user.is_authenticated is falsy."""
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
|
||||
assert backend._distinct_id(AnonymousUser()) is None
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_distinct_id_returns_none_when_attribute_missing(mock_posthog_cls):
|
||||
"""Should return None when the user object has no is_authenticated attribute at all."""
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
|
||||
assert backend._distinct_id(object()) is None
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_distinct_id_returns_stringified_pk_for_authenticated_user(mock_posthog_cls):
|
||||
"""Should return str(user.pk) for an authenticated user."""
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
user = UserFactory()
|
||||
|
||||
assert backend._distinct_id(user) == str(user.pk)
|
||||
|
||||
|
||||
# ==============================
|
||||
# identify
|
||||
# ==============================
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_identify_noop_for_anonymous_user(mock_posthog_cls):
|
||||
"""Should not call the SDK when the user is anonymous."""
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
|
||||
backend.identify(AnonymousUser(), {"email": "a@example.com"})
|
||||
|
||||
mock_posthog_cls.return_value.set.assert_not_called()
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_identify_noop_for_none_user(mock_posthog_cls):
|
||||
"""Should not call the SDK when user is None."""
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
|
||||
backend.identify(None, {"email": "a@example.com"})
|
||||
|
||||
mock_posthog_cls.return_value.set.assert_not_called()
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_identify_sends_set_properties_for_authenticated_user(mock_posthog_cls):
|
||||
"""Should call capture with event=$identify and properties wrapped in $set."""
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
user = UserFactory()
|
||||
|
||||
backend.identify(user, {"email": "a@example.com", "name": "A"})
|
||||
|
||||
mock_posthog_cls.return_value.set.assert_called_once_with(
|
||||
distinct_id=str(user.pk),
|
||||
properties={"email": "a@example.com", "name": "A"},
|
||||
)
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_identify_defaults_properties_to_empty_dict(mock_posthog_cls):
|
||||
"""Should send an empty $set payload when properties is None."""
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
user = UserFactory()
|
||||
|
||||
backend.identify(user, None)
|
||||
|
||||
mock_posthog_cls.return_value.set.assert_called_once_with(
|
||||
distinct_id=str(user.pk),
|
||||
properties={},
|
||||
)
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_identify_swallows_sdk_exceptions(mock_posthog_cls):
|
||||
"""Should log and not raise when the SDK call fails."""
|
||||
mock_posthog_cls.return_value.set.side_effect = RuntimeError("network down")
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
user = UserFactory()
|
||||
|
||||
# Must not propagate.
|
||||
backend.identify(user, {"email": "a@example.com"})
|
||||
|
||||
|
||||
# ==============================
|
||||
# capture
|
||||
# ==============================
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_capture_noop_for_anonymous_user(mock_posthog_cls):
|
||||
"""Should not call the SDK when the user is anonymous."""
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
|
||||
backend.capture(AnonymousUser(), AnalyticsEvent.ROOM_CREATED, {"room_id": "1"})
|
||||
|
||||
mock_posthog_cls.return_value.capture.assert_not_called()
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_capture_noop_for_none_user(mock_posthog_cls):
|
||||
"""Should not call the SDK when user is None."""
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
|
||||
backend.capture(None, AnalyticsEvent.ROOM_CREATED, {"room_id": "1"})
|
||||
|
||||
mock_posthog_cls.return_value.capture.assert_not_called()
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_capture_sends_event_and_properties_for_authenticated_user(mock_posthog_cls):
|
||||
"""Should call capture with the distinct_id, event name, and properties."""
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
user = UserFactory()
|
||||
|
||||
backend.capture(user, AnalyticsEvent.ROOM_CREATED, {"room_id": "room-1"})
|
||||
|
||||
mock_posthog_cls.return_value.capture.assert_called_once_with(
|
||||
distinct_id=str(user.pk),
|
||||
event="room_created",
|
||||
properties={"room_id": "room-1"},
|
||||
)
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_capture_serializes_event_enum_to_plain_string(mock_posthog_cls):
|
||||
"""Should send the wire string, not the AnalyticsEvent enum member, to the SDK."""
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
user = UserFactory()
|
||||
|
||||
backend.capture(user, AnalyticsEvent.ROOM_CREATED)
|
||||
|
||||
_, kwargs = mock_posthog_cls.return_value.capture.call_args
|
||||
assert kwargs["event"] == "room_created"
|
||||
assert isinstance(
|
||||
kwargs["event"], str
|
||||
) # not AnalyticsEvent, not StrEnum subclass leaking through
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_capture_defaults_properties_to_empty_dict(mock_posthog_cls):
|
||||
"""Should send an empty properties dict when properties is None."""
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
user = UserFactory()
|
||||
|
||||
backend.capture(user, AnalyticsEvent.ROOM_CREATED, None)
|
||||
|
||||
mock_posthog_cls.return_value.capture.assert_called_once_with(
|
||||
distinct_id=str(user.pk),
|
||||
event="room_created",
|
||||
properties={},
|
||||
)
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_capture_swallows_sdk_exceptions(mock_posthog_cls):
|
||||
"""Should log and not raise when the SDK call fails."""
|
||||
mock_posthog_cls.return_value.capture.side_effect = RuntimeError("network down")
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
user = UserFactory()
|
||||
|
||||
# Must not propagate.
|
||||
backend.capture(user, AnalyticsEvent.ROOM_CREATED, {"room_id": "1"})
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_capture_logs_the_failing_event_name_on_exception(mock_posthog_cls, caplog):
|
||||
"""Should log which event failed, to aid debugging without crashing the caller."""
|
||||
mock_posthog_cls.return_value.capture.side_effect = RuntimeError("network down")
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
user = UserFactory()
|
||||
|
||||
with caplog.at_level("ERROR"):
|
||||
backend.capture(user, AnalyticsEvent.ROOM_CREATED)
|
||||
|
||||
assert any("PostHog capture failed" in record.message for record in caplog.records)
|
||||
|
||||
|
||||
# ==============================
|
||||
# feature flags
|
||||
# ==============================
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_compute_feature_flags_returns_all_catalog_entries(mock_posthog_cls):
|
||||
"""Should map every declared feature flag key to the SDK evaluated value."""
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
user = UserFactory()
|
||||
|
||||
mock_posthog_cls.return_value.evaluate_flags.return_value.get_flag.return_value = (
|
||||
True
|
||||
)
|
||||
|
||||
flags = backend._fetch_user_feature_flags(user)
|
||||
|
||||
assert flags == {UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED: True}
|
||||
mock_posthog_cls.return_value.evaluate_flags.assert_called_once_with(str(user.pk))
|
||||
mock_posthog_cls.return_value.evaluate_flags.return_value.get_flag.assert_called_once_with(
|
||||
UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED.value
|
||||
)
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.cache.get_or_set")
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_get_feature_flags_uses_cache_get_or_set(
|
||||
mock_posthog_cls, mock_cache_get_or_set
|
||||
):
|
||||
"""Should cache feature flags by user distinct id with configured TTL."""
|
||||
cached_flags = {UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED: False}
|
||||
mock_cache_get_or_set.return_value = cached_flags
|
||||
backend = PostHogAnalytics(api_key="test-api-key", feature_flags_cache_ttl=120)
|
||||
user = UserFactory()
|
||||
|
||||
flags = backend.get_user_feature_flags(user)
|
||||
|
||||
assert flags == cached_flags
|
||||
mock_cache_get_or_set.assert_called_once()
|
||||
args, kwargs = mock_cache_get_or_set.call_args
|
||||
assert kwargs["timeout"] == 120
|
||||
assert args[0] == f"user_feature_flags:{user.pk}"
|
||||
assert callable(kwargs["default"])
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_get_feature_flags_returns_empty_dict_on_exception(mock_posthog_cls):
|
||||
"""Should swallow failures and return an empty mapping."""
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
user = UserFactory()
|
||||
with patch("core.analytics.posthog.cache.get_or_set", side_effect=RuntimeError):
|
||||
assert backend.get_user_feature_flags(user) == {}
|
||||
|
||||
|
||||
# ==============================
|
||||
# shutdown
|
||||
# ==============================
|
||||
|
||||
|
||||
@patch("core.analytics.posthog.Posthog")
|
||||
def test_shutdown_flushes_the_client(mock_posthog_cls):
|
||||
"""Should delegate to the SDK's shutdown to flush pending events."""
|
||||
backend = PostHogAnalytics(api_key="test-api-key")
|
||||
|
||||
backend.shutdown()
|
||||
|
||||
mock_posthog_cls.return_value.shutdown.assert_called_once()
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Tests for the clean_pending_files management command."""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.management import call_command
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_clean_pending_files_no_stale_files():
|
||||
"""Nothing happens when there are no stale pending files."""
|
||||
call_command("clean_pending_files")
|
||||
|
||||
|
||||
def test_clean_pending_files_recent_pending_not_deleted():
|
||||
"""Recent pending files (within threshold) should not be deleted."""
|
||||
file = factories.FileFactory(
|
||||
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||
update_upload_state=models.FileUploadStateChoices.PENDING,
|
||||
upload_bytes=b"hello",
|
||||
)
|
||||
|
||||
call_command("clean_pending_files")
|
||||
|
||||
file.refresh_from_db()
|
||||
assert file.deleted_at is None
|
||||
assert default_storage.exists(file.file_key)
|
||||
|
||||
|
||||
def test_clean_pending_files_old_pending_deleted():
|
||||
"""Pending files older than the threshold should be deleted."""
|
||||
old_date = timezone.now() - timedelta(hours=49)
|
||||
file = factories.FileFactory(
|
||||
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||
update_upload_state=models.FileUploadStateChoices.PENDING,
|
||||
upload_bytes=b"hello",
|
||||
)
|
||||
assert default_storage.exists(file.file_key)
|
||||
models.File.objects.filter(pk=file.pk).update(created_at=old_date)
|
||||
|
||||
call_command("clean_pending_files")
|
||||
|
||||
assert not models.File.objects.filter(pk=file.pk).exists()
|
||||
assert not default_storage.exists(file.file_key)
|
||||
|
||||
|
||||
def test_clean_pending_files_old_non_pending_not_deleted():
|
||||
"""Old files that are not pending should not be deleted."""
|
||||
old_date = timezone.now() - timedelta(hours=49)
|
||||
file = factories.FileFactory(
|
||||
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||
update_upload_state=models.FileUploadStateChoices.READY,
|
||||
)
|
||||
models.File.objects.filter(pk=file.pk).update(created_at=old_date)
|
||||
|
||||
call_command("clean_pending_files")
|
||||
|
||||
file.refresh_from_db()
|
||||
assert file.deleted_at is None
|
||||
assert file.hard_deleted_at is None
|
||||
|
||||
|
||||
def test_clean_pending_files_custom_hours():
|
||||
"""The --hours argument controls the age threshold."""
|
||||
old_date = timezone.now() - timedelta(hours=10)
|
||||
file = factories.FileFactory(
|
||||
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||
update_upload_state=models.FileUploadStateChoices.PENDING,
|
||||
upload_bytes=b"hello",
|
||||
)
|
||||
models.File.objects.filter(pk=file.pk).update(created_at=old_date)
|
||||
|
||||
# Default 24h threshold -> file not deleted
|
||||
call_command("clean_pending_files")
|
||||
|
||||
file.refresh_from_db()
|
||||
assert file.deleted_at is None
|
||||
assert default_storage.exists(file.file_key)
|
||||
|
||||
# 8h threshold -> file deleted
|
||||
call_command("clean_pending_files", "--hours=8")
|
||||
|
||||
assert not models.File.objects.filter(pk=file.pk).exists()
|
||||
assert not default_storage.exists(file.file_key)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Tests for the purge_deleted_files management command."""
|
||||
|
||||
from datetime import timedelta
|
||||
from io import StringIO
|
||||
from random import randint
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core.files.storage import default_storage
|
||||
from django.core.management import call_command
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories, models
|
||||
from core.tasks.file import process_file_deletion
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_purge_deleted_files_no_deleted_files(django_assert_num_queries):
|
||||
"""Nothing happens when there are no purgeable files."""
|
||||
with django_assert_num_queries(1):
|
||||
call_command("purge_deleted_files")
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_purge_deleted_files_success(settings):
|
||||
"""
|
||||
Queue deletion for:
|
||||
- hard-deleted files
|
||||
- soft-deleted files past retention period + grace period.
|
||||
"""
|
||||
out = StringIO()
|
||||
|
||||
settings.FILE_PURGE_GRACE_DAYS = grace = randint(1, 20)
|
||||
|
||||
now = timezone.now()
|
||||
purge_now = now - timedelta(days=grace)
|
||||
|
||||
not_deleted_file = factories.FileFactory(
|
||||
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||
upload_bytes=b"hello",
|
||||
)
|
||||
|
||||
with patch("django.utils.timezone.now", return_value=now):
|
||||
not_purgeable_file = factories.FileFactory(
|
||||
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||
upload_bytes=b"hello",
|
||||
)
|
||||
not_purgeable_file.soft_delete()
|
||||
|
||||
with patch("django.utils.timezone.now", return_value=purge_now):
|
||||
purgeable_file = factories.FileFactory(
|
||||
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||
upload_bytes=b"hello",
|
||||
)
|
||||
purgeable_file.soft_delete()
|
||||
|
||||
hard_deleted_file = factories.FileFactory(
|
||||
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||
upload_bytes=b"hello",
|
||||
)
|
||||
hard_deleted_file.soft_delete()
|
||||
hard_deleted_file.hard_delete()
|
||||
|
||||
with patch(
|
||||
"core.management.commands.purge_deleted_files.process_file_deletion.delay",
|
||||
side_effect=process_file_deletion,
|
||||
) as mock_delay:
|
||||
call_command("purge_deleted_files", stdout=out)
|
||||
|
||||
assert "Purged 2 deleted file(s)." in out.getvalue()
|
||||
assert mock_delay.call_count == 2
|
||||
called_ids = {call.args[0] for call in mock_delay.call_args_list}
|
||||
assert called_ids == {purgeable_file.id, hard_deleted_file.id}
|
||||
|
||||
assert models.File.objects.filter(id=not_deleted_file.id).exists()
|
||||
assert models.File.objects.filter(id=not_purgeable_file.id).exists()
|
||||
assert not models.File.objects.filter(id=purgeable_file.id).exists()
|
||||
assert not models.File.objects.filter(id=hard_deleted_file.id).exists()
|
||||
|
||||
assert default_storage.exists(not_deleted_file.file_key)
|
||||
assert default_storage.exists(not_purgeable_file.file_key)
|
||||
assert not default_storage.exists(purgeable_file.file_key)
|
||||
assert not default_storage.exists(hard_deleted_file.file_key)
|
||||
@@ -117,7 +117,7 @@ def test_api_files_create_file_authenticated_success():
|
||||
policy_parsed = urlparse(policy)
|
||||
|
||||
assert policy_parsed.scheme == "http"
|
||||
assert policy_parsed.netloc in ["minio:9000", "localhost:9000"]
|
||||
assert policy_parsed.netloc in ["meet-minio:9000", "minio:9000", "localhost:9000"]
|
||||
assert policy_parsed.path == f"/meet-media-storage/tmp/files/{file.id!s}.png"
|
||||
|
||||
query_params = parse_qs(policy_parsed.query)
|
||||
|
||||
@@ -36,6 +36,26 @@ def test_merge_keeps_most_recently_created_user():
|
||||
assert User.objects.filter(id=user2.id).exists()
|
||||
|
||||
|
||||
def test_merge_user_case_insensitive():
|
||||
"""Emails differing only by case should be treated as duplicates and merged,
|
||||
keeping the most recently created user."""
|
||||
user1 = UserFactory(email="Dup@example.com")
|
||||
user2 = UserFactory(email="dup@example.com")
|
||||
call_command("merge_duplicate_users")
|
||||
|
||||
assert not User.objects.filter(id=user1.id).exists()
|
||||
assert User.objects.filter(id=user2.id).exists()
|
||||
|
||||
user3 = UserFactory(email="joe@example.com")
|
||||
user4 = UserFactory(email="Joe@example.com")
|
||||
call_command("merge_duplicate_users")
|
||||
assert not User.objects.filter(id=user3.id).exists()
|
||||
assert User.objects.filter(id=user4.id).exists()
|
||||
|
||||
user4.refresh_from_db()
|
||||
assert user4.email.islower()
|
||||
|
||||
|
||||
def test_merge_deletes_all_stale_users():
|
||||
"""Command should delete all stale users and keep only the most recently created one."""
|
||||
email = "many@example.com"
|
||||
@@ -457,4 +477,4 @@ def test_merge_email_filter_is_case_insensitive():
|
||||
UserFactory(email="user1@Example.com")
|
||||
UserFactory(email="user1@Example.com")
|
||||
call_command("merge_duplicate_users", email_filter="@example.com")
|
||||
assert User.objects.filter(email="user1@Example.com").count() == 1
|
||||
assert User.objects.filter(email="user1@example.com").count() == 1
|
||||
|
||||
@@ -136,10 +136,10 @@ def test_authenticate_header():
|
||||
|
||||
|
||||
def test_multiple_spaces_in_auth_header(settings):
|
||||
"""Test failure when Authorization header contains multiple spaces."""
|
||||
"""Test success when Authorization header contains multiple spaces."""
|
||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
||||
request = RequestFactory().get("/")
|
||||
request.headers = {"Authorization": "Bearer extra-spaces-token"}
|
||||
|
||||
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
|
||||
StorageEventAuthentication().authenticate(request)
|
||||
header = StorageEventAuthentication().authenticate_header(request)
|
||||
assert header == "Bearer realm='Storage event API'"
|
||||
|
||||
@@ -13,6 +13,7 @@ from django.contrib.sites.models import Site
|
||||
import pytest
|
||||
|
||||
from core import factories, models
|
||||
from core.analytics import UserFeatureFlag
|
||||
from core.recording.event.notification import NotificationService, notification_service
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -243,3 +244,177 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
|
||||
assert result is False
|
||||
assert mock_send_mail.call_count == 2
|
||||
assert "notification could not be sent:" in caplog.text
|
||||
|
||||
|
||||
@mock.patch("core.recording.event.notification.requests.post")
|
||||
@mock.patch("core.recording.event.notification.generate_download_s3_url")
|
||||
@mock.patch.object(
|
||||
NotificationService, "_get_recording_timestamps", new_callable=mock.AsyncMock
|
||||
)
|
||||
def test_notify_summary_service_post_args_with_metadata(
|
||||
mock_get_recording_timestamps,
|
||||
mock_generate_download_s3_url,
|
||||
mock_post,
|
||||
settings,
|
||||
):
|
||||
"""Test summary notification computed request args when metadata is enabled."""
|
||||
settings.SUMMARY_SERVICE_VERSION = 2
|
||||
settings.SUMMARY_SERVICE_ENDPOINT = "https://summary.test/api/v2/tasks"
|
||||
settings.SUMMARY_SERVICE_API_TOKEN = "summary-token"
|
||||
settings.RECORDING_DOWNLOAD_BASE_URL = "https://app.test/recordings"
|
||||
settings.SCREEN_RECORDING_BASE_URL = None
|
||||
settings.METADATA_COLLECTOR_ENABLED = True
|
||||
settings.METADATA_COLLECTOR_OUTPUT_FOLDER = "recordings-metadata"
|
||||
|
||||
recording = factories.RecordingFactory(
|
||||
room__name="Engineering Sync",
|
||||
worker_id="egress-1",
|
||||
options={"collect_metadata": True, "language": "en-us"},
|
||||
)
|
||||
owner = factories.UserFactory(
|
||||
email="owner@test.com",
|
||||
sub="owner-sub",
|
||||
language="fr-fr",
|
||||
timezone="Europe/Paris",
|
||||
)
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER, user=owner
|
||||
)
|
||||
|
||||
started_at = datetime.datetime(2026, 1, 2, 10, 30, tzinfo=datetime.timezone.utc)
|
||||
ended_at = datetime.datetime(2026, 1, 2, 11, 45, tzinfo=datetime.timezone.utc)
|
||||
mock_get_recording_timestamps.return_value = (started_at, ended_at)
|
||||
mock_generate_download_s3_url.side_effect = [
|
||||
"https://storage.test/metadata.json",
|
||||
"https://storage.test/recording.ogg",
|
||||
]
|
||||
|
||||
mock_response = mock.Mock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = {"job_id": "job-42"}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
result = NotificationService._notify_summary_service(recording)
|
||||
recording.refresh_from_db()
|
||||
|
||||
assert result is True
|
||||
assert recording.external_process_id == "job-42"
|
||||
|
||||
metadata_filename = (
|
||||
f"{settings.METADATA_COLLECTOR_OUTPUT_FOLDER}/{recording.id}-metadata.json"
|
||||
)
|
||||
expected_payload = {
|
||||
"user_sub": owner.sub,
|
||||
"user_email": owner.email,
|
||||
"cloud_storage_url": "https://storage.test/recording.ogg",
|
||||
"language": "en-us",
|
||||
"context_language": owner.language,
|
||||
"push_to_docs_config": {
|
||||
"user_email": owner.email,
|
||||
"title": 'Réunion "Engineering Sync" du 2026-01-02 à 11:30',
|
||||
"download_link": f"{settings.RECORDING_DOWNLOAD_BASE_URL}/{recording.id}",
|
||||
"auto_create_summary": False,
|
||||
"form_link": None,
|
||||
},
|
||||
"metadata": {
|
||||
"cloud_storage_url": "https://storage.test/metadata.json",
|
||||
"started_at": started_at.isoformat(),
|
||||
"ended_at": ended_at.isoformat(),
|
||||
},
|
||||
}
|
||||
expected_headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer summary-token",
|
||||
}
|
||||
mock_post.assert_called_once_with(
|
||||
"https://summary.test/api/v2/tasks",
|
||||
json=expected_payload,
|
||||
headers=expected_headers,
|
||||
timeout=30,
|
||||
)
|
||||
assert mock_generate_download_s3_url.call_args_list == [
|
||||
mock.call(metadata_filename, expires_in=60 * 60 * 24, override_domain=False),
|
||||
mock.call(recording.key, expires_in=60 * 60 * 24, override_domain=False),
|
||||
]
|
||||
mock_get_recording_timestamps.assert_awaited_once_with("egress-1")
|
||||
|
||||
|
||||
@mock.patch("core.recording.event.notification.requests.post")
|
||||
@mock.patch("core.recording.event.notification.generate_download_s3_url")
|
||||
@mock.patch.object(
|
||||
NotificationService, "_get_recording_timestamps", new_callable=mock.AsyncMock
|
||||
)
|
||||
@pytest.mark.parametrize("auto_create_summary_enabled", [False, True])
|
||||
def test_notify_summary_service_post_args_without_metadata(
|
||||
mock_get_recording_timestamps,
|
||||
mock_generate_download_s3_url,
|
||||
mock_post,
|
||||
auto_create_summary_enabled,
|
||||
settings,
|
||||
):
|
||||
"""Test summary notification computed request args when metadata is not available."""
|
||||
settings.SUMMARY_SERVICE_VERSION = 2
|
||||
settings.SUMMARY_SERVICE_ENDPOINT = "https://summary.test/api/v2/tasks"
|
||||
settings.SUMMARY_SERVICE_API_TOKEN = "summary-token"
|
||||
settings.RECORDING_DOWNLOAD_BASE_URL = "https://app.test/recordings"
|
||||
settings.SCREEN_RECORDING_BASE_URL = None
|
||||
settings.METADATA_COLLECTOR_ENABLED = False
|
||||
|
||||
recording = factories.RecordingFactory(room__name="Daily")
|
||||
owner = factories.UserFactory(
|
||||
email="owner@test.com",
|
||||
sub="owner-sub",
|
||||
language="en-us",
|
||||
timezone="UTC",
|
||||
)
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER, user=owner
|
||||
)
|
||||
|
||||
mock_get_recording_timestamps.return_value = (None, None)
|
||||
mock_generate_download_s3_url.return_value = "https://storage.test/recording.mp4"
|
||||
|
||||
mock_response = mock.Mock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = {"job_id": "job-51"}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
with mock.patch(
|
||||
"core.recording.event.notification.is_user_feature_flag_enabled",
|
||||
return_value=auto_create_summary_enabled,
|
||||
) as mock_is_feature_flag_enabled:
|
||||
result = NotificationService._notify_summary_service(recording)
|
||||
|
||||
assert result is True
|
||||
expected_payload = {
|
||||
"user_sub": owner.sub,
|
||||
"user_email": owner.email,
|
||||
"cloud_storage_url": "https://storage.test/recording.mp4",
|
||||
"language": "en",
|
||||
"context_language": owner.language,
|
||||
"push_to_docs_config": {
|
||||
"user_email": owner.email,
|
||||
"title": "Transcription",
|
||||
"download_link": f"{settings.RECORDING_DOWNLOAD_BASE_URL}/{recording.id}",
|
||||
"auto_create_summary": auto_create_summary_enabled,
|
||||
"form_link": None,
|
||||
},
|
||||
"metadata": None,
|
||||
}
|
||||
expected_headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer summary-token",
|
||||
}
|
||||
mock_post.assert_called_once_with(
|
||||
"https://summary.test/api/v2/tasks",
|
||||
json=expected_payload,
|
||||
headers=expected_headers,
|
||||
timeout=30,
|
||||
)
|
||||
mock_generate_download_s3_url.assert_called_once_with(
|
||||
recording.key, expires_in=60 * 60 * 24, override_domain=False
|
||||
)
|
||||
mock_get_recording_timestamps.assert_awaited_once_with(recording.worker_id)
|
||||
mock_is_feature_flag_enabled.assert_called_once_with(
|
||||
owner, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||
)
|
||||
|
||||
@@ -360,6 +360,75 @@ def test_s3_parse_unrecognized_extension(s3_parser):
|
||||
s3_parser.parse(event_with_unknown_ext)
|
||||
|
||||
|
||||
def test_s3_parser_keeps_encoded_filepath_compatible(settings):
|
||||
"""Test S3 parser keeps already encoded object keys compatible."""
|
||||
settings.RECORDING_OUTPUT_FOLDER = "recordings"
|
||||
|
||||
recording_id = "80ae9fe5-639a-438b-b86e-9e3dd2d55f4d"
|
||||
parser = S3Parser(bucket_name="recordings-bucket")
|
||||
|
||||
data = {
|
||||
"Records": [
|
||||
{
|
||||
"s3": {
|
||||
"bucket": {"name": "recordings-bucket"},
|
||||
"object": {
|
||||
"key": f"recordings%2F{recording_id}.mp4",
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
assert parser.get_recording_id(data) == recording_id
|
||||
|
||||
|
||||
def test_s3_parser_accepts_unencoded_filepath(settings):
|
||||
"""Test S3 parser accepts raw object keys with slash separators."""
|
||||
settings.RECORDING_OUTPUT_FOLDER = "recordings"
|
||||
|
||||
recording_id = "80ae9fe5-639a-438b-b86e-9e3dd2d55f4d"
|
||||
parser = S3Parser(bucket_name="recordings-bucket")
|
||||
|
||||
data = {
|
||||
"Records": [
|
||||
{
|
||||
"s3": {
|
||||
"bucket": {"name": "recordings-bucket"},
|
||||
"object": {
|
||||
"key": f"recordings/{recording_id}.mp4",
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
assert parser.get_recording_id(data) == recording_id
|
||||
|
||||
|
||||
def test_s3_parser_preserves_plus_signs_in_encoded_filepath(settings):
|
||||
"""Test S3 parser preserves plus signs in already encoded object keys."""
|
||||
settings.RECORDING_OUTPUT_FOLDER = "recordings"
|
||||
|
||||
recording_id = "80ae9fe5-639a-438b-b86e-9e3dd2d55f4d"
|
||||
parser = S3Parser(bucket_name="recordings-bucket")
|
||||
|
||||
data = {
|
||||
"Records": [
|
||||
{
|
||||
"s3": {
|
||||
"bucket": {"name": "recordings-bucket"},
|
||||
"object": {
|
||||
"key": f"folder+name%2Frecordings%2F{recording_id}.mp4",
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
assert parser.get_recording_id(data) == recording_id
|
||||
|
||||
|
||||
def test_s3_get_recording_id_success(s3_parser, valid_s3_event):
|
||||
"""Test successful extraction of recording ID from S3 event."""
|
||||
recording_id = s3_parser.get_recording_id(valid_s3_event)
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
Test recordings API endpoints: external process hook.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
|
||||
import pytest
|
||||
|
||||
from ...factories import RecordingFactory
|
||||
from ...models import RecordingStatusChoices
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def external_process_settings(settings):
|
||||
"""Configure authentication token for the external process webhook."""
|
||||
settings.SUMMARY_SERVICE_WEBHOOK_API_TOKEN = "testWebhookToken"
|
||||
return settings
|
||||
|
||||
|
||||
def test_external_process_event_missing_authorization_header(
|
||||
external_process_settings, client
|
||||
):
|
||||
"""Requests without authorization must be rejected."""
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/external-process-hook/",
|
||||
{"job_id": "job-1", "type": "transcript", "status": "success"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_external_process_event_wrong_bearer_token(external_process_settings, client):
|
||||
"""Requests with invalid bearer token must be rejected."""
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/external-process-hook/",
|
||||
{"job_id": "job-1", "type": "transcript", "status": "success"},
|
||||
HTTP_AUTHORIZATION="Bearer wrongToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_external_process_event_missing_job_id(external_process_settings, client):
|
||||
"""Payload without job_id must fail validation."""
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/external-process-hook/",
|
||||
{"type": "transcript", "status": "success"},
|
||||
HTTP_AUTHORIZATION="Bearer testWebhookToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"job_id": ["This field is required."]}
|
||||
|
||||
|
||||
def test_external_process_event_success_updates_recording_status(
|
||||
external_process_settings, client
|
||||
):
|
||||
"""A successful transcript process should update recording status."""
|
||||
recording = RecordingFactory(
|
||||
status=RecordingStatusChoices.SAVED,
|
||||
external_process_id="job-123",
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/external-process-hook/",
|
||||
{"job_id": "job-123", "type": "transcript", "status": "success"},
|
||||
HTTP_AUTHORIZATION="Bearer testWebhookToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "Event processed."}
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL
|
||||
|
||||
|
||||
def test_external_process_event_failure_updates_recording_status(
|
||||
external_process_settings, client
|
||||
):
|
||||
"""A failing transcript process should update recording status."""
|
||||
recording = RecordingFactory(
|
||||
status=RecordingStatusChoices.SAVED,
|
||||
external_process_id="job-456",
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/external-process-hook/",
|
||||
{"job_id": "job-456", "type": "transcript", "status": "failure"},
|
||||
HTTP_AUTHORIZATION="Bearer testWebhookToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "Event processed."}
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == RecordingStatusChoices.EXTERNAL_PROCESS_FAILED
|
||||
|
||||
|
||||
def test_external_process_event_unknown_recording_is_ignored(
|
||||
external_process_settings, client
|
||||
):
|
||||
"""Unknown job_id should not fail the webhook processing."""
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/external-process-hook/",
|
||||
{"job_id": "missing-job", "type": "transcript", "status": "success"},
|
||||
HTTP_AUTHORIZATION="Bearer testWebhookToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "Event processed."}
|
||||
|
||||
|
||||
def test_external_process_event_non_transcript_event_does_not_change_status(
|
||||
external_process_settings, client
|
||||
):
|
||||
"""Only transcript events should update recording status."""
|
||||
recording = RecordingFactory(
|
||||
status=RecordingStatusChoices.SAVED,
|
||||
external_process_id="job-789",
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/external-process-hook/",
|
||||
{"job_id": "job-789", "type": "thumbnail", "status": "success"},
|
||||
HTTP_AUTHORIZATION="Bearer testWebhookToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "Event processed."}
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == RecordingStatusChoices.SAVED
|
||||
@@ -224,3 +224,44 @@ def test_save_recording_success(recording_settings, mock_get_parser, client, sta
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == RecordingStatusChoices.SAVED
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
@pytest.mark.parametrize("notification_succeeded", [True, False])
|
||||
def test_save_recording_notifies_external_services(
|
||||
mock_notify_external_services,
|
||||
recording_settings,
|
||||
mock_get_parser,
|
||||
client,
|
||||
notification_succeeded,
|
||||
):
|
||||
"""External services should be notified when a recording is saved."""
|
||||
|
||||
recording = RecordingFactory(status="active")
|
||||
|
||||
mock_parser = mock.Mock()
|
||||
mock_parser.get_recording_id.return_value = recording.id
|
||||
mock_get_parser.return_value = mock_parser
|
||||
|
||||
mock_notify_external_services.return_value = notification_succeeded
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/storage-hook/",
|
||||
{"recording_data": "valid-data"},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "Event processed."}
|
||||
|
||||
mock_notify_external_services.assert_called_once_with(recording)
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == (
|
||||
RecordingStatusChoices.NOTIFICATION_SUCCEEDED
|
||||
if notification_succeeded
|
||||
else RecordingStatusChoices.SAVED
|
||||
)
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
"""
|
||||
Test pushing a recording to the owner's Drive.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
|
||||
from core import factories, models
|
||||
from core.recording.event.notification import NotificationService
|
||||
from core.tasks.push_recording import push_recording
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
DRIVE_API = "https://drive.test/external_api/v1.0"
|
||||
DOWNLOAD_URL = "https://storage.test/recordings/recording.mp4"
|
||||
# Signed by Drive with the object storage domain meant for its browser clients.
|
||||
UPLOAD_URL = "http://drive-storage.test:9100/drive-media/item?X-Amz-Signature=deadbeef"
|
||||
INTERNAL_UPLOAD_URL = "http://drive-minio:9000/drive-media/item"
|
||||
|
||||
WORKSPACE_ID = "11111111-1111-4111-8111-111111111111"
|
||||
ITEM_ID = "22222222-2222-4222-8222-222222222222"
|
||||
|
||||
RECORDING_CONTENT = b"fake-recording-bytes"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def recording_with_token():
|
||||
"""Return a recording carrying a parked access token."""
|
||||
|
||||
recording = factories.RecordingFactory(
|
||||
mode=models.RecordingModeChoices.SCREEN_RECORDING
|
||||
)
|
||||
recording.set_owner_access_token("user-access-token")
|
||||
return recording
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mocked_download_url():
|
||||
"""Avoid signing a real S3 URL, the object storage is not the point here."""
|
||||
|
||||
with mock.patch(
|
||||
"core.utils.generate_download_s3_url", return_value=DOWNLOAD_URL
|
||||
) as patched:
|
||||
yield patched
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def upload():
|
||||
"""Capture what gets PUT to the presigned URL.
|
||||
|
||||
The upload sends a stream, but "responses" drains file-like bodies before
|
||||
handing the request over, so what lands here is already the bytes.
|
||||
"""
|
||||
|
||||
captured = {}
|
||||
|
||||
def callback(request):
|
||||
captured["url"] = request.url
|
||||
captured["headers"] = request.headers
|
||||
captured["body"] = request.body
|
||||
return 200, {}, ""
|
||||
|
||||
captured["callback"] = callback
|
||||
return captured
|
||||
|
||||
|
||||
def register_download(with_content_length=True):
|
||||
"""Register the object storage response holding the recording bytes."""
|
||||
|
||||
headers = (
|
||||
{"Content-Length": str(len(RECORDING_CONTENT))} if with_content_length else None
|
||||
)
|
||||
responses.add(
|
||||
responses.GET,
|
||||
DOWNLOAD_URL,
|
||||
body=RECORDING_CONTENT,
|
||||
status=200,
|
||||
headers=headers,
|
||||
content_type="video/mp4",
|
||||
)
|
||||
|
||||
|
||||
def register_drive(upload=None, workspaces=None):
|
||||
"""Register the Drive API calls of a successful upload."""
|
||||
|
||||
responses.add(
|
||||
responses.GET,
|
||||
f"{DRIVE_API}/items/",
|
||||
json={
|
||||
"results": workspaces
|
||||
if workspaces is not None
|
||||
else [
|
||||
{"id": "shared-workspace", "main_workspace": False},
|
||||
{"id": WORKSPACE_ID, "main_workspace": True},
|
||||
],
|
||||
"next": None,
|
||||
},
|
||||
status=200,
|
||||
)
|
||||
responses.add(
|
||||
responses.POST,
|
||||
f"{DRIVE_API}/items/{WORKSPACE_ID}/children/",
|
||||
json={"id": ITEM_ID, "policy": UPLOAD_URL},
|
||||
status=201,
|
||||
)
|
||||
if upload is not None:
|
||||
responses.add_callback(responses.PUT, UPLOAD_URL, callback=upload["callback"])
|
||||
responses.add_callback(
|
||||
responses.PUT, INTERNAL_UPLOAD_URL, callback=upload["callback"]
|
||||
)
|
||||
responses.add(
|
||||
responses.POST,
|
||||
f"{DRIVE_API}/items/{ITEM_ID}/upload-ended/",
|
||||
json={},
|
||||
status=200,
|
||||
)
|
||||
|
||||
|
||||
@override_settings(DRIVE_API_BASE_URL=DRIVE_API, DRIVE_UPLOAD_STORAGE_NETLOC=None)
|
||||
@responses.activate
|
||||
def test_push_recording_uploads_to_the_main_workspace(
|
||||
recording_with_token, mocked_download_url, upload
|
||||
):
|
||||
"""The recording is created in the main workspace, uploaded, then confirmed."""
|
||||
|
||||
register_download()
|
||||
register_drive(upload=upload)
|
||||
|
||||
assert push_recording(str(recording_with_token.id)) is True
|
||||
|
||||
workspaces_call, create_call, ended_call = (
|
||||
responses.calls[0].request,
|
||||
responses.calls[1].request,
|
||||
responses.calls[4].request,
|
||||
)
|
||||
|
||||
assert workspaces_call.url == f"{DRIVE_API}/items/"
|
||||
assert workspaces_call.headers["Authorization"] == "Bearer user-access-token"
|
||||
|
||||
room = recording_with_token.room
|
||||
expected_filename = (
|
||||
f"{room.slug}-{recording_with_token.created_at:%Y-%m-%d-%H-%M}.mp4"
|
||||
)
|
||||
assert expected_filename in create_call.body.decode()
|
||||
|
||||
assert upload["url"] == UPLOAD_URL
|
||||
assert upload["body"] == RECORDING_CONTENT
|
||||
assert upload["headers"]["Content-Length"] == str(len(RECORDING_CONTENT))
|
||||
assert upload["headers"]["Content-Type"] == "video/mp4"
|
||||
assert upload["headers"]["x-amz-acl"] == "private"
|
||||
# The presigned URL carries its own credentials, an extra Authorization
|
||||
# header would make the object storage reject the upload.
|
||||
assert "Authorization" not in upload["headers"]
|
||||
|
||||
assert ended_call.url == f"{DRIVE_API}/items/{ITEM_ID}/upload-ended/"
|
||||
|
||||
|
||||
@override_settings(DRIVE_API_BASE_URL=DRIVE_API, DRIVE_UPLOAD_STORAGE_NETLOC=None)
|
||||
@responses.activate
|
||||
def test_push_recording_drops_the_access_token(
|
||||
recording_with_token, mocked_download_url, upload
|
||||
):
|
||||
"""The parked credential does not outlive the push."""
|
||||
|
||||
register_download()
|
||||
register_drive(upload=upload)
|
||||
|
||||
push_recording(str(recording_with_token.id))
|
||||
|
||||
recording_with_token.refresh_from_db()
|
||||
assert recording_with_token.owner_access_token is None
|
||||
|
||||
|
||||
@override_settings(DRIVE_API_BASE_URL=DRIVE_API, DRIVE_UPLOAD_STORAGE_NETLOC=None)
|
||||
@responses.activate
|
||||
def test_push_recording_drops_the_access_token_on_failure(
|
||||
recording_with_token, mocked_download_url, upload
|
||||
):
|
||||
"""A failed push does not leave the credential behind either."""
|
||||
|
||||
register_download()
|
||||
register_drive(
|
||||
upload=upload, workspaces=[{"id": "shared", "main_workspace": False}]
|
||||
)
|
||||
|
||||
assert push_recording(str(recording_with_token.id)) is False
|
||||
|
||||
recording_with_token.refresh_from_db()
|
||||
assert recording_with_token.owner_access_token is None
|
||||
|
||||
|
||||
@override_settings(DRIVE_API_BASE_URL=DRIVE_API)
|
||||
@responses.activate
|
||||
def test_push_recording_without_parked_token():
|
||||
"""Without a token there is nobody to push on behalf of, so nothing happens."""
|
||||
|
||||
recording = factories.RecordingFactory()
|
||||
|
||||
assert push_recording(str(recording.id)) is False
|
||||
assert not responses.calls
|
||||
|
||||
|
||||
@override_settings(DRIVE_API_BASE_URL=DRIVE_API)
|
||||
@responses.activate
|
||||
def test_push_recording_unknown_recording():
|
||||
"""An unknown recording is reported, not raised."""
|
||||
|
||||
assert push_recording("33333333-3333-4333-8333-333333333333") is False
|
||||
assert not responses.calls
|
||||
|
||||
|
||||
@override_settings(
|
||||
DRIVE_API_BASE_URL=DRIVE_API, DRIVE_UPLOAD_STORAGE_NETLOC="drive-minio:9000"
|
||||
)
|
||||
@responses.activate
|
||||
def test_push_recording_rewrites_the_upload_host(
|
||||
recording_with_token, mocked_download_url, upload
|
||||
):
|
||||
"""The upload reaches the internal address while announcing the signed host.
|
||||
|
||||
The presigned signature covers the Host header, so it has to stay untouched
|
||||
even when the address we connect to does not.
|
||||
"""
|
||||
|
||||
register_download()
|
||||
register_drive(upload=upload)
|
||||
|
||||
assert push_recording(str(recording_with_token.id)) is True
|
||||
|
||||
assert upload["url"].startswith(INTERNAL_UPLOAD_URL)
|
||||
assert upload["headers"]["Host"] == "drive-storage.test:9100"
|
||||
assert upload["body"] == RECORDING_CONTENT
|
||||
|
||||
|
||||
@override_settings(DRIVE_API_BASE_URL=DRIVE_API, DRIVE_UPLOAD_STORAGE_NETLOC=None)
|
||||
@responses.activate
|
||||
def test_push_recording_without_content_length(
|
||||
recording_with_token, mocked_download_url, upload
|
||||
):
|
||||
"""A size-less download cannot be relayed, and is reported as a failure."""
|
||||
|
||||
register_download(with_content_length=False)
|
||||
register_drive(upload=upload)
|
||||
|
||||
assert push_recording(str(recording_with_token.id)) is False
|
||||
assert "body" not in upload
|
||||
|
||||
|
||||
@override_settings(DRIVE_API_BASE_URL=None)
|
||||
def test_push_recording_without_drive_configured(
|
||||
recording_with_token, mocked_download_url
|
||||
):
|
||||
"""An unconfigured Drive is reported, not raised."""
|
||||
|
||||
assert push_recording(str(recording_with_token.id)) is False
|
||||
|
||||
|
||||
def test_recording_access_token_roundtrip():
|
||||
"""The parked token is encrypted at rest and read back as-is."""
|
||||
|
||||
recording = factories.RecordingFactory()
|
||||
recording.set_owner_access_token("user-access-token")
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.owner_access_token != "user-access-token"
|
||||
assert recording.get_owner_access_token() == "user-access-token"
|
||||
|
||||
recording.clear_owner_access_token()
|
||||
recording.refresh_from_db()
|
||||
assert recording.get_owner_access_token() is None
|
||||
|
||||
|
||||
def test_recording_access_token_undecryptable():
|
||||
"""A token encrypted with another key is reported as missing, not raised."""
|
||||
|
||||
recording = factories.RecordingFactory(owner_access_token="not-a-fernet-token")
|
||||
|
||||
assert recording.get_owner_access_token() is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enabled", [True, False])
|
||||
def test_notify_external_services_schedules_the_push(enabled):
|
||||
"""The push is scheduled from the notification flow, when enabled."""
|
||||
|
||||
recording = factories.RecordingFactory(
|
||||
mode=models.RecordingModeChoices.SCREEN_RECORDING
|
||||
)
|
||||
recording.set_owner_access_token("user-access-token")
|
||||
|
||||
with (
|
||||
override_settings(RECORDING_PUSH_TO_DRIVE_ENABLED=enabled),
|
||||
mock.patch("core.recording.event.notification.push_recording") as mocked_push,
|
||||
mock.patch.object(
|
||||
NotificationService, "_notify_user_by_email", return_value=True
|
||||
),
|
||||
):
|
||||
NotificationService().notify_external_services(recording)
|
||||
|
||||
assert mocked_push.delay.called is enabled
|
||||
|
||||
|
||||
def test_notify_external_services_without_parked_token():
|
||||
"""No token means nothing to push with, so no task is scheduled."""
|
||||
|
||||
recording = factories.RecordingFactory(
|
||||
mode=models.RecordingModeChoices.SCREEN_RECORDING
|
||||
)
|
||||
|
||||
with (
|
||||
override_settings(RECORDING_PUSH_TO_DRIVE_ENABLED=True),
|
||||
mock.patch("core.recording.event.notification.push_recording") as mocked_push,
|
||||
mock.patch.object(
|
||||
NotificationService, "_notify_user_by_email", return_value=True
|
||||
),
|
||||
):
|
||||
NotificationService().notify_external_services(recording)
|
||||
|
||||
assert not mocked_push.delay.called
|
||||
@@ -34,10 +34,8 @@ def mediator(mock_worker_service):
|
||||
return WorkerServiceMediator(mock_worker_service)
|
||||
|
||||
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_start_recording_success(
|
||||
mock_update_room_metadata, mediator, mock_worker_service
|
||||
):
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_start_recording_success(mock_update_metadata, mediator, mock_worker_service):
|
||||
"""Test successful recording start"""
|
||||
# Setup
|
||||
worker_id = "test-worker-123"
|
||||
@@ -60,7 +58,7 @@ def test_start_recording_success(
|
||||
assert mock_recording.worker_id == worker_id
|
||||
assert mock_recording.status == RecordingStatusChoices.ACTIVE
|
||||
|
||||
mock_update_room_metadata.assert_called_once_with(
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
str(mock_recording.room.id),
|
||||
{"recording_mode": mock_recording.mode, "recording_status": "starting"},
|
||||
)
|
||||
@@ -69,9 +67,9 @@ def test_start_recording_success(
|
||||
@pytest.mark.parametrize(
|
||||
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
|
||||
)
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_mediator_start_recording_worker_errors(
|
||||
mock_update_room_metadata, mediator, mock_worker_service, error_class
|
||||
mock_update_metadata, mediator, mock_worker_service, error_class
|
||||
):
|
||||
"""Test handling of various worker errors during start"""
|
||||
# Setup
|
||||
@@ -89,7 +87,7 @@ def test_mediator_start_recording_worker_errors(
|
||||
assert mock_recording.status == RecordingStatusChoices.FAILED_TO_START
|
||||
assert mock_recording.worker_id is None
|
||||
|
||||
mock_update_room_metadata.assert_not_called()
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -103,9 +101,9 @@ def test_mediator_start_recording_worker_errors(
|
||||
RecordingStatusChoices.ABORTED,
|
||||
],
|
||||
)
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_mediator_start_recording_from_forbidden_status(
|
||||
mock_update_room_metadata, mediator, mock_worker_service, status
|
||||
mock_update_metadata, mediator, mock_worker_service, status
|
||||
):
|
||||
"""Test handling of various worker errors during start"""
|
||||
# Setup
|
||||
@@ -119,7 +117,7 @@ def test_mediator_start_recording_from_forbidden_status(
|
||||
mock_recording.refresh_from_db()
|
||||
assert mock_recording.status == status
|
||||
|
||||
mock_update_room_metadata.assert_not_called()
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
|
||||
def test_mediator_stop_recording_success(mediator, mock_worker_service):
|
||||
|
||||
@@ -80,7 +80,7 @@ def test_mute_participant_with_livekit_token_for_this_room(mock_livekit_client):
|
||||
room = RoomFactory()
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
token = utils.generate_token(str(room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
@@ -106,7 +106,7 @@ def test_mute_participant_with_livekit_token_for_another_room_forbidden(
|
||||
other_room = RoomFactory()
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=False)
|
||||
token = utils.generate_token(str(other_room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
|
||||
response = client.post(
|
||||
@@ -146,7 +146,7 @@ def test_mute_participant_everyone_can_mute_disabled_blocks_non_admin(
|
||||
room = RoomFactory(configuration={"everyone_can_mute": False})
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
token = utils.generate_token(str(room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
@@ -293,7 +293,7 @@ def test_mute_participant_admin_with_token_for_this_room(mock_livekit_client):
|
||||
)
|
||||
# Token identity matches the admin user so LiveKitTokenAuthentication
|
||||
# resolves request.user back to the admin.
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=True)
|
||||
token = utils.generate_token(str(room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
@@ -323,7 +323,7 @@ def test_mute_participant_admin_with_token_for_another_room(mock_livekit_client)
|
||||
# Token is scoped to a DIFFERENT room, and admin status must only be
|
||||
# honored when established via session, never via a LiveKit
|
||||
# token, which can be replayed off-host.
|
||||
token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=True)
|
||||
token = utils.generate_token(str(other_room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
|
||||
response = client.post(
|
||||
@@ -354,7 +354,7 @@ def test_mute_participant_admin_token_replayed_does_not_grant_admin(
|
||||
role=random.choice(["administrator", "owner"]),
|
||||
)
|
||||
# The token is the only credential.
|
||||
token = utils.generate_token(str(room.id), admin_user, is_admin_or_owner=True)
|
||||
token = utils.generate_token(str(room.id), admin_user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
@@ -374,7 +374,7 @@ def test_mute_participant_livekit_token_triggers_presence_check(mock_livekit_cli
|
||||
room = RoomFactory()
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
token = utils.generate_token(str(room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
@@ -405,7 +405,7 @@ def test_mute_participant_livekit_token_presence_check_returns_participant(
|
||||
)
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
token = utils.generate_token(str(room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
@@ -433,7 +433,7 @@ def test_mute_participant_livekit_token_presence_check_participant_not_found(
|
||||
)
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
token = utils.generate_token(str(room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
@@ -462,7 +462,7 @@ def test_mute_participant_livekit_token_presence_check_twirp_error_forbidden(
|
||||
)
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
token = utils.generate_token(str(room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
@@ -719,13 +719,13 @@ def test_update_participant_invalid_payload():
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {"participant_identity": "invalid-uuid"}
|
||||
payload = {"participant_identity": ["test"]}
|
||||
|
||||
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)
|
||||
assert "Not a valid string." in str(response.data)
|
||||
|
||||
|
||||
def test_update_participant_no_update_fields():
|
||||
@@ -918,7 +918,7 @@ def test_remove_participant_invalid_payload():
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {"participant_identity": "invalid-uuid"}
|
||||
payload = {"participant_identity": ["invalid-uuid"]}
|
||||
|
||||
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
@@ -12,7 +12,7 @@ import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||
from ...models import RoomAccessLevel
|
||||
from ...models import RoleChoices, RoomAccessLevel
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -31,7 +31,6 @@ def test_api_rooms_retrieve_anonymous_private_pk():
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -51,7 +50,6 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
|
||||
"configuration": {},
|
||||
"access_level": "trusted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -70,7 +68,6 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -87,7 +84,6 @@ def test_api_rooms_retrieve_anonymous_private_slug():
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -104,7 +100,6 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -130,6 +125,9 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed(mock_token):
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": None,
|
||||
"slug": "unregistered-room",
|
||||
"access_level": "public",
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": "unregistered-room",
|
||||
@@ -162,6 +160,9 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed_not_normalized(mock_t
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"id": None,
|
||||
"slug": "reunion",
|
||||
"access_level": "public",
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": "reunion",
|
||||
@@ -208,7 +209,6 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
|
||||
"configuration": {},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
@@ -255,7 +255,6 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
@@ -272,7 +271,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
username=None,
|
||||
color=None,
|
||||
sources=["camera"],
|
||||
is_admin_or_owner=False,
|
||||
role=None,
|
||||
participant_id=None,
|
||||
)
|
||||
|
||||
@@ -307,7 +306,6 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
||||
"configuration": {},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
@@ -324,7 +322,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
||||
username=None,
|
||||
color=None,
|
||||
sources=None,
|
||||
is_admin_or_owner=False,
|
||||
role=None,
|
||||
participant_id=None,
|
||||
)
|
||||
|
||||
@@ -349,7 +347,6 @@ def test_api_rooms_retrieve_authenticated():
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -395,7 +392,6 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
@@ -412,7 +408,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
username=None,
|
||||
color=None,
|
||||
sources=["camera"],
|
||||
is_admin_or_owner=False,
|
||||
role=str(RoleChoices.MEMBER),
|
||||
participant_id=None,
|
||||
)
|
||||
|
||||
@@ -487,7 +483,6 @@ def test_api_rooms_retrieve_administrators(
|
||||
assert content_dict == {
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": True,
|
||||
"configuration": {},
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
@@ -505,6 +500,6 @@ def test_api_rooms_retrieve_administrators(
|
||||
username=None,
|
||||
color=None,
|
||||
sources=None,
|
||||
is_admin_or_owner=True,
|
||||
role=str(user_access.role),
|
||||
participant_id=None,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
"""
|
||||
Test rooms API endpoints in the Meet core app: update-participant-role.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
from ...models import ResourceAccess, RoleChoices
|
||||
from ...services.participants_management import ParticipantNotFoundException
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_update_participant_role_anonymous():
|
||||
"""Anonymous requesters are rejected."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": "some-identity", "role": "administrator"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_update_participant_role_requires_privileges():
|
||||
"""A simple member cannot promote other participants."""
|
||||
client = APIClient()
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.MEMBER)])
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": "some-identity", "role": "administrator"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_requester_not_in_meeting(mock_perm_pm):
|
||||
"""An admin who is not connected to the meeting is rejected."""
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = False
|
||||
client = APIClient()
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.ADMIN)])
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": "some-identity", "role": "administrator"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
mock_perm_pm.return_value.check_if_in_meeting.assert_called_once_with(
|
||||
room_name=str(room.pk), identity=str(user.sub)
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_cannot_target_self(mock_perm_pm):
|
||||
"""Requesters cannot change their own role."""
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||
client = APIClient()
|
||||
user = UserFactory(sub=uuid.uuid4())
|
||||
room = RoomFactory(users=[(user, RoleChoices.ADMIN)])
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": user.sub, "role": "member"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {"error": "You cannot change your own role."}
|
||||
|
||||
|
||||
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
|
||||
@mock.patch("core.services.room_roles.ParticipantsManagement")
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_promotes_authenticated_target(
|
||||
mock_perm_pm, mock_svc_pm, mock_sync
|
||||
):
|
||||
"""Promoting a connected, authenticated participant persists the role."""
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_sync.return_value = True
|
||||
|
||||
client = APIClient()
|
||||
admin = UserFactory()
|
||||
target = UserFactory(sub=uuid.uuid4())
|
||||
room = RoomFactory(users=[(admin, RoleChoices.OWNER)])
|
||||
client.force_login(admin)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": str(target.sub), "role": "administrator"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"role": "administrator",
|
||||
"livekit_synced": True,
|
||||
}
|
||||
access = ResourceAccess.objects.get(resource=room, user=target)
|
||||
assert access.role == RoleChoices.ADMIN
|
||||
mock_sync.assert_called_once_with(
|
||||
room_name=str(room.pk),
|
||||
participant_identity=str(target.sub),
|
||||
role="administrator",
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
|
||||
@mock.patch("core.services.room_roles.ParticipantsManagement")
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_demotes_authenticated_target(
|
||||
mock_perm_pm, mock_svc_pm, mock_sync
|
||||
):
|
||||
"""Demoting a connected admin back to member updates the access row."""
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_sync.return_value = True
|
||||
|
||||
client = APIClient()
|
||||
admin = UserFactory()
|
||||
target = UserFactory(sub=uuid.uuid4())
|
||||
room = RoomFactory(users=[(admin, RoleChoices.OWNER), (target, RoleChoices.ADMIN)])
|
||||
client.force_login(admin)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": str(target.sub), "role": "member"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
access = ResourceAccess.objects.get(resource=room, user=target)
|
||||
assert access.role == RoleChoices.MEMBER
|
||||
|
||||
|
||||
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
|
||||
@mock.patch("core.services.room_roles.ParticipantsManagement")
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_cannot_demote_owner(
|
||||
mock_perm_pm, mock_svc_pm, mock_sync
|
||||
):
|
||||
"""Room owners can never be demoted."""
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
|
||||
|
||||
client = APIClient()
|
||||
admin = UserFactory()
|
||||
owner = UserFactory(sub=uuid.uuid4())
|
||||
room = RoomFactory(users=[(admin, RoleChoices.ADMIN), (owner, RoleChoices.OWNER)])
|
||||
client.force_login(admin)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": str(owner.sub), "role": "member"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {"error": "Room owners cannot be demoted."}
|
||||
assert (
|
||||
ResourceAccess.objects.get(resource=room, user=owner).role == RoleChoices.OWNER
|
||||
)
|
||||
mock_sync.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
|
||||
@mock.patch("core.services.room_roles.ParticipantsManagement")
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_anonymous_target_is_ephemeral(
|
||||
mock_perm_pm, mock_svc_pm, mock_sync
|
||||
):
|
||||
"""Promoting an anonymous participant should not be possible."""
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_sync.return_value = True
|
||||
|
||||
client = APIClient()
|
||||
admin = UserFactory()
|
||||
room = RoomFactory(users=[(admin, RoleChoices.ADMIN)])
|
||||
client.force_login(admin)
|
||||
|
||||
anonymous_identity = uuid.uuid4()
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": anonymous_identity, "role": "administrator"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {
|
||||
"error": "This participant has no user account and cannot be assigned a role."
|
||||
}
|
||||
assert not ResourceAccess.objects.filter(resource=room).exclude(user=admin).exists()
|
||||
|
||||
|
||||
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
|
||||
@mock.patch("core.services.room_roles.ParticipantsManagement")
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_target_not_in_meeting(
|
||||
mock_perm_pm, mock_svc_pm, mock_sync
|
||||
):
|
||||
"""Only connected participants can be promoted or demoted."""
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_svc_pm.return_value.check_if_in_meeting.side_effect = (
|
||||
ParticipantNotFoundException("Participant does not exist")
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
admin = UserFactory()
|
||||
target = UserFactory(sub=uuid.uuid4())
|
||||
room = RoomFactory(users=[(admin, RoleChoices.ADMIN)])
|
||||
client.force_login(admin)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": str(target.sub), "role": "administrator"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert not ResourceAccess.objects.filter(resource=room, user=target).exists()
|
||||
mock_sync.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
|
||||
@mock.patch("core.services.room_roles.ParticipantsManagement")
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_is_idempotent_and_resyncs(
|
||||
mock_perm_pm, mock_svc_pm, mock_sync
|
||||
):
|
||||
"""Promoting an existing admin succeeds and still re-syncs LiveKit."""
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_sync.return_value = True
|
||||
|
||||
client = APIClient()
|
||||
admin = UserFactory()
|
||||
target = UserFactory(sub=uuid.uuid4())
|
||||
room = RoomFactory(users=[(admin, RoleChoices.OWNER), (target, RoleChoices.ADMIN)])
|
||||
client.force_login(admin)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": str(target.sub), "role": "administrator"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_sync.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
|
||||
@mock.patch("core.services.room_roles.ParticipantsManagement")
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_livekit_failure_reports_partial_success(
|
||||
mock_perm_pm, mock_svc_pm, mock_sync
|
||||
):
|
||||
"""A LiveKit sync failure does not lose the persisted role."""
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_sync.return_value = False
|
||||
|
||||
client = APIClient()
|
||||
admin = UserFactory()
|
||||
target = UserFactory(sub=uuid.uuid4())
|
||||
room = RoomFactory(users=[(admin, RoleChoices.OWNER)])
|
||||
client.force_login(admin)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": str(target.sub), "role": "administrator"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"role": "administrator",
|
||||
"livekit_synced": False,
|
||||
}
|
||||
assert (
|
||||
ResourceAccess.objects.get(resource=room, user=target).role == RoleChoices.ADMIN
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_rejects_owner_role(mock_perm_pm):
|
||||
"""The owner role can never be granted through this endpoint."""
|
||||
client = APIClient()
|
||||
admin = UserFactory()
|
||||
room = RoomFactory(users=[(admin, RoleChoices.ADMIN)])
|
||||
client.force_login(admin)
|
||||
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": "some-identity", "role": "owner"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
@@ -6,6 +6,8 @@ Test LiveKitEvents service.
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
from livekit.api import EgressStatus
|
||||
|
||||
@@ -20,8 +22,9 @@ from core.services.livekit_events import (
|
||||
api,
|
||||
)
|
||||
from core.services.lobby import LobbyService
|
||||
from core.services.room_management import RoomManagementException
|
||||
from core.services.telephony import TelephonyException, TelephonyService
|
||||
from core.utils import MetadataUpdateException, NotificationError
|
||||
from core.utils import NotificationError
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -70,9 +73,11 @@ def test_initialization(
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
# Without storage events, completion falls back to the egress event itself.
|
||||
@override_settings(RECORDING_STORAGE_EVENT_ENABLE=False)
|
||||
def test_handle_egress_ended_success(
|
||||
mock_update_room_metadata, mock_notify, mode, notification_type, service
|
||||
mock_update_metadata, mock_notify, mode, notification_type, service
|
||||
):
|
||||
"""Should successfully stop recording and notifies all participant."""
|
||||
|
||||
@@ -86,12 +91,14 @@ def test_handle_egress_ended_success(
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||
)
|
||||
mock_update_room_metadata.assert_called_once_with(
|
||||
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||
)
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == "stopped"
|
||||
|
||||
# NB: notify_external_services will return False, so status is "saved"
|
||||
assert recording.status == "saved"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -102,9 +109,9 @@ def test_handle_egress_ended_success(
|
||||
(EgressStatus.EGRESS_ABORTED, "aborted"),
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_updated_success(
|
||||
mock_update_room_metadata, egress_status, status, service
|
||||
mock_update_metadata, egress_status, status, service
|
||||
):
|
||||
"""Should successfully update room's metadata."""
|
||||
|
||||
@@ -115,7 +122,7 @@ def test_handle_egress_updated_success(
|
||||
|
||||
service._handle_egress_updated(mock_data)
|
||||
|
||||
mock_update_room_metadata.assert_called_once_with(
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
str(recording.room.id), {"recording_status": status}
|
||||
)
|
||||
|
||||
@@ -127,9 +134,9 @@ def test_handle_egress_updated_success(
|
||||
EgressStatus.EGRESS_LIMIT_REACHED,
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_updated_non_handled(
|
||||
mock_update_room_metadata, egress_status, service
|
||||
mock_update_metadata, egress_status, service
|
||||
):
|
||||
"""Should ignore certain egress status and don't trigger metadata updates."""
|
||||
|
||||
@@ -140,7 +147,7 @@ def test_handle_egress_updated_non_handled(
|
||||
|
||||
service._handle_egress_updated(mock_data)
|
||||
|
||||
mock_update_room_metadata.assert_not_called()
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -151,18 +158,20 @@ def test_handle_egress_updated_non_handled(
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
# Without storage events, completion falls back to the egress event itself.
|
||||
@override_settings(RECORDING_STORAGE_EVENT_ENABLE=False)
|
||||
def test_handle_egress_ended_metadata_update_fails(
|
||||
mock_update_room_metadata, mock_notify, mode, notification_type, service
|
||||
mock_update_metadata, mock_notify, mode, notification_type, service
|
||||
):
|
||||
"""Should successfully stop recording when metadata's update fails."""
|
||||
"""Should successfully stop and save recording when metadata's update fails."""
|
||||
|
||||
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
|
||||
|
||||
mock_update_room_metadata.side_effect = MetadataUpdateException("Error notifying")
|
||||
mock_update_metadata.side_effect = RoomManagementException("Error notifying")
|
||||
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
@@ -170,13 +179,15 @@ def test_handle_egress_ended_metadata_update_fails(
|
||||
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||
)
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == "stopped"
|
||||
|
||||
# NB: notify_external_services will return False, so status is "saved"
|
||||
assert recording.status == "saved"
|
||||
|
||||
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_notification_fails(
|
||||
mock_update_room_metadata, mock_notify, service
|
||||
mock_update_metadata, mock_notify, service
|
||||
):
|
||||
"""Should raise ActionFailedError when notification fails but still stop recording."""
|
||||
|
||||
@@ -196,15 +207,15 @@ def test_handle_egress_ended_notification_fails(
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == "stopped"
|
||||
|
||||
mock_update_room_metadata.assert_called_once_with(
|
||||
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_recording_not_found(
|
||||
mock_update_room_metadata, mock_notify, service
|
||||
mock_update_metadata, mock_notify, service
|
||||
):
|
||||
"""Should raise ActionFailedError when recording doesn't exist."""
|
||||
|
||||
@@ -219,16 +230,16 @@ def test_handle_egress_ended_recording_not_found(
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_notify.assert_not_called()
|
||||
mock_update_room_metadata.assert_not_called()
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == "active"
|
||||
|
||||
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_recording_not_active(
|
||||
mock_update_room_metadata, mock_notify, service
|
||||
mock_update_metadata, mock_notify, service
|
||||
):
|
||||
"""Should ignore non-active recordings."""
|
||||
|
||||
@@ -240,8 +251,8 @@ def test_handle_egress_ended_recording_not_active(
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_notify.assert_not_called()
|
||||
mock_update_room_metadata.assert_called_once_with(
|
||||
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||
)
|
||||
|
||||
recording.refresh_from_db()
|
||||
@@ -249,9 +260,9 @@ def test_handle_egress_ended_recording_not_active(
|
||||
|
||||
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_recording_not_limit_reached(
|
||||
mock_update_room_metadata, mock_notify, service
|
||||
mock_update_metadata, mock_notify, service
|
||||
):
|
||||
"""Should ignore egress non-limit-reached statuses."""
|
||||
|
||||
@@ -263,16 +274,16 @@ def test_handle_egress_ended_recording_not_limit_reached(
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_notify.assert_not_called()
|
||||
mock_update_room_metadata.assert_called_once_with(
|
||||
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||
)
|
||||
assert recording.status == "stopped"
|
||||
|
||||
|
||||
@mock.patch("core.services.livekit_events.MetadataCollectorService")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_calls_metadata_collector_stop_when_conditions_are_met(
|
||||
mock_update_room_metadata, mock_collector_class, service, settings
|
||||
mock_update_metadata, mock_collector_class, service, settings
|
||||
):
|
||||
"""Should call MetadataCollectorService.stop when it exists."""
|
||||
settings.METADATA_COLLECTOR_ENABLED = True
|
||||
@@ -302,7 +313,7 @@ def test_handle_egress_ended_calls_metadata_collector_stop_when_conditions_are_m
|
||||
],
|
||||
)
|
||||
@mock.patch("core.services.livekit_events.MetadataCollectorService")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditions_not_met(
|
||||
_, mock_collector_class, metadata_enabled, options, service, settings
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
@@ -326,6 +337,143 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
|
||||
mock_collector.stop.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
@pytest.mark.parametrize(
|
||||
"egress_status",
|
||||
[EgressStatus.EGRESS_COMPLETE, EgressStatus.EGRESS_LIMIT_REACHED],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"notify_return_value, recording_status",
|
||||
[(True, "notification_succeeded"), (False, "saved")],
|
||||
)
|
||||
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
|
||||
mock_update_metadata,
|
||||
mock_notify,
|
||||
mock_notify_external_services,
|
||||
notify_return_value,
|
||||
recording_status,
|
||||
egress_status,
|
||||
service,
|
||||
settings,
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
"""Should notify external services and save the recording on egress completion
|
||||
(EGRESS_COMPLETE or EGRESS_LIMIT_REACHED) when RECORDING_STORAGE_EVENT_ENABLE is False.
|
||||
"""
|
||||
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||
mock_notify_external_services.return_value = notify_return_value
|
||||
|
||||
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 = egress_status
|
||||
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_notify_external_services.assert_called_once_with(recording)
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == recording_status
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
@pytest.mark.parametrize(
|
||||
"egress_status, expected_status",
|
||||
[
|
||||
(EgressStatus.EGRESS_COMPLETE, "active"),
|
||||
(EgressStatus.EGRESS_LIMIT_REACHED, "stopped"),
|
||||
],
|
||||
)
|
||||
def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: PLR0913
|
||||
mock_update_metadata,
|
||||
mock_notify,
|
||||
mock_notify_external_services,
|
||||
egress_status,
|
||||
expected_status,
|
||||
service,
|
||||
settings,
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
"""When storage event webhooks are enabled, egress_ended must not finalize the
|
||||
recording: external services are never notified. EGRESS_LIMIT_REACHED still stops
|
||||
the recording, EGRESS_COMPLETE leaves it active.
|
||||
"""
|
||||
settings.RECORDING_STORAGE_EVENT_ENABLE = True
|
||||
|
||||
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 = egress_status
|
||||
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_notify_external_services.assert_not_called()
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == expected_status
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"egress_status",
|
||||
[
|
||||
EgressStatus.EGRESS_STARTING,
|
||||
EgressStatus.EGRESS_ACTIVE,
|
||||
EgressStatus.EGRESS_ENDING,
|
||||
EgressStatus.EGRESS_FAILED,
|
||||
EgressStatus.EGRESS_ABORTED,
|
||||
],
|
||||
)
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_does_not_save_on_wrong_status(
|
||||
mock_update_metadata, egress_status, service, settings
|
||||
):
|
||||
"""Shouldn't save on invalid status."""
|
||||
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||
|
||||
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 = egress_status
|
||||
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == "active"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
|
||||
)
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_ignores_non_savable_recording(
|
||||
mock_update_metadata, status, service, settings
|
||||
):
|
||||
"""Should handle non-savable recordings idempotently without raising.
|
||||
|
||||
'egress_ended' may be redelivered (e.g. for an already-saved recording);
|
||||
this must not raise, otherwise the webhook would 500 and LiveKit would retry.
|
||||
"""
|
||||
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status=status)
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = EgressStatus.EGRESS_COMPLETE
|
||||
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == status
|
||||
|
||||
|
||||
@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(
|
||||
|
||||
@@ -9,13 +9,14 @@ import uuid
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.cache import cache
|
||||
from django.http import HttpResponse
|
||||
|
||||
import pytest
|
||||
|
||||
from core.factories import RoomFactory
|
||||
from core.models import RoomAccessLevel
|
||||
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||
from core.models import RoleChoices, RoomAccessLevel
|
||||
from core.services.lobby import (
|
||||
LobbyParticipant,
|
||||
LobbyParticipantNotFound,
|
||||
@@ -188,17 +189,17 @@ def test_prepare_response_new_cookie(lobby_service, participant_id):
|
||||
|
||||
|
||||
def test_can_bypass_lobby_public_room(lobby_service):
|
||||
"""Should return True for public rooms regardless of user auth."""
|
||||
"""Should return True for public rooms regardless of user auth and role."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
|
||||
# Anonymous user
|
||||
user = mock.Mock()
|
||||
user.is_authenticated = False
|
||||
assert lobby_service.can_bypass_lobby(room, user) is True
|
||||
assert lobby_service.can_bypass_lobby(room, user, role=None) is True
|
||||
|
||||
# Authenticated user
|
||||
user.is_authenticated = True
|
||||
assert lobby_service.can_bypass_lobby(room, user) is True
|
||||
assert lobby_service.can_bypass_lobby(room, user, role=None) is True
|
||||
|
||||
|
||||
def test_can_bypass_lobby_trusted_room_authenticated(lobby_service):
|
||||
@@ -208,7 +209,7 @@ def test_can_bypass_lobby_trusted_room_authenticated(lobby_service):
|
||||
# Authenticated user
|
||||
user = mock.Mock()
|
||||
user.is_authenticated = True
|
||||
assert lobby_service.can_bypass_lobby(room, user) is True
|
||||
assert lobby_service.can_bypass_lobby(room, user, role=None) is True
|
||||
|
||||
|
||||
def test_can_bypass_lobby_trusted_room_anonymous(lobby_service):
|
||||
@@ -218,21 +219,34 @@ def test_can_bypass_lobby_trusted_room_anonymous(lobby_service):
|
||||
# Anonymous user
|
||||
user = mock.Mock()
|
||||
user.is_authenticated = False
|
||||
assert lobby_service.can_bypass_lobby(room, user) is False
|
||||
assert lobby_service.can_bypass_lobby(room, user, role=None) is False
|
||||
|
||||
|
||||
def test_can_bypass_lobby_private_room(lobby_service):
|
||||
"""Should return False for private rooms regardless of user auth."""
|
||||
"""Should return False for private rooms regardless of user auth if role is not."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
# Anonymous user
|
||||
user = mock.Mock()
|
||||
user.is_authenticated = False
|
||||
assert lobby_service.can_bypass_lobby(room, user) is False
|
||||
assert lobby_service.can_bypass_lobby(room, user, role=None) is False
|
||||
|
||||
# Authenticated user
|
||||
user.is_authenticated = True
|
||||
assert lobby_service.can_bypass_lobby(room, user) is False
|
||||
assert lobby_service.can_bypass_lobby(room, user, role=None) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
[RoleChoices.MEMBER, RoleChoices.ADMIN, RoleChoices.OWNER],
|
||||
)
|
||||
def test_can_bypass_lobby_private_room_with_any_role(role, lobby_service):
|
||||
"""Should return True for private rooms if the user is authenticated and has any role."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
user = mock.Mock()
|
||||
user.is_authenticated = True
|
||||
assert lobby_service.can_bypass_lobby(room, user, role=role) is True
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_livekit_config")
|
||||
@@ -241,7 +255,7 @@ def test_request_entry_public_room(
|
||||
):
|
||||
"""Test requesting entry to a public room."""
|
||||
request = mock.Mock()
|
||||
request.user = mock.Mock()
|
||||
request.user = AnonymousUser()
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
|
||||
@@ -266,8 +280,8 @@ def test_request_entry_public_room(
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id="test-participant-id",
|
||||
role=None,
|
||||
)
|
||||
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
@@ -279,8 +293,7 @@ def test_request_entry_trusted_room(
|
||||
):
|
||||
"""Test requesting entry to a trusted room when the user is authenticated."""
|
||||
request = mock.Mock()
|
||||
request.user = mock.Mock()
|
||||
request.user.is_authenticated = True
|
||||
request.user = UserFactory()
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
|
||||
@@ -305,8 +318,8 @@ def test_request_entry_trusted_room(
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id="test-participant-id",
|
||||
role=None,
|
||||
)
|
||||
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
@@ -319,6 +332,7 @@ def test_request_entry_new_participant(
|
||||
"""Test requesting entry for a new participant."""
|
||||
request = mock.Mock()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
request.user = AnonymousUser()
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
@@ -348,6 +362,7 @@ def test_request_entry_waiting_participant(
|
||||
"""Test requesting entry for a waiting participant."""
|
||||
request = mock.Mock()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
request.user = AnonymousUser()
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
@@ -374,7 +389,7 @@ def test_request_entry_accepted_participant(
|
||||
):
|
||||
"""Test requesting entry for an accepted participant."""
|
||||
request = mock.Mock()
|
||||
request.user = mock.Mock()
|
||||
request.user = AnonymousUser()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
@@ -400,8 +415,48 @@ def test_request_entry_accepted_participant(
|
||||
username=username,
|
||||
color="#123456",
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id="test-participant-id",
|
||||
role=None,
|
||||
)
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_livekit_config")
|
||||
def test_request_entry_participant_with_role(
|
||||
mock_generate_config, lobby_service, participant_id, username
|
||||
):
|
||||
"""Test requesting entry for a participant with a role on the room."""
|
||||
request = mock.Mock()
|
||||
request.user = UserFactory()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
UserResourceAccessFactory(resource=room, user=request.user, role="administrator")
|
||||
|
||||
mocked_participant = LobbyParticipant(
|
||||
status=LobbyParticipantStatus.ACCEPTED,
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
)
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||
|
||||
mock_generate_config.return_value = {"token": "test-token"}
|
||||
|
||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||
|
||||
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
||||
assert livekit_config == {"token": "test-token"}
|
||||
mock_generate_config.assert_called_once_with(
|
||||
room_id=str(room.id),
|
||||
user=request.user,
|
||||
username=username,
|
||||
color="#123456",
|
||||
configuration=room.configuration,
|
||||
participant_id="test-participant-id",
|
||||
role="administrator",
|
||||
)
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@@ -2,13 +2,109 @@
|
||||
Test utils functions
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from livekit.api import TwirpError
|
||||
|
||||
from core.utils import NotificationError, create_livekit_client, notify_participants
|
||||
from core.factories import UserFactory
|
||||
from core.utils import (
|
||||
NotificationError,
|
||||
create_livekit_client,
|
||||
generate_token,
|
||||
notify_participants,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
"""Decode a LiveKit JWT access token for inspection."""
|
||||
return jwt.decode(
|
||||
token,
|
||||
settings.LIVEKIT_CONFIGURATION["api_secret"],
|
||||
algorithms=["HS256"],
|
||||
)
|
||||
|
||||
|
||||
def test_generate_token_authenticated_uses_full_name():
|
||||
"""The token's display name should default to the user's full name."""
|
||||
user = UserFactory(full_name="Jane Doe")
|
||||
|
||||
token = generate_token(room="my-room", user=user)
|
||||
|
||||
claims = decode_token(token)
|
||||
assert claims["name"] == "Jane Doe"
|
||||
assert claims["sub"] == str(user.sub)
|
||||
|
||||
|
||||
def test_generate_token_authenticated_fallback_user_representation():
|
||||
"""
|
||||
When the user has no full name, the token's display name should fall back
|
||||
to the user's string representation.
|
||||
"""
|
||||
user = UserFactory(full_name=None)
|
||||
|
||||
token = generate_token(room="my-room", user=user)
|
||||
|
||||
claims = decode_token(token)
|
||||
assert claims["name"] == str(user)
|
||||
|
||||
|
||||
def test_generate_token_explicit_username_overrides_default():
|
||||
"""An explicitly provided username should take precedence over the full name."""
|
||||
user = UserFactory(full_name="Jane Doe")
|
||||
|
||||
token = generate_token(room="my-room", user=user, username="Custom Name")
|
||||
|
||||
claims = decode_token(token)
|
||||
assert claims["name"] == "Custom Name"
|
||||
|
||||
|
||||
def test_authenticated_username_ignored_when_editing_disabled(settings):
|
||||
"""With editing disabled, an authenticated user's username is ignored."""
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||
user = UserFactory(full_name="Jane Doe")
|
||||
token = generate_token(room="my-room", user=user, username="Custom Name")
|
||||
claims = decode_token(token)
|
||||
assert claims["name"] == "Jane Doe"
|
||||
|
||||
|
||||
def test_authenticated_default_name_unaffected_when_editing_disabled(settings):
|
||||
"""Disabling editing doesn't disturb the default full-name path."""
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||
user = UserFactory(full_name="Jane Doe")
|
||||
token = generate_token(room="my-room", user=user)
|
||||
claims = decode_token(token)
|
||||
assert claims["name"] == "Jane Doe"
|
||||
|
||||
|
||||
def test_anonymous_uses_username_when_provided():
|
||||
"""An anonymous user's provided username is used as the display name."""
|
||||
token = generate_token(room="my-room", user=AnonymousUser(), username="Guest42")
|
||||
claims = decode_token(token)
|
||||
assert claims["name"] == "Guest42"
|
||||
|
||||
|
||||
def test_anonymous_username_used_even_when_editing_disabled(settings):
|
||||
"""The setting governs authenticated users only; anonymous can still set a name."""
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||
token = generate_token(room="my-room", user=AnonymousUser(), username="Guest42")
|
||||
claims = decode_token(token)
|
||||
assert claims["name"] == "Guest42"
|
||||
|
||||
|
||||
def test_anonymous_falls_back_to_anonymous_label():
|
||||
"""With no username, an anonymous user is labelled 'Anonymous'."""
|
||||
token = generate_token(room="my-room", user=AnonymousUser())
|
||||
claims = decode_token(token)
|
||||
assert claims["name"] == "Anonymous"
|
||||
|
||||
|
||||
@mock.patch("asyncio.get_running_loop")
|
||||
|
||||
+63
-64
@@ -17,6 +17,7 @@ from typing import List, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
import aiohttp
|
||||
@@ -25,13 +26,13 @@ import botocore
|
||||
import magic
|
||||
import phonenumbers
|
||||
from asgiref.sync import async_to_sync
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from livekit.api import ( # pylint: disable=E0611
|
||||
AccessToken,
|
||||
ListRoomsRequest,
|
||||
LiveKitAPI,
|
||||
SendDataRequest,
|
||||
TwirpError,
|
||||
UpdateRoomMetadataRequest,
|
||||
VideoGrants,
|
||||
)
|
||||
|
||||
@@ -66,7 +67,7 @@ def generate_token(
|
||||
username: Optional[str] = None,
|
||||
color: Optional[str] = None,
|
||||
sources: Optional[List[str]] = None,
|
||||
is_admin_or_owner: bool = False,
|
||||
role: Optional[str] = None,
|
||||
participant_id: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Generate a LiveKit access token for a user in a specific room.
|
||||
@@ -80,7 +81,7 @@ def generate_token(
|
||||
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
|
||||
role (Optional[str]): Room's access role if any
|
||||
participant_id (Optional[str]): Stable identifier for anonymous users;
|
||||
used as identity when user.is_anonymous.
|
||||
|
||||
@@ -88,6 +89,7 @@ def generate_token(
|
||||
str: The LiveKit JWT access token.
|
||||
"""
|
||||
|
||||
is_admin_or_owner = role in ("owner", "administrator")
|
||||
if is_admin_or_owner:
|
||||
sources = settings.LIVEKIT_DEFAULT_SOURCES
|
||||
|
||||
@@ -109,11 +111,16 @@ def generate_token(
|
||||
default_username = "Anonymous"
|
||||
else:
|
||||
identity = str(user.sub)
|
||||
default_username = str(user)
|
||||
default_username = user.full_name or str(user)
|
||||
|
||||
if color is None:
|
||||
color = generate_color(identity)
|
||||
|
||||
can_edit = (
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME or user.is_anonymous
|
||||
)
|
||||
display_name = (username or default_username) if can_edit else default_username
|
||||
|
||||
token = (
|
||||
AccessToken(
|
||||
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
||||
@@ -121,9 +128,13 @@ def generate_token(
|
||||
)
|
||||
.with_grants(video_grants)
|
||||
.with_identity(identity)
|
||||
.with_name(username or default_username)
|
||||
.with_name(display_name)
|
||||
.with_attributes(
|
||||
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
|
||||
{
|
||||
"color": color,
|
||||
"room_role": role,
|
||||
"is_authenticated": "true" if user.is_authenticated else "false",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -134,7 +145,7 @@ def generate_livekit_config(
|
||||
room_id: str,
|
||||
user,
|
||||
username: str,
|
||||
is_admin_or_owner: bool,
|
||||
role: Optional[str] = None,
|
||||
color: Optional[str] = None,
|
||||
configuration: Optional[dict] = None,
|
||||
participant_id: Optional[str] = None,
|
||||
@@ -145,7 +156,7 @@ 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.
|
||||
role (str): Room's access role if any
|
||||
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;
|
||||
@@ -168,7 +179,7 @@ def generate_livekit_config(
|
||||
username=username,
|
||||
color=color,
|
||||
sources=sources,
|
||||
is_admin_or_owner=is_admin_or_owner,
|
||||
role=role,
|
||||
participant_id=participant_id,
|
||||
),
|
||||
}
|
||||
@@ -253,57 +264,6 @@ async def notify_participants(room_name: str, notification_data: dict):
|
||||
await lkapi.aclose()
|
||||
|
||||
|
||||
class MetadataUpdateException(Exception):
|
||||
"""Room's metadata update fails."""
|
||||
|
||||
|
||||
@async_to_sync
|
||||
async def update_room_metadata(
|
||||
room_name: str, metadata: dict, remove_keys: Optional[list[str]] = None
|
||||
):
|
||||
"""Update LiveKit room metadata by merging new values with existing metadata.
|
||||
|
||||
Args:
|
||||
room_name: Name of the room to update
|
||||
metadata: Dictionary of metadata key-values to add/update
|
||||
remove_keys: Optional list of keys to remove from existing metadata.
|
||||
"""
|
||||
|
||||
lkapi = create_livekit_client()
|
||||
|
||||
try:
|
||||
response = await lkapi.room.list_rooms(
|
||||
ListRoomsRequest(
|
||||
names=[room_name],
|
||||
)
|
||||
)
|
||||
|
||||
if not response.rooms:
|
||||
return
|
||||
|
||||
room = response.rooms[0]
|
||||
|
||||
existing_metadata = json.loads(room.metadata) if room.metadata else {}
|
||||
|
||||
if remove_keys:
|
||||
for key in remove_keys:
|
||||
existing_metadata.pop(key, None)
|
||||
|
||||
updated_metadata = {**existing_metadata, **metadata}
|
||||
|
||||
await lkapi.room.update_room_metadata(
|
||||
UpdateRoomMetadataRequest(
|
||||
room=room_name, metadata=json.dumps(updated_metadata).encode("utf-8")
|
||||
)
|
||||
)
|
||||
except TwirpError as e:
|
||||
raise MetadataUpdateException(
|
||||
f"Failed to update metadata for room {room_name}: {e}"
|
||||
) from e
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
|
||||
ALPHANUMERIC_CHARSET = string.ascii_letters + string.digits
|
||||
|
||||
|
||||
@@ -459,12 +419,51 @@ def generate_upload_policy(file):
|
||||
return policy
|
||||
|
||||
|
||||
def generate_download_file_url(file, *, expires_in: int, override_domain: bool = True):
|
||||
"""
|
||||
Generate a S3 signed download url for a given file.
|
||||
class SecretDecryptionError(Exception):
|
||||
"""Raised when a stored secret cannot be decrypted."""
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_cipher_suite():
|
||||
"""Return the Fernet cipher suite used to encrypt secrets at rest.
|
||||
|
||||
Deliberately the same key as django-lasuite's OIDC token storage, so a
|
||||
deployment only has one key to provision for user credentials.
|
||||
"""
|
||||
|
||||
key = file.file_key
|
||||
key = settings.OIDC_STORE_REFRESH_TOKEN_KEY
|
||||
|
||||
if not key:
|
||||
raise ImproperlyConfigured("OIDC_STORE_REFRESH_TOKEN_KEY setting is required.")
|
||||
|
||||
return Fernet(key)
|
||||
|
||||
|
||||
def encrypt_secret(value: str) -> str:
|
||||
"""Encrypt a secret meant to be stored at rest."""
|
||||
|
||||
return get_cipher_suite().encrypt(value.encode()).decode()
|
||||
|
||||
|
||||
def decrypt_secret(value: str) -> str:
|
||||
"""Decrypt a secret stored by `encrypt_secret`."""
|
||||
|
||||
try:
|
||||
return get_cipher_suite().decrypt(value.encode()).decode()
|
||||
except InvalidToken as exc:
|
||||
raise SecretDecryptionError(
|
||||
"The stored secret could not be decrypted, was the key rotated?"
|
||||
) from exc
|
||||
|
||||
|
||||
def generate_download_s3_url(
|
||||
key: str, *, expires_in: int, override_domain: bool = True
|
||||
):
|
||||
"""
|
||||
Generate a S3 signed download url for a given key.
|
||||
"""
|
||||
if not key:
|
||||
raise ValueError("key cannot be empty")
|
||||
|
||||
# This setting should be used if the backend application and the frontend application
|
||||
# can't connect to the object storage with the same domain. This is the case in the
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-04-21 14:09+0000\n"
|
||||
"POT-Creation-Date: 2026-07-02 10:47+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -17,70 +17,92 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#: core/admin.py:29
|
||||
#: core/admin.py:67
|
||||
msgid "Personal info"
|
||||
msgstr "Persönliche Informationen"
|
||||
|
||||
#: core/admin.py:42
|
||||
#: core/admin.py:80
|
||||
msgid "Permissions"
|
||||
msgstr "Berechtigungen"
|
||||
|
||||
#: core/admin.py:54
|
||||
#: core/admin.py:92
|
||||
msgid "Important dates"
|
||||
msgstr "Wichtige Daten"
|
||||
|
||||
#: core/admin.py:132 core/admin.py:275
|
||||
#: core/admin.py:206
|
||||
msgid "Content"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:217
|
||||
#, fuzzy
|
||||
#| msgid "Delete rooms"
|
||||
msgid "Deletion"
|
||||
msgstr "Räume löschen"
|
||||
|
||||
#: core/admin.py:226
|
||||
msgid "Derived info"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:237
|
||||
msgid "Timestamps"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:240
|
||||
msgid "File preview"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:300 core/admin.py:443
|
||||
msgid "No owner"
|
||||
msgstr "Kein Eigentümer"
|
||||
|
||||
#: core/admin.py:135 core/admin.py:278
|
||||
#: core/admin.py:303 core/admin.py:446
|
||||
msgid "Multiple owners"
|
||||
msgstr "Mehrere Eigentümer"
|
||||
|
||||
#: core/admin.py:148
|
||||
#: core/admin.py:316
|
||||
msgid "Resend notification to external service"
|
||||
msgstr "Benachrichtigung erneut an externen Dienst senden"
|
||||
|
||||
#: core/admin.py:171
|
||||
#: core/admin.py:339
|
||||
#, python-format
|
||||
msgid "Failed to notify for recording %(id)s"
|
||||
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen"
|
||||
|
||||
#: core/admin.py:179
|
||||
#: core/admin.py:347
|
||||
#, python-format
|
||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen: %(error)s"
|
||||
|
||||
#: core/admin.py:187
|
||||
#: core/admin.py:355
|
||||
#, python-format
|
||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||
msgstr "Benachrichtigungen für %(count)s Aufnahme(n) erfolgreich gesendet."
|
||||
|
||||
#: core/admin.py:195
|
||||
#: core/admin.py:363
|
||||
#, python-format
|
||||
msgid "Skipped %(count)s expired recording(s)."
|
||||
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
|
||||
|
||||
#: core/admin.py:200
|
||||
#: core/admin.py:368
|
||||
msgid "Mark selected recordings as 'Failed to Stop'"
|
||||
msgstr "Ausgewählte Aufnahmen als ‚Fehler beim Stoppen‘ markieren"
|
||||
|
||||
#: core/admin.py:218
|
||||
#: core/admin.py:386
|
||||
#, python-format
|
||||
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
||||
msgstr "%(count)s Aufnahme(n) erfolgreich als ‚Fehler beim Stoppen‘ markiert."
|
||||
|
||||
#: core/admin.py:226
|
||||
#: core/admin.py:394
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Skipped %(count)s expired recording(s)."
|
||||
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
||||
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
|
||||
|
||||
#: core/admin.py:342
|
||||
#: core/admin.py:510
|
||||
msgid "No scopes"
|
||||
msgstr "Keine Scopes"
|
||||
|
||||
#: core/admin.py:344
|
||||
#: core/admin.py:512
|
||||
msgid "Scopes"
|
||||
msgstr "Scopes"
|
||||
|
||||
@@ -88,17 +110,17 @@ msgstr "Scopes"
|
||||
msgid "Creator is me"
|
||||
msgstr "Ersteller bin ich"
|
||||
|
||||
#: core/api/serializers.py:88
|
||||
#: core/api/serializers.py:89
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr ""
|
||||
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
|
||||
"hinzuzufügen."
|
||||
|
||||
#: core/api/serializers.py:509
|
||||
#: core/api/serializers.py:534
|
||||
msgid "This file extension is not allowed."
|
||||
msgstr "Diese Dateiendung ist nicht erlaubt."
|
||||
|
||||
#: core/api/viewsets.py:1090
|
||||
#: core/api/viewsets.py:1222
|
||||
msgid "You have reached the maximum number of files for this type."
|
||||
msgstr "Sie haben die maximale Anzahl an Dateien dieses Typs erreicht."
|
||||
|
||||
@@ -146,51 +168,59 @@ msgstr "Stopp fehlgeschlagen"
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Benachrichtigung erfolgreich"
|
||||
|
||||
#: core/models.py:89
|
||||
#: core/models.py:65
|
||||
msgid "External process successful"
|
||||
msgstr "Externer Prozess erfolgreich"
|
||||
|
||||
#: core/models.py:67
|
||||
msgid "External process failed"
|
||||
msgstr "Externer Prozess fehlgeschlagen"
|
||||
|
||||
#: core/models.py:96
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "BILDSCHIRMAUFZEICHNUNG"
|
||||
|
||||
#: core/models.py:90
|
||||
#: core/models.py:97
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSKRIPT"
|
||||
|
||||
#: core/models.py:96
|
||||
#: core/models.py:103
|
||||
msgid "Public Access"
|
||||
msgstr "Öffentlicher Zugriff"
|
||||
|
||||
#: core/models.py:97
|
||||
#: core/models.py:104
|
||||
msgid "Trusted Access"
|
||||
msgstr "Vertrauenswürdiger Zugriff"
|
||||
|
||||
#: core/models.py:98
|
||||
#: core/models.py:105
|
||||
msgid "Restricted Access"
|
||||
msgstr "Eingeschränkter Zugriff"
|
||||
|
||||
#: core/models.py:110
|
||||
#: core/models.py:117
|
||||
msgid "id"
|
||||
msgstr "ID"
|
||||
|
||||
#: core/models.py:111
|
||||
#: core/models.py:118
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "Primärschlüssel des Eintrags als UUID"
|
||||
|
||||
#: core/models.py:117
|
||||
#: core/models.py:124
|
||||
msgid "created on"
|
||||
msgstr "erstellt am"
|
||||
|
||||
#: core/models.py:118
|
||||
#: core/models.py:125
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
|
||||
|
||||
#: core/models.py:123
|
||||
#: core/models.py:130
|
||||
msgid "updated on"
|
||||
msgstr "aktualisiert am"
|
||||
|
||||
#: core/models.py:124
|
||||
#: core/models.py:131
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
|
||||
|
||||
#: core/models.py:144
|
||||
#: core/models.py:151
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
@@ -198,11 +228,11 @@ msgstr ""
|
||||
"Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen "
|
||||
"und die Zeichen @/./+/-/_ enthalten."
|
||||
|
||||
#: core/models.py:150
|
||||
#: core/models.py:157
|
||||
msgid "sub"
|
||||
msgstr "Sub"
|
||||
|
||||
#: core/models.py:152
|
||||
#: core/models.py:159
|
||||
msgid ""
|
||||
"Optional for pending users; required upon account activation. 255 characters "
|
||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
@@ -210,55 +240,55 @@ msgstr ""
|
||||
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
|
||||
"Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt."
|
||||
|
||||
#: core/models.py:161
|
||||
#: core/models.py:168
|
||||
msgid "identity email address"
|
||||
msgstr "Identitäts-E-Mail-Adresse"
|
||||
|
||||
#: core/models.py:166
|
||||
#: core/models.py:173
|
||||
msgid "admin email address"
|
||||
msgstr "Administrator-E-Mail-Adresse"
|
||||
|
||||
#: core/models.py:168
|
||||
#: core/models.py:175
|
||||
msgid "full name"
|
||||
msgstr "Vollständiger Name"
|
||||
|
||||
#: core/models.py:170
|
||||
#: core/models.py:177
|
||||
msgid "short name"
|
||||
msgstr "Kurzname"
|
||||
|
||||
#: core/models.py:176
|
||||
#: core/models.py:183
|
||||
msgid "language"
|
||||
msgstr "Sprache"
|
||||
|
||||
#: core/models.py:177
|
||||
#: core/models.py:184
|
||||
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:183
|
||||
#: core/models.py:190
|
||||
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:186
|
||||
#: core/models.py:193
|
||||
msgid "device"
|
||||
msgstr "Gerät"
|
||||
|
||||
#: core/models.py:188
|
||||
#: core/models.py:195
|
||||
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:191
|
||||
#: core/models.py:198
|
||||
msgid "staff status"
|
||||
msgstr "Mitarbeiterstatus"
|
||||
|
||||
#: core/models.py:193
|
||||
#: core/models.py:200
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
|
||||
|
||||
#: core/models.py:196
|
||||
#: core/models.py:203
|
||||
msgid "active"
|
||||
msgstr "aktiv"
|
||||
|
||||
#: core/models.py:199
|
||||
#: core/models.py:206
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
@@ -266,66 +296,66 @@ msgstr ""
|
||||
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
|
||||
"anstelle des Löschens des Kontos."
|
||||
|
||||
#: core/models.py:212
|
||||
#: core/models.py:219
|
||||
msgid "user"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#: core/models.py:213
|
||||
#: core/models.py:220
|
||||
msgid "users"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#: core/models.py:272
|
||||
#: core/models.py:286
|
||||
msgid "Resource"
|
||||
msgstr "Ressource"
|
||||
|
||||
#: core/models.py:273
|
||||
#: core/models.py:287
|
||||
msgid "Resources"
|
||||
msgstr "Ressourcen"
|
||||
|
||||
#: core/models.py:331
|
||||
#: core/models.py:345
|
||||
msgid "Resource access"
|
||||
msgstr "Ressourcenzugriff"
|
||||
|
||||
#: core/models.py:332
|
||||
#: core/models.py:346
|
||||
msgid "Resource accesses"
|
||||
msgstr "Ressourcenzugriffe"
|
||||
|
||||
#: core/models.py:338
|
||||
#: core/models.py:352
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr ""
|
||||
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
|
||||
"bereits."
|
||||
|
||||
#: core/models.py:394
|
||||
#: core/models.py:409
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Visio-Raumkonfiguration"
|
||||
|
||||
#: core/models.py:395
|
||||
#: core/models.py:410
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
|
||||
|
||||
#: core/models.py:402
|
||||
#: core/models.py:417
|
||||
msgid "Room PIN code"
|
||||
msgstr "PIN-Code für den Raum"
|
||||
|
||||
#: core/models.py:403
|
||||
#: core/models.py:418
|
||||
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:409 core/models.py:563
|
||||
#: core/models.py:424 core/models.py:578
|
||||
msgid "Room"
|
||||
msgstr "Raum"
|
||||
|
||||
#: core/models.py:410
|
||||
#: core/models.py:425
|
||||
msgid "Rooms"
|
||||
msgstr "Räume"
|
||||
|
||||
#: core/models.py:574
|
||||
#: core/models.py:589
|
||||
msgid "Worker ID"
|
||||
msgstr "Worker-ID"
|
||||
|
||||
#: core/models.py:576
|
||||
#: core/models.py:591
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
@@ -334,141 +364,153 @@ msgstr ""
|
||||
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
|
||||
"ermöglicht."
|
||||
|
||||
#: core/models.py:584
|
||||
#: core/models.py:599
|
||||
msgid "Recording mode"
|
||||
msgstr "Aufzeichnungsmodus"
|
||||
|
||||
#: core/models.py:585
|
||||
#: core/models.py:600
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
|
||||
|
||||
#: core/models.py:590 core/models.py:591
|
||||
#: core/models.py:605 core/models.py:606
|
||||
msgid "Recording options"
|
||||
msgstr "Aufnahmeoptionen"
|
||||
|
||||
#: core/models.py:597
|
||||
#: core/models.py:613
|
||||
msgid "External Process ID"
|
||||
msgstr "External Process ID"
|
||||
|
||||
#: core/models.py:614
|
||||
msgid "ID of the external process associated with the recording."
|
||||
msgstr "ID des externen Prozesses, der mit der Aufzeichnung verknüpft ist"
|
||||
|
||||
#: core/models.py:620
|
||||
msgid "Recording"
|
||||
msgstr "Aufzeichnung"
|
||||
|
||||
#: core/models.py:598
|
||||
#: core/models.py:621
|
||||
msgid "Recordings"
|
||||
msgstr "Aufzeichnungen"
|
||||
|
||||
#: core/models.py:706
|
||||
#: core/models.py:731
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Beziehung Aufzeichnung/Benutzer"
|
||||
|
||||
#: core/models.py:707
|
||||
#: core/models.py:732
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Beziehungen Aufzeichnung/Benutzer"
|
||||
|
||||
#: core/models.py:713
|
||||
#: core/models.py:738
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
|
||||
|
||||
#: core/models.py:719
|
||||
#: core/models.py:744
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
|
||||
|
||||
#: core/models.py:725
|
||||
#: core/models.py:750
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
|
||||
|
||||
#: core/models.py:742
|
||||
#: core/models.py:767
|
||||
msgid "Create rooms"
|
||||
msgstr "Räume erstellen"
|
||||
|
||||
#: core/models.py:743
|
||||
#: core/models.py:768
|
||||
msgid "List rooms"
|
||||
msgstr "Räume auflisten"
|
||||
|
||||
#: core/models.py:744
|
||||
#: core/models.py:769
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Raumdetails abrufen"
|
||||
|
||||
#: core/models.py:745
|
||||
#: core/models.py:770
|
||||
msgid "Update rooms"
|
||||
msgstr "Räume aktualisieren"
|
||||
|
||||
#: core/models.py:746
|
||||
#: core/models.py:771
|
||||
msgid "Delete rooms"
|
||||
msgstr "Räume löschen"
|
||||
|
||||
#: core/models.py:759
|
||||
#: core/models.py:784
|
||||
msgid "Application name"
|
||||
msgstr "Anwendungsname"
|
||||
|
||||
#: core/models.py:760
|
||||
#: core/models.py:785
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Beschreibender Name für diese Anwendung."
|
||||
|
||||
#: core/models.py:770
|
||||
#: core/models.py:795
|
||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||
msgstr ""
|
||||
"Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
|
||||
|
||||
#: core/models.py:781
|
||||
#: core/models.py:806
|
||||
msgid "Application"
|
||||
msgstr "Anwendung"
|
||||
|
||||
#: core/models.py:782
|
||||
#: core/models.py:807
|
||||
msgid "Applications"
|
||||
msgstr "Anwendungen"
|
||||
|
||||
#: core/models.py:805
|
||||
#: core/models.py:830
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Geben Sie eine gültige Domain ein"
|
||||
|
||||
#: core/models.py:808
|
||||
#: core/models.py:833
|
||||
msgid "Domain"
|
||||
msgstr "Domain"
|
||||
|
||||
#: core/models.py:809
|
||||
#: core/models.py:834
|
||||
msgid "Email domain this application can act on behalf of."
|
||||
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
|
||||
|
||||
#: core/models.py:821
|
||||
#: core/models.py:846
|
||||
msgid "Application domain"
|
||||
msgstr "Anwendungsdomain"
|
||||
|
||||
#: core/models.py:822
|
||||
#: core/models.py:847
|
||||
msgid "Application domains"
|
||||
msgstr "Anwendungsdomains"
|
||||
|
||||
#: core/models.py:840
|
||||
#: core/models.py:865
|
||||
msgid "Pending"
|
||||
msgstr "Ausstehend"
|
||||
|
||||
#: core/models.py:848
|
||||
#: core/models.py:866
|
||||
msgid "Analyzing"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:873
|
||||
msgid "Ready"
|
||||
msgstr "Bereit"
|
||||
|
||||
#: core/models.py:854
|
||||
#: core/models.py:879
|
||||
msgid "Background image"
|
||||
msgstr "Hintergrundbild"
|
||||
|
||||
#: core/models.py:866
|
||||
#: core/models.py:891
|
||||
msgid "title"
|
||||
msgstr "Titel"
|
||||
|
||||
#: core/models.py:890
|
||||
#: core/models.py:915
|
||||
msgid "Malware detection info when the analysis status is unsafe."
|
||||
msgstr ""
|
||||
"Informationen zur Malware-Erkennung, wenn der Analyse-Status unsicher ist."
|
||||
|
||||
#: core/models.py:895
|
||||
#: core/models.py:920
|
||||
msgid "File"
|
||||
msgstr "Datei"
|
||||
|
||||
#: core/models.py:896
|
||||
#: core/models.py:921
|
||||
msgid "Files"
|
||||
msgstr "Dateien"
|
||||
|
||||
#: core/models.py:1000
|
||||
#: core/models.py:1041
|
||||
msgid "This file is already hard deleted."
|
||||
msgstr "Diese Datei wurde bereits endgültig gelöscht."
|
||||
|
||||
#: core/models.py:1010
|
||||
#: core/models.py:1051
|
||||
#, fuzzy
|
||||
#| msgid "To hard delete a file, it must first be soft deleted."
|
||||
msgid "To hard delete a file, it must first be soft deleted."
|
||||
@@ -476,10 +518,20 @@ msgstr ""
|
||||
"Um eine Datei endgültig zu löschen, muss sie zuvor weich gelöscht worden "
|
||||
"sein."
|
||||
|
||||
#: core/recording/event/notification.py:116
|
||||
#: core/recording/event/notification.py:123
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Ihre Aufzeichnung ist bereit"
|
||||
|
||||
#: core/recording/event/notification.py:194
|
||||
msgid "Transcription"
|
||||
msgstr "Transkription"
|
||||
|
||||
#: core/recording/event/notification.py:204
|
||||
#, python-brace-format
|
||||
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
||||
msgstr ""
|
||||
"Besprechung \"{room}\" am {room_recording_date} um {room_recording_time}"
|
||||
|
||||
#: core/services/invitation.py:44
|
||||
#, python-brace-format
|
||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||
@@ -575,7 +627,7 @@ 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
|
||||
#, python-format
|
||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
|
||||
|
||||
@@ -604,18 +656,24 @@ 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:223
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgid "Click the \"Open [%(link)s]\" link below "
|
||||
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
|
||||
|
||||
#: meet/settings.py:228
|
||||
msgid "English"
|
||||
msgstr "Englisch"
|
||||
|
||||
#: meet/settings.py:224
|
||||
#: meet/settings.py:229
|
||||
msgid "French"
|
||||
msgstr "Französisch"
|
||||
|
||||
#: meet/settings.py:225
|
||||
#: meet/settings.py:230
|
||||
msgid "Dutch"
|
||||
msgstr "Niederländisch"
|
||||
|
||||
#: meet/settings.py:226
|
||||
#: meet/settings.py:231
|
||||
msgid "German"
|
||||
msgstr "Deutsch"
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-04-21 14:09+0000\n"
|
||||
"POT-Creation-Date: 2026-07-02 10:47+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -17,70 +17,92 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#: core/admin.py:29
|
||||
#: core/admin.py:67
|
||||
msgid "Personal info"
|
||||
msgstr "Personal info"
|
||||
|
||||
#: core/admin.py:42
|
||||
#: core/admin.py:80
|
||||
msgid "Permissions"
|
||||
msgstr "Permissions"
|
||||
|
||||
#: core/admin.py:54
|
||||
#: core/admin.py:92
|
||||
msgid "Important dates"
|
||||
msgstr "Important dates"
|
||||
|
||||
#: core/admin.py:132 core/admin.py:275
|
||||
#: core/admin.py:206
|
||||
msgid "Content"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:217
|
||||
#, fuzzy
|
||||
#| msgid "Delete rooms"
|
||||
msgid "Deletion"
|
||||
msgstr "Delete rooms"
|
||||
|
||||
#: core/admin.py:226
|
||||
msgid "Derived info"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:237
|
||||
msgid "Timestamps"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:240
|
||||
msgid "File preview"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:300 core/admin.py:443
|
||||
msgid "No owner"
|
||||
msgstr "No owner"
|
||||
|
||||
#: core/admin.py:135 core/admin.py:278
|
||||
#: core/admin.py:303 core/admin.py:446
|
||||
msgid "Multiple owners"
|
||||
msgstr "Multiple owners"
|
||||
|
||||
#: core/admin.py:148
|
||||
#: core/admin.py:316
|
||||
msgid "Resend notification to external service"
|
||||
msgstr "Resend notification to external service"
|
||||
|
||||
#: core/admin.py:171
|
||||
#: core/admin.py:339
|
||||
#, python-format
|
||||
msgid "Failed to notify for recording %(id)s"
|
||||
msgstr "Failed to notify for recording %(id)s"
|
||||
|
||||
#: core/admin.py:179
|
||||
#: core/admin.py:347
|
||||
#, python-format
|
||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||
msgstr "Failed to notify for recording %(id)s: %(error)s"
|
||||
|
||||
#: core/admin.py:187
|
||||
#: core/admin.py:355
|
||||
#, python-format
|
||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||
msgstr "Successfully sent notifications for %(count)s recording(s)."
|
||||
|
||||
#: core/admin.py:195
|
||||
#: core/admin.py:363
|
||||
#, python-format
|
||||
msgid "Skipped %(count)s expired recording(s)."
|
||||
msgstr "Skipped %(count)s expired recording(s)."
|
||||
|
||||
#: core/admin.py:200
|
||||
#: core/admin.py:368
|
||||
msgid "Mark selected recordings as 'Failed to Stop'"
|
||||
msgstr "Mark selected recordings as 'Failed to Stop'"
|
||||
|
||||
#: core/admin.py:218
|
||||
#: core/admin.py:386
|
||||
#, python-format
|
||||
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
||||
msgstr "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
||||
|
||||
#: core/admin.py:226
|
||||
#: core/admin.py:394
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Skipped %(count)s expired recording(s)."
|
||||
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
||||
msgstr "Skipped %(count)s expired recording(s)."
|
||||
|
||||
#: core/admin.py:342
|
||||
#: core/admin.py:510
|
||||
msgid "No scopes"
|
||||
msgstr "No scopes"
|
||||
|
||||
#: core/admin.py:344
|
||||
#: core/admin.py:512
|
||||
msgid "Scopes"
|
||||
msgstr "Scopes"
|
||||
|
||||
@@ -88,15 +110,15 @@ msgstr "Scopes"
|
||||
msgid "Creator is me"
|
||||
msgstr "Creator is me"
|
||||
|
||||
#: core/api/serializers.py:88
|
||||
#: core/api/serializers.py:89
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr "You must be administrator or owner of a room to add accesses to it."
|
||||
|
||||
#: core/api/serializers.py:509
|
||||
#: core/api/serializers.py:534
|
||||
msgid "This file extension is not allowed."
|
||||
msgstr "This file extension is not allowed."
|
||||
|
||||
#: core/api/viewsets.py:1090
|
||||
#: core/api/viewsets.py:1222
|
||||
msgid "You have reached the maximum number of files for this type."
|
||||
msgstr "You have reached the maximum number of files for this type."
|
||||
|
||||
@@ -144,51 +166,59 @@ msgstr "Failed to Stop"
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Notification succeeded"
|
||||
|
||||
#: core/models.py:89
|
||||
#: core/models.py:65
|
||||
msgid "External process successful"
|
||||
msgstr "External process successful"
|
||||
|
||||
#: core/models.py:67
|
||||
msgid "External process failed"
|
||||
msgstr "External process failed"
|
||||
|
||||
#: core/models.py:96
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "SCREEN_RECORDING"
|
||||
|
||||
#: core/models.py:90
|
||||
#: core/models.py:97
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSCRIPT"
|
||||
|
||||
#: core/models.py:96
|
||||
#: core/models.py:103
|
||||
msgid "Public Access"
|
||||
msgstr "Public Access"
|
||||
|
||||
#: core/models.py:97
|
||||
#: core/models.py:104
|
||||
msgid "Trusted Access"
|
||||
msgstr "Trusted Access"
|
||||
|
||||
#: core/models.py:98
|
||||
#: core/models.py:105
|
||||
msgid "Restricted Access"
|
||||
msgstr "Restricted Access"
|
||||
|
||||
#: core/models.py:110
|
||||
#: core/models.py:117
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: core/models.py:111
|
||||
#: core/models.py:118
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "primary key for the record as UUID"
|
||||
|
||||
#: core/models.py:117
|
||||
#: core/models.py:124
|
||||
msgid "created on"
|
||||
msgstr "created on"
|
||||
|
||||
#: core/models.py:118
|
||||
#: core/models.py:125
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "date and time at which a record was created"
|
||||
|
||||
#: core/models.py:123
|
||||
#: core/models.py:130
|
||||
msgid "updated on"
|
||||
msgstr "updated on"
|
||||
|
||||
#: core/models.py:124
|
||||
#: core/models.py:131
|
||||
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:144
|
||||
#: core/models.py:151
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
@@ -196,11 +226,11 @@ msgstr ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
|
||||
#: core/models.py:150
|
||||
#: core/models.py:157
|
||||
msgid "sub"
|
||||
msgstr "sub"
|
||||
|
||||
#: core/models.py:152
|
||||
#: core/models.py:159
|
||||
msgid ""
|
||||
"Optional for pending users; required upon account activation. 255 characters "
|
||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
@@ -208,55 +238,55 @@ msgstr ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
|
||||
#: core/models.py:161
|
||||
#: core/models.py:168
|
||||
msgid "identity email address"
|
||||
msgstr "identity email address"
|
||||
|
||||
#: core/models.py:166
|
||||
#: core/models.py:173
|
||||
msgid "admin email address"
|
||||
msgstr "admin email address"
|
||||
|
||||
#: core/models.py:168
|
||||
#: core/models.py:175
|
||||
msgid "full name"
|
||||
msgstr "full name"
|
||||
|
||||
#: core/models.py:170
|
||||
#: core/models.py:177
|
||||
msgid "short name"
|
||||
msgstr "short name"
|
||||
|
||||
#: core/models.py:176
|
||||
#: core/models.py:183
|
||||
msgid "language"
|
||||
msgstr "language"
|
||||
|
||||
#: core/models.py:177
|
||||
#: core/models.py:184
|
||||
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:183
|
||||
#: core/models.py:190
|
||||
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:186
|
||||
#: core/models.py:193
|
||||
msgid "device"
|
||||
msgstr "device"
|
||||
|
||||
#: core/models.py:188
|
||||
#: core/models.py:195
|
||||
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:191
|
||||
#: core/models.py:198
|
||||
msgid "staff status"
|
||||
msgstr "staff status"
|
||||
|
||||
#: core/models.py:193
|
||||
#: core/models.py:200
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Whether the user can log into this admin site."
|
||||
|
||||
#: core/models.py:196
|
||||
#: core/models.py:203
|
||||
msgid "active"
|
||||
msgstr "active"
|
||||
|
||||
#: core/models.py:199
|
||||
#: core/models.py:206
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
@@ -264,63 +294,63 @@ msgstr ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
|
||||
#: core/models.py:212
|
||||
#: core/models.py:219
|
||||
msgid "user"
|
||||
msgstr "user"
|
||||
|
||||
#: core/models.py:213
|
||||
#: core/models.py:220
|
||||
msgid "users"
|
||||
msgstr "users"
|
||||
|
||||
#: core/models.py:272
|
||||
#: core/models.py:286
|
||||
msgid "Resource"
|
||||
msgstr "Resource"
|
||||
|
||||
#: core/models.py:273
|
||||
#: core/models.py:287
|
||||
msgid "Resources"
|
||||
msgstr "Resources"
|
||||
|
||||
#: core/models.py:331
|
||||
#: core/models.py:345
|
||||
msgid "Resource access"
|
||||
msgstr "Resource access"
|
||||
|
||||
#: core/models.py:332
|
||||
#: core/models.py:346
|
||||
msgid "Resource accesses"
|
||||
msgstr "Resource accesses"
|
||||
|
||||
#: core/models.py:338
|
||||
#: core/models.py:352
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr "Resource access with this User and Resource already exists."
|
||||
|
||||
#: core/models.py:394
|
||||
#: core/models.py:409
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Visio room configuration"
|
||||
|
||||
#: core/models.py:395
|
||||
#: core/models.py:410
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr "Values for Visio parameters to configure the room."
|
||||
|
||||
#: core/models.py:402
|
||||
#: core/models.py:417
|
||||
msgid "Room PIN code"
|
||||
msgstr "Room PIN code"
|
||||
|
||||
#: core/models.py:403
|
||||
#: core/models.py:418
|
||||
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:409 core/models.py:563
|
||||
#: core/models.py:424 core/models.py:578
|
||||
msgid "Room"
|
||||
msgstr "Room"
|
||||
|
||||
#: core/models.py:410
|
||||
#: core/models.py:425
|
||||
msgid "Rooms"
|
||||
msgstr "Rooms"
|
||||
|
||||
#: core/models.py:574
|
||||
#: core/models.py:589
|
||||
msgid "Worker ID"
|
||||
msgstr "Worker ID"
|
||||
|
||||
#: core/models.py:576
|
||||
#: core/models.py:591
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
@@ -328,154 +358,175 @@ msgstr ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
|
||||
#: core/models.py:584
|
||||
#: core/models.py:599
|
||||
msgid "Recording mode"
|
||||
msgstr "Recording mode"
|
||||
|
||||
#: core/models.py:585
|
||||
#: core/models.py:600
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Defines the mode of recording being called."
|
||||
|
||||
#: core/models.py:590 core/models.py:591
|
||||
#: core/models.py:605 core/models.py:606
|
||||
msgid "Recording options"
|
||||
msgstr "Recording options"
|
||||
|
||||
#: core/models.py:597
|
||||
#: core/models.py:613
|
||||
msgid "External Process ID"
|
||||
msgstr "External Process ID"
|
||||
|
||||
#: core/models.py:614
|
||||
msgid "ID of the external process associated with the recording."
|
||||
msgstr "ID of the external process associated with the recording."
|
||||
|
||||
#: core/models.py:620
|
||||
msgid "Recording"
|
||||
msgstr "Recording"
|
||||
|
||||
#: core/models.py:598
|
||||
#: core/models.py:621
|
||||
msgid "Recordings"
|
||||
msgstr "Recordings"
|
||||
|
||||
#: core/models.py:706
|
||||
#: core/models.py:731
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Recording/user relation"
|
||||
|
||||
#: core/models.py:707
|
||||
#: core/models.py:732
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Recording/user relations"
|
||||
|
||||
#: core/models.py:713
|
||||
#: core/models.py:738
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "This user is already in this recording."
|
||||
|
||||
#: core/models.py:719
|
||||
#: core/models.py:744
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "This team is already in this recording."
|
||||
|
||||
#: core/models.py:725
|
||||
#: core/models.py:750
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Either user or team must be set, not both."
|
||||
|
||||
#: core/models.py:742
|
||||
#: core/models.py:767
|
||||
#, fuzzy
|
||||
#| msgid "created on"
|
||||
msgid "Create rooms"
|
||||
msgstr "Create rooms"
|
||||
|
||||
#: core/models.py:743
|
||||
#: core/models.py:768
|
||||
msgid "List rooms"
|
||||
msgstr "List rooms"
|
||||
|
||||
#: core/models.py:744
|
||||
#: core/models.py:769
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Retrieve room details"
|
||||
|
||||
#: core/models.py:745
|
||||
#: core/models.py:770
|
||||
#, fuzzy
|
||||
#| msgid "updated on"
|
||||
msgid "Update rooms"
|
||||
msgstr "Update rooms"
|
||||
|
||||
#: core/models.py:746
|
||||
#: core/models.py:771
|
||||
msgid "Delete rooms"
|
||||
msgstr "Delete rooms"
|
||||
|
||||
#: core/models.py:759
|
||||
#: core/models.py:784
|
||||
msgid "Application name"
|
||||
msgstr "Application name"
|
||||
|
||||
#: core/models.py:760
|
||||
#: core/models.py:785
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Descriptive name for this application."
|
||||
|
||||
#: core/models.py:770
|
||||
#: core/models.py:795
|
||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||
msgstr "Hashed on Save. Copy it now if this is a new secret."
|
||||
|
||||
#: core/models.py:781
|
||||
#: core/models.py:806
|
||||
msgid "Application"
|
||||
msgstr "Application"
|
||||
|
||||
#: core/models.py:782
|
||||
#: core/models.py:807
|
||||
msgid "Applications"
|
||||
msgstr "Applications"
|
||||
|
||||
#: core/models.py:805
|
||||
#: core/models.py:830
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Enter a valid domain"
|
||||
|
||||
#: core/models.py:808
|
||||
#: core/models.py:833
|
||||
msgid "Domain"
|
||||
msgstr "Domain"
|
||||
|
||||
#: core/models.py:809
|
||||
#: core/models.py:834
|
||||
msgid "Email domain this application can act on behalf of."
|
||||
msgstr "Email domain this application can act on behalf of."
|
||||
|
||||
#: core/models.py:821
|
||||
#: core/models.py:846
|
||||
msgid "Application domain"
|
||||
msgstr "Application domain"
|
||||
|
||||
#: core/models.py:822
|
||||
#: core/models.py:847
|
||||
msgid "Application domains"
|
||||
msgstr "Application domains"
|
||||
|
||||
#: core/models.py:840
|
||||
#: core/models.py:865
|
||||
#, fuzzy
|
||||
#| msgid "Recording"
|
||||
msgid "Pending"
|
||||
msgstr "Pending"
|
||||
|
||||
#: core/models.py:848
|
||||
#: core/models.py:866
|
||||
msgid "Analyzing"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:873
|
||||
msgid "Ready"
|
||||
msgstr "Ready"
|
||||
|
||||
#: core/models.py:854
|
||||
#: core/models.py:879
|
||||
msgid "Background image"
|
||||
msgstr "Background image"
|
||||
|
||||
#: core/models.py:866
|
||||
#: core/models.py:891
|
||||
msgid "title"
|
||||
msgstr "title"
|
||||
|
||||
#: core/models.py:890
|
||||
#: core/models.py:915
|
||||
msgid "Malware detection info when the analysis status is unsafe."
|
||||
msgstr "Malware detection info when the analysis status is unsafe."
|
||||
|
||||
#: core/models.py:895
|
||||
#: core/models.py:920
|
||||
msgid "File"
|
||||
msgstr "File"
|
||||
|
||||
#: core/models.py:896
|
||||
#: core/models.py:921
|
||||
msgid "Files"
|
||||
msgstr "Files"
|
||||
|
||||
#: core/models.py:1000
|
||||
#: core/models.py:1041
|
||||
#, fuzzy
|
||||
#| msgid "This user is already in this recording."
|
||||
msgid "This file is already hard deleted."
|
||||
msgstr "This file is already hard deleted."
|
||||
|
||||
#: core/models.py:1010
|
||||
#: core/models.py:1051
|
||||
msgid "To hard delete a file, it must first be soft deleted."
|
||||
msgstr "To hard delete a file, it must first be soft deleted."
|
||||
|
||||
#: core/recording/event/notification.py:116
|
||||
#: core/recording/event/notification.py:123
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Your recording is ready"
|
||||
|
||||
#: core/recording/event/notification.py:194
|
||||
msgid "Transcription"
|
||||
msgstr "Transcription"
|
||||
|
||||
#: core/recording/event/notification.py:204
|
||||
#, python-brace-format
|
||||
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
||||
msgstr "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
||||
|
||||
#: core/services/invitation.py:44
|
||||
#, python-brace-format
|
||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||
@@ -571,7 +622,7 @@ 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
|
||||
#, python-format
|
||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
|
||||
@@ -600,18 +651,24 @@ msgstr ""
|
||||
" If you have any questions or need assistance, please contact our support "
|
||||
"team at %(support_email)s. "
|
||||
|
||||
#: meet/settings.py:223
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgid "Click the \"Open [%(link)s]\" link below "
|
||||
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
|
||||
#: meet/settings.py:228
|
||||
msgid "English"
|
||||
msgstr "English"
|
||||
|
||||
#: meet/settings.py:224
|
||||
#: meet/settings.py:229
|
||||
msgid "French"
|
||||
msgstr "French"
|
||||
|
||||
#: meet/settings.py:225
|
||||
#: meet/settings.py:230
|
||||
msgid "Dutch"
|
||||
msgstr "Dutch"
|
||||
|
||||
#: meet/settings.py:226
|
||||
#: meet/settings.py:231
|
||||
msgid "German"
|
||||
msgstr "German"
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-04-21 14:09+0000\n"
|
||||
"POT-Creation-Date: 2026-07-02 10:47+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -17,71 +17,93 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#: core/admin.py:29
|
||||
#: core/admin.py:67
|
||||
msgid "Personal info"
|
||||
msgstr "Informations personnelles"
|
||||
|
||||
#: core/admin.py:42
|
||||
#: core/admin.py:80
|
||||
msgid "Permissions"
|
||||
msgstr "Permissions"
|
||||
|
||||
#: core/admin.py:54
|
||||
#: core/admin.py:92
|
||||
msgid "Important dates"
|
||||
msgstr "Dates importantes"
|
||||
|
||||
#: core/admin.py:132 core/admin.py:275
|
||||
#: core/admin.py:206
|
||||
msgid "Content"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:217
|
||||
#, fuzzy
|
||||
#| msgid "Delete rooms"
|
||||
msgid "Deletion"
|
||||
msgstr "Supprimer les salles"
|
||||
|
||||
#: core/admin.py:226
|
||||
msgid "Derived info"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:237
|
||||
msgid "Timestamps"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:240
|
||||
msgid "File preview"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:300 core/admin.py:443
|
||||
msgid "No owner"
|
||||
msgstr "Pas de propriétaire"
|
||||
|
||||
#: core/admin.py:135 core/admin.py:278
|
||||
#: core/admin.py:303 core/admin.py:446
|
||||
msgid "Multiple owners"
|
||||
msgstr "Plusieurs propriétaires"
|
||||
|
||||
#: core/admin.py:148
|
||||
#: core/admin.py:316
|
||||
msgid "Resend notification to external service"
|
||||
msgstr "Renvoyer la notification au service externe"
|
||||
|
||||
#: core/admin.py:171
|
||||
#: core/admin.py:339
|
||||
#, python-format
|
||||
msgid "Failed to notify for recording %(id)s"
|
||||
msgstr "Échec de la notification pour l’enregistrement %(id)s"
|
||||
|
||||
#: core/admin.py:179
|
||||
#: core/admin.py:347
|
||||
#, python-format
|
||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||
msgstr "Échec de la notification pour l’enregistrement %(id)s : %(error)s"
|
||||
|
||||
#: core/admin.py:187
|
||||
#: core/admin.py:355
|
||||
#, python-format
|
||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||
msgstr "Notifications envoyées avec succès pour %(count)s enregistrement(s)."
|
||||
|
||||
#: core/admin.py:195
|
||||
#: core/admin.py:363
|
||||
#, python-format
|
||||
msgid "Skipped %(count)s expired recording(s)."
|
||||
msgstr "%(count)s enregistrement(s) expiré(s) ignoré(s)."
|
||||
|
||||
#: core/admin.py:200
|
||||
#: core/admin.py:368
|
||||
msgid "Mark selected recordings as 'Failed to Stop'"
|
||||
msgstr "Marquer les enregistrements sélectionnés comme « Échec d’arrêt »"
|
||||
|
||||
#: core/admin.py:218
|
||||
#: core/admin.py:386
|
||||
#, python-format
|
||||
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
||||
msgstr ""
|
||||
"%(count)s enregistrement(s) marqué(s) avec succès comme « Échec d’arrêt »."
|
||||
|
||||
#: core/admin.py:226
|
||||
#: core/admin.py:394
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Skipped %(count)s expired recording(s)."
|
||||
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
||||
msgstr "%(count)s enregistrement(s) avec un statut inéligible ignoré(s)."
|
||||
|
||||
#: core/admin.py:342
|
||||
#: core/admin.py:510
|
||||
msgid "No scopes"
|
||||
msgstr "Aucun scopes"
|
||||
|
||||
#: core/admin.py:344
|
||||
#: core/admin.py:512
|
||||
msgid "Scopes"
|
||||
msgstr "Scopes"
|
||||
|
||||
@@ -89,17 +111,17 @@ msgstr "Scopes"
|
||||
msgid "Creator is me"
|
||||
msgstr "Je suis le créateur"
|
||||
|
||||
#: core/api/serializers.py:88
|
||||
#: core/api/serializers.py:89
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr ""
|
||||
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
|
||||
"des accès."
|
||||
|
||||
#: core/api/serializers.py:509
|
||||
#: core/api/serializers.py:534
|
||||
msgid "This file extension is not allowed."
|
||||
msgstr "Cette extension n'est pas autorisée"
|
||||
|
||||
#: core/api/viewsets.py:1090
|
||||
#: core/api/viewsets.py:1222
|
||||
msgid "You have reached the maximum number of files for this type."
|
||||
msgstr "Vous avez atteint le nombre maximum de fichiers de ce type"
|
||||
|
||||
@@ -147,53 +169,61 @@ msgstr "Échec à l'arrêt"
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Notification réussie"
|
||||
|
||||
#: core/models.py:89
|
||||
#: core/models.py:65
|
||||
msgid "External process successful"
|
||||
msgstr "Traitement externe : succès"
|
||||
|
||||
#: core/models.py:67
|
||||
msgid "External process failed"
|
||||
msgstr "Traitement externe : erreur"
|
||||
|
||||
#: core/models.py:96
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "ENREGISTREMENT_ÉCRAN"
|
||||
|
||||
#: core/models.py:90
|
||||
#: core/models.py:97
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSCRIPTION"
|
||||
|
||||
#: core/models.py:96
|
||||
#: core/models.py:103
|
||||
msgid "Public Access"
|
||||
msgstr "Accès public"
|
||||
|
||||
#: core/models.py:97
|
||||
#: core/models.py:104
|
||||
msgid "Trusted Access"
|
||||
msgstr "Accès de confiance"
|
||||
|
||||
#: core/models.py:98
|
||||
#: core/models.py:105
|
||||
msgid "Restricted Access"
|
||||
msgstr "Accès restreint"
|
||||
|
||||
#: core/models.py:110
|
||||
#: core/models.py:117
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: core/models.py:111
|
||||
#: core/models.py:118
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
|
||||
|
||||
#: core/models.py:117
|
||||
#: core/models.py:124
|
||||
msgid "created on"
|
||||
msgstr "créé le"
|
||||
|
||||
#: core/models.py:118
|
||||
#: core/models.py:125
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "date et heure auxquelles un enregistrement a été créé"
|
||||
|
||||
#: core/models.py:123
|
||||
#: core/models.py:130
|
||||
msgid "updated on"
|
||||
msgstr "mis à jour le"
|
||||
|
||||
#: core/models.py:124
|
||||
#: core/models.py:131
|
||||
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:144
|
||||
#: core/models.py:151
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
@@ -201,11 +231,11 @@ msgstr ""
|
||||
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des "
|
||||
"chiffres et les caractères @/./+/-/_."
|
||||
|
||||
#: core/models.py:150
|
||||
#: core/models.py:157
|
||||
msgid "sub"
|
||||
msgstr "sub"
|
||||
|
||||
#: core/models.py:152
|
||||
#: core/models.py:159
|
||||
msgid ""
|
||||
"Optional for pending users; required upon account activation. 255 characters "
|
||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
@@ -213,55 +243,55 @@ msgstr ""
|
||||
"Optionnel pour les utilisateurs en attente ; requis lors de l'activation du "
|
||||
"compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement."
|
||||
|
||||
#: core/models.py:161
|
||||
#: core/models.py:168
|
||||
msgid "identity email address"
|
||||
msgstr "adresse e-mail d'identité"
|
||||
|
||||
#: core/models.py:166
|
||||
#: core/models.py:173
|
||||
msgid "admin email address"
|
||||
msgstr "adresse e-mail d'administrateur"
|
||||
|
||||
#: core/models.py:168
|
||||
#: core/models.py:175
|
||||
msgid "full name"
|
||||
msgstr "nom complet"
|
||||
|
||||
#: core/models.py:170
|
||||
#: core/models.py:177
|
||||
msgid "short name"
|
||||
msgstr "nom court"
|
||||
|
||||
#: core/models.py:176
|
||||
#: core/models.py:183
|
||||
msgid "language"
|
||||
msgstr "langue"
|
||||
|
||||
#: core/models.py:177
|
||||
#: core/models.py:184
|
||||
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:183
|
||||
#: core/models.py:190
|
||||
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:186
|
||||
#: core/models.py:193
|
||||
msgid "device"
|
||||
msgstr "appareil"
|
||||
|
||||
#: core/models.py:188
|
||||
#: core/models.py:195
|
||||
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:191
|
||||
#: core/models.py:198
|
||||
msgid "staff status"
|
||||
msgstr "statut du personnel"
|
||||
|
||||
#: core/models.py:193
|
||||
#: core/models.py:200
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
|
||||
|
||||
#: core/models.py:196
|
||||
#: core/models.py:203
|
||||
msgid "active"
|
||||
msgstr "actif"
|
||||
|
||||
#: core/models.py:199
|
||||
#: core/models.py:206
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
@@ -269,65 +299,65 @@ msgstr ""
|
||||
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
|
||||
"au lieu de supprimer des comptes."
|
||||
|
||||
#: core/models.py:212
|
||||
#: core/models.py:219
|
||||
msgid "user"
|
||||
msgstr "utilisateur"
|
||||
|
||||
#: core/models.py:213
|
||||
#: core/models.py:220
|
||||
msgid "users"
|
||||
msgstr "utilisateurs"
|
||||
|
||||
#: core/models.py:272
|
||||
#: core/models.py:286
|
||||
msgid "Resource"
|
||||
msgstr "Ressource"
|
||||
|
||||
#: core/models.py:273
|
||||
#: core/models.py:287
|
||||
msgid "Resources"
|
||||
msgstr "Ressources"
|
||||
|
||||
#: core/models.py:331
|
||||
#: core/models.py:345
|
||||
msgid "Resource access"
|
||||
msgstr "Accès aux ressources"
|
||||
|
||||
#: core/models.py:332
|
||||
#: core/models.py:346
|
||||
msgid "Resource accesses"
|
||||
msgstr "Accès aux ressources"
|
||||
|
||||
#: core/models.py:338
|
||||
#: core/models.py:352
|
||||
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:394
|
||||
#: core/models.py:409
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Configuration de la salle de visioconférence"
|
||||
|
||||
#: core/models.py:395
|
||||
#: core/models.py:410
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
|
||||
|
||||
#: core/models.py:402
|
||||
#: core/models.py:417
|
||||
msgid "Room PIN code"
|
||||
msgstr "Code PIN de la salle"
|
||||
|
||||
#: core/models.py:403
|
||||
#: core/models.py:418
|
||||
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:409 core/models.py:563
|
||||
#: core/models.py:424 core/models.py:578
|
||||
msgid "Room"
|
||||
msgstr "Salle"
|
||||
|
||||
#: core/models.py:410
|
||||
#: core/models.py:425
|
||||
msgid "Rooms"
|
||||
msgstr "Salles"
|
||||
|
||||
#: core/models.py:574
|
||||
#: core/models.py:589
|
||||
msgid "Worker ID"
|
||||
msgstr "ID du Worker"
|
||||
|
||||
#: core/models.py:576
|
||||
#: core/models.py:591
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
@@ -335,153 +365,174 @@ msgstr ""
|
||||
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
|
||||
"conservé même lorsque le Worker s'arrête, permettant un suivi facile."
|
||||
|
||||
#: core/models.py:584
|
||||
#: core/models.py:599
|
||||
msgid "Recording mode"
|
||||
msgstr "Mode d'enregistrement"
|
||||
|
||||
#: core/models.py:585
|
||||
#: core/models.py:600
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Définit le mode d'enregistrement appelé."
|
||||
|
||||
#: core/models.py:590 core/models.py:591
|
||||
#: core/models.py:605 core/models.py:606
|
||||
msgid "Recording options"
|
||||
msgstr "Options d'enregistrement"
|
||||
|
||||
#: core/models.py:597
|
||||
#: core/models.py:613
|
||||
msgid "External Process ID"
|
||||
msgstr "ID Traitement externe"
|
||||
|
||||
#: core/models.py:614
|
||||
msgid "ID of the external process associated with the recording."
|
||||
msgstr "ID du traitement externe associé avec l'enregistrement."
|
||||
|
||||
#: core/models.py:620
|
||||
msgid "Recording"
|
||||
msgstr "Enregistrement"
|
||||
|
||||
#: core/models.py:598
|
||||
#: core/models.py:621
|
||||
msgid "Recordings"
|
||||
msgstr "Enregistrements"
|
||||
|
||||
#: core/models.py:706
|
||||
#: core/models.py:731
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Relation enregistrement/utilisateur"
|
||||
|
||||
#: core/models.py:707
|
||||
#: core/models.py:732
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Relations enregistrement/utilisateur"
|
||||
|
||||
#: core/models.py:713
|
||||
#: core/models.py:738
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "Cet utilisateur est déjà dans cet enregistrement."
|
||||
|
||||
#: core/models.py:719
|
||||
#: core/models.py:744
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "Cette équipe est déjà dans cet enregistrement."
|
||||
|
||||
#: core/models.py:725
|
||||
#: core/models.py:750
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
|
||||
|
||||
#: core/models.py:742
|
||||
#: core/models.py:767
|
||||
msgid "Create rooms"
|
||||
msgstr "Créer des salles"
|
||||
|
||||
#: core/models.py:743
|
||||
#: core/models.py:768
|
||||
msgid "List rooms"
|
||||
msgstr "Lister les salles"
|
||||
|
||||
#: core/models.py:744
|
||||
#: core/models.py:769
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Afficher les détails d’une salle"
|
||||
|
||||
#: core/models.py:745
|
||||
#: core/models.py:770
|
||||
msgid "Update rooms"
|
||||
msgstr "Mettre à jour les salles"
|
||||
|
||||
#: core/models.py:746
|
||||
#: core/models.py:771
|
||||
msgid "Delete rooms"
|
||||
msgstr "Supprimer les salles"
|
||||
|
||||
#: core/models.py:759
|
||||
#: core/models.py:784
|
||||
msgid "Application name"
|
||||
msgstr "Nom de l’application"
|
||||
|
||||
#: core/models.py:760
|
||||
#: core/models.py:785
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Nom descriptif de cette application."
|
||||
|
||||
#: core/models.py:770
|
||||
#: core/models.py:795
|
||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||
msgstr ""
|
||||
"Haché lors de l’enregistrement. Copiez-le maintenant s’il s’agit d’un "
|
||||
"nouveau secret."
|
||||
|
||||
#: core/models.py:781
|
||||
#: core/models.py:806
|
||||
msgid "Application"
|
||||
msgstr "Application"
|
||||
|
||||
#: core/models.py:782
|
||||
#: core/models.py:807
|
||||
msgid "Applications"
|
||||
msgstr "Applications"
|
||||
|
||||
#: core/models.py:805
|
||||
#: core/models.py:830
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Saisissez un domaine valide"
|
||||
|
||||
#: core/models.py:808
|
||||
#: core/models.py:833
|
||||
msgid "Domain"
|
||||
msgstr "Domaine"
|
||||
|
||||
#: core/models.py:809
|
||||
#: core/models.py:834
|
||||
msgid "Email domain this application can act on behalf of."
|
||||
msgstr "Domaine de messagerie au nom duquel cette application peut agir."
|
||||
|
||||
#: core/models.py:821
|
||||
#: core/models.py:846
|
||||
msgid "Application domain"
|
||||
msgstr "Domaine d’application"
|
||||
|
||||
#: core/models.py:822
|
||||
#: core/models.py:847
|
||||
msgid "Application domains"
|
||||
msgstr "Domaines d’application"
|
||||
|
||||
#: core/models.py:840
|
||||
#: core/models.py:865
|
||||
msgid "Pending"
|
||||
msgstr "En attente"
|
||||
|
||||
#: core/models.py:848
|
||||
#: core/models.py:866
|
||||
msgid "Analyzing"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:873
|
||||
msgid "Ready"
|
||||
msgstr "Prêt"
|
||||
|
||||
#: core/models.py:854
|
||||
#: core/models.py:879
|
||||
msgid "Background image"
|
||||
msgstr "Image de fond"
|
||||
|
||||
#: core/models.py:866
|
||||
#: core/models.py:891
|
||||
msgid "title"
|
||||
msgstr "Titre"
|
||||
|
||||
#: core/models.py:890
|
||||
#: core/models.py:915
|
||||
msgid "Malware detection info when the analysis status is unsafe."
|
||||
msgstr ""
|
||||
"Information concernant la détection de Malware cand le statut n'est pas sain"
|
||||
|
||||
#: core/models.py:895
|
||||
#: core/models.py:920
|
||||
msgid "File"
|
||||
msgstr "Fichier"
|
||||
|
||||
#: core/models.py:896
|
||||
#: core/models.py:921
|
||||
msgid "Files"
|
||||
msgstr "Fichiers"
|
||||
|
||||
#: core/models.py:1000
|
||||
#: core/models.py:1041
|
||||
#, fuzzy
|
||||
#| msgid "This user is already in this recording."
|
||||
msgid "This file is already hard deleted."
|
||||
msgstr "Ce fichier a été supprimé."
|
||||
|
||||
#: core/models.py:1010
|
||||
#: core/models.py:1051
|
||||
msgid "To hard delete a file, it must first be soft deleted."
|
||||
msgstr ""
|
||||
"Pour supprimer définitivement un fichier il doit d'abord avoir été marqué "
|
||||
"comme supprimé (soft delete)"
|
||||
|
||||
#: core/recording/event/notification.py:116
|
||||
#: core/recording/event/notification.py:123
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Votre enregistrement est prêt"
|
||||
|
||||
#: core/recording/event/notification.py:194
|
||||
msgid "Transcription"
|
||||
msgstr "Transcription"
|
||||
|
||||
#: core/recording/event/notification.py:204
|
||||
#, python-brace-format
|
||||
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
||||
msgstr "Réunion \"{room}\" du {room_recording_date} à {room_recording_time}"
|
||||
|
||||
#: core/services/invitation.py:44
|
||||
#, python-brace-format
|
||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||
@@ -577,7 +628,7 @@ 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
|
||||
#, python-format
|
||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" ci-dessous "
|
||||
|
||||
@@ -606,18 +657,24 @@ msgstr ""
|
||||
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
|
||||
"équipe d'assistance à %(support_email)s. "
|
||||
|
||||
#: meet/settings.py:223
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgid "Click the \"Open [%(link)s]\" link below "
|
||||
msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" ci-dessous "
|
||||
|
||||
#: meet/settings.py:228
|
||||
msgid "English"
|
||||
msgstr "Anglais"
|
||||
|
||||
#: meet/settings.py:224
|
||||
#: meet/settings.py:229
|
||||
msgid "French"
|
||||
msgstr "Français"
|
||||
|
||||
#: meet/settings.py:225
|
||||
#: meet/settings.py:230
|
||||
msgid "Dutch"
|
||||
msgstr "Néerlandais"
|
||||
|
||||
#: meet/settings.py:226
|
||||
#: meet/settings.py:231
|
||||
msgid "German"
|
||||
msgstr "Allemand"
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-04-21 14:09+0000\n"
|
||||
"POT-Creation-Date: 2026-07-02 10:47+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -17,70 +17,92 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#: core/admin.py:29
|
||||
#: core/admin.py:67
|
||||
msgid "Personal info"
|
||||
msgstr "Persoonlijke informatie"
|
||||
|
||||
#: core/admin.py:42
|
||||
#: core/admin.py:80
|
||||
msgid "Permissions"
|
||||
msgstr "Rechten"
|
||||
|
||||
#: core/admin.py:54
|
||||
#: core/admin.py:92
|
||||
msgid "Important dates"
|
||||
msgstr "Belangrijke datums"
|
||||
|
||||
#: core/admin.py:132 core/admin.py:275
|
||||
#: core/admin.py:206
|
||||
msgid "Content"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:217
|
||||
#, fuzzy
|
||||
#| msgid "Delete rooms"
|
||||
msgid "Deletion"
|
||||
msgstr "Ruimtes verwijderen"
|
||||
|
||||
#: core/admin.py:226
|
||||
msgid "Derived info"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:237
|
||||
msgid "Timestamps"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:240
|
||||
msgid "File preview"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:300 core/admin.py:443
|
||||
msgid "No owner"
|
||||
msgstr "Geen eigenaar"
|
||||
|
||||
#: core/admin.py:135 core/admin.py:278
|
||||
#: core/admin.py:303 core/admin.py:446
|
||||
msgid "Multiple owners"
|
||||
msgstr "Meerdere eigenaren"
|
||||
|
||||
#: core/admin.py:148
|
||||
#: core/admin.py:316
|
||||
msgid "Resend notification to external service"
|
||||
msgstr "Melding opnieuw verzenden naar externe dienst"
|
||||
|
||||
#: core/admin.py:171
|
||||
#: core/admin.py:339
|
||||
#, python-format
|
||||
msgid "Failed to notify for recording %(id)s"
|
||||
msgstr "Melding voor opname %(id)s mislukt"
|
||||
|
||||
#: core/admin.py:179
|
||||
#: core/admin.py:347
|
||||
#, python-format
|
||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||
msgstr "Melding voor opname %(id)s mislukt: %(error)s"
|
||||
|
||||
#: core/admin.py:187
|
||||
#: core/admin.py:355
|
||||
#, python-format
|
||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||
msgstr "Meldingen succesvol verzonden voor %(count)s opname(n)."
|
||||
|
||||
#: core/admin.py:195
|
||||
#: core/admin.py:363
|
||||
#, python-format
|
||||
msgid "Skipped %(count)s expired recording(s)."
|
||||
msgstr "%(count)s verlopen opname(n) overgeslagen."
|
||||
|
||||
#: core/admin.py:200
|
||||
#: core/admin.py:368
|
||||
msgid "Mark selected recordings as 'Failed to Stop'"
|
||||
msgstr "Geselecteerde opnames markeren als 'Mislukt bij stoppen'"
|
||||
|
||||
#: core/admin.py:218
|
||||
#: core/admin.py:386
|
||||
#, python-format
|
||||
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
||||
msgstr "%(count)s opname(s) succesvol gemarkeerd als 'Mislukt bij stoppen'."
|
||||
|
||||
#: core/admin.py:226
|
||||
#: core/admin.py:394
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Skipped %(count)s expired recording(s)."
|
||||
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
||||
msgstr "%(count)s opname(s) met een niet-toegestane status overgeslagen."
|
||||
|
||||
#: core/admin.py:342
|
||||
#: core/admin.py:510
|
||||
msgid "No scopes"
|
||||
msgstr "Geen scopes"
|
||||
|
||||
#: core/admin.py:344
|
||||
#: core/admin.py:512
|
||||
msgid "Scopes"
|
||||
msgstr "Scopes"
|
||||
|
||||
@@ -88,16 +110,16 @@ msgstr "Scopes"
|
||||
msgid "Creator is me"
|
||||
msgstr "Maker ben ik"
|
||||
|
||||
#: core/api/serializers.py:88
|
||||
#: core/api/serializers.py:89
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr ""
|
||||
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
|
||||
|
||||
#: core/api/serializers.py:509
|
||||
#: core/api/serializers.py:534
|
||||
msgid "This file extension is not allowed."
|
||||
msgstr "Deze bestandsextensie is niet toegestaan."
|
||||
|
||||
#: core/api/viewsets.py:1090
|
||||
#: core/api/viewsets.py:1222
|
||||
msgid "You have reached the maximum number of files for this type."
|
||||
msgstr "Het maximale aantal bestanden voor dit type is bereikt."
|
||||
|
||||
@@ -145,51 +167,59 @@ msgstr "Stoppen mislukt"
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Notificatie geslaagd"
|
||||
|
||||
#: core/models.py:89
|
||||
#: core/models.py:65
|
||||
msgid "External process successful"
|
||||
msgstr "Externe procedure succesvol"
|
||||
|
||||
#: core/models.py:67
|
||||
msgid "External process failed"
|
||||
msgstr "Het externe proces is mislukt."
|
||||
|
||||
#: core/models.py:96
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "SCHERM_OPNAME"
|
||||
|
||||
#: core/models.py:90
|
||||
#: core/models.py:97
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSCRIPT"
|
||||
|
||||
#: core/models.py:96
|
||||
#: core/models.py:103
|
||||
msgid "Public Access"
|
||||
msgstr "Openbare toegang"
|
||||
|
||||
#: core/models.py:97
|
||||
#: core/models.py:104
|
||||
msgid "Trusted Access"
|
||||
msgstr "Vertrouwde toegang"
|
||||
|
||||
#: core/models.py:98
|
||||
#: core/models.py:105
|
||||
msgid "Restricted Access"
|
||||
msgstr "Beperkte toegang"
|
||||
|
||||
#: core/models.py:110
|
||||
#: core/models.py:117
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: core/models.py:111
|
||||
#: core/models.py:118
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "primaire sleutel voor het record als UUID"
|
||||
|
||||
#: core/models.py:117
|
||||
#: core/models.py:124
|
||||
msgid "created on"
|
||||
msgstr "aangemaakt op"
|
||||
|
||||
#: core/models.py:118
|
||||
#: core/models.py:125
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "datum en tijd waarop een record werd aangemaakt"
|
||||
|
||||
#: core/models.py:123
|
||||
#: core/models.py:130
|
||||
msgid "updated on"
|
||||
msgstr "bijgewerkt op"
|
||||
|
||||
#: core/models.py:124
|
||||
#: core/models.py:131
|
||||
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:144
|
||||
#: core/models.py:151
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
@@ -197,11 +227,11 @@ msgstr ""
|
||||
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
|
||||
"_ tekens bevatten."
|
||||
|
||||
#: core/models.py:150
|
||||
#: core/models.py:157
|
||||
msgid "sub"
|
||||
msgstr "sub"
|
||||
|
||||
#: core/models.py:152
|
||||
#: core/models.py:159
|
||||
msgid ""
|
||||
"Optional for pending users; required upon account activation. 255 characters "
|
||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
@@ -209,55 +239,55 @@ msgstr ""
|
||||
"Optioneel voor gebruikers in afwachting; vereist bij accountactivering. "
|
||||
"Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan."
|
||||
|
||||
#: core/models.py:161
|
||||
#: core/models.py:168
|
||||
msgid "identity email address"
|
||||
msgstr "identiteit e-mailadres"
|
||||
|
||||
#: core/models.py:166
|
||||
#: core/models.py:173
|
||||
msgid "admin email address"
|
||||
msgstr "beheerder e-mailadres"
|
||||
|
||||
#: core/models.py:168
|
||||
#: core/models.py:175
|
||||
msgid "full name"
|
||||
msgstr "volledige naam"
|
||||
|
||||
#: core/models.py:170
|
||||
#: core/models.py:177
|
||||
msgid "short name"
|
||||
msgstr "korte naam"
|
||||
|
||||
#: core/models.py:176
|
||||
#: core/models.py:183
|
||||
msgid "language"
|
||||
msgstr "taal"
|
||||
|
||||
#: core/models.py:177
|
||||
#: core/models.py:184
|
||||
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:183
|
||||
#: core/models.py:190
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
|
||||
|
||||
#: core/models.py:186
|
||||
#: core/models.py:193
|
||||
msgid "device"
|
||||
msgstr "apparaat"
|
||||
|
||||
#: core/models.py:188
|
||||
#: core/models.py:195
|
||||
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:191
|
||||
#: core/models.py:198
|
||||
msgid "staff status"
|
||||
msgstr "personeelsstatus"
|
||||
|
||||
#: core/models.py:193
|
||||
#: core/models.py:200
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Of de gebruiker kan inloggen op deze beheersite."
|
||||
|
||||
#: core/models.py:196
|
||||
#: core/models.py:203
|
||||
msgid "active"
|
||||
msgstr "actief"
|
||||
|
||||
#: core/models.py:199
|
||||
#: core/models.py:206
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
@@ -265,64 +295,64 @@ msgstr ""
|
||||
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
|
||||
"plaats van accounts te verwijderen."
|
||||
|
||||
#: core/models.py:212
|
||||
#: core/models.py:219
|
||||
msgid "user"
|
||||
msgstr "gebruiker"
|
||||
|
||||
#: core/models.py:213
|
||||
#: core/models.py:220
|
||||
msgid "users"
|
||||
msgstr "gebruikers"
|
||||
|
||||
#: core/models.py:272
|
||||
#: core/models.py:286
|
||||
msgid "Resource"
|
||||
msgstr "Bron"
|
||||
|
||||
#: core/models.py:273
|
||||
#: core/models.py:287
|
||||
msgid "Resources"
|
||||
msgstr "Bronnen"
|
||||
|
||||
#: core/models.py:331
|
||||
#: core/models.py:345
|
||||
msgid "Resource access"
|
||||
msgstr "Brontoegang"
|
||||
|
||||
#: core/models.py:332
|
||||
#: core/models.py:346
|
||||
msgid "Resource accesses"
|
||||
msgstr "Brontoegangsrechten"
|
||||
|
||||
#: core/models.py:338
|
||||
#: core/models.py:352
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
|
||||
|
||||
#: core/models.py:394
|
||||
#: core/models.py:409
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Visio-ruimteconfiguratie"
|
||||
|
||||
#: core/models.py:395
|
||||
#: core/models.py:410
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
|
||||
|
||||
#: core/models.py:402
|
||||
#: core/models.py:417
|
||||
msgid "Room PIN code"
|
||||
msgstr "Pincode van de kamer"
|
||||
|
||||
#: core/models.py:403
|
||||
#: core/models.py:418
|
||||
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:409 core/models.py:563
|
||||
#: core/models.py:424 core/models.py:578
|
||||
msgid "Room"
|
||||
msgstr "Ruimte"
|
||||
|
||||
#: core/models.py:410
|
||||
#: core/models.py:425
|
||||
msgid "Rooms"
|
||||
msgstr "Ruimtes"
|
||||
|
||||
#: core/models.py:574
|
||||
#: core/models.py:589
|
||||
msgid "Worker ID"
|
||||
msgstr "Worker ID"
|
||||
|
||||
#: core/models.py:576
|
||||
#: core/models.py:591
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
@@ -330,140 +360,152 @@ 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:584
|
||||
#: core/models.py:599
|
||||
msgid "Recording mode"
|
||||
msgstr "Opnamemodus"
|
||||
|
||||
#: core/models.py:585
|
||||
#: core/models.py:600
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Definieert de modus van opname die wordt aangeroepen."
|
||||
|
||||
#: core/models.py:590 core/models.py:591
|
||||
#: core/models.py:605 core/models.py:606
|
||||
msgid "Recording options"
|
||||
msgstr "Opnameopties"
|
||||
|
||||
#: core/models.py:597
|
||||
#: core/models.py:613
|
||||
msgid "External Process ID"
|
||||
msgstr "Externe proces-ID"
|
||||
|
||||
#: core/models.py:614
|
||||
msgid "ID of the external process associated with the recording."
|
||||
msgstr "ID van het externe proces dat aan de opname is gekoppeld."
|
||||
|
||||
#: core/models.py:620
|
||||
msgid "Recording"
|
||||
msgstr "Opname"
|
||||
|
||||
#: core/models.py:598
|
||||
#: core/models.py:621
|
||||
msgid "Recordings"
|
||||
msgstr "Opnames"
|
||||
|
||||
#: core/models.py:706
|
||||
#: core/models.py:731
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Opname/gebruiker-relatie"
|
||||
|
||||
#: core/models.py:707
|
||||
#: core/models.py:732
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Opname/gebruiker-relaties"
|
||||
|
||||
#: core/models.py:713
|
||||
#: core/models.py:738
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "Deze gebruiker is al in deze opname."
|
||||
|
||||
#: core/models.py:719
|
||||
#: core/models.py:744
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "Dit team is al in deze opname."
|
||||
|
||||
#: core/models.py:725
|
||||
#: core/models.py:750
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
|
||||
|
||||
#: core/models.py:742
|
||||
#: core/models.py:767
|
||||
msgid "Create rooms"
|
||||
msgstr "Ruimtes aanmaken"
|
||||
|
||||
#: core/models.py:743
|
||||
#: core/models.py:768
|
||||
msgid "List rooms"
|
||||
msgstr "Ruimtes weergeven"
|
||||
|
||||
#: core/models.py:744
|
||||
#: core/models.py:769
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Details van een ruimte ophalen"
|
||||
|
||||
#: core/models.py:745
|
||||
#: core/models.py:770
|
||||
msgid "Update rooms"
|
||||
msgstr "Ruimtes bijwerken"
|
||||
|
||||
#: core/models.py:746
|
||||
#: core/models.py:771
|
||||
msgid "Delete rooms"
|
||||
msgstr "Ruimtes verwijderen"
|
||||
|
||||
#: core/models.py:759
|
||||
#: core/models.py:784
|
||||
msgid "Application name"
|
||||
msgstr "Naam van de applicatie"
|
||||
|
||||
#: core/models.py:760
|
||||
#: core/models.py:785
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Beschrijvende naam voor deze applicatie."
|
||||
|
||||
#: core/models.py:770
|
||||
#: core/models.py:795
|
||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||
msgstr ""
|
||||
"Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
|
||||
|
||||
#: core/models.py:781
|
||||
#: core/models.py:806
|
||||
msgid "Application"
|
||||
msgstr "Applicatie"
|
||||
|
||||
#: core/models.py:782
|
||||
#: core/models.py:807
|
||||
msgid "Applications"
|
||||
msgstr "Applicaties"
|
||||
|
||||
#: core/models.py:805
|
||||
#: core/models.py:830
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Voer een geldig domein in"
|
||||
|
||||
#: core/models.py:808
|
||||
#: core/models.py:833
|
||||
msgid "Domain"
|
||||
msgstr "Domein"
|
||||
|
||||
#: core/models.py:809
|
||||
#: core/models.py:834
|
||||
msgid "Email domain this application can act on behalf of."
|
||||
msgstr "E-maildomein namens welke deze applicatie kan handelen."
|
||||
|
||||
#: core/models.py:821
|
||||
#: core/models.py:846
|
||||
msgid "Application domain"
|
||||
msgstr "Applicatiedomein"
|
||||
|
||||
#: core/models.py:822
|
||||
#: core/models.py:847
|
||||
msgid "Application domains"
|
||||
msgstr "Applicatiedomeinen"
|
||||
|
||||
#: core/models.py:840
|
||||
#: core/models.py:865
|
||||
msgid "Pending"
|
||||
msgstr "In afwachting"
|
||||
|
||||
#: core/models.py:848
|
||||
#: core/models.py:866
|
||||
msgid "Analyzing"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:873
|
||||
msgid "Ready"
|
||||
msgstr "Klaar"
|
||||
|
||||
#: core/models.py:854
|
||||
#: core/models.py:879
|
||||
msgid "Background image"
|
||||
msgstr "Achtergrondafbeelding"
|
||||
|
||||
#: core/models.py:866
|
||||
#: core/models.py:891
|
||||
msgid "title"
|
||||
msgstr "Titel"
|
||||
|
||||
#: core/models.py:890
|
||||
#: core/models.py:915
|
||||
msgid "Malware detection info when the analysis status is unsafe."
|
||||
msgstr "Informatie over malwaredetectie wanneer de analysestatus onveilig is."
|
||||
|
||||
#: core/models.py:895
|
||||
#: core/models.py:920
|
||||
msgid "File"
|
||||
msgstr "Bestand"
|
||||
|
||||
#: core/models.py:896
|
||||
#: core/models.py:921
|
||||
msgid "Files"
|
||||
msgstr "Bestanden"
|
||||
|
||||
#: core/models.py:1000
|
||||
#: core/models.py:1041
|
||||
msgid "This file is already hard deleted."
|
||||
msgstr "Dit bestand is al definitief verwijderd."
|
||||
|
||||
#: core/models.py:1010
|
||||
#: core/models.py:1051
|
||||
#, fuzzy
|
||||
#| msgid "To hard delete a file, it must first be soft deleted."
|
||||
msgid "To hard delete a file, it must first be soft deleted."
|
||||
@@ -471,10 +513,20 @@ msgstr ""
|
||||
"Om een bestand definitief te verwijderen, moet het eerst zacht verwijderd "
|
||||
"zijn."
|
||||
|
||||
#: core/recording/event/notification.py:116
|
||||
#: core/recording/event/notification.py:123
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Je opname is klaar"
|
||||
|
||||
#: core/recording/event/notification.py:194
|
||||
msgid "Transcription"
|
||||
msgstr "Transcriptie"
|
||||
|
||||
#: core/recording/event/notification.py:204
|
||||
#, python-brace-format
|
||||
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
||||
msgstr ""
|
||||
"Vergadering \"{room}\" op {room_recording_date} om {room_recording_time}"
|
||||
|
||||
#: core/services/invitation.py:44
|
||||
#, python-brace-format
|
||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||
@@ -570,7 +622,7 @@ 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
|
||||
#, python-format
|
||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgstr "Klik op de \"<a href=\"%(link)s\">Openen</a>\"-link hieronder "
|
||||
|
||||
@@ -599,18 +651,24 @@ msgstr ""
|
||||
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
|
||||
"team via %(support_email)s. "
|
||||
|
||||
#: meet/settings.py:223
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgid "Click the \"Open [%(link)s]\" link below "
|
||||
msgstr "Klik op de \"<a href=\"%(link)s\">Openen</a>\"-link hieronder "
|
||||
|
||||
#: meet/settings.py:228
|
||||
msgid "English"
|
||||
msgstr "Engels"
|
||||
|
||||
#: meet/settings.py:224
|
||||
#: meet/settings.py:229
|
||||
msgid "French"
|
||||
msgstr "Frans"
|
||||
|
||||
#: meet/settings.py:225
|
||||
#: meet/settings.py:230
|
||||
msgid "Dutch"
|
||||
msgstr "Nederlands"
|
||||
|
||||
#: meet/settings.py:226
|
||||
#: meet/settings.py:231
|
||||
msgid "German"
|
||||
msgstr "Duits"
|
||||
|
||||
@@ -13,6 +13,7 @@ https://docs.djangoproject.com/en/3.1/ref/settings/
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import json
|
||||
import warnings
|
||||
from os import path
|
||||
from socket import gethostbyname, gethostname
|
||||
|
||||
@@ -182,7 +183,9 @@ class Base(Configuration):
|
||||
environ_name="FILE_UPLOAD_ENABLED",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
FILE_PURGE_GRACE_DAYS = values.PositiveIntegerValue(
|
||||
default=7, environ_name="FILE_PURGE_GRACE_DAYS", environ_prefix=None
|
||||
)
|
||||
FILE_UPLOAD_PATH = values.Value(
|
||||
"files", environ_name="FILE_UPLOAD_PATH", environ_prefix=None
|
||||
)
|
||||
@@ -321,8 +324,7 @@ class Base(Configuration):
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||
"mozilla_django_oidc.contrib.drf.OIDCAuthentication",
|
||||
"rest_framework.authentication.SessionAuthentication",
|
||||
"core.authentication.backends.SessionAuthenticationWith401",
|
||||
),
|
||||
"DEFAULT_PARSER_CLASSES": [
|
||||
"rest_framework.parsers.JSONParser",
|
||||
@@ -396,6 +398,9 @@ class Base(Configuration):
|
||||
"feedback": values.DictValue(
|
||||
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
|
||||
),
|
||||
"documentation_url": values.Value(
|
||||
None, environ_name="FRONTEND_DOCUMENTATION_URL", environ_prefix=None
|
||||
),
|
||||
"external_home_url": values.Value(
|
||||
None, environ_name="FRONTEND_EXTERNAL_HOME_URL", environ_prefix=None
|
||||
),
|
||||
@@ -563,6 +568,20 @@ class Base(Configuration):
|
||||
OIDC_STORE_ID_TOKEN = values.BooleanValue(
|
||||
default=True, environ_name="OIDC_STORE_ID_TOKEN", environ_prefix=None
|
||||
)
|
||||
# Required to call other La Suite applications on behalf of the user, e.g.
|
||||
# to push a recording to their Drive.
|
||||
OIDC_STORE_ACCESS_TOKEN = values.BooleanValue(
|
||||
default=False, environ_name="OIDC_STORE_ACCESS_TOKEN", environ_prefix=None
|
||||
)
|
||||
OIDC_STORE_REFRESH_TOKEN = values.BooleanValue(
|
||||
default=False, environ_name="OIDC_STORE_REFRESH_TOKEN", environ_prefix=None
|
||||
)
|
||||
# Fernet key used to encrypt OIDC tokens at rest, both the refresh token
|
||||
# django-lasuite stores in the session and the access token parked on a
|
||||
# recording. Generate one with `Fernet.generate_key()`.
|
||||
OIDC_STORE_REFRESH_TOKEN_KEY = SecretFileValue(
|
||||
None, environ_name="OIDC_STORE_REFRESH_TOKEN_KEY", environ_prefix=None
|
||||
)
|
||||
ALLOW_LOGOUT_GET_METHOD = values.BooleanValue(
|
||||
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
|
||||
)
|
||||
@@ -669,6 +688,11 @@ class Base(Configuration):
|
||||
environ_name="PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = values.BooleanValue(
|
||||
True,
|
||||
environ_name="AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Recording settings
|
||||
RECORDING_ENABLE = values.BooleanValue(
|
||||
@@ -710,6 +734,29 @@ class Base(Configuration):
|
||||
None, environ_name="RECORDING_MAX_DURATION", environ_prefix=None
|
||||
)
|
||||
|
||||
# Push recordings to Drive
|
||||
# Once a recording is over, it is pushed to the main workspace of the user who
|
||||
# started it, using their OIDC access token. It requires OIDC_STORE_ACCESS_TOKEN,
|
||||
# and Drive to be configured as an OIDC resource server accepting Meet's audience.
|
||||
RECORDING_PUSH_TO_DRIVE_ENABLED = values.BooleanValue(
|
||||
False, environ_name="RECORDING_PUSH_TO_DRIVE_ENABLED", environ_prefix=None
|
||||
)
|
||||
# Base URL of Drive's external API, e.g. https://drive.example.com/external_api/v1.0
|
||||
DRIVE_API_BASE_URL = values.Value(
|
||||
None, environ_name="DRIVE_API_BASE_URL", environ_prefix=None
|
||||
)
|
||||
# Lifetime of the signed URL the worker downloads the recording from.
|
||||
RECORDING_PUSH_TO_DRIVE_SIGNED_URL_EXPIRY_SECONDS = values.PositiveIntegerValue(
|
||||
60 * 60,
|
||||
environ_name="RECORDING_PUSH_TO_DRIVE_SIGNED_URL_EXPIRY_SECONDS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
# Development only: host:port to reach Drive's object storage at, when the
|
||||
# domain Drive signs its upload URLs with is only resolvable from a browser.
|
||||
DRIVE_UPLOAD_STORAGE_NETLOC = values.Value(
|
||||
None, environ_name="DRIVE_UPLOAD_STORAGE_NETLOC", environ_prefix=None
|
||||
)
|
||||
|
||||
# Recording encoding options for LiveKit Egress (video composite egress only).
|
||||
# These settings affect screen recordings handled by VideoCompositeEgressService;
|
||||
# they are silently ignored by AudioCompositeEgressService (audio-only transcript
|
||||
@@ -747,12 +794,30 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
SUMMARY_SERVICE_VERSION = values.PositiveIntegerValue(
|
||||
1, environ_name="SUMMARY_SERVICE_VERSION", environ_prefix=None
|
||||
)
|
||||
SUMMARY_SERVICE_ENDPOINT = values.Value(
|
||||
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
|
||||
)
|
||||
SUMMARY_SERVICE_API_TOKEN = SecretFileValue(
|
||||
None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None
|
||||
)
|
||||
SUMMARY_SERVICE_WEBHOOK_API_TOKEN = SecretFileValue(
|
||||
None, environ_name="SUMMARY_SERVICE_WEBHOOK_API_TOKEN", environ_prefix=None
|
||||
)
|
||||
SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS = (
|
||||
values.PositiveIntegerValue(
|
||||
60 * 60 * 24,
|
||||
environ_name="SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
)
|
||||
TRANSCRIPTION_SATISFACTION_FORM_BASE_URL = values.Value(
|
||||
None,
|
||||
environ_name="TRANSCRIPTION_SATISFACTION_FORM_BASE_URL",
|
||||
environ_prefix=None,
|
||||
)
|
||||
SCREEN_RECORDING_BASE_URL = values.Value(
|
||||
None, environ_name="SCREEN_RECORDING_BASE_URL", environ_prefix=None
|
||||
)
|
||||
@@ -760,6 +825,14 @@ class Base(Configuration):
|
||||
None, environ_name="RECORDING_DOWNLOAD_BASE_URL", environ_prefix=None
|
||||
)
|
||||
|
||||
# Analytics
|
||||
ANALYTICS_BACKEND = values.Value(
|
||||
None, environ_name="ANALYTICS_BACKEND", environ_prefix=None
|
||||
)
|
||||
ANALYTICS_BACKEND_SETTINGS = values.DictValue(
|
||||
{}, environ_name="ANALYTICS_BACKEND_SETTINGS", environ_prefix=None
|
||||
)
|
||||
|
||||
# Marketing and communication settings
|
||||
SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue(
|
||||
False, # When enabled, new users are automatically added to mailing list.
|
||||
@@ -1101,11 +1174,25 @@ class Base(Configuration):
|
||||
"FILE_UPLOAD_TMP_PATH cannot be the same as FILE_UPLOAD_PATH"
|
||||
)
|
||||
|
||||
if (
|
||||
cls.SUMMARY_SERVICE_VERSION == 1
|
||||
and cls.SUMMARY_SERVICE_ENDPOINT is not None
|
||||
):
|
||||
warnings.warn(
|
||||
"SUMMARY_SERVICE_VERSION=1 is deprecated. "
|
||||
"The legacy v1 API has been removed from the experimental "
|
||||
"Summary service. Please update your Summary service deployment to "
|
||||
"the v2 API and set SUMMARY_SERVICE_VERSION=2.",
|
||||
# We use UserWarning to make sure it shows up in production deployment
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
# The SENTRY_DSN setting should be available to activate sentry for an environment
|
||||
if cls.SENTRY_DSN is not None:
|
||||
sentry_sdk.init(
|
||||
dsn=cls.SENTRY_DSN,
|
||||
environment=cls.__name__.lower(),
|
||||
environment=cls.__name__.lower(), # build, test, development, production
|
||||
release=get_release(),
|
||||
integrations=[DjangoIntegration()],
|
||||
)
|
||||
|
||||
+14
-13
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "1.21.0"
|
||||
version = "1.24.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -24,19 +24,19 @@ keywords = ["Django", "Contacts", "Templates", "RBAC"]
|
||||
license = "MIT"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"boto3==1.43.14",
|
||||
"boto3==1.43.36",
|
||||
"Brotli==1.2.0",
|
||||
"brevo-python==1.2.0",
|
||||
"celery[redis]==5.6.3",
|
||||
"dj-database-url==3.1.2",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.9.0",
|
||||
"django-countries==8.2.0",
|
||||
"django-countries==9.0.0",
|
||||
"django-filter==25.2",
|
||||
"django-lasuite[all]==0.0.26",
|
||||
"django-lasuite[all]==0.0.27",
|
||||
"django-parler==2.4",
|
||||
"redis==5.2.1",
|
||||
"django-redis==6.0.0",
|
||||
"django-redis==7.0.0",
|
||||
"django-storages[s3]==1.14.6",
|
||||
"django-timezone-field>=5.1",
|
||||
"django-pydantic-field==0.5.4",
|
||||
@@ -50,19 +50,20 @@ dependencies = [
|
||||
"jsonschema==4.26.0",
|
||||
"markdown==3.10.2",
|
||||
"nested-multipart-parser==1.6.0",
|
||||
"posthog==7.16.1",
|
||||
"psycopg[binary]==3.3.4",
|
||||
"pydantic==2.13.4",
|
||||
"PyJWT==2.13.0",
|
||||
"python-frontmatter==1.3.0",
|
||||
"python-magic==0.4.27",
|
||||
"requests==2.34.2",
|
||||
"sentry-sdk==2.60.0",
|
||||
"sentry-sdk==2.63.0",
|
||||
"whitenoise==6.12.0",
|
||||
"mozilla-django-oidc==5.0.2",
|
||||
"livekit-api==1.1.0",
|
||||
"aiohttp==3.14.0",
|
||||
"livekit-api==1.1.1",
|
||||
"aiohttp==3.14.1",
|
||||
"urllib3==2.7.0",
|
||||
"phonenumbers==9.0.31",
|
||||
"phonenumbers==9.0.33",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -74,20 +75,20 @@ dependencies = [
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"django-extensions==4.1",
|
||||
"drf-spectacular-sidecar==2026.5.1",
|
||||
"drf-spectacular-sidecar==2026.6.1",
|
||||
"freezegun==1.5.5",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==9.13.0",
|
||||
"ipython==9.14.1",
|
||||
"pyfakefs==6.2.0",
|
||||
"pylint-django==2.7.0",
|
||||
"pylint<4.0.0",
|
||||
"pytest-cov==7.1.0",
|
||||
"pytest-django==4.12.0",
|
||||
"pytest==9.0.3",
|
||||
"pytest==9.1.1",
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.8.0",
|
||||
"responses==0.26.1",
|
||||
"ruff==0.15.14",
|
||||
"ruff==0.15.19",
|
||||
"types-requests==2.33.0.20260518",
|
||||
]
|
||||
|
||||
|
||||
Generated
+584
-561
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
FROM node:20-alpine AS frontend-deps
|
||||
FROM node:22-alpine AS frontend-deps
|
||||
|
||||
USER node
|
||||
|
||||
@@ -39,7 +39,7 @@ ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||
RUN npm run build
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:alpine3.23 AS frontend-production
|
||||
FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
|
||||
|
||||
USER root
|
||||
|
||||
|
||||
Generated
+274
-2050
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.21.0",
|
||||
"version": "1.24.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
@@ -17,31 +17,33 @@
|
||||
"dependencies": {
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||
"@fontsource-variable/lexend": "5.2.11",
|
||||
"@libreaudio/la-call": "0.1.4",
|
||||
"@fontsource/opendyslexic": "5.2.5",
|
||||
"@libreaudio/la-call": "0.1.4",
|
||||
"@livekit/components-react": "2.9.21",
|
||||
"@livekit/components-styles": "1.2.0",
|
||||
"@livekit/track-processors": "0.7.2",
|
||||
"@pandacss/preset-panda": "1.11.1",
|
||||
"@react-aria/toast": "3.0.10",
|
||||
"@mediapipe/tasks-vision": "0.10.35",
|
||||
"@pandacss/preset-panda": "1.11.3",
|
||||
"@react-types/overlays": "3.10.0",
|
||||
"@remixicon/react": "4.9.0",
|
||||
"@tanstack/react-query": "5.100.14",
|
||||
"@tanstack/react-query": "5.101.1",
|
||||
"@timephy/rnnoise-wasm": "1.0.0",
|
||||
"crisp-sdk-web": "1.1.2",
|
||||
"derive-valtio": "0.2.0",
|
||||
"hoofd": "1.7.3",
|
||||
"humanize-duration": "3.33.2",
|
||||
"i18next": "26.2.0",
|
||||
"i18next": "26.3.1",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"i18next-parser": "9.4.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"livekit-client": "2.19.0",
|
||||
"posthog-js": "1.376.0",
|
||||
"livekit-client": "2.20.0",
|
||||
"posthog-js": "1.395.0",
|
||||
"react": "18.3.1",
|
||||
"react-aria-components": "1.14.0",
|
||||
"react-aria": "3.50.0",
|
||||
"react-aria-components": "1.19.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "17.0.8",
|
||||
"react-stately": "3.48.0",
|
||||
"use-sound": "5.0.0",
|
||||
"valtio": "2.3.2",
|
||||
"wouter": "3.10.0"
|
||||
@@ -52,6 +54,7 @@
|
||||
"@tanstack/eslint-plugin-query": "5.100.14",
|
||||
"@tanstack/react-query-devtools": "5.100.14",
|
||||
"@types/humanize-duration": "3.27.4",
|
||||
"@types/node": "24.12.4",
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@vitejs/plugin-react": "6.0.2",
|
||||
@@ -68,6 +71,7 @@
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.60.1",
|
||||
"vite": "8.0.14",
|
||||
"vite-plugin-static-copy": "4.1.1",
|
||||
"vite-plugin-svgr": "5.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,6 +114,10 @@ const config: Config = {
|
||||
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 0)',
|
||||
},
|
||||
},
|
||||
overlayIn: {
|
||||
from: { opacity: 0 },
|
||||
to: { opacity: 0.6 },
|
||||
},
|
||||
},
|
||||
tokens: defineTokens({
|
||||
/* we take a few things from the panda preset but for now we clear out some stuff.
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -9,6 +9,7 @@ export interface ApiConfig {
|
||||
analytics?: {
|
||||
id: string
|
||||
host: string
|
||||
flags_api_host?: string
|
||||
}
|
||||
support?: {
|
||||
id: string
|
||||
@@ -19,6 +20,7 @@ export interface ApiConfig {
|
||||
feedback: {
|
||||
url: string
|
||||
}
|
||||
documentation_url?: string
|
||||
external_home_url?: string
|
||||
silence_livekit_debug_logs?: boolean
|
||||
is_silent_login_enabled?: boolean
|
||||
@@ -57,6 +59,7 @@ export interface ApiConfig {
|
||||
transcription_destination?: string
|
||||
max_participants_for_sound: number
|
||||
auto_mute_on_join_threshold: number
|
||||
authenticated_users_can_edit_display_name: boolean
|
||||
}
|
||||
|
||||
const fetchConfig = (): Promise<ApiConfig> => {
|
||||
|
||||
@@ -7,36 +7,20 @@ const avatar = cva({
|
||||
color: 'white',
|
||||
display: 'flex',
|
||||
borderRadius: '50%',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
userSelect: 'none',
|
||||
cursor: 'default',
|
||||
flexGrow: 0,
|
||||
flexShrink: 0,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
variants: {
|
||||
context: {
|
||||
subtitles: {
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
fontSize: '1.3rem',
|
||||
lineHeight: '1rem',
|
||||
},
|
||||
list: {
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
fontSize: '1.3rem',
|
||||
lineHeight: '1rem',
|
||||
},
|
||||
placeholder: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
subtitles: { width: '40px', height: '40px' },
|
||||
list: { width: '32px', height: '32px' },
|
||||
placeholder: { width: '100%', height: '100%' },
|
||||
},
|
||||
notification: {
|
||||
true: {
|
||||
border: '2px solid white',
|
||||
},
|
||||
true: { border: '2px solid white' },
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
@@ -44,37 +28,49 @@ const avatar = cva({
|
||||
},
|
||||
})
|
||||
|
||||
const getInitials = (name?: string): string => {
|
||||
if (!name) return ''
|
||||
const words = name.trim().split(/\s+/).filter(Boolean)
|
||||
if (words.length === 0) return ''
|
||||
const first = words[0].charAt(0)
|
||||
const second = words.length > 1 ? words[1].charAt(0) : ''
|
||||
return (first + second).toUpperCase()
|
||||
}
|
||||
|
||||
export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
name?: string
|
||||
bgColor?: string
|
||||
} & RecipeVariantProps<typeof avatar>
|
||||
|
||||
export const Avatar = ({
|
||||
name,
|
||||
bgColor,
|
||||
context,
|
||||
notification,
|
||||
style,
|
||||
...props
|
||||
}: AvatarProps) => {
|
||||
const initial = name?.trim()?.charAt(0) ?? ''
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: bgColor,
|
||||
...style,
|
||||
}}
|
||||
className={avatar({ context, notification })}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={css({
|
||||
marginTop: '-0.3rem',
|
||||
})}
|
||||
export const Avatar = React.memo(
|
||||
({ name, bgColor, context, notification, style, ...props }: AvatarProps) => {
|
||||
const initials = getInitials(name)
|
||||
return (
|
||||
<div
|
||||
style={{ backgroundColor: bgColor, ...style }}
|
||||
className={avatar({ context, notification })}
|
||||
{...props}
|
||||
>
|
||||
{initial}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<svg
|
||||
viewBox="0 0 100 100"
|
||||
aria-hidden="true"
|
||||
className={css({ width: '100%', height: '100%', display: 'block' })}
|
||||
>
|
||||
<text
|
||||
x="50"
|
||||
y="50"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize={initials.length > 1 ? 48 : 52}
|
||||
fontWeight="500"
|
||||
fill="currentColor"
|
||||
>
|
||||
{initials.toUpperCase()}
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Avatar.displayName = 'Avatar'
|
||||
|
||||
@@ -28,10 +28,16 @@ export const terminateAnalyticsSession = async () => {
|
||||
export type useAnalyticsProps = {
|
||||
id?: string
|
||||
host?: string
|
||||
flags_api_host?: string
|
||||
isDisabled?: boolean
|
||||
}
|
||||
|
||||
export const useAnalytics = ({ id, host, isDisabled }: useAnalyticsProps) => {
|
||||
export const useAnalytics = ({
|
||||
id,
|
||||
host,
|
||||
flags_api_host,
|
||||
isDisabled,
|
||||
}: useAnalyticsProps) => {
|
||||
const [location] = useLocation()
|
||||
const { user } = useUser()
|
||||
|
||||
@@ -39,9 +45,13 @@ export const useAnalytics = ({ id, host, isDisabled }: useAnalyticsProps) => {
|
||||
if (!id || !host || isDisabled) return
|
||||
getPosthog().then((ph) => {
|
||||
if (ph.__loaded) return
|
||||
ph.init(id, { api_host: host, person_profiles: 'always' })
|
||||
ph.init(id, {
|
||||
api_host: host,
|
||||
flags_api_host: flags_api_host,
|
||||
person_profiles: 'always',
|
||||
})
|
||||
})
|
||||
}, [id, host, isDisabled])
|
||||
}, [id, host, flags_api_host, isDisabled])
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user