mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-26 20:08:24 +00:00
Compare commits
126 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 976abba1d6 | |||
| 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 | |||
| 898bc9a0f8 | |||
| fd1715bacf | |||
| c9de7d049f | |||
| 6368b676a6 | |||
| 65789ef706 | |||
| c0feb1ee82 | |||
| 135b99aee7 | |||
| 040df0e15a | |||
| 00e197b216 | |||
| eae1a382d5 | |||
| d4a7cf279c | |||
| 70a296eea6 | |||
| ac85a20271 | |||
| 13036f6ab7 | |||
| 64819b3696 | |||
| 53722ad1bc | |||
| dcfdd35c82 | |||
| 16f465432d | |||
| 90f95ab2a9 | |||
| 61f7ad05e9 | |||
| 69a6dd1463 | |||
| 6d06aee92d | |||
| 54908b9caa | |||
| 08af6e77bb | |||
| 4525c9c255 | |||
| 44d3ed8f2e | |||
| 9522dc72ac | |||
| 09dbb250a7 | |||
| 321cb99f82 | |||
| 52bad6faaf | |||
| 0065527a5e | |||
| 818d20888e | |||
| f1fce4431f | |||
| 491866e584 | |||
| f777f2fdeb | |||
| e5184695bb | |||
| 71d59dd9f1 | |||
| 3537fdf648 | |||
| 7b485377cf | |||
| f5a5fa93af | |||
| aca3261a9a | |||
| 8c3d1bdd95 | |||
| a01f0256a1 | |||
| 27ebc2f2e3 | |||
| 553df5070e | |||
| c79984a883 | |||
| 22b2e6bd1e | |||
| be35c1d6e0 | |||
| 8d653b30e5 | |||
| 5602d256d8 | |||
| d9804172e7 | |||
| 61d0043790 | |||
| 6ccc9ef0bf | |||
| 73a7841b96 | |||
| 6c25d0a525 | |||
| d13e3a8a5d | |||
| a82d7f885a | |||
| 5ef6e8b5ea | |||
| 913d4f91ae | |||
| 1f437089ad | |||
| 85eff8afaf | |||
| 29b0a6fcb4 | |||
| 3554b2eb53 | |||
| e25aa6ce05 | |||
| a8b79740e9 | |||
| 28f652e035 | |||
| a4997e7431 |
@@ -19,6 +19,8 @@ env:
|
||||
DOCKER_USER: 1001:127
|
||||
DOCKER_CONTAINER_REGISTRY_HOSTNAME: docker.io
|
||||
DOCKER_CONTAINER_REGISTRY_NAMESPACE: lasuite
|
||||
IS_MULTI_PLATFORM_BUILD: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
BUILD_PLATFORMS: ${{ startsWith(github.ref, 'refs/tags/v') && 'linux/amd64,linux/arm64' || 'linux/amd64' }}
|
||||
|
||||
jobs:
|
||||
build-and-push-backend:
|
||||
@@ -31,6 +33,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
-
|
||||
name: Set up QEMU
|
||||
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||
uses: docker/setup-qemu-action@v3
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
@@ -43,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 }}
|
||||
@@ -60,9 +63,9 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
target: backend-production
|
||||
platforms: linux/amd64,linux/arm64
|
||||
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 }}
|
||||
|
||||
@@ -76,6 +79,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
-
|
||||
name: Set up QEMU
|
||||
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||
uses: docker/setup-qemu-action@v3
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
@@ -88,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 }}
|
||||
@@ -106,9 +110,9 @@ jobs:
|
||||
context: .
|
||||
file: ./src/frontend/Dockerfile
|
||||
target: frontend-production
|
||||
platforms: linux/amd64,linux/arm64
|
||||
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 }}
|
||||
|
||||
@@ -122,6 +126,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
-
|
||||
name: Set up QEMU
|
||||
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||
uses: docker/setup-qemu-action@v3
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
@@ -134,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 }}
|
||||
@@ -152,9 +157,9 @@ jobs:
|
||||
context: .
|
||||
file: ./docker/dinum-frontend/Dockerfile
|
||||
target: frontend-production
|
||||
platforms: linux/amd64,linux/arm64
|
||||
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 }}
|
||||
|
||||
@@ -168,6 +173,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
-
|
||||
name: Set up QEMU
|
||||
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||
uses: docker/setup-qemu-action@v3
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
@@ -180,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 }}
|
||||
@@ -200,9 +206,9 @@ jobs:
|
||||
context: ./src/summary
|
||||
file: ./src/summary/Dockerfile
|
||||
target: production
|
||||
platforms: linux/amd64,linux/arm64
|
||||
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 }}
|
||||
|
||||
@@ -216,6 +222,7 @@ jobs:
|
||||
uses: actions/checkout@v6
|
||||
-
|
||||
name: Set up QEMU
|
||||
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||
uses: docker/setup-qemu-action@v3
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
@@ -228,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 }}
|
||||
@@ -248,9 +255,9 @@ jobs:
|
||||
context: ./src/agents
|
||||
file: ./src/agents/Dockerfile
|
||||
target: production
|
||||
platforms: linux/amd64,linux/arm64
|
||||
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 }}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
+109
-1
@@ -8,6 +8,113 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 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
|
||||
|
||||
### 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
|
||||
|
||||
### 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
|
||||
|
||||
- ✨(frontend) allow disabling silent login via a URL parameter
|
||||
- ✨(frontend) allow hiding the login button via a URL parameter
|
||||
- ✨(summary) add optional satisfaction survey footer
|
||||
|
||||
### Changed
|
||||
|
||||
- ✨(frontend) enhance noise reduction with BBBA audio processing pipeline
|
||||
- 🚸(frontend) mute join notification sound in larger rooms
|
||||
- 🚸(frontend) mute participants by default when joining a large meeting
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) fix metadata agent collector enabled check
|
||||
|
||||
### Fixed
|
||||
|
||||
- ♿️(frontend) improve accessibilty of the Effects panel #1401
|
||||
|
||||
## [1.20.0] - 2026-06-12
|
||||
|
||||
### Changed
|
||||
|
||||
- ♻️(addon) improve Outlook add-on: i18n support, feedback link, smarter link
|
||||
- ⬆️(frontend) upgrade react-i18next from 15.1.1 to 17.0.8
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(frontend) fix noise reduction left-channel-only audio
|
||||
|
||||
## [1.19.0] - 2026-06-04
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) add file specific admin #1387
|
||||
|
||||
### Changed
|
||||
|
||||
- 🐛(agents) fix bug when closing metadata-collector
|
||||
- ⬆️(dependencies) update python dependencies
|
||||
- ⬆️(frontend) update js dependencies
|
||||
- ♻️(agents) replace deprecated room options API
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔇(summary) make ffmpeg quiet #1404
|
||||
- 🔒️(backend) prevent accessing files if they are not ready #1395
|
||||
- # ⬆️(backend) upgrade idna to >=3.15 to address CVE-2026-45409
|
||||
|
||||
## [1.18.0] - 2026-06-03
|
||||
|
||||
### Added
|
||||
|
||||
- 🔧(backend) backport logging configuration from docs
|
||||
- 🧑💻(backend) add management command to merge duplicate users
|
||||
- 👷(helm) add Kubernetes job for duplicate user merge command
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(backend) prevent duplicate pending users on concurrent requests
|
||||
- 🔒️(backend) prevent file change post checks #1377
|
||||
|
||||
## [1.17.0] - 2026-05-31
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(fullstack) allow participants to mute others based on room configuration
|
||||
@@ -18,7 +125,7 @@ and this project adheres to
|
||||
- ✨(backend) add core.recording.event.parsers.S3Parser
|
||||
- ✨(summary) extended support for all video / audio files #1358
|
||||
|
||||
### Changed
|
||||
### Changed
|
||||
|
||||
- ♻️(fullstack) simplify source serialization
|
||||
- ✨(backend) expose room configuration to all API consumers
|
||||
@@ -28,6 +135,7 @@ and this project adheres to
|
||||
- ♻️(backend) prefix Swagger routes with /api
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🩹(backend) fix swagger and redoc documentation URLs
|
||||
|
||||
## [1.16.0] - 2026-05-13
|
||||
|
||||
@@ -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
|
||||
@@ -210,24 +211,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 +294,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
|
||||
|
||||
|
||||
+13
-20
@@ -93,7 +93,7 @@ services:
|
||||
networks:
|
||||
- resource-server
|
||||
- default
|
||||
|
||||
|
||||
celery-dev:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: meet:backend-development
|
||||
@@ -237,29 +237,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 +301,7 @@ services:
|
||||
context: ./src/summary
|
||||
dockerfile: Dockerfile
|
||||
target: production
|
||||
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q transcribe-queue
|
||||
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 +321,7 @@ services:
|
||||
context: ./src/summary
|
||||
dockerfile: Dockerfile
|
||||
target: production
|
||||
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q summarize-queue
|
||||
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q summarize-queue-v2
|
||||
env_file:
|
||||
- env.d/development/summary
|
||||
volumes:
|
||||
|
||||
@@ -60,6 +60,8 @@ USER root
|
||||
|
||||
# Security patches for known CVEs
|
||||
RUN apk update && apk upgrade \
|
||||
libcrypto3>=3.5.7-r0 \
|
||||
libssl3>=3.5.7-r0 \
|
||||
musl \
|
||||
musl-utils \
|
||||
zlib>=1.3.2-r0 \
|
||||
|
||||
@@ -48,9 +48,12 @@ server {
|
||||
|
||||
set $nonce $request_id;
|
||||
|
||||
set $csp "upgrade-insecure-requests; ";
|
||||
set $csp "default-src 'self'; upgrade-insecure-requests; ";
|
||||
set $csp "${csp}frame-ancestors ${ms_domains}; ";
|
||||
set $csp "${csp}script-src 'nonce-${nonce}' 'strict-dynamic'; ";
|
||||
set $csp "${csp}style-src 'self' 'unsafe-inline'; ";
|
||||
set $csp "${csp}img-src 'self' data:; ";
|
||||
set $csp "${csp}font-src 'self' data:; ";
|
||||
set $csp "${csp}connect-src 'self' ${ms_domains}; ";
|
||||
set $csp "${csp}frame-src 'none'; ";
|
||||
set $csp "${csp}object-src 'none'; ";
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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.
|
||||
@@ -344,7 +407,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. | |
|
||||
|
||||
@@ -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,10 @@ 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=
|
||||
|
||||
@@ -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,5 +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}]'
|
||||
|
||||
@@ -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.1.0</Version>
|
||||
<Version>0.0.2.0</Version>
|
||||
<ProviderName>__APP_NAME__</ProviderName>
|
||||
<DefaultLocale>fr-FR</DefaultLocale>
|
||||
<DisplayName DefaultValue="__APP_NAME__"/>
|
||||
@@ -87,9 +87,9 @@
|
||||
<Description resid="GenerateLink.Tooltip"/>
|
||||
</Supertip>
|
||||
<Icon>
|
||||
<bt:Image size="16" resid="Add.16x16"/>
|
||||
<bt:Image size="32" resid="Add.32x32"/>
|
||||
<bt:Image size="80" resid="Add.80x80"/>
|
||||
<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>
|
||||
@@ -126,9 +126,9 @@
|
||||
<Description resid="GenerateLink.Tooltip"/>
|
||||
</Supertip>
|
||||
<Icon>
|
||||
<bt:Image size="16" resid="Add.16x16"/>
|
||||
<bt:Image size="32" resid="Add.32x32"/>
|
||||
<bt:Image size="80" resid="Add.80x80"/>
|
||||
<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>
|
||||
@@ -175,15 +175,34 @@
|
||||
<bt:Url id="Taskpane.Url" DefaultValue="https://localhost:3000/taskpane.html"/>
|
||||
</bt:Urls>
|
||||
<bt:ShortStrings>
|
||||
<!-- Default (French) -->
|
||||
<bt:String id="GroupLabel" DefaultValue="__APP_NAME__"/>
|
||||
<bt:String id="TaskpaneButton.Label" DefaultValue="Ouvrir les paramètres"/>
|
||||
<bt:String id="GenerateLink.Label" DefaultValue="Ajouter un lien __APP_NAME__"/>
|
||||
<bt:String id="OpenSettings.Label" DefaultValue="Paramètres"/>
|
||||
<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="TaskpaneButton.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__."/>
|
||||
<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:String id="OpenSettings.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__."/>
|
||||
<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>
|
||||
|
||||
Generated
+65
-28
@@ -9,33 +9,35 @@
|
||||
"version": "0.0.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "^3.49.0",
|
||||
"regenerator-runtime": "^0.14.1"
|
||||
"core-js": "3.49.0",
|
||||
"i18next": "26.3.1",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"regenerator-runtime": "0.14.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.24.0",
|
||||
"@babel/preset-env": "^7.25.4",
|
||||
"@types/office-js": "^1.0.377",
|
||||
"@types/office-runtime": "^1.0.35",
|
||||
"acorn": "^8.11.3",
|
||||
"babel-loader": "^9.1.3",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"eslint-plugin-office-addins": "^4.0.3",
|
||||
"file-loader": "^6.2.0",
|
||||
"html-loader": "^5.0.0",
|
||||
"html-webpack-inject-attributes-plugin": "^1.0.6",
|
||||
"html-webpack-plugin": "^5.6.0",
|
||||
"office-addin-cli": "^2.0.3",
|
||||
"office-addin-debugging": "^6.0.3",
|
||||
"office-addin-dev-certs": "^2.0.3",
|
||||
"office-addin-lint": "^3.0.3",
|
||||
"office-addin-manifest": "^2.0.3",
|
||||
"office-addin-prettier-config": "^2.0.1",
|
||||
"os-browserify": "^0.3.0",
|
||||
"process": "^0.11.10",
|
||||
"source-map-loader": "^5.0.0",
|
||||
"webpack": "^5.95.0",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"@babel/core": "7.29.0",
|
||||
"@babel/preset-env": "7.29.0",
|
||||
"@types/office-js": "1.0.582",
|
||||
"@types/office-runtime": "1.0.36",
|
||||
"acorn": "8.16.0",
|
||||
"babel-loader": "9.2.1",
|
||||
"copy-webpack-plugin": "14.0.0",
|
||||
"eslint-plugin-office-addins": "4.0.6",
|
||||
"file-loader": "6.2.0",
|
||||
"html-loader": "5.1.0",
|
||||
"html-webpack-inject-attributes-plugin": "1.0.6",
|
||||
"html-webpack-plugin": "5.6.6",
|
||||
"office-addin-cli": "2.0.6",
|
||||
"office-addin-debugging": "6.0.6",
|
||||
"office-addin-dev-certs": "2.0.6",
|
||||
"office-addin-lint": "3.0.6",
|
||||
"office-addin-manifest": "2.1.2",
|
||||
"office-addin-prettier-config": "2.0.1",
|
||||
"os-browserify": "0.3.0",
|
||||
"process": "0.11.10",
|
||||
"source-map-loader": "5.0.0",
|
||||
"webpack": "5.105.4",
|
||||
"webpack-cli": "5.1.4",
|
||||
"webpack-dev-server": "5.2.4"
|
||||
}
|
||||
},
|
||||
@@ -2111,9 +2113,7 @@
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
||||
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
@@ -9363,6 +9363,43 @@
|
||||
"node": ">=10.18"
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
|
||||
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com/i18next"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
|
||||
},
|
||||
{
|
||||
"type": "individual",
|
||||
"url": "https://www.locize.com"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/i18next-browser-languagedetector": {
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
|
||||
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.23.2"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
@@ -15213,7 +15250,7 @@
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
|
||||
@@ -26,33 +26,35 @@
|
||||
"watch": "webpack --mode development --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"core-js": "^3.49.0",
|
||||
"regenerator-runtime": "^0.14.1"
|
||||
"core-js": "3.49.0",
|
||||
"i18next": "26.3.1",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"regenerator-runtime": "0.14.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.24.0",
|
||||
"@babel/preset-env": "^7.25.4",
|
||||
"@types/office-js": "^1.0.377",
|
||||
"@types/office-runtime": "^1.0.35",
|
||||
"acorn": "^8.11.3",
|
||||
"babel-loader": "^9.1.3",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"eslint-plugin-office-addins": "^4.0.3",
|
||||
"file-loader": "^6.2.0",
|
||||
"html-loader": "^5.0.0",
|
||||
"html-webpack-inject-attributes-plugin": "^1.0.6",
|
||||
"html-webpack-plugin": "^5.6.0",
|
||||
"office-addin-cli": "^2.0.3",
|
||||
"office-addin-debugging": "^6.0.3",
|
||||
"office-addin-dev-certs": "^2.0.3",
|
||||
"office-addin-lint": "^3.0.3",
|
||||
"office-addin-manifest": "^2.0.3",
|
||||
"office-addin-prettier-config": "^2.0.1",
|
||||
"os-browserify": "^0.3.0",
|
||||
"process": "^0.11.10",
|
||||
"source-map-loader": "^5.0.0",
|
||||
"webpack": "^5.95.0",
|
||||
"webpack-cli": "^5.1.4",
|
||||
"@babel/core": "7.29.0",
|
||||
"@babel/preset-env": "7.29.0",
|
||||
"@types/office-js": "1.0.582",
|
||||
"@types/office-runtime": "1.0.36",
|
||||
"acorn": "8.16.0",
|
||||
"babel-loader": "9.2.1",
|
||||
"copy-webpack-plugin": "14.0.0",
|
||||
"eslint-plugin-office-addins": "4.0.6",
|
||||
"file-loader": "6.2.0",
|
||||
"html-loader": "5.1.0",
|
||||
"html-webpack-inject-attributes-plugin": "1.0.6",
|
||||
"html-webpack-plugin": "5.6.6",
|
||||
"office-addin-cli": "2.0.6",
|
||||
"office-addin-debugging": "6.0.6",
|
||||
"office-addin-dev-certs": "2.0.6",
|
||||
"office-addin-lint": "3.0.6",
|
||||
"office-addin-manifest": "2.1.2",
|
||||
"office-addin-prettier-config": "2.0.1",
|
||||
"os-browserify": "0.3.0",
|
||||
"process": "0.11.10",
|
||||
"source-map-loader": "5.0.0",
|
||||
"webpack": "5.105.4",
|
||||
"webpack-cli": "5.1.4",
|
||||
"webpack-dev-server": "5.2.4"
|
||||
},
|
||||
"prettier": "office-addin-prettier-config",
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
/* global Office */
|
||||
const { APP_NAME } = require("../common/index");
|
||||
const { createRoom, initSession } = require("../common/api");
|
||||
const { startPolling } = require("../common/polling");
|
||||
const { saveSession, loadSession } = require("../common/session");
|
||||
const { openTransitDialog } = require("../common/transitDialog");
|
||||
const { buildMeetingMessage } = require("../common/messageBuilder");
|
||||
const { applyAppName } = require("../common/helpers");
|
||||
const { initI18n, t } = require("../common/i18n");
|
||||
const { isMeetingAlreadyAdded } = require("../common/meetingDetector");
|
||||
|
||||
Office.onReady(async function (info) {
|
||||
|
||||
await initI18n()
|
||||
|
||||
Office.onReady(function (info) {
|
||||
if (info.host === Office.HostType.Outlook) {
|
||||
applyAppName();
|
||||
}
|
||||
@@ -22,41 +28,52 @@ function notify(message) {
|
||||
}
|
||||
|
||||
function insertMeetingLink(event, session) {
|
||||
const item = Office.context.mailbox.item;
|
||||
|
||||
isMeetingAlreadyAdded(item)
|
||||
.then((alreadyAdded) => {
|
||||
if (alreadyAdded) {
|
||||
notify(t("meeting.already_added", { app_name: APP_NAME }));
|
||||
event.completed();
|
||||
return;
|
||||
}
|
||||
return _doInsertMeetingLink(event, session);
|
||||
})
|
||||
.catch((err) => {
|
||||
notify(t("meeting.error.details", { message: err.message }));
|
||||
event.completed();
|
||||
});
|
||||
}
|
||||
|
||||
function _doInsertMeetingLink(event, session) {
|
||||
createRoom(session)
|
||||
.then((data) => {
|
||||
const { url, message } = buildMeetingMessage(data);
|
||||
const isWeb = Office.context.diagnostics.platform === "OfficeOnline";
|
||||
const { url, text } = buildMeetingMessage(data, isWeb);
|
||||
const item = Office.context.mailbox.item;
|
||||
const coercionType = isWeb ? Office.CoercionType.Html : Office.CoercionType.Text;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
item.body.getAsync(Office.CoercionType.Html, (getResult) => {
|
||||
if (getResult.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
notify(`Erreur de lecture : ${getResult.error.message}`);
|
||||
item.body.setSelectedDataAsync(text, { coercionType }, (setResult) => {
|
||||
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
notify(t("meeting.error.details", { message: setResult.error.message }));
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const newBody = getResult.value + message;
|
||||
item.body.setAsync(newBody, { coercionType: Office.CoercionType.Html }, (setResult) => {
|
||||
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
notify(`Erreur d'insertion : ${setResult.error.message}`);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
if (item.itemType !== Office.MailboxEnums.ItemType.Appointment) {
|
||||
notify(t("meeting.link_inserted"));
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.itemType !== Office.MailboxEnums.ItemType.Appointment) {
|
||||
notify("Lien de réunion inséré !");
|
||||
resolve();
|
||||
return;
|
||||
item.location.setAsync(url, (locationResult) => {
|
||||
if (locationResult.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
notify(t("meeting.error.details", { message: locationResult.error.message }));
|
||||
} else {
|
||||
notify(t("meeting.link_inserted"));
|
||||
}
|
||||
|
||||
item.location.setAsync(url, (locationResult) => {
|
||||
if (locationResult.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
notify(`Erreur de localisation : ${locationResult.error.message}`);
|
||||
} else {
|
||||
notify("Lien de réunion inséré !");
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -79,11 +96,11 @@ function connect(event) {
|
||||
});
|
||||
},
|
||||
onTimeout: () => {
|
||||
notify("Connexion expirée, veuillez réessayer.");
|
||||
notify(t("meeting.error.auth"));
|
||||
event.completed();
|
||||
},
|
||||
onError: (err) => {
|
||||
notify("Une erreur est survenue, veuillez ré-essayer");
|
||||
notify(t("meeting.error.retry"));
|
||||
event.completed();
|
||||
},
|
||||
});
|
||||
@@ -99,7 +116,7 @@ function connect(event) {
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
notify(`Erreur : ${err.message}`);
|
||||
notify(t("meeting.error.details", { message: err.message }));
|
||||
event.completed();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
const { APP_NAME } = require("../common");
|
||||
|
||||
const i18nextModule = require("i18next");
|
||||
const i18next = i18nextModule.default || i18nextModule;
|
||||
|
||||
const fr = require("../locales/fr/translation.json");
|
||||
const en = require("../locales/en/translation.json");
|
||||
const de = require("../locales/de/translation.json");
|
||||
|
||||
async function initI18n() {
|
||||
const lng = typeof Office !== "undefined" ? Office.context.displayLanguage : navigator.language;
|
||||
|
||||
await i18next.init({
|
||||
lng,
|
||||
fallbackLng: "fr",
|
||||
interpolation: { escapeValue: false },
|
||||
resources: {
|
||||
fr: { translation: fr },
|
||||
en: { translation: en },
|
||||
de: { translation: de },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function t(key, vars) {
|
||||
return i18next.t(key, vars);
|
||||
}
|
||||
|
||||
function translateUI() {
|
||||
document.querySelectorAll("[data-i18n]").forEach((el) => {
|
||||
const key = el.getAttribute("data-i18n");
|
||||
el.textContent = t(key, { app_name: APP_NAME });
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-i18n-attr]").forEach((el) => {
|
||||
const pairs = el.getAttribute("data-i18n-attr").split(",");
|
||||
pairs.forEach((pair) => {
|
||||
const [attr, key] = pair.split(":");
|
||||
el.setAttribute(attr, t(key, { app_name: APP_NAME }));
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll("[data-i18n-aria]").forEach((el) => {
|
||||
el.setAttribute("aria-label", t(el.getAttribute("data-i18n-aria")));
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { initI18n, t, translateUI };
|
||||
@@ -1,7 +1,11 @@
|
||||
const BASE_URL = window.__APP_CONFIG__?.BASE_URL || "https://meet.127.0.0.1.nip.io";
|
||||
const APP_NAME = window.__APP_CONFIG__?.APP_NAME || "LaSuite Meet";
|
||||
const ENABLE_SOURCE_TRACKING = window.__APP_CONFIG__?.ENABLE_SOURCE_TRACKING === "true";
|
||||
const FEEDBACK_FORM = window.__APP_CONFIG__?.FEEDBACK_FORM || null;
|
||||
|
||||
module.exports = {
|
||||
BASE_URL,
|
||||
APP_NAME,
|
||||
ENABLE_SOURCE_TRACKING,
|
||||
FEEDBACK_FORM
|
||||
};
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
const { BASE_URL } = require("./index");
|
||||
|
||||
/**
|
||||
* Returns a promise that resolves to true if a meeting link is already present
|
||||
*/
|
||||
function isMeetingAlreadyAdded(item) {
|
||||
return Promise.all([_checkBody(item), _checkLocation(item)]).then(
|
||||
([inBody, inLocation]) => inBody || inLocation
|
||||
);
|
||||
}
|
||||
|
||||
function _checkBody(item) {
|
||||
return new Promise((resolve) => {
|
||||
item.body.getAsync(Office.CoercionType.Text, (result) => {
|
||||
if (result.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
resolve(_containsMeetingUrl(result.value));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _checkLocation(item) {
|
||||
// Location only exists on appointments
|
||||
if (item.itemType !== Office.MailboxEnums.ItemType.Appointment) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
item.location.getAsync((result) => {
|
||||
if (result.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
resolve(_containsMeetingUrl(result.value));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _containsMeetingUrl(text) {
|
||||
if (!text) return false;
|
||||
return text.includes(BASE_URL);
|
||||
}
|
||||
|
||||
function removeMeetingLink(item) {
|
||||
return Promise.all([_removeFromBody(item), _removeFromLocation(item)]);
|
||||
}
|
||||
|
||||
function _removeFromBody(item) {
|
||||
return new Promise((resolve) => {
|
||||
item.body.getAsync(Office.CoercionType.Html, (result) => {
|
||||
if (result.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const cleaned = _cleanBody(result.value || "");
|
||||
if (cleaned === null) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
item.body.setAsync(cleaned, { coercionType: Office.CoercionType.Html }, () => resolve());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _removeFromLocation(item) {
|
||||
if (item.itemType !== Office.MailboxEnums.ItemType.Appointment) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
item.location.getAsync((result) => {
|
||||
if (
|
||||
result.status === Office.AsyncResultStatus.Succeeded &&
|
||||
_containsMeetingUrl(result.value)
|
||||
) {
|
||||
item.location.setAsync("", () => resolve());
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const SEPARATOR = /─{10,}/;
|
||||
|
||||
/**
|
||||
* Returns cleaned HTML, or null if no meeting block was found.
|
||||
*/
|
||||
function _cleanBody(html) {
|
||||
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||
|
||||
const hits = [];
|
||||
const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_TEXT);
|
||||
while (walker.nextNode()) {
|
||||
if (SEPARATOR.test(walker.currentNode.nodeValue)) hits.push(walker.currentNode);
|
||||
}
|
||||
if (hits.length < 2) return null;
|
||||
|
||||
const range = doc.createRange();
|
||||
range.setStartBefore(hits[0]);
|
||||
range.setEndAfter(hits[hits.length - 1]);
|
||||
range.deleteContents();
|
||||
|
||||
return doc.documentElement.outerHTML;
|
||||
}
|
||||
|
||||
module.exports = { isMeetingAlreadyAdded, removeMeetingLink };
|
||||
@@ -1,4 +1,5 @@
|
||||
const { APP_NAME } = require("./index");
|
||||
const { APP_NAME, ENABLE_SOURCE_TRACKING } = require("./index");
|
||||
const { t } = require("./i18n");
|
||||
|
||||
function _formatPin(pin) {
|
||||
if (!pin) return "";
|
||||
@@ -20,33 +21,59 @@ function _formatPhone(phone) {
|
||||
return clean;
|
||||
}
|
||||
|
||||
function _appendTrackingParams(url) {
|
||||
if (!ENABLE_SOURCE_TRACKING) return url;
|
||||
const u = new URL(url);
|
||||
u.searchParams.set("from", "outlook-addin");
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
|
||||
// todo - escape html / link
|
||||
function buildMeetingMessage(data) {
|
||||
function buildMeetingMessage(data, isWeb) {
|
||||
if (!data?.url) {
|
||||
throw new Error("buildMeetingMessage: missing url in data");
|
||||
}
|
||||
|
||||
const url = data.url;
|
||||
const url = _appendTrackingParams(data.url);
|
||||
const phone = _formatPhone(data.telephony?.phone_number);
|
||||
const pin = _formatPin(data.telephony?.pin_code);
|
||||
|
||||
const telephonyBlock =
|
||||
phone && pin
|
||||
? `
|
||||
let textLines = "";
|
||||
let phoneLines = [];
|
||||
|
||||
Ou appelez (audio uniquement)
|
||||
(FR) ${phone}
|
||||
Code : ${pin}`
|
||||
: "";
|
||||
const join = t("meeting_message.join", { app_name: APP_NAME });
|
||||
const phoneOnly = t("meeting_message.phone_only");
|
||||
const phoneFr = t("meeting_message.phone_fr", { phone });
|
||||
const pinCode = t("meeting_message.pin_code", { pin });
|
||||
|
||||
const message = `<pre style="font-family:inherit; font-size:inherit; border:none; background:none; margin:16px 0;">
|
||||
────────────────────────────────────────
|
||||
Rejoindre la réunion ${APP_NAME}
|
||||
if (isWeb) {
|
||||
phoneLines = phone && pin ? [`<br><br>${phoneOnly}`, `<br>${phoneFr}`, `<br>${pinCode}`] : [];
|
||||
|
||||
<a href="${url}">${url}</a>${telephonyBlock}
|
||||
────────────────────────────────────────</pre>`;
|
||||
textLines = [
|
||||
"<br><br>────────────────────────────────────────",
|
||||
`<br>${join}`,
|
||||
`<br><br><a href="${url}" target="_blank">${url}</a>`,
|
||||
...phoneLines,
|
||||
"<br>────────────────────────────────────────<br>",
|
||||
];
|
||||
|
||||
return { url, message };
|
||||
} else {
|
||||
|
||||
phoneLines = phone && pin ? [`\n\n${phoneOnly}`, `\n${phoneFr}`, `\n${pinCode}`] : [];
|
||||
|
||||
textLines = [
|
||||
"\n\n────────────────────────────────────────",
|
||||
`\n${join}`,
|
||||
`\n\n${url}`,
|
||||
...phoneLines,
|
||||
"\n────────────────────────────────────────\n",
|
||||
];
|
||||
}
|
||||
|
||||
const text = textLines.join("");
|
||||
|
||||
return { url, text };
|
||||
}
|
||||
|
||||
module.exports = { buildMeetingMessage };
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"app": {
|
||||
"sideload": "Laden Sie das Add-In.",
|
||||
"loading": "Wird geladen..."
|
||||
},
|
||||
"unauth": {
|
||||
"intro": "Fügen Sie Ihren Outlook-Terminen ganz einfach einen {{app_name}}-Besprechungslink hinzu.",
|
||||
"proconnect_btn": "Aanmelden met ProConnect",
|
||||
"proconnect_link": "Wat is ProConnect?",
|
||||
"proconnect_link_title": "Wat is ProConnect? - nieuw venster"
|
||||
},
|
||||
"success": {
|
||||
"close_window": "Falls sich dieses Fenster nicht automatisch schließt, schließen Sie es bitte manuell."
|
||||
},
|
||||
"auth": {
|
||||
"disconnect": "Abmelden"
|
||||
},
|
||||
"meeting": {
|
||||
"already_added": "Es wurde bereits ein {{app_name}}-Meeting hinzugefügt.",
|
||||
"link_inserted": "Besprechungslink erfolgreich eingefügt",
|
||||
"generating": "Wird erstellt...",
|
||||
"add_meeting": "{{app_name}}-Besprechung hinzufügen",
|
||||
"remove_meeting": "{{app_name}}-Besprechung entfernen",
|
||||
"removing": "Wird entfernt...",
|
||||
"error": {
|
||||
"auth": "Ihre Sitzung ist abgelaufen. Bitte versuchen Sie es erneut.",
|
||||
"retry": "Es ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.",
|
||||
"details": "Fehler: {{message}}"
|
||||
}
|
||||
},
|
||||
"meeting_message": {
|
||||
"join": "An der {{app_name}}-Besprechung teilnehmen",
|
||||
"phone_only": "Oder per Telefon teilnehmen (nur Audio)",
|
||||
"phone_fr": "(FR) {{phone}}",
|
||||
"pin_code": "Code {{pin}}"
|
||||
},
|
||||
"footer": {
|
||||
"feedback": "Teilen Sie uns Ihr Feedback mit"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"app": {
|
||||
"sideload": "Please load the add-in.",
|
||||
"loading": "Loading..."
|
||||
},
|
||||
"unauth": {
|
||||
"intro": "Easily add a {{app_name}} meeting link to your Outlook events.",
|
||||
"proconnect_btn": "Sign in with ProConnect",
|
||||
"proconnect_link": "What is ProConnect?",
|
||||
"proconnect_link_title": "What is ProConnect? - new window"
|
||||
},
|
||||
"success": {
|
||||
"close_window": "If this window does not close automatically, please close it manually."
|
||||
},
|
||||
"auth": {
|
||||
"disconnect": "Sign out"
|
||||
},
|
||||
"meeting": {
|
||||
"already_added": "A {{app_name}} meeting has already been added.",
|
||||
"link_inserted": "Meeting link inserted successfully",
|
||||
"generating": "Generating...",
|
||||
"add_meeting": "Add a {{app_name}} meeting",
|
||||
"remove_meeting": "Remove the {{app_name}} meeting",
|
||||
"removing": "Removing...",
|
||||
"error": {
|
||||
"auth": "Your session has expired. Please try again.",
|
||||
"retry": "An error occurred. Please try again.",
|
||||
"details": "Error: {{message}}"
|
||||
}
|
||||
},
|
||||
"meeting_message": {
|
||||
"join": "Join the {{app_name}} meeting",
|
||||
"phone_only": "Or call in (audio only)",
|
||||
"phone_fr": "(FR) {{phone}}",
|
||||
"pin_code": "Code {{pin}}"
|
||||
},
|
||||
"footer": {
|
||||
"feedback": "Share your feedback"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"app": {
|
||||
"sideload": "Veuillez charger le complément.",
|
||||
"loading": "Chargement..."
|
||||
},
|
||||
"unauth": {
|
||||
"intro": "Ajoutez facilement un lien de réunion {{app_name}} à vos événements Outlook.",
|
||||
"proconnect_btn": "S'identifier avec ProConnect",
|
||||
"proconnect_link": "Qu'est-ce que ProConnect ?",
|
||||
"proconnect_link_title": "Qu'est-ce que ProConnect ? - nouvelle fenêtre"
|
||||
},
|
||||
"success": {
|
||||
"close_window": "Si cette fenêtre ne se ferme pas toute seule, veuillez la fermer manuellement."
|
||||
},
|
||||
"auth": {
|
||||
"disconnect": "Se déconnecter"
|
||||
},
|
||||
"meeting": {
|
||||
"already_added": "Une réunion {{app_name}} a déjà été ajoutée.",
|
||||
"link_inserted": "Lien de réunion inséré avec succès",
|
||||
"generating": "Génération...",
|
||||
"add_meeting": "Ajouter une réunion {{app_name}}",
|
||||
"remove_meeting": "Supprimer la réunion {{app_name}}",
|
||||
"removing": "Suppression en cours...",
|
||||
"error": {
|
||||
"auth": "Connexion expirée, veuillez réessayer.",
|
||||
"retry": "Une erreur est survenue, veuillez ré-essayer",
|
||||
"details": "Erreur : {{message}}"
|
||||
}
|
||||
},
|
||||
"meeting_message": {
|
||||
"join": "Rejoindre la réunion {{app_name}}",
|
||||
"phone_only": "Ou appelez (audio uniquement)",
|
||||
"phone_fr": "(FR) {{phone}}",
|
||||
"pin_code": "Code {{pin}}"
|
||||
},
|
||||
"footer": {
|
||||
"feedback": "Partagez-nous vos retours"
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,10 @@
|
||||
<script nonce="NONCE_PLACEHOLDER" src="/addons/outlook/config.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="sideload-msg">Veuillez charger le complément.</div>
|
||||
<div id="sideload-msg" data-i18n="app.sideload"></div>
|
||||
<div class="spinner-container"
|
||||
role="progressbar"
|
||||
aria-label="Chargement..."
|
||||
data-i18n-aria="app.loading"
|
||||
>
|
||||
<svg class="spinner-svg"
|
||||
viewBox="0 0 28 28"
|
||||
@@ -40,5 +40,6 @@
|
||||
</svg>
|
||||
</span>
|
||||
</div>
|
||||
<p id="close-msg" style="display: none; text-align: center; font-size: 13px; color: #666; margin-top: 16px;" data-i18n="success.close_window"></p>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,20 +1,35 @@
|
||||
const { applyAppName } = require("../common/helpers");
|
||||
const { exchangeSession } = require("../common/api");
|
||||
const { consume } = require("../common/transitToken");
|
||||
const { initI18n, translateUI } = require("../common/i18n");
|
||||
|
||||
applyAppName();
|
||||
(async () => {
|
||||
await initI18n();
|
||||
|
||||
const transitToken = consume();
|
||||
applyAppName();
|
||||
translateUI();
|
||||
|
||||
if (!transitToken) {
|
||||
console.error("Transit token not found in sessionStorage");
|
||||
window.close();
|
||||
} else {
|
||||
exchangeSession(transitToken)
|
||||
.catch((e) => {
|
||||
console.error(`Error occured: ${e}`);
|
||||
})
|
||||
.finally(() => {
|
||||
window.close();
|
||||
});
|
||||
}
|
||||
const transitToken = consume();
|
||||
|
||||
if (!transitToken) {
|
||||
console.error("Transit token not found in sessionStorage");
|
||||
window.close();
|
||||
} else {
|
||||
exchangeSession(transitToken)
|
||||
.then(() => {
|
||||
document.querySelector(".spinner-container").style.display = "none";
|
||||
document.querySelector("#close-msg").style.display = "block";
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(`Error occured: ${e}`);
|
||||
})
|
||||
.finally(() => {
|
||||
// NOTE: doesn't work with the desktop client — the browser considers
|
||||
// this window wasn't opened by this script (it was opened externally),
|
||||
// so it blocks window.close() for security reasons. The "#close-msg"
|
||||
// shown above is the fallback for that case.g
|
||||
|
||||
window.close();
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -115,15 +115,39 @@ button {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
/* ── Danger button (remove meeting) ── */
|
||||
|
||||
#btn-remove {
|
||||
background-color: #CA3632; /* error.400 */
|
||||
color: #FFFFFF;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#btn-remove:hover {
|
||||
background-color: #EE6A66; /* error.600 */
|
||||
}
|
||||
|
||||
#btn-remove:active {
|
||||
background-color: #F28D8A; /* error.700 */
|
||||
color: #F6AFAD; /* error.200 */
|
||||
}
|
||||
|
||||
#btn-remove:disabled {
|
||||
background-color: #F6AFAD; /* error.800 */
|
||||
color: #FAD2D1; /* error.900 */
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ── Version ── */
|
||||
|
||||
#version-tag {
|
||||
position: fixed;
|
||||
bottom: 8px;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 11px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #6b7280;
|
||||
@@ -131,9 +155,29 @@ button {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#feedback-link {
|
||||
font-size: 11px;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
color: #6b7280;
|
||||
text-decoration: underline;
|
||||
pointer-events: all; /* override parent's pointer-events: none */
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#feedback-link:hover {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
#footer-right {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.version-badge {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
background: #EEF1F4;
|
||||
color: #2845C1;
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -10,47 +10,56 @@
|
||||
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="sideload-msg">Veuillez charger le complément.</div>
|
||||
<div id="sideload-msg" data-i18n="app.sideload"></div>
|
||||
|
||||
<div id="app-body">
|
||||
|
||||
<!-- Loading -->
|
||||
<div id="view-loading">
|
||||
<p class="intro-text">Chargement...</p>
|
||||
<p class="intro-text" data-i18n="app.loading"></p>
|
||||
</div>
|
||||
|
||||
<!-- Unauthenticated -->
|
||||
<div id="view-unauth" style="display:none;">
|
||||
<p class="intro-text">
|
||||
<span>Ajoutez facilement un lien de réunion <span data-app-name></span> à vos événements Outlook.</span>
|
||||
<span data-i18n="unauth.intro"></span>
|
||||
</p>
|
||||
<hr class="divider" />
|
||||
<button class="proconnect-button" id="btn-connect">
|
||||
<span class="proconnect-sr-only">S'identifier avec ProConnect</span>
|
||||
<span class="proconnect-sr-only" data-i18n="unauth.proconnect_btn"></span>
|
||||
</button>
|
||||
<p>
|
||||
<a
|
||||
href="https://www.proconnect.gouv.fr/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title="Qu’est-ce que ProConnect ? - nouvelle fenêtre"
|
||||
>
|
||||
Qu’est-ce que ProConnect ?
|
||||
</a>
|
||||
<a href="https://www.proconnect.gouv.fr/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-i18n-attr="title:unauth.proconnect_link_title"
|
||||
data-i18n="unauth.proconnect_link"
|
||||
></a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Authenticated -->
|
||||
<div id="view-auth" style="display:none;">
|
||||
<div id="btn-container">
|
||||
<button id="btn-generate">Ajouter une réunion <span data-app-name></span></button>
|
||||
<button id="btn-disconnect">Se déconnecter</button>
|
||||
<!-- shown when no meeting is present -->
|
||||
<button id="btn-generate" data-i18n="meeting.add_meeting"></button>
|
||||
<!-- shown when a meeting is already present -->
|
||||
<button id="btn-remove" style="display:none;" data-i18n="meeting.remove_meeting"></button>
|
||||
<button id="btn-disconnect" data-i18n="auth.disconnect"></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer id="version-tag">
|
||||
<span class="version-badge">alpha</span>
|
||||
<span class="version-number">0.0.1</span>
|
||||
<a id="feedback-link"
|
||||
style="display:none;"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
data-i18n="footer.feedback"
|
||||
></a>
|
||||
<div id="footer-right">
|
||||
<span class="version-badge">beta</span>
|
||||
<span class="version-number">0.0.2</span>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,22 +1,77 @@
|
||||
const { APP_NAME } = require("../common");
|
||||
|
||||
/* global Office */
|
||||
const { APP_NAME, FEEDBACK_FORM } = require("../common");
|
||||
const { applyAppName } = require("../common/helpers");
|
||||
const { initSession, createRoom } = require("../common/api");
|
||||
const { startPolling } = require("../common/polling");
|
||||
const { openTransitDialog } = require("../common/transitDialog");
|
||||
const { loadSession, saveSession, clearSession } = require("../common/session");
|
||||
|
||||
const { buildMeetingMessage } = require("../common/messageBuilder");
|
||||
const { initI18n, t, translateUI } = require("../common/i18n");
|
||||
const { isMeetingAlreadyAdded, removeMeetingLink } = require("../common/meetingDetector");
|
||||
|
||||
// ── Views ────────────────────────────────────────────────────
|
||||
|
||||
// todo - support loading view while polling
|
||||
// todo - support error view
|
||||
function showView(name) {
|
||||
document.getElementById("view-loading").style.display = "none";
|
||||
document.getElementById("view-unauth").style.display = "none";
|
||||
document.getElementById("view-auth").style.display = "none";
|
||||
document.getElementById(`view-${name}`).style.display = "block";
|
||||
|
||||
if (name === "auth") {
|
||||
_refreshMeetingButtonState();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Button state ─────────────────────────────────────────────
|
||||
|
||||
function _showAddButton() {
|
||||
document.getElementById("btn-generate").style.display = "block";
|
||||
document.getElementById("btn-remove").style.display = "none";
|
||||
}
|
||||
|
||||
function _showRemoveButton() {
|
||||
document.getElementById("btn-generate").style.display = "none";
|
||||
document.getElementById("btn-remove").style.display = "block";
|
||||
}
|
||||
|
||||
function _setButtonLoading() {
|
||||
const btn = document.getElementById("btn-generate");
|
||||
btn.disabled = true;
|
||||
btn.textContent = t("meeting.generating");
|
||||
}
|
||||
|
||||
function _setButtonIdle() {
|
||||
const btn = document.getElementById("btn-generate");
|
||||
btn.disabled = false;
|
||||
btn.textContent = t("meeting.add_meeting", { app_name: APP_NAME });
|
||||
}
|
||||
|
||||
function _setRemoveLoading() {
|
||||
const btn = document.getElementById("btn-remove");
|
||||
btn.disabled = true;
|
||||
btn.textContent = t("meeting.removing");
|
||||
}
|
||||
|
||||
function _setRemoveIdle() {
|
||||
const btn = document.getElementById("btn-remove");
|
||||
btn.disabled = false;
|
||||
btn.textContent = t("meeting.remove_meeting", { app_name: APP_NAME });
|
||||
}
|
||||
|
||||
function _refreshMeetingButtonState() {
|
||||
const item = Office.context.mailbox.item;
|
||||
if (!item) return;
|
||||
isMeetingAlreadyAdded(item).then((alreadyAdded) => {
|
||||
if (alreadyAdded) {
|
||||
_showRemoveButton();
|
||||
} else {
|
||||
_showAddButton();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Auth ─────────────────────────────────────────────────────
|
||||
|
||||
function connect() {
|
||||
initSession()
|
||||
.then((data) => {
|
||||
@@ -47,22 +102,11 @@ function disconnect() {
|
||||
clearSession().finally(() => showView("unauth"));
|
||||
}
|
||||
|
||||
function _setButtonLoading() {
|
||||
const btn = document.getElementById("btn-generate");
|
||||
btn.disabled = true;
|
||||
btn.textContent = "Génération...";
|
||||
}
|
||||
|
||||
function _setButtonIdle() {
|
||||
const btn = document.getElementById("btn-generate");
|
||||
btn.disabled = false;
|
||||
btn.textContent = `Ajouter une réunion ${APP_NAME}`;
|
||||
}
|
||||
// ── Meeting ──────────────────────────────────────────────────
|
||||
|
||||
function generateMeetingLink() {
|
||||
const session = loadSession();
|
||||
if (!session?.access_token) {
|
||||
console.error("Session introuvable. Veuillez vous reconnecter.");
|
||||
showView("unauth");
|
||||
return;
|
||||
}
|
||||
@@ -71,36 +115,28 @@ function generateMeetingLink() {
|
||||
|
||||
createRoom(session)
|
||||
.then((data) => {
|
||||
const { url, message } = buildMeetingMessage(data);
|
||||
const isWeb = Office.context.diagnostics.platform === "OfficeOnline";
|
||||
const { url, text } = buildMeetingMessage(data, isWeb);
|
||||
const item = Office.context.mailbox.item;
|
||||
const coercionType = isWeb ? Office.CoercionType.Html : Office.CoercionType.Text;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
item.body.getAsync(Office.CoercionType.Html, (getResult) => {
|
||||
if (getResult.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
reject(getResult.error);
|
||||
item.body.setSelectedDataAsync(text, { coercionType }, (setResult) => {
|
||||
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
reject(setResult.error);
|
||||
return;
|
||||
}
|
||||
|
||||
item.body.setAsync(
|
||||
getResult.value + message,
|
||||
{ coercionType: Office.CoercionType.Html },
|
||||
(setResult) => {
|
||||
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
|
||||
reject(setResult.error);
|
||||
return;
|
||||
}
|
||||
|
||||
// ─── If calendar event, also set location ──────────────
|
||||
if (item.itemType === Office.MailboxEnums.ItemType.Appointment) {
|
||||
item.location.setAsync(url, () => resolve());
|
||||
return;
|
||||
}
|
||||
|
||||
resolve();
|
||||
}
|
||||
);
|
||||
if (item.itemType === Office.MailboxEnums.ItemType.Appointment) {
|
||||
item.location.setAsync(url, () => resolve());
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
})
|
||||
.then(() => {
|
||||
_showRemoveButton();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
})
|
||||
@@ -109,7 +145,41 @@ function generateMeetingLink() {
|
||||
});
|
||||
}
|
||||
|
||||
Office.onReady((info) => {
|
||||
function removeMeetingLinkFromItem() {
|
||||
const session = loadSession();
|
||||
if (!session?.access_token) {
|
||||
showView("unauth");
|
||||
return;
|
||||
}
|
||||
|
||||
_setRemoveLoading();
|
||||
|
||||
const item = Office.context.mailbox.item;
|
||||
|
||||
removeMeetingLink(item)
|
||||
.then(() => {
|
||||
_showAddButton();
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
})
|
||||
.finally(() => {
|
||||
_setRemoveIdle();
|
||||
});
|
||||
}
|
||||
|
||||
// ── Init ─────────────────────────────────────────────────────
|
||||
|
||||
Office.onReady(async (info) => {
|
||||
await initI18n();
|
||||
translateUI();
|
||||
|
||||
if (FEEDBACK_FORM) {
|
||||
const link = document.getElementById("feedback-link");
|
||||
link.href = FEEDBACK_FORM;
|
||||
link.style.display = "inline";
|
||||
}
|
||||
|
||||
if (info.host === Office.HostType.Outlook) {
|
||||
applyAppName();
|
||||
document.getElementById("sideload-msg").style.display = "none";
|
||||
@@ -117,10 +187,11 @@ Office.onReady((info) => {
|
||||
document.getElementById("btn-connect").onclick = connect;
|
||||
document.getElementById("btn-disconnect").onclick = disconnect;
|
||||
document.getElementById("btn-generate").onclick = generateMeetingLink;
|
||||
document.getElementById("btn-remove").onclick = removeMeetingLinkFromItem;
|
||||
|
||||
const session = loadSession();
|
||||
if (session?.state === "authenticated" && session?.access_token) {
|
||||
showView("auth");
|
||||
showView("auth"); // this already calls _refreshMeetingButtonState internally
|
||||
} else {
|
||||
showView("unauth");
|
||||
}
|
||||
|
||||
@@ -10,11 +10,11 @@
|
||||
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="sideload-msg">Veuillez charger le complément.</div>
|
||||
<div id="sideload-msg" data-i18n="app.sideload"></div>
|
||||
<div
|
||||
class="spinner-container"
|
||||
role="progressbar"
|
||||
aria-label="Chargement..."
|
||||
data-i18n-aria="app.loading"
|
||||
>
|
||||
<svg
|
||||
class="spinner-svg"
|
||||
|
||||
@@ -2,6 +2,7 @@ const { applyAppName } = require("../common/helpers");
|
||||
const { URLS } = require("../common/urls");
|
||||
const { save } = require("../common/transitToken");
|
||||
const { DIALOG_SIGNALS } = require("../common/transitDialog");
|
||||
const { initI18n, translateUI } = require("../common/i18n");
|
||||
|
||||
// Initiate the authentication flow, then return to the success page
|
||||
function getAuthenticateUrl() {
|
||||
@@ -10,7 +11,11 @@ function getAuthenticateUrl() {
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
Office.onReady(function (info) {
|
||||
Office.onReady(async function (info) {
|
||||
|
||||
await initI18n();
|
||||
translateUI();
|
||||
|
||||
if (info.host === Office.HostType.Outlook) {
|
||||
applyAppName();
|
||||
}
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -19,13 +19,14 @@ from livekit.agents import (
|
||||
JobContext,
|
||||
JobProcess,
|
||||
JobRequest,
|
||||
RoomInputOptions,
|
||||
RoomIO,
|
||||
RoomOutputOptions,
|
||||
WorkerPermissions,
|
||||
cli,
|
||||
utils,
|
||||
)
|
||||
from livekit.agents import (
|
||||
room_io as lk_room_io,
|
||||
)
|
||||
from livekit.plugins import silero
|
||||
from minio import Minio
|
||||
from minio.error import S3Error
|
||||
@@ -302,13 +303,11 @@ class MetadataCollector:
|
||||
agent_session=session,
|
||||
room=self.ctx.room,
|
||||
participant=participant,
|
||||
input_options=RoomInputOptions(
|
||||
audio_enabled=True,
|
||||
text_enabled=False,
|
||||
),
|
||||
output_options=RoomOutputOptions(
|
||||
audio_enabled=False,
|
||||
transcription_enabled=False,
|
||||
options=lk_room_io.RoomOptions(
|
||||
audio_input=lk_room_io.AudioInputOptions(),
|
||||
text_input=False,
|
||||
audio_output=False,
|
||||
text_output=False,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -324,7 +323,6 @@ class MetadataCollector:
|
||||
async def _close_session(self, session: AgentSession) -> None:
|
||||
"""Close and cleanup VAD monitoring session."""
|
||||
try:
|
||||
await session.drain()
|
||||
await session.aclose()
|
||||
except Exception:
|
||||
logger.exception("Error closing session")
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.16.0"
|
||||
version = "1.22.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.4.5",
|
||||
"livekit-plugins-deepgram==1.4.5",
|
||||
"livekit-plugins-silero==1.4.5",
|
||||
"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.5",
|
||||
"minio==7.2.15"
|
||||
"protobuf>=6.33.5",
|
||||
"minio==7.2.20"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"ruff==0.15.6",
|
||||
"ruff==0.15.19",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
Generated
+810
-737
File diff suppressed because it is too large
Load Diff
@@ -3,17 +3,55 @@
|
||||
from django import forms
|
||||
from django.contrib import admin, messages
|
||||
from django.contrib.auth import admin as auth_admin
|
||||
from django.db import transaction
|
||||
from django.utils.html import format_html
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.recording.event import notification
|
||||
|
||||
from . import models
|
||||
from .tasks.file import process_file_deletion
|
||||
from .utils import generate_download_s3_url
|
||||
|
||||
|
||||
def hard_delete_file(file):
|
||||
"""Hard delete a file, soft deleting it first when needed."""
|
||||
if file.deleted_at is None:
|
||||
file.soft_delete()
|
||||
file.hard_delete()
|
||||
transaction.on_commit(lambda: process_file_deletion.delay(file.id))
|
||||
|
||||
|
||||
class FileInlineFormSet(forms.BaseInlineFormSet):
|
||||
"""Inline formset overriding delete behavior for files."""
|
||||
|
||||
def delete_existing(self, obj, commit=True):
|
||||
"""Hard delete files instead of calling model.delete()."""
|
||||
hard_delete_file(obj)
|
||||
|
||||
|
||||
class FileInline(admin.TabularInline):
|
||||
"""Inline class for the File model."""
|
||||
|
||||
model = models.File
|
||||
formset = FileInlineFormSet
|
||||
fk_name = "creator"
|
||||
extra = 0
|
||||
fields = ("id", "title", "type", "upload_state", "created_at")
|
||||
readonly_fields = ("id", "created_at", "upload_state", "type")
|
||||
show_change_link = True
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Hide hard deleted files in the inline."""
|
||||
return super().get_queryset(request).filter(hard_deleted_at__isnull=True)
|
||||
|
||||
|
||||
@admin.register(models.User)
|
||||
class UserAdmin(auth_admin.UserAdmin):
|
||||
"""Admin class for the User model"""
|
||||
|
||||
inlines = (FileInline,)
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
@@ -97,6 +135,136 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
search_fields = ("id", "sub", "admin_email", "email", "full_name")
|
||||
|
||||
|
||||
@admin.register(models.File)
|
||||
class FileAdmin(admin.ModelAdmin):
|
||||
"""Admin class for the File model."""
|
||||
|
||||
list_display = (
|
||||
"id",
|
||||
"title",
|
||||
"type",
|
||||
"creator",
|
||||
"upload_state",
|
||||
"deleted_at",
|
||||
"hard_deleted_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
list_filter = (
|
||||
"type",
|
||||
"upload_state",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"deleted_at",
|
||||
"hard_deleted_at",
|
||||
)
|
||||
search_fields = (
|
||||
"id",
|
||||
"title",
|
||||
"filename",
|
||||
"mimetype",
|
||||
"description",
|
||||
"creator__email",
|
||||
"creator__admin_email",
|
||||
"creator__full_name",
|
||||
)
|
||||
ordering = ("-created_at",)
|
||||
readonly_fields = (
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"deleted_at",
|
||||
"hard_deleted_at",
|
||||
"description",
|
||||
"malware_detection_info",
|
||||
"is_ready",
|
||||
"preview_url",
|
||||
"extension",
|
||||
"key_base",
|
||||
"file_key",
|
||||
"upload_state",
|
||||
"type",
|
||||
"mimetype",
|
||||
"size",
|
||||
)
|
||||
autocomplete_fields = ("creator",)
|
||||
fieldsets = (
|
||||
(
|
||||
None,
|
||||
{
|
||||
"fields": (
|
||||
"id",
|
||||
"title",
|
||||
"type",
|
||||
"creator",
|
||||
"filename",
|
||||
"upload_state",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
_("Content"),
|
||||
{
|
||||
"fields": (
|
||||
"mimetype",
|
||||
"size",
|
||||
"description",
|
||||
"malware_detection_info",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
_("Deletion"),
|
||||
{
|
||||
"fields": (
|
||||
"deleted_at",
|
||||
"hard_deleted_at",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
_("Derived info"),
|
||||
{
|
||||
"fields": (
|
||||
"is_ready",
|
||||
"extension",
|
||||
"key_base",
|
||||
"file_key",
|
||||
"preview_url",
|
||||
)
|
||||
},
|
||||
),
|
||||
(_("Timestamps"), {"fields": ("created_at", "updated_at")}),
|
||||
)
|
||||
|
||||
@admin.display(description=_("File preview"))
|
||||
def preview_url(self, obj):
|
||||
"""Return a clickable preview URL for the file."""
|
||||
if not obj.is_ready:
|
||||
return "-"
|
||||
url = generate_download_s3_url(obj.key, expires_in=60 * 60)
|
||||
|
||||
return format_html(
|
||||
'<a href="{}" target="_blank" rel="noopener noreferrer">Open File</a>', url
|
||||
)
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Hide hard deleted files in admin listing and lookups."""
|
||||
return super().get_queryset(request).filter(hard_deleted_at__isnull=True)
|
||||
|
||||
def delete_model(self, request, obj):
|
||||
"""Hard delete instead of calling model.delete()."""
|
||||
hard_delete_file(obj)
|
||||
|
||||
def delete_queryset(self, request, queryset):
|
||||
"""Hard delete all selected files."""
|
||||
for file in queryset:
|
||||
hard_delete_file(file)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
|
||||
class ResourceAccessInline(admin.TabularInline):
|
||||
"""Admin class for the room user access model"""
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
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
|
||||
|
||||
__all__ = [
|
||||
"get_analytics",
|
||||
"identify",
|
||||
"capture",
|
||||
"AnalyticsBackend",
|
||||
"AnalyticsEvent",
|
||||
]
|
||||
|
||||
|
||||
@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)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Analytics backend protocol and default no-op implementation."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from ..models import User
|
||||
from .events import AnalyticsEvent
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
|
||||
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,75 @@
|
||||
"""PostHog implementation of the analytics backend protocol."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from posthog import Posthog
|
||||
|
||||
from ..models import User
|
||||
from . import AnalyticsBackend
|
||||
from .events import AnalyticsEvent
|
||||
|
||||
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",
|
||||
**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,
|
||||
)
|
||||
|
||||
@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()
|
||||
@@ -456,7 +456,7 @@ class ListFileSerializer(serializers.ModelSerializer):
|
||||
|
||||
def get_url(self, obj):
|
||||
"""Return the URL of the file."""
|
||||
if obj.is_pending_upload:
|
||||
if not obj.is_ready:
|
||||
return None
|
||||
|
||||
return f"{settings.MEDIA_BASE_URL}{settings.MEDIA_URL}{quote(obj.file_key)}"
|
||||
@@ -563,3 +563,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,
|
||||
@@ -305,6 +311,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."""
|
||||
|
||||
@@ -972,29 +988,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 +1248,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")
|
||||
@@ -1196,96 +1260,124 @@ class FileViewSet(
|
||||
"""
|
||||
Check the actual uploaded file and mark it as ready.
|
||||
"""
|
||||
|
||||
# Ensures we go through authorization checks
|
||||
file = self.get_object()
|
||||
|
||||
if not file.is_pending_upload:
|
||||
# Try to update the file with the new state. If the file is already in this state
|
||||
# we are in a concurrent request, and we should reject that request
|
||||
updated_rows = models.File.objects.filter(
|
||||
upload_state=models.FileUploadStateChoices.PENDING,
|
||||
pk=kwargs["pk"],
|
||||
).update(upload_state=models.FileUploadStateChoices.ANALYZING)
|
||||
if updated_rows != 1:
|
||||
raise drf_exceptions.ValidationError(
|
||||
{"file": "This action is only available for files in PENDING state."},
|
||||
code="file_upload_state_not_pending",
|
||||
)
|
||||
file.refresh_from_db()
|
||||
|
||||
s3_client = default_storage.connection.meta.client
|
||||
validation_error = None
|
||||
|
||||
head_response = s3_client.head_object(
|
||||
Bucket=default_storage.bucket_name, Key=file.file_key
|
||||
)
|
||||
file_size = head_response["ContentLength"]
|
||||
|
||||
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
|
||||
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[file.type]
|
||||
if file_size > config_for_file_type["max_size"]:
|
||||
self._complete_file_deletion(file)
|
||||
logger.info(
|
||||
"upload_ended: file size (%s) for file %s higher than the allowed max size",
|
||||
file_size,
|
||||
file.file_key,
|
||||
)
|
||||
raise drf_exceptions.ValidationError(
|
||||
detail="The file size is higher than the allowed max size.",
|
||||
code="file_size_exceeded",
|
||||
)
|
||||
|
||||
# python-magic recommends using at least the first 2048 bytes
|
||||
# to reduce incorrect identification.
|
||||
# This is a tradeoff between pulling in the whole file and the most likely relevant bytes
|
||||
# of the file for mime type identification.
|
||||
if file_size > 2048:
|
||||
range_response = s3_client.get_object(
|
||||
Bucket=default_storage.bucket_name,
|
||||
Key=file.file_key,
|
||||
Range="bytes=0-2047",
|
||||
)
|
||||
file_head = range_response["Body"].read()
|
||||
else:
|
||||
file_head = s3_client.get_object(
|
||||
Bucket=default_storage.bucket_name, Key=file.file_key
|
||||
)["Body"].read()
|
||||
|
||||
# Use improved MIME type detection combining magic bytes and file extension
|
||||
logger.info("upload_ended: detecting mimetype for file: %s", file.file_key)
|
||||
mimetype = utils.detect_mimetype(file_head, filename=file.filename)
|
||||
|
||||
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
|
||||
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[file.type]
|
||||
allowed_file_mimetypes = config_for_file_type["allowed_mimetypes"]
|
||||
if mimetype not in allowed_file_mimetypes:
|
||||
self._complete_file_deletion(file)
|
||||
logger.warning(
|
||||
"upload_ended: mimetype not allowed %s for file %s",
|
||||
mimetype,
|
||||
file.file_key,
|
||||
)
|
||||
raise drf_exceptions.ValidationError(
|
||||
detail="The file type is not allowed.",
|
||||
code="file_type_not_allowed",
|
||||
)
|
||||
|
||||
file.upload_state = models.FileUploadStateChoices.READY
|
||||
file.mimetype = mimetype
|
||||
file.size = file_size
|
||||
|
||||
file.save(update_fields=["upload_state", "mimetype", "size"])
|
||||
|
||||
if head_response["ContentType"] != mimetype:
|
||||
logger.info(
|
||||
"upload_ended: content type mismatch between object storage and file,"
|
||||
" updating from %s to %s",
|
||||
head_response["ContentType"],
|
||||
mimetype,
|
||||
)
|
||||
try:
|
||||
# We copy the file to its final destination, we will run the checks on that
|
||||
# final file and ignore any updates to the temporary file. (We cannot revoke the policy,
|
||||
# so the temporary file might still be updated after that.)
|
||||
# The temporary folders will need to be cleaned periodically
|
||||
s3_client.copy_object(
|
||||
Bucket=default_storage.bucket_name,
|
||||
Key=file.file_key,
|
||||
CopySource={
|
||||
"Bucket": default_storage.bucket_name,
|
||||
"Key": file.file_key,
|
||||
"Key": file.temporary_file_key,
|
||||
},
|
||||
ContentType=mimetype,
|
||||
Metadata=head_response["Metadata"],
|
||||
MetadataDirective="REPLACE",
|
||||
)
|
||||
|
||||
head_response = s3_client.head_object(
|
||||
Bucket=default_storage.bucket_name, Key=file.file_key
|
||||
)
|
||||
file_size = head_response["ContentLength"]
|
||||
# python-magic recommends using at least the first 2048 bytes
|
||||
# to reduce incorrect identification.
|
||||
# This is a tradeoff between pulling in the whole file and
|
||||
# the most likely relevant bytes
|
||||
# of the file for mime type identification.
|
||||
if file_size > 2048:
|
||||
range_response = s3_client.get_object(
|
||||
Bucket=default_storage.bucket_name,
|
||||
Key=file.file_key,
|
||||
Range="bytes=0-2047",
|
||||
)
|
||||
file_head = range_response["Body"].read()
|
||||
else:
|
||||
file_head = s3_client.get_object(
|
||||
Bucket=default_storage.bucket_name, Key=file.file_key
|
||||
)["Body"].read()
|
||||
|
||||
logger.info("upload_ended: detecting mimetype for file: %s", file.file_key)
|
||||
mimetype = utils.detect_mimetype(file_head, filename=file.filename)
|
||||
|
||||
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
|
||||
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[file.type]
|
||||
if file_size > config_for_file_type["max_size"]:
|
||||
logger.info(
|
||||
"upload_ended: file size (%s) for file %s higher than the allowed max size",
|
||||
file_size,
|
||||
file.file_key,
|
||||
)
|
||||
validation_error = drf_exceptions.ValidationError(
|
||||
detail="The file size is higher than the allowed max size.",
|
||||
code="file_size_exceeded",
|
||||
)
|
||||
else:
|
||||
# Use improved MIME type detection combining magic bytes and file extension
|
||||
allowed_file_mimetypes = config_for_file_type["allowed_mimetypes"]
|
||||
if mimetype not in allowed_file_mimetypes:
|
||||
logger.warning(
|
||||
"upload_ended: mimetype not allowed %s for file %s",
|
||||
mimetype,
|
||||
file.file_key,
|
||||
)
|
||||
validation_error = drf_exceptions.ValidationError(
|
||||
detail="The file type is not allowed.",
|
||||
code="file_type_not_allowed",
|
||||
)
|
||||
|
||||
if validation_error is not None:
|
||||
self._complete_file_deletion(file)
|
||||
else:
|
||||
file.upload_state = models.FileUploadStateChoices.READY
|
||||
file.mimetype = mimetype
|
||||
file.size = file_size
|
||||
file.save(update_fields=["upload_state", "mimetype", "size"])
|
||||
|
||||
if head_response["ContentType"] != mimetype:
|
||||
logger.info(
|
||||
"upload_ended: content type mismatch between object storage and file,"
|
||||
" updating from %s to %s",
|
||||
head_response["ContentType"],
|
||||
mimetype,
|
||||
)
|
||||
s3_client.copy_object(
|
||||
Bucket=default_storage.bucket_name,
|
||||
Key=file.file_key,
|
||||
CopySource={
|
||||
"Bucket": default_storage.bucket_name,
|
||||
"Key": file.file_key,
|
||||
},
|
||||
ContentType=mimetype,
|
||||
Metadata=head_response["Metadata"],
|
||||
MetadataDirective="REPLACE",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to analyze file, reverting to pending state")
|
||||
file.upload_state = models.FileUploadStateChoices.PENDING
|
||||
file.save()
|
||||
raise e
|
||||
|
||||
if validation_error:
|
||||
raise validation_error
|
||||
|
||||
# Not yet implemented
|
||||
# Change the file.upload_state when this will be done
|
||||
# malware_detection.analyse_file(file.file_key, file_id=file.id)
|
||||
@@ -1298,7 +1390,7 @@ class FileViewSet(
|
||||
"""Delete a file completely."""
|
||||
file.soft_delete()
|
||||
file.hard_delete()
|
||||
process_file_deletion.delay(file.id)
|
||||
transaction.on_commit(lambda: process_file_deletion.delay(file.id))
|
||||
|
||||
def _authorize_subrequest(self, request, pattern):
|
||||
"""
|
||||
@@ -1389,7 +1481,7 @@ class FileViewSet(
|
||||
request, MEDIA_STORAGE_URL_PATTERN
|
||||
)
|
||||
|
||||
if file.is_pending_upload:
|
||||
if not file.is_ready:
|
||||
logger.warning("File '%s' is not ready", file.id)
|
||||
raise drf_exceptions.PermissionDenied()
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -4,7 +4,7 @@ from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import check_password
|
||||
from django.core.exceptions import SuspiciousOperation, ValidationError
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_email
|
||||
|
||||
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
||||
@@ -19,10 +19,15 @@ 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
|
||||
|
||||
from ..services.provisional_user_service import (
|
||||
ProvisionalUserCreationDisabledError,
|
||||
ProvisionalUserIntegrityError,
|
||||
ProvisionalUserService,
|
||||
)
|
||||
from . import authentication, permissions, serializers
|
||||
|
||||
logger = getLogger(__name__)
|
||||
@@ -94,40 +99,14 @@ class ApplicationViewSet(viewsets.ViewSet):
|
||||
)
|
||||
|
||||
try:
|
||||
user = models.User.objects.get(email__iexact=email)
|
||||
except models.User.DoesNotExist as e:
|
||||
if (
|
||||
settings.APPLICATION_ALLOW_USER_CREATION
|
||||
and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
|
||||
and not settings.OIDC_USER_SUB_FIELD_IMMUTABLE
|
||||
):
|
||||
# Create a provisional user without `sub`, identified by email only.
|
||||
#
|
||||
# This relies on Django LaSuite implicitly updating the `sub` field on the
|
||||
# user's first successful OIDC authentication. If this stops working,
|
||||
# check for behavior changes in Django LaSuite.
|
||||
#
|
||||
# `OIDC_USER_SUB_FIELD_IMMUTABLE` comes from Django LaSuite and prevents `sub`
|
||||
# updates. We override its default value to allow setting `sub` for
|
||||
# provisional users.
|
||||
user = models.User(
|
||||
sub=None,
|
||||
email=email,
|
||||
)
|
||||
user.set_unusable_password()
|
||||
user.save()
|
||||
logger.info(
|
||||
"Provisional user created via application: user_id=%s, email=%s, client_id=%s",
|
||||
user.id,
|
||||
email,
|
||||
application.client_id,
|
||||
)
|
||||
else:
|
||||
raise drf_exceptions.NotFound("User not found.") from e
|
||||
except models.User.MultipleObjectsReturned as e:
|
||||
raise SuspiciousOperation(
|
||||
"Multiple user accounts share a common email."
|
||||
) from e
|
||||
user, _ = ProvisionalUserService().get_or_create(email, client_id)
|
||||
except ProvisionalUserCreationDisabledError as not_found_error:
|
||||
raise drf_exceptions.NotFound("User not found.") from not_found_error
|
||||
except ProvisionalUserIntegrityError:
|
||||
return drf_response.Response(
|
||||
{"error": "Failed to create or retrieve provisional user."},
|
||||
status=drf_status.HTTP_409_CONFLICT,
|
||||
)
|
||||
|
||||
scope = " ".join(application.scopes or [])
|
||||
|
||||
@@ -215,10 +194,26 @@ 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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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).")
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Management command to merge duplicate users based on their email address."""
|
||||
|
||||
# pylint: disable=too-many-locals
|
||||
|
||||
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
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
ROLE_PRIORITY = {
|
||||
RoleChoices.OWNER: 3,
|
||||
RoleChoices.ADMIN: 2,
|
||||
RoleChoices.MEMBER: 1,
|
||||
}
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Merge duplicate users sharing the same email (case-insensitive) into the
|
||||
most recently created one.
|
||||
|
||||
Emails are compared case-insensitively, so 'John@Example.com' and
|
||||
'john@example.com' are treated as duplicates. The KEPT user is the most
|
||||
recently created. All room memberships, recording accesses and files are
|
||||
transferred to it. When a conflict exists, the higher-privilege role wins.
|
||||
Stale users are then deleted.
|
||||
Each email group is processed inside a single database transaction.
|
||||
"""
|
||||
|
||||
help = __doc__
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="Simulate the merge without writing any changes to the database.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--email-filter",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Only merge users whose email contains this string (e.g. '@example.com').",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Execute the management command."""
|
||||
dry_run = options["dry_run"]
|
||||
email_filter = options["email_filter"]
|
||||
|
||||
if dry_run:
|
||||
self.stdout.write("[DRY-RUN] No changes will be written.\n")
|
||||
|
||||
users_qs = User.objects.all()
|
||||
if email_filter:
|
||||
users_qs = users_qs.filter(email__icontains=email_filter)
|
||||
self.stdout.write(f"[INFO] Filtering emails containing '{email_filter}'.\n")
|
||||
|
||||
# Group emails case-insensitively so 'John@X.com' and 'john@x.com'
|
||||
# are detected as duplicates of each other.
|
||||
duplicate_emails = (
|
||||
users_qs.exclude(email__isnull=True)
|
||||
.exclude(email="")
|
||||
.annotate(email_lower=Lower("email"))
|
||||
.values("email_lower")
|
||||
.annotate(cnt=Count("id"))
|
||||
.filter(cnt__gt=1)
|
||||
.values_list("email_lower", flat=True)
|
||||
)
|
||||
|
||||
if not duplicate_emails:
|
||||
self.stdout.write("[INFO] No duplicate users found. Nothing to do.")
|
||||
return
|
||||
|
||||
self.stdout.write(
|
||||
f"[INFO] Found {len(duplicate_emails)} email(s) with duplicate users."
|
||||
)
|
||||
|
||||
total_merged = 0
|
||||
total_deleted = 0
|
||||
failed_emails = []
|
||||
|
||||
for email in duplicate_emails:
|
||||
# Case-insensitive lookup to fetch every casing variant of the email.
|
||||
# Secondary sort by id ensures a stable, deterministic order when
|
||||
# created_at timestamps are equal (common in tests and bulk imports).
|
||||
users = list(
|
||||
User.objects.filter(email__iexact=email).order_by("created_at", "id")
|
||||
)
|
||||
kept_user = users[-1]
|
||||
stale_users = users[:-1]
|
||||
|
||||
self.stdout.write(
|
||||
f"\n[INFO] Email '{email}': {len(users)} users — "
|
||||
f"keeping {kept_user.id} (created {kept_user.created_at.date()})."
|
||||
)
|
||||
for u in stale_users:
|
||||
self.stdout.write(
|
||||
f" stale: {u.id} (created {u.created_at.date()})"
|
||||
)
|
||||
|
||||
if dry_run:
|
||||
ra_count = ResourceAccess.objects.filter(user__in=stale_users).count()
|
||||
rca_count = RecordingAccess.objects.filter(user__in=stale_users).count()
|
||||
f_count = File.objects.filter(creator__in=stale_users).count()
|
||||
self.stdout.write(
|
||||
f" [DRY-RUN] Would migrate: {ra_count} ResourceAccess, "
|
||||
f"{rca_count} RecordingAccess, {f_count} File(s)."
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
group_deleted = 0
|
||||
with transaction.atomic():
|
||||
for stale_user in stale_users:
|
||||
self._merge_resource_accesses(stale_user, kept_user)
|
||||
self._merge_recording_accesses(stale_user, kept_user)
|
||||
self._merge_files(stale_user, kept_user)
|
||||
stale_user.delete()
|
||||
group_deleted += 1
|
||||
|
||||
total_deleted += group_deleted
|
||||
total_merged += 1
|
||||
|
||||
except Exception as exc: # noqa: BLE001 #pylint: disable=broad-exception-caught
|
||||
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)}"
|
||||
)
|
||||
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(
|
||||
f"\n[DONE] Merged {total_merged} group(s), deleted {total_deleted} user(s)."
|
||||
)
|
||||
)
|
||||
|
||||
def _merge_resource_accesses(self, stale_user, kept_user):
|
||||
"""Transfer room memberships from stale_user to kept_user."""
|
||||
for ra in ResourceAccess.objects.filter(user=stale_user):
|
||||
existing = ResourceAccess.objects.filter(
|
||||
user=kept_user, resource=ra.resource
|
||||
).first()
|
||||
|
||||
if existing is None:
|
||||
ra.user = kept_user
|
||||
ra.save(update_fields=["user"])
|
||||
else:
|
||||
if ROLE_PRIORITY.get(ra.role, 0) > ROLE_PRIORITY.get(existing.role, 0):
|
||||
existing.role = ra.role
|
||||
existing.save(update_fields=["role"])
|
||||
ra.delete()
|
||||
|
||||
def _merge_recording_accesses(self, stale_user, kept_user):
|
||||
"""Transfer recording accesses from stale_user to kept_user."""
|
||||
for rca in RecordingAccess.objects.filter(user=stale_user):
|
||||
existing = RecordingAccess.objects.filter(
|
||||
user=kept_user, recording=rca.recording
|
||||
).first()
|
||||
|
||||
if existing is None:
|
||||
rca.user = kept_user
|
||||
rca.save(update_fields=["user"])
|
||||
else:
|
||||
if ROLE_PRIORITY.get(rca.role, 0) > ROLE_PRIORITY.get(existing.role, 0):
|
||||
existing.role = rca.role
|
||||
existing.save(update_fields=["role"])
|
||||
rca.delete()
|
||||
|
||||
def _merge_files(self, stale_user, kept_user):
|
||||
"""Re-assign files created by stale_user to kept_user."""
|
||||
File.objects.filter(creator=stale_user).update(creator=kept_user)
|
||||
@@ -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).")
|
||||
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.2.14 on 2026-06-02 17:31
|
||||
|
||||
import django.db.models.functions.text
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('auth', '0012_alter_user_first_name_max_length'),
|
||||
('core', '0018_rename_active_application_is_active'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddConstraint(
|
||||
model_name='user',
|
||||
constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('email'), condition=models.Q(('sub__isnull', True)), name='unique_email_when_sub_is_null'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.14 on 2026-06-03 12:14
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0019_user_unique_email_when_sub_is_null'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='file',
|
||||
name='upload_state',
|
||||
field=models.CharField(choices=[('pending', 'Pending'), ('analyzing', 'Analyzing'), ('ready', 'Ready')], max_length=25),
|
||||
),
|
||||
]
|
||||
+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),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
}
|
||||
@@ -211,6 +218,13 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
ordering = ("-created_at",)
|
||||
verbose_name = _("user")
|
||||
verbose_name_plural = _("users")
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
models.functions.Lower("email"),
|
||||
condition=models.Q(sub__isnull=True),
|
||||
name="unique_email_when_sub_is_null",
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.email or self.admin_email or str(self.id)
|
||||
@@ -591,6 +605,14 @@ class Recording(BaseModel):
|
||||
verbose_name=_("Recording options"),
|
||||
help_text=_("Recording options"),
|
||||
)
|
||||
external_process_id = models.CharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
blank=True,
|
||||
unique=True,
|
||||
verbose_name=_("External Process ID"),
|
||||
help_text=_("ID of the external process associated with the recording."),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "meet_recording"
|
||||
@@ -646,6 +668,8 @@ class Recording(BaseModel):
|
||||
return self.status in {
|
||||
RecordingStatusChoices.NOTIFICATION_SUCCEEDED,
|
||||
RecordingStatusChoices.SAVED,
|
||||
RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL,
|
||||
RecordingStatusChoices.EXTERNAL_PROCESS_FAILED,
|
||||
}
|
||||
|
||||
@property
|
||||
@@ -839,8 +863,8 @@ class FileUploadStateChoices(models.TextChoices):
|
||||
"""Possible states of a file."""
|
||||
|
||||
PENDING = "pending", _("Pending")
|
||||
ANALYZING = "analyzing", _("Analyzing")
|
||||
# Commented out for now, as we may need this when we implement the malware detection logic.
|
||||
# ANALYZING = "analyzing", _("Analyzing")
|
||||
# SUSPICIOUS = "suspicious", _("Suspicious")
|
||||
# FILE_TOO_LARGE_TO_ANALYZE = (
|
||||
# "file_too_large_to_analyze",
|
||||
@@ -918,9 +942,9 @@ class File(BaseModel):
|
||||
return super().delete(using, keep_parents)
|
||||
|
||||
@property
|
||||
def is_pending_upload(self):
|
||||
"""Return whether the file is in a pending upload state"""
|
||||
return self.upload_state == FileUploadStateChoices.PENDING
|
||||
def is_ready(self):
|
||||
"""Return whether the file is in a ready upload state"""
|
||||
return self.upload_state == FileUploadStateChoices.READY
|
||||
|
||||
@property
|
||||
def extension(self):
|
||||
@@ -947,6 +971,16 @@ class File(BaseModel):
|
||||
|
||||
return f"{settings.FILE_UPLOAD_PATH}/{self.pk!s}"
|
||||
|
||||
@property
|
||||
def temporary_key_base(self):
|
||||
"""Temporary key base used while upload is still pending."""
|
||||
if not self.pk:
|
||||
raise RuntimeError(
|
||||
"The file instance must be saved before requesting a storage key."
|
||||
)
|
||||
|
||||
return f"{settings.FILE_UPLOAD_TMP_PATH}/{self.pk!s}"
|
||||
|
||||
@property
|
||||
def file_key(self):
|
||||
"""Key used to store the file in object storage."""
|
||||
@@ -955,6 +989,12 @@ class File(BaseModel):
|
||||
# leaking Personal Information in logs, etc.
|
||||
return f"{self.key_base}{extension!s}"
|
||||
|
||||
@property
|
||||
def temporary_file_key(self):
|
||||
"""Temporary key used to upload the file before it is finalized."""
|
||||
_, extension = splitext(self.filename)
|
||||
return f"{self.temporary_key_base}{extension!s}"
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""
|
||||
Compute and return abilities for a given user on the file.
|
||||
|
||||
@@ -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,7 @@ from asgiref.sync import async_to_sync
|
||||
from livekit import api as livekit_api
|
||||
|
||||
from core import models, utils
|
||||
from core.utils import generate_download_s3_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -178,8 +180,49 @@ class NotificationService:
|
||||
|
||||
return _ns_to_utc(file_result.started_at), _ns_to_utc(file_result.ended_at)
|
||||
|
||||
@staticmethod
|
||||
def _generate_title(
|
||||
*,
|
||||
locale: str,
|
||||
room: str,
|
||||
recording_datetime: datetime | None,
|
||||
owner_timezone: str | None,
|
||||
) -> str:
|
||||
"""Generate title from context or return default."""
|
||||
if recording_datetime is None:
|
||||
with override(locale):
|
||||
return _("Transcription")
|
||||
|
||||
dt = recording_datetime
|
||||
if owner_timezone:
|
||||
try:
|
||||
dt = recording_datetime.astimezone(ZoneInfo(owner_timezone))
|
||||
except (KeyError, ZoneInfoNotFoundError):
|
||||
pass # Keep the original UTC datetime
|
||||
|
||||
with override(locale):
|
||||
translated_template = _(
|
||||
'Meeting "{room}" on {room_recording_date} at {room_recording_time}'
|
||||
)
|
||||
return translated_template.format(
|
||||
room=room,
|
||||
room_recording_date=dt.strftime("%Y-%m-%d"),
|
||||
room_recording_time=dt.strftime("%H:%M"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service(recording: models.Recording):
|
||||
if settings.SUMMARY_SERVICE_VERSION == 1:
|
||||
return NotificationService._notify_summary_service_v1(recording)
|
||||
if settings.SUMMARY_SERVICE_VERSION == 2:
|
||||
return NotificationService._notify_summary_service_v2(recording)
|
||||
|
||||
raise NotImplementedError(
|
||||
f"Unknown summary service version: {settings.SUMMARY_SERVICE_VERSION}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service_v1(recording: models.Recording):
|
||||
"""Notify summary service about a new recording."""
|
||||
|
||||
if (
|
||||
@@ -253,5 +296,119 @@ 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,
|
||||
# For now the feature flag logic is handled on summary side
|
||||
"auto_create_summary": True,
|
||||
},
|
||||
"metadata": metadata_payload,
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {settings.SUMMARY_SERVICE_API_TOKEN}",
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
settings.SUMMARY_SERVICE_ENDPOINT,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
response_json = response.json()
|
||||
# We do not require a job_id to avoid a breaking change
|
||||
job_id = response_json.get("job_id")
|
||||
if not isinstance(job_id, str):
|
||||
raise ValueError("job_id is not a string")
|
||||
|
||||
recording.external_process_id = job_id
|
||||
recording.save()
|
||||
|
||||
except requests.RequestException as exc:
|
||||
logger.exception(
|
||||
"Summary service error for recording %s. URL: %s. Exception: %s",
|
||||
recording.id,
|
||||
settings.SUMMARY_SERVICE_ENDPOINT,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
notification_service = NotificationService()
|
||||
|
||||
@@ -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,7 @@ from livekit import api
|
||||
|
||||
from core import models, utils
|
||||
from core.models import Recording
|
||||
from core.recording.event.notification import notification_service
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
@@ -16,6 +17,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."""
|
||||
|
||||
@@ -73,3 +78,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()
|
||||
|
||||
@@ -19,6 +19,7 @@ from core.recording.services.metadata_collector import (
|
||||
from core.recording.services.recording_events import (
|
||||
RecordingEventsError,
|
||||
RecordingEventsService,
|
||||
RecordingNotSavableError,
|
||||
)
|
||||
|
||||
from .lobby import LobbyService
|
||||
@@ -88,6 +89,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 +143,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."""
|
||||
@@ -195,6 +200,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."""
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Service for provisional user creation."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import SuspiciousOperation, ValidationError
|
||||
from django.db import IntegrityError
|
||||
|
||||
from core import models
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ProvisionalUserError(Exception):
|
||||
"""Base exception for provisional user service errors."""
|
||||
|
||||
|
||||
class ProvisionalUserCreationDisabledError(ProvisionalUserError):
|
||||
"""Raised when provisional user creation is disabled by configuration."""
|
||||
|
||||
|
||||
class ProvisionalUserIntegrityError(ProvisionalUserError):
|
||||
"""Raised when a provisional user cannot be created or retrieved after a race condition."""
|
||||
|
||||
|
||||
class ProvisionalUserService:
|
||||
"""Handles creation and retrieval of provisional users.
|
||||
|
||||
A provisional user is created without a `sub`, identified by email only.
|
||||
The `sub` is set on first successful OIDC authentication via Django LaSuite.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the service."""
|
||||
|
||||
# `OIDC_USER_SUB_FIELD_IMMUTABLE` comes from Django LaSuite and prevents `sub`
|
||||
# updates. We override its default value to allow setting `sub` for
|
||||
# provisional users.
|
||||
|
||||
self._is_creation_enabled = (
|
||||
settings.APPLICATION_ALLOW_USER_CREATION
|
||||
and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION
|
||||
and not settings.OIDC_USER_SUB_FIELD_IMMUTABLE
|
||||
)
|
||||
|
||||
def _get_by_email(self, email: str) -> models.User | None:
|
||||
"""Return the user with this email, or None if not found."""
|
||||
try:
|
||||
return models.User.objects.get(email__iexact=email)
|
||||
except models.User.DoesNotExist:
|
||||
return None
|
||||
except models.User.MultipleObjectsReturned as e:
|
||||
raise SuspiciousOperation(
|
||||
"Multiple user accounts share a common email."
|
||||
) from e
|
||||
|
||||
def get_or_create(
|
||||
self, email: str, client_id: str
|
||||
) -> tuple[models.User | None, bool]:
|
||||
"""Get or create a provisional user identified by email.
|
||||
|
||||
Args:
|
||||
email: The email address to identify the user.
|
||||
client_id: The application client_id, used for audit logging only.
|
||||
|
||||
Returns:
|
||||
A (user, created) tuple — mirrors get_or_create conventions.
|
||||
|
||||
Raises:
|
||||
ProvisionalUserError: If creation and retrieval both fail.
|
||||
"""
|
||||
user = self._get_by_email(email)
|
||||
if user:
|
||||
return user, False
|
||||
|
||||
if not self._is_creation_enabled:
|
||||
raise ProvisionalUserCreationDisabledError(
|
||||
"Provisional user creation is disabled by configuration."
|
||||
)
|
||||
|
||||
# Create a provisional user without `sub`, identified by email only.
|
||||
# This relies on Django LaSuite implicitly updating the `sub` field on the
|
||||
# user's first successful OIDC authentication. If this stops working,
|
||||
# check for behavior changes in Django LaSuite.
|
||||
try:
|
||||
user = models.User(sub=None, email=email)
|
||||
user.set_unusable_password()
|
||||
user.save()
|
||||
logger.info(
|
||||
"Provisional user created via application: user_id=%s, email=%s, client_id=%s",
|
||||
user.id,
|
||||
email,
|
||||
client_id,
|
||||
)
|
||||
return user, True
|
||||
except (IntegrityError, ValidationError) as e:
|
||||
logger.warning(
|
||||
"Race condition on provisional user creation, fetching existing: "
|
||||
"email=%s, client_id=%s",
|
||||
email,
|
||||
client_id,
|
||||
)
|
||||
user = self._get_by_email(email)
|
||||
|
||||
if user:
|
||||
return user, False
|
||||
|
||||
raise ProvisionalUserIntegrityError(
|
||||
"Failed to create or retrieve provisional user."
|
||||
) from e
|
||||
@@ -0,0 +1,263 @@
|
||||
"""
|
||||
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.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)
|
||||
|
||||
|
||||
# ==============================
|
||||
# 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()
|
||||
@@ -346,8 +346,11 @@ def test_authentication_getter_existing_user_change_fields(
|
||||
|
||||
# One and only one additional update query when a field has changed
|
||||
# Note: .save() triggers uniqueness validation queries for unique fields,
|
||||
# adding extra SELECT queries before the UPDATE (e.g., checking unique=True on 'sub')
|
||||
with django_assert_num_queries(3):
|
||||
# adding extra SELECT queries before the UPDATE:
|
||||
# - unique=True on 'sub'
|
||||
# - unique=True on 'admin_email'
|
||||
# - partial unique index 'unique_email_when_sub_is_null'
|
||||
with django_assert_num_queries(5):
|
||||
authenticated_user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
@@ -118,7 +118,7 @@ def test_api_files_create_file_authenticated_success():
|
||||
|
||||
assert policy_parsed.scheme == "http"
|
||||
assert policy_parsed.netloc in ["minio:9000", "localhost:9000"]
|
||||
assert policy_parsed.path == f"/meet-media-storage/files/{file.id!s}.png"
|
||||
assert policy_parsed.path == f"/meet-media-storage/tmp/files/{file.id!s}.png"
|
||||
|
||||
query_params = parse_qs(policy_parsed.query)
|
||||
|
||||
|
||||
@@ -86,7 +86,11 @@ def test_api_files_media_get_own():
|
||||
assert response.content.decode("utf-8") == "my prose"
|
||||
|
||||
|
||||
def test_api_files_media_auth_file_pending():
|
||||
@pytest.mark.parametrize(
|
||||
"rejecting_status",
|
||||
[models.FileUploadStateChoices.PENDING, models.FileUploadStateChoices.ANALYZING],
|
||||
)
|
||||
def test_api_files_media_auth_rejects(rejecting_status):
|
||||
"""
|
||||
Users who have a specific access to an file, whatever the role, should not be able to
|
||||
retrieve related attachments if the file is not ready.
|
||||
@@ -97,7 +101,7 @@ def test_api_files_media_auth_file_pending():
|
||||
|
||||
file = factories.FileFactory(
|
||||
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||
upload_state=models.FileUploadStateChoices.PENDING,
|
||||
upload_state=rejecting_status,
|
||||
creator=user,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Test related to item upload ended API."""
|
||||
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from io import BytesIO
|
||||
|
||||
from django.core.files.storage import default_storage
|
||||
@@ -81,7 +82,7 @@ def test_api_file_upload_ended_success(settings):
|
||||
)
|
||||
|
||||
default_storage.save(
|
||||
file.file_key,
|
||||
file.temporary_file_key,
|
||||
BytesIO(b"my prose"),
|
||||
)
|
||||
|
||||
@@ -97,6 +98,7 @@ def test_api_file_upload_ended_success(settings):
|
||||
assert response.json()["mimetype"] == "text/plain"
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_api_file_upload_ended_mimetype_not_allowed(settings, caplog):
|
||||
"""
|
||||
Test that the API returns a 400 when the mimetype is not allowed.
|
||||
@@ -119,7 +121,7 @@ def test_api_file_upload_ended_mimetype_not_allowed(settings, caplog):
|
||||
)
|
||||
|
||||
default_storage.save(
|
||||
file.file_key,
|
||||
file.temporary_file_key,
|
||||
BytesIO(b"my prose"),
|
||||
)
|
||||
|
||||
@@ -156,7 +158,7 @@ def test_api_file_upload_ended_mimetype_not_allowed_not_checking_mimetype(settin
|
||||
)
|
||||
|
||||
default_storage.save(
|
||||
file.file_key,
|
||||
file.temporary_file_key,
|
||||
BytesIO(b"my prose"),
|
||||
)
|
||||
|
||||
@@ -200,7 +202,7 @@ def test_api_upload_ended_mismatch_mimetype_with_object_storage(settings, caplog
|
||||
|
||||
s3_client.put_object(
|
||||
Bucket=default_storage.bucket_name,
|
||||
Key=file.file_key,
|
||||
Key=file.temporary_file_key,
|
||||
ContentType="text/html",
|
||||
Body=BytesIO(
|
||||
b'<meta http-equiv="refresh" content="0; url=https://fichiers.numerique.gouv.fr">'
|
||||
@@ -211,7 +213,7 @@ def test_api_upload_ended_mismatch_mimetype_with_object_storage(settings, caplog
|
||||
)
|
||||
|
||||
head_object = s3_client.head_object(
|
||||
Bucket=default_storage.bucket_name, Key=file.file_key
|
||||
Bucket=default_storage.bucket_name, Key=file.temporary_file_key
|
||||
)
|
||||
|
||||
assert head_object["ContentType"] == "text/html"
|
||||
@@ -234,9 +236,10 @@ def test_api_upload_ended_mismatch_mimetype_with_object_storage(settings, caplog
|
||||
assert head_object["Metadata"] == {"foo": "bar"}
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_api_upload_ended_file_size_exceeded(settings, caplog):
|
||||
"""
|
||||
Test when the file size exceed the allowed max upload file size
|
||||
Test when the file size exceeds the allowed max upload file size
|
||||
should return a 400 and delete the file.
|
||||
"""
|
||||
|
||||
@@ -256,7 +259,7 @@ def test_api_upload_ended_file_size_exceeded(settings, caplog):
|
||||
)
|
||||
|
||||
default_storage.save(
|
||||
file.file_key,
|
||||
file.temporary_file_key,
|
||||
BytesIO(b"my prose"),
|
||||
)
|
||||
|
||||
@@ -270,3 +273,48 @@ def test_api_upload_ended_file_size_exceeded(settings, caplog):
|
||||
|
||||
assert not models.File.objects.filter(id=file.id).exists()
|
||||
assert not default_storage.exists(file.file_key)
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_api_file_upload_ended_concurrent_calls_are_serialized(settings):
|
||||
"""Only one concurrent upload-ended call can finalize a pending upload."""
|
||||
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = True
|
||||
settings.FILE_UPLOAD_RESTRICTIONS = {
|
||||
"background_image": {
|
||||
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
|
||||
"allowed_mimetypes": ["text/plain"],
|
||||
},
|
||||
}
|
||||
|
||||
user = factories.UserFactory()
|
||||
file = factories.FileFactory(
|
||||
type=FileTypeChoices.BACKGROUND_IMAGE,
|
||||
filename="my_file.txt",
|
||||
creator=user,
|
||||
)
|
||||
default_storage.save(file.temporary_file_key, BytesIO(b"my prose"))
|
||||
|
||||
def call_upload_ended():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
return client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
futures = [
|
||||
executor.submit(call_upload_ended),
|
||||
executor.submit(call_upload_ended),
|
||||
]
|
||||
responses = [future.result() for future in futures]
|
||||
|
||||
status_codes = sorted(response.status_code for response in responses)
|
||||
assert status_codes == [200, 400]
|
||||
|
||||
failed_response = next(
|
||||
response for response in responses if response.status_code == 400
|
||||
)
|
||||
assert failed_response.json() == {
|
||||
"file": "This action is only available for files in PENDING state."
|
||||
}
|
||||
|
||||
file.refresh_from_db()
|
||||
assert file.upload_state == FileUploadStateChoices.READY
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
"""Tests for the merge_duplicate_users management command."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.core.management import base, call_command
|
||||
|
||||
import pytest
|
||||
|
||||
from core.factories import (
|
||||
FileFactory,
|
||||
UserFactory,
|
||||
UserRecordingAccessFactory,
|
||||
UserResourceAccessFactory,
|
||||
)
|
||||
from core.models import RecordingAccess, ResourceAccess, RoleChoices, User
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
# pylint: disable=W0613
|
||||
|
||||
|
||||
def test_merge_no_duplicates_does_nothing():
|
||||
"""Command should do nothing when no duplicate users exist."""
|
||||
user = UserFactory(email="unique@example.com")
|
||||
call_command("merge_duplicate_users")
|
||||
assert User.objects.count() == 1
|
||||
assert User.objects.filter(id=user.id).exists()
|
||||
|
||||
|
||||
def test_merge_keeps_most_recently_created_user():
|
||||
"""Command should keep the most recently created user when duplicates exist."""
|
||||
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()
|
||||
|
||||
|
||||
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"
|
||||
UserFactory(email=email)
|
||||
UserFactory(email=email)
|
||||
user_kept = UserFactory(email=email)
|
||||
call_command("merge_duplicate_users")
|
||||
assert User.objects.filter(email=email).count() == 1
|
||||
assert User.objects.filter(id=user_kept.id).exists()
|
||||
|
||||
|
||||
# ── ResourceAccess ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_merge_transfers_resource_access_to_kept_user():
|
||||
"""ResourceAccess should be transferred to the kept user when stale user is merged."""
|
||||
user1 = UserFactory(email="dup@example.com")
|
||||
user2 = UserFactory(email="dup@example.com")
|
||||
ra = UserResourceAccessFactory(user=user1)
|
||||
call_command("merge_duplicate_users")
|
||||
ra.refresh_from_db()
|
||||
assert ra.user == user2
|
||||
|
||||
|
||||
def test_merge_transfers_multiple_room_accesses():
|
||||
"""All ResourceAccesses should be transferred to the kept user when stale user is merged."""
|
||||
user1 = UserFactory(email="dup@example.com")
|
||||
user2 = UserFactory(email="dup@example.com")
|
||||
accesses = UserResourceAccessFactory.create_batch(3, user=user1)
|
||||
call_command("merge_duplicate_users")
|
||||
assert not ResourceAccess.objects.filter(user=user1).exists()
|
||||
for ra in accesses:
|
||||
assert ResourceAccess.objects.filter(user=user2, resource=ra.resource).exists()
|
||||
|
||||
|
||||
def test_merge_all_resource_accesses_owned_by_kept_user_nothing_changes():
|
||||
"""ResourceAccesses should remain unchanged when all are already owned by the kept user."""
|
||||
user1 = UserFactory(email="dup@example.com")
|
||||
user2 = UserFactory(email="dup@example.com")
|
||||
accesses = UserResourceAccessFactory.create_batch(3, user=user2)
|
||||
call_command("merge_duplicate_users")
|
||||
assert not ResourceAccess.objects.filter(user=user1).exists()
|
||||
for ra in accesses:
|
||||
ra.refresh_from_db()
|
||||
assert ra.user == user2
|
||||
|
||||
|
||||
def test_merge_resource_access_conflict_upgrades_to_owner():
|
||||
"""ResourceAccess role should be upgraded to owner when stale user has a higher role."""
|
||||
user1 = UserFactory(email="dup@example.com")
|
||||
user2 = UserFactory(email="dup@example.com")
|
||||
ra1 = UserResourceAccessFactory(user=user1, role=RoleChoices.OWNER)
|
||||
ra2 = UserResourceAccessFactory(
|
||||
user=user2, resource=ra1.resource, role=RoleChoices.MEMBER
|
||||
)
|
||||
other_accesses = UserResourceAccessFactory.create_batch(
|
||||
3, user=user1, role=RoleChoices.MEMBER
|
||||
)
|
||||
call_command("merge_duplicate_users")
|
||||
ra2.refresh_from_db()
|
||||
assert ra2.role == RoleChoices.OWNER
|
||||
assert not ResourceAccess.objects.filter(user=user1).exists()
|
||||
for ra in other_accesses:
|
||||
assert ResourceAccess.objects.filter(
|
||||
user=user2, resource=ra.resource, role=RoleChoices.MEMBER
|
||||
).exists()
|
||||
|
||||
|
||||
def test_merge_resource_access_conflict_upgrades_to_admin():
|
||||
"""ResourceAccess role should be upgraded to admin when stale user has a higher role."""
|
||||
user1 = UserFactory(email="dup@example.com")
|
||||
user2 = UserFactory(email="dup@example.com")
|
||||
ra1 = UserResourceAccessFactory(user=user1, role=RoleChoices.ADMIN)
|
||||
ra2 = UserResourceAccessFactory(
|
||||
user=user2, resource=ra1.resource, role=RoleChoices.MEMBER
|
||||
)
|
||||
other_accesses = UserResourceAccessFactory.create_batch(
|
||||
3, user=user1, role=RoleChoices.MEMBER
|
||||
)
|
||||
call_command("merge_duplicate_users")
|
||||
ra2.refresh_from_db()
|
||||
assert ra2.role == RoleChoices.ADMIN
|
||||
assert not ResourceAccess.objects.filter(user=user1).exists()
|
||||
for ra in other_accesses:
|
||||
assert ResourceAccess.objects.filter(
|
||||
user=user2, resource=ra.resource, role=RoleChoices.MEMBER
|
||||
).exists()
|
||||
|
||||
|
||||
def test_merge_resource_access_conflict_does_not_downgrade_role():
|
||||
"""ResourceAccess role should not be downgraded when stale user has a lower role."""
|
||||
user1 = UserFactory(email="dup@example.com")
|
||||
user2 = UserFactory(email="dup@example.com")
|
||||
ra1 = UserResourceAccessFactory(user=user1, role=RoleChoices.MEMBER)
|
||||
ra2 = UserResourceAccessFactory(
|
||||
user=user2, resource=ra1.resource, role=RoleChoices.OWNER
|
||||
)
|
||||
other_accesses = UserResourceAccessFactory.create_batch(
|
||||
3, user=user1, role=RoleChoices.MEMBER
|
||||
)
|
||||
call_command("merge_duplicate_users")
|
||||
ra2.refresh_from_db()
|
||||
assert ra2.role == RoleChoices.OWNER
|
||||
assert not ResourceAccess.objects.filter(user=user1).exists()
|
||||
for ra in other_accesses:
|
||||
assert ResourceAccess.objects.filter(
|
||||
user=user2, resource=ra.resource, role=RoleChoices.MEMBER
|
||||
).exists()
|
||||
|
||||
|
||||
def test_merge_resource_access_conflict_equal_role_keeps_single_access():
|
||||
"""ResourceAccess should keep one entry for the kept user when both ones have the same role."""
|
||||
user1 = UserFactory(email="dup@example.com")
|
||||
user2 = UserFactory(email="dup@example.com")
|
||||
ra1 = UserResourceAccessFactory(user=user1, role=RoleChoices.MEMBER)
|
||||
UserResourceAccessFactory(
|
||||
user=user2, resource=ra1.resource, role=RoleChoices.MEMBER
|
||||
)
|
||||
call_command("merge_duplicate_users")
|
||||
accesses = ResourceAccess.objects.filter(resource=ra1.resource)
|
||||
assert accesses.count() == 1
|
||||
assert accesses.first().user == user2
|
||||
assert accesses.first().role == RoleChoices.MEMBER
|
||||
|
||||
|
||||
# ── RecordingAccess ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_merge_transfers_recording_access_to_kept_user():
|
||||
"""RecordingAccess should be transferred to the kept user when stale user is merged."""
|
||||
user1 = UserFactory(email="dup@example.com")
|
||||
user2 = UserFactory(email="dup@example.com")
|
||||
rca = UserRecordingAccessFactory(user=user1)
|
||||
call_command("merge_duplicate_users")
|
||||
rca.refresh_from_db()
|
||||
assert rca.user == user2
|
||||
|
||||
|
||||
def test_merge_transfers_multiple_recording_accesses():
|
||||
"""All RecordingAccesses should be transferred to the kept user when stale user is merged."""
|
||||
user1 = UserFactory(email="dup@example.com")
|
||||
user2 = UserFactory(email="dup@example.com")
|
||||
accesses = UserRecordingAccessFactory.create_batch(3, user=user1)
|
||||
call_command("merge_duplicate_users")
|
||||
assert not RecordingAccess.objects.filter(user=user1).exists()
|
||||
for rca in accesses:
|
||||
assert RecordingAccess.objects.filter(
|
||||
user=user2, recording=rca.recording
|
||||
).exists()
|
||||
|
||||
|
||||
def test_merge_all_recording_accesses_owned_by_kept_user_nothing_changes():
|
||||
"""RecordingAccesses should remain unchanged when all are already owned by the kept user."""
|
||||
user1 = UserFactory(email="dup@example.com")
|
||||
user2 = UserFactory(email="dup@example.com")
|
||||
accesses = UserRecordingAccessFactory.create_batch(3, user=user2)
|
||||
call_command("merge_duplicate_users")
|
||||
assert not RecordingAccess.objects.filter(user=user1).exists()
|
||||
for rca in accesses:
|
||||
rca.refresh_from_db()
|
||||
assert rca.user == user2
|
||||
|
||||
|
||||
def test_merge_recording_access_conflict_upgrades_to_owner():
|
||||
"""RecordingAccess role should be upgraded to owner when stale user has a higher role."""
|
||||
user1 = UserFactory(email="dup@example.com")
|
||||
user2 = UserFactory(email="dup@example.com")
|
||||
rca1 = UserRecordingAccessFactory(user=user1, role=RoleChoices.OWNER)
|
||||
rca2 = UserRecordingAccessFactory(
|
||||
user=user2, recording=rca1.recording, role=RoleChoices.MEMBER
|
||||
)
|
||||
other_accesses = UserRecordingAccessFactory.create_batch(
|
||||
3, user=user1, role=RoleChoices.MEMBER
|
||||
)
|
||||
call_command("merge_duplicate_users")
|
||||
rca2.refresh_from_db()
|
||||
assert rca2.role == RoleChoices.OWNER
|
||||
assert not RecordingAccess.objects.filter(user=user1).exists()
|
||||
for rca in other_accesses:
|
||||
assert RecordingAccess.objects.filter(
|
||||
user=user2, recording=rca.recording, role=RoleChoices.MEMBER
|
||||
).exists()
|
||||
|
||||
|
||||
def test_merge_recording_access_conflict_upgrades_to_admin():
|
||||
"""RecordingAccess role should be upgraded to admin when stale user has a higher role."""
|
||||
user1 = UserFactory(email="dup@example.com")
|
||||
user2 = UserFactory(email="dup@example.com")
|
||||
rca1 = UserRecordingAccessFactory(user=user1, role=RoleChoices.ADMIN)
|
||||
rca2 = UserRecordingAccessFactory(
|
||||
user=user2, recording=rca1.recording, role=RoleChoices.MEMBER
|
||||
)
|
||||
other_accesses = UserRecordingAccessFactory.create_batch(
|
||||
3, user=user1, role=RoleChoices.MEMBER
|
||||
)
|
||||
call_command("merge_duplicate_users")
|
||||
rca2.refresh_from_db()
|
||||
assert rca2.role == RoleChoices.ADMIN
|
||||
assert not RecordingAccess.objects.filter(user=user1).exists()
|
||||
for rca in other_accesses:
|
||||
assert RecordingAccess.objects.filter(
|
||||
user=user2, recording=rca.recording, role=RoleChoices.MEMBER
|
||||
).exists()
|
||||
|
||||
|
||||
def test_merge_recording_access_conflict_does_not_downgrade_role():
|
||||
"""RecordingAccess role should not be downgraded when stale user has a lower role."""
|
||||
user1 = UserFactory(email="dup@example.com")
|
||||
user2 = UserFactory(email="dup@example.com")
|
||||
rca1 = UserRecordingAccessFactory(user=user1, role=RoleChoices.MEMBER)
|
||||
rca2 = UserRecordingAccessFactory(
|
||||
user=user2, recording=rca1.recording, role=RoleChoices.OWNER
|
||||
)
|
||||
other_accesses = UserRecordingAccessFactory.create_batch(
|
||||
3, user=user1, role=RoleChoices.MEMBER
|
||||
)
|
||||
call_command("merge_duplicate_users")
|
||||
rca2.refresh_from_db()
|
||||
assert rca2.role == RoleChoices.OWNER
|
||||
assert not RecordingAccess.objects.filter(user=user1).exists()
|
||||
for rca in other_accesses:
|
||||
assert RecordingAccess.objects.filter(
|
||||
user=user2, recording=rca.recording, role=RoleChoices.MEMBER
|
||||
).exists()
|
||||
|
||||
|
||||
def test_merge_recording_access_conflict_equal_role_keeps_single_access():
|
||||
"""RecordingAccess should keep one entry for the user when both users have the same role."""
|
||||
user1 = UserFactory(email="dup@example.com")
|
||||
user2 = UserFactory(email="dup@example.com")
|
||||
rca1 = UserRecordingAccessFactory(user=user1, role=RoleChoices.MEMBER)
|
||||
UserRecordingAccessFactory(
|
||||
user=user2, recording=rca1.recording, role=RoleChoices.MEMBER
|
||||
)
|
||||
call_command("merge_duplicate_users")
|
||||
accesses = RecordingAccess.objects.filter(recording=rca1.recording)
|
||||
assert accesses.count() == 1
|
||||
assert accesses.first().user == user2
|
||||
assert accesses.first().role == RoleChoices.MEMBER
|
||||
|
||||
|
||||
# ── Files ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_merge_reassigns_files_to_kept_user():
|
||||
"""Files should be reassigned to the kept user when stale user is merged."""
|
||||
user1 = UserFactory(email="dup@example.com")
|
||||
user2 = UserFactory(email="dup@example.com")
|
||||
files = FileFactory.create_batch(3, creator=user1)
|
||||
call_command("merge_duplicate_users")
|
||||
for f in files:
|
||||
f.refresh_from_db()
|
||||
assert f.creator == user2
|
||||
|
||||
|
||||
def test_merge_kept_user_own_files_untouched():
|
||||
"""Files already owned by the kept user should remain unchanged after merge."""
|
||||
user1 = UserFactory(email="dup@example.com")
|
||||
user2 = UserFactory(email="dup@example.com")
|
||||
FileFactory(creator=user1)
|
||||
kept_file = FileFactory(creator=user2)
|
||||
call_command("merge_duplicate_users")
|
||||
kept_file.refresh_from_db()
|
||||
assert kept_file.creator == user2
|
||||
|
||||
|
||||
# ── Dry-run ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_merge_dry_run_does_not_delete_users():
|
||||
"""Command should not delete any users when dry-run is enabled."""
|
||||
UserFactory(email="dup@example.com")
|
||||
UserFactory(email="dup@example.com")
|
||||
call_command("merge_duplicate_users", dry_run=True)
|
||||
assert User.objects.filter(email="dup@example.com").count() == 2
|
||||
|
||||
|
||||
def test_merge_dry_run_does_not_move_resource_access():
|
||||
"""Command should not move resource accesses when dry-run is enabled."""
|
||||
user1 = UserFactory(email="dup@example.com")
|
||||
UserFactory(email="dup@example.com")
|
||||
ra = UserResourceAccessFactory(user=user1)
|
||||
call_command("merge_duplicate_users", dry_run=True)
|
||||
ra.refresh_from_db()
|
||||
assert ra.user == user1
|
||||
|
||||
|
||||
def test_merge_dry_run_does_not_move_recording_access():
|
||||
"""Command should not move recording accesses when dry-run is enabled."""
|
||||
user1 = UserFactory(email="dup@example.com")
|
||||
UserFactory(email="dup@example.com")
|
||||
ra = UserRecordingAccessFactory(user=user1)
|
||||
call_command("merge_duplicate_users", dry_run=True)
|
||||
ra.refresh_from_db()
|
||||
assert ra.user == user1
|
||||
|
||||
|
||||
def test_merge_dry_run_does_not_move_files():
|
||||
"""Command should not reassign files when dry-run is enabled."""
|
||||
user1 = UserFactory(email="dup@example.com")
|
||||
UserFactory(email="dup@example.com")
|
||||
f = FileFactory(creator=user1)
|
||||
call_command("merge_duplicate_users", dry_run=True)
|
||||
f.refresh_from_db()
|
||||
assert f.creator == user1
|
||||
|
||||
|
||||
# ── Isolation ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_merge_non_duplicate_users_untouched():
|
||||
"""Non-duplicate users should remain untouched when other duplicates are merged."""
|
||||
unique = UserFactory(email="unique@example.com")
|
||||
UserFactory(email="dup@example.com")
|
||||
UserFactory(email="dup@example.com")
|
||||
call_command("merge_duplicate_users")
|
||||
assert User.objects.filter(id=unique.id).exists()
|
||||
|
||||
|
||||
def test_merge_non_duplicate_resource_access_untouched():
|
||||
"""ResourceAccess of non-duplicate users should remain untouched."""
|
||||
unique = UserFactory(email="unique@example.com")
|
||||
ra = UserResourceAccessFactory(user=unique)
|
||||
UserFactory(email="dup@example.com")
|
||||
UserFactory(email="dup@example.com")
|
||||
call_command("merge_duplicate_users")
|
||||
ra.refresh_from_db()
|
||||
assert ra.user == unique
|
||||
|
||||
|
||||
def test_merge_multiple_email_groups_all_merged():
|
||||
"""Command should merge all duplicate email groups in a single run."""
|
||||
for i in range(3):
|
||||
UserFactory(email=f"group{i}@example.com")
|
||||
UserFactory(email=f"group{i}@example.com")
|
||||
call_command("merge_duplicate_users")
|
||||
for i in range(3):
|
||||
assert User.objects.filter(email=f"group{i}@example.com").count() == 1
|
||||
assert User.objects.count() == 3
|
||||
|
||||
|
||||
# ── NULL / blank email guard ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_merge_does_not_merge_users_with_null_email():
|
||||
"""Users with NULL email must never be merged together, even if multiple exist."""
|
||||
user1 = UserFactory(email=None)
|
||||
user2 = UserFactory(email=None)
|
||||
call_command("merge_duplicate_users")
|
||||
assert User.objects.filter(id=user1.id).exists()
|
||||
assert User.objects.filter(id=user2.id).exists()
|
||||
|
||||
|
||||
def test_merge_does_not_merge_users_with_blank_email():
|
||||
"""Users with empty-string email must never be merged together, even if multiple exist."""
|
||||
user1 = UserFactory(email="")
|
||||
user2 = UserFactory(email="")
|
||||
call_command("merge_duplicate_users")
|
||||
assert User.objects.filter(id=user1.id).exists()
|
||||
assert User.objects.filter(id=user2.id).exists()
|
||||
|
||||
|
||||
# ── Atomicity ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.management.commands.merge_duplicate_users.Command._merge_recording_accesses",
|
||||
side_effect=Exception("forced failure"),
|
||||
)
|
||||
def test_merge_is_atomic_rolls_back_all_on_any_failure(mock_reassign_files):
|
||||
"""Merge should be fully rolled back when any step fails."""
|
||||
user1 = UserFactory(email="dup@example.com")
|
||||
user2 = UserFactory(email="dup@example.com")
|
||||
resource_accesses = UserResourceAccessFactory.create_batch(3, user=user1)
|
||||
recording_accesses = UserRecordingAccessFactory.create_batch(3, user=user1)
|
||||
files = FileFactory.create_batch(3, creator=user1)
|
||||
|
||||
with pytest.raises(base.CommandError):
|
||||
call_command("merge_duplicate_users")
|
||||
|
||||
assert User.objects.filter(id=user1.id).exists()
|
||||
assert User.objects.filter(id=user2.id).exists()
|
||||
for ra in resource_accesses:
|
||||
ra.refresh_from_db()
|
||||
assert ra.user == user1
|
||||
for rca in recording_accesses:
|
||||
rca.refresh_from_db()
|
||||
assert rca.user == user1
|
||||
for f in files:
|
||||
f.refresh_from_db()
|
||||
assert f.creator == user1
|
||||
|
||||
|
||||
# ── Email filter ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_merge_email_filter_only_merges_matching_emails():
|
||||
"""Command should only merge users whose email matches the filter."""
|
||||
UserFactory(email="user1@example.com")
|
||||
UserFactory(email="user1@example.com")
|
||||
other1 = UserFactory(email="user1@other.com")
|
||||
other2 = UserFactory(email="user1@other.com")
|
||||
call_command("merge_duplicate_users", email_filter="@example.com")
|
||||
assert User.objects.filter(email="user1@example.com").count() == 1
|
||||
assert User.objects.filter(id=other1.id).exists()
|
||||
assert User.objects.filter(id=other2.id).exists()
|
||||
|
||||
|
||||
def test_merge_email_filter_no_match_does_nothing():
|
||||
"""Command should do nothing when the email filter matches no users."""
|
||||
UserFactory(email="user1@example.com")
|
||||
UserFactory(email="user1@example.com")
|
||||
call_command("merge_duplicate_users", email_filter="@nomatch.com")
|
||||
assert User.objects.filter(email="user1@example.com").count() == 2
|
||||
|
||||
|
||||
def test_merge_email_filter_is_case_insensitive():
|
||||
"""Command should match emails case-insensitively when filtering."""
|
||||
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
|
||||
@@ -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'"
|
||||
|
||||
@@ -243,3 +243,168 @@ 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": True,
|
||||
"form_link": None,
|
||||
},
|
||||
"metadata": {
|
||||
"cloud_storage_url": "https://storage.test/metadata.json",
|
||||
"started_at": started_at.isoformat(),
|
||||
"ended_at": ended_at.isoformat(),
|
||||
},
|
||||
}
|
||||
expected_headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer summary-token",
|
||||
}
|
||||
mock_post.assert_called_once_with(
|
||||
"https://summary.test/api/v2/tasks",
|
||||
json=expected_payload,
|
||||
headers=expected_headers,
|
||||
timeout=30,
|
||||
)
|
||||
assert mock_generate_download_s3_url.call_args_list == [
|
||||
mock.call(metadata_filename, expires_in=60 * 60 * 24, override_domain=False),
|
||||
mock.call(recording.key, expires_in=60 * 60 * 24, override_domain=False),
|
||||
]
|
||||
mock_get_recording_timestamps.assert_awaited_once_with("egress-1")
|
||||
|
||||
|
||||
@mock.patch("core.recording.event.notification.requests.post")
|
||||
@mock.patch("core.recording.event.notification.generate_download_s3_url")
|
||||
@mock.patch.object(
|
||||
NotificationService, "_get_recording_timestamps", new_callable=mock.AsyncMock
|
||||
)
|
||||
def test_notify_summary_service_post_args_without_metadata(
|
||||
mock_get_recording_timestamps,
|
||||
mock_generate_download_s3_url,
|
||||
mock_post,
|
||||
settings,
|
||||
):
|
||||
"""Test summary notification computed request args when metadata is not available."""
|
||||
settings.SUMMARY_SERVICE_VERSION = 2
|
||||
settings.SUMMARY_SERVICE_ENDPOINT = "https://summary.test/api/v2/tasks"
|
||||
settings.SUMMARY_SERVICE_API_TOKEN = "summary-token"
|
||||
settings.RECORDING_DOWNLOAD_BASE_URL = "https://app.test/recordings"
|
||||
settings.SCREEN_RECORDING_BASE_URL = None
|
||||
settings.METADATA_COLLECTOR_ENABLED = False
|
||||
|
||||
recording = factories.RecordingFactory(room__name="Daily")
|
||||
owner = factories.UserFactory(
|
||||
email="owner@test.com",
|
||||
sub="owner-sub",
|
||||
language="en-us",
|
||||
timezone="UTC",
|
||||
)
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER, user=owner
|
||||
)
|
||||
|
||||
mock_get_recording_timestamps.return_value = (None, None)
|
||||
mock_generate_download_s3_url.return_value = "https://storage.test/recording.mp4"
|
||||
|
||||
mock_response = mock.Mock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = {"job_id": "job-51"}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
result = NotificationService._notify_summary_service(recording)
|
||||
|
||||
assert result is True
|
||||
expected_payload = {
|
||||
"user_sub": owner.sub,
|
||||
"user_email": owner.email,
|
||||
"cloud_storage_url": "https://storage.test/recording.mp4",
|
||||
"language": "en",
|
||||
"context_language": owner.language,
|
||||
"push_to_docs_config": {
|
||||
"user_email": owner.email,
|
||||
"title": "Transcription",
|
||||
"download_link": f"{settings.RECORDING_DOWNLOAD_BASE_URL}/{recording.id}",
|
||||
"auto_create_summary": True,
|
||||
"form_link": None,
|
||||
},
|
||||
"metadata": None,
|
||||
}
|
||||
expected_headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer summary-token",
|
||||
}
|
||||
mock_post.assert_called_once_with(
|
||||
"https://summary.test/api/v2/tasks",
|
||||
json=expected_payload,
|
||||
headers=expected_headers,
|
||||
timeout=30,
|
||||
)
|
||||
mock_generate_download_s3_url.assert_called_once_with(
|
||||
recording.key, expires_in=60 * 60 * 24, override_domain=False
|
||||
)
|
||||
mock_get_recording_timestamps.assert_awaited_once_with(recording.worker_id)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -776,7 +776,7 @@ def test_update_participant_invalid_permission():
|
||||
"loc": ["invalid-attributes"],
|
||||
"msg": "Extra inputs are not permitted",
|
||||
"input": "True",
|
||||
"url": "https://errors.pydantic.dev/2.12/v/extra_forbidden",
|
||||
"url": "https://errors.pydantic.dev/2.13/v/extra_forbidden",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -91,7 +91,9 @@ def test_handle_egress_ended_success(
|
||||
)
|
||||
|
||||
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(
|
||||
@@ -155,7 +157,7 @@ def test_handle_egress_updated_non_handled(
|
||||
def test_handle_egress_ended_metadata_update_fails(
|
||||
mock_update_room_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()
|
||||
@@ -170,7 +172,9 @@ 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")
|
||||
@@ -326,6 +330,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.utils.update_room_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_room_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.utils.update_room_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_room_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.utils.update_room_metadata")
|
||||
def test_handle_egress_ended_does_not_save_on_wrong_status(
|
||||
mock_update_room_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.utils.update_room_metadata")
|
||||
def test_handle_egress_ended_ignores_non_savable_recording(
|
||||
mock_update_room_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(
|
||||
|
||||
@@ -4,6 +4,8 @@ Tests for external API /token endpoint
|
||||
|
||||
# pylint: disable=W0621
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from freezegun import freeze_time
|
||||
@@ -15,6 +17,7 @@ from core.factories import (
|
||||
UserFactory,
|
||||
)
|
||||
from core.models import ApplicationScope, User
|
||||
from core.services import provisional_user_service
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -436,3 +439,88 @@ def test_api_applications_token_existing_user(settings):
|
||||
"delegated": True,
|
||||
"scope": "rooms:list rooms:create",
|
||||
}
|
||||
|
||||
|
||||
@mock.patch.object(provisional_user_service.ProvisionalUserService, "_get_by_email")
|
||||
def test_api_applications_token_new_user_race_condition(mock_get_by_email, settings):
|
||||
"""Should handle race condition where two concurrent requests create the same user."""
|
||||
settings.APPLICATION_ALLOW_USER_CREATION = True
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False
|
||||
|
||||
application = ApplicationFactory(
|
||||
is_active=True, scopes=[ApplicationScope.ROOMS_LIST]
|
||||
)
|
||||
plain_secret = "test-secret-123"
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
email = "john.doe@example.com"
|
||||
|
||||
# First call: lie and say user doesn't exist, simulating the race window
|
||||
# Second call (recovery path): return the real user
|
||||
existing_user = UserFactory(sub=None, email=email)
|
||||
mock_get_by_email.side_effect = [None, existing_user]
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
{
|
||||
"client_id": application.client_id,
|
||||
"client_secret": plain_secret,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": email,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert mock_get_by_email.call_count == 2
|
||||
|
||||
token = response.data["access_token"]
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.APPLICATION_JWT_SECRET_KEY,
|
||||
algorithms=[settings.APPLICATION_JWT_ALG],
|
||||
issuer=settings.APPLICATION_JWT_ISSUER,
|
||||
audience=settings.APPLICATION_JWT_AUDIENCE,
|
||||
)
|
||||
assert payload["user_id"] == str(existing_user.id)
|
||||
assert User.objects.filter(email=email).count() == 1
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
provisional_user_service.ProvisionalUserService,
|
||||
"get_or_create",
|
||||
side_effect=provisional_user_service.ProvisionalUserIntegrityError,
|
||||
)
|
||||
def test_api_applications_token_new_user_race_condition_unrecoverable(
|
||||
mock_get_or_create, settings
|
||||
):
|
||||
"""Should return 500 when ProvisionalUserIntegrityError is raised."""
|
||||
|
||||
settings.APPLICATION_ALLOW_USER_CREATION = True
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
settings.OIDC_USER_SUB_FIELD_IMMUTABLE = False
|
||||
|
||||
application = ApplicationFactory(
|
||||
is_active=True, scopes=[ApplicationScope.ROOMS_LIST]
|
||||
)
|
||||
plain_secret = "test-secret-123"
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
{
|
||||
"client_id": application.client_id,
|
||||
"client_secret": plain_secret,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": "john.doe@example.com",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert mock_get_or_create.call_count == 1
|
||||
|
||||
@@ -44,3 +44,73 @@ def test_models_users_send_mail_main_missing():
|
||||
user.email_user("my subject", "my message")
|
||||
|
||||
assert str(excinfo.value) == "User has no email address."
|
||||
|
||||
|
||||
def test_models_users_email_unique_when_sub_is_null():
|
||||
"""Email should be unique among users with no sub (pending users)."""
|
||||
user = factories.UserFactory(sub=None, email="test@example.com")
|
||||
with pytest.raises(
|
||||
ValidationError, match="Constraint “unique_email_when_sub_is_null” is violated."
|
||||
):
|
||||
factories.UserFactory(sub=None, email=user.email)
|
||||
|
||||
|
||||
def test_models_users_email_unique_case_insensitive_when_sub_is_null():
|
||||
"""Email uniqueness should be case-insensitive among users with no sub (pending users)."""
|
||||
factories.UserFactory(sub=None, email="Test@example.com")
|
||||
with pytest.raises(
|
||||
ValidationError, match="Constraint “unique_email_when_sub_is_null” is violated."
|
||||
):
|
||||
factories.UserFactory(sub=None, email="test@example.com")
|
||||
|
||||
|
||||
def test_models_users_email_not_unique_when_sub_is_set():
|
||||
"""Email uniqueness should not be enforced when users have a sub."""
|
||||
user = factories.UserFactory(sub="sub-1", email="test@example.com")
|
||||
user2 = factories.UserFactory(sub="sub-2", email=user.email)
|
||||
assert user2.email == user.email
|
||||
|
||||
|
||||
def test_models_users_email_not_unique_between_sub_null_and_sub_set():
|
||||
"""A user with a sub and a pending user (sub=None) can share the same email."""
|
||||
user = factories.UserFactory(sub="sub-1", email="test@example.com")
|
||||
user2 = factories.UserFactory(sub=None, email=user.email)
|
||||
assert user2.email == user.email
|
||||
|
||||
|
||||
def test_models_users_email_unique_constraint_allows_multiple_null_emails():
|
||||
"""Multiple users with sub=None and email=None should be allowed."""
|
||||
factories.UserFactory(sub=None, email=None)
|
||||
factories.UserFactory(sub=None, email=None)
|
||||
|
||||
|
||||
def test_models_users_sub_null_email_null_does_not_prevent_creation():
|
||||
"""Multiple pending users (sub=None, email=None) can be created without conflict.
|
||||
|
||||
sub=None is not unique-constrained. email uniqueness is only enforced among
|
||||
sub=None users with a non-null email, so email=None bypasses it (NULL != NULL in SQL).
|
||||
"""
|
||||
# Ghost row can still appear from bad code path
|
||||
u1 = factories.UserFactory(sub=None, email=None)
|
||||
u2 = factories.UserFactory(sub=None, email=None)
|
||||
assert u1.pk != u2.pk
|
||||
|
||||
|
||||
def test_models_users_sub_can_be_null():
|
||||
"""sub is nullable: pending users exist before OIDC activation."""
|
||||
user = factories.UserFactory(sub=None)
|
||||
user.refresh_from_db()
|
||||
assert user.sub is None
|
||||
|
||||
|
||||
def test_models_users_sub_null_does_not_prevent_creation():
|
||||
"""Multiple users can be created with sub=None (pending state)."""
|
||||
u1 = factories.UserFactory(sub=None)
|
||||
u2 = factories.UserFactory(sub=None)
|
||||
assert u1.pk != u2.pk
|
||||
|
||||
|
||||
def test_models_users_sub_blank_is_accepted():
|
||||
"""sub='' passes validation because blank=True; null is preferred but not enforced."""
|
||||
user = factories.UserFactory.build(sub="")
|
||||
user.full_clean()
|
||||
|
||||
@@ -426,7 +426,7 @@ def generate_upload_policy(file):
|
||||
Originally taken from https://github.com/suitenumerique/drive/blob/564822d31f071c6dfacd112ef4b7146c73077cd9/src/backend/core/api/utils.py#L102 # pylint: disable=line-too-long
|
||||
"""
|
||||
|
||||
key = file.file_key
|
||||
key = file.temporary_file_key
|
||||
|
||||
# This settings 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
|
||||
@@ -459,6 +459,43 @@ def generate_upload_policy(file):
|
||||
return policy
|
||||
|
||||
|
||||
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
|
||||
# docker compose stack used in development. The frontend application will use localhost
|
||||
# to connect to the object storage while the backend application will use the object storage
|
||||
# service name declared in the docker compose stack.
|
||||
# This is needed because the domain name is used to compute the signature. So it can't be
|
||||
# changed dynamically by the frontend application.
|
||||
if settings.AWS_S3_DOMAIN_REPLACE and override_domain:
|
||||
s3_client = boto3.client(
|
||||
"s3",
|
||||
aws_access_key_id=settings.AWS_S3_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=settings.AWS_S3_SECRET_ACCESS_KEY,
|
||||
endpoint_url=settings.AWS_S3_DOMAIN_REPLACE,
|
||||
config=botocore.client.Config(
|
||||
region_name=settings.AWS_S3_REGION_NAME,
|
||||
signature_version=settings.AWS_S3_SIGNATURE_VERSION,
|
||||
),
|
||||
)
|
||||
else:
|
||||
s3_client = default_storage.connection.meta.client
|
||||
|
||||
return s3_client.generate_presigned_url(
|
||||
ClientMethod="get_object",
|
||||
Params={"Bucket": default_storage.bucket_name, "Key": key},
|
||||
ExpiresIn=expires_in,
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _format_telephony_phone_number(raw_number, default_country):
|
||||
"""Parse a configured phone number and return (country, international_format).
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -182,10 +182,15 @@ 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
|
||||
)
|
||||
FILE_UPLOAD_TMP_PATH = values.Value(
|
||||
"tmp/files", environ_name="FILE_UPLOAD_TMP_PATH", environ_prefix=None
|
||||
)
|
||||
|
||||
FILE_UPLOAD_APPLY_RESTRICTIONS = values.BooleanValue(
|
||||
default=True, environ_name="FILE_UPLOAD_APPLY_RESTRICTIONS", environ_prefix=None
|
||||
@@ -318,8 +323,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",
|
||||
@@ -408,6 +412,12 @@ class Base(Configuration):
|
||||
"transcription_destination": values.Value(
|
||||
None, environ_name="FRONTEND_TRANSCRIPTION_DESTINATION", environ_prefix=None
|
||||
),
|
||||
"max_participants_for_sound": values.PositiveIntegerValue(
|
||||
5, environ_name="FRONTEND_MAX_PARTICIPANTS_FOR_SOUND", environ_prefix=None
|
||||
),
|
||||
"auto_mute_on_join_threshold": values.PositiveIntegerValue(
|
||||
50, environ_name="FRONTEND_AUTO_MUTE_ON_JOIN_THRESHOLD", environ_prefix=None
|
||||
),
|
||||
}
|
||||
|
||||
# Mail
|
||||
@@ -738,12 +748,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
|
||||
)
|
||||
@@ -751,6 +779,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.
|
||||
@@ -1011,6 +1047,44 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Logging
|
||||
# We want to make it easy to log to console but by default we log production
|
||||
# to Sentry and don't want to log to console.
|
||||
LOGGING = {
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"simple": {
|
||||
"format": "{asctime} {name} {levelname} {message}",
|
||||
"style": "{",
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "simple",
|
||||
},
|
||||
},
|
||||
# Override root logger to send it to console
|
||||
"root": {
|
||||
"handlers": ["console"],
|
||||
"level": values.Value(
|
||||
"INFO", environ_name="LOGGING_LEVEL_LOGGERS_ROOT", environ_prefix=None
|
||||
),
|
||||
},
|
||||
"loggers": {
|
||||
"core": {
|
||||
"handlers": ["console"],
|
||||
"level": values.Value(
|
||||
"INFO",
|
||||
environ_name="LOGGING_LEVEL_LOGGERS_APP",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
def ENVIRONMENT(self):
|
||||
@@ -1049,6 +1123,11 @@ class Base(Configuration):
|
||||
"""
|
||||
super().post_setup()
|
||||
|
||||
if cls.FILE_UPLOAD_TMP_PATH == cls.FILE_UPLOAD_PATH:
|
||||
raise ValueError(
|
||||
"FILE_UPLOAD_TMP_PATH cannot be the same as FILE_UPLOAD_PATH"
|
||||
)
|
||||
|
||||
# The SENTRY_DSN setting should be available to activate sentry for an environment
|
||||
if cls.SENTRY_DSN is not None:
|
||||
sentry_sdk.init(
|
||||
|
||||
+28
-27
@@ -2,12 +2,12 @@
|
||||
# Meet package
|
||||
#
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.10.9,<0.11.0"]
|
||||
requires = ["uv_build>=0.11.16,<0.12.0"]
|
||||
build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "1.16.0"
|
||||
version = "1.22.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -24,45 +24,46 @@ keywords = ["Django", "Contacts", "Templates", "RBAC"]
|
||||
license = "MIT"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"boto3==1.42.68",
|
||||
"boto3==1.43.36",
|
||||
"Brotli==1.2.0",
|
||||
"brevo-python==1.2.0",
|
||||
"celery[redis]==5.6.2",
|
||||
"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-parler==2.3",
|
||||
"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",
|
||||
"django==5.2.14",
|
||||
"djangorestframework==3.16.1",
|
||||
"djangorestframework==3.17.1",
|
||||
"drf_spectacular==0.29.0",
|
||||
"dockerflow==2026.3.4",
|
||||
"easy_thumbnails==2.10.1",
|
||||
"factory_boy==3.3.3",
|
||||
"gunicorn==25.1.0",
|
||||
"gunicorn==26.0.0",
|
||||
"jsonschema==4.26.0",
|
||||
"markdown==3.10.2",
|
||||
"nested-multipart-parser==1.6.0",
|
||||
"psycopg[binary]==3.3.3",
|
||||
"pydantic==2.12.5",
|
||||
"PyJWT==2.12.1",
|
||||
"python-frontmatter==1.1.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.33.0",
|
||||
"sentry-sdk==2.54.0",
|
||||
"requests==2.34.2",
|
||||
"sentry-sdk==2.63.0",
|
||||
"whitenoise==6.12.0",
|
||||
"mozilla-django-oidc==5.0.2",
|
||||
"livekit-api==1.1.0",
|
||||
"aiohttp==3.13.4",
|
||||
"livekit-api==1.1.1",
|
||||
"aiohttp==3.14.1",
|
||||
"urllib3==2.7.0",
|
||||
"phonenumbers==9.0.30",
|
||||
"phonenumbers==9.0.33",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -74,21 +75,21 @@ dependencies = [
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"django-extensions==4.1",
|
||||
"drf-spectacular-sidecar==2026.3.1",
|
||||
"drf-spectacular-sidecar==2026.6.1",
|
||||
"freezegun==1.5.5",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==9.11.0",
|
||||
"pyfakefs==6.1.5",
|
||||
"ipython==9.14.1",
|
||||
"pyfakefs==6.2.0",
|
||||
"pylint-django==2.7.0",
|
||||
"pylint<4.0.0",
|
||||
"pytest-cov==7.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.0",
|
||||
"ruff==0.15.6",
|
||||
"types-requests==2.32.4.20260107",
|
||||
"responses==0.26.1",
|
||||
"ruff==0.15.19",
|
||||
"types-requests==2.33.0.20260518",
|
||||
]
|
||||
|
||||
[tool.uv.build-backend]
|
||||
|
||||
Generated
+737
-669
File diff suppressed because it is too large
Load Diff
@@ -1,21 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
'plugin:@tanstack/eslint-plugin-query/recommended',
|
||||
'plugin:jsx-a11y/recommended',
|
||||
'prettier'
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs', 'styled-system'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
@@ -45,6 +45,8 @@ USER root
|
||||
|
||||
# Security patches for known CVEs
|
||||
RUN apk update && apk upgrade \
|
||||
libcrypto3>=3.5.7-r0 \
|
||||
libssl3>=3.5.7-r0 \
|
||||
musl \
|
||||
musl-utils \
|
||||
zlib>=1.3.2-r0 \
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
// src/frontend/eslint.config.js
|
||||
import js from '@eslint/js'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import reactPlugin from 'eslint-plugin-react'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import jsxA11y from 'eslint-plugin-jsx-a11y'
|
||||
import globals from 'globals'
|
||||
|
||||
export default tseslint.config(
|
||||
// Global ignores (replaces .eslintignore)
|
||||
{
|
||||
ignores: [
|
||||
'dist/**',
|
||||
'node_modules/**',
|
||||
'coverage/**',
|
||||
'src/styled-system/**',
|
||||
],
|
||||
},
|
||||
|
||||
// Base JS recommended rules
|
||||
js.configs.recommended,
|
||||
|
||||
// TypeScript recommended rules (parser + rules bundled together)
|
||||
...tseslint.configs.recommended,
|
||||
|
||||
// React-specific config
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
plugins: {
|
||||
react: reactPlugin,
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
'jsx-a11y': jsxA11y,
|
||||
},
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
parserOptions: {
|
||||
ecmaFeatures: { jsx: true },
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
react: { version: 'detect' },
|
||||
},
|
||||
rules: {
|
||||
// React rules
|
||||
...reactPlugin.configs.recommended.rules,
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react/react-in-jsx-scope': 'off', // not needed with React 17+
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
|
||||
// React Compiler rules (v7+) — disable until migrated to React 19
|
||||
'react-hooks/react-compiler': 'off',
|
||||
'react-hooks/set-state-in-effect': 'off',
|
||||
'react-hooks/preserve-manual-memoization': 'off',
|
||||
'react-hooks/immutability': 'off',
|
||||
'react-hooks/refs': 'off',
|
||||
|
||||
// jsx-a11y recommended rules
|
||||
...jsxA11y.configs.recommended.rules,
|
||||
|
||||
// TypeScript rules you may want to adjust
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{ argsIgnorePattern: '^_' },
|
||||
],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
},
|
||||
},
|
||||
|
||||
// CJS and root-level JS config files (postcss.config.js, etc.)
|
||||
{
|
||||
files: ['**/*.cjs', '*.config.js'],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
Generated
+3591
-4839
File diff suppressed because it is too large
Load Diff
+37
-33
@@ -1,13 +1,14 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.16.0",
|
||||
"version": "1.22.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
"build": "panda codegen && tsc -b && vite build",
|
||||
"build:debug": "VITE_ANALYZE=true npm run build -- --debug",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"preview": "vite preview",
|
||||
"i18n:extract": "npx i18next -c i18next-parser.config.json",
|
||||
"format": "prettier --write ./src",
|
||||
@@ -16,56 +17,59 @@
|
||||
"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",
|
||||
"@livekit/components-react": "2.9.19",
|
||||
"@livekit/components-react": "2.9.21",
|
||||
"@livekit/components-styles": "1.2.0",
|
||||
"@livekit/track-processors": "0.7.0",
|
||||
"@pandacss/preset-panda": "1.8.2",
|
||||
"@react-aria/toast": "3.0.10",
|
||||
"@react-types/overlays": "3.9.3",
|
||||
"@remixicon/react": "4.6.0",
|
||||
"@tanstack/react-query": "5.90.21",
|
||||
"@livekit/track-processors": "0.7.2",
|
||||
"@pandacss/preset-panda": "1.11.3",
|
||||
"@react-types/overlays": "3.10.0",
|
||||
"@remixicon/react": "4.9.0",
|
||||
"@tanstack/react-query": "5.100.14",
|
||||
"@timephy/rnnoise-wasm": "1.0.0",
|
||||
"crisp-sdk-web": "1.0.27",
|
||||
"crisp-sdk-web": "1.1.2",
|
||||
"derive-valtio": "0.2.0",
|
||||
"hoofd": "1.7.3",
|
||||
"humanize-duration": "3.33.2",
|
||||
"i18next": "25.8.8",
|
||||
"i18next": "26.3.1",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"i18next-parser": "9.3.0",
|
||||
"i18next-parser": "9.4.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"livekit-client": "2.17.1",
|
||||
"posthog-js": "1.342.1",
|
||||
"livekit-client": "2.19.0",
|
||||
"posthog-js": "1.386.5",
|
||||
"react": "18.3.1",
|
||||
"react-aria-components": "1.14.0",
|
||||
"react-aria": "3.49.0",
|
||||
"react-aria-components": "1.18.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "15.1.1",
|
||||
"react-i18next": "17.0.8",
|
||||
"react-stately": "3.47.0",
|
||||
"use-sound": "5.0.0",
|
||||
"valtio": "2.3.0",
|
||||
"wouter": "3.9.0"
|
||||
"valtio": "2.3.2",
|
||||
"wouter": "3.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.39.4",
|
||||
"@pandacss/dev": "1.11.1",
|
||||
"@tanstack/eslint-plugin-query": "5.91.4",
|
||||
"@tanstack/react-query-devtools": "5.91.3",
|
||||
"@tanstack/eslint-plugin-query": "5.100.14",
|
||||
"@tanstack/react-query-devtools": "5.100.14",
|
||||
"@types/humanize-duration": "3.27.4",
|
||||
"@types/node": "22.16.0",
|
||||
"@types/node": "24.12.4",
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.35.1",
|
||||
"@typescript-eslint/parser": "8.35.1",
|
||||
"@vitejs/plugin-react": "5.1.4",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-prettier": "10.1.5",
|
||||
"@vitejs/plugin-react": "6.0.2",
|
||||
"eslint": "9.39.4",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"eslint-plugin-react-refresh": "0.4.20",
|
||||
"postcss": "8.5.14",
|
||||
"prettier": "3.8.1",
|
||||
"eslint-plugin-react": "7.37.5",
|
||||
"eslint-plugin-react-hooks": "7.1.1",
|
||||
"eslint-plugin-react-refresh": "0.5.2",
|
||||
"globals": "17.6.0",
|
||||
"postcss": "8.5.15",
|
||||
"prettier": "3.8.3",
|
||||
"rollup-plugin-visualizer": "7.0.1",
|
||||
"typescript": "5.8.3",
|
||||
"vite": "7.3.2",
|
||||
"vite-plugin-svgr": "5.2.0",
|
||||
"vite-tsconfig-paths": "6.1.1"
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.60.1",
|
||||
"vite": "8.0.14",
|
||||
"vite-plugin-svgr": "5.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface ApiConfig {
|
||||
analytics?: {
|
||||
id: string
|
||||
host: string
|
||||
flags_api_host?: string
|
||||
}
|
||||
support?: {
|
||||
id: string
|
||||
@@ -55,6 +56,8 @@ export interface ApiConfig {
|
||||
default_sources: Source[]
|
||||
}
|
||||
transcription_destination?: string
|
||||
max_participants_for_sound: number
|
||||
auto_mute_on_join_threshold: number
|
||||
}
|
||||
|
||||
const fetchConfig = (): Promise<ApiConfig> => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,16 @@ import { type ApiUser } from './ApiUser'
|
||||
import { useMemo } from 'react'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
const SILENT_LOGIN_PARAM = 'silentLogin'
|
||||
|
||||
const isSilentLoginDisabledByUrl = () => {
|
||||
if (typeof window === 'undefined') return false
|
||||
const value = new URLSearchParams(window.location.search).get(
|
||||
SILENT_LOGIN_PARAM
|
||||
)
|
||||
return value === 'false'
|
||||
}
|
||||
|
||||
/**
|
||||
* returns info about currently logged-in user
|
||||
*
|
||||
@@ -17,19 +27,24 @@ export const useUser = (
|
||||
) => {
|
||||
const { data, isLoading: isConfigLoading } = useConfig()
|
||||
|
||||
const disabledByUrl = useMemo(() => isSilentLoginDisabledByUrl(), [])
|
||||
|
||||
const options = useMemo(() => {
|
||||
if (isConfigLoading) return
|
||||
if (data?.is_silent_login_enabled !== true) {
|
||||
|
||||
const silentDisabled =
|
||||
data?.is_silent_login_enabled !== true || disabledByUrl
|
||||
|
||||
if (silentDisabled) {
|
||||
return {
|
||||
...opts,
|
||||
...opts.fetchUserOptions,
|
||||
attemptSilent: false,
|
||||
}
|
||||
}
|
||||
return opts.fetchUserOptions
|
||||
}, [data, opts, isConfigLoading])
|
||||
}, [data, opts, isConfigLoading, disabledByUrl])
|
||||
|
||||
const query = useQuery({
|
||||
// eslint-disable-next-line @tanstack/query/exhaustive-deps
|
||||
queryKey: [keys.user],
|
||||
queryFn: () => fetchUser(options),
|
||||
staleTime: Infinity,
|
||||
|
||||
@@ -5,7 +5,7 @@ export type ApiFileCreator = {
|
||||
}
|
||||
|
||||
export type ApiFileType = 'background_image'
|
||||
export type ApiFileUploadState = 'pending' | 'ready'
|
||||
export type ApiFileUploadState = 'pending' | 'analyzing' | 'ready'
|
||||
|
||||
export type ApiFileItem = {
|
||||
id: string // UUID
|
||||
|
||||
@@ -44,7 +44,8 @@ export function PaginationControl({
|
||||
if (totalPageCount <= 1) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
<nav
|
||||
aria-label={t('label')}
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
bottom: '1rem',
|
||||
@@ -74,7 +75,7 @@ export function PaginationControl({
|
||||
<RiArrowLeftSLine />
|
||||
</Button>
|
||||
<span
|
||||
aria-live="polite"
|
||||
role="status"
|
||||
className={css({
|
||||
padding: '0.25rem 0.5rem',
|
||||
})}
|
||||
@@ -93,6 +94,6 @@ export function PaginationControl({
|
||||
>
|
||||
<RiArrowRightSLine />
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -14,9 +14,11 @@ import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
import { Emoji } from '@/features/reactions/types'
|
||||
import { useReactions } from '@/features/reactions/hooks/useReactions'
|
||||
import { NotificationProvider } from './NotificationProvider'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
export const MainNotificationToast = () => {
|
||||
const room = useRoomContext()
|
||||
const { data } = useConfig()
|
||||
const { triggerNotificationSound } = useNotificationSound()
|
||||
const { t } = useTranslation('notifications')
|
||||
const announce = useScreenReaderAnnounce()
|
||||
@@ -135,12 +137,21 @@ export const MainNotificationToast = () => {
|
||||
}
|
||||
}, [room, handleEmoji])
|
||||
|
||||
const triggerNotificationSoundIfRoomIsSmall = useCallback(
|
||||
(type: NotificationType) => {
|
||||
if (!data) return
|
||||
if (room.numParticipants >= data.max_participants_for_sound) return
|
||||
triggerNotificationSound(type)
|
||||
},
|
||||
[room, data, triggerNotificationSound]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const showJoinNotification = (participant: Participant) => {
|
||||
if (isMobileBrowser()) {
|
||||
return
|
||||
}
|
||||
triggerNotificationSound(NotificationType.ParticipantJoined)
|
||||
triggerNotificationSoundIfRoomIsSmall(NotificationType.ParticipantJoined)
|
||||
toastQueue.add(
|
||||
{
|
||||
participant,
|
||||
@@ -155,7 +166,7 @@ export const MainNotificationToast = () => {
|
||||
return () => {
|
||||
room.off(RoomEvent.ParticipantConnected, showJoinNotification)
|
||||
}
|
||||
}, [room, triggerNotificationSound])
|
||||
}, [room, triggerNotificationSoundIfRoomIsSmall])
|
||||
|
||||
useEffect(() => {
|
||||
const removeParticipantNotifications = (participant: Participant) => {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user