mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-29 13:18:57 +00:00
Compare commits
137 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b2c4bdf64a | |||
| 47a8acf673 | |||
| 89f8480e0b | |||
| d9bf6efa2a | |||
| 4cb7412194 | |||
| e8df597055 | |||
| 05dfcd11ca | |||
| b018832fcf | |||
| a269160f6f | |||
| c3afa84d9b | |||
| 8c6752a29f | |||
| 4c57432a03 | |||
| 3b7bfd999c | |||
| 7f386b2e2f | |||
| c55d8235fd | |||
| c7b23abd68 | |||
| 5a641a4366 | |||
| f043ad6f98 | |||
| 1141c1cecd | |||
| 33792b050a | |||
| f4569c64e5 | |||
| 6a00d3d087 | |||
| 2e975e2643 | |||
| 4c63aa827f | |||
| 67e9bf2fef | |||
| 7124167947 | |||
| 759388c72f | |||
| a663b4dc76 | |||
| 73aa162dc8 | |||
| a3851842e9 | |||
| 77964c6a74 | |||
| 72b863e794 | |||
| 0e3c978af2 | |||
| 13c9d3635c | |||
| eced9891c6 | |||
| ef05c0ab19 | |||
| 932332c858 | |||
| 7343560a9b | |||
| fca7c3b8b8 | |||
| 29672cda6b | |||
| c3c1e918b0 | |||
| d1dde71bea | |||
| 64b4200fc3 | |||
| 4b393e28ca | |||
| 8dc1bacc38 | |||
| 97a9735c0b | |||
| d2e13cc826 | |||
| 183b0fc697 | |||
| b7be0eabb0 | |||
| f21c4c91ed | |||
| 5f52fa8d8d | |||
| 9420fed563 | |||
| 4598aafa23 | |||
| 94abdfc0f9 | |||
| 56a8f72e39 | |||
| 86fcc2c7fe | |||
| 0a0306b0a2 | |||
| c00fcc78e1 | |||
| 39a59d1b35 | |||
| f4493d412f | |||
| e1457604ee | |||
| bf6351e838 | |||
| aba5fca655 | |||
| acb583b14f | |||
| fb3a54a224 | |||
| 8cd6496b5b | |||
| 63a7de402b | |||
| f842cc340e | |||
| 8c235571eb | |||
| d6f39e602d | |||
| 0c0b2f2616 | |||
| a5b79afde1 | |||
| 052d3c1b22 | |||
| dff5aa9575 | |||
| ded93bf24f | |||
| 5ba1885411 | |||
| 273af221e6 | |||
| e8d6aba306 | |||
| 449503208a | |||
| 7eb3553bea | |||
| f0356af365 | |||
| 9921ef9d09 | |||
| 68e999a037 | |||
| 34103bf326 | |||
| e273ac9e43 | |||
| adcdbc0695 | |||
| 8f9008f1e0 | |||
| 9320a1af0c | |||
| e3827970c8 | |||
| ab707d866f | |||
| dac0b9c000 | |||
| 53cc8642eb | |||
| c8d9d2fea8 | |||
| e910b1f0b7 | |||
| 581e115c03 | |||
| 8118ef0612 | |||
| 85f886d9c5 | |||
| b66f82e4b7 | |||
| aabb7d629c | |||
| 1e5f5c4fe9 | |||
| f55fa0c42b | |||
| 2bc5e47c75 | |||
| 0d5136206f | |||
| 05a67320b1 | |||
| 2fbaa49089 | |||
| 76542b2235 | |||
| 10231b0333 | |||
| 9b9e6578ee | |||
| 0c81d29ee7 | |||
| 839a8f1c71 | |||
| 40ef420cda | |||
| d995bd9041 | |||
| 8ac4c2409d | |||
| e47ad42656 | |||
| 2509452c52 | |||
| 1022780027 | |||
| 4a1a04f86e | |||
| 8702638cf3 | |||
| f88c0307ea | |||
| e7f15b50ff | |||
| 75ba2ff146 | |||
| bbe2a32efc | |||
| 86fff16eed | |||
| 195d2b5006 | |||
| 30cf264276 | |||
| 945d779b45 | |||
| d43b335546 | |||
| e6f0ff5112 | |||
| 224d6ce358 | |||
| 20718f2d5a | |||
| 8edcb6e973 | |||
| b00c4e5d6d | |||
| 3c0ba03976 | |||
| 7fa172d100 | |||
| ecf8f0fe3f | |||
| 27da57aff5 | |||
| e327a5e35f |
@@ -82,7 +82,7 @@ jobs:
|
|||||||
- name: Install Node.js
|
- name: Install Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: "18"
|
node-version: "22"
|
||||||
|
|
||||||
- name: Restore the mail templates
|
- name: Restore the mail templates
|
||||||
uses: actions/cache@v5
|
uses: actions/cache@v5
|
||||||
@@ -305,7 +305,6 @@ jobs:
|
|||||||
working-directory: src/summary
|
working-directory: src/summary
|
||||||
|
|
||||||
env:
|
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"}]'
|
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_STORAGE_BUCKET_NAME: "http://meet-media-storage"
|
||||||
AWS_S3_ENDPOINT_URL: "minio:9000"
|
AWS_S3_ENDPOINT_URL: "minio:9000"
|
||||||
|
|||||||
@@ -8,9 +8,89 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨(summary) report exception type in failure analytics
|
||||||
|
- ✨(frontend) add configurable documentation menu item
|
||||||
|
- ✨(frontend) allow promoting authenticated participants
|
||||||
|
- ✨(frontend) introduce an "unauthenticated" participant badge
|
||||||
|
- ✨(frontend) add connection test feature
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- ⬆️(frontend) upgrade @mediapipe/tasks-vision from 0.10.14 to 0.10.35
|
||||||
|
- ⬆️(frontend) upgrade i18next from 26.3.1 to 26.3.4
|
||||||
|
- ⬆️(frontend) upgrade posthog-js from 1.391.2 to 1.395.0
|
||||||
|
- ⬆️(frontend) upgrade @tanstack/react-query from 5.101.0 to 5.101.1
|
||||||
|
- ⬆️(frontend) upgrade livekit-client from 2.19.2 to 2.20.0
|
||||||
|
- ⚡️(frontend) limit unnecessary re-renders #1510
|
||||||
|
- 📝(legal) update terms of service
|
||||||
|
- 💄(frontend) render Avatar initials in uppercase
|
||||||
|
- 💄(frontend) improve participant name rendering in the list
|
||||||
|
|
||||||
|
## Fixed
|
||||||
|
|
||||||
|
- 🐛(transcription) fix silent bug in speaker assignment
|
||||||
|
- 🐛(summary) extend tasks auto retry logic
|
||||||
|
- 🐛(summary) properly detect when failure webhook should be sent
|
||||||
|
- 🐛(backend) preserve recording metadata when updating room access
|
||||||
|
- 🐛(backend) allow any string as sub in the API serializer
|
||||||
|
- 🐛(frontend) fall back to user.full_name on request-entry
|
||||||
|
- 🚸(frontend) show two initials in the Avatar when possible
|
||||||
|
|
||||||
|
## [1.24.0] - 2026-07-21
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨(backend) allow searching the recording admin table by owner email
|
||||||
|
- ✨(frontend) add participant color gradient when camera is off #1490
|
||||||
|
- ✨(all) allow forcing SSO display name for authenticated users
|
||||||
|
- ➕(frontend) install vite-plugin-static-copy for MediaPipe WASM assets
|
||||||
|
- ✨(addon) show add-in tools when creating meetings in shared calendars
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- 🗑️(settings) deprecate SUMMARY_SERVICE_VERSION=1
|
||||||
|
- ⬆️(mail) update mjml to v5 and @html-to/text-cli
|
||||||
|
- 🚸(frontend) initialize the join input name with the persisted full name
|
||||||
|
- ♻️(frontend) refactor background processors to use the new API
|
||||||
|
- ♻️(frontend) inline model weights to avoid loading them from remote
|
||||||
|
- ♻️(frontend) inline MediaPipe WASM modules to avoid loading from remote
|
||||||
|
- ⬆️(frontend) upgrade posthog-js from 1.387.0 to 1.391.2
|
||||||
|
- ⬆️(frontend) upgrade react-stately from 3.47.0 to 3.48.0
|
||||||
|
- ⬆️(frontend) upgrade react-aria from 3.49.0 to 3.50.0
|
||||||
|
- ⬆️(frontend) upgrade react-aria-components from 1.18.0 to 1.19.0
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- 🩹(backend) identify externally provisioned users to PostHog
|
||||||
|
- 🐛(backend) fix info panel crash for unregistered rooms
|
||||||
|
- ♿️(frontend) focus side panel container on open #1452
|
||||||
|
- 🐛(summary) whisper call error handling
|
||||||
|
|
||||||
|
## [1.23.0] - 2026-07-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨(backend) extend analytics module to support feature flags
|
||||||
|
- ✨(backend) implement feature flags in Posthog analytics backend
|
||||||
|
- ✨(agents) report errors to Sentry for all LiveKit agents
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- ⬆️(agents) upgrade to python 3.14 slim
|
- ⬆️(agents) upgrade to python 3.14 slim
|
||||||
|
- ⬆️(dependencies) update python dependencies
|
||||||
|
- 💥(summary) remove v1 related code #1362
|
||||||
|
- ✨(meet) use compatible with summary v2 #1362
|
||||||
|
- ♻️(backend) refactor analytics backend from Protocol to abstract class
|
||||||
|
- 🔥(summary) remove call to summary enabled feature flag
|
||||||
|
- ♻️(frontend) wrap MuteEveryoneButton with AdminOrOwnerOnly
|
||||||
|
- ⬆️(frontend) upgrade livekit-client from 2.19.0 to 2.19.2
|
||||||
|
- ⬆️(frontend) upgrade posthog-js from 1.386.5 to 1.387.0
|
||||||
|
- ⬆️(frontend) upgrade @tanstack/react-query from 5.100.14 to 5.101.0
|
||||||
|
- ⬆️(frontend) update the frontend build image to Node 22
|
||||||
|
- 🔒️(frontend) update docker image to nginx-unprivileged:1.30.3-alpine3.23
|
||||||
|
- ✨(summary) more precise analytics events
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
@@ -30,6 +110,7 @@ and this project adheres to
|
|||||||
- ✨(backend) add fallback to save recordings without S3/MinIO webhooks
|
- ✨(backend) add fallback to save recordings without S3/MinIO webhooks
|
||||||
- 🩹(frontend) enable screen share button in PiP #1458
|
- 🩹(frontend) enable screen share button in PiP #1458
|
||||||
- 🐛(backend) support unencoded S3 notification object keys #1455
|
- 🐛(backend) support unencoded S3 notification object keys #1455
|
||||||
|
- ✨(frontend) prioritize screen share in picture-in-picture layout #1467
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -37,7 +37,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
|||||||
uv sync --locked --no-dev
|
uv sync --locked --no-dev
|
||||||
|
|
||||||
# ---- mails ----
|
# ---- mails ----
|
||||||
FROM node:20 AS mail-builder
|
FROM node:22 AS mail-builder
|
||||||
|
|
||||||
COPY ./src/mail /mail/app
|
COPY ./src/mail /mail/app
|
||||||
|
|
||||||
|
|||||||
+12
@@ -15,3 +15,15 @@ the following command inside your docker container:
|
|||||||
(Note : in your development environment, you can `make migrate`.)
|
(Note : in your development environment, you can `make migrate`.)
|
||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## v1.23.0
|
||||||
|
|
||||||
|
As part of the 1.23.0 release, the legacy `api/v1` implementation has been removed from the _experimental_ Summary service and Meet has been migrated to the new `api/v2`.
|
||||||
|
|
||||||
|
**To avoid a breaking change, the Meet backend continues to use the Summary service's v1-compatible API format by default (`SUMMARY_SERVICE_VERSION` setting defaults to `1`).**
|
||||||
|
|
||||||
|
If you are deploying both Meet and Summary from this repository, you must configure the Meet backend to use the v2 API by setting the following environment variable `SUMMARY_SERVICE_VERSION=2`.
|
||||||
|
|
||||||
|
If you are upgrading only the Meet deployment while keeping an older Summary v1 compatible deployment, no action is required, as the v1-compatible API remains the default.
|
||||||
|
|
||||||
|
Note that we plan on removing the legacy `v1` summary compatibility in a future major version. If you have your own implementation for the summary service, we recommend updating its API contract and setting `SUMMARY_SERVICE_VERSION=2`.
|
||||||
|
|||||||
@@ -113,6 +113,12 @@ update_python_version "summary"
|
|||||||
# Update agents pyproject.toml
|
# Update agents pyproject.toml
|
||||||
update_python_version "agents"
|
update_python_version "agents"
|
||||||
|
|
||||||
|
# Run uv lock in agents
|
||||||
|
print_info "Running uv lock in agents..."
|
||||||
|
cd "src/agents"
|
||||||
|
uv lock
|
||||||
|
cd -
|
||||||
|
|
||||||
# Update CHANGELOG
|
# Update CHANGELOG
|
||||||
print_info "Updating CHANGELOG..."
|
print_info "Updating CHANGELOG..."
|
||||||
|
|
||||||
@@ -158,6 +164,7 @@ echo " - src/backend/pyproject.toml"
|
|||||||
echo " - src/backend/uv.lock"
|
echo " - src/backend/uv.lock"
|
||||||
echo " - src/summary/pyproject.toml"
|
echo " - src/summary/pyproject.toml"
|
||||||
echo " - src/agents/pyproject.toml"
|
echo " - src/agents/pyproject.toml"
|
||||||
|
echo " - src/agents/uv.lock"
|
||||||
echo " - CHANGELOG.md"
|
echo " - CHANGELOG.md"
|
||||||
echo ""
|
echo ""
|
||||||
print_warning "Next steps:"
|
print_warning "Next steps:"
|
||||||
|
|||||||
+2
-2
@@ -301,7 +301,7 @@ services:
|
|||||||
context: ./src/summary
|
context: ./src/summary
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
target: production
|
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_file:
|
||||||
- env.d/development/summary
|
- env.d/development/summary
|
||||||
volumes:
|
volumes:
|
||||||
@@ -321,7 +321,7 @@ services:
|
|||||||
context: ./src/summary
|
context: ./src/summary
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
target: production
|
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_file:
|
||||||
- env.d/development/summary
|
- env.d/development/summary
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# ---- Front-end image ----
|
# ---- Front-end image ----
|
||||||
FROM node:20-alpine AS frontend-deps
|
FROM node:22-alpine AS frontend-deps
|
||||||
|
|
||||||
WORKDIR /home/frontend/
|
WORKDIR /home/frontend/
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ RUN npx webpack --mode production
|
|||||||
|
|
||||||
|
|
||||||
# ---- Front-end image ----
|
# ---- Front-end image ----
|
||||||
FROM nginxinc/nginx-unprivileged:alpine3.23 AS frontend-production
|
FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
|
|
||||||
|
|||||||
@@ -344,6 +344,7 @@ These are the environmental options available on meet backend.
|
|||||||
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
|
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
|
||||||
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
|
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
|
||||||
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
|
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
|
||||||
|
| FRONTEND_DOCUMENTATION_URL | URL of the documentation opened from the room options menu. If unset, the documentation menu item is hidden | |
|
||||||
| FRONTEND_USE_FRENCH_GOV_FOOTER | Show the French government footer in the homepage | false |
|
| FRONTEND_USE_FRENCH_GOV_FOOTER | Show the French government footer in the homepage | false |
|
||||||
| FRONTEND_USE_PROCONNECT_BUTTON | Show a "Login with ProConnect" button in the homepage instead of a "Login" button | false |
|
| FRONTEND_USE_PROCONNECT_BUTTON | Show a "Login with ProConnect" button in the homepage instead of a "Login" button | false |
|
||||||
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
|
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
|
||||||
|
|||||||
+4
-4
@@ -11,14 +11,14 @@ There are two ways to customize LaSuite Meet:
|
|||||||
|
|
||||||
### How to Use
|
### How to Use
|
||||||
|
|
||||||
To use this feature, simply set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. For example:
|
To use this feature, simply set the `FRONTEND_CUSTOM_CSS_URL` environment variable (of the **backend** service) to the URL of your custom CSS file. For example:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
FRONTEND_CSS_URL=https://example.com/custom-style.css
|
FRONTEND_CUSTOM_CSS_URL=https://example.com/custom-style.css
|
||||||
```
|
```
|
||||||
|
|
||||||
> [!TIP]
|
> [!TIP]
|
||||||
> If you serve your CSS file on the same domain as LaSuite Meet, paths are supported, i.e. `FRONTEND_CSS_URL=/custom/style.css` will load `https://your-domain.com/custom/style.css`.
|
> If you serve your CSS file on the same domain as LaSuite Meet, paths are supported, i.e. `FRONTEND_CUSTOM_CSS_URL=/custom/style.css` will load `https://your-domain.com/custom/style.css`.
|
||||||
|
|
||||||
Setting this variable makes the app load your CSS at runtime, adding a `<link>` to `<head>` so you can override CSS variables and customize the frontend without rebuilding.
|
Setting this variable makes the app load your CSS at runtime, adding a `<link>` to `<head>` so you can override CSS variables and customize the frontend without rebuilding.
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ Let's say you want to change the font of our application to a custom font. You c
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the default font to the one you specified.
|
Then, set the `FRONTEND_CUSTOM_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the default font to the one you specified.
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> You can override any CSS token—semantic or palette. See [panda.config.ts](../src/frontend/panda.config.ts) for all defined semantic tokens.
|
> You can override any CSS token—semantic or palette. See [panda.config.ts](../src/frontend/panda.config.ts) for all defined semantic tokens.
|
||||||
|
|||||||
@@ -65,8 +65,9 @@ ALLOW_UNREGISTERED_ROOMS=False
|
|||||||
RECORDING_ENABLE=True
|
RECORDING_ENABLE=True
|
||||||
RECORDING_STORAGE_EVENT_ENABLE=True
|
RECORDING_STORAGE_EVENT_ENABLE=True
|
||||||
RECORDING_STORAGE_EVENT_TOKEN=password
|
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_API_TOKEN=password
|
||||||
|
SUMMARY_SERVICE_WEBHOOK_API_TOKEN=webhook-password
|
||||||
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
|
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
|
||||||
|
|
||||||
# Recording encoding (LiveKit Egress advanced options).
|
# Recording encoding (LiveKit Egress advanced options).
|
||||||
|
|||||||
@@ -9,3 +9,6 @@ DEEPGRAM_API_KEY=
|
|||||||
|
|
||||||
KYUTAI_STT_BASE_URL=
|
KYUTAI_STT_BASE_URL=
|
||||||
KYUTAI_API_KEY=
|
KYUTAI_API_KEY=
|
||||||
|
|
||||||
|
SENTRY_DSN=
|
||||||
|
SENTRY_ENVIRONMENT=
|
||||||
|
|||||||
@@ -30,3 +30,4 @@ POSTHOG_ENABLED="False"
|
|||||||
|
|
||||||
# Transcription
|
# Transcription
|
||||||
TRANSCRIPTION_SATISFACTION_FORM_BASE_URL=
|
TRANSCRIPTION_SATISFACTION_FORM_BASE_URL=
|
||||||
|
AUTHORIZED_TENANTS='[{"id": "meet","api_key": "password","webhook_url": "https://configure-your-url.com/api/v1.0/recordings/external-process-hook/","webhook_api_key": "webhook-password","allowed_push_to_docs": true}]'
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
publiccodeYmlVersion: 0.5.0
|
||||||
|
name: LaSuite Meet
|
||||||
|
applicationSuite: LaSuite
|
||||||
|
url: https://github.com/suitenumerique/meet
|
||||||
|
releaseDate: 2026-07-22
|
||||||
|
platforms:
|
||||||
|
- web
|
||||||
|
organisation:
|
||||||
|
name: DINUM
|
||||||
|
uri: https://numerique.gouv.fr
|
||||||
|
fundedBy:
|
||||||
|
- name: Direction interministérielle du numérique (DINUM)
|
||||||
|
uri: https://www.numerique.gouv.fr
|
||||||
|
developmentStatus: stable
|
||||||
|
softwareType: standalone/web
|
||||||
|
intendedAudience:
|
||||||
|
countries:
|
||||||
|
- FR
|
||||||
|
description:
|
||||||
|
en:
|
||||||
|
localisedName: LaSuite Meet
|
||||||
|
shortDescription: "Open Source video conference solution, based on LiveKit"
|
||||||
|
longDescription: "Open Source video conference application, based on LiveKit,
|
||||||
|
Django and React. It is the official web video conference application of
|
||||||
|
French Ministries."
|
||||||
|
features:
|
||||||
|
- Optimized for stability in large meetings (+100 p.)
|
||||||
|
- Support for multiple screen sharing streams
|
||||||
|
- Non-persistent, secure chat
|
||||||
|
- Meeting recording
|
||||||
|
- Meeting transcription & Summary
|
||||||
|
- Telephony integration
|
||||||
|
- Secure participation with robust authentication and access control
|
||||||
|
- Customizable frontend style
|
||||||
|
legal:
|
||||||
|
license: MIT
|
||||||
|
maintenance:
|
||||||
|
type: internal
|
||||||
|
contacts:
|
||||||
|
- name: "Samuel Paccoud"
|
||||||
|
email: samuel.paccoud@numerique.gouv.fr
|
||||||
|
affiliation: DINUM
|
||||||
|
- name: "Antoine Lebaud"
|
||||||
|
email: antoine.lebaud.ext@numerique.gouv.fr
|
||||||
|
affiliation: DINUM
|
||||||
|
localisation:
|
||||||
|
localisationReady: true
|
||||||
|
availableLanguages:
|
||||||
|
- fr
|
||||||
|
- de
|
||||||
|
- en
|
||||||
|
- nl
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
<?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">
|
<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>
|
<Id>a025f0f6-757a-4790-97f3-99c66c4a5795</Id>
|
||||||
<Version>0.0.2.0</Version>
|
<Version>1.0.0.0</Version>
|
||||||
<ProviderName>__APP_NAME__</ProviderName>
|
<ProviderName>__APP_NAME__</ProviderName>
|
||||||
<DefaultLocale>fr-FR</DefaultLocale>
|
<DefaultLocale>fr-FR</DefaultLocale>
|
||||||
<DisplayName DefaultValue="__APP_NAME__"/>
|
<DisplayName DefaultValue="__APP_NAME__"/>
|
||||||
@@ -205,5 +205,174 @@
|
|||||||
</bt:String>
|
</bt:String>
|
||||||
</bt:LongStrings>
|
</bt:LongStrings>
|
||||||
</Resources>
|
</Resources>
|
||||||
|
|
||||||
|
<!-- ─── V1.1 override: required for shared folder / delegate support ─── -->
|
||||||
|
<VersionOverrides xmlns="http://schemas.microsoft.com/office/mailappversionoverrides/1.1" xsi:type="VersionOverridesV1_1">
|
||||||
|
<Requirements>
|
||||||
|
<bt:Sets DefaultMinVersion="1.8">
|
||||||
|
<bt:Set Name="Mailbox"/>
|
||||||
|
</bt:Sets>
|
||||||
|
</Requirements>
|
||||||
|
<Hosts>
|
||||||
|
<Host xsi:type="MailHost">
|
||||||
|
<DesktopFormFactor>
|
||||||
|
<FunctionFile resid="Commands.Url"/>
|
||||||
|
<SupportsSharedFolders>true</SupportsSharedFolders>
|
||||||
|
|
||||||
|
<!-- ─── Mail: Read ─────────────────────────────────────────── -->
|
||||||
|
<ExtensionPoint xsi:type="MessageReadCommandSurface">
|
||||||
|
<OfficeTab id="TabDefault">
|
||||||
|
<Group id="msgReadGroup">
|
||||||
|
<Label resid="GroupLabel"/>
|
||||||
|
<Control xsi:type="Button" id="msgReadOpenPaneButton">
|
||||||
|
<Label resid="TaskpaneButton.Label"/>
|
||||||
|
<Supertip>
|
||||||
|
<Title resid="TaskpaneButton.Label"/>
|
||||||
|
<Description resid="TaskpaneButton.Tooltip"/>
|
||||||
|
</Supertip>
|
||||||
|
<Icon>
|
||||||
|
<bt:Image size="16" resid="Icon.16x16"/>
|
||||||
|
<bt:Image size="32" resid="Icon.32x32"/>
|
||||||
|
<bt:Image size="80" resid="Icon.80x80"/>
|
||||||
|
</Icon>
|
||||||
|
<Action xsi:type="ShowTaskpane">
|
||||||
|
<SourceLocation resid="Taskpane.Url"/>
|
||||||
|
</Action>
|
||||||
|
</Control>
|
||||||
|
</Group>
|
||||||
|
</OfficeTab>
|
||||||
|
</ExtensionPoint>
|
||||||
|
|
||||||
|
<!-- ─── Mail: Compose ─────────────────────────────────────── -->
|
||||||
|
<ExtensionPoint xsi:type="MessageComposeCommandSurface">
|
||||||
|
<OfficeTab id="TabDefault">
|
||||||
|
<Group id="msgComposeGroup">
|
||||||
|
<Label resid="GroupLabel"/>
|
||||||
|
<Control xsi:type="Button" id="msgComposeGenerateLinkButton">
|
||||||
|
<Label resid="GenerateLink.Label"/>
|
||||||
|
<Supertip>
|
||||||
|
<Title resid="GenerateLink.Label"/>
|
||||||
|
<Description resid="GenerateLink.Tooltip"/>
|
||||||
|
</Supertip>
|
||||||
|
<Icon>
|
||||||
|
<bt:Image size="16" resid="Icon.16x16"/>
|
||||||
|
<bt:Image size="32" resid="Icon.32x32"/>
|
||||||
|
<bt:Image size="80" resid="Icon.80x80"/>
|
||||||
|
</Icon>
|
||||||
|
<Action xsi:type="ExecuteFunction">
|
||||||
|
<FunctionName>generateMeetingLinkFromMail</FunctionName>
|
||||||
|
</Action>
|
||||||
|
</Control>
|
||||||
|
<Control xsi:type="Button" id="msgComposeOpenPaneButton">
|
||||||
|
<Label resid="TaskpaneButton.Label"/>
|
||||||
|
<Supertip>
|
||||||
|
<Title resid="TaskpaneButton.Label"/>
|
||||||
|
<Description resid="TaskpaneButton.Tooltip"/>
|
||||||
|
</Supertip>
|
||||||
|
<Icon>
|
||||||
|
<bt:Image size="16" resid="Settings.16x16"/>
|
||||||
|
<bt:Image size="32" resid="Settings.32x32"/>
|
||||||
|
<bt:Image size="80" resid="Settings.80x80"/>
|
||||||
|
</Icon>
|
||||||
|
<Action xsi:type="ShowTaskpane">
|
||||||
|
<SourceLocation resid="Taskpane.Url"/>
|
||||||
|
</Action>
|
||||||
|
</Control>
|
||||||
|
</Group>
|
||||||
|
</OfficeTab>
|
||||||
|
</ExtensionPoint>
|
||||||
|
|
||||||
|
<!-- ─── Calendar: Compose (New/Edit appointment) ──────────── -->
|
||||||
|
<ExtensionPoint xsi:type="AppointmentOrganizerCommandSurface">
|
||||||
|
<OfficeTab id="TabDefault">
|
||||||
|
<Group id="apptComposeGroup">
|
||||||
|
<Label resid="GroupLabel"/>
|
||||||
|
<Control xsi:type="Button" id="apptGenerateLinkButton">
|
||||||
|
<Label resid="GenerateLink.Label"/>
|
||||||
|
<Supertip>
|
||||||
|
<Title resid="GenerateLink.Label"/>
|
||||||
|
<Description resid="GenerateLink.Tooltip"/>
|
||||||
|
</Supertip>
|
||||||
|
<Icon>
|
||||||
|
<bt:Image size="16" resid="Icon.16x16"/>
|
||||||
|
<bt:Image size="32" resid="Icon.32x32"/>
|
||||||
|
<bt:Image size="80" resid="Icon.80x80"/>
|
||||||
|
</Icon>
|
||||||
|
<Action xsi:type="ExecuteFunction">
|
||||||
|
<FunctionName>generateMeetingLinkFromCalendar</FunctionName>
|
||||||
|
</Action>
|
||||||
|
</Control>
|
||||||
|
|
||||||
|
<Control xsi:type="Button" id="apptOpenSettingsButton">
|
||||||
|
<Label resid="OpenSettings.Label"/>
|
||||||
|
<Supertip>
|
||||||
|
<Title resid="OpenSettings.Label"/>
|
||||||
|
<Description resid="OpenSettings.Tooltip"/>
|
||||||
|
</Supertip>
|
||||||
|
<Icon>
|
||||||
|
<bt:Image size="16" resid="Settings.16x16"/>
|
||||||
|
<bt:Image size="32" resid="Settings.32x32"/>
|
||||||
|
<bt:Image size="80" resid="Settings.80x80"/>
|
||||||
|
</Icon>
|
||||||
|
<Action xsi:type="ShowTaskpane">
|
||||||
|
<SourceLocation resid="Taskpane.Url"/>
|
||||||
|
</Action>
|
||||||
|
</Control>
|
||||||
|
|
||||||
|
</Group>
|
||||||
|
</OfficeTab>
|
||||||
|
</ExtensionPoint>
|
||||||
|
|
||||||
|
</DesktopFormFactor>
|
||||||
|
</Host>
|
||||||
|
</Hosts>
|
||||||
|
<Resources>
|
||||||
|
<bt:Images>
|
||||||
|
<bt:Image id="Settings.16x16" DefaultValue="https://localhost:3000/addons/outlook/assets/settings-16.png"/>
|
||||||
|
<bt:Image id="Settings.32x32" DefaultValue="https://localhost:3000/addons/outlook/assets/settings-32.png"/>
|
||||||
|
<bt:Image id="Settings.80x80" DefaultValue="https://localhost:3000/addons/outlook/assets/settings-80.png"/>
|
||||||
|
<bt:Image id="Add.16x16" DefaultValue="https://localhost:3000/addons/outlook/assets/add-16.png"/>
|
||||||
|
<bt:Image id="Add.32x32" DefaultValue="https://localhost:3000/addons/outlook/assets/add-32.png"/>
|
||||||
|
<bt:Image id="Add.80x80" DefaultValue="https://localhost:3000/addons/outlook/assets/add-80.png"/>
|
||||||
|
<bt:Image id="Icon.16x16" DefaultValue="https://localhost:3000/addons/outlook/assets/icon-16.png"/>
|
||||||
|
<bt:Image id="Icon.32x32" DefaultValue="https://localhost:3000/addons/outlook/assets/icon-32.png"/>
|
||||||
|
<bt:Image id="Icon.80x80" DefaultValue="https://localhost:3000/addons/outlook/assets/icon-80.png"/>
|
||||||
|
</bt:Images>
|
||||||
|
<bt:Urls>
|
||||||
|
<bt:Url id="Commands.Url" DefaultValue="https://localhost:3000/addons/outlook/commands.html"/>
|
||||||
|
<bt:Url id="Taskpane.Url" DefaultValue="https://localhost:3000/addons/outlook/taskpane.html"/>
|
||||||
|
</bt:Urls>
|
||||||
|
<bt:ShortStrings>
|
||||||
|
<!-- Default (French) -->
|
||||||
|
<bt:String id="GroupLabel" DefaultValue="__APP_NAME__"/>
|
||||||
|
<bt:String id="GenerateLink.Label" DefaultValue="Ajouter un lien __APP_NAME__">
|
||||||
|
<bt:Override Locale="en-US" Value="Add a __APP_NAME__ link"/>
|
||||||
|
<bt:Override Locale="de-DE" Value="__APP_NAME__-Link hinzufügen"/>
|
||||||
|
</bt:String>
|
||||||
|
<bt:String id="TaskpaneButton.Label" DefaultValue="Ouvrir les paramètres">
|
||||||
|
<bt:Override Locale="en-US" Value="Open settings"/>
|
||||||
|
<bt:Override Locale="de-DE" Value="Einstellungen öffnen"/>
|
||||||
|
</bt:String>
|
||||||
|
<bt:String id="OpenSettings.Label" DefaultValue="Paramètres">
|
||||||
|
<bt:Override Locale="en-US" Value="Settings"/>
|
||||||
|
<bt:Override Locale="de-DE" Value="Einstellungen"/>
|
||||||
|
</bt:String>
|
||||||
|
</bt:ShortStrings>
|
||||||
|
<bt:LongStrings>
|
||||||
|
<bt:String id="GenerateLink.Tooltip" DefaultValue="Génère un lien de réunion __APP_NAME__ et l'insère dans l'événement.">
|
||||||
|
<bt:Override Locale="de-DE" Value="Generiert einen __APP_NAME__-Besprechungslink und fügt ihn in den Termin ein."/>
|
||||||
|
<bt:Override Locale="en-US" Value="Generates a __APP_NAME__ meeting link and inserts it into the item."/>
|
||||||
|
</bt:String>
|
||||||
|
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__.">
|
||||||
|
<bt:Override Locale="de-DE" Value="Öffnet die __APP_NAME__-Verbindungseinstellungen."/>
|
||||||
|
<bt:Override Locale="en-US" Value="Opens the __APP_NAME__ connection settings."/>
|
||||||
|
</bt:String>
|
||||||
|
<bt:String id="OpenSettings.Tooltip" DefaultValue="Ouvre les paramètres de connexion __APP_NAME__.">
|
||||||
|
<bt:Override Locale="de-DE" Value="Öffnet die __APP_NAME__-Verbindungseinstellungen."/>
|
||||||
|
<bt:Override Locale="en-US" Value="Opens the __APP_NAME__ connection settings."/>
|
||||||
|
</bt:String>
|
||||||
|
</bt:LongStrings>
|
||||||
|
</Resources>
|
||||||
|
</VersionOverrides>
|
||||||
</VersionOverrides>
|
</VersionOverrides>
|
||||||
</OfficeApp>
|
</OfficeApp>
|
||||||
|
|||||||
Generated
+4
-4
@@ -10,7 +10,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "3.49.0",
|
"core-js": "3.49.0",
|
||||||
"i18next": "26.3.1",
|
"i18next": "^26.3.4",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"regenerator-runtime": "0.14.1"
|
"regenerator-runtime": "0.14.1"
|
||||||
},
|
},
|
||||||
@@ -9364,9 +9364,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next": {
|
"node_modules/i18next": {
|
||||||
"version": "26.3.1",
|
"version": "26.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.4.tgz",
|
||||||
"integrity": "sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==",
|
"integrity": "sha512-pa7m0d7pBDqGHZxljT+WPFeyFgQ7P7SciPPo1tTqYuO0z4sqADYhwnBESmmGp/wEof1inwdls/k8ZgTg8rxFHA==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "3.49.0",
|
"core-js": "3.49.0",
|
||||||
"i18next": "26.3.1",
|
"i18next": "26.3.4",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"regenerator-runtime": "0.14.1"
|
"regenerator-runtime": "0.14.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -57,8 +57,7 @@
|
|||||||
data-i18n="footer.feedback"
|
data-i18n="footer.feedback"
|
||||||
></a>
|
></a>
|
||||||
<div id="footer-right">
|
<div id="footer-right">
|
||||||
<span class="version-badge">beta</span>
|
<span class="version-number">1.0.0</span>
|
||||||
<span class="version-number">0.0.2</span>
|
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ from minio import Minio
|
|||||||
from minio.error import S3Error
|
from minio.error import S3Error
|
||||||
|
|
||||||
from exceptions import MissingConfigError
|
from exceptions import MissingConfigError
|
||||||
|
from observability import configure_sentry, set_job_context
|
||||||
|
from tasks import done_callback
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
@@ -42,6 +44,7 @@ AGENT_NAME = os.getenv("METADATA_COLLECTOR_AGENT_NAME", "metadata-collector")
|
|||||||
|
|
||||||
def prewarm(proc: JobProcess):
|
def prewarm(proc: JobProcess):
|
||||||
"""Preload voice activity detection model."""
|
"""Preload voice activity detection model."""
|
||||||
|
configure_sentry(AGENT_NAME)
|
||||||
proc.userdata["vad"] = silero.VAD.load()
|
proc.userdata["vad"] = silero.VAD.load()
|
||||||
|
|
||||||
|
|
||||||
@@ -174,7 +177,13 @@ class MetadataCollector:
|
|||||||
self.on_chat_message_received(reader, participant_identity)
|
self.on_chat_message_received(reader, participant_identity)
|
||||||
)
|
)
|
||||||
self._tasks.add(task)
|
self._tasks.add(task)
|
||||||
task.add_done_callback(lambda _: self._tasks.remove(task))
|
task.add_done_callback(
|
||||||
|
done_callback(
|
||||||
|
logger,
|
||||||
|
self._tasks,
|
||||||
|
f"process chat stream from {participant_identity}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
"""Serialize collected events and upload as JSON to S3."""
|
"""Serialize collected events and upload as JSON to S3."""
|
||||||
@@ -270,16 +279,18 @@ class MetadataCollector:
|
|||||||
logger.info("Participant disconnected: %s", participant.identity)
|
logger.info("Participant disconnected: %s", participant.identity)
|
||||||
task = asyncio.create_task(self._close_session(session))
|
task = asyncio.create_task(self._close_session(session))
|
||||||
self._tasks.add(task)
|
self._tasks.add(task)
|
||||||
|
task.add_done_callback(
|
||||||
def on_close_done(_):
|
done_callback(
|
||||||
self._tasks.discard(task)
|
logger,
|
||||||
logger.info(
|
self._tasks,
|
||||||
|
f"close VAD session for {participant.identity}",
|
||||||
|
on_success=lambda _: logger.info(
|
||||||
"VAD session closed for %s (remaining sessions: %d)",
|
"VAD session closed for %s (remaining sessions: %d)",
|
||||||
participant.identity,
|
participant.identity,
|
||||||
len(self._sessions),
|
len(self._sessions),
|
||||||
|
),
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
task.add_done_callback(on_close_done)
|
|
||||||
|
|
||||||
def on_participant_name_changed(self, participant: rtc.RemoteParticipant):
|
def on_participant_name_changed(self, participant: rtc.RemoteParticipant):
|
||||||
"""Update stored participant name when it changes."""
|
"""Update stored participant name when it changes."""
|
||||||
@@ -360,6 +371,8 @@ async def handle_job_request(job_req: JobRequest) -> None:
|
|||||||
@server.rtc_session(agent_name=AGENT_NAME, on_request=handle_job_request)
|
@server.rtc_session(agent_name=AGENT_NAME, on_request=handle_job_request)
|
||||||
async def entrypoint(ctx: JobContext):
|
async def entrypoint(ctx: JobContext):
|
||||||
"""Initialize and run the metadata collector."""
|
"""Initialize and run the metadata collector."""
|
||||||
|
set_job_context(room=ctx.room.name, job_id=ctx.job.id)
|
||||||
|
|
||||||
logger.info("Starting metadata agent in room: %s", ctx.room.name)
|
logger.info("Starting metadata agent in room: %s", ctx.room.name)
|
||||||
recording_id = ctx.job.metadata
|
recording_id = ctx.job.metadata
|
||||||
metadata_collector = MetadataCollector(ctx, recording_id)
|
metadata_collector = MetadataCollector(ctx, recording_id)
|
||||||
@@ -377,4 +390,7 @@ async def entrypoint(ctx: JobContext):
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
# Initialize Sentry for the worker process. Each job runs in its own
|
||||||
|
# (forked) process and re-initializes Sentry via prewarm().
|
||||||
|
configure_sentry(AGENT_NAME)
|
||||||
cli.run_app(server)
|
cli.run_app(server)
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ from livekit.agents import (
|
|||||||
)
|
)
|
||||||
from livekit.plugins import deepgram, silero
|
from livekit.plugins import deepgram, silero
|
||||||
|
|
||||||
|
from observability import configure_sentry, set_job_context
|
||||||
|
from tasks import done_callback
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
logger = logging.getLogger("transcriber")
|
logger = logging.getLogger("transcriber")
|
||||||
@@ -99,24 +102,29 @@ class MultiUserTranscriber:
|
|||||||
logger.info(f"starting session for {participant.identity}")
|
logger.info(f"starting session for {participant.identity}")
|
||||||
task = asyncio.create_task(self._start_session(participant))
|
task = asyncio.create_task(self._start_session(participant))
|
||||||
self._tasks.add(task)
|
self._tasks.add(task)
|
||||||
|
task.add_done_callback(
|
||||||
def on_task_done(task: asyncio.Task):
|
done_callback(
|
||||||
try:
|
logger,
|
||||||
self._sessions[participant.identity] = task.result()
|
self._tasks,
|
||||||
finally:
|
f"start transcription session for {participant.identity}",
|
||||||
self._tasks.discard(task)
|
)
|
||||||
|
)
|
||||||
task.add_done_callback(on_task_done)
|
|
||||||
|
|
||||||
def on_participant_disconnected(self, participant: rtc.RemoteParticipant):
|
def on_participant_disconnected(self, participant: rtc.RemoteParticipant):
|
||||||
"""Handle participant disconnection by closing transcription session."""
|
"""Handle participant disconnection by closing transcription session."""
|
||||||
if (session := self._sessions.pop(participant.identity)) is None:
|
if (session := self._sessions.pop(participant.identity, None)) is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
logger.info(f"closing session for {participant.identity}")
|
logger.info(f"closing session for {participant.identity}")
|
||||||
task = asyncio.create_task(self._close_session(session))
|
task = asyncio.create_task(self._close_session(session))
|
||||||
self._tasks.add(task)
|
self._tasks.add(task)
|
||||||
task.add_done_callback(lambda _: self._tasks.discard(task))
|
task.add_done_callback(
|
||||||
|
done_callback(
|
||||||
|
logger,
|
||||||
|
self._tasks,
|
||||||
|
f"close transcription session for {participant.identity}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
async def _start_session(self, participant: rtc.RemoteParticipant) -> AgentSession:
|
async def _start_session(self, participant: rtc.RemoteParticipant) -> AgentSession:
|
||||||
"""Create and start transcription session for participant."""
|
"""Create and start transcription session for participant."""
|
||||||
@@ -139,6 +147,7 @@ class MultiUserTranscriber:
|
|||||||
participant_identity=participant.identity,
|
participant_identity=participant.identity,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
self._sessions[participant.identity] = session
|
||||||
return session
|
return session
|
||||||
|
|
||||||
async def _close_session(self, sess: AgentSession) -> None:
|
async def _close_session(self, sess: AgentSession) -> None:
|
||||||
@@ -149,6 +158,8 @@ class MultiUserTranscriber:
|
|||||||
|
|
||||||
async def entrypoint(ctx: JobContext):
|
async def entrypoint(ctx: JobContext):
|
||||||
"""Initialize and run the multi-user transcriber."""
|
"""Initialize and run the multi-user transcriber."""
|
||||||
|
set_job_context(room=ctx.room.name, job_id=ctx.job.id)
|
||||||
|
|
||||||
transcriber = MultiUserTranscriber(ctx)
|
transcriber = MultiUserTranscriber(ctx)
|
||||||
transcriber.start()
|
transcriber.start()
|
||||||
|
|
||||||
@@ -193,11 +204,15 @@ async def handle_transcriber_job_request(job_req: JobRequest) -> None:
|
|||||||
|
|
||||||
def prewarm(proc: JobProcess):
|
def prewarm(proc: JobProcess):
|
||||||
"""Preload voice activity detection model."""
|
"""Preload voice activity detection model."""
|
||||||
|
configure_sentry(TRANSCRIBER_AGENT_NAME)
|
||||||
if ENABLE_SILERO_VAD:
|
if ENABLE_SILERO_VAD:
|
||||||
proc.userdata["vad"] = silero.VAD.load()
|
proc.userdata["vad"] = silero.VAD.load()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
# Initialize Sentry for the worker process. Each job runs in its own
|
||||||
|
# (forked) process and re-initializes Sentry via prewarm().
|
||||||
|
configure_sentry(TRANSCRIBER_AGENT_NAME)
|
||||||
cli.run_app(
|
cli.run_app(
|
||||||
WorkerOptions(
|
WorkerOptions(
|
||||||
entrypoint_fnc=entrypoint,
|
entrypoint_fnc=entrypoint,
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Sentry helpers for the LiveKit agents."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import tomllib
|
||||||
|
from os import path
|
||||||
|
|
||||||
|
import sentry_sdk
|
||||||
|
from sentry_sdk.integrations.logging import LoggingIntegration
|
||||||
|
|
||||||
|
logger = logging.getLogger("observability")
|
||||||
|
|
||||||
|
BASE_DIR = path.dirname(path.abspath(__file__))
|
||||||
|
|
||||||
|
|
||||||
|
def get_release():
|
||||||
|
"""Get the current release of the application.
|
||||||
|
|
||||||
|
By release, we mean the ``version`` declared in ``pyproject.toml``.
|
||||||
|
If the file cannot be read or declares no version, it defaults to "NA".
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with open(path.join(BASE_DIR, "pyproject.toml"), "rb") as pyproject:
|
||||||
|
return tomllib.load(pyproject)["project"]["version"]
|
||||||
|
except (FileNotFoundError, KeyError, tomllib.TOMLDecodeError):
|
||||||
|
return "NA" # Default: not available
|
||||||
|
|
||||||
|
|
||||||
|
def configure_sentry(agent_name: str) -> None:
|
||||||
|
"""Initialize Sentry for the current agent process.
|
||||||
|
|
||||||
|
No-op if ``SENTRY_DSN`` is not configured. Otherwise (re)initializes Sentry
|
||||||
|
unconditionally so the calling process gets its own live transport.
|
||||||
|
|
||||||
|
Must be called once per process: in the worker entrypoint and again in the
|
||||||
|
per-job ``prewarm``/``setup_fnc`` hook, because LiveKit runs each job in a
|
||||||
|
forked process. A forked child inherits the parent's initialized Sentry
|
||||||
|
client but not its background transport thread (threads do not survive
|
||||||
|
``fork()``), so it must re-init to get a working transport. For that reason,
|
||||||
|
do NOT guard this with ``sentry_sdk.is_initialized()``: the child inherits it
|
||||||
|
as ``True`` and would skip init, silently dropping every event.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_name: Identifier of the agent, attached as a tag to Sentry issues
|
||||||
|
"""
|
||||||
|
# Read the DSN at call time so it picks up variables that load_dotenv()
|
||||||
|
# populated after this module was first imported.
|
||||||
|
sentry_dsn = os.getenv("SENTRY_DSN")
|
||||||
|
if not sentry_dsn:
|
||||||
|
logger.debug("SENTRY_DSN not defined for agent '%s'", agent_name)
|
||||||
|
return
|
||||||
|
|
||||||
|
sentry_sdk.init(
|
||||||
|
dsn=sentry_dsn,
|
||||||
|
environment=os.getenv("SENTRY_ENVIRONMENT"),
|
||||||
|
release=get_release(),
|
||||||
|
debug=False,
|
||||||
|
integrations=[
|
||||||
|
# Capture log records emitted at ERROR and above as Sentry events.
|
||||||
|
# This covers the agents' explicit logger.exception(...) calls as
|
||||||
|
# well as asyncio's "Exception in callback" / "Task exception was
|
||||||
|
# never retrieved" records, so unhandled task failures surface too.
|
||||||
|
LoggingIntegration(level=logging.INFO, event_level=logging.ERROR),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
sentry_sdk.set_tag("application", "agents")
|
||||||
|
sentry_sdk.set_tag("agent", agent_name)
|
||||||
|
|
||||||
|
logger.info("Sentry initialized for agent '%s' (pid %d)", agent_name, os.getpid())
|
||||||
|
|
||||||
|
|
||||||
|
def set_job_context(*, room: str | None = None, job_id: str | None = None) -> None:
|
||||||
|
"""Tag the current Sentry scope with the LiveKit job being handled.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
room: Name of the room the job is serving.
|
||||||
|
job_id: LiveKit job identifier.
|
||||||
|
"""
|
||||||
|
scope = sentry_sdk.get_current_scope()
|
||||||
|
if room is not None:
|
||||||
|
scope.set_tag("room", room)
|
||||||
|
if job_id is not None:
|
||||||
|
scope.set_tag("job_id", job_id)
|
||||||
@@ -1,21 +1,22 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.22.0"
|
version = "1.24.0"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"livekit-agents==1.5.13",
|
"livekit-agents==1.6.4",
|
||||||
"livekit-plugins-deepgram==1.5.13",
|
"livekit-plugins-deepgram==1.6.4",
|
||||||
"livekit-plugins-silero==1.5.13",
|
"livekit-plugins-silero==1.6.4",
|
||||||
"livekit-plugins-kyutai-lasuite==0.0.6",
|
"livekit-plugins-kyutai-lasuite==0.0.6",
|
||||||
"python-dotenv==1.2.2",
|
"python-dotenv==1.2.2",
|
||||||
"protobuf==6.33.6",
|
"protobuf==6.33.6",
|
||||||
"minio==7.2.20"
|
"minio==7.2.20",
|
||||||
|
"sentry-sdk==2.60.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"ruff==0.15.14",
|
"ruff==0.15.19",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Helpers for managing asyncio tasks."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def done_callback(
|
||||||
|
logger: logging.Logger,
|
||||||
|
tasks: set[asyncio.Task],
|
||||||
|
description: str,
|
||||||
|
*,
|
||||||
|
on_success: Callable[[Any], None] | None = None,
|
||||||
|
) -> Callable[[asyncio.Task], None]:
|
||||||
|
"""Build a done-callback for a background task.
|
||||||
|
|
||||||
|
Meant to be passed to `asyncio.Task.add_done_callback`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
logger: Logger used to report failures, so records keep the caller's
|
||||||
|
logger name.
|
||||||
|
tasks: Set the task was registered in; the task is discarded from it.
|
||||||
|
description: Human-readable intended action
|
||||||
|
on_success: Optional callback invoked with the task's result when it
|
||||||
|
completes without error.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A callback suitable for ``task.add_done_callback(...)``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _finalize(task: asyncio.Task) -> None:
|
||||||
|
tasks.discard(task)
|
||||||
|
if task.cancelled():
|
||||||
|
return
|
||||||
|
if (exc := task.exception()) is not None:
|
||||||
|
logger.exception("failed to %s", description, exc_info=exc)
|
||||||
|
return
|
||||||
|
if on_success is not None:
|
||||||
|
on_success(task.result())
|
||||||
|
|
||||||
|
return _finalize
|
||||||
Generated
+936
-832
File diff suppressed because it is too large
Load Diff
@@ -11,7 +11,7 @@ from core.recording.event import notification
|
|||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
from .tasks.file import process_file_deletion
|
from .tasks.file import process_file_deletion
|
||||||
from .utils import generate_download_file_url
|
from .utils import generate_download_s3_url
|
||||||
|
|
||||||
|
|
||||||
def hard_delete_file(file):
|
def hard_delete_file(file):
|
||||||
@@ -242,7 +242,7 @@ class FileAdmin(admin.ModelAdmin):
|
|||||||
"""Return a clickable preview URL for the file."""
|
"""Return a clickable preview URL for the file."""
|
||||||
if not obj.is_ready:
|
if not obj.is_ready:
|
||||||
return "-"
|
return "-"
|
||||||
url = generate_download_file_url(obj, expires_in=60 * 60)
|
url = generate_download_s3_url(obj.key, expires_in=60 * 60)
|
||||||
|
|
||||||
return format_html(
|
return format_html(
|
||||||
'<a href="{}" target="_blank" rel="noopener noreferrer">Open File</a>', url
|
'<a href="{}" target="_blank" rel="noopener noreferrer">Open File</a>', url
|
||||||
@@ -402,7 +402,14 @@ class RecordingAdmin(admin.ModelAdmin):
|
|||||||
"""Recording admin interface declaration."""
|
"""Recording admin interface declaration."""
|
||||||
|
|
||||||
inlines = (RecordingAccessInline,)
|
inlines = (RecordingAccessInline,)
|
||||||
search_fields = ["status", "=id", "worker_id", "room__slug", "=room__id"]
|
search_fields = [
|
||||||
|
"status",
|
||||||
|
"=id",
|
||||||
|
"worker_id",
|
||||||
|
"room__slug",
|
||||||
|
"=room__id",
|
||||||
|
"accesses__user__email",
|
||||||
|
]
|
||||||
list_display = (
|
list_display = (
|
||||||
"id",
|
"id",
|
||||||
"status",
|
"status",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from django.utils.module_loading import import_string
|
|||||||
|
|
||||||
from .base import AnalyticsBackend, NoOpAnalytics
|
from .base import AnalyticsBackend, NoOpAnalytics
|
||||||
from .events import AnalyticsEvent
|
from .events import AnalyticsEvent
|
||||||
|
from .user_feature_flags import UserFeatureFlag
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"get_analytics",
|
"get_analytics",
|
||||||
@@ -26,6 +27,8 @@ __all__ = [
|
|||||||
"capture",
|
"capture",
|
||||||
"AnalyticsBackend",
|
"AnalyticsBackend",
|
||||||
"AnalyticsEvent",
|
"AnalyticsEvent",
|
||||||
|
"is_user_feature_flag_enabled",
|
||||||
|
"UserFeatureFlag",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -57,3 +60,8 @@ def capture(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Record an event performed by an identified user."""
|
"""Record an event performed by an identified user."""
|
||||||
analytics_instance.capture(user, event, properties)
|
analytics_instance.capture(user, event, properties)
|
||||||
|
|
||||||
|
|
||||||
|
def is_user_feature_flag_enabled(user, feature_name: UserFeatureFlag) -> bool:
|
||||||
|
"""Check if a feature is enabled at the user level."""
|
||||||
|
return analytics_instance.is_user_feature_enabled(user, feature_name)
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
"""Analytics backend protocol and default no-op implementation."""
|
"""Analytics backend protocol and default no-op implementation."""
|
||||||
|
|
||||||
from typing import Any, Protocol
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
from ..models import User
|
from ..models import User
|
||||||
from .events import AnalyticsEvent
|
from .events import AnalyticsEvent
|
||||||
|
from .user_feature_flags import UserFeatureFlag
|
||||||
|
|
||||||
|
|
||||||
class AnalyticsBackend(Protocol):
|
class AnalyticsBackend(ABC):
|
||||||
"""
|
"""
|
||||||
Interface every analytics backend must implement.
|
Interface every analytics backend must implement.
|
||||||
|
|
||||||
@@ -17,11 +19,11 @@ class AnalyticsBackend(Protocol):
|
|||||||
ANALYTICS_BACKEND_SETTINGS = {"api_key": "...", "host": "..."}
|
ANALYTICS_BACKEND_SETTINGS = {"api_key": "...", "host": "..."}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, **kwargs: Any) -> None: ...
|
@abstractmethod
|
||||||
|
|
||||||
def identify(self, user: User, properties: dict[str, Any] | None = None) -> None:
|
def identify(self, user: User, properties: dict[str, Any] | None = None) -> None:
|
||||||
"""Associate traits (email, name, ...) with an identified user."""
|
"""Associate traits (email, name, ...) with an identified user."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
def capture(
|
def capture(
|
||||||
self,
|
self,
|
||||||
user: User,
|
user: User,
|
||||||
@@ -30,16 +32,29 @@ class AnalyticsBackend(Protocol):
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Record an event performed by an identified user."""
|
"""Record an event performed by an identified user."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
def shutdown(self) -> None:
|
def shutdown(self) -> None:
|
||||||
"""Flush pending events. Called on process exit."""
|
"""Flush pending events. Called on process exit."""
|
||||||
|
|
||||||
|
def get_user_feature_flags(
|
||||||
|
self,
|
||||||
|
user: User, # pylint: disable=unused-argument
|
||||||
|
) -> Mapping[UserFeatureFlag, bool | str | None]:
|
||||||
|
"""Return a dict of feature flags for the given user."""
|
||||||
|
# We return an empty dict here by default to avoid a breaking change
|
||||||
|
# By making this method abstract.
|
||||||
|
return {}
|
||||||
|
|
||||||
class NoOpAnalytics:
|
def is_user_feature_enabled(
|
||||||
|
self, user: User, feature_name: UserFeatureFlag
|
||||||
|
) -> bool:
|
||||||
|
"""Check if a feature is enabled at the user level."""
|
||||||
|
return self.get_user_feature_flags(user).get(feature_name, False) is True
|
||||||
|
|
||||||
|
|
||||||
|
class NoOpAnalytics(AnalyticsBackend):
|
||||||
"""Default backend: silently discards everything."""
|
"""Default backend: silently discards everything."""
|
||||||
|
|
||||||
def __init__(self, **kwargs: Any) -> None:
|
|
||||||
"""No-op: accepts and ignores any backend settings kwargs."""
|
|
||||||
|
|
||||||
def identify(self, user: User, properties=None) -> None:
|
def identify(self, user: User, properties=None) -> None:
|
||||||
"""No-op: discards identify calls."""
|
"""No-op: discards identify calls."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
"""PostHog implementation of the analytics backend protocol."""
|
"""PostHog implementation of the analytics backend protocol."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from django.core.cache import cache
|
||||||
|
|
||||||
from posthog import Posthog
|
from posthog import Posthog
|
||||||
|
|
||||||
from ..models import User
|
from ..models import User
|
||||||
|
from .base import AnalyticsBackend
|
||||||
from .events import AnalyticsEvent
|
from .events import AnalyticsEvent
|
||||||
|
from .user_feature_flags import UserFeatureFlag
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class PostHogAnalytics:
|
class PostHogAnalytics(AnalyticsBackend):
|
||||||
"""Send events to PostHog, keyed on the user's primary key (UUID)."""
|
"""Send events to PostHog, keyed on the user's primary key (UUID)."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -19,6 +23,8 @@ class PostHogAnalytics:
|
|||||||
*,
|
*,
|
||||||
api_key: str,
|
api_key: str,
|
||||||
host: str = "https://eu.i.posthog.com",
|
host: str = "https://eu.i.posthog.com",
|
||||||
|
feature_flags_cache_ttl: int = 60,
|
||||||
|
feature_flags_cache_prefix: str = "user_feature_flags:",
|
||||||
**kwargs: Any,
|
**kwargs: Any,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|
||||||
@@ -29,6 +35,8 @@ class PostHogAnalytics:
|
|||||||
host=host,
|
host=host,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
self._feature_flags_cache_ttl = feature_flags_cache_ttl
|
||||||
|
self._feature_flags_cache_prefix = feature_flags_cache_prefix
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _distinct_id(user: User) -> str | None:
|
def _distinct_id(user: User) -> str | None:
|
||||||
@@ -72,3 +80,37 @@ class PostHogAnalytics:
|
|||||||
def shutdown(self) -> None:
|
def shutdown(self) -> None:
|
||||||
"""Flush pending events. Called on process exit."""
|
"""Flush pending events. Called on process exit."""
|
||||||
self._client.shutdown()
|
self._client.shutdown()
|
||||||
|
|
||||||
|
def _fetch_user_feature_flags(
|
||||||
|
self, user: User
|
||||||
|
) -> Mapping[UserFeatureFlag, bool | str | None]:
|
||||||
|
"""Compute feature flags for a user."""
|
||||||
|
|
||||||
|
distinct_id = self._distinct_id(user)
|
||||||
|
if distinct_id is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
flags = self._client.evaluate_flags(distinct_id)
|
||||||
|
out: dict[UserFeatureFlag, bool | str | None] = {}
|
||||||
|
for flag_key in UserFeatureFlag:
|
||||||
|
out[flag_key] = flags.get_flag(flag_key.value)
|
||||||
|
|
||||||
|
return out
|
||||||
|
|
||||||
|
def get_user_feature_flags(
|
||||||
|
self, user: User
|
||||||
|
) -> Mapping[UserFeatureFlag, bool | str | None]:
|
||||||
|
"""Get feature flags for a user. Caches the result for a short time."""
|
||||||
|
distinct_id = self._distinct_id(user)
|
||||||
|
if distinct_id is None:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
return cache.get_or_set(
|
||||||
|
f"{self._feature_flags_cache_prefix}{distinct_id}",
|
||||||
|
default=lambda: self._fetch_user_feature_flags(user),
|
||||||
|
timeout=self._feature_flags_cache_ttl,
|
||||||
|
)
|
||||||
|
except Exception: # pylint: disable=broad-exception-caught
|
||||||
|
logger.exception("Failed to get feature flags for user %s", user.pk)
|
||||||
|
return {}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""Catalog of all analytics feature flags used by the backend."""
|
||||||
|
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class UserFeatureFlag(StrEnum):
|
||||||
|
"""All feature flags configured in the app."""
|
||||||
|
|
||||||
|
TRANSCRIPT_SUMMARY_ENABLED = "summary-enabled"
|
||||||
@@ -68,6 +68,9 @@ def get_frontend_configuration(request):
|
|||||||
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
|
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
|
||||||
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
||||||
},
|
},
|
||||||
|
"authenticated_users_can_edit_display_name": (
|
||||||
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
|
||||||
|
),
|
||||||
}
|
}
|
||||||
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
||||||
return Response(frontend_configuration)
|
return Response(frontend_configuration)
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Connection test API endpoint."""
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from rest_framework.decorators import api_view, throttle_classes
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from core.api.throttling import (
|
||||||
|
ConnectionTestAnonRateThrottle,
|
||||||
|
ConnectionTestUserRateThrottle,
|
||||||
|
)
|
||||||
|
from core.tasks.connection_test import delete_connection_test_room
|
||||||
|
from core.utils import generate_token
|
||||||
|
|
||||||
|
CONNECTION_TEST_USERNAME = "Test connexion"
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(["GET"])
|
||||||
|
@throttle_classes([ConnectionTestUserRateThrottle, ConnectionTestAnonRateThrottle])
|
||||||
|
def get_connection_test_config(request):
|
||||||
|
"""Return a short-lived LiveKit token for an ephemeral connection test room."""
|
||||||
|
room = f"{settings.CONNECTION_TEST_ROOM_PREFIX}{uuid4()}"
|
||||||
|
expires_in = settings.CONNECTION_TEST_TOKEN_TTL_SECONDS
|
||||||
|
|
||||||
|
# LiveKit refreshes tokens for connected clients, so JWT TTL alone does not
|
||||||
|
# eject someone who stays connected. Schedule a hard DeleteRoom when Celery
|
||||||
|
# is available.
|
||||||
|
if settings.CELERY_ENABLED:
|
||||||
|
delete_connection_test_room.apply_async(
|
||||||
|
args=[room],
|
||||||
|
countdown=settings.CONNECTION_TEST_ROOM_MAX_AGE_SECONDS,
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
"livekit": {
|
||||||
|
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||||
|
"room": room,
|
||||||
|
"token": generate_token(
|
||||||
|
room=room,
|
||||||
|
user=request.user,
|
||||||
|
username=CONNECTION_TEST_USERNAME,
|
||||||
|
ttl=timedelta(seconds=expires_in),
|
||||||
|
),
|
||||||
|
"expires_in": expires_in,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -6,6 +6,11 @@ from django.http import Http404
|
|||||||
from rest_framework import permissions
|
from rest_framework import permissions
|
||||||
|
|
||||||
from ..models import RoleChoices
|
from ..models import RoleChoices
|
||||||
|
from ..services.participants_management import (
|
||||||
|
ParticipantNotFoundException,
|
||||||
|
ParticipantsManagement,
|
||||||
|
ParticipantsManagementException,
|
||||||
|
)
|
||||||
|
|
||||||
ACTION_FOR_METHOD_TO_PERMISSION = {
|
ACTION_FOR_METHOD_TO_PERMISSION = {
|
||||||
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
|
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
|
||||||
@@ -166,3 +171,30 @@ class CanMuteParticipant(permissions.BasePermission):
|
|||||||
|
|
||||||
# LiveKit token scoped to this room
|
# LiveKit token scoped to this room
|
||||||
return request.auth.video.room == str(obj.id)
|
return request.auth.video.room == str(obj.id)
|
||||||
|
|
||||||
|
|
||||||
|
class IsPresentInMeeting(permissions.BasePermission):
|
||||||
|
"""Check that the requesting user is currently connected to the meeting.
|
||||||
|
|
||||||
|
The requester must be session-authenticated (their DB identity is needed
|
||||||
|
to check privileges); presence is verified against LiveKit using their
|
||||||
|
`sub` as participant identity. Fails closed on LiveKit errors.
|
||||||
|
"""
|
||||||
|
|
||||||
|
message = "You must be connected to the meeting to perform this action."
|
||||||
|
|
||||||
|
def has_object_permission(self, request, view, obj):
|
||||||
|
"""Verify the requester's identity is a participant of the room."""
|
||||||
|
user = request.user
|
||||||
|
|
||||||
|
if not user or not user.is_authenticated:
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
return ParticipantsManagement().check_if_in_meeting(
|
||||||
|
room_name=str(obj.pk), identity=str(user.sub)
|
||||||
|
)
|
||||||
|
except ParticipantNotFoundException:
|
||||||
|
return False
|
||||||
|
except ParticipantsManagementException:
|
||||||
|
return False
|
||||||
|
|||||||
@@ -183,13 +183,11 @@ class RoomSerializer(serializers.ModelSerializer):
|
|||||||
user=request.user,
|
user=request.user,
|
||||||
username=username,
|
username=username,
|
||||||
configuration=output["configuration"],
|
configuration=output["configuration"],
|
||||||
is_admin_or_owner=is_admin_or_owner,
|
role=role,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
del output["pin_code"]
|
del output["pin_code"]
|
||||||
|
|
||||||
output["is_administrable"] = is_admin_or_owner
|
|
||||||
|
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
|
||||||
@@ -299,8 +297,8 @@ class RoomInviteSerializer(serializers.Serializer):
|
|||||||
class BaseParticipantsManagementSerializer(BaseValidationOnlySerializer):
|
class BaseParticipantsManagementSerializer(BaseValidationOnlySerializer):
|
||||||
"""Base serializer for participant management operations."""
|
"""Base serializer for participant management operations."""
|
||||||
|
|
||||||
participant_identity = serializers.UUIDField(
|
participant_identity = serializers.CharField(
|
||||||
help_text="LiveKit participant identity (UUID format)"
|
help_text="LiveKit participant identity (matching the user's sub format)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -312,6 +310,15 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ParticipantRoleSerializer(BaseParticipantsManagementSerializer):
|
||||||
|
"""Validate an in-meeting role change (promotion/demotion) request."""
|
||||||
|
|
||||||
|
role = serializers.ChoiceField(
|
||||||
|
choices=[models.RoleChoices.MEMBER, models.RoleChoices.ADMIN],
|
||||||
|
help_text="Target role. Ownership cannot be granted this way.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
TrackSource = Literal["camera", "microphone", "screen_share", "screen_share_audio"]
|
TrackSource = Literal["camera", "microphone", "screen_share", "screen_share_audio"]
|
||||||
|
|
||||||
|
|
||||||
@@ -563,3 +570,13 @@ class RenameParticipantSerializer(BaseValidationOnlySerializer):
|
|||||||
"""Serializer for renaming a participant in a room."""
|
"""Serializer for renaming a participant in a room."""
|
||||||
|
|
||||||
name = serializers.CharField(min_length=1, max_length=255, allow_blank=False)
|
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)
|
||||||
|
|||||||
@@ -73,3 +73,15 @@ class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
|
|||||||
"""Throttle Anonymous user requesting room generation callback"""
|
"""Throttle Anonymous user requesting room generation callback"""
|
||||||
|
|
||||||
scope = "creation_callback"
|
scope = "creation_callback"
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionTestUserRateThrottle(MonitoredUserRateThrottle):
|
||||||
|
"""Throttle authenticated users requesting connection test tokens."""
|
||||||
|
|
||||||
|
scope = "connection_test"
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectionTestAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||||
|
"""Throttle anonymous users requesting connection test tokens."""
|
||||||
|
|
||||||
|
scope = "connection_test"
|
||||||
|
|||||||
@@ -39,7 +39,10 @@ from core import analytics, enums, models, utils
|
|||||||
from core.api.filters import ListFileFilter
|
from core.api.filters import ListFileFilter
|
||||||
from core.enums import MEDIA_STORAGE_URL_PATTERN
|
from core.enums import MEDIA_STORAGE_URL_PATTERN
|
||||||
from core.recording.enums import FileExtension
|
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 (
|
from core.recording.event.exceptions import (
|
||||||
InvalidBucketError,
|
InvalidBucketError,
|
||||||
InvalidFilepathError,
|
InvalidFilepathError,
|
||||||
@@ -85,10 +88,15 @@ from core.services.room_management import (
|
|||||||
RoomManagementException,
|
RoomManagementException,
|
||||||
RoomNotFoundException,
|
RoomNotFoundException,
|
||||||
)
|
)
|
||||||
|
from core.services.room_roles import (
|
||||||
|
RoomRoleError,
|
||||||
|
RoomRoleService,
|
||||||
|
)
|
||||||
from core.services.subtitle import SubtitleException, SubtitleService
|
from core.services.subtitle import SubtitleException, SubtitleService
|
||||||
from core.tasks.file import process_file_deletion
|
from core.tasks.file import process_file_deletion
|
||||||
|
|
||||||
from ..authentication.livekit import LiveKitTokenAuthentication
|
from ..authentication.livekit import LiveKitTokenAuthentication
|
||||||
|
from ..models import RoomAccessLevel
|
||||||
from . import permissions, serializers, throttling
|
from . import permissions, serializers, throttling
|
||||||
from .feature_flag import FeatureFlag
|
from .feature_flag import FeatureFlag
|
||||||
|
|
||||||
@@ -264,6 +272,9 @@ class RoomViewSet(
|
|||||||
username = request.query_params.get("username", None)
|
username = request.query_params.get("username", None)
|
||||||
data = {
|
data = {
|
||||||
"id": None,
|
"id": None,
|
||||||
|
"slug": slug,
|
||||||
|
"is_administrable": False,
|
||||||
|
"access_level": RoomAccessLevel.PUBLIC,
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||||
"room": slug,
|
"room": slug,
|
||||||
@@ -632,6 +643,53 @@ class RoomViewSet(
|
|||||||
status=drf_status.HTTP_200_OK,
|
status=drf_status.HTTP_200_OK,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@decorators.action(
|
||||||
|
detail=True,
|
||||||
|
methods=["post"],
|
||||||
|
url_path="update-participant-role",
|
||||||
|
permission_classes=[
|
||||||
|
permissions.HasPrivilegesOnRoom,
|
||||||
|
permissions.IsPresentInMeeting,
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def update_participant_role(self, request, pk=None): # pylint: disable=unused-argument
|
||||||
|
"""Promote or demote a participant currently connected to the meeting.
|
||||||
|
|
||||||
|
Requires the requester to be session-authenticated, have privileges
|
||||||
|
(admin/owner) on the room, and be connected to the meeting.
|
||||||
|
|
||||||
|
If the target participant has a user account, the role is persisted
|
||||||
|
(`ResourceAccess`) then mirrored to their LiveKit attributes.
|
||||||
|
|
||||||
|
If the participant is anonymous, the promotion will fail.
|
||||||
|
"""
|
||||||
|
|
||||||
|
room = self.get_object()
|
||||||
|
|
||||||
|
serializer = serializers.ParticipantRoleSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
participant_identity = serializer.validated_data["participant_identity"]
|
||||||
|
role = serializer.validated_data["role"]
|
||||||
|
|
||||||
|
if str(request.user.sub) == str(participant_identity):
|
||||||
|
return drf_response.Response(
|
||||||
|
{"error": "You cannot change your own role."},
|
||||||
|
status=drf_status.HTTP_403_FORBIDDEN,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = RoomRoleService().set_participant_role(
|
||||||
|
room=room,
|
||||||
|
participant_identity=participant_identity,
|
||||||
|
role=role,
|
||||||
|
actor=request.user,
|
||||||
|
)
|
||||||
|
except RoomRoleError as e:
|
||||||
|
return drf_response.Response({"error": str(e)}, status=e.status_code)
|
||||||
|
|
||||||
|
return drf_response.Response(result, status=drf_status.HTTP_200_OK)
|
||||||
|
|
||||||
@decorators.action(
|
@decorators.action(
|
||||||
detail=True,
|
detail=True,
|
||||||
methods=["post"],
|
methods=["post"],
|
||||||
@@ -999,6 +1057,60 @@ class RecordingViewSet(
|
|||||||
{"message": "Event processed."},
|
{"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):
|
def _auth_get_original_url(self, request):
|
||||||
"""
|
"""
|
||||||
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
|
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
|
||||||
|
|||||||
@@ -215,5 +215,6 @@ class RoomViewSet(
|
|||||||
"client_id": client_id,
|
"client_id": client_id,
|
||||||
"external_api": True,
|
"external_api": True,
|
||||||
"auth_method": auth_method,
|
"auth_method": auth_method,
|
||||||
|
"$set": {"email": self.request.user.email},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
+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_START = "failed_to_start", _("Failed to Start")
|
||||||
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
|
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
|
||||||
NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded")
|
NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded")
|
||||||
|
EXTERNAL_PROCESS_SUCCESSFUL = (
|
||||||
|
"external_process_successful",
|
||||||
|
_("External process successful"),
|
||||||
|
)
|
||||||
|
EXTERNAL_PROCESS_FAILED = "external_process_failed", _("External process failed")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def is_final(cls, status):
|
def is_final(cls, status):
|
||||||
@@ -73,6 +78,8 @@ class RecordingStatusChoices(models.TextChoices):
|
|||||||
cls.STOPPED,
|
cls.STOPPED,
|
||||||
cls.SAVED,
|
cls.SAVED,
|
||||||
cls.ABORTED,
|
cls.ABORTED,
|
||||||
|
cls.EXTERNAL_PROCESS_SUCCESSFUL,
|
||||||
|
cls.EXTERNAL_PROCESS_FAILED,
|
||||||
cls.FAILED_TO_START,
|
cls.FAILED_TO_START,
|
||||||
cls.FAILED_TO_STOP,
|
cls.FAILED_TO_STOP,
|
||||||
}
|
}
|
||||||
@@ -598,6 +605,14 @@ class Recording(BaseModel):
|
|||||||
verbose_name=_("Recording options"),
|
verbose_name=_("Recording options"),
|
||||||
help_text=_("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:
|
class Meta:
|
||||||
db_table = "meet_recording"
|
db_table = "meet_recording"
|
||||||
@@ -653,6 +668,8 @@ class Recording(BaseModel):
|
|||||||
return self.status in {
|
return self.status in {
|
||||||
RecordingStatusChoices.NOTIFICATION_SUCCEEDED,
|
RecordingStatusChoices.NOTIFICATION_SUCCEEDED,
|
||||||
RecordingStatusChoices.SAVED,
|
RecordingStatusChoices.SAVED,
|
||||||
|
RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL,
|
||||||
|
RecordingStatusChoices.EXTERNAL_PROCESS_FAILED,
|
||||||
}
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ logger = logging.getLogger(__name__)
|
|||||||
class MachineUser:
|
class MachineUser:
|
||||||
"""Represent a non-interactive system user for automated storage operations."""
|
"""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.pk = None
|
||||||
self.username = "storage_event_user"
|
self.username = username
|
||||||
self.is_active = True
|
self.is_active = True
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -34,33 +34,33 @@ class MachineUser:
|
|||||||
return self.username
|
return self.username
|
||||||
|
|
||||||
|
|
||||||
class StorageEventAuthentication(BaseAuthentication):
|
class HeaderBasedAuthentication(BaseAuthentication):
|
||||||
"""Authenticate requests using a Bearer token for storage event integration.
|
"""Authenticate requests using a header with a secret key."""
|
||||||
This class validates Bearer tokens for storage events that don't map to database users.
|
|
||||||
It's designed for S3-compatible storage integrations and similar use cases.
|
|
||||||
Events are submitted when a webhook is configured on some bucket's events.
|
|
||||||
"""
|
|
||||||
|
|
||||||
AUTH_HEADER = "Authorization"
|
AUTH_HEADER = "Authorization"
|
||||||
TOKEN_TYPE = "Bearer" # noqa S105
|
TOKEN_TYPE = "Bearer" # noqa S105
|
||||||
|
REALM = ""
|
||||||
|
|
||||||
|
IS_ENFORCED_SETTINGS_KEY = None
|
||||||
|
EXPECTED_TOKEN_SETTINGS_KEY = None
|
||||||
|
|
||||||
def authenticate(self, request):
|
def authenticate(self, request):
|
||||||
"""Validate the Bearer token from the Authorization header."""
|
"""Validate the Bearer token from the Authorization header."""
|
||||||
|
|
||||||
if not settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
|
if self.IS_ENFORCED_SETTINGS_KEY is not None:
|
||||||
|
if not getattr(settings, self.IS_ENFORCED_SETTINGS_KEY):
|
||||||
return MachineUser(), None
|
return MachineUser(), None
|
||||||
|
|
||||||
required_token = settings.RECORDING_STORAGE_EVENT_TOKEN
|
if (
|
||||||
if not required_token:
|
self.EXPECTED_TOKEN_SETTINGS_KEY is None
|
||||||
if settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
|
or (required_token := getattr(settings, self.EXPECTED_TOKEN_SETTINGS_KEY))
|
||||||
|
is None
|
||||||
|
):
|
||||||
raise AuthenticationFailed(
|
raise AuthenticationFailed(
|
||||||
"Authentication is enabled but token is not configured."
|
"Authentication is enabled but token is not configured."
|
||||||
)
|
)
|
||||||
|
|
||||||
return MachineUser(), None
|
|
||||||
|
|
||||||
auth_header = request.headers.get(self.AUTH_HEADER)
|
auth_header = request.headers.get(self.AUTH_HEADER)
|
||||||
|
|
||||||
if not auth_header:
|
if not auth_header:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Authentication failed: Missing Authorization header (ip: %s)",
|
"Authentication failed: Missing Authorization header (ip: %s)",
|
||||||
@@ -68,15 +68,10 @@ class StorageEventAuthentication(BaseAuthentication):
|
|||||||
)
|
)
|
||||||
raise AuthenticationFailed("Authorization header is required")
|
raise AuthenticationFailed("Authorization header is required")
|
||||||
|
|
||||||
auth_parts = auth_header.split(" ")
|
scheme, _, token = auth_header.partition(" ")
|
||||||
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
|
if scheme.lower() != self.TOKEN_TYPE.lower() or not token.strip():
|
||||||
logger.warning(
|
raise AuthenticationFailed("Invalid authorization header format.")
|
||||||
"Authentication failed: Invalid authorization header (ip: %s)",
|
token = token.strip()
|
||||||
request.META.get("REMOTE_ADDR"),
|
|
||||||
)
|
|
||||||
raise AuthenticationFailed("Invalid authorization header.")
|
|
||||||
|
|
||||||
token = auth_parts[1]
|
|
||||||
|
|
||||||
# Use constant-time comparison to prevent timing attacks
|
# Use constant-time comparison to prevent timing attacks
|
||||||
if not secrets.compare_digest(token.encode(), required_token.encode()):
|
if not secrets.compare_digest(token.encode(), required_token.encode()):
|
||||||
@@ -90,4 +85,26 @@ class StorageEventAuthentication(BaseAuthentication):
|
|||||||
|
|
||||||
def authenticate_header(self, request):
|
def authenticate_header(self, request):
|
||||||
"""Return the WWW-Authenticate header value."""
|
"""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 logging
|
||||||
import smtplib
|
import smtplib
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.mail import send_mail
|
from django.core.mail import send_mail
|
||||||
@@ -17,6 +18,8 @@ from asgiref.sync import async_to_sync
|
|||||||
from livekit import api as livekit_api
|
from livekit import api as livekit_api
|
||||||
|
|
||||||
from core import models, utils
|
from core import models, utils
|
||||||
|
from core.analytics import UserFeatureFlag, is_user_feature_flag_enabled
|
||||||
|
from core.utils import generate_download_s3_url
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -178,8 +181,49 @@ class NotificationService:
|
|||||||
|
|
||||||
return _ns_to_utc(file_result.started_at), _ns_to_utc(file_result.ended_at)
|
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
|
@staticmethod
|
||||||
def _notify_summary_service(recording: models.Recording):
|
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."""
|
"""Notify summary service about a new recording."""
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -253,5 +297,120 @@ class NotificationService:
|
|||||||
|
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _notify_summary_service_v2(recording: models.Recording):
|
||||||
|
"""Notify summary service about a new recording."""
|
||||||
|
|
||||||
|
if (
|
||||||
|
not settings.SUMMARY_SERVICE_ENDPOINT
|
||||||
|
or not settings.SUMMARY_SERVICE_API_TOKEN
|
||||||
|
):
|
||||||
|
logger.error("Summary service not configured")
|
||||||
|
return False
|
||||||
|
|
||||||
|
owner_access = (
|
||||||
|
models.RecordingAccess.objects.select_related("user")
|
||||||
|
.filter(
|
||||||
|
role=models.RoleChoices.OWNER,
|
||||||
|
recording_id=recording.id,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
metadata_filename: None | str = None
|
||||||
|
if settings.METADATA_COLLECTOR_ENABLED and recording.options.get(
|
||||||
|
"collect_metadata", False
|
||||||
|
):
|
||||||
|
output_folder = settings.METADATA_COLLECTOR_OUTPUT_FOLDER
|
||||||
|
metadata_filename = f"{output_folder}/{recording.id}-metadata.json"
|
||||||
|
|
||||||
|
if not owner_access:
|
||||||
|
logger.error("No owner found for recording %s", recording.id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
started_at, ended_at = async_to_sync(
|
||||||
|
NotificationService._get_recording_timestamps
|
||||||
|
)(recording.worker_id)
|
||||||
|
|
||||||
|
form_base_url = settings.TRANSCRIPTION_SATISFACTION_FORM_BASE_URL
|
||||||
|
form_link = (
|
||||||
|
f"{form_base_url}?room_id={recording.room.id}"
|
||||||
|
if (form_base_url and metadata_filename is not None)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
metadata_payload = None
|
||||||
|
if started_at and ended_at and metadata_filename:
|
||||||
|
metadata_payload = {
|
||||||
|
"cloud_storage_url": generate_download_s3_url(
|
||||||
|
metadata_filename,
|
||||||
|
expires_in=settings.SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS,
|
||||||
|
override_domain=False,
|
||||||
|
),
|
||||||
|
"started_at": started_at.isoformat(),
|
||||||
|
"ended_at": ended_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"user_sub": owner_access.user.sub,
|
||||||
|
"user_email": owner_access.user.email,
|
||||||
|
"cloud_storage_url": generate_download_s3_url(
|
||||||
|
recording.key,
|
||||||
|
expires_in=settings.SUMMARY_SERVICE_CLOUD_STORAGE_SIGNED_URL_EXPIRY_SECONDS,
|
||||||
|
override_domain=False,
|
||||||
|
),
|
||||||
|
"language": recording.options.get(
|
||||||
|
"language", get_language().split("-")[0].lower()
|
||||||
|
),
|
||||||
|
"context_language": owner_access.user.language,
|
||||||
|
"push_to_docs_config": {
|
||||||
|
"user_email": owner_access.user.email,
|
||||||
|
"title": NotificationService._generate_title(
|
||||||
|
locale=owner_access.user.language
|
||||||
|
or recording.options.get("language", get_language()),
|
||||||
|
room=recording.room.name,
|
||||||
|
recording_datetime=started_at,
|
||||||
|
owner_timezone=str(owner_access.user.timezone),
|
||||||
|
),
|
||||||
|
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
|
||||||
|
"form_link": form_link,
|
||||||
|
"auto_create_summary": is_user_feature_flag_enabled(
|
||||||
|
owner_access.user, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"metadata": metadata_payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": f"Bearer {settings.SUMMARY_SERVICE_API_TOKEN}",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
settings.SUMMARY_SERVICE_ENDPOINT,
|
||||||
|
json=payload,
|
||||||
|
headers=headers,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
response_json = response.json()
|
||||||
|
# We do not require a job_id to avoid a breaking change
|
||||||
|
job_id = response_json.get("job_id")
|
||||||
|
if not isinstance(job_id, str):
|
||||||
|
raise ValueError("job_id is not a string")
|
||||||
|
|
||||||
|
recording.external_process_id = job_id
|
||||||
|
recording.save()
|
||||||
|
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
logger.exception(
|
||||||
|
"Summary service error for recording %s. URL: %s. Exception: %s",
|
||||||
|
recording.id,
|
||||||
|
settings.SUMMARY_SERVICE_ENDPOINT,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
notification_service = NotificationService()
|
notification_service = NotificationService()
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ from livekit import api
|
|||||||
from core import models, utils
|
from core import models, utils
|
||||||
from core.models import Recording
|
from core.models import Recording
|
||||||
from core.recording.event.notification import notification_service
|
from core.recording.event.notification import notification_service
|
||||||
|
from core.services.room_management import (
|
||||||
|
RoomManagement,
|
||||||
|
RoomManagementException,
|
||||||
|
RoomNotFoundException,
|
||||||
|
)
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
@@ -39,10 +44,15 @@ class RecordingEventsService:
|
|||||||
recording_status = status_mapping.get(egress_status)
|
recording_status = status_mapping.get(egress_status)
|
||||||
if recording_status:
|
if recording_status:
|
||||||
try:
|
try:
|
||||||
utils.update_room_metadata(
|
RoomManagement().update_metadata(
|
||||||
room_name, {"recording_status": recording_status}
|
room_name, {"recording_status": recording_status}
|
||||||
)
|
)
|
||||||
except utils.MetadataUpdateException as e:
|
except RoomNotFoundException:
|
||||||
|
logger.info(
|
||||||
|
"LiveKit room %s no longer exists, skipping metadata update",
|
||||||
|
room_name,
|
||||||
|
)
|
||||||
|
except RoomManagementException as e:
|
||||||
logger.exception("Failed to update room's metadata: %s", e)
|
logger.exception("Failed to update room's metadata: %s", e)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -2,8 +2,12 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from core import utils
|
|
||||||
from core.models import Recording, RecordingStatusChoices
|
from core.models import Recording, RecordingStatusChoices
|
||||||
|
from core.services.room_management import (
|
||||||
|
RoomManagement,
|
||||||
|
RoomManagementException,
|
||||||
|
RoomNotFoundException,
|
||||||
|
)
|
||||||
|
|
||||||
from .exceptions import (
|
from .exceptions import (
|
||||||
RecordingStartError,
|
RecordingStartError,
|
||||||
@@ -64,10 +68,15 @@ class WorkerServiceMediator:
|
|||||||
mode = recording.options.get("original_mode", None) or recording.mode
|
mode = recording.options.get("original_mode", None) or recording.mode
|
||||||
|
|
||||||
try:
|
try:
|
||||||
utils.update_room_metadata(
|
RoomManagement().update_metadata(
|
||||||
room_name, {"recording_mode": mode, "recording_status": "starting"}
|
room_name, {"recording_mode": mode, "recording_status": "starting"}
|
||||||
)
|
)
|
||||||
except utils.MetadataUpdateException as e:
|
except RoomNotFoundException:
|
||||||
|
logger.info(
|
||||||
|
"LiveKit room %s no longer exists, skipping metadata update",
|
||||||
|
room_name,
|
||||||
|
)
|
||||||
|
except RoomManagementException as e:
|
||||||
logger.exception("Failed to update room's metadata: %s", e)
|
logger.exception("Failed to update room's metadata: %s", e)
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from django.conf import settings
|
|||||||
|
|
||||||
from livekit import api
|
from livekit import api
|
||||||
|
|
||||||
from core import models, utils
|
from core import models
|
||||||
from core.recording.services.metadata_collector import (
|
from core.recording.services.metadata_collector import (
|
||||||
MetadataCollectorException,
|
MetadataCollectorException,
|
||||||
MetadataCollectorService,
|
MetadataCollectorService,
|
||||||
@@ -23,6 +23,11 @@ from core.recording.services.recording_events import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from .lobby import LobbyService
|
from .lobby import LobbyService
|
||||||
|
from .room_management import (
|
||||||
|
RoomManagement,
|
||||||
|
RoomManagementException,
|
||||||
|
RoomNotFoundException,
|
||||||
|
)
|
||||||
from .telephony import TelephonyException, TelephonyService
|
from .telephony import TelephonyException, TelephonyService
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
@@ -177,10 +182,15 @@ class LiveKitEventsService:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
room_name = str(recording.room.id)
|
room_name = str(recording.room.id)
|
||||||
utils.update_room_metadata(
|
RoomManagement().update_metadata(
|
||||||
room_name, {}, ["recording_mode", "recording_status"]
|
room_name, remove_keys=["recording_mode", "recording_status"]
|
||||||
)
|
)
|
||||||
except utils.MetadataUpdateException as e:
|
except RoomNotFoundException:
|
||||||
|
logger.info(
|
||||||
|
"LiveKit room %s no longer exists, skipping metadata update",
|
||||||
|
room_name,
|
||||||
|
)
|
||||||
|
except RoomManagementException as e:
|
||||||
logger.exception("Failed to update room's metadata: %s", e)
|
logger.exception("Failed to update room's metadata: %s", e)
|
||||||
|
|
||||||
if recording.options.get("metadata_collector_dispatch_id", None) is not None:
|
if recording.options.get("metadata_collector_dispatch_id", None) is not None:
|
||||||
@@ -218,9 +228,21 @@ class LiveKitEventsService:
|
|||||||
|
|
||||||
# Silently ignoring EGRESS_ABORTED, EGRESS_FAILED
|
# Silently ignoring EGRESS_ABORTED, EGRESS_FAILED
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _is_connection_test_room(room_name: str) -> bool:
|
||||||
|
"""Return True for ephemeral rooms created by the connection test endpoint."""
|
||||||
|
return room_name.startswith(settings.CONNECTION_TEST_ROOM_PREFIX)
|
||||||
|
|
||||||
def _handle_room_started(self, data):
|
def _handle_room_started(self, data):
|
||||||
"""Handle 'room_started' event."""
|
"""Handle 'room_started' event."""
|
||||||
|
|
||||||
|
if self._is_connection_test_room(data.room.name):
|
||||||
|
logger.info(
|
||||||
|
"Ignoring room_started event for connection test room '%s'.",
|
||||||
|
data.room.name,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
room_id = uuid.UUID(data.room.name)
|
room_id = uuid.UUID(data.room.name)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
@@ -246,6 +268,13 @@ class LiveKitEventsService:
|
|||||||
def _handle_room_finished(self, data):
|
def _handle_room_finished(self, data):
|
||||||
"""Handle 'room_finished' event."""
|
"""Handle 'room_finished' event."""
|
||||||
|
|
||||||
|
if self._is_connection_test_room(data.room.name):
|
||||||
|
logger.info(
|
||||||
|
"Ignoring room_finished event for connection test room '%s'.",
|
||||||
|
data.room.name,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
room_id = uuid.UUID(data.room.name)
|
room_id = uuid.UUID(data.room.name)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
|
|||||||
@@ -104,22 +104,31 @@ class LobbyService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def can_bypass_lobby(room, user) -> bool:
|
def can_bypass_lobby(room, user, role) -> bool:
|
||||||
"""Determines if a user can bypass the waiting lobby and join a room directly.
|
"""Determines if a user can bypass the waiting lobby and join a room directly.
|
||||||
|
|
||||||
A user can bypass the lobby if:
|
A user can bypass the lobby if:
|
||||||
1. The room is public (open to everyone)
|
1. The room is public (open to everyone)
|
||||||
2. The room has TRUSTED access level and the user is authenticated
|
2. The room has TRUSTED access level and the user is authenticated
|
||||||
|
2. The room has RESTRICTED access level and the user has any role
|
||||||
|
|
||||||
Note: Room access levels can change while participants are waiting in the lobby.
|
Note: Room access levels can change while participants are waiting in the lobby.
|
||||||
This function only checks the current state and should be called each time
|
This function only checks the current state and should be called each time
|
||||||
a participant requests entry to ensure consistent access control, even for
|
a participant requests entry to ensure consistent access control, even for
|
||||||
participants who have already begun waiting.
|
participants who have already begun waiting.
|
||||||
"""
|
"""
|
||||||
return room.is_public or (
|
return (
|
||||||
|
room.is_public
|
||||||
|
or (
|
||||||
room.access_level == models.RoomAccessLevel.TRUSTED
|
room.access_level == models.RoomAccessLevel.TRUSTED
|
||||||
and user.is_authenticated
|
and user.is_authenticated
|
||||||
)
|
)
|
||||||
|
or (
|
||||||
|
room.access_level == models.RoomAccessLevel.RESTRICTED
|
||||||
|
and user.is_authenticated
|
||||||
|
and role is not None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def request_entry(
|
def request_entry(
|
||||||
self,
|
self,
|
||||||
@@ -144,8 +153,9 @@ class LobbyService:
|
|||||||
participant = self._get_participant(room.id, participant_id)
|
participant = self._get_participant(room.id, participant_id)
|
||||||
|
|
||||||
room_id = str(room.id)
|
room_id = str(room.id)
|
||||||
|
user_role = room.get_role(request.user)
|
||||||
|
|
||||||
if self.can_bypass_lobby(room=room, user=request.user):
|
if self.can_bypass_lobby(room=room, user=request.user, role=user_role):
|
||||||
if participant is None:
|
if participant is None:
|
||||||
participant = LobbyParticipant(
|
participant = LobbyParticipant(
|
||||||
status=LobbyParticipantStatus.ACCEPTED,
|
status=LobbyParticipantStatus.ACCEPTED,
|
||||||
@@ -162,8 +172,8 @@ class LobbyService:
|
|||||||
username=username,
|
username=username,
|
||||||
color=participant.color,
|
color=participant.color,
|
||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
is_admin_or_owner=False,
|
|
||||||
participant_id=participant_id,
|
participant_id=participant_id,
|
||||||
|
role=user_role,
|
||||||
)
|
)
|
||||||
return participant, livekit_config
|
return participant, livekit_config
|
||||||
|
|
||||||
@@ -183,8 +193,8 @@ class LobbyService:
|
|||||||
username=username,
|
username=username,
|
||||||
color=participant.color,
|
color=participant.color,
|
||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
is_admin_or_owner=False,
|
|
||||||
participant_id=participant_id,
|
participant_id=participant_id,
|
||||||
|
role=user_role,
|
||||||
)
|
)
|
||||||
|
|
||||||
return participant, livekit_config
|
return participant, livekit_config
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from typing import Dict, Optional
|
|||||||
|
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
from livekit.api import (
|
from livekit.api import (
|
||||||
|
ListRoomsRequest,
|
||||||
TwirpError,
|
TwirpError,
|
||||||
UpdateRoomMetadataRequest,
|
UpdateRoomMetadataRequest,
|
||||||
)
|
)
|
||||||
@@ -29,20 +30,45 @@ class RoomManagement:
|
|||||||
"""Service for managing LiveKit rooms."""
|
"""Service for managing LiveKit rooms."""
|
||||||
|
|
||||||
@async_to_sync
|
@async_to_sync
|
||||||
async def update_metadata(self, room_name: str, metadata: Optional[Dict] = None):
|
async def update_metadata(
|
||||||
"""Update a LiveKit room's metadata.
|
self,
|
||||||
|
room_name: str,
|
||||||
|
metadata: Optional[Dict] = None,
|
||||||
|
remove_keys: Optional[list[str]] = None,
|
||||||
|
):
|
||||||
|
"""Merge values into a LiveKit room's metadata.
|
||||||
|
|
||||||
The `room_name` corresponds to the LiveKit room identifier
|
The `room_name` corresponds to the LiveKit room identifier
|
||||||
(i.e. the Room model's UUID as a string).
|
(i.e. the Room model's UUID as a string).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RoomNotFoundException: the room does not exist in LiveKit.
|
||||||
|
RoomManagementException: the metadata update otherwise fails.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
lkapi = utils.create_livekit_client()
|
lkapi = utils.create_livekit_client()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
response = await lkapi.room.list_rooms(ListRoomsRequest(names=[room_name]))
|
||||||
|
|
||||||
|
if not response.rooms:
|
||||||
|
logger.warning(
|
||||||
|
"Room %s not found in LiveKit, skipping metadata update",
|
||||||
|
room_name,
|
||||||
|
)
|
||||||
|
raise RoomNotFoundException("Room does not exist")
|
||||||
|
|
||||||
|
existing_metadata = json.loads(response.rooms[0].metadata or "{}")
|
||||||
|
|
||||||
|
for key in remove_keys or []:
|
||||||
|
existing_metadata.pop(key, None)
|
||||||
|
|
||||||
|
updated_metadata = {**existing_metadata, **(metadata or {})}
|
||||||
|
|
||||||
await lkapi.room.update_room_metadata(
|
await lkapi.room.update_room_metadata(
|
||||||
UpdateRoomMetadataRequest(
|
UpdateRoomMetadataRequest(
|
||||||
room=room_name,
|
room=room_name,
|
||||||
metadata=json.dumps(metadata) if metadata is not None else "",
|
metadata=json.dumps(updated_metadata),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""Room role management service.
|
||||||
|
|
||||||
|
Single entry point for changing a user's role on a room, used by:
|
||||||
|
- the in-meeting endpoint (promote/demote a connected participant)
|
||||||
|
- (more to come soon)
|
||||||
|
|
||||||
|
`ResourceAccess` is the source of truth. The LiveKit `room_role`
|
||||||
|
participant attribute is only a projection of it, synced best-effort.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from logging import getLogger
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from core import models
|
||||||
|
from core.services.participants_management import (
|
||||||
|
ParticipantNotFoundException,
|
||||||
|
ParticipantsManagement,
|
||||||
|
ParticipantsManagementException,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RoomRoleError(Exception):
|
||||||
|
"""Base exception for room role management errors."""
|
||||||
|
|
||||||
|
status_code = 400
|
||||||
|
|
||||||
|
|
||||||
|
class SelfActionError(RoomRoleError):
|
||||||
|
"""Raised when a user tries to change their own role."""
|
||||||
|
|
||||||
|
status_code = 403
|
||||||
|
|
||||||
|
|
||||||
|
class OwnerRoleError(RoomRoleError):
|
||||||
|
"""Raised when trying to demote an owner or grant ownership."""
|
||||||
|
|
||||||
|
status_code = 403
|
||||||
|
|
||||||
|
|
||||||
|
class ParticipantNotInMeetingError(RoomRoleError):
|
||||||
|
"""Raised when the target participant is not connected to the meeting."""
|
||||||
|
|
||||||
|
status_code = 404
|
||||||
|
|
||||||
|
|
||||||
|
class UserNotFoundError(RoomRoleError):
|
||||||
|
"""Raised when the target participant has no user account in database."""
|
||||||
|
|
||||||
|
status_code = 404
|
||||||
|
|
||||||
|
|
||||||
|
ASSIGNABLE_ROLES = (models.RoleChoices.MEMBER, models.RoleChoices.ADMIN)
|
||||||
|
|
||||||
|
|
||||||
|
class RoomRoleService:
|
||||||
|
"""Manage promotion and demotion of room co-hosts."""
|
||||||
|
|
||||||
|
def set_role(
|
||||||
|
self, room: models.Room, user: models.User, role: str, actor: models.User
|
||||||
|
):
|
||||||
|
"""Persist `role` for `user` on `room`, idempotently and atomically.
|
||||||
|
|
||||||
|
Returns the up-to-date `ResourceAccess`. Never grants or removes
|
||||||
|
ownership: granting OWNER is refused, and an existing OWNER access
|
||||||
|
is never modified.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if role not in ASSIGNABLE_ROLES:
|
||||||
|
raise OwnerRoleError("Ownership cannot be granted through this action.")
|
||||||
|
|
||||||
|
if actor is not None and user == actor:
|
||||||
|
raise SelfActionError("You cannot change your own role.")
|
||||||
|
|
||||||
|
access, created = models.ResourceAccess.objects.get_or_create(
|
||||||
|
resource=room,
|
||||||
|
user=user,
|
||||||
|
defaults={"role": role},
|
||||||
|
)
|
||||||
|
|
||||||
|
if created:
|
||||||
|
return access
|
||||||
|
|
||||||
|
if access.role == models.RoleChoices.OWNER:
|
||||||
|
raise OwnerRoleError("Room owners cannot be demoted.")
|
||||||
|
|
||||||
|
if access.role != role:
|
||||||
|
access.role = role
|
||||||
|
access.save(update_fields=["role", "updated_at"])
|
||||||
|
|
||||||
|
return access
|
||||||
|
|
||||||
|
def set_participant_role(
|
||||||
|
self,
|
||||||
|
room: models.Room,
|
||||||
|
participant_identity: UUID,
|
||||||
|
role: str,
|
||||||
|
actor: models.User,
|
||||||
|
):
|
||||||
|
"""Change the role of a participant currently connected to the meeting.
|
||||||
|
|
||||||
|
- The participant must be connected (checked against LiveKit).
|
||||||
|
- The participant must map to a user account.
|
||||||
|
- The role is persisted in DB then mirrored to LiveKit.
|
||||||
|
|
||||||
|
Returns a dict: {"role", "livekit_synced"}.
|
||||||
|
"""
|
||||||
|
|
||||||
|
room_name = str(room.pk)
|
||||||
|
participants_management = ParticipantsManagement()
|
||||||
|
|
||||||
|
try:
|
||||||
|
is_in_meeting = participants_management.check_if_in_meeting(
|
||||||
|
room_name=room_name, identity=str(participant_identity)
|
||||||
|
)
|
||||||
|
except ParticipantNotFoundException as e:
|
||||||
|
raise ParticipantNotInMeetingError(
|
||||||
|
"Participant is not connected to this meeting."
|
||||||
|
) from e
|
||||||
|
|
||||||
|
if not is_in_meeting:
|
||||||
|
raise ParticipantNotInMeetingError(
|
||||||
|
"Participant is not connected to this meeting."
|
||||||
|
)
|
||||||
|
|
||||||
|
user = models.User.objects.filter(sub=participant_identity).first()
|
||||||
|
|
||||||
|
if user is None:
|
||||||
|
raise UserNotFoundError(
|
||||||
|
"This participant has no user account and cannot be assigned a role."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Source of truth first: even if the LiveKit sync below fails,
|
||||||
|
# the role is real and any fresh token will carry it.
|
||||||
|
self.set_role(room=room, user=user, role=role, actor=actor)
|
||||||
|
|
||||||
|
livekit_synced = self._sync_livekit_role(
|
||||||
|
room_name=room_name,
|
||||||
|
participant_identity=str(participant_identity),
|
||||||
|
role=str(role),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"role": role,
|
||||||
|
"livekit_synced": livekit_synced,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sync_livekit_role(room_name: str, participant_identity: str, role: str):
|
||||||
|
"""Mirror the role to the participant's LiveKit attributes.
|
||||||
|
|
||||||
|
Best-effort: returns False on failure instead of raising, so callers
|
||||||
|
can report a partial success. Re-running the action re-syncs.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
ParticipantsManagement().update(
|
||||||
|
room_name=room_name,
|
||||||
|
identity=participant_identity,
|
||||||
|
attributes={"room_role": role},
|
||||||
|
)
|
||||||
|
except ParticipantNotFoundException:
|
||||||
|
# The participant left between the presence check and the update:
|
||||||
|
# harmless, the DB state (if any) remains authoritative.
|
||||||
|
logger.info(
|
||||||
|
"Participant %s left room %s before role sync",
|
||||||
|
participant_identity,
|
||||||
|
room_name,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
except ParticipantsManagementException:
|
||||||
|
logger.exception(
|
||||||
|
"Could not sync role to LiveKit for participant %s in room %s",
|
||||||
|
participant_identity,
|
||||||
|
room_name,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
"""Celery tasks for the core app."""
|
||||||
|
|
||||||
|
from core.tasks.connection_test import delete_connection_test_room
|
||||||
|
from core.tasks.file import process_file_deletion
|
||||||
|
|
||||||
|
__all__ = (
|
||||||
|
"delete_connection_test_room",
|
||||||
|
"process_file_deletion",
|
||||||
|
)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Tasks related to connection test rooms."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from asgiref.sync import async_to_sync
|
||||||
|
from livekit.api import ( # pylint: disable=no-name-in-module
|
||||||
|
DeleteRoomRequest,
|
||||||
|
TwirpError,
|
||||||
|
)
|
||||||
|
|
||||||
|
from core.tasks._task import task
|
||||||
|
from core.utils import create_livekit_client
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@task
|
||||||
|
def delete_connection_test_room(room_name: str):
|
||||||
|
"""Force-delete an ephemeral connection-test room.
|
||||||
|
|
||||||
|
Used as a hard cap so a participant cannot keep an auto-refreshed
|
||||||
|
LiveKit session open indefinitely after requesting a test token.
|
||||||
|
"""
|
||||||
|
prefix = settings.CONNECTION_TEST_ROOM_PREFIX
|
||||||
|
if not room_name.startswith(prefix):
|
||||||
|
logger.error(
|
||||||
|
"Refusing to delete room '%s': expected prefix '%s'.",
|
||||||
|
room_name,
|
||||||
|
prefix,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
async_to_sync(_delete_room)(room_name)
|
||||||
|
|
||||||
|
|
||||||
|
async def _delete_room(room_name: str):
|
||||||
|
lkapi = create_livekit_client()
|
||||||
|
try:
|
||||||
|
await lkapi.room.delete_room(DeleteRoomRequest(room=room_name))
|
||||||
|
logger.info("Deleted connection test room '%s'.", room_name)
|
||||||
|
except TwirpError as exc:
|
||||||
|
# Room may already be gone after empty/departure timeout.
|
||||||
|
logger.info(
|
||||||
|
"Could not delete connection test room '%s': %s",
|
||||||
|
room_name,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await lkapi.aclose()
|
||||||
@@ -12,6 +12,7 @@ import pytest
|
|||||||
|
|
||||||
from core.analytics.events import AnalyticsEvent
|
from core.analytics.events import AnalyticsEvent
|
||||||
from core.analytics.posthog import PostHogAnalytics
|
from core.analytics.posthog import PostHogAnalytics
|
||||||
|
from core.analytics.user_feature_flags import UserFeatureFlag
|
||||||
from core.factories import UserFactory
|
from core.factories import UserFactory
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
@@ -248,6 +249,60 @@ def test_capture_logs_the_failing_event_name_on_exception(mock_posthog_cls, capl
|
|||||||
assert any("PostHog capture failed" in record.message for record in caplog.records)
|
assert any("PostHog capture failed" in record.message for record in caplog.records)
|
||||||
|
|
||||||
|
|
||||||
|
# ==============================
|
||||||
|
# feature flags
|
||||||
|
# ==============================
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_compute_feature_flags_returns_all_catalog_entries(mock_posthog_cls):
|
||||||
|
"""Should map every declared feature flag key to the SDK evaluated value."""
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
mock_posthog_cls.return_value.evaluate_flags.return_value.get_flag.return_value = (
|
||||||
|
True
|
||||||
|
)
|
||||||
|
|
||||||
|
flags = backend._fetch_user_feature_flags(user)
|
||||||
|
|
||||||
|
assert flags == {UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED: True}
|
||||||
|
mock_posthog_cls.return_value.evaluate_flags.assert_called_once_with(str(user.pk))
|
||||||
|
mock_posthog_cls.return_value.evaluate_flags.return_value.get_flag.assert_called_once_with(
|
||||||
|
UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED.value
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.cache.get_or_set")
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_get_feature_flags_uses_cache_get_or_set(
|
||||||
|
mock_posthog_cls, mock_cache_get_or_set
|
||||||
|
):
|
||||||
|
"""Should cache feature flags by user distinct id with configured TTL."""
|
||||||
|
cached_flags = {UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED: False}
|
||||||
|
mock_cache_get_or_set.return_value = cached_flags
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key", feature_flags_cache_ttl=120)
|
||||||
|
user = UserFactory()
|
||||||
|
|
||||||
|
flags = backend.get_user_feature_flags(user)
|
||||||
|
|
||||||
|
assert flags == cached_flags
|
||||||
|
mock_cache_get_or_set.assert_called_once()
|
||||||
|
args, kwargs = mock_cache_get_or_set.call_args
|
||||||
|
assert kwargs["timeout"] == 120
|
||||||
|
assert args[0] == f"user_feature_flags:{user.pk}"
|
||||||
|
assert callable(kwargs["default"])
|
||||||
|
|
||||||
|
|
||||||
|
@patch("core.analytics.posthog.Posthog")
|
||||||
|
def test_get_feature_flags_returns_empty_dict_on_exception(mock_posthog_cls):
|
||||||
|
"""Should swallow failures and return an empty mapping."""
|
||||||
|
backend = PostHogAnalytics(api_key="test-api-key")
|
||||||
|
user = UserFactory()
|
||||||
|
with patch("core.analytics.posthog.cache.get_or_set", side_effect=RuntimeError):
|
||||||
|
assert backend.get_user_feature_flags(user) == {}
|
||||||
|
|
||||||
|
|
||||||
# ==============================
|
# ==============================
|
||||||
# shutdown
|
# shutdown
|
||||||
# ==============================
|
# ==============================
|
||||||
|
|||||||
@@ -136,10 +136,10 @@ def test_authenticate_header():
|
|||||||
|
|
||||||
|
|
||||||
def test_multiple_spaces_in_auth_header(settings):
|
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"
|
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
||||||
request = RequestFactory().get("/")
|
request = RequestFactory().get("/")
|
||||||
request.headers = {"Authorization": "Bearer extra-spaces-token"}
|
request.headers = {"Authorization": "Bearer extra-spaces-token"}
|
||||||
|
|
||||||
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
|
header = StorageEventAuthentication().authenticate_header(request)
|
||||||
StorageEventAuthentication().authenticate(request)
|
assert header == "Bearer realm='Storage event API'"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from django.contrib.sites.models import Site
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from core import factories, models
|
from core import factories, models
|
||||||
|
from core.analytics import UserFeatureFlag
|
||||||
from core.recording.event.notification import NotificationService, notification_service
|
from core.recording.event.notification import NotificationService, notification_service
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
@@ -243,3 +244,177 @@ def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
|
|||||||
assert result is False
|
assert result is False
|
||||||
assert mock_send_mail.call_count == 2
|
assert mock_send_mail.call_count == 2
|
||||||
assert "notification could not be sent:" in caplog.text
|
assert "notification could not be sent:" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.recording.event.notification.requests.post")
|
||||||
|
@mock.patch("core.recording.event.notification.generate_download_s3_url")
|
||||||
|
@mock.patch.object(
|
||||||
|
NotificationService, "_get_recording_timestamps", new_callable=mock.AsyncMock
|
||||||
|
)
|
||||||
|
def test_notify_summary_service_post_args_with_metadata(
|
||||||
|
mock_get_recording_timestamps,
|
||||||
|
mock_generate_download_s3_url,
|
||||||
|
mock_post,
|
||||||
|
settings,
|
||||||
|
):
|
||||||
|
"""Test summary notification computed request args when metadata is enabled."""
|
||||||
|
settings.SUMMARY_SERVICE_VERSION = 2
|
||||||
|
settings.SUMMARY_SERVICE_ENDPOINT = "https://summary.test/api/v2/tasks"
|
||||||
|
settings.SUMMARY_SERVICE_API_TOKEN = "summary-token"
|
||||||
|
settings.RECORDING_DOWNLOAD_BASE_URL = "https://app.test/recordings"
|
||||||
|
settings.SCREEN_RECORDING_BASE_URL = None
|
||||||
|
settings.METADATA_COLLECTOR_ENABLED = True
|
||||||
|
settings.METADATA_COLLECTOR_OUTPUT_FOLDER = "recordings-metadata"
|
||||||
|
|
||||||
|
recording = factories.RecordingFactory(
|
||||||
|
room__name="Engineering Sync",
|
||||||
|
worker_id="egress-1",
|
||||||
|
options={"collect_metadata": True, "language": "en-us"},
|
||||||
|
)
|
||||||
|
owner = factories.UserFactory(
|
||||||
|
email="owner@test.com",
|
||||||
|
sub="owner-sub",
|
||||||
|
language="fr-fr",
|
||||||
|
timezone="Europe/Paris",
|
||||||
|
)
|
||||||
|
factories.UserRecordingAccessFactory(
|
||||||
|
recording=recording, role=models.RoleChoices.OWNER, user=owner
|
||||||
|
)
|
||||||
|
|
||||||
|
started_at = datetime.datetime(2026, 1, 2, 10, 30, tzinfo=datetime.timezone.utc)
|
||||||
|
ended_at = datetime.datetime(2026, 1, 2, 11, 45, tzinfo=datetime.timezone.utc)
|
||||||
|
mock_get_recording_timestamps.return_value = (started_at, ended_at)
|
||||||
|
mock_generate_download_s3_url.side_effect = [
|
||||||
|
"https://storage.test/metadata.json",
|
||||||
|
"https://storage.test/recording.ogg",
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_response = mock.Mock()
|
||||||
|
mock_response.raise_for_status.return_value = None
|
||||||
|
mock_response.json.return_value = {"job_id": "job-42"}
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
result = NotificationService._notify_summary_service(recording)
|
||||||
|
recording.refresh_from_db()
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
assert recording.external_process_id == "job-42"
|
||||||
|
|
||||||
|
metadata_filename = (
|
||||||
|
f"{settings.METADATA_COLLECTOR_OUTPUT_FOLDER}/{recording.id}-metadata.json"
|
||||||
|
)
|
||||||
|
expected_payload = {
|
||||||
|
"user_sub": owner.sub,
|
||||||
|
"user_email": owner.email,
|
||||||
|
"cloud_storage_url": "https://storage.test/recording.ogg",
|
||||||
|
"language": "en-us",
|
||||||
|
"context_language": owner.language,
|
||||||
|
"push_to_docs_config": {
|
||||||
|
"user_email": owner.email,
|
||||||
|
"title": 'Réunion "Engineering Sync" du 2026-01-02 à 11:30',
|
||||||
|
"download_link": f"{settings.RECORDING_DOWNLOAD_BASE_URL}/{recording.id}",
|
||||||
|
"auto_create_summary": False,
|
||||||
|
"form_link": None,
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"cloud_storage_url": "https://storage.test/metadata.json",
|
||||||
|
"started_at": started_at.isoformat(),
|
||||||
|
"ended_at": ended_at.isoformat(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
expected_headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": "Bearer summary-token",
|
||||||
|
}
|
||||||
|
mock_post.assert_called_once_with(
|
||||||
|
"https://summary.test/api/v2/tasks",
|
||||||
|
json=expected_payload,
|
||||||
|
headers=expected_headers,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
assert mock_generate_download_s3_url.call_args_list == [
|
||||||
|
mock.call(metadata_filename, expires_in=60 * 60 * 24, override_domain=False),
|
||||||
|
mock.call(recording.key, expires_in=60 * 60 * 24, override_domain=False),
|
||||||
|
]
|
||||||
|
mock_get_recording_timestamps.assert_awaited_once_with("egress-1")
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.recording.event.notification.requests.post")
|
||||||
|
@mock.patch("core.recording.event.notification.generate_download_s3_url")
|
||||||
|
@mock.patch.object(
|
||||||
|
NotificationService, "_get_recording_timestamps", new_callable=mock.AsyncMock
|
||||||
|
)
|
||||||
|
@pytest.mark.parametrize("auto_create_summary_enabled", [False, True])
|
||||||
|
def test_notify_summary_service_post_args_without_metadata(
|
||||||
|
mock_get_recording_timestamps,
|
||||||
|
mock_generate_download_s3_url,
|
||||||
|
mock_post,
|
||||||
|
auto_create_summary_enabled,
|
||||||
|
settings,
|
||||||
|
):
|
||||||
|
"""Test summary notification computed request args when metadata is not available."""
|
||||||
|
settings.SUMMARY_SERVICE_VERSION = 2
|
||||||
|
settings.SUMMARY_SERVICE_ENDPOINT = "https://summary.test/api/v2/tasks"
|
||||||
|
settings.SUMMARY_SERVICE_API_TOKEN = "summary-token"
|
||||||
|
settings.RECORDING_DOWNLOAD_BASE_URL = "https://app.test/recordings"
|
||||||
|
settings.SCREEN_RECORDING_BASE_URL = None
|
||||||
|
settings.METADATA_COLLECTOR_ENABLED = False
|
||||||
|
|
||||||
|
recording = factories.RecordingFactory(room__name="Daily")
|
||||||
|
owner = factories.UserFactory(
|
||||||
|
email="owner@test.com",
|
||||||
|
sub="owner-sub",
|
||||||
|
language="en-us",
|
||||||
|
timezone="UTC",
|
||||||
|
)
|
||||||
|
factories.UserRecordingAccessFactory(
|
||||||
|
recording=recording, role=models.RoleChoices.OWNER, user=owner
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_get_recording_timestamps.return_value = (None, None)
|
||||||
|
mock_generate_download_s3_url.return_value = "https://storage.test/recording.mp4"
|
||||||
|
|
||||||
|
mock_response = mock.Mock()
|
||||||
|
mock_response.raise_for_status.return_value = None
|
||||||
|
mock_response.json.return_value = {"job_id": "job-51"}
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"core.recording.event.notification.is_user_feature_flag_enabled",
|
||||||
|
return_value=auto_create_summary_enabled,
|
||||||
|
) as mock_is_feature_flag_enabled:
|
||||||
|
result = NotificationService._notify_summary_service(recording)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
|
expected_payload = {
|
||||||
|
"user_sub": owner.sub,
|
||||||
|
"user_email": owner.email,
|
||||||
|
"cloud_storage_url": "https://storage.test/recording.mp4",
|
||||||
|
"language": "en",
|
||||||
|
"context_language": owner.language,
|
||||||
|
"push_to_docs_config": {
|
||||||
|
"user_email": owner.email,
|
||||||
|
"title": "Transcription",
|
||||||
|
"download_link": f"{settings.RECORDING_DOWNLOAD_BASE_URL}/{recording.id}",
|
||||||
|
"auto_create_summary": auto_create_summary_enabled,
|
||||||
|
"form_link": None,
|
||||||
|
},
|
||||||
|
"metadata": None,
|
||||||
|
}
|
||||||
|
expected_headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": "Bearer summary-token",
|
||||||
|
}
|
||||||
|
mock_post.assert_called_once_with(
|
||||||
|
"https://summary.test/api/v2/tasks",
|
||||||
|
json=expected_payload,
|
||||||
|
headers=expected_headers,
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
mock_generate_download_s3_url.assert_called_once_with(
|
||||||
|
recording.key, expires_in=60 * 60 * 24, override_domain=False
|
||||||
|
)
|
||||||
|
mock_get_recording_timestamps.assert_awaited_once_with(recording.worker_id)
|
||||||
|
mock_is_feature_flag_enabled.assert_called_once_with(
|
||||||
|
owner, UserFeatureFlag.TRANSCRIPT_SUMMARY_ENABLED
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -34,10 +34,8 @@ def mediator(mock_worker_service):
|
|||||||
return WorkerServiceMediator(mock_worker_service)
|
return WorkerServiceMediator(mock_worker_service)
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.update_room_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_start_recording_success(
|
def test_start_recording_success(mock_update_metadata, mediator, mock_worker_service):
|
||||||
mock_update_room_metadata, mediator, mock_worker_service
|
|
||||||
):
|
|
||||||
"""Test successful recording start"""
|
"""Test successful recording start"""
|
||||||
# Setup
|
# Setup
|
||||||
worker_id = "test-worker-123"
|
worker_id = "test-worker-123"
|
||||||
@@ -60,7 +58,7 @@ def test_start_recording_success(
|
|||||||
assert mock_recording.worker_id == worker_id
|
assert mock_recording.worker_id == worker_id
|
||||||
assert mock_recording.status == RecordingStatusChoices.ACTIVE
|
assert mock_recording.status == RecordingStatusChoices.ACTIVE
|
||||||
|
|
||||||
mock_update_room_metadata.assert_called_once_with(
|
mock_update_metadata.assert_called_once_with(
|
||||||
str(mock_recording.room.id),
|
str(mock_recording.room.id),
|
||||||
{"recording_mode": mock_recording.mode, "recording_status": "starting"},
|
{"recording_mode": mock_recording.mode, "recording_status": "starting"},
|
||||||
)
|
)
|
||||||
@@ -69,9 +67,9 @@ def test_start_recording_success(
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
|
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
|
||||||
)
|
)
|
||||||
@mock.patch("core.utils.update_room_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_mediator_start_recording_worker_errors(
|
def test_mediator_start_recording_worker_errors(
|
||||||
mock_update_room_metadata, mediator, mock_worker_service, error_class
|
mock_update_metadata, mediator, mock_worker_service, error_class
|
||||||
):
|
):
|
||||||
"""Test handling of various worker errors during start"""
|
"""Test handling of various worker errors during start"""
|
||||||
# Setup
|
# Setup
|
||||||
@@ -89,7 +87,7 @@ def test_mediator_start_recording_worker_errors(
|
|||||||
assert mock_recording.status == RecordingStatusChoices.FAILED_TO_START
|
assert mock_recording.status == RecordingStatusChoices.FAILED_TO_START
|
||||||
assert mock_recording.worker_id is None
|
assert mock_recording.worker_id is None
|
||||||
|
|
||||||
mock_update_room_metadata.assert_not_called()
|
mock_update_metadata.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -103,9 +101,9 @@ def test_mediator_start_recording_worker_errors(
|
|||||||
RecordingStatusChoices.ABORTED,
|
RecordingStatusChoices.ABORTED,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@mock.patch("core.utils.update_room_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_mediator_start_recording_from_forbidden_status(
|
def test_mediator_start_recording_from_forbidden_status(
|
||||||
mock_update_room_metadata, mediator, mock_worker_service, status
|
mock_update_metadata, mediator, mock_worker_service, status
|
||||||
):
|
):
|
||||||
"""Test handling of various worker errors during start"""
|
"""Test handling of various worker errors during start"""
|
||||||
# Setup
|
# Setup
|
||||||
@@ -119,7 +117,7 @@ def test_mediator_start_recording_from_forbidden_status(
|
|||||||
mock_recording.refresh_from_db()
|
mock_recording.refresh_from_db()
|
||||||
assert mock_recording.status == status
|
assert mock_recording.status == status
|
||||||
|
|
||||||
mock_update_room_metadata.assert_not_called()
|
mock_update_metadata.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def test_mediator_stop_recording_success(mediator, mock_worker_service):
|
def test_mediator_stop_recording_success(mediator, mock_worker_service):
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ def test_mute_participant_with_livekit_token_for_this_room(mock_livekit_client):
|
|||||||
room = RoomFactory()
|
room = RoomFactory()
|
||||||
|
|
||||||
user = AnonymousUser()
|
user = AnonymousUser()
|
||||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
token = utils.generate_token(str(room.id), user)
|
||||||
|
|
||||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -106,7 +106,7 @@ def test_mute_participant_with_livekit_token_for_another_room_forbidden(
|
|||||||
other_room = RoomFactory()
|
other_room = RoomFactory()
|
||||||
|
|
||||||
user = AnonymousUser()
|
user = AnonymousUser()
|
||||||
token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=False)
|
token = utils.generate_token(str(other_room.id), user)
|
||||||
|
|
||||||
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
|
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -146,7 +146,7 @@ def test_mute_participant_everyone_can_mute_disabled_blocks_non_admin(
|
|||||||
room = RoomFactory(configuration={"everyone_can_mute": False})
|
room = RoomFactory(configuration={"everyone_can_mute": False})
|
||||||
|
|
||||||
user = AnonymousUser()
|
user = AnonymousUser()
|
||||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
token = utils.generate_token(str(room.id), user)
|
||||||
|
|
||||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -293,7 +293,7 @@ def test_mute_participant_admin_with_token_for_this_room(mock_livekit_client):
|
|||||||
)
|
)
|
||||||
# Token identity matches the admin user so LiveKitTokenAuthentication
|
# Token identity matches the admin user so LiveKitTokenAuthentication
|
||||||
# resolves request.user back to the admin.
|
# resolves request.user back to the admin.
|
||||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=True)
|
token = utils.generate_token(str(room.id), user)
|
||||||
|
|
||||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -323,7 +323,7 @@ def test_mute_participant_admin_with_token_for_another_room(mock_livekit_client)
|
|||||||
# Token is scoped to a DIFFERENT room, and admin status must only be
|
# Token is scoped to a DIFFERENT room, and admin status must only be
|
||||||
# honored when established via session, never via a LiveKit
|
# honored when established via session, never via a LiveKit
|
||||||
# token, which can be replayed off-host.
|
# token, which can be replayed off-host.
|
||||||
token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=True)
|
token = utils.generate_token(str(other_room.id), user)
|
||||||
|
|
||||||
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
|
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -354,7 +354,7 @@ def test_mute_participant_admin_token_replayed_does_not_grant_admin(
|
|||||||
role=random.choice(["administrator", "owner"]),
|
role=random.choice(["administrator", "owner"]),
|
||||||
)
|
)
|
||||||
# The token is the only credential.
|
# The token is the only credential.
|
||||||
token = utils.generate_token(str(room.id), admin_user, is_admin_or_owner=True)
|
token = utils.generate_token(str(room.id), admin_user)
|
||||||
|
|
||||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -374,7 +374,7 @@ def test_mute_participant_livekit_token_triggers_presence_check(mock_livekit_cli
|
|||||||
room = RoomFactory()
|
room = RoomFactory()
|
||||||
|
|
||||||
user = AnonymousUser()
|
user = AnonymousUser()
|
||||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
token = utils.generate_token(str(room.id), user)
|
||||||
|
|
||||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -405,7 +405,7 @@ def test_mute_participant_livekit_token_presence_check_returns_participant(
|
|||||||
)
|
)
|
||||||
|
|
||||||
user = AnonymousUser()
|
user = AnonymousUser()
|
||||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
token = utils.generate_token(str(room.id), user)
|
||||||
|
|
||||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -433,7 +433,7 @@ def test_mute_participant_livekit_token_presence_check_participant_not_found(
|
|||||||
)
|
)
|
||||||
|
|
||||||
user = AnonymousUser()
|
user = AnonymousUser()
|
||||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
token = utils.generate_token(str(room.id), user)
|
||||||
|
|
||||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -462,7 +462,7 @@ def test_mute_participant_livekit_token_presence_check_twirp_error_forbidden(
|
|||||||
)
|
)
|
||||||
|
|
||||||
user = AnonymousUser()
|
user = AnonymousUser()
|
||||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
token = utils.generate_token(str(room.id), user)
|
||||||
|
|
||||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -719,13 +719,13 @@ def test_update_participant_invalid_payload():
|
|||||||
)
|
)
|
||||||
client.force_authenticate(user=user)
|
client.force_authenticate(user=user)
|
||||||
|
|
||||||
payload = {"participant_identity": "invalid-uuid"}
|
payload = {"participant_identity": ["test"]}
|
||||||
|
|
||||||
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
||||||
response = client.post(url, payload, format="json")
|
response = client.post(url, payload, format="json")
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
assert "Must be a valid UUID." in str(response.data)
|
assert "Not a valid string." in str(response.data)
|
||||||
|
|
||||||
|
|
||||||
def test_update_participant_no_update_fields():
|
def test_update_participant_no_update_fields():
|
||||||
@@ -918,7 +918,7 @@ def test_remove_participant_invalid_payload():
|
|||||||
)
|
)
|
||||||
client.force_authenticate(user=user)
|
client.force_authenticate(user=user)
|
||||||
|
|
||||||
payload = {"participant_identity": "invalid-uuid"}
|
payload = {"participant_identity": ["invalid-uuid"]}
|
||||||
|
|
||||||
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
|
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
|
||||||
response = client.post(url, payload, format="json")
|
response = client.post(url, payload, format="json")
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import pytest
|
|||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from ...factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
from ...factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||||
from ...models import RoomAccessLevel
|
from ...models import RoleChoices, RoomAccessLevel
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -31,7 +31,6 @@ def test_api_rooms_retrieve_anonymous_private_pk():
|
|||||||
"configuration": {},
|
"configuration": {},
|
||||||
"access_level": "restricted",
|
"access_level": "restricted",
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
"is_administrable": False,
|
|
||||||
"name": room.name,
|
"name": room.name,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
}
|
}
|
||||||
@@ -51,7 +50,6 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
|
|||||||
"configuration": {},
|
"configuration": {},
|
||||||
"access_level": "trusted",
|
"access_level": "trusted",
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
"is_administrable": False,
|
|
||||||
"name": room.name,
|
"name": room.name,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
}
|
}
|
||||||
@@ -70,7 +68,6 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
|
|||||||
"configuration": {},
|
"configuration": {},
|
||||||
"access_level": "restricted",
|
"access_level": "restricted",
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
"is_administrable": False,
|
|
||||||
"name": room.name,
|
"name": room.name,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
}
|
}
|
||||||
@@ -87,7 +84,6 @@ def test_api_rooms_retrieve_anonymous_private_slug():
|
|||||||
"configuration": {},
|
"configuration": {},
|
||||||
"access_level": "restricted",
|
"access_level": "restricted",
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
"is_administrable": False,
|
|
||||||
"name": room.name,
|
"name": room.name,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
}
|
}
|
||||||
@@ -104,7 +100,6 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
|
|||||||
"configuration": {},
|
"configuration": {},
|
||||||
"access_level": "restricted",
|
"access_level": "restricted",
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
"is_administrable": False,
|
|
||||||
"name": room.name,
|
"name": room.name,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
}
|
}
|
||||||
@@ -130,6 +125,9 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed(mock_token):
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": None,
|
"id": None,
|
||||||
|
"slug": "unregistered-room",
|
||||||
|
"access_level": "public",
|
||||||
|
"is_administrable": False,
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": "test_url_value",
|
"url": "test_url_value",
|
||||||
"room": "unregistered-room",
|
"room": "unregistered-room",
|
||||||
@@ -162,6 +160,9 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed_not_normalized(mock_t
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": None,
|
"id": None,
|
||||||
|
"slug": "reunion",
|
||||||
|
"access_level": "public",
|
||||||
|
"is_administrable": False,
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": "test_url_value",
|
"url": "test_url_value",
|
||||||
"room": "reunion",
|
"room": "reunion",
|
||||||
@@ -208,7 +209,6 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
|
|||||||
"configuration": {},
|
"configuration": {},
|
||||||
"access_level": str(room.access_level),
|
"access_level": str(room.access_level),
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
"is_administrable": False,
|
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": "test_url_value",
|
"url": "test_url_value",
|
||||||
"room": expected_name,
|
"room": expected_name,
|
||||||
@@ -255,7 +255,6 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
|||||||
"configuration": {"can_publish_sources": ["camera"]},
|
"configuration": {"can_publish_sources": ["camera"]},
|
||||||
"access_level": str(room.access_level),
|
"access_level": str(room.access_level),
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
"is_administrable": False,
|
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": "test_url_value",
|
"url": "test_url_value",
|
||||||
"room": expected_name,
|
"room": expected_name,
|
||||||
@@ -272,7 +271,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
|||||||
username=None,
|
username=None,
|
||||||
color=None,
|
color=None,
|
||||||
sources=["camera"],
|
sources=["camera"],
|
||||||
is_admin_or_owner=False,
|
role=None,
|
||||||
participant_id=None,
|
participant_id=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -307,7 +306,6 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
|||||||
"configuration": {},
|
"configuration": {},
|
||||||
"access_level": str(room.access_level),
|
"access_level": str(room.access_level),
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
"is_administrable": False,
|
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": "test_url_value",
|
"url": "test_url_value",
|
||||||
"room": expected_name,
|
"room": expected_name,
|
||||||
@@ -324,7 +322,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
|||||||
username=None,
|
username=None,
|
||||||
color=None,
|
color=None,
|
||||||
sources=None,
|
sources=None,
|
||||||
is_admin_or_owner=False,
|
role=None,
|
||||||
participant_id=None,
|
participant_id=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -349,7 +347,6 @@ def test_api_rooms_retrieve_authenticated():
|
|||||||
"configuration": {},
|
"configuration": {},
|
||||||
"access_level": "restricted",
|
"access_level": "restricted",
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
"is_administrable": False,
|
|
||||||
"name": room.name,
|
"name": room.name,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
}
|
}
|
||||||
@@ -395,7 +392,6 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
|||||||
"configuration": {"can_publish_sources": ["camera"]},
|
"configuration": {"can_publish_sources": ["camera"]},
|
||||||
"access_level": str(room.access_level),
|
"access_level": str(room.access_level),
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
"is_administrable": False,
|
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": "test_url_value",
|
"url": "test_url_value",
|
||||||
"room": expected_name,
|
"room": expected_name,
|
||||||
@@ -412,7 +408,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
|||||||
username=None,
|
username=None,
|
||||||
color=None,
|
color=None,
|
||||||
sources=["camera"],
|
sources=["camera"],
|
||||||
is_admin_or_owner=False,
|
role=str(RoleChoices.MEMBER),
|
||||||
participant_id=None,
|
participant_id=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -487,7 +483,6 @@ def test_api_rooms_retrieve_administrators(
|
|||||||
assert content_dict == {
|
assert content_dict == {
|
||||||
"access_level": str(room.access_level),
|
"access_level": str(room.access_level),
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
"is_administrable": True,
|
|
||||||
"configuration": {},
|
"configuration": {},
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": "test_url_value",
|
"url": "test_url_value",
|
||||||
@@ -505,6 +500,6 @@ def test_api_rooms_retrieve_administrators(
|
|||||||
username=None,
|
username=None,
|
||||||
color=None,
|
color=None,
|
||||||
sources=None,
|
sources=None,
|
||||||
is_admin_or_owner=True,
|
role=str(user_access.role),
|
||||||
participant_id=None,
|
participant_id=None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,319 @@
|
|||||||
|
"""
|
||||||
|
Test rooms API endpoints in the Meet core app: update-participant-role.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=redefined-outer-name,unused-argument
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from ...factories import RoomFactory, UserFactory
|
||||||
|
from ...models import ResourceAccess, RoleChoices
|
||||||
|
from ...services.participants_management import ParticipantNotFoundException
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_participant_role_anonymous():
|
||||||
|
"""Anonymous requesters are rejected."""
|
||||||
|
client = APIClient()
|
||||||
|
room = RoomFactory()
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||||
|
{"participant_identity": "some-identity", "role": "administrator"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_participant_role_requires_privileges():
|
||||||
|
"""A simple member cannot promote other participants."""
|
||||||
|
client = APIClient()
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(users=[(user, RoleChoices.MEMBER)])
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||||
|
{"participant_identity": "some-identity", "role": "administrator"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||||
|
def test_update_participant_role_requester_not_in_meeting(mock_perm_pm):
|
||||||
|
"""An admin who is not connected to the meeting is rejected."""
|
||||||
|
mock_perm_pm.return_value.check_if_in_meeting.return_value = False
|
||||||
|
client = APIClient()
|
||||||
|
user = UserFactory()
|
||||||
|
room = RoomFactory(users=[(user, RoleChoices.ADMIN)])
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||||
|
{"participant_identity": "some-identity", "role": "administrator"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
mock_perm_pm.return_value.check_if_in_meeting.assert_called_once_with(
|
||||||
|
room_name=str(room.pk), identity=str(user.sub)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||||
|
def test_update_participant_role_cannot_target_self(mock_perm_pm):
|
||||||
|
"""Requesters cannot change their own role."""
|
||||||
|
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||||
|
client = APIClient()
|
||||||
|
user = UserFactory(sub=uuid.uuid4())
|
||||||
|
room = RoomFactory(users=[(user, RoleChoices.ADMIN)])
|
||||||
|
client.force_login(user)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||||
|
{"participant_identity": user.sub, "role": "member"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json() == {"error": "You cannot change your own role."}
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
|
||||||
|
@mock.patch("core.services.room_roles.ParticipantsManagement")
|
||||||
|
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||||
|
def test_update_participant_role_promotes_authenticated_target(
|
||||||
|
mock_perm_pm, mock_svc_pm, mock_sync
|
||||||
|
):
|
||||||
|
"""Promoting a connected, authenticated participant persists the role."""
|
||||||
|
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||||
|
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
|
||||||
|
mock_sync.return_value = True
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
admin = UserFactory()
|
||||||
|
target = UserFactory(sub=uuid.uuid4())
|
||||||
|
room = RoomFactory(users=[(admin, RoleChoices.OWNER)])
|
||||||
|
client.force_login(admin)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||||
|
{"participant_identity": str(target.sub), "role": "administrator"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"role": "administrator",
|
||||||
|
"livekit_synced": True,
|
||||||
|
}
|
||||||
|
access = ResourceAccess.objects.get(resource=room, user=target)
|
||||||
|
assert access.role == RoleChoices.ADMIN
|
||||||
|
mock_sync.assert_called_once_with(
|
||||||
|
room_name=str(room.pk),
|
||||||
|
participant_identity=str(target.sub),
|
||||||
|
role="administrator",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
|
||||||
|
@mock.patch("core.services.room_roles.ParticipantsManagement")
|
||||||
|
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||||
|
def test_update_participant_role_demotes_authenticated_target(
|
||||||
|
mock_perm_pm, mock_svc_pm, mock_sync
|
||||||
|
):
|
||||||
|
"""Demoting a connected admin back to member updates the access row."""
|
||||||
|
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||||
|
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
|
||||||
|
mock_sync.return_value = True
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
admin = UserFactory()
|
||||||
|
target = UserFactory(sub=uuid.uuid4())
|
||||||
|
room = RoomFactory(users=[(admin, RoleChoices.OWNER), (target, RoleChoices.ADMIN)])
|
||||||
|
client.force_login(admin)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||||
|
{"participant_identity": str(target.sub), "role": "member"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
access = ResourceAccess.objects.get(resource=room, user=target)
|
||||||
|
assert access.role == RoleChoices.MEMBER
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
|
||||||
|
@mock.patch("core.services.room_roles.ParticipantsManagement")
|
||||||
|
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||||
|
def test_update_participant_role_cannot_demote_owner(
|
||||||
|
mock_perm_pm, mock_svc_pm, mock_sync
|
||||||
|
):
|
||||||
|
"""Room owners can never be demoted."""
|
||||||
|
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||||
|
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
admin = UserFactory()
|
||||||
|
owner = UserFactory(sub=uuid.uuid4())
|
||||||
|
room = RoomFactory(users=[(admin, RoleChoices.ADMIN), (owner, RoleChoices.OWNER)])
|
||||||
|
client.force_login(admin)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||||
|
{"participant_identity": str(owner.sub), "role": "member"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 403
|
||||||
|
assert response.json() == {"error": "Room owners cannot be demoted."}
|
||||||
|
assert (
|
||||||
|
ResourceAccess.objects.get(resource=room, user=owner).role == RoleChoices.OWNER
|
||||||
|
)
|
||||||
|
mock_sync.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
|
||||||
|
@mock.patch("core.services.room_roles.ParticipantsManagement")
|
||||||
|
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||||
|
def test_update_participant_role_anonymous_target_is_ephemeral(
|
||||||
|
mock_perm_pm, mock_svc_pm, mock_sync
|
||||||
|
):
|
||||||
|
"""Promoting an anonymous participant should not be possible."""
|
||||||
|
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||||
|
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
|
||||||
|
mock_sync.return_value = True
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
admin = UserFactory()
|
||||||
|
room = RoomFactory(users=[(admin, RoleChoices.ADMIN)])
|
||||||
|
client.force_login(admin)
|
||||||
|
|
||||||
|
anonymous_identity = uuid.uuid4()
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||||
|
{"participant_identity": anonymous_identity, "role": "administrator"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert response.json() == {
|
||||||
|
"error": "This participant has no user account and cannot be assigned a role."
|
||||||
|
}
|
||||||
|
assert not ResourceAccess.objects.filter(resource=room).exclude(user=admin).exists()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
|
||||||
|
@mock.patch("core.services.room_roles.ParticipantsManagement")
|
||||||
|
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||||
|
def test_update_participant_role_target_not_in_meeting(
|
||||||
|
mock_perm_pm, mock_svc_pm, mock_sync
|
||||||
|
):
|
||||||
|
"""Only connected participants can be promoted or demoted."""
|
||||||
|
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||||
|
mock_svc_pm.return_value.check_if_in_meeting.side_effect = (
|
||||||
|
ParticipantNotFoundException("Participant does not exist")
|
||||||
|
)
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
admin = UserFactory()
|
||||||
|
target = UserFactory(sub=uuid.uuid4())
|
||||||
|
room = RoomFactory(users=[(admin, RoleChoices.ADMIN)])
|
||||||
|
client.force_login(admin)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||||
|
{"participant_identity": str(target.sub), "role": "administrator"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 404
|
||||||
|
assert not ResourceAccess.objects.filter(resource=room, user=target).exists()
|
||||||
|
mock_sync.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
|
||||||
|
@mock.patch("core.services.room_roles.ParticipantsManagement")
|
||||||
|
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||||
|
def test_update_participant_role_is_idempotent_and_resyncs(
|
||||||
|
mock_perm_pm, mock_svc_pm, mock_sync
|
||||||
|
):
|
||||||
|
"""Promoting an existing admin succeeds and still re-syncs LiveKit."""
|
||||||
|
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||||
|
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
|
||||||
|
mock_sync.return_value = True
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
admin = UserFactory()
|
||||||
|
target = UserFactory(sub=uuid.uuid4())
|
||||||
|
room = RoomFactory(users=[(admin, RoleChoices.OWNER), (target, RoleChoices.ADMIN)])
|
||||||
|
client.force_login(admin)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||||
|
{"participant_identity": str(target.sub), "role": "administrator"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
mock_sync.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
|
||||||
|
@mock.patch("core.services.room_roles.ParticipantsManagement")
|
||||||
|
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||||
|
def test_update_participant_role_livekit_failure_reports_partial_success(
|
||||||
|
mock_perm_pm, mock_svc_pm, mock_sync
|
||||||
|
):
|
||||||
|
"""A LiveKit sync failure does not lose the persisted role."""
|
||||||
|
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||||
|
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
|
||||||
|
mock_sync.return_value = False
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
admin = UserFactory()
|
||||||
|
target = UserFactory(sub=uuid.uuid4())
|
||||||
|
room = RoomFactory(users=[(admin, RoleChoices.OWNER)])
|
||||||
|
client.force_login(admin)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||||
|
{"participant_identity": str(target.sub), "role": "administrator"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == {
|
||||||
|
"role": "administrator",
|
||||||
|
"livekit_synced": False,
|
||||||
|
}
|
||||||
|
assert (
|
||||||
|
ResourceAccess.objects.get(resource=room, user=target).role == RoleChoices.ADMIN
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||||
|
def test_update_participant_role_rejects_owner_role(mock_perm_pm):
|
||||||
|
"""The owner role can never be granted through this endpoint."""
|
||||||
|
client = APIClient()
|
||||||
|
admin = UserFactory()
|
||||||
|
room = RoomFactory(users=[(admin, RoleChoices.ADMIN)])
|
||||||
|
client.force_login(admin)
|
||||||
|
|
||||||
|
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||||
|
response = client.post(
|
||||||
|
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||||
|
{"participant_identity": "some-identity", "role": "owner"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
@@ -6,6 +6,8 @@ Test LiveKitEvents service.
|
|||||||
import uuid
|
import uuid
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.test.utils import override_settings
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from livekit.api import EgressStatus
|
from livekit.api import EgressStatus
|
||||||
|
|
||||||
@@ -20,8 +22,9 @@ from core.services.livekit_events import (
|
|||||||
api,
|
api,
|
||||||
)
|
)
|
||||||
from core.services.lobby import LobbyService
|
from core.services.lobby import LobbyService
|
||||||
|
from core.services.room_management import RoomManagementException
|
||||||
from core.services.telephony import TelephonyException, TelephonyService
|
from core.services.telephony import TelephonyException, TelephonyService
|
||||||
from core.utils import MetadataUpdateException, NotificationError
|
from core.utils import NotificationError
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -70,9 +73,9 @@ def test_initialization(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.utils.update_room_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_success(
|
def test_handle_egress_ended_success(
|
||||||
mock_update_room_metadata, mock_notify, mode, notification_type, service
|
mock_update_metadata, mock_notify, mode, notification_type, service
|
||||||
):
|
):
|
||||||
"""Should successfully stop recording and notifies all participant."""
|
"""Should successfully stop recording and notifies all participant."""
|
||||||
|
|
||||||
@@ -86,8 +89,8 @@ def test_handle_egress_ended_success(
|
|||||||
mock_notify.assert_called_once_with(
|
mock_notify.assert_called_once_with(
|
||||||
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||||
)
|
)
|
||||||
mock_update_room_metadata.assert_called_once_with(
|
mock_update_metadata.assert_called_once_with(
|
||||||
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||||
)
|
)
|
||||||
|
|
||||||
recording.refresh_from_db()
|
recording.refresh_from_db()
|
||||||
@@ -104,9 +107,9 @@ def test_handle_egress_ended_success(
|
|||||||
(EgressStatus.EGRESS_ABORTED, "aborted"),
|
(EgressStatus.EGRESS_ABORTED, "aborted"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@mock.patch("core.utils.update_room_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_updated_success(
|
def test_handle_egress_updated_success(
|
||||||
mock_update_room_metadata, egress_status, status, service
|
mock_update_metadata, egress_status, status, service
|
||||||
):
|
):
|
||||||
"""Should successfully update room's metadata."""
|
"""Should successfully update room's metadata."""
|
||||||
|
|
||||||
@@ -117,7 +120,7 @@ def test_handle_egress_updated_success(
|
|||||||
|
|
||||||
service._handle_egress_updated(mock_data)
|
service._handle_egress_updated(mock_data)
|
||||||
|
|
||||||
mock_update_room_metadata.assert_called_once_with(
|
mock_update_metadata.assert_called_once_with(
|
||||||
str(recording.room.id), {"recording_status": status}
|
str(recording.room.id), {"recording_status": status}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -129,9 +132,9 @@ def test_handle_egress_updated_success(
|
|||||||
EgressStatus.EGRESS_LIMIT_REACHED,
|
EgressStatus.EGRESS_LIMIT_REACHED,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@mock.patch("core.utils.update_room_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_updated_non_handled(
|
def test_handle_egress_updated_non_handled(
|
||||||
mock_update_room_metadata, egress_status, service
|
mock_update_metadata, egress_status, service
|
||||||
):
|
):
|
||||||
"""Should ignore certain egress status and don't trigger metadata updates."""
|
"""Should ignore certain egress status and don't trigger metadata updates."""
|
||||||
|
|
||||||
@@ -142,7 +145,7 @@ def test_handle_egress_updated_non_handled(
|
|||||||
|
|
||||||
service._handle_egress_updated(mock_data)
|
service._handle_egress_updated(mock_data)
|
||||||
|
|
||||||
mock_update_room_metadata.assert_not_called()
|
mock_update_metadata.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -153,9 +156,9 @@ def test_handle_egress_updated_non_handled(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.utils.update_room_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_metadata_update_fails(
|
def test_handle_egress_ended_metadata_update_fails(
|
||||||
mock_update_room_metadata, mock_notify, mode, notification_type, service
|
mock_update_metadata, mock_notify, mode, notification_type, service
|
||||||
):
|
):
|
||||||
"""Should successfully stop and save recording when metadata's update fails."""
|
"""Should successfully stop and save recording when metadata's update fails."""
|
||||||
|
|
||||||
@@ -164,7 +167,7 @@ def test_handle_egress_ended_metadata_update_fails(
|
|||||||
mock_data.egress_info.egress_id = recording.worker_id
|
mock_data.egress_info.egress_id = recording.worker_id
|
||||||
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
|
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
|
||||||
|
|
||||||
mock_update_room_metadata.side_effect = MetadataUpdateException("Error notifying")
|
mock_update_metadata.side_effect = RoomManagementException("Error notifying")
|
||||||
|
|
||||||
service._handle_egress_ended(mock_data)
|
service._handle_egress_ended(mock_data)
|
||||||
|
|
||||||
@@ -178,9 +181,9 @@ def test_handle_egress_ended_metadata_update_fails(
|
|||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.utils.update_room_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_notification_fails(
|
def test_handle_egress_ended_notification_fails(
|
||||||
mock_update_room_metadata, mock_notify, service
|
mock_update_metadata, mock_notify, service
|
||||||
):
|
):
|
||||||
"""Should raise ActionFailedError when notification fails but still stop recording."""
|
"""Should raise ActionFailedError when notification fails but still stop recording."""
|
||||||
|
|
||||||
@@ -200,15 +203,15 @@ def test_handle_egress_ended_notification_fails(
|
|||||||
recording.refresh_from_db()
|
recording.refresh_from_db()
|
||||||
assert recording.status == "stopped"
|
assert recording.status == "stopped"
|
||||||
|
|
||||||
mock_update_room_metadata.assert_called_once_with(
|
mock_update_metadata.assert_called_once_with(
|
||||||
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.utils.update_room_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_recording_not_found(
|
def test_handle_egress_ended_recording_not_found(
|
||||||
mock_update_room_metadata, mock_notify, service
|
mock_update_metadata, mock_notify, service
|
||||||
):
|
):
|
||||||
"""Should raise ActionFailedError when recording doesn't exist."""
|
"""Should raise ActionFailedError when recording doesn't exist."""
|
||||||
|
|
||||||
@@ -223,16 +226,16 @@ def test_handle_egress_ended_recording_not_found(
|
|||||||
service._handle_egress_ended(mock_data)
|
service._handle_egress_ended(mock_data)
|
||||||
|
|
||||||
mock_notify.assert_not_called()
|
mock_notify.assert_not_called()
|
||||||
mock_update_room_metadata.assert_not_called()
|
mock_update_metadata.assert_not_called()
|
||||||
|
|
||||||
recording.refresh_from_db()
|
recording.refresh_from_db()
|
||||||
assert recording.status == "active"
|
assert recording.status == "active"
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.utils.update_room_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_recording_not_active(
|
def test_handle_egress_ended_recording_not_active(
|
||||||
mock_update_room_metadata, mock_notify, service
|
mock_update_metadata, mock_notify, service
|
||||||
):
|
):
|
||||||
"""Should ignore non-active recordings."""
|
"""Should ignore non-active recordings."""
|
||||||
|
|
||||||
@@ -244,8 +247,8 @@ def test_handle_egress_ended_recording_not_active(
|
|||||||
service._handle_egress_ended(mock_data)
|
service._handle_egress_ended(mock_data)
|
||||||
|
|
||||||
mock_notify.assert_not_called()
|
mock_notify.assert_not_called()
|
||||||
mock_update_room_metadata.assert_called_once_with(
|
mock_update_metadata.assert_called_once_with(
|
||||||
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||||
)
|
)
|
||||||
|
|
||||||
recording.refresh_from_db()
|
recording.refresh_from_db()
|
||||||
@@ -253,9 +256,9 @@ def test_handle_egress_ended_recording_not_active(
|
|||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.utils.update_room_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_recording_not_limit_reached(
|
def test_handle_egress_ended_recording_not_limit_reached(
|
||||||
mock_update_room_metadata, mock_notify, service
|
mock_update_metadata, mock_notify, service
|
||||||
):
|
):
|
||||||
"""Should ignore egress non-limit-reached statuses."""
|
"""Should ignore egress non-limit-reached statuses."""
|
||||||
|
|
||||||
@@ -267,16 +270,16 @@ def test_handle_egress_ended_recording_not_limit_reached(
|
|||||||
service._handle_egress_ended(mock_data)
|
service._handle_egress_ended(mock_data)
|
||||||
|
|
||||||
mock_notify.assert_not_called()
|
mock_notify.assert_not_called()
|
||||||
mock_update_room_metadata.assert_called_once_with(
|
mock_update_metadata.assert_called_once_with(
|
||||||
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||||
)
|
)
|
||||||
assert recording.status == "stopped"
|
assert recording.status == "stopped"
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.livekit_events.MetadataCollectorService")
|
@mock.patch("core.services.livekit_events.MetadataCollectorService")
|
||||||
@mock.patch("core.utils.update_room_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_calls_metadata_collector_stop_when_conditions_are_met(
|
def test_handle_egress_ended_calls_metadata_collector_stop_when_conditions_are_met(
|
||||||
mock_update_room_metadata, mock_collector_class, service, settings
|
mock_update_metadata, mock_collector_class, service, settings
|
||||||
):
|
):
|
||||||
"""Should call MetadataCollectorService.stop when it exists."""
|
"""Should call MetadataCollectorService.stop when it exists."""
|
||||||
settings.METADATA_COLLECTOR_ENABLED = True
|
settings.METADATA_COLLECTOR_ENABLED = True
|
||||||
@@ -306,7 +309,7 @@ def test_handle_egress_ended_calls_metadata_collector_stop_when_conditions_are_m
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
@mock.patch("core.services.livekit_events.MetadataCollectorService")
|
@mock.patch("core.services.livekit_events.MetadataCollectorService")
|
||||||
@mock.patch("core.utils.update_room_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditions_not_met(
|
def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditions_not_met(
|
||||||
_, mock_collector_class, metadata_enabled, options, service, settings
|
_, mock_collector_class, metadata_enabled, options, service, settings
|
||||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||||
@@ -335,7 +338,7 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
|
|||||||
"notify_external_services"
|
"notify_external_services"
|
||||||
)
|
)
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.utils.update_room_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"egress_status",
|
"egress_status",
|
||||||
[EgressStatus.EGRESS_COMPLETE, EgressStatus.EGRESS_LIMIT_REACHED],
|
[EgressStatus.EGRESS_COMPLETE, EgressStatus.EGRESS_LIMIT_REACHED],
|
||||||
@@ -345,7 +348,7 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
|
|||||||
[(True, "notification_succeeded"), (False, "saved")],
|
[(True, "notification_succeeded"), (False, "saved")],
|
||||||
)
|
)
|
||||||
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
|
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
|
||||||
mock_update_room_metadata,
|
mock_update_metadata,
|
||||||
mock_notify,
|
mock_notify,
|
||||||
mock_notify_external_services,
|
mock_notify_external_services,
|
||||||
notify_return_value,
|
notify_return_value,
|
||||||
@@ -378,7 +381,7 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
|
|||||||
"notify_external_services"
|
"notify_external_services"
|
||||||
)
|
)
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.utils.update_room_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"egress_status, expected_status",
|
"egress_status, expected_status",
|
||||||
[
|
[
|
||||||
@@ -387,7 +390,7 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: PLR0913
|
def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: PLR0913
|
||||||
mock_update_room_metadata,
|
mock_update_metadata,
|
||||||
mock_notify,
|
mock_notify,
|
||||||
mock_notify_external_services,
|
mock_notify_external_services,
|
||||||
egress_status,
|
egress_status,
|
||||||
@@ -424,9 +427,9 @@ def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: P
|
|||||||
EgressStatus.EGRESS_ABORTED,
|
EgressStatus.EGRESS_ABORTED,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@mock.patch("core.utils.update_room_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_does_not_save_on_wrong_status(
|
def test_handle_egress_ended_does_not_save_on_wrong_status(
|
||||||
mock_update_room_metadata, egress_status, service, settings
|
mock_update_metadata, egress_status, service, settings
|
||||||
):
|
):
|
||||||
"""Shouldn't save on invalid status."""
|
"""Shouldn't save on invalid status."""
|
||||||
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||||
@@ -445,9 +448,9 @@ def test_handle_egress_ended_does_not_save_on_wrong_status(
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
|
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
|
||||||
)
|
)
|
||||||
@mock.patch("core.utils.update_room_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_ignores_non_savable_recording(
|
def test_handle_egress_ended_ignores_non_savable_recording(
|
||||||
mock_update_room_metadata, status, service, settings
|
mock_update_metadata, status, service, settings
|
||||||
):
|
):
|
||||||
"""Should handle non-savable recordings idempotently without raising.
|
"""Should handle non-savable recordings idempotently without raising.
|
||||||
|
|
||||||
@@ -550,6 +553,23 @@ def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
|
|||||||
mock_clear_cache.assert_not_called()
|
mock_clear_cache.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(CONNECTION_TEST_ROOM_PREFIX="connection-test-")
|
||||||
|
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||||
|
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
||||||
|
def test_handle_room_finished_ignores_connection_test_room(
|
||||||
|
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||||
|
):
|
||||||
|
"""Should ignore room_finished events for connection test rooms."""
|
||||||
|
settings.ROOM_TELEPHONY_ENABLED = True
|
||||||
|
mock_data = mock.MagicMock()
|
||||||
|
mock_data.room.name = f"{settings.CONNECTION_TEST_ROOM_PREFIX}{uuid.uuid4()}"
|
||||||
|
|
||||||
|
service._handle_room_finished(mock_data)
|
||||||
|
|
||||||
|
mock_delete_dispatch_rule.assert_not_called()
|
||||||
|
mock_clear_cache.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def test_handle_room_finished_raises_error_for_invalid_room_name(service):
|
def test_handle_room_finished_raises_error_for_invalid_room_name(service):
|
||||||
"""Should raise ActionFailedError when room name format is invalid when room finishes."""
|
"""Should raise ActionFailedError when room name format is invalid when room finishes."""
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
@@ -600,6 +620,15 @@ def test_handle_room_started_raises_error_for_invalid_room_name(service):
|
|||||||
service._handle_room_started(mock_data)
|
service._handle_room_started(mock_data)
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(CONNECTION_TEST_ROOM_PREFIX="connection-test-")
|
||||||
|
def test_handle_room_started_ignores_connection_test_room(service, settings):
|
||||||
|
"""Should ignore room_started events for connection test rooms."""
|
||||||
|
mock_data = mock.MagicMock()
|
||||||
|
mock_data.room.name = f"{settings.CONNECTION_TEST_ROOM_PREFIX}{uuid.uuid4()}"
|
||||||
|
|
||||||
|
service._handle_room_started(mock_data)
|
||||||
|
|
||||||
|
|
||||||
def test_handle_room_started_raises_error_for_nonexistent_room(service):
|
def test_handle_room_started_raises_error_for_nonexistent_room(service):
|
||||||
"""Should raise ActionFailedError when a room starts that doesn't exist in the database."""
|
"""Should raise ActionFailedError when a room starts that doesn't exist in the database."""
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ import uuid
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from core.factories import RoomFactory
|
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||||
from core.models import RoomAccessLevel
|
from core.models import RoleChoices, RoomAccessLevel
|
||||||
from core.services.lobby import (
|
from core.services.lobby import (
|
||||||
LobbyParticipant,
|
LobbyParticipant,
|
||||||
LobbyParticipantNotFound,
|
LobbyParticipantNotFound,
|
||||||
@@ -188,17 +189,17 @@ def test_prepare_response_new_cookie(lobby_service, participant_id):
|
|||||||
|
|
||||||
|
|
||||||
def test_can_bypass_lobby_public_room(lobby_service):
|
def test_can_bypass_lobby_public_room(lobby_service):
|
||||||
"""Should return True for public rooms regardless of user auth."""
|
"""Should return True for public rooms regardless of user auth and role."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||||
|
|
||||||
# Anonymous user
|
# Anonymous user
|
||||||
user = mock.Mock()
|
user = mock.Mock()
|
||||||
user.is_authenticated = False
|
user.is_authenticated = False
|
||||||
assert lobby_service.can_bypass_lobby(room, user) is True
|
assert lobby_service.can_bypass_lobby(room, user, role=None) is True
|
||||||
|
|
||||||
# Authenticated user
|
# Authenticated user
|
||||||
user.is_authenticated = True
|
user.is_authenticated = True
|
||||||
assert lobby_service.can_bypass_lobby(room, user) is True
|
assert lobby_service.can_bypass_lobby(room, user, role=None) is True
|
||||||
|
|
||||||
|
|
||||||
def test_can_bypass_lobby_trusted_room_authenticated(lobby_service):
|
def test_can_bypass_lobby_trusted_room_authenticated(lobby_service):
|
||||||
@@ -208,7 +209,7 @@ def test_can_bypass_lobby_trusted_room_authenticated(lobby_service):
|
|||||||
# Authenticated user
|
# Authenticated user
|
||||||
user = mock.Mock()
|
user = mock.Mock()
|
||||||
user.is_authenticated = True
|
user.is_authenticated = True
|
||||||
assert lobby_service.can_bypass_lobby(room, user) is True
|
assert lobby_service.can_bypass_lobby(room, user, role=None) is True
|
||||||
|
|
||||||
|
|
||||||
def test_can_bypass_lobby_trusted_room_anonymous(lobby_service):
|
def test_can_bypass_lobby_trusted_room_anonymous(lobby_service):
|
||||||
@@ -218,21 +219,34 @@ def test_can_bypass_lobby_trusted_room_anonymous(lobby_service):
|
|||||||
# Anonymous user
|
# Anonymous user
|
||||||
user = mock.Mock()
|
user = mock.Mock()
|
||||||
user.is_authenticated = False
|
user.is_authenticated = False
|
||||||
assert lobby_service.can_bypass_lobby(room, user) is False
|
assert lobby_service.can_bypass_lobby(room, user, role=None) is False
|
||||||
|
|
||||||
|
|
||||||
def test_can_bypass_lobby_private_room(lobby_service):
|
def test_can_bypass_lobby_private_room(lobby_service):
|
||||||
"""Should return False for private rooms regardless of user auth."""
|
"""Should return False for private rooms regardless of user auth if role is not."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
|
||||||
# Anonymous user
|
# Anonymous user
|
||||||
user = mock.Mock()
|
user = mock.Mock()
|
||||||
user.is_authenticated = False
|
user.is_authenticated = False
|
||||||
assert lobby_service.can_bypass_lobby(room, user) is False
|
assert lobby_service.can_bypass_lobby(room, user, role=None) is False
|
||||||
|
|
||||||
# Authenticated user
|
# Authenticated user
|
||||||
user.is_authenticated = True
|
user.is_authenticated = True
|
||||||
assert lobby_service.can_bypass_lobby(room, user) is False
|
assert lobby_service.can_bypass_lobby(room, user, role=None) is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"role",
|
||||||
|
[RoleChoices.MEMBER, RoleChoices.ADMIN, RoleChoices.OWNER],
|
||||||
|
)
|
||||||
|
def test_can_bypass_lobby_private_room_with_any_role(role, lobby_service):
|
||||||
|
"""Should return True for private rooms if the user is authenticated and has any role."""
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
|
||||||
|
user = mock.Mock()
|
||||||
|
user.is_authenticated = True
|
||||||
|
assert lobby_service.can_bypass_lobby(room, user, role=role) is True
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.generate_livekit_config")
|
@mock.patch("core.utils.generate_livekit_config")
|
||||||
@@ -241,7 +255,7 @@ def test_request_entry_public_room(
|
|||||||
):
|
):
|
||||||
"""Test requesting entry to a public room."""
|
"""Test requesting entry to a public room."""
|
||||||
request = mock.Mock()
|
request = mock.Mock()
|
||||||
request.user = mock.Mock()
|
request.user = AnonymousUser()
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||||
|
|
||||||
@@ -266,8 +280,8 @@ def test_request_entry_public_room(
|
|||||||
username=username,
|
username=username,
|
||||||
color=participant.color,
|
color=participant.color,
|
||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
is_admin_or_owner=False,
|
|
||||||
participant_id="test-participant-id",
|
participant_id="test-participant-id",
|
||||||
|
role=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||||
@@ -279,8 +293,7 @@ def test_request_entry_trusted_room(
|
|||||||
):
|
):
|
||||||
"""Test requesting entry to a trusted room when the user is authenticated."""
|
"""Test requesting entry to a trusted room when the user is authenticated."""
|
||||||
request = mock.Mock()
|
request = mock.Mock()
|
||||||
request.user = mock.Mock()
|
request.user = UserFactory()
|
||||||
request.user.is_authenticated = True
|
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||||
|
|
||||||
@@ -305,8 +318,8 @@ def test_request_entry_trusted_room(
|
|||||||
username=username,
|
username=username,
|
||||||
color=participant.color,
|
color=participant.color,
|
||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
is_admin_or_owner=False,
|
|
||||||
participant_id="test-participant-id",
|
participant_id="test-participant-id",
|
||||||
|
role=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||||
@@ -319,6 +332,7 @@ def test_request_entry_new_participant(
|
|||||||
"""Test requesting entry for a new participant."""
|
"""Test requesting entry for a new participant."""
|
||||||
request = mock.Mock()
|
request = mock.Mock()
|
||||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||||
|
request.user = AnonymousUser()
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
|
||||||
@@ -348,6 +362,7 @@ def test_request_entry_waiting_participant(
|
|||||||
"""Test requesting entry for a waiting participant."""
|
"""Test requesting entry for a waiting participant."""
|
||||||
request = mock.Mock()
|
request = mock.Mock()
|
||||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||||
|
request.user = AnonymousUser()
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
|
||||||
@@ -374,7 +389,7 @@ def test_request_entry_accepted_participant(
|
|||||||
):
|
):
|
||||||
"""Test requesting entry for an accepted participant."""
|
"""Test requesting entry for an accepted participant."""
|
||||||
request = mock.Mock()
|
request = mock.Mock()
|
||||||
request.user = mock.Mock()
|
request.user = AnonymousUser()
|
||||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
@@ -400,8 +415,48 @@ def test_request_entry_accepted_participant(
|
|||||||
username=username,
|
username=username,
|
||||||
color="#123456",
|
color="#123456",
|
||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
is_admin_or_owner=False,
|
|
||||||
participant_id="test-participant-id",
|
participant_id="test-participant-id",
|
||||||
|
role=None,
|
||||||
|
)
|
||||||
|
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||||
|
|
||||||
|
|
||||||
|
@mock.patch("core.utils.generate_livekit_config")
|
||||||
|
def test_request_entry_participant_with_role(
|
||||||
|
mock_generate_config, lobby_service, participant_id, username
|
||||||
|
):
|
||||||
|
"""Test requesting entry for a participant with a role on the room."""
|
||||||
|
request = mock.Mock()
|
||||||
|
request.user = UserFactory()
|
||||||
|
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||||
|
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
|
||||||
|
UserResourceAccessFactory(resource=room, user=request.user, role="administrator")
|
||||||
|
|
||||||
|
mocked_participant = LobbyParticipant(
|
||||||
|
status=LobbyParticipantStatus.ACCEPTED,
|
||||||
|
username=username,
|
||||||
|
id=participant_id,
|
||||||
|
color="#123456",
|
||||||
|
)
|
||||||
|
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||||
|
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||||
|
|
||||||
|
mock_generate_config.return_value = {"token": "test-token"}
|
||||||
|
|
||||||
|
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||||
|
|
||||||
|
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
||||||
|
assert livekit_config == {"token": "test-token"}
|
||||||
|
mock_generate_config.assert_called_once_with(
|
||||||
|
room_id=str(room.id),
|
||||||
|
user=request.user,
|
||||||
|
username=username,
|
||||||
|
color="#123456",
|
||||||
|
configuration=room.configuration,
|
||||||
|
participant_id="test-participant-id",
|
||||||
|
role="administrator",
|
||||||
)
|
)
|
||||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Tests for connection test Celery tasks."""
|
||||||
|
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.test.utils import override_settings
|
||||||
|
|
||||||
|
from livekit.api import TwirpError
|
||||||
|
|
||||||
|
from core.tasks.connection_test import delete_connection_test_room
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(CONNECTION_TEST_ROOM_PREFIX="connection-test-")
|
||||||
|
@mock.patch("core.tasks.connection_test.create_livekit_client")
|
||||||
|
def test_delete_connection_test_room_calls_livekit(mock_create_livekit_client):
|
||||||
|
"""DeleteRoom is called for rooms with the connection-test prefix."""
|
||||||
|
mock_api = mock.MagicMock()
|
||||||
|
mock_api.room.delete_room = mock.AsyncMock()
|
||||||
|
mock_api.aclose = mock.AsyncMock()
|
||||||
|
mock_create_livekit_client.return_value = mock_api
|
||||||
|
|
||||||
|
delete_connection_test_room("connection-test-abc")
|
||||||
|
|
||||||
|
mock_api.room.delete_room.assert_awaited_once()
|
||||||
|
request = mock_api.room.delete_room.await_args.args[0]
|
||||||
|
assert request.room == "connection-test-abc"
|
||||||
|
mock_api.aclose.assert_awaited_once()
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(CONNECTION_TEST_ROOM_PREFIX="connection-test-")
|
||||||
|
@mock.patch("core.tasks.connection_test.create_livekit_client")
|
||||||
|
def test_delete_connection_test_room_refuses_other_rooms(mock_create_livekit_client):
|
||||||
|
"""Refuse to delete rooms outside the connection-test namespace."""
|
||||||
|
delete_connection_test_room("production-room")
|
||||||
|
|
||||||
|
mock_create_livekit_client.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(CONNECTION_TEST_ROOM_PREFIX="connection-test-")
|
||||||
|
@mock.patch("core.tasks.connection_test.create_livekit_client")
|
||||||
|
def test_delete_connection_test_room_ignores_missing_room(mock_create_livekit_client):
|
||||||
|
"""Missing rooms are treated as already cleaned up."""
|
||||||
|
mock_api = mock.MagicMock()
|
||||||
|
mock_api.room.delete_room = mock.AsyncMock(
|
||||||
|
side_effect=TwirpError("not_found", "room not found", status=404)
|
||||||
|
)
|
||||||
|
mock_api.aclose = mock.AsyncMock()
|
||||||
|
mock_create_livekit_client.return_value = mock_api
|
||||||
|
|
||||||
|
delete_connection_test_room("connection-test-gone")
|
||||||
|
|
||||||
|
mock_api.aclose.assert_awaited_once()
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Test connection test API endpoint."""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.test.utils import override_settings
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import pytest
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from core.api.connection_test import CONNECTION_TEST_USERNAME
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
CONNECTION_TEST_TOKEN_TTL_SECONDS=600,
|
||||||
|
CONNECTION_TEST_ROOM_PREFIX="connection-test-",
|
||||||
|
)
|
||||||
|
def test_api_connection_test_returns_ephemeral_livekit_config():
|
||||||
|
"""Each request gets a dedicated room and a short-lived token."""
|
||||||
|
client = APIClient()
|
||||||
|
response_a = client.get("/api/v1.0/connection-test/")
|
||||||
|
response_b = client.get("/api/v1.0/connection-test/")
|
||||||
|
|
||||||
|
assert response_a.status_code == 200
|
||||||
|
assert response_b.status_code == 200
|
||||||
|
|
||||||
|
data_a = response_a.json()
|
||||||
|
data_b = response_b.json()
|
||||||
|
|
||||||
|
room_a = data_a["livekit"]["room"]
|
||||||
|
room_b = data_b["livekit"]["room"]
|
||||||
|
|
||||||
|
assert room_a.startswith("connection-test-")
|
||||||
|
assert room_b.startswith("connection-test-")
|
||||||
|
uuid.UUID(room_a.removeprefix("connection-test-"))
|
||||||
|
uuid.UUID(room_b.removeprefix("connection-test-"))
|
||||||
|
assert room_a != room_b
|
||||||
|
assert data_a["livekit"]["url"]
|
||||||
|
assert data_a["livekit"]["token"]
|
||||||
|
assert data_a["livekit"]["expires_in"] == 600
|
||||||
|
assert data_a["livekit"]["token"] != data_b["livekit"]["token"]
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(CONNECTION_TEST_TOKEN_TTL_SECONDS=300)
|
||||||
|
def test_api_connection_test_token_is_short_lived_for_user(settings):
|
||||||
|
"""Connection test tokens expire quickly for users."""
|
||||||
|
client = APIClient()
|
||||||
|
response = client.get("/api/v1.0/connection-test/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
config = response.json()["livekit"]
|
||||||
|
payload = jwt.decode(
|
||||||
|
config["token"],
|
||||||
|
settings.LIVEKIT_CONFIGURATION["api_secret"],
|
||||||
|
algorithms=["HS256"],
|
||||||
|
options={"verify_exp": False},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert config["expires_in"] == 300
|
||||||
|
assert payload["video"]["room"] == config["room"]
|
||||||
|
assert payload["name"] == CONNECTION_TEST_USERNAME
|
||||||
|
assert payload["video"]["roomAdmin"] is False
|
||||||
|
assert payload["exp"] - payload["nbf"] == 300
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(
|
||||||
|
CELERY_ENABLED=True,
|
||||||
|
CONNECTION_TEST_ROOM_MAX_AGE_SECONDS=300,
|
||||||
|
CONNECTION_TEST_ROOM_PREFIX="connection-test-",
|
||||||
|
)
|
||||||
|
@mock.patch("core.api.connection_test.delete_connection_test_room.apply_async")
|
||||||
|
def test_api_connection_test_schedules_room_deletion(mock_apply_async):
|
||||||
|
"""When Celery is enabled, schedule a hard room delete after max age."""
|
||||||
|
client = APIClient()
|
||||||
|
response = client.get("/api/v1.0/connection-test/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
room = response.json()["livekit"]["room"]
|
||||||
|
mock_apply_async.assert_called_once_with(args=[room], countdown=300)
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(CELERY_ENABLED=False)
|
||||||
|
@mock.patch("core.api.connection_test.delete_connection_test_room.apply_async")
|
||||||
|
def test_api_connection_test_skips_room_deletion_without_celery(mock_apply_async):
|
||||||
|
"""Without Celery, do not schedule deletion (apply_async would run immediately)."""
|
||||||
|
client = APIClient()
|
||||||
|
response = client.get("/api/v1.0/connection-test/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
mock_apply_async.assert_not_called()
|
||||||
@@ -2,13 +2,109 @@
|
|||||||
Test utils functions
|
Test utils functions
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# pylint: disable=W0621
|
||||||
import json
|
import json
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
|
||||||
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
from livekit.api import TwirpError
|
from livekit.api import TwirpError
|
||||||
|
|
||||||
from core.utils import NotificationError, create_livekit_client, notify_participants
|
from core.factories import UserFactory
|
||||||
|
from core.utils import (
|
||||||
|
NotificationError,
|
||||||
|
create_livekit_client,
|
||||||
|
generate_token,
|
||||||
|
notify_participants,
|
||||||
|
)
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str) -> dict:
|
||||||
|
"""Decode a LiveKit JWT access token for inspection."""
|
||||||
|
return jwt.decode(
|
||||||
|
token,
|
||||||
|
settings.LIVEKIT_CONFIGURATION["api_secret"],
|
||||||
|
algorithms=["HS256"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_token_authenticated_uses_full_name():
|
||||||
|
"""The token's display name should default to the user's full name."""
|
||||||
|
user = UserFactory(full_name="Jane Doe")
|
||||||
|
|
||||||
|
token = generate_token(room="my-room", user=user)
|
||||||
|
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Jane Doe"
|
||||||
|
assert claims["sub"] == str(user.sub)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_token_authenticated_fallback_user_representation():
|
||||||
|
"""
|
||||||
|
When the user has no full name, the token's display name should fall back
|
||||||
|
to the user's string representation.
|
||||||
|
"""
|
||||||
|
user = UserFactory(full_name=None)
|
||||||
|
|
||||||
|
token = generate_token(room="my-room", user=user)
|
||||||
|
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == str(user)
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_token_explicit_username_overrides_default():
|
||||||
|
"""An explicitly provided username should take precedence over the full name."""
|
||||||
|
user = UserFactory(full_name="Jane Doe")
|
||||||
|
|
||||||
|
token = generate_token(room="my-room", user=user, username="Custom Name")
|
||||||
|
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Custom Name"
|
||||||
|
|
||||||
|
|
||||||
|
def test_authenticated_username_ignored_when_editing_disabled(settings):
|
||||||
|
"""With editing disabled, an authenticated user's username is ignored."""
|
||||||
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||||
|
user = UserFactory(full_name="Jane Doe")
|
||||||
|
token = generate_token(room="my-room", user=user, username="Custom Name")
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Jane Doe"
|
||||||
|
|
||||||
|
|
||||||
|
def test_authenticated_default_name_unaffected_when_editing_disabled(settings):
|
||||||
|
"""Disabling editing doesn't disturb the default full-name path."""
|
||||||
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||||
|
user = UserFactory(full_name="Jane Doe")
|
||||||
|
token = generate_token(room="my-room", user=user)
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Jane Doe"
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_uses_username_when_provided():
|
||||||
|
"""An anonymous user's provided username is used as the display name."""
|
||||||
|
token = generate_token(room="my-room", user=AnonymousUser(), username="Guest42")
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Guest42"
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_username_used_even_when_editing_disabled(settings):
|
||||||
|
"""The setting governs authenticated users only; anonymous can still set a name."""
|
||||||
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||||
|
token = generate_token(room="my-room", user=AnonymousUser(), username="Guest42")
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Guest42"
|
||||||
|
|
||||||
|
|
||||||
|
def test_anonymous_falls_back_to_anonymous_label():
|
||||||
|
"""With no username, an anonymous user is labelled 'Anonymous'."""
|
||||||
|
token = generate_token(room="my-room", user=AnonymousUser())
|
||||||
|
claims = decode_token(token)
|
||||||
|
assert claims["name"] == "Anonymous"
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("asyncio.get_running_loop")
|
@mock.patch("asyncio.get_running_loop")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from rest_framework.routers import DefaultRouter, SimpleRouter
|
|||||||
|
|
||||||
from core.addons import viewsets as addons_viewsets
|
from core.addons import viewsets as addons_viewsets
|
||||||
from core.api import get_frontend_configuration, viewsets
|
from core.api import get_frontend_configuration, viewsets
|
||||||
|
from core.api.connection_test import get_connection_test_config
|
||||||
from core.external_api import viewsets as external_viewsets
|
from core.external_api import viewsets as external_viewsets
|
||||||
|
|
||||||
# - Main endpoints
|
# - Main endpoints
|
||||||
@@ -46,6 +47,11 @@ urlpatterns = [
|
|||||||
*router.urls,
|
*router.urls,
|
||||||
*oidc_urls,
|
*oidc_urls,
|
||||||
path("config/", get_frontend_configuration, name="config"),
|
path("config/", get_frontend_configuration, name="config"),
|
||||||
|
path(
|
||||||
|
"connection-test/",
|
||||||
|
get_connection_test_config,
|
||||||
|
name="connection_test",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
+29
-64
@@ -12,6 +12,7 @@ import mimetypes
|
|||||||
import random
|
import random
|
||||||
import secrets
|
import secrets
|
||||||
import string
|
import string
|
||||||
|
from datetime import timedelta
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
@@ -31,7 +32,6 @@ from livekit.api import ( # pylint: disable=E0611
|
|||||||
LiveKitAPI,
|
LiveKitAPI,
|
||||||
SendDataRequest,
|
SendDataRequest,
|
||||||
TwirpError,
|
TwirpError,
|
||||||
UpdateRoomMetadataRequest,
|
|
||||||
VideoGrants,
|
VideoGrants,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -66,8 +66,9 @@ def generate_token(
|
|||||||
username: Optional[str] = None,
|
username: Optional[str] = None,
|
||||||
color: Optional[str] = None,
|
color: Optional[str] = None,
|
||||||
sources: Optional[List[str]] = None,
|
sources: Optional[List[str]] = None,
|
||||||
is_admin_or_owner: bool = False,
|
role: Optional[str] = None,
|
||||||
participant_id: Optional[str] = None,
|
participant_id: Optional[str] = None,
|
||||||
|
ttl: Optional[timedelta] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Generate a LiveKit access token for a user in a specific room.
|
"""Generate a LiveKit access token for a user in a specific room.
|
||||||
|
|
||||||
@@ -80,14 +81,16 @@ def generate_token(
|
|||||||
If none, a value will be generated
|
If none, a value will be generated
|
||||||
sources: (Optional[List[str]]): List of media sources the user can publish
|
sources: (Optional[List[str]]): List of media sources the user can publish
|
||||||
If none, defaults to LIVEKIT_DEFAULT_SOURCES.
|
If none, defaults to LIVEKIT_DEFAULT_SOURCES.
|
||||||
is_admin_or_owner (bool): Whether user has admin privileges
|
role (Optional[str]): Room's access role if any
|
||||||
participant_id (Optional[str]): Stable identifier for anonymous users;
|
participant_id (Optional[str]): Stable identifier for anonymous users;
|
||||||
used as identity when user.is_anonymous.
|
used as identity when user.is_anonymous.
|
||||||
|
ttl (Optional[timedelta]): Token validity duration. Defaults to LiveKit SDK default.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
str: The LiveKit JWT access token.
|
str: The LiveKit JWT access token.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
is_admin_or_owner = role in ("owner", "administrator")
|
||||||
if is_admin_or_owner:
|
if is_admin_or_owner:
|
||||||
sources = settings.LIVEKIT_DEFAULT_SOURCES
|
sources = settings.LIVEKIT_DEFAULT_SOURCES
|
||||||
|
|
||||||
@@ -109,11 +112,16 @@ def generate_token(
|
|||||||
default_username = "Anonymous"
|
default_username = "Anonymous"
|
||||||
else:
|
else:
|
||||||
identity = str(user.sub)
|
identity = str(user.sub)
|
||||||
default_username = str(user)
|
default_username = user.full_name or str(user)
|
||||||
|
|
||||||
if color is None:
|
if color is None:
|
||||||
color = generate_color(identity)
|
color = generate_color(identity)
|
||||||
|
|
||||||
|
can_edit = (
|
||||||
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME or user.is_anonymous
|
||||||
|
)
|
||||||
|
display_name = (username or default_username) if can_edit else default_username
|
||||||
|
|
||||||
token = (
|
token = (
|
||||||
AccessToken(
|
AccessToken(
|
||||||
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
||||||
@@ -121,11 +129,17 @@ def generate_token(
|
|||||||
)
|
)
|
||||||
.with_grants(video_grants)
|
.with_grants(video_grants)
|
||||||
.with_identity(identity)
|
.with_identity(identity)
|
||||||
.with_name(username or default_username)
|
.with_name(display_name)
|
||||||
.with_attributes(
|
.with_attributes(
|
||||||
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
|
{
|
||||||
|
"color": color,
|
||||||
|
"room_role": role,
|
||||||
|
"is_authenticated": "true" if user.is_authenticated else "false",
|
||||||
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
if ttl is not None:
|
||||||
|
token = token.with_ttl(ttl)
|
||||||
|
|
||||||
return token.to_jwt()
|
return token.to_jwt()
|
||||||
|
|
||||||
@@ -134,7 +148,7 @@ def generate_livekit_config(
|
|||||||
room_id: str,
|
room_id: str,
|
||||||
user,
|
user,
|
||||||
username: str,
|
username: str,
|
||||||
is_admin_or_owner: bool,
|
role: Optional[str] = None,
|
||||||
color: Optional[str] = None,
|
color: Optional[str] = None,
|
||||||
configuration: Optional[dict] = None,
|
configuration: Optional[dict] = None,
|
||||||
participant_id: Optional[str] = None,
|
participant_id: Optional[str] = None,
|
||||||
@@ -145,7 +159,7 @@ def generate_livekit_config(
|
|||||||
room_id: Room identifier
|
room_id: Room identifier
|
||||||
user: User instance requesting access
|
user: User instance requesting access
|
||||||
username: Display name in room
|
username: Display name in room
|
||||||
is_admin_or_owner (bool): Whether the user has admin/owner privileges for this room.
|
role (str): Room's access role if any
|
||||||
color (Optional[str]): Optional color to associate with the participant.
|
color (Optional[str]): Optional color to associate with the participant.
|
||||||
configuration (Optional[dict]): Room configuration dict that can override default settings.
|
configuration (Optional[dict]): Room configuration dict that can override default settings.
|
||||||
participant_id (Optional[str]): Stable identifier for anonymous users;
|
participant_id (Optional[str]): Stable identifier for anonymous users;
|
||||||
@@ -168,7 +182,7 @@ def generate_livekit_config(
|
|||||||
username=username,
|
username=username,
|
||||||
color=color,
|
color=color,
|
||||||
sources=sources,
|
sources=sources,
|
||||||
is_admin_or_owner=is_admin_or_owner,
|
role=role,
|
||||||
participant_id=participant_id,
|
participant_id=participant_id,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -253,57 +267,6 @@ async def notify_participants(room_name: str, notification_data: dict):
|
|||||||
await lkapi.aclose()
|
await lkapi.aclose()
|
||||||
|
|
||||||
|
|
||||||
class MetadataUpdateException(Exception):
|
|
||||||
"""Room's metadata update fails."""
|
|
||||||
|
|
||||||
|
|
||||||
@async_to_sync
|
|
||||||
async def update_room_metadata(
|
|
||||||
room_name: str, metadata: dict, remove_keys: Optional[list[str]] = None
|
|
||||||
):
|
|
||||||
"""Update LiveKit room metadata by merging new values with existing metadata.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
room_name: Name of the room to update
|
|
||||||
metadata: Dictionary of metadata key-values to add/update
|
|
||||||
remove_keys: Optional list of keys to remove from existing metadata.
|
|
||||||
"""
|
|
||||||
|
|
||||||
lkapi = create_livekit_client()
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = await lkapi.room.list_rooms(
|
|
||||||
ListRoomsRequest(
|
|
||||||
names=[room_name],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if not response.rooms:
|
|
||||||
return
|
|
||||||
|
|
||||||
room = response.rooms[0]
|
|
||||||
|
|
||||||
existing_metadata = json.loads(room.metadata) if room.metadata else {}
|
|
||||||
|
|
||||||
if remove_keys:
|
|
||||||
for key in remove_keys:
|
|
||||||
existing_metadata.pop(key, None)
|
|
||||||
|
|
||||||
updated_metadata = {**existing_metadata, **metadata}
|
|
||||||
|
|
||||||
await lkapi.room.update_room_metadata(
|
|
||||||
UpdateRoomMetadataRequest(
|
|
||||||
room=room_name, metadata=json.dumps(updated_metadata).encode("utf-8")
|
|
||||||
)
|
|
||||||
)
|
|
||||||
except TwirpError as e:
|
|
||||||
raise MetadataUpdateException(
|
|
||||||
f"Failed to update metadata for room {room_name}: {e}"
|
|
||||||
) from e
|
|
||||||
finally:
|
|
||||||
await lkapi.aclose()
|
|
||||||
|
|
||||||
|
|
||||||
ALPHANUMERIC_CHARSET = string.ascii_letters + string.digits
|
ALPHANUMERIC_CHARSET = string.ascii_letters + string.digits
|
||||||
|
|
||||||
|
|
||||||
@@ -459,12 +422,14 @@ def generate_upload_policy(file):
|
|||||||
return policy
|
return policy
|
||||||
|
|
||||||
|
|
||||||
def generate_download_file_url(file, *, expires_in: int, override_domain: bool = True):
|
def generate_download_s3_url(
|
||||||
|
key: str, *, expires_in: int, override_domain: bool = True
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Generate a S3 signed download url for a given file.
|
Generate a S3 signed download url for a given key.
|
||||||
"""
|
"""
|
||||||
|
if not key:
|
||||||
key = file.file_key
|
raise ValueError("key cannot be empty")
|
||||||
|
|
||||||
# This setting should be used if the backend application and the frontend application
|
# 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
|
# can't connect to the object storage with the same domain. This is the case in the
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-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"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -17,70 +17,92 @@ msgstr ""
|
|||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
|
||||||
#: core/admin.py:29
|
#: core/admin.py:67
|
||||||
msgid "Personal info"
|
msgid "Personal info"
|
||||||
msgstr "Persönliche Informationen"
|
msgstr "Persönliche Informationen"
|
||||||
|
|
||||||
#: core/admin.py:42
|
#: core/admin.py:80
|
||||||
msgid "Permissions"
|
msgid "Permissions"
|
||||||
msgstr "Berechtigungen"
|
msgstr "Berechtigungen"
|
||||||
|
|
||||||
#: core/admin.py:54
|
#: core/admin.py:92
|
||||||
msgid "Important dates"
|
msgid "Important dates"
|
||||||
msgstr "Wichtige Daten"
|
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"
|
msgid "No owner"
|
||||||
msgstr "Kein Eigentümer"
|
msgstr "Kein Eigentümer"
|
||||||
|
|
||||||
#: core/admin.py:135 core/admin.py:278
|
#: core/admin.py:303 core/admin.py:446
|
||||||
msgid "Multiple owners"
|
msgid "Multiple owners"
|
||||||
msgstr "Mehrere Eigentümer"
|
msgstr "Mehrere Eigentümer"
|
||||||
|
|
||||||
#: core/admin.py:148
|
#: core/admin.py:316
|
||||||
msgid "Resend notification to external service"
|
msgid "Resend notification to external service"
|
||||||
msgstr "Benachrichtigung erneut an externen Dienst senden"
|
msgstr "Benachrichtigung erneut an externen Dienst senden"
|
||||||
|
|
||||||
#: core/admin.py:171
|
#: core/admin.py:339
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s"
|
msgid "Failed to notify for recording %(id)s"
|
||||||
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen"
|
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen"
|
||||||
|
|
||||||
#: core/admin.py:179
|
#: core/admin.py:347
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||||
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen: %(error)s"
|
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen: %(error)s"
|
||||||
|
|
||||||
#: core/admin.py:187
|
#: core/admin.py:355
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||||
msgstr "Benachrichtigungen für %(count)s Aufnahme(n) erfolgreich gesendet."
|
msgstr "Benachrichtigungen für %(count)s Aufnahme(n) erfolgreich gesendet."
|
||||||
|
|
||||||
#: core/admin.py:195
|
#: core/admin.py:363
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Skipped %(count)s expired recording(s)."
|
msgid "Skipped %(count)s expired recording(s)."
|
||||||
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
|
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
|
||||||
|
|
||||||
#: core/admin.py:200
|
#: core/admin.py:368
|
||||||
msgid "Mark selected recordings as 'Failed to Stop'"
|
msgid "Mark selected recordings as 'Failed to Stop'"
|
||||||
msgstr "Ausgewählte Aufnahmen als ‚Fehler beim Stoppen‘ markieren"
|
msgstr "Ausgewählte Aufnahmen als ‚Fehler beim Stoppen‘ markieren"
|
||||||
|
|
||||||
#: core/admin.py:218
|
#: core/admin.py:386
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
||||||
msgstr "%(count)s Aufnahme(n) erfolgreich als ‚Fehler beim Stoppen‘ markiert."
|
msgstr "%(count)s Aufnahme(n) erfolgreich als ‚Fehler beim Stoppen‘ markiert."
|
||||||
|
|
||||||
#: core/admin.py:226
|
#: core/admin.py:394
|
||||||
#, fuzzy, python-format
|
#, fuzzy, python-format
|
||||||
#| msgid "Skipped %(count)s expired recording(s)."
|
#| msgid "Skipped %(count)s expired recording(s)."
|
||||||
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
||||||
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
|
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
|
||||||
|
|
||||||
#: core/admin.py:342
|
#: core/admin.py:510
|
||||||
msgid "No scopes"
|
msgid "No scopes"
|
||||||
msgstr "Keine Scopes"
|
msgstr "Keine Scopes"
|
||||||
|
|
||||||
#: core/admin.py:344
|
#: core/admin.py:512
|
||||||
msgid "Scopes"
|
msgid "Scopes"
|
||||||
msgstr "Scopes"
|
msgstr "Scopes"
|
||||||
|
|
||||||
@@ -88,17 +110,17 @@ msgstr "Scopes"
|
|||||||
msgid "Creator is me"
|
msgid "Creator is me"
|
||||||
msgstr "Ersteller bin ich"
|
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."
|
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
|
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
|
||||||
"hinzuzufügen."
|
"hinzuzufügen."
|
||||||
|
|
||||||
#: core/api/serializers.py:509
|
#: core/api/serializers.py:534
|
||||||
msgid "This file extension is not allowed."
|
msgid "This file extension is not allowed."
|
||||||
msgstr "Diese Dateiendung ist nicht erlaubt."
|
msgstr "Diese Dateiendung ist nicht erlaubt."
|
||||||
|
|
||||||
#: core/api/viewsets.py:1090
|
#: core/api/viewsets.py:1222
|
||||||
msgid "You have reached the maximum number of files for this type."
|
msgid "You have reached the maximum number of files for this type."
|
||||||
msgstr "Sie haben die maximale Anzahl an Dateien dieses Typs erreicht."
|
msgstr "Sie haben die maximale Anzahl an Dateien dieses Typs erreicht."
|
||||||
|
|
||||||
@@ -146,51 +168,59 @@ msgstr "Stopp fehlgeschlagen"
|
|||||||
msgid "Notification succeeded"
|
msgid "Notification succeeded"
|
||||||
msgstr "Benachrichtigung erfolgreich"
|
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"
|
msgid "SCREEN_RECORDING"
|
||||||
msgstr "BILDSCHIRMAUFZEICHNUNG"
|
msgstr "BILDSCHIRMAUFZEICHNUNG"
|
||||||
|
|
||||||
#: core/models.py:90
|
#: core/models.py:97
|
||||||
msgid "TRANSCRIPT"
|
msgid "TRANSCRIPT"
|
||||||
msgstr "TRANSKRIPT"
|
msgstr "TRANSKRIPT"
|
||||||
|
|
||||||
#: core/models.py:96
|
#: core/models.py:103
|
||||||
msgid "Public Access"
|
msgid "Public Access"
|
||||||
msgstr "Öffentlicher Zugriff"
|
msgstr "Öffentlicher Zugriff"
|
||||||
|
|
||||||
#: core/models.py:97
|
#: core/models.py:104
|
||||||
msgid "Trusted Access"
|
msgid "Trusted Access"
|
||||||
msgstr "Vertrauenswürdiger Zugriff"
|
msgstr "Vertrauenswürdiger Zugriff"
|
||||||
|
|
||||||
#: core/models.py:98
|
#: core/models.py:105
|
||||||
msgid "Restricted Access"
|
msgid "Restricted Access"
|
||||||
msgstr "Eingeschränkter Zugriff"
|
msgstr "Eingeschränkter Zugriff"
|
||||||
|
|
||||||
#: core/models.py:110
|
#: core/models.py:117
|
||||||
msgid "id"
|
msgid "id"
|
||||||
msgstr "ID"
|
msgstr "ID"
|
||||||
|
|
||||||
#: core/models.py:111
|
#: core/models.py:118
|
||||||
msgid "primary key for the record as UUID"
|
msgid "primary key for the record as UUID"
|
||||||
msgstr "Primärschlüssel des Eintrags als UUID"
|
msgstr "Primärschlüssel des Eintrags als UUID"
|
||||||
|
|
||||||
#: core/models.py:117
|
#: core/models.py:124
|
||||||
msgid "created on"
|
msgid "created on"
|
||||||
msgstr "erstellt am"
|
msgstr "erstellt am"
|
||||||
|
|
||||||
#: core/models.py:118
|
#: core/models.py:125
|
||||||
msgid "date and time at which a record was created"
|
msgid "date and time at which a record was created"
|
||||||
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
|
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
|
||||||
|
|
||||||
#: core/models.py:123
|
#: core/models.py:130
|
||||||
msgid "updated on"
|
msgid "updated on"
|
||||||
msgstr "aktualisiert am"
|
msgstr "aktualisiert am"
|
||||||
|
|
||||||
#: core/models.py:124
|
#: core/models.py:131
|
||||||
msgid "date and time at which a record was last updated"
|
msgid "date and time at which a record was last updated"
|
||||||
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
|
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
|
||||||
|
|
||||||
#: core/models.py:144
|
#: core/models.py:151
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||||
"_ characters."
|
"_ characters."
|
||||||
@@ -198,11 +228,11 @@ msgstr ""
|
|||||||
"Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen "
|
"Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen "
|
||||||
"und die Zeichen @/./+/-/_ enthalten."
|
"und die Zeichen @/./+/-/_ enthalten."
|
||||||
|
|
||||||
#: core/models.py:150
|
#: core/models.py:157
|
||||||
msgid "sub"
|
msgid "sub"
|
||||||
msgstr "Sub"
|
msgstr "Sub"
|
||||||
|
|
||||||
#: core/models.py:152
|
#: core/models.py:159
|
||||||
msgid ""
|
msgid ""
|
||||||
"Optional for pending users; required upon account activation. 255 characters "
|
"Optional for pending users; required upon account activation. 255 characters "
|
||||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||||
@@ -210,55 +240,55 @@ msgstr ""
|
|||||||
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
|
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
|
||||||
"Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt."
|
"Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt."
|
||||||
|
|
||||||
#: core/models.py:161
|
#: core/models.py:168
|
||||||
msgid "identity email address"
|
msgid "identity email address"
|
||||||
msgstr "Identitäts-E-Mail-Adresse"
|
msgstr "Identitäts-E-Mail-Adresse"
|
||||||
|
|
||||||
#: core/models.py:166
|
#: core/models.py:173
|
||||||
msgid "admin email address"
|
msgid "admin email address"
|
||||||
msgstr "Administrator-E-Mail-Adresse"
|
msgstr "Administrator-E-Mail-Adresse"
|
||||||
|
|
||||||
#: core/models.py:168
|
#: core/models.py:175
|
||||||
msgid "full name"
|
msgid "full name"
|
||||||
msgstr "Vollständiger Name"
|
msgstr "Vollständiger Name"
|
||||||
|
|
||||||
#: core/models.py:170
|
#: core/models.py:177
|
||||||
msgid "short name"
|
msgid "short name"
|
||||||
msgstr "Kurzname"
|
msgstr "Kurzname"
|
||||||
|
|
||||||
#: core/models.py:176
|
#: core/models.py:183
|
||||||
msgid "language"
|
msgid "language"
|
||||||
msgstr "Sprache"
|
msgstr "Sprache"
|
||||||
|
|
||||||
#: core/models.py:177
|
#: core/models.py:184
|
||||||
msgid "The language in which the user wants to see the interface."
|
msgid "The language in which the user wants to see the interface."
|
||||||
msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
|
msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
|
||||||
|
|
||||||
#: core/models.py:183
|
#: core/models.py:190
|
||||||
msgid "The timezone in which the user wants to see times."
|
msgid "The timezone in which the user wants to see times."
|
||||||
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
|
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
|
||||||
|
|
||||||
#: core/models.py:186
|
#: core/models.py:193
|
||||||
msgid "device"
|
msgid "device"
|
||||||
msgstr "Gerät"
|
msgstr "Gerät"
|
||||||
|
|
||||||
#: core/models.py:188
|
#: core/models.py:195
|
||||||
msgid "Whether the user is a device or a real user."
|
msgid "Whether the user is a device or a real user."
|
||||||
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
|
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
|
||||||
|
|
||||||
#: core/models.py:191
|
#: core/models.py:198
|
||||||
msgid "staff status"
|
msgid "staff status"
|
||||||
msgstr "Mitarbeiterstatus"
|
msgstr "Mitarbeiterstatus"
|
||||||
|
|
||||||
#: core/models.py:193
|
#: core/models.py:200
|
||||||
msgid "Whether the user can log into this admin site."
|
msgid "Whether the user can log into this admin site."
|
||||||
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
|
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
|
||||||
|
|
||||||
#: core/models.py:196
|
#: core/models.py:203
|
||||||
msgid "active"
|
msgid "active"
|
||||||
msgstr "aktiv"
|
msgstr "aktiv"
|
||||||
|
|
||||||
#: core/models.py:199
|
#: core/models.py:206
|
||||||
msgid ""
|
msgid ""
|
||||||
"Whether this user should be treated as active. Unselect this instead of "
|
"Whether this user should be treated as active. Unselect this instead of "
|
||||||
"deleting accounts."
|
"deleting accounts."
|
||||||
@@ -266,66 +296,66 @@ msgstr ""
|
|||||||
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
|
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
|
||||||
"anstelle des Löschens des Kontos."
|
"anstelle des Löschens des Kontos."
|
||||||
|
|
||||||
#: core/models.py:212
|
#: core/models.py:219
|
||||||
msgid "user"
|
msgid "user"
|
||||||
msgstr "Benutzer"
|
msgstr "Benutzer"
|
||||||
|
|
||||||
#: core/models.py:213
|
#: core/models.py:220
|
||||||
msgid "users"
|
msgid "users"
|
||||||
msgstr "Benutzer"
|
msgstr "Benutzer"
|
||||||
|
|
||||||
#: core/models.py:272
|
#: core/models.py:286
|
||||||
msgid "Resource"
|
msgid "Resource"
|
||||||
msgstr "Ressource"
|
msgstr "Ressource"
|
||||||
|
|
||||||
#: core/models.py:273
|
#: core/models.py:287
|
||||||
msgid "Resources"
|
msgid "Resources"
|
||||||
msgstr "Ressourcen"
|
msgstr "Ressourcen"
|
||||||
|
|
||||||
#: core/models.py:331
|
#: core/models.py:345
|
||||||
msgid "Resource access"
|
msgid "Resource access"
|
||||||
msgstr "Ressourcenzugriff"
|
msgstr "Ressourcenzugriff"
|
||||||
|
|
||||||
#: core/models.py:332
|
#: core/models.py:346
|
||||||
msgid "Resource accesses"
|
msgid "Resource accesses"
|
||||||
msgstr "Ressourcenzugriffe"
|
msgstr "Ressourcenzugriffe"
|
||||||
|
|
||||||
#: core/models.py:338
|
#: core/models.py:352
|
||||||
msgid "Resource access with this User and Resource already exists."
|
msgid "Resource access with this User and Resource already exists."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
|
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
|
||||||
"bereits."
|
"bereits."
|
||||||
|
|
||||||
#: core/models.py:394
|
#: core/models.py:409
|
||||||
msgid "Visio room configuration"
|
msgid "Visio room configuration"
|
||||||
msgstr "Visio-Raumkonfiguration"
|
msgstr "Visio-Raumkonfiguration"
|
||||||
|
|
||||||
#: core/models.py:395
|
#: core/models.py:410
|
||||||
msgid "Values for Visio parameters to configure the room."
|
msgid "Values for Visio parameters to configure the room."
|
||||||
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
|
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
|
||||||
|
|
||||||
#: core/models.py:402
|
#: core/models.py:417
|
||||||
msgid "Room PIN code"
|
msgid "Room PIN code"
|
||||||
msgstr "PIN-Code für den Raum"
|
msgstr "PIN-Code für den Raum"
|
||||||
|
|
||||||
#: core/models.py:403
|
#: core/models.py:418
|
||||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
|
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
|
||||||
|
|
||||||
#: core/models.py:409 core/models.py:563
|
#: core/models.py:424 core/models.py:578
|
||||||
msgid "Room"
|
msgid "Room"
|
||||||
msgstr "Raum"
|
msgstr "Raum"
|
||||||
|
|
||||||
#: core/models.py:410
|
#: core/models.py:425
|
||||||
msgid "Rooms"
|
msgid "Rooms"
|
||||||
msgstr "Räume"
|
msgstr "Räume"
|
||||||
|
|
||||||
#: core/models.py:574
|
#: core/models.py:589
|
||||||
msgid "Worker ID"
|
msgid "Worker ID"
|
||||||
msgstr "Worker-ID"
|
msgstr "Worker-ID"
|
||||||
|
|
||||||
#: core/models.py:576
|
#: core/models.py:591
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||||
"the worker stops, allowing for easy tracking."
|
"the worker stops, allowing for easy tracking."
|
||||||
@@ -334,141 +364,153 @@ msgstr ""
|
|||||||
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
|
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
|
||||||
"ermöglicht."
|
"ermöglicht."
|
||||||
|
|
||||||
#: core/models.py:584
|
#: core/models.py:599
|
||||||
msgid "Recording mode"
|
msgid "Recording mode"
|
||||||
msgstr "Aufzeichnungsmodus"
|
msgstr "Aufzeichnungsmodus"
|
||||||
|
|
||||||
#: core/models.py:585
|
#: core/models.py:600
|
||||||
msgid "Defines the mode of recording being called."
|
msgid "Defines the mode of recording being called."
|
||||||
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
|
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
|
||||||
|
|
||||||
#: core/models.py:590 core/models.py:591
|
#: core/models.py:605 core/models.py:606
|
||||||
msgid "Recording options"
|
msgid "Recording options"
|
||||||
msgstr "Aufnahmeoptionen"
|
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"
|
msgid "Recording"
|
||||||
msgstr "Aufzeichnung"
|
msgstr "Aufzeichnung"
|
||||||
|
|
||||||
#: core/models.py:598
|
#: core/models.py:621
|
||||||
msgid "Recordings"
|
msgid "Recordings"
|
||||||
msgstr "Aufzeichnungen"
|
msgstr "Aufzeichnungen"
|
||||||
|
|
||||||
#: core/models.py:706
|
#: core/models.py:731
|
||||||
msgid "Recording/user relation"
|
msgid "Recording/user relation"
|
||||||
msgstr "Beziehung Aufzeichnung/Benutzer"
|
msgstr "Beziehung Aufzeichnung/Benutzer"
|
||||||
|
|
||||||
#: core/models.py:707
|
#: core/models.py:732
|
||||||
msgid "Recording/user relations"
|
msgid "Recording/user relations"
|
||||||
msgstr "Beziehungen Aufzeichnung/Benutzer"
|
msgstr "Beziehungen Aufzeichnung/Benutzer"
|
||||||
|
|
||||||
#: core/models.py:713
|
#: core/models.py:738
|
||||||
msgid "This user is already in this recording."
|
msgid "This user is already in this recording."
|
||||||
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
|
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
|
||||||
|
|
||||||
#: core/models.py:719
|
#: core/models.py:744
|
||||||
msgid "This team is already in this recording."
|
msgid "This team is already in this recording."
|
||||||
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
|
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
|
||||||
|
|
||||||
#: core/models.py:725
|
#: core/models.py:750
|
||||||
msgid "Either user or team must be set, not both."
|
msgid "Either user or team must be set, not both."
|
||||||
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
|
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
|
||||||
|
|
||||||
#: core/models.py:742
|
#: core/models.py:767
|
||||||
msgid "Create rooms"
|
msgid "Create rooms"
|
||||||
msgstr "Räume erstellen"
|
msgstr "Räume erstellen"
|
||||||
|
|
||||||
#: core/models.py:743
|
#: core/models.py:768
|
||||||
msgid "List rooms"
|
msgid "List rooms"
|
||||||
msgstr "Räume auflisten"
|
msgstr "Räume auflisten"
|
||||||
|
|
||||||
#: core/models.py:744
|
#: core/models.py:769
|
||||||
msgid "Retrieve room details"
|
msgid "Retrieve room details"
|
||||||
msgstr "Raumdetails abrufen"
|
msgstr "Raumdetails abrufen"
|
||||||
|
|
||||||
#: core/models.py:745
|
#: core/models.py:770
|
||||||
msgid "Update rooms"
|
msgid "Update rooms"
|
||||||
msgstr "Räume aktualisieren"
|
msgstr "Räume aktualisieren"
|
||||||
|
|
||||||
#: core/models.py:746
|
#: core/models.py:771
|
||||||
msgid "Delete rooms"
|
msgid "Delete rooms"
|
||||||
msgstr "Räume löschen"
|
msgstr "Räume löschen"
|
||||||
|
|
||||||
#: core/models.py:759
|
#: core/models.py:784
|
||||||
msgid "Application name"
|
msgid "Application name"
|
||||||
msgstr "Anwendungsname"
|
msgstr "Anwendungsname"
|
||||||
|
|
||||||
#: core/models.py:760
|
#: core/models.py:785
|
||||||
msgid "Descriptive name for this application."
|
msgid "Descriptive name for this application."
|
||||||
msgstr "Beschreibender Name für diese Anwendung."
|
msgstr "Beschreibender Name für diese Anwendung."
|
||||||
|
|
||||||
#: core/models.py:770
|
#: core/models.py:795
|
||||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
|
"Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
|
||||||
|
|
||||||
#: core/models.py:781
|
#: core/models.py:806
|
||||||
msgid "Application"
|
msgid "Application"
|
||||||
msgstr "Anwendung"
|
msgstr "Anwendung"
|
||||||
|
|
||||||
#: core/models.py:782
|
#: core/models.py:807
|
||||||
msgid "Applications"
|
msgid "Applications"
|
||||||
msgstr "Anwendungen"
|
msgstr "Anwendungen"
|
||||||
|
|
||||||
#: core/models.py:805
|
#: core/models.py:830
|
||||||
msgid "Enter a valid domain"
|
msgid "Enter a valid domain"
|
||||||
msgstr "Geben Sie eine gültige Domain ein"
|
msgstr "Geben Sie eine gültige Domain ein"
|
||||||
|
|
||||||
#: core/models.py:808
|
#: core/models.py:833
|
||||||
msgid "Domain"
|
msgid "Domain"
|
||||||
msgstr "Domain"
|
msgstr "Domain"
|
||||||
|
|
||||||
#: core/models.py:809
|
#: core/models.py:834
|
||||||
msgid "Email domain this application can act on behalf of."
|
msgid "Email domain this application can act on behalf of."
|
||||||
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
|
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
|
||||||
|
|
||||||
#: core/models.py:821
|
#: core/models.py:846
|
||||||
msgid "Application domain"
|
msgid "Application domain"
|
||||||
msgstr "Anwendungsdomain"
|
msgstr "Anwendungsdomain"
|
||||||
|
|
||||||
#: core/models.py:822
|
#: core/models.py:847
|
||||||
msgid "Application domains"
|
msgid "Application domains"
|
||||||
msgstr "Anwendungsdomains"
|
msgstr "Anwendungsdomains"
|
||||||
|
|
||||||
#: core/models.py:840
|
#: core/models.py:865
|
||||||
msgid "Pending"
|
msgid "Pending"
|
||||||
msgstr "Ausstehend"
|
msgstr "Ausstehend"
|
||||||
|
|
||||||
#: core/models.py:848
|
#: core/models.py:866
|
||||||
|
msgid "Analyzing"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/models.py:873
|
||||||
msgid "Ready"
|
msgid "Ready"
|
||||||
msgstr "Bereit"
|
msgstr "Bereit"
|
||||||
|
|
||||||
#: core/models.py:854
|
#: core/models.py:879
|
||||||
msgid "Background image"
|
msgid "Background image"
|
||||||
msgstr "Hintergrundbild"
|
msgstr "Hintergrundbild"
|
||||||
|
|
||||||
#: core/models.py:866
|
#: core/models.py:891
|
||||||
msgid "title"
|
msgid "title"
|
||||||
msgstr "Titel"
|
msgstr "Titel"
|
||||||
|
|
||||||
#: core/models.py:890
|
#: core/models.py:915
|
||||||
msgid "Malware detection info when the analysis status is unsafe."
|
msgid "Malware detection info when the analysis status is unsafe."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Informationen zur Malware-Erkennung, wenn der Analyse-Status unsicher ist."
|
"Informationen zur Malware-Erkennung, wenn der Analyse-Status unsicher ist."
|
||||||
|
|
||||||
#: core/models.py:895
|
#: core/models.py:920
|
||||||
msgid "File"
|
msgid "File"
|
||||||
msgstr "Datei"
|
msgstr "Datei"
|
||||||
|
|
||||||
#: core/models.py:896
|
#: core/models.py:921
|
||||||
msgid "Files"
|
msgid "Files"
|
||||||
msgstr "Dateien"
|
msgstr "Dateien"
|
||||||
|
|
||||||
#: core/models.py:1000
|
#: core/models.py:1041
|
||||||
msgid "This file is already hard deleted."
|
msgid "This file is already hard deleted."
|
||||||
msgstr "Diese Datei wurde bereits endgültig gelöscht."
|
msgstr "Diese Datei wurde bereits endgültig gelöscht."
|
||||||
|
|
||||||
#: core/models.py:1010
|
#: core/models.py:1051
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
#| msgid "To hard delete a file, it must first be soft deleted."
|
#| msgid "To hard delete a file, it must first be soft deleted."
|
||||||
msgid "To hard delete a file, it must first be soft deleted."
|
msgid "To hard delete a file, it must first be soft deleted."
|
||||||
@@ -476,10 +518,20 @@ msgstr ""
|
|||||||
"Um eine Datei endgültig zu löschen, muss sie zuvor weich gelöscht worden "
|
"Um eine Datei endgültig zu löschen, muss sie zuvor weich gelöscht worden "
|
||||||
"sein."
|
"sein."
|
||||||
|
|
||||||
#: core/recording/event/notification.py:116
|
#: core/recording/event/notification.py:123
|
||||||
msgid "Your recording is ready"
|
msgid "Your recording is ready"
|
||||||
msgstr "Ihre Aufzeichnung ist bereit"
|
msgstr "Ihre Aufzeichnung ist bereit"
|
||||||
|
|
||||||
|
#: core/recording/event/notification.py: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
|
#: core/services/invitation.py:44
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||||
@@ -575,7 +627,7 @@ msgid "To keep this recording permanently:"
|
|||||||
msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
|
msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:208
|
#: 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 "
|
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||||
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
|
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
|
||||||
|
|
||||||
@@ -604,18 +656,24 @@ msgstr ""
|
|||||||
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
|
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
|
||||||
"an unser Support-Team unter %(support_email)s. "
|
"an unser Support-Team unter %(support_email)s. "
|
||||||
|
|
||||||
#: 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"
|
msgid "English"
|
||||||
msgstr "Englisch"
|
msgstr "Englisch"
|
||||||
|
|
||||||
#: meet/settings.py:224
|
#: meet/settings.py:229
|
||||||
msgid "French"
|
msgid "French"
|
||||||
msgstr "Französisch"
|
msgstr "Französisch"
|
||||||
|
|
||||||
#: meet/settings.py:225
|
#: meet/settings.py:230
|
||||||
msgid "Dutch"
|
msgid "Dutch"
|
||||||
msgstr "Niederländisch"
|
msgstr "Niederländisch"
|
||||||
|
|
||||||
#: meet/settings.py:226
|
#: meet/settings.py:231
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr "Deutsch"
|
msgstr "Deutsch"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-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"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -17,70 +17,92 @@ msgstr ""
|
|||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
|
||||||
#: core/admin.py:29
|
#: core/admin.py:67
|
||||||
msgid "Personal info"
|
msgid "Personal info"
|
||||||
msgstr "Personal info"
|
msgstr "Personal info"
|
||||||
|
|
||||||
#: core/admin.py:42
|
#: core/admin.py:80
|
||||||
msgid "Permissions"
|
msgid "Permissions"
|
||||||
msgstr "Permissions"
|
msgstr "Permissions"
|
||||||
|
|
||||||
#: core/admin.py:54
|
#: core/admin.py:92
|
||||||
msgid "Important dates"
|
msgid "Important dates"
|
||||||
msgstr "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"
|
msgid "No owner"
|
||||||
msgstr "No owner"
|
msgstr "No owner"
|
||||||
|
|
||||||
#: core/admin.py:135 core/admin.py:278
|
#: core/admin.py:303 core/admin.py:446
|
||||||
msgid "Multiple owners"
|
msgid "Multiple owners"
|
||||||
msgstr "Multiple owners"
|
msgstr "Multiple owners"
|
||||||
|
|
||||||
#: core/admin.py:148
|
#: core/admin.py:316
|
||||||
msgid "Resend notification to external service"
|
msgid "Resend notification to external service"
|
||||||
msgstr "Resend notification to external service"
|
msgstr "Resend notification to external service"
|
||||||
|
|
||||||
#: core/admin.py:171
|
#: core/admin.py:339
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s"
|
msgid "Failed to notify for recording %(id)s"
|
||||||
msgstr "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
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||||
msgstr "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
|
#, python-format
|
||||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||||
msgstr "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
|
#, python-format
|
||||||
msgid "Skipped %(count)s expired recording(s)."
|
msgid "Skipped %(count)s expired recording(s)."
|
||||||
msgstr "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'"
|
msgid "Mark selected recordings as 'Failed to Stop'"
|
||||||
msgstr "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
|
#, python-format
|
||||||
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
||||||
msgstr "%(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
|
#, fuzzy, python-format
|
||||||
#| msgid "Skipped %(count)s expired recording(s)."
|
#| msgid "Skipped %(count)s expired recording(s)."
|
||||||
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
||||||
msgstr "Skipped %(count)s expired recording(s)."
|
msgstr "Skipped %(count)s expired recording(s)."
|
||||||
|
|
||||||
#: core/admin.py:342
|
#: core/admin.py:510
|
||||||
msgid "No scopes"
|
msgid "No scopes"
|
||||||
msgstr "No scopes"
|
msgstr "No scopes"
|
||||||
|
|
||||||
#: core/admin.py:344
|
#: core/admin.py:512
|
||||||
msgid "Scopes"
|
msgid "Scopes"
|
||||||
msgstr "Scopes"
|
msgstr "Scopes"
|
||||||
|
|
||||||
@@ -88,15 +110,15 @@ msgstr "Scopes"
|
|||||||
msgid "Creator is me"
|
msgid "Creator is me"
|
||||||
msgstr "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."
|
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||||
msgstr "You must be administrator or owner of a room to add accesses to it."
|
msgstr "You must be administrator or owner of a room to add accesses to it."
|
||||||
|
|
||||||
#: core/api/serializers.py:509
|
#: core/api/serializers.py:534
|
||||||
msgid "This file extension is not allowed."
|
msgid "This file extension is not allowed."
|
||||||
msgstr "This file extension is not allowed."
|
msgstr "This file extension is not allowed."
|
||||||
|
|
||||||
#: core/api/viewsets.py:1090
|
#: core/api/viewsets.py:1222
|
||||||
msgid "You have reached the maximum number of files for this type."
|
msgid "You have reached the maximum number of files for this type."
|
||||||
msgstr "You have reached the maximum number of files for this type."
|
msgstr "You have reached the maximum number of files for this type."
|
||||||
|
|
||||||
@@ -144,51 +166,59 @@ msgstr "Failed to Stop"
|
|||||||
msgid "Notification succeeded"
|
msgid "Notification succeeded"
|
||||||
msgstr "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"
|
msgid "SCREEN_RECORDING"
|
||||||
msgstr "SCREEN_RECORDING"
|
msgstr "SCREEN_RECORDING"
|
||||||
|
|
||||||
#: core/models.py:90
|
#: core/models.py:97
|
||||||
msgid "TRANSCRIPT"
|
msgid "TRANSCRIPT"
|
||||||
msgstr "TRANSCRIPT"
|
msgstr "TRANSCRIPT"
|
||||||
|
|
||||||
#: core/models.py:96
|
#: core/models.py:103
|
||||||
msgid "Public Access"
|
msgid "Public Access"
|
||||||
msgstr "Public Access"
|
msgstr "Public Access"
|
||||||
|
|
||||||
#: core/models.py:97
|
#: core/models.py:104
|
||||||
msgid "Trusted Access"
|
msgid "Trusted Access"
|
||||||
msgstr "Trusted Access"
|
msgstr "Trusted Access"
|
||||||
|
|
||||||
#: core/models.py:98
|
#: core/models.py:105
|
||||||
msgid "Restricted Access"
|
msgid "Restricted Access"
|
||||||
msgstr "Restricted Access"
|
msgstr "Restricted Access"
|
||||||
|
|
||||||
#: core/models.py:110
|
#: core/models.py:117
|
||||||
msgid "id"
|
msgid "id"
|
||||||
msgstr "id"
|
msgstr "id"
|
||||||
|
|
||||||
#: core/models.py:111
|
#: core/models.py:118
|
||||||
msgid "primary key for the record as UUID"
|
msgid "primary key for the record as UUID"
|
||||||
msgstr "primary key for the record as UUID"
|
msgstr "primary key for the record as UUID"
|
||||||
|
|
||||||
#: core/models.py:117
|
#: core/models.py:124
|
||||||
msgid "created on"
|
msgid "created on"
|
||||||
msgstr "created on"
|
msgstr "created on"
|
||||||
|
|
||||||
#: core/models.py:118
|
#: core/models.py:125
|
||||||
msgid "date and time at which a record was created"
|
msgid "date and time at which a record was created"
|
||||||
msgstr "date and time at which a record was created"
|
msgstr "date and time at which a record was created"
|
||||||
|
|
||||||
#: core/models.py:123
|
#: core/models.py:130
|
||||||
msgid "updated on"
|
msgid "updated on"
|
||||||
msgstr "updated on"
|
msgstr "updated on"
|
||||||
|
|
||||||
#: core/models.py:124
|
#: core/models.py:131
|
||||||
msgid "date and time at which a record was last updated"
|
msgid "date and time at which a record was last updated"
|
||||||
msgstr "date and time at which a record was last updated"
|
msgstr "date and time at which a record was last updated"
|
||||||
|
|
||||||
#: core/models.py:144
|
#: core/models.py:151
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||||
"_ characters."
|
"_ characters."
|
||||||
@@ -196,11 +226,11 @@ msgstr ""
|
|||||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||||
"_ characters."
|
"_ characters."
|
||||||
|
|
||||||
#: core/models.py:150
|
#: core/models.py:157
|
||||||
msgid "sub"
|
msgid "sub"
|
||||||
msgstr "sub"
|
msgstr "sub"
|
||||||
|
|
||||||
#: core/models.py:152
|
#: core/models.py:159
|
||||||
msgid ""
|
msgid ""
|
||||||
"Optional for pending users; required upon account activation. 255 characters "
|
"Optional for pending users; required upon account activation. 255 characters "
|
||||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||||
@@ -208,55 +238,55 @@ msgstr ""
|
|||||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||||
"characters only."
|
"characters only."
|
||||||
|
|
||||||
#: core/models.py:161
|
#: core/models.py:168
|
||||||
msgid "identity email address"
|
msgid "identity email address"
|
||||||
msgstr "identity email address"
|
msgstr "identity email address"
|
||||||
|
|
||||||
#: core/models.py:166
|
#: core/models.py:173
|
||||||
msgid "admin email address"
|
msgid "admin email address"
|
||||||
msgstr "admin email address"
|
msgstr "admin email address"
|
||||||
|
|
||||||
#: core/models.py:168
|
#: core/models.py:175
|
||||||
msgid "full name"
|
msgid "full name"
|
||||||
msgstr "full name"
|
msgstr "full name"
|
||||||
|
|
||||||
#: core/models.py:170
|
#: core/models.py:177
|
||||||
msgid "short name"
|
msgid "short name"
|
||||||
msgstr "short name"
|
msgstr "short name"
|
||||||
|
|
||||||
#: core/models.py:176
|
#: core/models.py:183
|
||||||
msgid "language"
|
msgid "language"
|
||||||
msgstr "language"
|
msgstr "language"
|
||||||
|
|
||||||
#: core/models.py:177
|
#: core/models.py:184
|
||||||
msgid "The language in which the user wants to see the interface."
|
msgid "The language in which the user wants to see the interface."
|
||||||
msgstr "The language in which the user wants to see the interface."
|
msgstr "The language in which the user wants to see the interface."
|
||||||
|
|
||||||
#: core/models.py:183
|
#: core/models.py:190
|
||||||
msgid "The timezone in which the user wants to see times."
|
msgid "The timezone in which the user wants to see times."
|
||||||
msgstr "The timezone in which the user wants to see times."
|
msgstr "The timezone in which the user wants to see times."
|
||||||
|
|
||||||
#: core/models.py:186
|
#: core/models.py:193
|
||||||
msgid "device"
|
msgid "device"
|
||||||
msgstr "device"
|
msgstr "device"
|
||||||
|
|
||||||
#: core/models.py:188
|
#: core/models.py:195
|
||||||
msgid "Whether the user is a device or a real user."
|
msgid "Whether the user is a device or a real user."
|
||||||
msgstr "Whether the user is a device or a real user."
|
msgstr "Whether the user is a device or a real user."
|
||||||
|
|
||||||
#: core/models.py:191
|
#: core/models.py:198
|
||||||
msgid "staff status"
|
msgid "staff status"
|
||||||
msgstr "staff status"
|
msgstr "staff status"
|
||||||
|
|
||||||
#: core/models.py:193
|
#: core/models.py:200
|
||||||
msgid "Whether the user can log into this admin site."
|
msgid "Whether the user can log into this admin site."
|
||||||
msgstr "Whether the user can log into this admin site."
|
msgstr "Whether the user can log into this admin site."
|
||||||
|
|
||||||
#: core/models.py:196
|
#: core/models.py:203
|
||||||
msgid "active"
|
msgid "active"
|
||||||
msgstr "active"
|
msgstr "active"
|
||||||
|
|
||||||
#: core/models.py:199
|
#: core/models.py:206
|
||||||
msgid ""
|
msgid ""
|
||||||
"Whether this user should be treated as active. Unselect this instead of "
|
"Whether this user should be treated as active. Unselect this instead of "
|
||||||
"deleting accounts."
|
"deleting accounts."
|
||||||
@@ -264,63 +294,63 @@ msgstr ""
|
|||||||
"Whether this user should be treated as active. Unselect this instead of "
|
"Whether this user should be treated as active. Unselect this instead of "
|
||||||
"deleting accounts."
|
"deleting accounts."
|
||||||
|
|
||||||
#: core/models.py:212
|
#: core/models.py:219
|
||||||
msgid "user"
|
msgid "user"
|
||||||
msgstr "user"
|
msgstr "user"
|
||||||
|
|
||||||
#: core/models.py:213
|
#: core/models.py:220
|
||||||
msgid "users"
|
msgid "users"
|
||||||
msgstr "users"
|
msgstr "users"
|
||||||
|
|
||||||
#: core/models.py:272
|
#: core/models.py:286
|
||||||
msgid "Resource"
|
msgid "Resource"
|
||||||
msgstr "Resource"
|
msgstr "Resource"
|
||||||
|
|
||||||
#: core/models.py:273
|
#: core/models.py:287
|
||||||
msgid "Resources"
|
msgid "Resources"
|
||||||
msgstr "Resources"
|
msgstr "Resources"
|
||||||
|
|
||||||
#: core/models.py:331
|
#: core/models.py:345
|
||||||
msgid "Resource access"
|
msgid "Resource access"
|
||||||
msgstr "Resource access"
|
msgstr "Resource access"
|
||||||
|
|
||||||
#: core/models.py:332
|
#: core/models.py:346
|
||||||
msgid "Resource accesses"
|
msgid "Resource accesses"
|
||||||
msgstr "Resource accesses"
|
msgstr "Resource accesses"
|
||||||
|
|
||||||
#: core/models.py:338
|
#: core/models.py:352
|
||||||
msgid "Resource access with this User and Resource already exists."
|
msgid "Resource access with this User and Resource already exists."
|
||||||
msgstr "Resource access with this User and Resource already exists."
|
msgstr "Resource access with this User and Resource already exists."
|
||||||
|
|
||||||
#: core/models.py:394
|
#: core/models.py:409
|
||||||
msgid "Visio room configuration"
|
msgid "Visio room configuration"
|
||||||
msgstr "Visio room configuration"
|
msgstr "Visio room configuration"
|
||||||
|
|
||||||
#: core/models.py:395
|
#: core/models.py:410
|
||||||
msgid "Values for Visio parameters to configure the room."
|
msgid "Values for Visio parameters to configure the room."
|
||||||
msgstr "Values for Visio parameters to configure the room."
|
msgstr "Values for Visio parameters to configure the room."
|
||||||
|
|
||||||
#: core/models.py:402
|
#: core/models.py:417
|
||||||
msgid "Room PIN code"
|
msgid "Room PIN code"
|
||||||
msgstr "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."
|
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||||
msgstr "Unique n-digit code that identifies this room in telephony mode."
|
msgstr "Unique n-digit code that identifies this room in telephony mode."
|
||||||
|
|
||||||
#: core/models.py:409 core/models.py:563
|
#: core/models.py:424 core/models.py:578
|
||||||
msgid "Room"
|
msgid "Room"
|
||||||
msgstr "Room"
|
msgstr "Room"
|
||||||
|
|
||||||
#: core/models.py:410
|
#: core/models.py:425
|
||||||
msgid "Rooms"
|
msgid "Rooms"
|
||||||
msgstr "Rooms"
|
msgstr "Rooms"
|
||||||
|
|
||||||
#: core/models.py:574
|
#: core/models.py:589
|
||||||
msgid "Worker ID"
|
msgid "Worker ID"
|
||||||
msgstr "Worker ID"
|
msgstr "Worker ID"
|
||||||
|
|
||||||
#: core/models.py:576
|
#: core/models.py:591
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||||
"the worker stops, allowing for easy tracking."
|
"the worker stops, allowing for easy tracking."
|
||||||
@@ -328,154 +358,175 @@ msgstr ""
|
|||||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||||
"the worker stops, allowing for easy tracking."
|
"the worker stops, allowing for easy tracking."
|
||||||
|
|
||||||
#: core/models.py:584
|
#: core/models.py:599
|
||||||
msgid "Recording mode"
|
msgid "Recording mode"
|
||||||
msgstr "Recording mode"
|
msgstr "Recording mode"
|
||||||
|
|
||||||
#: core/models.py:585
|
#: core/models.py:600
|
||||||
msgid "Defines the mode of recording being called."
|
msgid "Defines the mode of recording being called."
|
||||||
msgstr "Defines the mode of recording being called."
|
msgstr "Defines the mode of recording being called."
|
||||||
|
|
||||||
#: core/models.py:590 core/models.py:591
|
#: core/models.py:605 core/models.py:606
|
||||||
msgid "Recording options"
|
msgid "Recording options"
|
||||||
msgstr "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"
|
msgid "Recording"
|
||||||
msgstr "Recording"
|
msgstr "Recording"
|
||||||
|
|
||||||
#: core/models.py:598
|
#: core/models.py:621
|
||||||
msgid "Recordings"
|
msgid "Recordings"
|
||||||
msgstr "Recordings"
|
msgstr "Recordings"
|
||||||
|
|
||||||
#: core/models.py:706
|
#: core/models.py:731
|
||||||
msgid "Recording/user relation"
|
msgid "Recording/user relation"
|
||||||
msgstr "Recording/user relation"
|
msgstr "Recording/user relation"
|
||||||
|
|
||||||
#: core/models.py:707
|
#: core/models.py:732
|
||||||
msgid "Recording/user relations"
|
msgid "Recording/user relations"
|
||||||
msgstr "Recording/user relations"
|
msgstr "Recording/user relations"
|
||||||
|
|
||||||
#: core/models.py:713
|
#: core/models.py:738
|
||||||
msgid "This user is already in this recording."
|
msgid "This user is already in this recording."
|
||||||
msgstr "This user is already in this recording."
|
msgstr "This user is already in this recording."
|
||||||
|
|
||||||
#: core/models.py:719
|
#: core/models.py:744
|
||||||
msgid "This team is already in this recording."
|
msgid "This team is already in this recording."
|
||||||
msgstr "This team is already in this recording."
|
msgstr "This team is already in this recording."
|
||||||
|
|
||||||
#: core/models.py:725
|
#: core/models.py:750
|
||||||
msgid "Either user or team must be set, not both."
|
msgid "Either user or team must be set, not both."
|
||||||
msgstr "Either user or team must be set, not both."
|
msgstr "Either user or team must be set, not both."
|
||||||
|
|
||||||
#: core/models.py:742
|
#: core/models.py:767
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
#| msgid "created on"
|
#| msgid "created on"
|
||||||
msgid "Create rooms"
|
msgid "Create rooms"
|
||||||
msgstr "Create rooms"
|
msgstr "Create rooms"
|
||||||
|
|
||||||
#: core/models.py:743
|
#: core/models.py:768
|
||||||
msgid "List rooms"
|
msgid "List rooms"
|
||||||
msgstr "List rooms"
|
msgstr "List rooms"
|
||||||
|
|
||||||
#: core/models.py:744
|
#: core/models.py:769
|
||||||
msgid "Retrieve room details"
|
msgid "Retrieve room details"
|
||||||
msgstr "Retrieve room details"
|
msgstr "Retrieve room details"
|
||||||
|
|
||||||
#: core/models.py:745
|
#: core/models.py:770
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
#| msgid "updated on"
|
#| msgid "updated on"
|
||||||
msgid "Update rooms"
|
msgid "Update rooms"
|
||||||
msgstr "Update rooms"
|
msgstr "Update rooms"
|
||||||
|
|
||||||
#: core/models.py:746
|
#: core/models.py:771
|
||||||
msgid "Delete rooms"
|
msgid "Delete rooms"
|
||||||
msgstr "Delete rooms"
|
msgstr "Delete rooms"
|
||||||
|
|
||||||
#: core/models.py:759
|
#: core/models.py:784
|
||||||
msgid "Application name"
|
msgid "Application name"
|
||||||
msgstr "Application name"
|
msgstr "Application name"
|
||||||
|
|
||||||
#: core/models.py:760
|
#: core/models.py:785
|
||||||
msgid "Descriptive name for this application."
|
msgid "Descriptive name for this application."
|
||||||
msgstr "Descriptive name for this application."
|
msgstr "Descriptive name for this application."
|
||||||
|
|
||||||
#: core/models.py:770
|
#: core/models.py:795
|
||||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||||
msgstr "Hashed on Save. Copy it now if this is a new secret."
|
msgstr "Hashed on Save. Copy it now if this is a new secret."
|
||||||
|
|
||||||
#: core/models.py:781
|
#: core/models.py:806
|
||||||
msgid "Application"
|
msgid "Application"
|
||||||
msgstr "Application"
|
msgstr "Application"
|
||||||
|
|
||||||
#: core/models.py:782
|
#: core/models.py:807
|
||||||
msgid "Applications"
|
msgid "Applications"
|
||||||
msgstr "Applications"
|
msgstr "Applications"
|
||||||
|
|
||||||
#: core/models.py:805
|
#: core/models.py:830
|
||||||
msgid "Enter a valid domain"
|
msgid "Enter a valid domain"
|
||||||
msgstr "Enter a valid domain"
|
msgstr "Enter a valid domain"
|
||||||
|
|
||||||
#: core/models.py:808
|
#: core/models.py:833
|
||||||
msgid "Domain"
|
msgid "Domain"
|
||||||
msgstr "Domain"
|
msgstr "Domain"
|
||||||
|
|
||||||
#: core/models.py:809
|
#: core/models.py:834
|
||||||
msgid "Email domain this application can act on behalf of."
|
msgid "Email domain this application can act on behalf of."
|
||||||
msgstr "Email domain this application can act on behalf of."
|
msgstr "Email domain this application can act on behalf of."
|
||||||
|
|
||||||
#: core/models.py:821
|
#: core/models.py:846
|
||||||
msgid "Application domain"
|
msgid "Application domain"
|
||||||
msgstr "Application domain"
|
msgstr "Application domain"
|
||||||
|
|
||||||
#: core/models.py:822
|
#: core/models.py:847
|
||||||
msgid "Application domains"
|
msgid "Application domains"
|
||||||
msgstr "Application domains"
|
msgstr "Application domains"
|
||||||
|
|
||||||
#: core/models.py:840
|
#: core/models.py:865
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
#| msgid "Recording"
|
#| msgid "Recording"
|
||||||
msgid "Pending"
|
msgid "Pending"
|
||||||
msgstr "Pending"
|
msgstr "Pending"
|
||||||
|
|
||||||
#: core/models.py:848
|
#: core/models.py:866
|
||||||
|
msgid "Analyzing"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/models.py:873
|
||||||
msgid "Ready"
|
msgid "Ready"
|
||||||
msgstr "Ready"
|
msgstr "Ready"
|
||||||
|
|
||||||
#: core/models.py:854
|
#: core/models.py:879
|
||||||
msgid "Background image"
|
msgid "Background image"
|
||||||
msgstr "Background image"
|
msgstr "Background image"
|
||||||
|
|
||||||
#: core/models.py:866
|
#: core/models.py:891
|
||||||
msgid "title"
|
msgid "title"
|
||||||
msgstr "title"
|
msgstr "title"
|
||||||
|
|
||||||
#: core/models.py:890
|
#: core/models.py:915
|
||||||
msgid "Malware detection info when the analysis status is unsafe."
|
msgid "Malware detection info when the analysis status is unsafe."
|
||||||
msgstr "Malware detection info when the analysis status is unsafe."
|
msgstr "Malware detection info when the analysis status is unsafe."
|
||||||
|
|
||||||
#: core/models.py:895
|
#: core/models.py:920
|
||||||
msgid "File"
|
msgid "File"
|
||||||
msgstr "File"
|
msgstr "File"
|
||||||
|
|
||||||
#: core/models.py:896
|
#: core/models.py:921
|
||||||
msgid "Files"
|
msgid "Files"
|
||||||
msgstr "Files"
|
msgstr "Files"
|
||||||
|
|
||||||
#: core/models.py:1000
|
#: core/models.py:1041
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
#| msgid "This user is already in this recording."
|
#| msgid "This user is already in this recording."
|
||||||
msgid "This file is already hard deleted."
|
msgid "This file is already hard deleted."
|
||||||
msgstr "This file is already hard deleted."
|
msgstr "This file is already hard deleted."
|
||||||
|
|
||||||
#: core/models.py:1010
|
#: core/models.py:1051
|
||||||
msgid "To hard delete a file, it must first be soft deleted."
|
msgid "To hard delete a file, it must first be soft deleted."
|
||||||
msgstr "To hard delete a file, it must first be soft deleted."
|
msgstr "To hard delete a file, it must first be soft deleted."
|
||||||
|
|
||||||
#: core/recording/event/notification.py:116
|
#: core/recording/event/notification.py:123
|
||||||
msgid "Your recording is ready"
|
msgid "Your recording is ready"
|
||||||
msgstr "Your recording is ready"
|
msgstr "Your recording is ready"
|
||||||
|
|
||||||
|
#: core/recording/event/notification.py: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
|
#: core/services/invitation.py:44
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||||
@@ -571,7 +622,7 @@ msgid "To keep this recording permanently:"
|
|||||||
msgstr "To keep this recording permanently:"
|
msgstr "To keep this recording permanently:"
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:208
|
#: 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 "
|
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||||
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||||
|
|
||||||
@@ -600,18 +651,24 @@ msgstr ""
|
|||||||
" If you have any questions or need assistance, please contact our support "
|
" If you have any questions or need assistance, please contact our support "
|
||||||
"team at %(support_email)s. "
|
"team at %(support_email)s. "
|
||||||
|
|
||||||
#: 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"
|
msgid "English"
|
||||||
msgstr "English"
|
msgstr "English"
|
||||||
|
|
||||||
#: meet/settings.py:224
|
#: meet/settings.py:229
|
||||||
msgid "French"
|
msgid "French"
|
||||||
msgstr "French"
|
msgstr "French"
|
||||||
|
|
||||||
#: meet/settings.py:225
|
#: meet/settings.py:230
|
||||||
msgid "Dutch"
|
msgid "Dutch"
|
||||||
msgstr "Dutch"
|
msgstr "Dutch"
|
||||||
|
|
||||||
#: meet/settings.py:226
|
#: meet/settings.py:231
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr "German"
|
msgstr "German"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-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"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
|
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -17,71 +17,93 @@ msgstr ""
|
|||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
|
||||||
#: core/admin.py:29
|
#: core/admin.py:67
|
||||||
msgid "Personal info"
|
msgid "Personal info"
|
||||||
msgstr "Informations personnelles"
|
msgstr "Informations personnelles"
|
||||||
|
|
||||||
#: core/admin.py:42
|
#: core/admin.py:80
|
||||||
msgid "Permissions"
|
msgid "Permissions"
|
||||||
msgstr "Permissions"
|
msgstr "Permissions"
|
||||||
|
|
||||||
#: core/admin.py:54
|
#: core/admin.py:92
|
||||||
msgid "Important dates"
|
msgid "Important dates"
|
||||||
msgstr "Dates importantes"
|
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"
|
msgid "No owner"
|
||||||
msgstr "Pas de propriétaire"
|
msgstr "Pas de propriétaire"
|
||||||
|
|
||||||
#: core/admin.py:135 core/admin.py:278
|
#: core/admin.py:303 core/admin.py:446
|
||||||
msgid "Multiple owners"
|
msgid "Multiple owners"
|
||||||
msgstr "Plusieurs propriétaires"
|
msgstr "Plusieurs propriétaires"
|
||||||
|
|
||||||
#: core/admin.py:148
|
#: core/admin.py:316
|
||||||
msgid "Resend notification to external service"
|
msgid "Resend notification to external service"
|
||||||
msgstr "Renvoyer la notification au service externe"
|
msgstr "Renvoyer la notification au service externe"
|
||||||
|
|
||||||
#: core/admin.py:171
|
#: core/admin.py:339
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s"
|
msgid "Failed to notify for recording %(id)s"
|
||||||
msgstr "Échec de la notification pour l’enregistrement %(id)s"
|
msgstr "Échec de la notification pour l’enregistrement %(id)s"
|
||||||
|
|
||||||
#: core/admin.py:179
|
#: core/admin.py:347
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||||
msgstr "Échec de la notification pour l’enregistrement %(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
|
#, python-format
|
||||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||||
msgstr "Notifications envoyées avec succès pour %(count)s enregistrement(s)."
|
msgstr "Notifications envoyées avec succès pour %(count)s enregistrement(s)."
|
||||||
|
|
||||||
#: core/admin.py:195
|
#: core/admin.py:363
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Skipped %(count)s expired recording(s)."
|
msgid "Skipped %(count)s expired recording(s)."
|
||||||
msgstr "%(count)s enregistrement(s) expiré(s) ignoré(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'"
|
msgid "Mark selected recordings as 'Failed to Stop'"
|
||||||
msgstr "Marquer les enregistrements sélectionnés comme « Échec d’arrêt »"
|
msgstr "Marquer les enregistrements sélectionnés comme « Échec d’arrêt »"
|
||||||
|
|
||||||
#: core/admin.py:218
|
#: core/admin.py:386
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"%(count)s enregistrement(s) marqué(s) avec succès comme « Échec d’arrêt »."
|
"%(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
|
#, fuzzy, python-format
|
||||||
#| msgid "Skipped %(count)s expired recording(s)."
|
#| msgid "Skipped %(count)s expired recording(s)."
|
||||||
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
||||||
msgstr "%(count)s enregistrement(s) avec un statut inéligible ignoré(s)."
|
msgstr "%(count)s enregistrement(s) avec un statut inéligible ignoré(s)."
|
||||||
|
|
||||||
#: core/admin.py:342
|
#: core/admin.py:510
|
||||||
msgid "No scopes"
|
msgid "No scopes"
|
||||||
msgstr "Aucun scopes"
|
msgstr "Aucun scopes"
|
||||||
|
|
||||||
#: core/admin.py:344
|
#: core/admin.py:512
|
||||||
msgid "Scopes"
|
msgid "Scopes"
|
||||||
msgstr "Scopes"
|
msgstr "Scopes"
|
||||||
|
|
||||||
@@ -89,17 +111,17 @@ msgstr "Scopes"
|
|||||||
msgid "Creator is me"
|
msgid "Creator is me"
|
||||||
msgstr "Je suis le créateur"
|
msgstr "Je suis le créateur"
|
||||||
|
|
||||||
#: core/api/serializers.py:88
|
#: core/api/serializers.py:89
|
||||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
|
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
|
||||||
"des accès."
|
"des accès."
|
||||||
|
|
||||||
#: core/api/serializers.py:509
|
#: core/api/serializers.py:534
|
||||||
msgid "This file extension is not allowed."
|
msgid "This file extension is not allowed."
|
||||||
msgstr "Cette extension n'est pas autorisée"
|
msgstr "Cette extension n'est pas autorisée"
|
||||||
|
|
||||||
#: core/api/viewsets.py:1090
|
#: core/api/viewsets.py:1222
|
||||||
msgid "You have reached the maximum number of files for this type."
|
msgid "You have reached the maximum number of files for this type."
|
||||||
msgstr "Vous avez atteint le nombre maximum de fichiers de ce type"
|
msgstr "Vous avez atteint le nombre maximum de fichiers de ce type"
|
||||||
|
|
||||||
@@ -147,53 +169,61 @@ msgstr "Échec à l'arrêt"
|
|||||||
msgid "Notification succeeded"
|
msgid "Notification succeeded"
|
||||||
msgstr "Notification réussie"
|
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"
|
msgid "SCREEN_RECORDING"
|
||||||
msgstr "ENREGISTREMENT_ÉCRAN"
|
msgstr "ENREGISTREMENT_ÉCRAN"
|
||||||
|
|
||||||
#: core/models.py:90
|
#: core/models.py:97
|
||||||
msgid "TRANSCRIPT"
|
msgid "TRANSCRIPT"
|
||||||
msgstr "TRANSCRIPTION"
|
msgstr "TRANSCRIPTION"
|
||||||
|
|
||||||
#: core/models.py:96
|
#: core/models.py:103
|
||||||
msgid "Public Access"
|
msgid "Public Access"
|
||||||
msgstr "Accès public"
|
msgstr "Accès public"
|
||||||
|
|
||||||
#: core/models.py:97
|
#: core/models.py:104
|
||||||
msgid "Trusted Access"
|
msgid "Trusted Access"
|
||||||
msgstr "Accès de confiance"
|
msgstr "Accès de confiance"
|
||||||
|
|
||||||
#: core/models.py:98
|
#: core/models.py:105
|
||||||
msgid "Restricted Access"
|
msgid "Restricted Access"
|
||||||
msgstr "Accès restreint"
|
msgstr "Accès restreint"
|
||||||
|
|
||||||
#: core/models.py:110
|
#: core/models.py:117
|
||||||
msgid "id"
|
msgid "id"
|
||||||
msgstr "id"
|
msgstr "id"
|
||||||
|
|
||||||
#: core/models.py:111
|
#: core/models.py:118
|
||||||
msgid "primary key for the record as UUID"
|
msgid "primary key for the record as UUID"
|
||||||
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
|
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
|
||||||
|
|
||||||
#: core/models.py:117
|
#: core/models.py:124
|
||||||
msgid "created on"
|
msgid "created on"
|
||||||
msgstr "créé le"
|
msgstr "créé le"
|
||||||
|
|
||||||
#: core/models.py:118
|
#: core/models.py:125
|
||||||
msgid "date and time at which a record was created"
|
msgid "date and time at which a record was created"
|
||||||
msgstr "date et heure auxquelles un enregistrement a été créé"
|
msgstr "date et heure auxquelles un enregistrement a été créé"
|
||||||
|
|
||||||
#: core/models.py:123
|
#: core/models.py:130
|
||||||
msgid "updated on"
|
msgid "updated on"
|
||||||
msgstr "mis à jour le"
|
msgstr "mis à jour le"
|
||||||
|
|
||||||
#: core/models.py:124
|
#: core/models.py:131
|
||||||
msgid "date and time at which a record was last updated"
|
msgid "date and time at which a record was last updated"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
|
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
|
||||||
"fois"
|
"fois"
|
||||||
|
|
||||||
#: core/models.py:144
|
#: core/models.py:151
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||||
"_ characters."
|
"_ characters."
|
||||||
@@ -201,11 +231,11 @@ msgstr ""
|
|||||||
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des "
|
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des "
|
||||||
"chiffres et les caractères @/./+/-/_."
|
"chiffres et les caractères @/./+/-/_."
|
||||||
|
|
||||||
#: core/models.py:150
|
#: core/models.py:157
|
||||||
msgid "sub"
|
msgid "sub"
|
||||||
msgstr "sub"
|
msgstr "sub"
|
||||||
|
|
||||||
#: core/models.py:152
|
#: core/models.py:159
|
||||||
msgid ""
|
msgid ""
|
||||||
"Optional for pending users; required upon account activation. 255 characters "
|
"Optional for pending users; required upon account activation. 255 characters "
|
||||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||||
@@ -213,55 +243,55 @@ msgstr ""
|
|||||||
"Optionnel pour les utilisateurs en attente ; requis lors de l'activation du "
|
"Optionnel pour les utilisateurs en attente ; requis lors de l'activation du "
|
||||||
"compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement."
|
"compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement."
|
||||||
|
|
||||||
#: core/models.py:161
|
#: core/models.py:168
|
||||||
msgid "identity email address"
|
msgid "identity email address"
|
||||||
msgstr "adresse e-mail d'identité"
|
msgstr "adresse e-mail d'identité"
|
||||||
|
|
||||||
#: core/models.py:166
|
#: core/models.py:173
|
||||||
msgid "admin email address"
|
msgid "admin email address"
|
||||||
msgstr "adresse e-mail d'administrateur"
|
msgstr "adresse e-mail d'administrateur"
|
||||||
|
|
||||||
#: core/models.py:168
|
#: core/models.py:175
|
||||||
msgid "full name"
|
msgid "full name"
|
||||||
msgstr "nom complet"
|
msgstr "nom complet"
|
||||||
|
|
||||||
#: core/models.py:170
|
#: core/models.py:177
|
||||||
msgid "short name"
|
msgid "short name"
|
||||||
msgstr "nom court"
|
msgstr "nom court"
|
||||||
|
|
||||||
#: core/models.py:176
|
#: core/models.py:183
|
||||||
msgid "language"
|
msgid "language"
|
||||||
msgstr "langue"
|
msgstr "langue"
|
||||||
|
|
||||||
#: core/models.py:177
|
#: core/models.py:184
|
||||||
msgid "The language in which the user wants to see the interface."
|
msgid "The language in which the user wants to see the interface."
|
||||||
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
|
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
|
||||||
|
|
||||||
#: core/models.py:183
|
#: core/models.py:190
|
||||||
msgid "The timezone in which the user wants to see times."
|
msgid "The timezone in which the user wants to see times."
|
||||||
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
|
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
|
||||||
|
|
||||||
#: core/models.py:186
|
#: core/models.py:193
|
||||||
msgid "device"
|
msgid "device"
|
||||||
msgstr "appareil"
|
msgstr "appareil"
|
||||||
|
|
||||||
#: core/models.py:188
|
#: core/models.py:195
|
||||||
msgid "Whether the user is a device or a real user."
|
msgid "Whether the user is a device or a real user."
|
||||||
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
|
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
|
||||||
|
|
||||||
#: core/models.py:191
|
#: core/models.py:198
|
||||||
msgid "staff status"
|
msgid "staff status"
|
||||||
msgstr "statut du personnel"
|
msgstr "statut du personnel"
|
||||||
|
|
||||||
#: core/models.py:193
|
#: core/models.py:200
|
||||||
msgid "Whether the user can log into this admin site."
|
msgid "Whether the user can log into this admin site."
|
||||||
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
|
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
|
||||||
|
|
||||||
#: core/models.py:196
|
#: core/models.py:203
|
||||||
msgid "active"
|
msgid "active"
|
||||||
msgstr "actif"
|
msgstr "actif"
|
||||||
|
|
||||||
#: core/models.py:199
|
#: core/models.py:206
|
||||||
msgid ""
|
msgid ""
|
||||||
"Whether this user should be treated as active. Unselect this instead of "
|
"Whether this user should be treated as active. Unselect this instead of "
|
||||||
"deleting accounts."
|
"deleting accounts."
|
||||||
@@ -269,65 +299,65 @@ msgstr ""
|
|||||||
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
|
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
|
||||||
"au lieu de supprimer des comptes."
|
"au lieu de supprimer des comptes."
|
||||||
|
|
||||||
#: core/models.py:212
|
#: core/models.py:219
|
||||||
msgid "user"
|
msgid "user"
|
||||||
msgstr "utilisateur"
|
msgstr "utilisateur"
|
||||||
|
|
||||||
#: core/models.py:213
|
#: core/models.py:220
|
||||||
msgid "users"
|
msgid "users"
|
||||||
msgstr "utilisateurs"
|
msgstr "utilisateurs"
|
||||||
|
|
||||||
#: core/models.py:272
|
#: core/models.py:286
|
||||||
msgid "Resource"
|
msgid "Resource"
|
||||||
msgstr "Ressource"
|
msgstr "Ressource"
|
||||||
|
|
||||||
#: core/models.py:273
|
#: core/models.py:287
|
||||||
msgid "Resources"
|
msgid "Resources"
|
||||||
msgstr "Ressources"
|
msgstr "Ressources"
|
||||||
|
|
||||||
#: core/models.py:331
|
#: core/models.py:345
|
||||||
msgid "Resource access"
|
msgid "Resource access"
|
||||||
msgstr "Accès aux ressources"
|
msgstr "Accès aux ressources"
|
||||||
|
|
||||||
#: core/models.py:332
|
#: core/models.py:346
|
||||||
msgid "Resource accesses"
|
msgid "Resource accesses"
|
||||||
msgstr "Accès aux ressources"
|
msgstr "Accès aux ressources"
|
||||||
|
|
||||||
#: core/models.py:338
|
#: core/models.py:352
|
||||||
msgid "Resource access with this User and Resource already exists."
|
msgid "Resource access with this User and Resource already exists."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
|
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
|
||||||
|
|
||||||
#: core/models.py:394
|
#: core/models.py:409
|
||||||
msgid "Visio room configuration"
|
msgid "Visio room configuration"
|
||||||
msgstr "Configuration de la salle de visioconférence"
|
msgstr "Configuration de la salle de visioconférence"
|
||||||
|
|
||||||
#: core/models.py:395
|
#: core/models.py:410
|
||||||
msgid "Values for Visio parameters to configure the room."
|
msgid "Values for Visio parameters to configure the room."
|
||||||
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
|
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
|
||||||
|
|
||||||
#: core/models.py:402
|
#: core/models.py:417
|
||||||
msgid "Room PIN code"
|
msgid "Room PIN code"
|
||||||
msgstr "Code PIN de la salle"
|
msgstr "Code PIN de la salle"
|
||||||
|
|
||||||
#: core/models.py:403
|
#: core/models.py:418
|
||||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Code unique à n chiffres qui identifie cette salle en mode téléphonique."
|
"Code unique à n chiffres qui identifie cette salle en mode téléphonique."
|
||||||
|
|
||||||
#: core/models.py:409 core/models.py:563
|
#: core/models.py:424 core/models.py:578
|
||||||
msgid "Room"
|
msgid "Room"
|
||||||
msgstr "Salle"
|
msgstr "Salle"
|
||||||
|
|
||||||
#: core/models.py:410
|
#: core/models.py:425
|
||||||
msgid "Rooms"
|
msgid "Rooms"
|
||||||
msgstr "Salles"
|
msgstr "Salles"
|
||||||
|
|
||||||
#: core/models.py:574
|
#: core/models.py:589
|
||||||
msgid "Worker ID"
|
msgid "Worker ID"
|
||||||
msgstr "ID du Worker"
|
msgstr "ID du Worker"
|
||||||
|
|
||||||
#: core/models.py:576
|
#: core/models.py:591
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||||
"the worker stops, allowing for easy tracking."
|
"the worker stops, allowing for easy tracking."
|
||||||
@@ -335,153 +365,174 @@ msgstr ""
|
|||||||
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
|
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
|
||||||
"conservé même lorsque le Worker s'arrête, permettant un suivi facile."
|
"conservé même lorsque le Worker s'arrête, permettant un suivi facile."
|
||||||
|
|
||||||
#: core/models.py:584
|
#: core/models.py:599
|
||||||
msgid "Recording mode"
|
msgid "Recording mode"
|
||||||
msgstr "Mode d'enregistrement"
|
msgstr "Mode d'enregistrement"
|
||||||
|
|
||||||
#: core/models.py:585
|
#: core/models.py:600
|
||||||
msgid "Defines the mode of recording being called."
|
msgid "Defines the mode of recording being called."
|
||||||
msgstr "Définit le mode d'enregistrement appelé."
|
msgstr "Définit le mode d'enregistrement appelé."
|
||||||
|
|
||||||
#: core/models.py:590 core/models.py:591
|
#: core/models.py:605 core/models.py:606
|
||||||
msgid "Recording options"
|
msgid "Recording options"
|
||||||
msgstr "Options d'enregistrement"
|
msgstr "Options d'enregistrement"
|
||||||
|
|
||||||
#: core/models.py: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"
|
msgid "Recording"
|
||||||
msgstr "Enregistrement"
|
msgstr "Enregistrement"
|
||||||
|
|
||||||
#: core/models.py:598
|
#: core/models.py:621
|
||||||
msgid "Recordings"
|
msgid "Recordings"
|
||||||
msgstr "Enregistrements"
|
msgstr "Enregistrements"
|
||||||
|
|
||||||
#: core/models.py:706
|
#: core/models.py:731
|
||||||
msgid "Recording/user relation"
|
msgid "Recording/user relation"
|
||||||
msgstr "Relation enregistrement/utilisateur"
|
msgstr "Relation enregistrement/utilisateur"
|
||||||
|
|
||||||
#: core/models.py:707
|
#: core/models.py:732
|
||||||
msgid "Recording/user relations"
|
msgid "Recording/user relations"
|
||||||
msgstr "Relations enregistrement/utilisateur"
|
msgstr "Relations enregistrement/utilisateur"
|
||||||
|
|
||||||
#: core/models.py:713
|
#: core/models.py:738
|
||||||
msgid "This user is already in this recording."
|
msgid "This user is already in this recording."
|
||||||
msgstr "Cet utilisateur est déjà dans cet enregistrement."
|
msgstr "Cet utilisateur est déjà dans cet enregistrement."
|
||||||
|
|
||||||
#: core/models.py:719
|
#: core/models.py:744
|
||||||
msgid "This team is already in this recording."
|
msgid "This team is already in this recording."
|
||||||
msgstr "Cette équipe est déjà dans cet enregistrement."
|
msgstr "Cette équipe est déjà dans cet enregistrement."
|
||||||
|
|
||||||
#: core/models.py:725
|
#: core/models.py:750
|
||||||
msgid "Either user or team must be set, not both."
|
msgid "Either user or team must be set, not both."
|
||||||
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
|
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
|
||||||
|
|
||||||
#: core/models.py:742
|
#: core/models.py:767
|
||||||
msgid "Create rooms"
|
msgid "Create rooms"
|
||||||
msgstr "Créer des salles"
|
msgstr "Créer des salles"
|
||||||
|
|
||||||
#: core/models.py:743
|
#: core/models.py:768
|
||||||
msgid "List rooms"
|
msgid "List rooms"
|
||||||
msgstr "Lister les salles"
|
msgstr "Lister les salles"
|
||||||
|
|
||||||
#: core/models.py:744
|
#: core/models.py:769
|
||||||
msgid "Retrieve room details"
|
msgid "Retrieve room details"
|
||||||
msgstr "Afficher les détails d’une salle"
|
msgstr "Afficher les détails d’une salle"
|
||||||
|
|
||||||
#: core/models.py:745
|
#: core/models.py:770
|
||||||
msgid "Update rooms"
|
msgid "Update rooms"
|
||||||
msgstr "Mettre à jour les salles"
|
msgstr "Mettre à jour les salles"
|
||||||
|
|
||||||
#: core/models.py:746
|
#: core/models.py:771
|
||||||
msgid "Delete rooms"
|
msgid "Delete rooms"
|
||||||
msgstr "Supprimer les salles"
|
msgstr "Supprimer les salles"
|
||||||
|
|
||||||
#: core/models.py:759
|
#: core/models.py:784
|
||||||
msgid "Application name"
|
msgid "Application name"
|
||||||
msgstr "Nom de l’application"
|
msgstr "Nom de l’application"
|
||||||
|
|
||||||
#: core/models.py:760
|
#: core/models.py:785
|
||||||
msgid "Descriptive name for this application."
|
msgid "Descriptive name for this application."
|
||||||
msgstr "Nom descriptif de cette application."
|
msgstr "Nom descriptif de cette application."
|
||||||
|
|
||||||
#: core/models.py:770
|
#: core/models.py:795
|
||||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Haché lors de l’enregistrement. Copiez-le maintenant s’il s’agit d’un "
|
"Haché lors de l’enregistrement. Copiez-le maintenant s’il s’agit d’un "
|
||||||
"nouveau secret."
|
"nouveau secret."
|
||||||
|
|
||||||
#: core/models.py:781
|
#: core/models.py:806
|
||||||
msgid "Application"
|
msgid "Application"
|
||||||
msgstr "Application"
|
msgstr "Application"
|
||||||
|
|
||||||
#: core/models.py:782
|
#: core/models.py:807
|
||||||
msgid "Applications"
|
msgid "Applications"
|
||||||
msgstr "Applications"
|
msgstr "Applications"
|
||||||
|
|
||||||
#: core/models.py:805
|
#: core/models.py:830
|
||||||
msgid "Enter a valid domain"
|
msgid "Enter a valid domain"
|
||||||
msgstr "Saisissez un domaine valide"
|
msgstr "Saisissez un domaine valide"
|
||||||
|
|
||||||
#: core/models.py:808
|
#: core/models.py:833
|
||||||
msgid "Domain"
|
msgid "Domain"
|
||||||
msgstr "Domaine"
|
msgstr "Domaine"
|
||||||
|
|
||||||
#: core/models.py:809
|
#: core/models.py:834
|
||||||
msgid "Email domain this application can act on behalf of."
|
msgid "Email domain this application can act on behalf of."
|
||||||
msgstr "Domaine de messagerie au nom duquel cette application peut agir."
|
msgstr "Domaine de messagerie au nom duquel cette application peut agir."
|
||||||
|
|
||||||
#: core/models.py:821
|
#: core/models.py:846
|
||||||
msgid "Application domain"
|
msgid "Application domain"
|
||||||
msgstr "Domaine d’application"
|
msgstr "Domaine d’application"
|
||||||
|
|
||||||
#: core/models.py:822
|
#: core/models.py:847
|
||||||
msgid "Application domains"
|
msgid "Application domains"
|
||||||
msgstr "Domaines d’application"
|
msgstr "Domaines d’application"
|
||||||
|
|
||||||
#: core/models.py:840
|
#: core/models.py:865
|
||||||
msgid "Pending"
|
msgid "Pending"
|
||||||
msgstr "En attente"
|
msgstr "En attente"
|
||||||
|
|
||||||
#: core/models.py:848
|
#: core/models.py:866
|
||||||
|
msgid "Analyzing"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/models.py:873
|
||||||
msgid "Ready"
|
msgid "Ready"
|
||||||
msgstr "Prêt"
|
msgstr "Prêt"
|
||||||
|
|
||||||
#: core/models.py:854
|
#: core/models.py:879
|
||||||
msgid "Background image"
|
msgid "Background image"
|
||||||
msgstr "Image de fond"
|
msgstr "Image de fond"
|
||||||
|
|
||||||
#: core/models.py:866
|
#: core/models.py:891
|
||||||
msgid "title"
|
msgid "title"
|
||||||
msgstr "Titre"
|
msgstr "Titre"
|
||||||
|
|
||||||
#: core/models.py:890
|
#: core/models.py:915
|
||||||
msgid "Malware detection info when the analysis status is unsafe."
|
msgid "Malware detection info when the analysis status is unsafe."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Information concernant la détection de Malware cand le statut n'est pas sain"
|
"Information concernant la détection de Malware cand le statut n'est pas sain"
|
||||||
|
|
||||||
#: core/models.py:895
|
#: core/models.py:920
|
||||||
msgid "File"
|
msgid "File"
|
||||||
msgstr "Fichier"
|
msgstr "Fichier"
|
||||||
|
|
||||||
#: core/models.py:896
|
#: core/models.py:921
|
||||||
msgid "Files"
|
msgid "Files"
|
||||||
msgstr "Fichiers"
|
msgstr "Fichiers"
|
||||||
|
|
||||||
#: core/models.py:1000
|
#: core/models.py:1041
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
#| msgid "This user is already in this recording."
|
#| msgid "This user is already in this recording."
|
||||||
msgid "This file is already hard deleted."
|
msgid "This file is already hard deleted."
|
||||||
msgstr "Ce fichier a été supprimé."
|
msgstr "Ce fichier a été supprimé."
|
||||||
|
|
||||||
#: core/models.py:1010
|
#: core/models.py:1051
|
||||||
msgid "To hard delete a file, it must first be soft deleted."
|
msgid "To hard delete a file, it must first be soft deleted."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Pour supprimer définitivement un fichier il doit d'abord avoir été marqué "
|
"Pour supprimer définitivement un fichier il doit d'abord avoir été marqué "
|
||||||
"comme supprimé (soft delete)"
|
"comme supprimé (soft delete)"
|
||||||
|
|
||||||
#: core/recording/event/notification.py:116
|
#: core/recording/event/notification.py:123
|
||||||
msgid "Your recording is ready"
|
msgid "Your recording is ready"
|
||||||
msgstr "Votre enregistrement est prêt"
|
msgstr "Votre enregistrement est prêt"
|
||||||
|
|
||||||
|
#: core/recording/event/notification.py: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
|
#: core/services/invitation.py:44
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||||
@@ -577,7 +628,7 @@ msgid "To keep this recording permanently:"
|
|||||||
msgstr "Pour conserver cet enregistrement de façon permanente :"
|
msgstr "Pour conserver cet enregistrement de façon permanente :"
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:208
|
#: 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 "
|
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||||
msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" ci-dessous "
|
msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" ci-dessous "
|
||||||
|
|
||||||
@@ -606,18 +657,24 @@ msgstr ""
|
|||||||
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
|
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
|
||||||
"équipe d'assistance à %(support_email)s. "
|
"équipe d'assistance à %(support_email)s. "
|
||||||
|
|
||||||
#: 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"
|
msgid "English"
|
||||||
msgstr "Anglais"
|
msgstr "Anglais"
|
||||||
|
|
||||||
#: meet/settings.py:224
|
#: meet/settings.py:229
|
||||||
msgid "French"
|
msgid "French"
|
||||||
msgstr "Français"
|
msgstr "Français"
|
||||||
|
|
||||||
#: meet/settings.py:225
|
#: meet/settings.py:230
|
||||||
msgid "Dutch"
|
msgid "Dutch"
|
||||||
msgstr "Néerlandais"
|
msgstr "Néerlandais"
|
||||||
|
|
||||||
#: meet/settings.py:226
|
#: meet/settings.py:231
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr "Allemand"
|
msgstr "Allemand"
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-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"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -17,70 +17,92 @@ msgstr ""
|
|||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
|
||||||
#: core/admin.py:29
|
#: core/admin.py:67
|
||||||
msgid "Personal info"
|
msgid "Personal info"
|
||||||
msgstr "Persoonlijke informatie"
|
msgstr "Persoonlijke informatie"
|
||||||
|
|
||||||
#: core/admin.py:42
|
#: core/admin.py:80
|
||||||
msgid "Permissions"
|
msgid "Permissions"
|
||||||
msgstr "Rechten"
|
msgstr "Rechten"
|
||||||
|
|
||||||
#: core/admin.py:54
|
#: core/admin.py:92
|
||||||
msgid "Important dates"
|
msgid "Important dates"
|
||||||
msgstr "Belangrijke datums"
|
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"
|
msgid "No owner"
|
||||||
msgstr "Geen eigenaar"
|
msgstr "Geen eigenaar"
|
||||||
|
|
||||||
#: core/admin.py:135 core/admin.py:278
|
#: core/admin.py:303 core/admin.py:446
|
||||||
msgid "Multiple owners"
|
msgid "Multiple owners"
|
||||||
msgstr "Meerdere eigenaren"
|
msgstr "Meerdere eigenaren"
|
||||||
|
|
||||||
#: core/admin.py:148
|
#: core/admin.py:316
|
||||||
msgid "Resend notification to external service"
|
msgid "Resend notification to external service"
|
||||||
msgstr "Melding opnieuw verzenden naar externe dienst"
|
msgstr "Melding opnieuw verzenden naar externe dienst"
|
||||||
|
|
||||||
#: core/admin.py:171
|
#: core/admin.py:339
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s"
|
msgid "Failed to notify for recording %(id)s"
|
||||||
msgstr "Melding voor opname %(id)s mislukt"
|
msgstr "Melding voor opname %(id)s mislukt"
|
||||||
|
|
||||||
#: core/admin.py:179
|
#: core/admin.py:347
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||||
msgstr "Melding voor opname %(id)s mislukt: %(error)s"
|
msgstr "Melding voor opname %(id)s mislukt: %(error)s"
|
||||||
|
|
||||||
#: core/admin.py:187
|
#: core/admin.py:355
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||||
msgstr "Meldingen succesvol verzonden voor %(count)s opname(n)."
|
msgstr "Meldingen succesvol verzonden voor %(count)s opname(n)."
|
||||||
|
|
||||||
#: core/admin.py:195
|
#: core/admin.py:363
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Skipped %(count)s expired recording(s)."
|
msgid "Skipped %(count)s expired recording(s)."
|
||||||
msgstr "%(count)s verlopen opname(n) overgeslagen."
|
msgstr "%(count)s verlopen opname(n) overgeslagen."
|
||||||
|
|
||||||
#: core/admin.py:200
|
#: core/admin.py:368
|
||||||
msgid "Mark selected recordings as 'Failed to Stop'"
|
msgid "Mark selected recordings as 'Failed to Stop'"
|
||||||
msgstr "Geselecteerde opnames markeren als 'Mislukt bij stoppen'"
|
msgstr "Geselecteerde opnames markeren als 'Mislukt bij stoppen'"
|
||||||
|
|
||||||
#: core/admin.py:218
|
#: core/admin.py:386
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
||||||
msgstr "%(count)s opname(s) succesvol gemarkeerd als 'Mislukt bij stoppen'."
|
msgstr "%(count)s opname(s) succesvol gemarkeerd als 'Mislukt bij stoppen'."
|
||||||
|
|
||||||
#: core/admin.py:226
|
#: core/admin.py:394
|
||||||
#, fuzzy, python-format
|
#, fuzzy, python-format
|
||||||
#| msgid "Skipped %(count)s expired recording(s)."
|
#| msgid "Skipped %(count)s expired recording(s)."
|
||||||
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
||||||
msgstr "%(count)s opname(s) met een niet-toegestane status overgeslagen."
|
msgstr "%(count)s opname(s) met een niet-toegestane status overgeslagen."
|
||||||
|
|
||||||
#: core/admin.py:342
|
#: core/admin.py:510
|
||||||
msgid "No scopes"
|
msgid "No scopes"
|
||||||
msgstr "Geen scopes"
|
msgstr "Geen scopes"
|
||||||
|
|
||||||
#: core/admin.py:344
|
#: core/admin.py:512
|
||||||
msgid "Scopes"
|
msgid "Scopes"
|
||||||
msgstr "Scopes"
|
msgstr "Scopes"
|
||||||
|
|
||||||
@@ -88,16 +110,16 @@ msgstr "Scopes"
|
|||||||
msgid "Creator is me"
|
msgid "Creator is me"
|
||||||
msgstr "Maker ben ik"
|
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."
|
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
|
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
|
||||||
|
|
||||||
#: core/api/serializers.py:509
|
#: core/api/serializers.py:534
|
||||||
msgid "This file extension is not allowed."
|
msgid "This file extension is not allowed."
|
||||||
msgstr "Deze bestandsextensie is niet toegestaan."
|
msgstr "Deze bestandsextensie is niet toegestaan."
|
||||||
|
|
||||||
#: core/api/viewsets.py:1090
|
#: core/api/viewsets.py:1222
|
||||||
msgid "You have reached the maximum number of files for this type."
|
msgid "You have reached the maximum number of files for this type."
|
||||||
msgstr "Het maximale aantal bestanden voor dit type is bereikt."
|
msgstr "Het maximale aantal bestanden voor dit type is bereikt."
|
||||||
|
|
||||||
@@ -145,51 +167,59 @@ msgstr "Stoppen mislukt"
|
|||||||
msgid "Notification succeeded"
|
msgid "Notification succeeded"
|
||||||
msgstr "Notificatie geslaagd"
|
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"
|
msgid "SCREEN_RECORDING"
|
||||||
msgstr "SCHERM_OPNAME"
|
msgstr "SCHERM_OPNAME"
|
||||||
|
|
||||||
#: core/models.py:90
|
#: core/models.py:97
|
||||||
msgid "TRANSCRIPT"
|
msgid "TRANSCRIPT"
|
||||||
msgstr "TRANSCRIPT"
|
msgstr "TRANSCRIPT"
|
||||||
|
|
||||||
#: core/models.py:96
|
#: core/models.py:103
|
||||||
msgid "Public Access"
|
msgid "Public Access"
|
||||||
msgstr "Openbare toegang"
|
msgstr "Openbare toegang"
|
||||||
|
|
||||||
#: core/models.py:97
|
#: core/models.py:104
|
||||||
msgid "Trusted Access"
|
msgid "Trusted Access"
|
||||||
msgstr "Vertrouwde toegang"
|
msgstr "Vertrouwde toegang"
|
||||||
|
|
||||||
#: core/models.py:98
|
#: core/models.py:105
|
||||||
msgid "Restricted Access"
|
msgid "Restricted Access"
|
||||||
msgstr "Beperkte toegang"
|
msgstr "Beperkte toegang"
|
||||||
|
|
||||||
#: core/models.py:110
|
#: core/models.py:117
|
||||||
msgid "id"
|
msgid "id"
|
||||||
msgstr "id"
|
msgstr "id"
|
||||||
|
|
||||||
#: core/models.py:111
|
#: core/models.py:118
|
||||||
msgid "primary key for the record as UUID"
|
msgid "primary key for the record as UUID"
|
||||||
msgstr "primaire sleutel voor het record als UUID"
|
msgstr "primaire sleutel voor het record als UUID"
|
||||||
|
|
||||||
#: core/models.py:117
|
#: core/models.py:124
|
||||||
msgid "created on"
|
msgid "created on"
|
||||||
msgstr "aangemaakt op"
|
msgstr "aangemaakt op"
|
||||||
|
|
||||||
#: core/models.py:118
|
#: core/models.py:125
|
||||||
msgid "date and time at which a record was created"
|
msgid "date and time at which a record was created"
|
||||||
msgstr "datum en tijd waarop een record werd aangemaakt"
|
msgstr "datum en tijd waarop een record werd aangemaakt"
|
||||||
|
|
||||||
#: core/models.py:123
|
#: core/models.py:130
|
||||||
msgid "updated on"
|
msgid "updated on"
|
||||||
msgstr "bijgewerkt op"
|
msgstr "bijgewerkt op"
|
||||||
|
|
||||||
#: core/models.py:124
|
#: core/models.py:131
|
||||||
msgid "date and time at which a record was last updated"
|
msgid "date and time at which a record was last updated"
|
||||||
msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt"
|
msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt"
|
||||||
|
|
||||||
#: core/models.py:144
|
#: core/models.py:151
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||||
"_ characters."
|
"_ characters."
|
||||||
@@ -197,11 +227,11 @@ msgstr ""
|
|||||||
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
|
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
|
||||||
"_ tekens bevatten."
|
"_ tekens bevatten."
|
||||||
|
|
||||||
#: core/models.py:150
|
#: core/models.py:157
|
||||||
msgid "sub"
|
msgid "sub"
|
||||||
msgstr "sub"
|
msgstr "sub"
|
||||||
|
|
||||||
#: core/models.py:152
|
#: core/models.py:159
|
||||||
msgid ""
|
msgid ""
|
||||||
"Optional for pending users; required upon account activation. 255 characters "
|
"Optional for pending users; required upon account activation. 255 characters "
|
||||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||||
@@ -209,55 +239,55 @@ msgstr ""
|
|||||||
"Optioneel voor gebruikers in afwachting; vereist bij accountactivering. "
|
"Optioneel voor gebruikers in afwachting; vereist bij accountactivering. "
|
||||||
"Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan."
|
"Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan."
|
||||||
|
|
||||||
#: core/models.py:161
|
#: core/models.py:168
|
||||||
msgid "identity email address"
|
msgid "identity email address"
|
||||||
msgstr "identiteit e-mailadres"
|
msgstr "identiteit e-mailadres"
|
||||||
|
|
||||||
#: core/models.py:166
|
#: core/models.py:173
|
||||||
msgid "admin email address"
|
msgid "admin email address"
|
||||||
msgstr "beheerder e-mailadres"
|
msgstr "beheerder e-mailadres"
|
||||||
|
|
||||||
#: core/models.py:168
|
#: core/models.py:175
|
||||||
msgid "full name"
|
msgid "full name"
|
||||||
msgstr "volledige naam"
|
msgstr "volledige naam"
|
||||||
|
|
||||||
#: core/models.py:170
|
#: core/models.py:177
|
||||||
msgid "short name"
|
msgid "short name"
|
||||||
msgstr "korte naam"
|
msgstr "korte naam"
|
||||||
|
|
||||||
#: core/models.py:176
|
#: core/models.py:183
|
||||||
msgid "language"
|
msgid "language"
|
||||||
msgstr "taal"
|
msgstr "taal"
|
||||||
|
|
||||||
#: core/models.py:177
|
#: core/models.py:184
|
||||||
msgid "The language in which the user wants to see the interface."
|
msgid "The language in which the user wants to see the interface."
|
||||||
msgstr "De taal waarin de gebruiker de interface wil zien."
|
msgstr "De taal waarin de gebruiker de interface wil zien."
|
||||||
|
|
||||||
#: core/models.py:183
|
#: core/models.py:190
|
||||||
msgid "The timezone in which the user wants to see times."
|
msgid "The timezone in which the user wants to see times."
|
||||||
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
|
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
|
||||||
|
|
||||||
#: core/models.py:186
|
#: core/models.py:193
|
||||||
msgid "device"
|
msgid "device"
|
||||||
msgstr "apparaat"
|
msgstr "apparaat"
|
||||||
|
|
||||||
#: core/models.py:188
|
#: core/models.py:195
|
||||||
msgid "Whether the user is a device or a real user."
|
msgid "Whether the user is a device or a real user."
|
||||||
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
|
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
|
||||||
|
|
||||||
#: core/models.py:191
|
#: core/models.py:198
|
||||||
msgid "staff status"
|
msgid "staff status"
|
||||||
msgstr "personeelsstatus"
|
msgstr "personeelsstatus"
|
||||||
|
|
||||||
#: core/models.py:193
|
#: core/models.py:200
|
||||||
msgid "Whether the user can log into this admin site."
|
msgid "Whether the user can log into this admin site."
|
||||||
msgstr "Of de gebruiker kan inloggen op deze beheersite."
|
msgstr "Of de gebruiker kan inloggen op deze beheersite."
|
||||||
|
|
||||||
#: core/models.py:196
|
#: core/models.py:203
|
||||||
msgid "active"
|
msgid "active"
|
||||||
msgstr "actief"
|
msgstr "actief"
|
||||||
|
|
||||||
#: core/models.py:199
|
#: core/models.py:206
|
||||||
msgid ""
|
msgid ""
|
||||||
"Whether this user should be treated as active. Unselect this instead of "
|
"Whether this user should be treated as active. Unselect this instead of "
|
||||||
"deleting accounts."
|
"deleting accounts."
|
||||||
@@ -265,64 +295,64 @@ msgstr ""
|
|||||||
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
|
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
|
||||||
"plaats van accounts te verwijderen."
|
"plaats van accounts te verwijderen."
|
||||||
|
|
||||||
#: core/models.py:212
|
#: core/models.py:219
|
||||||
msgid "user"
|
msgid "user"
|
||||||
msgstr "gebruiker"
|
msgstr "gebruiker"
|
||||||
|
|
||||||
#: core/models.py:213
|
#: core/models.py:220
|
||||||
msgid "users"
|
msgid "users"
|
||||||
msgstr "gebruikers"
|
msgstr "gebruikers"
|
||||||
|
|
||||||
#: core/models.py:272
|
#: core/models.py:286
|
||||||
msgid "Resource"
|
msgid "Resource"
|
||||||
msgstr "Bron"
|
msgstr "Bron"
|
||||||
|
|
||||||
#: core/models.py:273
|
#: core/models.py:287
|
||||||
msgid "Resources"
|
msgid "Resources"
|
||||||
msgstr "Bronnen"
|
msgstr "Bronnen"
|
||||||
|
|
||||||
#: core/models.py:331
|
#: core/models.py:345
|
||||||
msgid "Resource access"
|
msgid "Resource access"
|
||||||
msgstr "Brontoegang"
|
msgstr "Brontoegang"
|
||||||
|
|
||||||
#: core/models.py:332
|
#: core/models.py:346
|
||||||
msgid "Resource accesses"
|
msgid "Resource accesses"
|
||||||
msgstr "Brontoegangsrechten"
|
msgstr "Brontoegangsrechten"
|
||||||
|
|
||||||
#: core/models.py:338
|
#: core/models.py:352
|
||||||
msgid "Resource access with this User and Resource already exists."
|
msgid "Resource access with this User and Resource already exists."
|
||||||
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
|
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
|
||||||
|
|
||||||
#: core/models.py:394
|
#: core/models.py:409
|
||||||
msgid "Visio room configuration"
|
msgid "Visio room configuration"
|
||||||
msgstr "Visio-ruimteconfiguratie"
|
msgstr "Visio-ruimteconfiguratie"
|
||||||
|
|
||||||
#: core/models.py:395
|
#: core/models.py:410
|
||||||
msgid "Values for Visio parameters to configure the room."
|
msgid "Values for Visio parameters to configure the room."
|
||||||
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
|
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
|
||||||
|
|
||||||
#: core/models.py:402
|
#: core/models.py:417
|
||||||
msgid "Room PIN code"
|
msgid "Room PIN code"
|
||||||
msgstr "Pincode van de kamer"
|
msgstr "Pincode van de kamer"
|
||||||
|
|
||||||
#: core/models.py:403
|
#: core/models.py:418
|
||||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
|
"Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
|
||||||
|
|
||||||
#: core/models.py:409 core/models.py:563
|
#: core/models.py:424 core/models.py:578
|
||||||
msgid "Room"
|
msgid "Room"
|
||||||
msgstr "Ruimte"
|
msgstr "Ruimte"
|
||||||
|
|
||||||
#: core/models.py:410
|
#: core/models.py:425
|
||||||
msgid "Rooms"
|
msgid "Rooms"
|
||||||
msgstr "Ruimtes"
|
msgstr "Ruimtes"
|
||||||
|
|
||||||
#: core/models.py:574
|
#: core/models.py:589
|
||||||
msgid "Worker ID"
|
msgid "Worker ID"
|
||||||
msgstr "Worker ID"
|
msgstr "Worker ID"
|
||||||
|
|
||||||
#: core/models.py:576
|
#: core/models.py:591
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||||
"the worker stops, allowing for easy tracking."
|
"the worker stops, allowing for easy tracking."
|
||||||
@@ -330,140 +360,152 @@ msgstr ""
|
|||||||
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
|
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
|
||||||
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
|
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
|
||||||
|
|
||||||
#: core/models.py:584
|
#: core/models.py:599
|
||||||
msgid "Recording mode"
|
msgid "Recording mode"
|
||||||
msgstr "Opnamemodus"
|
msgstr "Opnamemodus"
|
||||||
|
|
||||||
#: core/models.py:585
|
#: core/models.py:600
|
||||||
msgid "Defines the mode of recording being called."
|
msgid "Defines the mode of recording being called."
|
||||||
msgstr "Definieert de modus van opname die wordt aangeroepen."
|
msgstr "Definieert de modus van opname die wordt aangeroepen."
|
||||||
|
|
||||||
#: core/models.py:590 core/models.py:591
|
#: core/models.py:605 core/models.py:606
|
||||||
msgid "Recording options"
|
msgid "Recording options"
|
||||||
msgstr "Opnameopties"
|
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"
|
msgid "Recording"
|
||||||
msgstr "Opname"
|
msgstr "Opname"
|
||||||
|
|
||||||
#: core/models.py:598
|
#: core/models.py:621
|
||||||
msgid "Recordings"
|
msgid "Recordings"
|
||||||
msgstr "Opnames"
|
msgstr "Opnames"
|
||||||
|
|
||||||
#: core/models.py:706
|
#: core/models.py:731
|
||||||
msgid "Recording/user relation"
|
msgid "Recording/user relation"
|
||||||
msgstr "Opname/gebruiker-relatie"
|
msgstr "Opname/gebruiker-relatie"
|
||||||
|
|
||||||
#: core/models.py:707
|
#: core/models.py:732
|
||||||
msgid "Recording/user relations"
|
msgid "Recording/user relations"
|
||||||
msgstr "Opname/gebruiker-relaties"
|
msgstr "Opname/gebruiker-relaties"
|
||||||
|
|
||||||
#: core/models.py:713
|
#: core/models.py:738
|
||||||
msgid "This user is already in this recording."
|
msgid "This user is already in this recording."
|
||||||
msgstr "Deze gebruiker is al in deze opname."
|
msgstr "Deze gebruiker is al in deze opname."
|
||||||
|
|
||||||
#: core/models.py:719
|
#: core/models.py:744
|
||||||
msgid "This team is already in this recording."
|
msgid "This team is already in this recording."
|
||||||
msgstr "Dit team is al in deze opname."
|
msgstr "Dit team is al in deze opname."
|
||||||
|
|
||||||
#: core/models.py:725
|
#: core/models.py:750
|
||||||
msgid "Either user or team must be set, not both."
|
msgid "Either user or team must be set, not both."
|
||||||
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
|
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
|
||||||
|
|
||||||
#: core/models.py:742
|
#: core/models.py:767
|
||||||
msgid "Create rooms"
|
msgid "Create rooms"
|
||||||
msgstr "Ruimtes aanmaken"
|
msgstr "Ruimtes aanmaken"
|
||||||
|
|
||||||
#: core/models.py:743
|
#: core/models.py:768
|
||||||
msgid "List rooms"
|
msgid "List rooms"
|
||||||
msgstr "Ruimtes weergeven"
|
msgstr "Ruimtes weergeven"
|
||||||
|
|
||||||
#: core/models.py:744
|
#: core/models.py:769
|
||||||
msgid "Retrieve room details"
|
msgid "Retrieve room details"
|
||||||
msgstr "Details van een ruimte ophalen"
|
msgstr "Details van een ruimte ophalen"
|
||||||
|
|
||||||
#: core/models.py:745
|
#: core/models.py:770
|
||||||
msgid "Update rooms"
|
msgid "Update rooms"
|
||||||
msgstr "Ruimtes bijwerken"
|
msgstr "Ruimtes bijwerken"
|
||||||
|
|
||||||
#: core/models.py:746
|
#: core/models.py:771
|
||||||
msgid "Delete rooms"
|
msgid "Delete rooms"
|
||||||
msgstr "Ruimtes verwijderen"
|
msgstr "Ruimtes verwijderen"
|
||||||
|
|
||||||
#: core/models.py:759
|
#: core/models.py:784
|
||||||
msgid "Application name"
|
msgid "Application name"
|
||||||
msgstr "Naam van de applicatie"
|
msgstr "Naam van de applicatie"
|
||||||
|
|
||||||
#: core/models.py:760
|
#: core/models.py:785
|
||||||
msgid "Descriptive name for this application."
|
msgid "Descriptive name for this application."
|
||||||
msgstr "Beschrijvende naam voor deze applicatie."
|
msgstr "Beschrijvende naam voor deze applicatie."
|
||||||
|
|
||||||
#: core/models.py:770
|
#: core/models.py:795
|
||||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
|
"Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
|
||||||
|
|
||||||
#: core/models.py:781
|
#: core/models.py:806
|
||||||
msgid "Application"
|
msgid "Application"
|
||||||
msgstr "Applicatie"
|
msgstr "Applicatie"
|
||||||
|
|
||||||
#: core/models.py:782
|
#: core/models.py:807
|
||||||
msgid "Applications"
|
msgid "Applications"
|
||||||
msgstr "Applicaties"
|
msgstr "Applicaties"
|
||||||
|
|
||||||
#: core/models.py:805
|
#: core/models.py:830
|
||||||
msgid "Enter a valid domain"
|
msgid "Enter a valid domain"
|
||||||
msgstr "Voer een geldig domein in"
|
msgstr "Voer een geldig domein in"
|
||||||
|
|
||||||
#: core/models.py:808
|
#: core/models.py:833
|
||||||
msgid "Domain"
|
msgid "Domain"
|
||||||
msgstr "Domein"
|
msgstr "Domein"
|
||||||
|
|
||||||
#: core/models.py:809
|
#: core/models.py:834
|
||||||
msgid "Email domain this application can act on behalf of."
|
msgid "Email domain this application can act on behalf of."
|
||||||
msgstr "E-maildomein namens welke deze applicatie kan handelen."
|
msgstr "E-maildomein namens welke deze applicatie kan handelen."
|
||||||
|
|
||||||
#: core/models.py:821
|
#: core/models.py:846
|
||||||
msgid "Application domain"
|
msgid "Application domain"
|
||||||
msgstr "Applicatiedomein"
|
msgstr "Applicatiedomein"
|
||||||
|
|
||||||
#: core/models.py:822
|
#: core/models.py:847
|
||||||
msgid "Application domains"
|
msgid "Application domains"
|
||||||
msgstr "Applicatiedomeinen"
|
msgstr "Applicatiedomeinen"
|
||||||
|
|
||||||
#: core/models.py:840
|
#: core/models.py:865
|
||||||
msgid "Pending"
|
msgid "Pending"
|
||||||
msgstr "In afwachting"
|
msgstr "In afwachting"
|
||||||
|
|
||||||
#: core/models.py:848
|
#: core/models.py:866
|
||||||
|
msgid "Analyzing"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: core/models.py:873
|
||||||
msgid "Ready"
|
msgid "Ready"
|
||||||
msgstr "Klaar"
|
msgstr "Klaar"
|
||||||
|
|
||||||
#: core/models.py:854
|
#: core/models.py:879
|
||||||
msgid "Background image"
|
msgid "Background image"
|
||||||
msgstr "Achtergrondafbeelding"
|
msgstr "Achtergrondafbeelding"
|
||||||
|
|
||||||
#: core/models.py:866
|
#: core/models.py:891
|
||||||
msgid "title"
|
msgid "title"
|
||||||
msgstr "Titel"
|
msgstr "Titel"
|
||||||
|
|
||||||
#: core/models.py:890
|
#: core/models.py:915
|
||||||
msgid "Malware detection info when the analysis status is unsafe."
|
msgid "Malware detection info when the analysis status is unsafe."
|
||||||
msgstr "Informatie over malwaredetectie wanneer de analysestatus onveilig is."
|
msgstr "Informatie over malwaredetectie wanneer de analysestatus onveilig is."
|
||||||
|
|
||||||
#: core/models.py:895
|
#: core/models.py:920
|
||||||
msgid "File"
|
msgid "File"
|
||||||
msgstr "Bestand"
|
msgstr "Bestand"
|
||||||
|
|
||||||
#: core/models.py:896
|
#: core/models.py:921
|
||||||
msgid "Files"
|
msgid "Files"
|
||||||
msgstr "Bestanden"
|
msgstr "Bestanden"
|
||||||
|
|
||||||
#: core/models.py:1000
|
#: core/models.py:1041
|
||||||
msgid "This file is already hard deleted."
|
msgid "This file is already hard deleted."
|
||||||
msgstr "Dit bestand is al definitief verwijderd."
|
msgstr "Dit bestand is al definitief verwijderd."
|
||||||
|
|
||||||
#: core/models.py:1010
|
#: core/models.py:1051
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
#| msgid "To hard delete a file, it must first be soft deleted."
|
#| msgid "To hard delete a file, it must first be soft deleted."
|
||||||
msgid "To hard delete a file, it must first be soft deleted."
|
msgid "To hard delete a file, it must first be soft deleted."
|
||||||
@@ -471,10 +513,20 @@ msgstr ""
|
|||||||
"Om een bestand definitief te verwijderen, moet het eerst zacht verwijderd "
|
"Om een bestand definitief te verwijderen, moet het eerst zacht verwijderd "
|
||||||
"zijn."
|
"zijn."
|
||||||
|
|
||||||
#: core/recording/event/notification.py:116
|
#: core/recording/event/notification.py:123
|
||||||
msgid "Your recording is ready"
|
msgid "Your recording is ready"
|
||||||
msgstr "Je opname is klaar"
|
msgstr "Je opname is klaar"
|
||||||
|
|
||||||
|
#: core/recording/event/notification.py: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
|
#: core/services/invitation.py:44
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||||
@@ -570,7 +622,7 @@ msgid "To keep this recording permanently:"
|
|||||||
msgstr "Om deze opname permanent te bewaren:"
|
msgstr "Om deze opname permanent te bewaren:"
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:208
|
#: 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 "
|
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||||
msgstr "Klik op de \"<a href=\"%(link)s\">Openen</a>\"-link hieronder "
|
msgstr "Klik op de \"<a href=\"%(link)s\">Openen</a>\"-link hieronder "
|
||||||
|
|
||||||
@@ -599,18 +651,24 @@ msgstr ""
|
|||||||
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
|
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
|
||||||
"team via %(support_email)s. "
|
"team via %(support_email)s. "
|
||||||
|
|
||||||
#: 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"
|
msgid "English"
|
||||||
msgstr "Engels"
|
msgstr "Engels"
|
||||||
|
|
||||||
#: meet/settings.py:224
|
#: meet/settings.py:229
|
||||||
msgid "French"
|
msgid "French"
|
||||||
msgstr "Frans"
|
msgstr "Frans"
|
||||||
|
|
||||||
#: meet/settings.py:225
|
#: meet/settings.py:230
|
||||||
msgid "Dutch"
|
msgid "Dutch"
|
||||||
msgstr "Nederlands"
|
msgstr "Nederlands"
|
||||||
|
|
||||||
#: meet/settings.py:226
|
#: meet/settings.py:231
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr "Duits"
|
msgstr "Duits"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ https://docs.djangoproject.com/en/3.1/ref/settings/
|
|||||||
# pylint: disable=too-many-lines
|
# pylint: disable=too-many-lines
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import warnings
|
||||||
from os import path
|
from os import path
|
||||||
from socket import gethostbyname, gethostname
|
from socket import gethostbyname, gethostname
|
||||||
|
|
||||||
@@ -348,6 +349,11 @@ class Base(Configuration):
|
|||||||
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
|
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
),
|
),
|
||||||
|
"connection_test": values.Value(
|
||||||
|
default="30/minute",
|
||||||
|
environ_name="CONNECTION_TEST_THROTTLE_RATES",
|
||||||
|
environ_prefix=None,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
MONITORED_THROTTLE_FAILURE_CALLBACK = (
|
MONITORED_THROTTLE_FAILURE_CALLBACK = (
|
||||||
@@ -397,6 +403,9 @@ class Base(Configuration):
|
|||||||
"feedback": values.DictValue(
|
"feedback": values.DictValue(
|
||||||
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
|
{}, environ_name="FRONTEND_FEEDBACK", environ_prefix=None
|
||||||
),
|
),
|
||||||
|
"documentation_url": values.Value(
|
||||||
|
None, environ_name="FRONTEND_DOCUMENTATION_URL", environ_prefix=None
|
||||||
|
),
|
||||||
"external_home_url": values.Value(
|
"external_home_url": values.Value(
|
||||||
None, environ_name="FRONTEND_EXTERNAL_HOME_URL", environ_prefix=None
|
None, environ_name="FRONTEND_EXTERNAL_HOME_URL", environ_prefix=None
|
||||||
),
|
),
|
||||||
@@ -651,6 +660,21 @@ class Base(Configuration):
|
|||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
|
CONNECTION_TEST_TOKEN_TTL_SECONDS = values.PositiveIntegerValue(
|
||||||
|
300,
|
||||||
|
environ_name="CONNECTION_TEST_TOKEN_TTL_SECONDS",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
CONNECTION_TEST_ROOM_MAX_AGE_SECONDS = values.PositiveIntegerValue(
|
||||||
|
300,
|
||||||
|
environ_name="CONNECTION_TEST_ROOM_MAX_AGE_SECONDS",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
CONNECTION_TEST_ROOM_PREFIX = values.Value(
|
||||||
|
"connection-test-",
|
||||||
|
environ_name="CONNECTION_TEST_ROOM_PREFIX",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
LIVEKIT_VERIFY_SSL = values.BooleanValue(
|
LIVEKIT_VERIFY_SSL = values.BooleanValue(
|
||||||
True, environ_name="LIVEKIT_VERIFY_SSL", environ_prefix=None
|
True, environ_name="LIVEKIT_VERIFY_SSL", environ_prefix=None
|
||||||
)
|
)
|
||||||
@@ -670,6 +694,11 @@ class Base(Configuration):
|
|||||||
environ_name="PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS",
|
environ_name="PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
|
AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = values.BooleanValue(
|
||||||
|
True,
|
||||||
|
environ_name="AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
|
||||||
# Recording settings
|
# Recording settings
|
||||||
RECORDING_ENABLE = values.BooleanValue(
|
RECORDING_ENABLE = values.BooleanValue(
|
||||||
@@ -748,12 +777,30 @@ class Base(Configuration):
|
|||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
SUMMARY_SERVICE_VERSION = values.PositiveIntegerValue(
|
||||||
|
1, environ_name="SUMMARY_SERVICE_VERSION", environ_prefix=None
|
||||||
|
)
|
||||||
SUMMARY_SERVICE_ENDPOINT = values.Value(
|
SUMMARY_SERVICE_ENDPOINT = values.Value(
|
||||||
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
|
None, environ_name="SUMMARY_SERVICE_ENDPOINT", environ_prefix=None
|
||||||
)
|
)
|
||||||
SUMMARY_SERVICE_API_TOKEN = SecretFileValue(
|
SUMMARY_SERVICE_API_TOKEN = SecretFileValue(
|
||||||
None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None
|
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(
|
SCREEN_RECORDING_BASE_URL = values.Value(
|
||||||
None, environ_name="SCREEN_RECORDING_BASE_URL", environ_prefix=None
|
None, environ_name="SCREEN_RECORDING_BASE_URL", environ_prefix=None
|
||||||
)
|
)
|
||||||
@@ -1110,11 +1157,25 @@ class Base(Configuration):
|
|||||||
"FILE_UPLOAD_TMP_PATH cannot be the same as FILE_UPLOAD_PATH"
|
"FILE_UPLOAD_TMP_PATH cannot be the same as FILE_UPLOAD_PATH"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
cls.SUMMARY_SERVICE_VERSION == 1
|
||||||
|
and cls.SUMMARY_SERVICE_ENDPOINT is not None
|
||||||
|
):
|
||||||
|
warnings.warn(
|
||||||
|
"SUMMARY_SERVICE_VERSION=1 is deprecated. "
|
||||||
|
"The legacy v1 API has been removed from the experimental "
|
||||||
|
"Summary service. Please update your Summary service deployment to "
|
||||||
|
"the v2 API and set SUMMARY_SERVICE_VERSION=2.",
|
||||||
|
# We use UserWarning to make sure it shows up in production deployment
|
||||||
|
UserWarning,
|
||||||
|
stacklevel=2,
|
||||||
|
)
|
||||||
|
|
||||||
# The SENTRY_DSN setting should be available to activate sentry for an environment
|
# The SENTRY_DSN setting should be available to activate sentry for an environment
|
||||||
if cls.SENTRY_DSN is not None:
|
if cls.SENTRY_DSN is not None:
|
||||||
sentry_sdk.init(
|
sentry_sdk.init(
|
||||||
dsn=cls.SENTRY_DSN,
|
dsn=cls.SENTRY_DSN,
|
||||||
environment=cls.__name__.lower(),
|
environment=cls.__name__.lower(), # build, test, development, production
|
||||||
release=get_release(),
|
release=get_release(),
|
||||||
integrations=[DjangoIntegration()],
|
integrations=[DjangoIntegration()],
|
||||||
)
|
)
|
||||||
|
|||||||
+13
-13
@@ -7,7 +7,7 @@ build-backend = "uv_build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.22.0"
|
version = "1.24.0"
|
||||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
@@ -24,19 +24,19 @@ keywords = ["Django", "Contacts", "Templates", "RBAC"]
|
|||||||
license = "MIT"
|
license = "MIT"
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"boto3==1.43.14",
|
"boto3==1.43.36",
|
||||||
"Brotli==1.2.0",
|
"Brotli==1.2.0",
|
||||||
"brevo-python==1.2.0",
|
"brevo-python==1.2.0",
|
||||||
"celery[redis]==5.6.3",
|
"celery[redis]==5.6.3",
|
||||||
"dj-database-url==3.1.2",
|
"dj-database-url==3.1.2",
|
||||||
"django-configurations==2.5.1",
|
"django-configurations==2.5.1",
|
||||||
"django-cors-headers==4.9.0",
|
"django-cors-headers==4.9.0",
|
||||||
"django-countries==8.2.0",
|
"django-countries==9.0.0",
|
||||||
"django-filter==25.2",
|
"django-filter==25.2",
|
||||||
"django-lasuite[all]==0.0.26",
|
"django-lasuite[all]==0.0.27",
|
||||||
"django-parler==2.4",
|
"django-parler==2.4",
|
||||||
"redis==5.2.1",
|
"redis==5.2.1",
|
||||||
"django-redis==6.0.0",
|
"django-redis==7.0.0",
|
||||||
"django-storages[s3]==1.14.6",
|
"django-storages[s3]==1.14.6",
|
||||||
"django-timezone-field>=5.1",
|
"django-timezone-field>=5.1",
|
||||||
"django-pydantic-field==0.5.4",
|
"django-pydantic-field==0.5.4",
|
||||||
@@ -57,13 +57,13 @@ dependencies = [
|
|||||||
"python-frontmatter==1.3.0",
|
"python-frontmatter==1.3.0",
|
||||||
"python-magic==0.4.27",
|
"python-magic==0.4.27",
|
||||||
"requests==2.34.2",
|
"requests==2.34.2",
|
||||||
"sentry-sdk==2.60.0",
|
"sentry-sdk==2.63.0",
|
||||||
"whitenoise==6.12.0",
|
"whitenoise==6.12.0",
|
||||||
"mozilla-django-oidc==5.0.2",
|
"mozilla-django-oidc==5.0.2",
|
||||||
"livekit-api==1.1.0",
|
"livekit-api==1.1.1",
|
||||||
"aiohttp==3.14.0",
|
"aiohttp==3.14.1",
|
||||||
"urllib3==2.7.0",
|
"urllib3==2.7.0",
|
||||||
"phonenumbers==9.0.31",
|
"phonenumbers==9.0.33",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
@@ -75,20 +75,20 @@ dependencies = [
|
|||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
"django-extensions==4.1",
|
"django-extensions==4.1",
|
||||||
"drf-spectacular-sidecar==2026.5.1",
|
"drf-spectacular-sidecar==2026.6.1",
|
||||||
"freezegun==1.5.5",
|
"freezegun==1.5.5",
|
||||||
"ipdb==0.13.13",
|
"ipdb==0.13.13",
|
||||||
"ipython==9.13.0",
|
"ipython==9.14.1",
|
||||||
"pyfakefs==6.2.0",
|
"pyfakefs==6.2.0",
|
||||||
"pylint-django==2.7.0",
|
"pylint-django==2.7.0",
|
||||||
"pylint<4.0.0",
|
"pylint<4.0.0",
|
||||||
"pytest-cov==7.1.0",
|
"pytest-cov==7.1.0",
|
||||||
"pytest-django==4.12.0",
|
"pytest-django==4.12.0",
|
||||||
"pytest==9.0.3",
|
"pytest==9.1.1",
|
||||||
"pytest-icdiff==0.9",
|
"pytest-icdiff==0.9",
|
||||||
"pytest-xdist==3.8.0",
|
"pytest-xdist==3.8.0",
|
||||||
"responses==0.26.1",
|
"responses==0.26.1",
|
||||||
"ruff==0.15.14",
|
"ruff==0.15.19",
|
||||||
"types-requests==2.33.0.20260518",
|
"types-requests==2.33.0.20260518",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Generated
+508
-517
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
|||||||
FROM node:20-alpine AS frontend-deps
|
FROM node:22-alpine AS frontend-deps
|
||||||
|
|
||||||
USER node
|
USER node
|
||||||
|
|
||||||
@@ -39,7 +39,7 @@ ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
|||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ---- Front-end image ----
|
# ---- Front-end image ----
|
||||||
FROM nginxinc/nginx-unprivileged:alpine3.23 AS frontend-production
|
FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
|
||||||
|
|
||||||
USER root
|
USER root
|
||||||
|
|
||||||
|
|||||||
Generated
+239
-54
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.22.0",
|
"version": "1.24.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"version": "1.22.0",
|
"version": "1.24.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||||
"@fontsource-variable/lexend": "5.2.11",
|
"@fontsource-variable/lexend": "5.2.11",
|
||||||
@@ -15,10 +15,11 @@
|
|||||||
"@livekit/components-react": "2.9.21",
|
"@livekit/components-react": "2.9.21",
|
||||||
"@livekit/components-styles": "1.2.0",
|
"@livekit/components-styles": "1.2.0",
|
||||||
"@livekit/track-processors": "0.7.2",
|
"@livekit/track-processors": "0.7.2",
|
||||||
|
"@mediapipe/tasks-vision": "0.10.35",
|
||||||
"@pandacss/preset-panda": "1.11.3",
|
"@pandacss/preset-panda": "1.11.3",
|
||||||
"@react-types/overlays": "3.10.0",
|
"@react-types/overlays": "3.10.0",
|
||||||
"@remixicon/react": "4.9.0",
|
"@remixicon/react": "4.9.0",
|
||||||
"@tanstack/react-query": "5.100.14",
|
"@tanstack/react-query": "5.101.1",
|
||||||
"@timephy/rnnoise-wasm": "1.0.0",
|
"@timephy/rnnoise-wasm": "1.0.0",
|
||||||
"crisp-sdk-web": "1.1.2",
|
"crisp-sdk-web": "1.1.2",
|
||||||
"derive-valtio": "0.2.0",
|
"derive-valtio": "0.2.0",
|
||||||
@@ -28,14 +29,14 @@
|
|||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"i18next-parser": "9.4.0",
|
"i18next-parser": "9.4.0",
|
||||||
"i18next-resources-to-backend": "1.2.1",
|
"i18next-resources-to-backend": "1.2.1",
|
||||||
"livekit-client": "2.19.0",
|
"livekit-client": "2.20.0",
|
||||||
"posthog-js": "1.386.5",
|
"posthog-js": "1.395.0",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria": "3.49.0",
|
"react-aria": "3.50.0",
|
||||||
"react-aria-components": "1.18.0",
|
"react-aria-components": "1.19.0",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-i18next": "17.0.8",
|
"react-i18next": "17.0.8",
|
||||||
"react-stately": "3.47.0",
|
"react-stately": "3.48.0",
|
||||||
"use-sound": "5.0.0",
|
"use-sound": "5.0.0",
|
||||||
"valtio": "2.3.2",
|
"valtio": "2.3.2",
|
||||||
"wouter": "3.10.0"
|
"wouter": "3.10.0"
|
||||||
@@ -63,6 +64,7 @@
|
|||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"typescript-eslint": "8.60.1",
|
"typescript-eslint": "8.60.1",
|
||||||
"vite": "8.0.14",
|
"vite": "8.0.14",
|
||||||
|
"vite-plugin-static-copy": "4.1.1",
|
||||||
"vite-plugin-svgr": "5.2.0"
|
"vite-plugin-svgr": "5.2.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -110,6 +112,62 @@
|
|||||||
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
|
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@adobe/react-spectrum/node_modules/react-aria": {
|
||||||
|
"version": "3.49.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.49.0.tgz",
|
||||||
|
"integrity": "sha512-4+oK9FwJQWYhyA5zLfj/feOGY0zZbkE1muoF4gyxMroHVypjcYaRSTlJwvxph2zIlxt757KX6xIK2wJ5Aw1Kog==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@internationalized/date": "^3.12.2",
|
||||||
|
"@internationalized/number": "^3.6.7",
|
||||||
|
"@internationalized/string": "^3.2.9",
|
||||||
|
"@react-types/shared": "^3.35.0",
|
||||||
|
"@swc/helpers": "^0.5.0",
|
||||||
|
"aria-hidden": "^1.2.3",
|
||||||
|
"clsx": "^2.0.0",
|
||||||
|
"react-stately": "3.47.0",
|
||||||
|
"use-sync-external-store": "^1.6.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@adobe/react-spectrum/node_modules/react-aria-components": {
|
||||||
|
"version": "1.18.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.18.0.tgz",
|
||||||
|
"integrity": "sha512-FhRQjuDkH4WhgFv+O2sYTzK3JzdZTGpBeaqfRlfTo+DcSZzD8elJEkytHe7SDpcexVKeire8NVd7OruZHfCVoA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@internationalized/date": "^3.12.2",
|
||||||
|
"@react-types/shared": "^3.35.0",
|
||||||
|
"@swc/helpers": "^0.5.0",
|
||||||
|
"client-only": "^0.0.1",
|
||||||
|
"react-aria": "3.49.0",
|
||||||
|
"react-stately": "3.47.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||||
|
"react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@adobe/react-spectrum/node_modules/react-stately": {
|
||||||
|
"version": "3.47.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.47.0.tgz",
|
||||||
|
"integrity": "sha512-H3ar+SOWP920EbVg7qWfP3fZjZiwhlEJAEJQqjt+w8oKijCwFgr0+R4941PIHscOXRNRvEOjvWilitImC0DdBg==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@internationalized/date": "^3.12.2",
|
||||||
|
"@internationalized/number": "^3.6.7",
|
||||||
|
"@internationalized/string": "^3.2.9",
|
||||||
|
"@react-types/shared": "^3.35.0",
|
||||||
|
"@swc/helpers": "^0.5.0",
|
||||||
|
"use-sync-external-store": "^1.6.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||||
@@ -970,9 +1028,9 @@
|
|||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/@livekit/protocol": {
|
"node_modules/@livekit/protocol": {
|
||||||
"version": "1.45.8",
|
"version": "1.46.6",
|
||||||
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.45.8.tgz",
|
"resolved": "https://registry.npmjs.org/@livekit/protocol/-/protocol-1.46.6.tgz",
|
||||||
"integrity": "sha512-Q+l57E7w/xxOBFVWzdX5rkAZO7ffyF+rlDzNUYq2SU114+5aTyCq+PK4unaEVDNd4952Af7wteKr3sOgasGuaA==",
|
"integrity": "sha512-upzlHP1vi/kZ/QqALZTFskQ0ifqc2f15RKucHYOsIHJsaXvEYanG75mAb7o+Yomfs4XhQ4BaRsdY+TFHXpaqrg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bufbuild/protobuf": "^1.10.0"
|
"@bufbuild/protobuf": "^1.10.0"
|
||||||
@@ -991,12 +1049,18 @@
|
|||||||
"livekit-client": "^1.12.0 || ^2.1.0"
|
"livekit-client": "^1.12.0 || ^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mediapipe/tasks-vision": {
|
"node_modules/@livekit/track-processors/node_modules/@mediapipe/tasks-vision": {
|
||||||
"version": "0.10.14",
|
"version": "0.10.14",
|
||||||
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.14.tgz",
|
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.14.tgz",
|
||||||
"integrity": "sha512-vOifgZhkndgybdvoRITzRkIueWWSiCKuEUXXK6Q4FaJsFvRJuwgg++vqFUMlL0Uox62U5aEXFhHxlhV7Ja5e3Q==",
|
"integrity": "sha512-vOifgZhkndgybdvoRITzRkIueWWSiCKuEUXXK6Q4FaJsFvRJuwgg++vqFUMlL0Uox62U5aEXFhHxlhV7Ja5e3Q==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/@mediapipe/tasks-vision": {
|
||||||
|
"version": "0.10.35",
|
||||||
|
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.35.tgz",
|
||||||
|
"integrity": "sha512-HOvadwVRE6JC+45nyYhmnywnr5h/J8KZvOeUNVOG9q/0875pZgItznFB9bRTvLc264YSJqiZ1NsIpCStJw/egg==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/@modelcontextprotocol/sdk": {
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
"version": "1.29.0",
|
"version": "1.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||||
@@ -1752,9 +1816,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@react-types/shared": {
|
"node_modules/@react-types/shared": {
|
||||||
"version": "3.35.0",
|
"version": "3.36.0",
|
||||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.35.0.tgz",
|
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.36.0.tgz",
|
||||||
"integrity": "sha512-iNWvuzEwANttpQpdlu8nPBtdHb0mcCMj1ZTH//iRB5E/14IAnyRlR25rxH7pNLyzHINsPGEKnWvpwDMCT6vziQ==",
|
"integrity": "sha512-DkP/H0C2YjjS7gZWKNqOmU8a16qHPjQNdzMwmTq9SzplM6Iw0kVMTZ0OIoe6FOgGqa+FwMsE2QbPjh/n3g/jXQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||||
@@ -2357,9 +2421,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/query-core": {
|
"node_modules/@tanstack/query-core": {
|
||||||
"version": "5.100.14",
|
"version": "5.101.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.100.14.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.1.tgz",
|
||||||
"integrity": "sha512-5X41dGpxgeaHISCRW2oYwcSycZeULZzAunaudXT9ov1KOTj9xwt0CH6hbwqP1/z74ZWF7rYFnDpyYH07XFcZew==",
|
"integrity": "sha512-Y6Y92dkXtNqx67m2pMSxUsA3zOCwv862JexZRP8/EPwvKXMPu9m8rv43spiXWzOUIggQ3SQApttALStzhA8B4g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -2378,12 +2442,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/react-query": {
|
"node_modules/@tanstack/react-query": {
|
||||||
"version": "5.100.14",
|
"version": "5.101.1",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.100.14.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.1.tgz",
|
||||||
"integrity": "sha512-oOr6aRdSFEwWhzxEkD/9ZcItM3+LjBSkeVmadWKwUssAHTsqd/7bOjWrX4AbvEkoEhgAxzN0Xk6H/aYzXiYBAw==",
|
"integrity": "sha512-ZnONUuQKJe1bJMStXUL1s5uKN9FcfC28j5cK+iDZcdSHtUv1wtin1cGc/Oewhf2Oc4eKY7lggtpvT/AbMmhHew==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/query-core": "5.100.14"
|
"@tanstack/query-core": "5.101.1"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -3192,6 +3256,19 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/binary-extensions": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bl": {
|
"node_modules/bl": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/bl/-/bl-5.1.0.tgz",
|
||||||
@@ -7094,6 +7171,19 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-binary-path": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"binary-extensions": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-boolean-object": {
|
"node_modules/is-boolean-object": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz",
|
||||||
@@ -8035,13 +8125,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/livekit-client": {
|
"node_modules/livekit-client": {
|
||||||
"version": "2.19.0",
|
"version": "2.20.0",
|
||||||
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.19.0.tgz",
|
"resolved": "https://registry.npmjs.org/livekit-client/-/livekit-client-2.20.0.tgz",
|
||||||
"integrity": "sha512-aolY1XDAtx0nHKBNm29W9OhzBnSz1CP5kq3phvRhFfi1NbvMXs8tcACjAkZTnIKgihkp+BiJScZZ3tZv0Gz8sA==",
|
"integrity": "sha512-RIJcpvBmOmwz3jTj3rmdY6Dzr55HrhcaJjMgY+HSmoEM+yIRyA40m7r8UKv0hnZWM3z/AYhP1q8C8ciz5UWFKQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@livekit/mutex": "1.1.1",
|
"@livekit/mutex": "1.1.1",
|
||||||
"@livekit/protocol": "1.45.8",
|
"@livekit/protocol": "1.46.6",
|
||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
"jose": "^6.1.0",
|
"jose": "^6.1.0",
|
||||||
"loglevel": "^1.9.2",
|
"loglevel": "^1.9.2",
|
||||||
@@ -8675,6 +8765,19 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/p-map": {
|
||||||
|
"version": "7.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.5.tgz",
|
||||||
|
"integrity": "sha512-e8vJF4XdVkzqqSHguEMz41mQO1wKwxKm5ENrUJQUu9kLDCtn83cxbyHZcszr4QC5zEA7WffRRC4gsTecC7J9oA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/package-manager-detector": {
|
"node_modules/package-manager-detector": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz",
|
||||||
@@ -9033,19 +9136,19 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/posthog-js": {
|
"node_modules/posthog-js": {
|
||||||
"version": "1.386.5",
|
"version": "1.395.0",
|
||||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.386.5.tgz",
|
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.395.0.tgz",
|
||||||
"integrity": "sha512-ASejQQf5Xw0XolMwH/KCLZlZtoyLK6VsvORwGagAtfa8/ElIOF76BMQspkDsRTybEI+uzHqRDm2m/na1Dki2mA==",
|
"integrity": "sha512-5iTb00CGt2eQUUiBQysQiX89RAbCN6wK2sDNzvs9zv0alaY8mJ0ZySrUD3LQ+XyLhgM5pCpacBuUwChqiYDLDw==",
|
||||||
"license": "SEE LICENSE IN LICENSE",
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@posthog/core": "^1.32.3",
|
"@posthog/core": "^1.38.0",
|
||||||
"@posthog/types": "^1.386.3",
|
"@posthog/types": "^1.391.1",
|
||||||
"core-js": "^3.38.1",
|
"core-js": "^3.38.1",
|
||||||
"dompurify": "^3.3.2",
|
"dompurify": "^3.3.2",
|
||||||
"fflate": "^0.4.8",
|
"fflate": "^0.4.8",
|
||||||
"preact": "^10.28.2",
|
"preact": "^10.29.2",
|
||||||
"query-selector-shadow-dom": "^1.0.1",
|
"query-selector-shadow-dom": "^1.0.1",
|
||||||
"web-vitals": "^5.1.0"
|
"web-vitals": "^5.3.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/powershell-utils": {
|
"node_modules/powershell-utils": {
|
||||||
@@ -9062,13 +9165,21 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/preact": {
|
"node_modules/preact": {
|
||||||
"version": "10.28.3",
|
"version": "10.29.7",
|
||||||
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.3.tgz",
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.29.7.tgz",
|
||||||
"integrity": "sha512-tCmoRkPQLpBeWzpmbhryairGnhW9tKV6c6gr/w+RhoRoKEJwsjzipwp//1oCpGPOchvSLaAPlpcJi9MwMmoPyA==",
|
"integrity": "sha512-DCHYrK/B10yUD3ZjLfhZ3WIE/9Vf9VFUODcRE2dRomTYDpJk6z6L9wecSfhfE6M9ZTHUdyQkoC46arIDhEV84Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/preact"
|
"url": "https://opencollective.com/preact"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"preact-render-to-string": ">=5"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"preact-render-to-string": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
@@ -9275,19 +9386,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-aria": {
|
"node_modules/react-aria": {
|
||||||
"version": "3.49.0",
|
"version": "3.50.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.49.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-aria/-/react-aria-3.50.0.tgz",
|
||||||
"integrity": "sha512-4+oK9FwJQWYhyA5zLfj/feOGY0zZbkE1muoF4gyxMroHVypjcYaRSTlJwvxph2zIlxt757KX6xIK2wJ5Aw1Kog==",
|
"integrity": "sha512-S0Os6QZk33fzUAKu1QLT9afoUaCBt1ZNdoiq0n2YMVgKIdNIQS8zxiZ8O9hYE6QyDkHKjD6q39LQZ+qaSAIgjw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@internationalized/date": "^3.12.2",
|
"@internationalized/date": "^3.12.2",
|
||||||
"@internationalized/number": "^3.6.7",
|
"@internationalized/number": "^3.6.7",
|
||||||
"@internationalized/string": "^3.2.9",
|
"@internationalized/string": "^3.2.9",
|
||||||
"@react-types/shared": "^3.35.0",
|
"@react-types/shared": "^3.36.0",
|
||||||
"@swc/helpers": "^0.5.0",
|
"@swc/helpers": "^0.5.0",
|
||||||
"aria-hidden": "^1.2.3",
|
"aria-hidden": "^1.2.3",
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"react-stately": "3.47.0",
|
"react-stately": "3.48.0",
|
||||||
"use-sync-external-store": "^1.6.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
@@ -9296,17 +9407,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-aria-components": {
|
"node_modules/react-aria-components": {
|
||||||
"version": "1.18.0",
|
"version": "1.19.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-aria-components/-/react-aria-components-1.19.0.tgz",
|
||||||
"integrity": "sha512-FhRQjuDkH4WhgFv+O2sYTzK3JzdZTGpBeaqfRlfTo+DcSZzD8elJEkytHe7SDpcexVKeire8NVd7OruZHfCVoA==",
|
"integrity": "sha512-2smSS5nqJ8cGYMQezuUXveZm7eMyHCqTN6mDpylQBYLYbdF5dxCCuW1DHn1VKLe1DybSfPvX/cZtJlDmvFfn8A==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@internationalized/date": "^3.12.2",
|
"@internationalized/date": "^3.12.2",
|
||||||
"@react-types/shared": "^3.35.0",
|
"@react-types/shared": "^3.36.0",
|
||||||
"@swc/helpers": "^0.5.0",
|
"@swc/helpers": "^0.5.0",
|
||||||
"client-only": "^0.0.1",
|
"client-only": "^0.0.1",
|
||||||
"react-aria": "3.49.0",
|
"react-aria": "3.50.0",
|
||||||
"react-stately": "3.47.0"
|
"react-stately": "3.48.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1",
|
||||||
@@ -9360,15 +9471,15 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/react-stately": {
|
"node_modules/react-stately": {
|
||||||
"version": "3.47.0",
|
"version": "3.48.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.47.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-stately/-/react-stately-3.48.0.tgz",
|
||||||
"integrity": "sha512-H3ar+SOWP920EbVg7qWfP3fZjZiwhlEJAEJQqjt+w8oKijCwFgr0+R4941PIHscOXRNRvEOjvWilitImC0DdBg==",
|
"integrity": "sha512-ImicSAG+lTotAe5izcs1fz49Zk48w7pDusqYg04WaPhCoej8BJ24soMu3iLXIrsi273s4P1gZrYGrqReMfgEEA==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@internationalized/date": "^3.12.2",
|
"@internationalized/date": "^3.12.2",
|
||||||
"@internationalized/number": "^3.6.7",
|
"@internationalized/number": "^3.6.7",
|
||||||
"@internationalized/string": "^3.2.9",
|
"@internationalized/string": "^3.2.9",
|
||||||
"@react-types/shared": "^3.35.0",
|
"@react-types/shared": "^3.36.0",
|
||||||
"@swc/helpers": "^0.5.0",
|
"@swc/helpers": "^0.5.0",
|
||||||
"use-sync-external-store": "^1.6.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
},
|
},
|
||||||
@@ -11137,6 +11248,80 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vite-plugin-static-copy": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-GrlA8YklrAfSyxJ4M3fdQLOo9oNkp56IM9FYgX/WtEgeIFkPwhu4wzpufBCIuNKCa6Fn77FkRdYxkHqV0FwjAw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"chokidar": "^3.6.0",
|
||||||
|
"p-map": "^7.0.4",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
|
"tinyglobby": "^0.2.17"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.0.0 || >=24.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/sapphi-red"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite-plugin-static-copy/node_modules/chokidar": {
|
||||||
|
"version": "3.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||||
|
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"anymatch": "~3.1.2",
|
||||||
|
"braces": "~3.0.2",
|
||||||
|
"glob-parent": "~5.1.2",
|
||||||
|
"is-binary-path": "~2.1.0",
|
||||||
|
"is-glob": "~4.0.1",
|
||||||
|
"normalize-path": "~3.0.0",
|
||||||
|
"readdirp": "~3.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8.10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://paulmillr.com/funding/"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "~2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite-plugin-static-copy/node_modules/glob-parent": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"is-glob": "^4.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/vite-plugin-static-copy/node_modules/readdirp": {
|
||||||
|
"version": "3.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||||
|
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"picomatch": "^2.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite-plugin-svgr": {
|
"node_modules/vite-plugin-svgr": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-5.2.0.tgz",
|
||||||
@@ -11470,9 +11655,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/web-vitals": {
|
"node_modules/web-vitals": {
|
||||||
"version": "5.1.0",
|
"version": "5.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.3.0.tgz",
|
||||||
"integrity": "sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==",
|
"integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==",
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
"node_modules/webrtc-adapter": {
|
"node_modules/webrtc-adapter": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "meet",
|
"name": "meet",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.22.0",
|
"version": "1.24.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "panda codegen && vite",
|
"dev": "panda codegen && vite",
|
||||||
@@ -17,15 +17,16 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
"@fontsource-variable/atkinson-hyperlegible-next": "5.2.6",
|
||||||
"@fontsource-variable/lexend": "5.2.11",
|
"@fontsource-variable/lexend": "5.2.11",
|
||||||
"@libreaudio/la-call": "0.1.4",
|
|
||||||
"@fontsource/opendyslexic": "5.2.5",
|
"@fontsource/opendyslexic": "5.2.5",
|
||||||
|
"@libreaudio/la-call": "0.1.4",
|
||||||
"@livekit/components-react": "2.9.21",
|
"@livekit/components-react": "2.9.21",
|
||||||
"@livekit/components-styles": "1.2.0",
|
"@livekit/components-styles": "1.2.0",
|
||||||
"@livekit/track-processors": "0.7.2",
|
"@livekit/track-processors": "0.7.2",
|
||||||
|
"@mediapipe/tasks-vision": "0.10.35",
|
||||||
"@pandacss/preset-panda": "1.11.3",
|
"@pandacss/preset-panda": "1.11.3",
|
||||||
"@react-types/overlays": "3.10.0",
|
"@react-types/overlays": "3.10.0",
|
||||||
"@remixicon/react": "4.9.0",
|
"@remixicon/react": "4.9.0",
|
||||||
"@tanstack/react-query": "5.100.14",
|
"@tanstack/react-query": "5.101.1",
|
||||||
"@timephy/rnnoise-wasm": "1.0.0",
|
"@timephy/rnnoise-wasm": "1.0.0",
|
||||||
"crisp-sdk-web": "1.1.2",
|
"crisp-sdk-web": "1.1.2",
|
||||||
"derive-valtio": "0.2.0",
|
"derive-valtio": "0.2.0",
|
||||||
@@ -35,14 +36,14 @@
|
|||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"i18next-parser": "9.4.0",
|
"i18next-parser": "9.4.0",
|
||||||
"i18next-resources-to-backend": "1.2.1",
|
"i18next-resources-to-backend": "1.2.1",
|
||||||
"livekit-client": "2.19.0",
|
"livekit-client": "2.20.0",
|
||||||
"posthog-js": "1.386.5",
|
"posthog-js": "1.395.0",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-aria": "3.49.0",
|
"react-aria": "3.50.0",
|
||||||
"react-aria-components": "1.18.0",
|
"react-aria-components": "1.19.0",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-i18next": "17.0.8",
|
"react-i18next": "17.0.8",
|
||||||
"react-stately": "3.47.0",
|
"react-stately": "3.48.0",
|
||||||
"use-sound": "5.0.0",
|
"use-sound": "5.0.0",
|
||||||
"valtio": "2.3.2",
|
"valtio": "2.3.2",
|
||||||
"wouter": "3.10.0"
|
"wouter": "3.10.0"
|
||||||
@@ -70,6 +71,7 @@
|
|||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"typescript-eslint": "8.60.1",
|
"typescript-eslint": "8.60.1",
|
||||||
"vite": "8.0.14",
|
"vite": "8.0.14",
|
||||||
|
"vite-plugin-static-copy": "4.1.1",
|
||||||
"vite-plugin-svgr": "5.2.0"
|
"vite-plugin-svgr": "5.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,10 @@ const config: Config = {
|
|||||||
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 0)',
|
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 0)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
overlayIn: {
|
||||||
|
from: { opacity: 0 },
|
||||||
|
to: { opacity: 0.6 },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
tokens: defineTokens({
|
tokens: defineTokens({
|
||||||
/* we take a few things from the panda preset but for now we clear out some stuff.
|
/* we take a few things from the panda preset but for now we clear out some stuff.
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -20,6 +20,7 @@ export interface ApiConfig {
|
|||||||
feedback: {
|
feedback: {
|
||||||
url: string
|
url: string
|
||||||
}
|
}
|
||||||
|
documentation_url?: string
|
||||||
external_home_url?: string
|
external_home_url?: string
|
||||||
silence_livekit_debug_logs?: boolean
|
silence_livekit_debug_logs?: boolean
|
||||||
is_silent_login_enabled?: boolean
|
is_silent_login_enabled?: boolean
|
||||||
@@ -58,6 +59,7 @@ export interface ApiConfig {
|
|||||||
transcription_destination?: string
|
transcription_destination?: string
|
||||||
max_participants_for_sound: number
|
max_participants_for_sound: number
|
||||||
auto_mute_on_join_threshold: number
|
auto_mute_on_join_threshold: number
|
||||||
|
authenticated_users_can_edit_display_name: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetchConfig = (): Promise<ApiConfig> => {
|
const fetchConfig = (): Promise<ApiConfig> => {
|
||||||
|
|||||||
@@ -7,36 +7,20 @@ const avatar = cva({
|
|||||||
color: 'white',
|
color: 'white',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
cursor: 'default',
|
cursor: 'default',
|
||||||
flexGrow: 0,
|
flexGrow: 0,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
variants: {
|
variants: {
|
||||||
context: {
|
context: {
|
||||||
subtitles: {
|
subtitles: { width: '40px', height: '40px' },
|
||||||
width: '40px',
|
list: { width: '32px', height: '32px' },
|
||||||
height: '40px',
|
placeholder: { width: '100%', height: '100%' },
|
||||||
fontSize: '1.3rem',
|
|
||||||
lineHeight: '1rem',
|
|
||||||
},
|
|
||||||
list: {
|
|
||||||
width: '32px',
|
|
||||||
height: '32px',
|
|
||||||
fontSize: '1.3rem',
|
|
||||||
lineHeight: '1rem',
|
|
||||||
},
|
|
||||||
placeholder: {
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
notification: {
|
notification: {
|
||||||
true: {
|
true: { border: '2px solid white' },
|
||||||
border: '2px solid white',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
@@ -44,37 +28,49 @@ const avatar = cva({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const getInitials = (name?: string): string => {
|
||||||
|
if (!name) return ''
|
||||||
|
const words = name.trim().split(/\s+/).filter(Boolean)
|
||||||
|
if (words.length === 0) return ''
|
||||||
|
const first = words[0].charAt(0)
|
||||||
|
const second = words.length > 1 ? words[1].charAt(0) : ''
|
||||||
|
return (first + second).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
|
export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||||
name?: string
|
name?: string
|
||||||
bgColor?: string
|
bgColor?: string
|
||||||
} & RecipeVariantProps<typeof avatar>
|
} & RecipeVariantProps<typeof avatar>
|
||||||
|
|
||||||
export const Avatar = ({
|
export const Avatar = React.memo(
|
||||||
name,
|
({ name, bgColor, context, notification, style, ...props }: AvatarProps) => {
|
||||||
bgColor,
|
const initials = getInitials(name)
|
||||||
context,
|
|
||||||
notification,
|
|
||||||
style,
|
|
||||||
...props
|
|
||||||
}: AvatarProps) => {
|
|
||||||
const initial = name?.trim()?.charAt(0) ?? ''
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{ backgroundColor: bgColor, ...style }}
|
||||||
backgroundColor: bgColor,
|
|
||||||
...style,
|
|
||||||
}}
|
|
||||||
className={avatar({ context, notification })}
|
className={avatar({ context, notification })}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span
|
<svg
|
||||||
|
viewBox="0 0 100 100"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={css({
|
className={css({ width: '100%', height: '100%', display: 'block' })}
|
||||||
marginTop: '-0.3rem',
|
|
||||||
})}
|
|
||||||
>
|
>
|
||||||
{initial}
|
<text
|
||||||
</span>
|
x="50"
|
||||||
|
y="50"
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="central"
|
||||||
|
fontSize={initials.length > 1 ? 48 : 52}
|
||||||
|
fontWeight="500"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
{initials.toUpperCase()}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Avatar.displayName = 'Avatar'
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Text } from '@/primitives'
|
||||||
|
import { ChatMessages } from './ChatMessages'
|
||||||
|
import { ChatTextArea } from './ChatTextArea'
|
||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
|
||||||
|
const ChatContainer = styled('div', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
padding: '0 1.5rem',
|
||||||
|
flexGrow: 1,
|
||||||
|
flexDirection: 'column',
|
||||||
|
minHeight: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const ChatMessagesContainer = styled('div', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
flexGrow: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const TextContainer = styled('div', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
padding: '0.75rem',
|
||||||
|
backgroundColor: 'greyscale.50',
|
||||||
|
borderRadius: 4,
|
||||||
|
marginBottom: '0.75rem',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const Chat = () => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'chat' })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ChatContainer>
|
||||||
|
<TextContainer>
|
||||||
|
<Text variant="sm">{t('disclaimer')}</Text>
|
||||||
|
</TextContainer>
|
||||||
|
<ChatMessagesContainer>
|
||||||
|
<ChatMessages />
|
||||||
|
</ChatMessagesContainer>
|
||||||
|
<ChatTextArea />
|
||||||
|
</ChatContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { ChatRow } from '@/stores/chat'
|
||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
import { ChatMessageMetadata } from './ChatMessageMedata'
|
||||||
|
import { ChatMessageBody } from './ChatMessageBody'
|
||||||
|
|
||||||
|
const StyledContainer = styled('li', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: '0.25rem',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
type ChatMessageProps = {
|
||||||
|
item: ChatRow
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChatMessage = ({ item }: ChatMessageProps) => {
|
||||||
|
const time = new Date(item.timestamp)
|
||||||
|
const locale = navigator ? navigator.language : 'en-US'
|
||||||
|
return (
|
||||||
|
<StyledContainer
|
||||||
|
title={time.toLocaleTimeString(locale, { timeStyle: 'full' })}
|
||||||
|
>
|
||||||
|
{!item.hideMetadata && (
|
||||||
|
<ChatMessageMetadata
|
||||||
|
timestamp={item.timestamp}
|
||||||
|
identity={item.identity}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ChatMessageBody message={item.message} />
|
||||||
|
</StyledContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { ChatRow } from '@/stores/chat'
|
||||||
|
import React, { useMemo } from 'react'
|
||||||
|
import { formatChatMessageLinks } from '@livekit/components-react'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { Text } from '@/primitives'
|
||||||
|
|
||||||
|
type ChatMessageBodyProps = Pick<ChatRow, 'message'>
|
||||||
|
|
||||||
|
export const ChatMessageBody = React.memo(
|
||||||
|
({ message }: ChatMessageBodyProps) => {
|
||||||
|
const formattedMessage = useMemo(() => {
|
||||||
|
return formatChatMessageLinks(message)
|
||||||
|
}, [message])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text
|
||||||
|
variant="sm"
|
||||||
|
margin={false}
|
||||||
|
className={css({
|
||||||
|
whiteSpace: 'pre-wrap',
|
||||||
|
'& .lk-chat-link': {
|
||||||
|
color: 'blue',
|
||||||
|
textDecoration: 'underline',
|
||||||
|
},
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{formattedMessage}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ChatMessageBody.displayName = 'ChatMessageBody'
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
import { ChatRow, chatStore } from '@/stores/chat'
|
||||||
|
import React, { useMemo } from 'react'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { Text } from '@/primitives'
|
||||||
|
|
||||||
|
const StyledContainer = styled('span', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
gap: '0.5rem',
|
||||||
|
paddingTop: '0.75rem',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
type ChatMessageMetadataProps = Pick<ChatRow, 'identity' | 'timestamp'>
|
||||||
|
|
||||||
|
export const ChatMessageMetadata = React.memo(
|
||||||
|
({ identity, timestamp }: ChatMessageMetadataProps) => {
|
||||||
|
const time = new Date(timestamp)
|
||||||
|
const locale = navigator ? navigator.language : 'en-US'
|
||||||
|
|
||||||
|
const { names } = useSnapshot(chatStore)
|
||||||
|
|
||||||
|
const currentDisplayName = useMemo(() => {
|
||||||
|
if (identity) return names[identity] ?? identity
|
||||||
|
}, [names, identity])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledContainer>
|
||||||
|
<Text bold={true} variant="sm">
|
||||||
|
{currentDisplayName}
|
||||||
|
</Text>
|
||||||
|
<Text variant="smNote" wrap="no">
|
||||||
|
{time.toLocaleTimeString(locale, { timeStyle: 'short' })}
|
||||||
|
</Text>
|
||||||
|
</StyledContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ChatMessageMetadata.displayName = 'ChatMessageMetadata'
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import {
|
||||||
|
ListBox,
|
||||||
|
ListBoxItem,
|
||||||
|
ListLayout,
|
||||||
|
Virtualizer,
|
||||||
|
} from 'react-aria-components'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { ChatMessage } from './ChatMessage.tsx'
|
||||||
|
import { useLayoutEffect, useRef } from 'react'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { ChatRow, chatStore } from '@/stores/chat'
|
||||||
|
|
||||||
|
// Estimated height of a chat entry in px. ListLayout measures the real
|
||||||
|
// rendered height of each row; this value is only used to size the
|
||||||
|
// scrollbar before rows have been measured.
|
||||||
|
const ESTIMATED_ROW_HEIGHT = 56
|
||||||
|
const BOTTOM_THRESHOLD_PX = 32
|
||||||
|
|
||||||
|
export const ChatMessages = () => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'chat' })
|
||||||
|
const { rows: items } = useSnapshot(chatStore)
|
||||||
|
|
||||||
|
const listRef = useRef<HTMLDivElement>(null)
|
||||||
|
const stick = useRef(true)
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = listRef.current
|
||||||
|
if (!el) return
|
||||||
|
const pin = () => {
|
||||||
|
if (stick.current) el.scrollTop = el.scrollHeight
|
||||||
|
}
|
||||||
|
const onScroll = () => {
|
||||||
|
stick.current =
|
||||||
|
el.scrollHeight - el.scrollTop - el.clientHeight <= BOTTOM_THRESHOLD_PX
|
||||||
|
}
|
||||||
|
const ro = new ResizeObserver(pin)
|
||||||
|
// Sizer div = ListLayout's content size. Fires on append, and again each
|
||||||
|
// time an estimated row height is replaced by a measured one.
|
||||||
|
if (el.firstElementChild) ro.observe(el.firstElementChild)
|
||||||
|
// Container. Height goes 0 -> N as the panel animates in, and on resize.
|
||||||
|
ro.observe(el)
|
||||||
|
el.addEventListener('scroll', onScroll, { passive: true })
|
||||||
|
pin()
|
||||||
|
return () => {
|
||||||
|
ro.disconnect()
|
||||||
|
el.removeEventListener('scroll', onScroll)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Virtualizer
|
||||||
|
layout={ListLayout}
|
||||||
|
layoutOptions={{
|
||||||
|
estimatedRowSize: ESTIMATED_ROW_HEIGHT,
|
||||||
|
gap: 4,
|
||||||
|
padding: 4,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ListBox
|
||||||
|
ref={listRef}
|
||||||
|
aria-label={t('messagesLabel', 'Chat messages')}
|
||||||
|
items={items}
|
||||||
|
selectionMode="none"
|
||||||
|
style={{
|
||||||
|
display: 'block',
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
overflow: 'auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(item: ChatRow) => (
|
||||||
|
<ListBoxItem style={{ width: '100%' }}>
|
||||||
|
<ChatMessage item={item} />
|
||||||
|
</ListBoxItem>
|
||||||
|
)}
|
||||||
|
</ListBox>
|
||||||
|
</Virtualizer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// features/rooms/chat/ChatProvider.tsx — renders no DOM, mounted once at room level
|
||||||
|
import { ref } from 'valtio'
|
||||||
|
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||||
|
import React, { useEffect } from 'react'
|
||||||
|
import { useChat, useRoomContext } from '@livekit/components-react'
|
||||||
|
import { appendRow, chatStore, resetChatStore } from '@/stores/chat'
|
||||||
|
import type { ChatMessage } from '@livekit/components-core'
|
||||||
|
import {
|
||||||
|
LocalParticipant,
|
||||||
|
Participant,
|
||||||
|
RemoteParticipant,
|
||||||
|
RoomEvent,
|
||||||
|
} from 'livekit-client'
|
||||||
|
|
||||||
|
export const ChatProvider = () => {
|
||||||
|
const lastReadMsgAt = React.useRef<ChatMessage['timestamp']>(0)
|
||||||
|
const { send, chatMessages, isSending } = useChat()
|
||||||
|
const { isChatOpen } = useSidePanel()
|
||||||
|
|
||||||
|
const room = useRoomContext()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
resetChatStore()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Tigger the message notification (temporary)
|
||||||
|
useEffect(() => {
|
||||||
|
// TEMPORARY: This is a brittle workaround that relies on message count tracking
|
||||||
|
// due to recent LiveKit useChat changes breaking the previous implementation
|
||||||
|
// (see https://github.com/livekit/components-js/issues/1158)
|
||||||
|
// Remove this once we refactor chat to use the new text stream approach
|
||||||
|
const latestMessage = chatMessages.slice(-1)[0]
|
||||||
|
if (!latestMessage) return
|
||||||
|
const from = latestMessage.from as
|
||||||
|
| RemoteParticipant
|
||||||
|
| LocalParticipant
|
||||||
|
| undefined
|
||||||
|
|
||||||
|
room.emit(RoomEvent.ChatMessage, latestMessage, from)
|
||||||
|
}, [chatMessages, room])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
for (let i = chatStore.rows.length; i < chatMessages.length; i++) {
|
||||||
|
appendRow(chatMessages[i])
|
||||||
|
}
|
||||||
|
}, [chatMessages])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
chatStore.send = ref(send)
|
||||||
|
}, [send])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
chatStore.isSending = isSending
|
||||||
|
}, [isSending])
|
||||||
|
|
||||||
|
// Set the unread messages count
|
||||||
|
useEffect(() => {
|
||||||
|
if (chatMessages.length === 0) return
|
||||||
|
const last = chatMessages[chatMessages.length - 1]
|
||||||
|
if (isChatOpen) {
|
||||||
|
lastReadMsgAt.current = last.timestamp
|
||||||
|
chatStore.unreadMessages = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chatStore.unreadMessages = chatMessages.filter(
|
||||||
|
(m) => !lastReadMsgAt.current || m.timestamp > lastReadMsgAt.current
|
||||||
|
).length
|
||||||
|
}, [chatMessages, isChatOpen])
|
||||||
|
|
||||||
|
// Listen to participant name changes
|
||||||
|
useEffect(() => {
|
||||||
|
const setName = (p: Participant) => {
|
||||||
|
chatStore.names[p.identity] = p.name || p.identity
|
||||||
|
}
|
||||||
|
const onNameChanged = (_name: string, p: Participant) => setName(p)
|
||||||
|
room.on(RoomEvent.ParticipantNameChanged, onNameChanged)
|
||||||
|
return () => {
|
||||||
|
room.off(RoomEvent.ParticipantNameChanged, onNameChanged)
|
||||||
|
}
|
||||||
|
}, [room])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Button } from '@/primitives'
|
||||||
|
import { RiSendPlane2Fill } from '@remixicon/react'
|
||||||
|
|
||||||
|
type ChatSubmitButtonProps = {
|
||||||
|
handleSubmit: () => Promise<void>
|
||||||
|
isDisabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChatSubmitButton = React.memo(
|
||||||
|
({ handleSubmit, isDisabled }: ChatSubmitButtonProps) => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' })
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
square
|
||||||
|
invisible
|
||||||
|
variant="tertiaryText"
|
||||||
|
size="sm"
|
||||||
|
onPress={handleSubmit}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
aria-label={t('button.label')}
|
||||||
|
>
|
||||||
|
<RiSendPlane2Fill />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ChatSubmitButton.displayName = 'ChatSubmitButton'
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { TextArea } from '@/primitives'
|
||||||
|
import { styled } from '@/styled-system/jsx'
|
||||||
|
import React, { useCallback, useEffect } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import {
|
||||||
|
chatStore,
|
||||||
|
clearTextAreaValue,
|
||||||
|
persistTextAreaValue,
|
||||||
|
} from '@/stores/chat'
|
||||||
|
import { ChatSubmitButton } from './ChatSubmitButton'
|
||||||
|
|
||||||
|
const StyledContainer = styled('div', {
|
||||||
|
base: {
|
||||||
|
display: 'flex',
|
||||||
|
margin: '0.75rem 0 1.5rem',
|
||||||
|
padding: '0.5rem',
|
||||||
|
backgroundColor: 'gray.100',
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const ChatTextArea = () => {
|
||||||
|
const { isSending, send, textAreaValue } = useSnapshot(chatStore)
|
||||||
|
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'controls.chat.input' })
|
||||||
|
|
||||||
|
const inputRef = React.useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = inputRef.current
|
||||||
|
if (!el) return
|
||||||
|
const raf = requestAnimationFrame(() => {
|
||||||
|
el.focus({ preventScroll: true })
|
||||||
|
const end = el.value.length
|
||||||
|
el.setSelectionRange(end, end)
|
||||||
|
})
|
||||||
|
return () => cancelAnimationFrame(raf)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(async () => {
|
||||||
|
const text = chatStore.textAreaValue
|
||||||
|
if (!send || !text) return
|
||||||
|
await send(text)
|
||||||
|
inputRef?.current?.focus({ preventScroll: true })
|
||||||
|
clearTextAreaValue()
|
||||||
|
}, [send, inputRef])
|
||||||
|
|
||||||
|
const isDisabled = !textAreaValue.trim() || isSending
|
||||||
|
|
||||||
|
const onKeyDown = async (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (e.key !== 'Enter' || (e.key === 'Enter' && e.shiftKey) || isDisabled)
|
||||||
|
return
|
||||||
|
e.preventDefault()
|
||||||
|
await handleSubmit()
|
||||||
|
}
|
||||||
|
|
||||||
|
const onKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledContainer>
|
||||||
|
<TextArea
|
||||||
|
ref={inputRef}
|
||||||
|
value={textAreaValue}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
onKeyUp={onKeyUp}
|
||||||
|
onChange={(e) => {
|
||||||
|
persistTextAreaValue(e.target.value)
|
||||||
|
}}
|
||||||
|
fieldSizing={'content'}
|
||||||
|
style={{
|
||||||
|
border: 'none',
|
||||||
|
resize: 'none',
|
||||||
|
height: 'auto',
|
||||||
|
maxHeight: '240px',
|
||||||
|
minHeight: `34px`,
|
||||||
|
lineHeight: 1.25,
|
||||||
|
padding: '7px 10px',
|
||||||
|
}}
|
||||||
|
placeholderStyle="strong"
|
||||||
|
spellCheck={false}
|
||||||
|
maxLength={2000}
|
||||||
|
placeholder={t('textArea.placeholder')}
|
||||||
|
aria-label={t('textArea.label')}
|
||||||
|
/>
|
||||||
|
<ChatSubmitButton handleSubmit={handleSubmit} isDisabled={isDisabled} />
|
||||||
|
</StyledContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
|
|
||||||
|
export type ConnectionTestTokenResponse = {
|
||||||
|
livekit: {
|
||||||
|
url: string
|
||||||
|
room: string
|
||||||
|
token: string
|
||||||
|
expires_in: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchConnectionTestToken = () =>
|
||||||
|
fetchApi<ConnectionTestTokenResponse>('/connection-test/')
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { useRef, useState } from 'react'
|
||||||
|
import {
|
||||||
|
CheckStatus,
|
||||||
|
ConnectionCheck,
|
||||||
|
createLocalAudioTrack,
|
||||||
|
createLocalVideoTrack,
|
||||||
|
getBrowser,
|
||||||
|
type CheckInfo,
|
||||||
|
type LocalVideoTrack,
|
||||||
|
} from 'livekit-client'
|
||||||
|
import { fetchConnectionTestToken } from '../api/fetchConnectionTestToken'
|
||||||
|
import {
|
||||||
|
createInitialSteps,
|
||||||
|
type ConnectionTestLog,
|
||||||
|
type ConnectionTestStepId,
|
||||||
|
type ConnectionTestStepResult,
|
||||||
|
type ConnectionTestStepStatus,
|
||||||
|
} from '../types'
|
||||||
|
import { openPermissionsDialog } from '@/stores/permissions'
|
||||||
|
|
||||||
|
const LIVEKIT_STEP_IDS: ConnectionTestStepId[] = [
|
||||||
|
'websocket',
|
||||||
|
'webrtc',
|
||||||
|
'turn',
|
||||||
|
'reconnect',
|
||||||
|
'publishAudio',
|
||||||
|
'publishVideo',
|
||||||
|
]
|
||||||
|
|
||||||
|
const CHECK_STATUS_TO_STEP: Record<CheckStatus, ConnectionTestStepStatus> = {
|
||||||
|
[CheckStatus.IDLE]: 'pending',
|
||||||
|
[CheckStatus.RUNNING]: 'running',
|
||||||
|
[CheckStatus.SUCCESS]: 'success',
|
||||||
|
[CheckStatus.FAILED]: 'failed',
|
||||||
|
[CheckStatus.SKIPPED]: 'skipped',
|
||||||
|
}
|
||||||
|
|
||||||
|
const getErrorMessage = (error: unknown, fallback = 'Unknown error') =>
|
||||||
|
error instanceof Error ? error.message : fallback
|
||||||
|
|
||||||
|
const fromCheckInfo = (info: CheckInfo): Partial<ConnectionTestStepResult> => ({
|
||||||
|
status: CHECK_STATUS_TO_STEP[info.status] ?? 'failed',
|
||||||
|
summary: info.description,
|
||||||
|
logs: info.logs,
|
||||||
|
})
|
||||||
|
|
||||||
|
const groupDevicesByKind = (devices: MediaDeviceInfo[]) => {
|
||||||
|
const grouped: Record<MediaDeviceKind, string[]> = {
|
||||||
|
audioinput: [],
|
||||||
|
audiooutput: [],
|
||||||
|
videoinput: [],
|
||||||
|
}
|
||||||
|
for (const device of devices) {
|
||||||
|
grouped[device.kind].push(device.label || device.deviceId)
|
||||||
|
}
|
||||||
|
return grouped
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useConnectionTestRunner = () => {
|
||||||
|
const [steps, setSteps] = useState(createInitialSteps)
|
||||||
|
const [isRunning, setIsRunning] = useState(false)
|
||||||
|
const [videoTrack, setVideoTrack] = useState<LocalVideoTrack | null>(null)
|
||||||
|
const videoTrackRef = useRef<LocalVideoTrack | null>(null)
|
||||||
|
const abortRef = useRef<AbortController | null>(null)
|
||||||
|
|
||||||
|
const updateStep = (
|
||||||
|
id: ConnectionTestStepId,
|
||||||
|
patch: Partial<ConnectionTestStepResult>
|
||||||
|
) => {
|
||||||
|
setSteps((current) =>
|
||||||
|
current.map((step) => (step.id === id ? { ...step, ...patch } : step))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopVideoTrack = () => {
|
||||||
|
videoTrackRef.current?.stop()
|
||||||
|
videoTrackRef.current = null
|
||||||
|
setVideoTrack(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const skipSteps = (
|
||||||
|
ids: ConnectionTestStepId[],
|
||||||
|
summary: string,
|
||||||
|
logs?: ConnectionTestLog[]
|
||||||
|
) => {
|
||||||
|
for (const id of ids) {
|
||||||
|
updateStep(id, { status: 'skipped', summary, logs })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true on success, false on failure, null if aborted. */
|
||||||
|
const runStep = async (
|
||||||
|
id: ConnectionTestStepId,
|
||||||
|
signal: AbortSignal,
|
||||||
|
fn: () => Promise<Partial<ConnectionTestStepResult>>
|
||||||
|
): Promise<boolean | null> => {
|
||||||
|
if (signal.aborted) return null
|
||||||
|
|
||||||
|
updateStep(id, { status: 'running', summary: undefined, logs: undefined })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fn()
|
||||||
|
if (signal.aborted) return null
|
||||||
|
// `result.status` overrides when set (LiveKit checks map their own status)
|
||||||
|
updateStep(id, { status: 'success', ...result })
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
if (signal.aborted) return null
|
||||||
|
updateStep(id, {
|
||||||
|
status: 'failed',
|
||||||
|
summary: getErrorMessage(error),
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const runTest = async () => {
|
||||||
|
abortRef.current?.abort()
|
||||||
|
const controller = new AbortController()
|
||||||
|
abortRef.current = controller
|
||||||
|
const { signal } = controller
|
||||||
|
|
||||||
|
setIsRunning(true)
|
||||||
|
setSteps(createInitialSteps())
|
||||||
|
stopVideoTrack()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await runStep('browser', signal, async () => {
|
||||||
|
const browser = getBrowser()
|
||||||
|
if (!browser) throw new Error('Browser not detected')
|
||||||
|
return {
|
||||||
|
summary: `${browser.name} ${browser.version}`,
|
||||||
|
data: {
|
||||||
|
name: browser.name,
|
||||||
|
version: browser.version,
|
||||||
|
os: browser.os,
|
||||||
|
osVersion: browser.osVersion,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (signal.aborted) return
|
||||||
|
|
||||||
|
const microphoneOk = await runStep('microphone', signal, async () => {
|
||||||
|
const track = await createLocalAudioTrack()
|
||||||
|
const label =
|
||||||
|
track.mediaStreamTrack.label ||
|
||||||
|
track.mediaStreamTrack.getSettings().deviceId ||
|
||||||
|
''
|
||||||
|
track.stop()
|
||||||
|
return { summary: label, data: { label } }
|
||||||
|
})
|
||||||
|
if (signal.aborted) return
|
||||||
|
if (!microphoneOk) openPermissionsDialog('audioinput')
|
||||||
|
|
||||||
|
const cameraOk = await runStep('camera', signal, async () => {
|
||||||
|
const track = await createLocalVideoTrack()
|
||||||
|
videoTrackRef.current = track
|
||||||
|
setVideoTrack(track)
|
||||||
|
const settings = track.mediaStreamTrack.getSettings()
|
||||||
|
const label = track.mediaStreamTrack.label || ''
|
||||||
|
return {
|
||||||
|
summary: label,
|
||||||
|
data: {
|
||||||
|
label,
|
||||||
|
width: settings.width,
|
||||||
|
height: settings.height,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (signal.aborted) return
|
||||||
|
if (!cameraOk) openPermissionsDialog('videoinput')
|
||||||
|
|
||||||
|
await runStep('devices', signal, async () => {
|
||||||
|
const devices = await navigator.mediaDevices.enumerateDevices()
|
||||||
|
return {
|
||||||
|
summary: String(devices.length),
|
||||||
|
data: groupDevicesByKind(devices),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (signal.aborted) return
|
||||||
|
|
||||||
|
let checker: ConnectionCheck
|
||||||
|
try {
|
||||||
|
const { livekit } = await fetchConnectionTestToken()
|
||||||
|
checker = new ConnectionCheck(livekit.url, livekit.token)
|
||||||
|
} catch (error) {
|
||||||
|
skipSteps(
|
||||||
|
LIVEKIT_STEP_IDS,
|
||||||
|
getErrorMessage(error, 'Failed to fetch test token')
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await runStep('websocket', signal, async () =>
|
||||||
|
fromCheckInfo(await checker.checkWebsocket())
|
||||||
|
)
|
||||||
|
await runStep('webrtc', signal, async () =>
|
||||||
|
fromCheckInfo(await checker.checkWebRTC())
|
||||||
|
)
|
||||||
|
await runStep('turn', signal, async () =>
|
||||||
|
fromCheckInfo(await checker.checkTURN())
|
||||||
|
)
|
||||||
|
await runStep('reconnect', signal, async () =>
|
||||||
|
fromCheckInfo(await checker.checkReconnect())
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!microphoneOk) {
|
||||||
|
skipSteps(['publishAudio'], 'Microphone permission required')
|
||||||
|
} else {
|
||||||
|
await runStep('publishAudio', signal, async () =>
|
||||||
|
fromCheckInfo(await checker.checkPublishAudio())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cameraOk) {
|
||||||
|
skipSteps(['publishVideo'], 'Camera permission required')
|
||||||
|
} else {
|
||||||
|
stopVideoTrack()
|
||||||
|
await runStep('publishVideo', signal, async () =>
|
||||||
|
fromCheckInfo(await checker.checkPublishVideo())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!signal.aborted) {
|
||||||
|
stopVideoTrack()
|
||||||
|
setIsRunning(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
abortRef.current?.abort()
|
||||||
|
stopVideoTrack()
|
||||||
|
setSteps(createInitialSteps())
|
||||||
|
setIsRunning(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
steps,
|
||||||
|
isRunning,
|
||||||
|
videoTrack,
|
||||||
|
runTest,
|
||||||
|
reset,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { CenteredContent } from '@/layout/CenteredContent'
|
||||||
|
import { Screen } from '@/layout/Screen'
|
||||||
|
import { Box, Button, Text, Ul } from '@/primitives'
|
||||||
|
import { Spinner } from '@/primitives/Spinner'
|
||||||
|
import { Center, HStack, VStack } from '@/styled-system/jsx'
|
||||||
|
import { Permissions } from '@/features/rooms/components/Permissions'
|
||||||
|
import { useConnectionTestRunner } from '../hooks/useConnectionTestRunner'
|
||||||
|
import type { ConnectionTestStepId, ConnectionTestStepResult } from '../types'
|
||||||
|
import { downloadConnectionTestReport } from '../utils/downloadConnectionTestReport'
|
||||||
|
|
||||||
|
const HIDE_LIVEKIT_VIDEO_CLASS = 'connection-test-hide-livekit-video'
|
||||||
|
|
||||||
|
const TestStepItem = ({ step }: { step: ConnectionTestStepResult }) => {
|
||||||
|
const { t } = useTranslation('connectionTest')
|
||||||
|
const [showDetails, setShowDetails] = useState(false)
|
||||||
|
const hasLogs = Boolean(step.logs?.length)
|
||||||
|
const statusLabel = t(`status.${step.status}`)
|
||||||
|
const stepLabel = t(`steps.${step.id as ConnectionTestStepId}`)
|
||||||
|
|
||||||
|
const statusVariant =
|
||||||
|
step.status === 'failed'
|
||||||
|
? 'warning'
|
||||||
|
: step.status === 'success'
|
||||||
|
? 'body'
|
||||||
|
: 'smNote'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VStack gap="0.25rem" alignItems="stretch" width="100%">
|
||||||
|
<HStack
|
||||||
|
justifyContent="space-between"
|
||||||
|
alignItems="flex-start"
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
<Text variant="bodyXsMedium">{stepLabel}</Text>
|
||||||
|
{step.status === 'running' ? (
|
||||||
|
<Spinner size={20} />
|
||||||
|
) : (
|
||||||
|
<Text variant={statusVariant}>{statusLabel}</Text>
|
||||||
|
)}
|
||||||
|
</HStack>
|
||||||
|
{step.summary && (
|
||||||
|
<Text variant="smNote" margin={false}>
|
||||||
|
{step.summary}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{hasLogs && step.status !== 'pending' && step.status !== 'running' && (
|
||||||
|
<Button
|
||||||
|
variant="secondaryText"
|
||||||
|
size="sm"
|
||||||
|
onPress={() => setShowDetails((open) => !open)}
|
||||||
|
>
|
||||||
|
{t('details')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{showDetails && step.logs && (
|
||||||
|
<Ul>
|
||||||
|
{step.logs.map((log, index) => (
|
||||||
|
<li key={index}>
|
||||||
|
<Text variant="xsNote" as="span">
|
||||||
|
{log.message}
|
||||||
|
</Text>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</Ul>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const ConnectionTest = () => {
|
||||||
|
const { t } = useTranslation('connectionTest')
|
||||||
|
const { steps, isRunning, videoTrack, runTest } = useConnectionTestRunner()
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
|
|
||||||
|
const hasStarted = steps.some((step) => step.status !== 'pending')
|
||||||
|
const hasFailed = steps.some((step) => step.status === 'failed')
|
||||||
|
const isPublishVideoRunning = steps.some(
|
||||||
|
(step) => step.id === 'publishVideo' && step.status === 'running'
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const element = videoRef.current
|
||||||
|
if (!element || !videoTrack) return
|
||||||
|
|
||||||
|
videoTrack.attach(element)
|
||||||
|
return () => {
|
||||||
|
videoTrack.detach(element)
|
||||||
|
}
|
||||||
|
}, [videoTrack])
|
||||||
|
|
||||||
|
// LiveKit appends a bare <video> to document.body during publishVideo.
|
||||||
|
// Keep it in the DOM (so the frame check still works) but hide it visually.
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.classList.toggle(
|
||||||
|
HIDE_LIVEKIT_VIDEO_CLASS,
|
||||||
|
isPublishVideoRunning
|
||||||
|
)
|
||||||
|
return () => {
|
||||||
|
document.body.classList.remove(HIDE_LIVEKIT_VIDEO_CLASS)
|
||||||
|
}
|
||||||
|
}, [isPublishVideoRunning])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Screen layout="centered">
|
||||||
|
<Permissions />
|
||||||
|
<CenteredContent title={t('title')} withBackButton>
|
||||||
|
<Center>
|
||||||
|
<VStack gap="1.5rem" maxWidth="36rem" width="100%">
|
||||||
|
<Text as="p" variant="paragraph" centered last>
|
||||||
|
{t('intro')}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Button variant="primary" onPress={runTest} isDisabled={isRunning}>
|
||||||
|
{hasStarted ? t('reset') : t('runTest')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{videoTrack && (
|
||||||
|
<Center>
|
||||||
|
<video ref={videoRef} autoPlay playsInline muted />
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasStarted && (
|
||||||
|
<Box variant="light" width="100%">
|
||||||
|
<VStack gap="1rem" alignItems="stretch">
|
||||||
|
{steps.map((step) => (
|
||||||
|
<TestStepItem key={step.id} step={step} />
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasFailed && !isRunning && (
|
||||||
|
<Text variant="smNote" centered>
|
||||||
|
{t('help.firewall')}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasStarted && !isRunning && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onPress={() => downloadConnectionTestReport(steps)}
|
||||||
|
>
|
||||||
|
{t('downloadReport')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</Center>
|
||||||
|
</CenteredContent>
|
||||||
|
</Screen>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ConnectionTest
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
export type ConnectionTestStepId =
|
||||||
|
| 'browser'
|
||||||
|
| 'microphone'
|
||||||
|
| 'camera'
|
||||||
|
| 'devices'
|
||||||
|
| 'websocket'
|
||||||
|
| 'webrtc'
|
||||||
|
| 'turn'
|
||||||
|
| 'reconnect'
|
||||||
|
| 'publishAudio'
|
||||||
|
| 'publishVideo'
|
||||||
|
|
||||||
|
export type ConnectionTestStepStatus =
|
||||||
|
| 'pending'
|
||||||
|
| 'running'
|
||||||
|
| 'success'
|
||||||
|
| 'failed'
|
||||||
|
| 'skipped'
|
||||||
|
|
||||||
|
export type ConnectionTestLog = {
|
||||||
|
level: 'info' | 'warning' | 'error'
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConnectionTestStepResult = {
|
||||||
|
id: ConnectionTestStepId
|
||||||
|
status: ConnectionTestStepStatus
|
||||||
|
summary?: string
|
||||||
|
logs?: ConnectionTestLog[]
|
||||||
|
data?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CONNECTION_TEST_STEP_IDS: ConnectionTestStepId[] = [
|
||||||
|
'browser',
|
||||||
|
'microphone',
|
||||||
|
'camera',
|
||||||
|
'devices',
|
||||||
|
'websocket',
|
||||||
|
'webrtc',
|
||||||
|
'turn',
|
||||||
|
'reconnect',
|
||||||
|
'publishAudio',
|
||||||
|
'publishVideo',
|
||||||
|
]
|
||||||
|
|
||||||
|
export const createInitialSteps = (): ConnectionTestStepResult[] =>
|
||||||
|
CONNECTION_TEST_STEP_IDS.map((id) => ({ id, status: 'pending' }))
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import type { ConnectionTestStepResult } from '../types'
|
||||||
|
|
||||||
|
export type ConnectionTestReport = {
|
||||||
|
generatedAt: string
|
||||||
|
userAgent: string
|
||||||
|
steps: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
status: ConnectionTestStepResult['status']
|
||||||
|
summary?: string
|
||||||
|
logs?: ConnectionTestStepResult['logs']
|
||||||
|
data?: ConnectionTestStepResult['data']
|
||||||
|
}
|
||||||
|
>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildConnectionTestReport = (
|
||||||
|
steps: ConnectionTestStepResult[]
|
||||||
|
): ConnectionTestReport => ({
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
steps: Object.fromEntries(
|
||||||
|
steps.map(({ id, status, summary, logs, data }) => [
|
||||||
|
id,
|
||||||
|
{
|
||||||
|
status,
|
||||||
|
...(summary !== undefined ? { summary } : {}),
|
||||||
|
...(logs?.length ? { logs } : {}),
|
||||||
|
...(data !== undefined ? { data } : {}),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const downloadConnectionTestReport = (
|
||||||
|
steps: ConnectionTestStepResult[]
|
||||||
|
) => {
|
||||||
|
const report = buildConnectionTestReport(steps)
|
||||||
|
const timestamp = report.generatedAt.slice(0, 19).replace(/:/g, '-')
|
||||||
|
const blob = new Blob([JSON.stringify(report, null, 2)], {
|
||||||
|
type: 'application/json',
|
||||||
|
})
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const anchor = document.createElement('a')
|
||||||
|
anchor.href = url
|
||||||
|
anchor.download = `connection-test-${timestamp}.json`
|
||||||
|
anchor.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
@@ -9,10 +9,11 @@ import { useState } from 'react'
|
|||||||
|
|
||||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||||
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
import { ApiRoom } from '@/features/rooms/api/ApiRoom'
|
||||||
import { loadUserChoices } from '@livekit/components-core'
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { userStore } from '@/stores/user'
|
||||||
|
|
||||||
export const CreateMeetingMenu = () => {
|
export const CreateMeetingMenu = () => {
|
||||||
const { username } = loadUserChoices()
|
const { username } = useSnapshot(userStore)
|
||||||
|
|
||||||
const { t } = useTranslation('home')
|
const { t } = useTranslation('home')
|
||||||
const { mutateAsync: createRoom } = useCreateRoom()
|
const { mutateAsync: createRoom } = useCreateRoom()
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { getScrollBarWidth } from '@livekit/components-core'
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
|
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
|
||||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||||
|
import { useCallback, useEffect, useLayoutEffect } from 'react'
|
||||||
|
|
||||||
const MIN_HEIGHT = 130
|
const MIN_HEIGHT = 130
|
||||||
const MIN_WIDTH = 140
|
const MIN_WIDTH = 140
|
||||||
@@ -10,6 +11,72 @@ const MIN_VISIBLE_TILES = 1
|
|||||||
const ASPECT_RATIO = 16 / 10
|
const ASPECT_RATIO = 16 / 10
|
||||||
const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1
|
const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1
|
||||||
|
|
||||||
|
type CarouselOrientation = 'vertical' | 'horizontal'
|
||||||
|
|
||||||
|
interface CarouselLayoutState {
|
||||||
|
orientation: CarouselOrientation
|
||||||
|
maxVisibleTiles: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CarouselLayoutObserverProps {
|
||||||
|
asideEl: React.RefObject<HTMLDivElement>
|
||||||
|
orientation?: CarouselOrientation
|
||||||
|
onLayoutChange: (layout: CarouselLayoutState) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CarouselLayoutObserver = ({
|
||||||
|
orientation,
|
||||||
|
asideEl,
|
||||||
|
onLayoutChange,
|
||||||
|
}: CarouselLayoutObserverProps) => {
|
||||||
|
const { width, height } = useSize(asideEl)
|
||||||
|
|
||||||
|
// Hysteresis memory: avoids flapping between N and N+1 tiles when the
|
||||||
|
// container size hovers around a breakpoint. A ref (not state) because
|
||||||
|
// updating it must not trigger a re-render.
|
||||||
|
const prevTilesRef = React.useRef(0)
|
||||||
|
|
||||||
|
const carouselOrientation: CarouselOrientation =
|
||||||
|
orientation ?? (height >= width ? 'vertical' : 'horizontal')
|
||||||
|
|
||||||
|
const tileSpan =
|
||||||
|
carouselOrientation === 'vertical'
|
||||||
|
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
|
||||||
|
: Math.max(height * ASPECT_RATIO, MIN_WIDTH)
|
||||||
|
|
||||||
|
const scrollBarWidth = getScrollBarWidth()
|
||||||
|
|
||||||
|
const availableSpan =
|
||||||
|
(carouselOrientation === 'vertical' ? height : width) - scrollBarWidth
|
||||||
|
|
||||||
|
const tilesThatFit = Math.max(availableSpan / tileSpan, MIN_VISIBLE_TILES)
|
||||||
|
|
||||||
|
let maxVisibleTiles: number
|
||||||
|
if (Math.abs(tilesThatFit - prevTilesRef.current) < 0.5) {
|
||||||
|
// Within the dead zone: keep the previous count.
|
||||||
|
maxVisibleTiles = Math.round(prevTilesRef.current)
|
||||||
|
} else {
|
||||||
|
maxVisibleTiles = Math.round(tilesThatFit)
|
||||||
|
prevTilesRef.current = tilesThatFit
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply cosmetic layout output straight to the DOM.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = asideEl.current
|
||||||
|
if (!el) return
|
||||||
|
el.dataset.lkOrientation = carouselOrientation
|
||||||
|
el.style.setProperty('--lk-max-visible-tiles', maxVisibleTiles.toString())
|
||||||
|
}, [asideEl, carouselOrientation, maxVisibleTiles])
|
||||||
|
|
||||||
|
// Report upward only what the parent actually needs for
|
||||||
|
// `useVisualStableUpdate` (and only when it changes — see parent handler).
|
||||||
|
useEffect(() => {
|
||||||
|
onLayoutChange({ orientation: carouselOrientation, maxVisibleTiles })
|
||||||
|
}, [carouselOrientation, maxVisibleTiles, onLayoutChange])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export interface CarouselLayoutProps extends React.HTMLAttributes<HTMLMediaElement> {
|
export interface CarouselLayoutProps extends React.HTMLAttributes<HTMLMediaElement> {
|
||||||
tracks: TrackReferenceOrPlaceholder[]
|
tracks: TrackReferenceOrPlaceholder[]
|
||||||
@@ -40,52 +107,42 @@ export function CarouselLayout({
|
|||||||
...props
|
...props
|
||||||
}: CarouselLayoutProps) {
|
}: CarouselLayoutProps) {
|
||||||
const asideEl = React.useRef<HTMLDivElement>(null)
|
const asideEl = React.useRef<HTMLDivElement>(null)
|
||||||
const [prevTiles, setPrevTiles] = React.useState(0)
|
|
||||||
const { width, height } = useSize(asideEl)
|
|
||||||
const carouselOrientation = orientation
|
|
||||||
? orientation
|
|
||||||
: height >= width
|
|
||||||
? 'vertical'
|
|
||||||
: 'horizontal'
|
|
||||||
|
|
||||||
const tileSpan =
|
const [layout, setLayout] = React.useState<CarouselLayoutState>({
|
||||||
carouselOrientation === 'vertical'
|
orientation: orientation ?? 'vertical',
|
||||||
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
|
maxVisibleTiles: MIN_VISIBLE_TILES,
|
||||||
: Math.max(height * ASPECT_RATIO, MIN_WIDTH)
|
})
|
||||||
const scrollBarWidth = getScrollBarWidth()
|
|
||||||
|
|
||||||
const tilesThatFit =
|
// Stable callback + identity check: the parent only re-renders when the
|
||||||
carouselOrientation === 'vertical'
|
// derived layout genuinely changed, not on every resize tick.
|
||||||
? Math.max((height - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
|
const handleLayoutChange = useCallback((next: CarouselLayoutState) => {
|
||||||
: Math.max((width - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
|
setLayout((prev) =>
|
||||||
|
prev.orientation === next.orientation &&
|
||||||
let maxVisibleTiles = Math.round(tilesThatFit)
|
prev.maxVisibleTiles === next.maxVisibleTiles
|
||||||
if (Math.abs(tilesThatFit - prevTiles) < 0.5) {
|
? prev
|
||||||
maxVisibleTiles = Math.round(prevTiles)
|
: next
|
||||||
} else if (prevTiles !== tilesThatFit) {
|
|
||||||
setPrevTiles(tilesThatFit)
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortedTiles = useVisualStableUpdate(tracks, maxVisibleTiles)
|
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
|
||||||
if (asideEl.current) {
|
|
||||||
asideEl.current.dataset.lkOrientation = carouselOrientation
|
|
||||||
asideEl.current.style.setProperty(
|
|
||||||
'--lk-max-visible-tiles',
|
|
||||||
maxVisibleTiles.toString()
|
|
||||||
)
|
)
|
||||||
}
|
}, [])
|
||||||
}, [maxVisibleTiles, carouselOrientation])
|
|
||||||
|
const sortedTiles = useVisualStableUpdate(tracks, layout.maxVisibleTiles)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<CarouselLayoutObserver
|
||||||
|
asideEl={asideEl}
|
||||||
|
orientation={orientation}
|
||||||
|
onLayoutChange={handleLayoutChange}
|
||||||
|
/>
|
||||||
|
{/* `key` intentionally remounts the container when orientation flips, */}
|
||||||
|
{/* which resets scroll position and re-runs the observer measurement. */}
|
||||||
<aside
|
<aside
|
||||||
key={carouselOrientation}
|
key={layout.orientation}
|
||||||
className="lk-carousel"
|
className="lk-carousel"
|
||||||
ref={asideEl}
|
ref={asideEl}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
|
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
|
||||||
</aside>
|
</aside>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import { ParticipantTile } from './ParticipantTile'
|
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile.tsx'
|
||||||
import type { FocusLayoutProps } from '@livekit/components-react'
|
import type { FocusLayoutProps } from '@livekit/components-react'
|
||||||
|
|
||||||
export function FocusLayout({ trackRef, ...htmlProps }: FocusLayoutProps) {
|
export function FocusLayout({ trackRef, ...htmlProps }: FocusLayoutProps) {
|
||||||
@@ -10,6 +10,36 @@ import { mergeProps } from '@/utils/mergeProps'
|
|||||||
import { PaginationIndicator } from './PaginationIndicator'
|
import { PaginationIndicator } from './PaginationIndicator'
|
||||||
import { useGridLayout } from '../hooks/useGridLayout'
|
import { useGridLayout } from '../hooks/useGridLayout'
|
||||||
import { PaginationControl } from './PaginationControl'
|
import { PaginationControl } from './PaginationControl'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useSpeakerPromotionTrigger } from '../hooks/useSpeakerPromotionTrigger'
|
||||||
|
|
||||||
|
interface GridLayoutObserverProps {
|
||||||
|
gridEl: React.RefObject<HTMLDivElement>
|
||||||
|
trackCount: number
|
||||||
|
onMaxTilesChange: (maxTiles: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Headless component that runs the layout calculation in isolation and
|
||||||
|
* reports the resulting tile capacity upward.
|
||||||
|
*
|
||||||
|
* `useGridLayout` re-renders its host on every layout recalculation
|
||||||
|
* (e.g. container resizes). Rendering it in a null child means only this
|
||||||
|
* component churns; the parent `GridLayout` re-renders solely when
|
||||||
|
* `maxTiles` actually changes, since `setState` bails out on equal values.
|
||||||
|
*/
|
||||||
|
const GridLayoutObserver = ({
|
||||||
|
gridEl,
|
||||||
|
trackCount,
|
||||||
|
onMaxTilesChange,
|
||||||
|
}: GridLayoutObserverProps) => {
|
||||||
|
const { layout } = useGridLayout(gridEl, trackCount)
|
||||||
|
useEffect(() => {
|
||||||
|
onMaxTilesChange(layout.maxTiles)
|
||||||
|
}, [onMaxTilesChange, layout.maxTiles])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export interface GridLayoutProps
|
export interface GridLayoutProps
|
||||||
@@ -37,14 +67,15 @@ export interface GridLayoutProps
|
|||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
||||||
const gridEl = React.createRef<HTMLDivElement>()
|
const gridEl = useRef<HTMLDivElement>(null)
|
||||||
|
const [maxTiles, setMaxTiles] = useState(1)
|
||||||
|
|
||||||
const elementProps = React.useMemo(
|
const elementProps = React.useMemo(
|
||||||
() => mergeProps(props, { className: 'lk-grid-layout' }),
|
() => mergeProps(props, { className: 'lk-grid-layout' }),
|
||||||
[props]
|
[props]
|
||||||
)
|
)
|
||||||
const { layout } = useGridLayout(gridEl, tracks.length)
|
const pagination = usePagination(maxTiles, tracks)
|
||||||
const pagination = usePagination(layout.maxTiles, tracks)
|
useSpeakerPromotionTrigger(pagination.tracks)
|
||||||
|
|
||||||
useSwipe(gridEl, {
|
useSwipe(gridEl, {
|
||||||
onLeftSwipe: pagination.nextPage,
|
onLeftSwipe: pagination.nextPage,
|
||||||
@@ -57,8 +88,13 @@ export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
|||||||
data-lk-pagination={pagination.totalPageCount > 1}
|
data-lk-pagination={pagination.totalPageCount > 1}
|
||||||
{...elementProps}
|
{...elementProps}
|
||||||
>
|
>
|
||||||
|
<GridLayoutObserver
|
||||||
|
gridEl={gridEl}
|
||||||
|
trackCount={tracks.length}
|
||||||
|
onMaxTilesChange={setMaxTiles}
|
||||||
|
/>
|
||||||
<TrackLoop tracks={pagination.tracks}>{props.children}</TrackLoop>
|
<TrackLoop tracks={pagination.tracks}>{props.children}</TrackLoop>
|
||||||
{tracks.length > layout.maxTiles && (
|
{tracks.length > maxTiles && (
|
||||||
<>
|
<>
|
||||||
<PaginationIndicator
|
<PaginationIndicator
|
||||||
totalPageCount={pagination.totalPageCount}
|
totalPageCount={pagination.totalPageCount}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { usePinAnnouncements } from '../hooks/usePinAnnouncements'
|
||||||
|
|
||||||
|
export const PinAnnouncer = () => {
|
||||||
|
usePinAnnouncements()
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import { FocusLayoutContainer, useTracks } from '@livekit/components-react'
|
||||||
|
import { CarouselLayout } from '@/features/layout/components/CarouselLayout'
|
||||||
|
import { FocusLayout } from '@/features/layout/components/FocusLayout'
|
||||||
|
import { ParticipantTile } from '@/features/participantTile/components/ParticipantTile'
|
||||||
|
import { GridLayout } from '@/features/layout/components/GridLayout'
|
||||||
|
import {
|
||||||
|
isEqualTrackRef,
|
||||||
|
isTrackReference,
|
||||||
|
log,
|
||||||
|
type TrackReferenceOrPlaceholder,
|
||||||
|
} from '@livekit/components-core'
|
||||||
|
import { Track } from 'livekit-client'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
|
||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
export const StageLayout = () => {
|
||||||
|
const lastAutoFocusedScreenShareTrack =
|
||||||
|
useRef<TrackReferenceOrPlaceholder | null>(null)
|
||||||
|
|
||||||
|
const tracks = useTracks(
|
||||||
|
[
|
||||||
|
{ source: Track.Source.Camera, withPlaceholder: true },
|
||||||
|
{ source: Track.Source.ScreenShare, withPlaceholder: false },
|
||||||
|
],
|
||||||
|
{ updateOnlyOn: [], onlySubscribed: false }
|
||||||
|
)
|
||||||
|
|
||||||
|
const screenShareTracks = tracks
|
||||||
|
.filter(isTrackReference)
|
||||||
|
.filter((track) => track.publication.source === Track.Source.ScreenShare)
|
||||||
|
|
||||||
|
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
||||||
|
|
||||||
|
const carouselTracks = tracks.filter(
|
||||||
|
(track) => !isEqualTrackRef(track, pinnedTrackRef)
|
||||||
|
)
|
||||||
|
|
||||||
|
/* eslint-disable react-hooks/exhaustive-deps */
|
||||||
|
// Code duplicated from LiveKit; this warning will be addressed in the refactoring.
|
||||||
|
useEffect(() => {
|
||||||
|
// If screen share tracks are published, and no pin is set explicitly, auto set the screen share.
|
||||||
|
if (
|
||||||
|
screenShareTracks.some((track) => track.publication.isSubscribed) &&
|
||||||
|
lastAutoFocusedScreenShareTrack.current === null
|
||||||
|
) {
|
||||||
|
log.debug('Auto set screen share focus:', {
|
||||||
|
newScreenShareTrack: screenShareTracks[0],
|
||||||
|
})
|
||||||
|
setPinnedTrack(screenShareTracks[0])
|
||||||
|
lastAutoFocusedScreenShareTrack.current = screenShareTracks[0]
|
||||||
|
} else if (
|
||||||
|
lastAutoFocusedScreenShareTrack.current &&
|
||||||
|
!screenShareTracks.some(
|
||||||
|
(track) =>
|
||||||
|
track.publication.trackSid ===
|
||||||
|
lastAutoFocusedScreenShareTrack.current?.publication?.trackSid
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
log.debug('Auto clearing screen share focus.')
|
||||||
|
clearPinnedTrack()
|
||||||
|
lastAutoFocusedScreenShareTrack.current = null
|
||||||
|
}
|
||||||
|
if (pinnedTrackRef && !isTrackReference(pinnedTrackRef)) {
|
||||||
|
const updatedFocusTrack = tracks.find(
|
||||||
|
(tr) =>
|
||||||
|
tr.participant.identity === pinnedTrackRef.participant.identity &&
|
||||||
|
tr.source === pinnedTrackRef.source
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
updatedFocusTrack !== pinnedTrackRef &&
|
||||||
|
isTrackReference(updatedFocusTrack)
|
||||||
|
) {
|
||||||
|
setPinnedTrack(updatedFocusTrack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [
|
||||||
|
screenShareTracks
|
||||||
|
.map(
|
||||||
|
(ref) => `${ref.publication.trackSid}_${ref.publication.isSubscribed}`
|
||||||
|
)
|
||||||
|
.join(),
|
||||||
|
pinnedTrackRef?.publication?.trackSid,
|
||||||
|
tracks,
|
||||||
|
])
|
||||||
|
/* eslint-enable react-hooks/exhaustive-deps */
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!pinnedTrackRef ? (
|
||||||
|
<div className="lk-grid-layout-wrapper" style={{ height: 'auto' }}>
|
||||||
|
<GridLayout tracks={tracks} style={{ padding: 0 }}>
|
||||||
|
<ParticipantTile />
|
||||||
|
</GridLayout>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="lk-focus-layout-wrapper" style={{ height: 'auto' }}>
|
||||||
|
<FocusLayoutContainer style={{ padding: 0 }}>
|
||||||
|
<CarouselLayout
|
||||||
|
tracks={carouselTracks}
|
||||||
|
style={{
|
||||||
|
minWidth: '200px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ParticipantTile />
|
||||||
|
</CarouselLayout>
|
||||||
|
<FocusLayout trackRef={pinnedTrackRef} />
|
||||||
|
</FocusLayoutContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from 'react'
|
||||||
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
|
import { Participant } from 'livekit-client'
|
||||||
|
import { useSnapshot } from 'valtio'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { layoutStore } from '@/stores/layout'
|
||||||
|
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
||||||
|
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||||
|
|
||||||
|
export const usePinAnnouncements = () => {
|
||||||
|
const { t } = useTranslation('rooms', { keyPrefix: 'pinAnnouncements' })
|
||||||
|
const { t: tRooms } = useTranslation('rooms')
|
||||||
|
|
||||||
|
const room = useRoomContext()
|
||||||
|
const announce = useScreenReaderAnnounce()
|
||||||
|
|
||||||
|
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
||||||
|
const lastPinnedIdentityRef = useRef<string | null>(null)
|
||||||
|
|
||||||
|
const getAnnouncementName = useCallback(
|
||||||
|
(participant?: Participant | null) => {
|
||||||
|
if (!participant) return tRooms('participants.unknown')
|
||||||
|
return participant.isLocal
|
||||||
|
? tRooms('participants.you')
|
||||||
|
: getParticipantName(participant)
|
||||||
|
},
|
||||||
|
[tRooms]
|
||||||
|
)
|
||||||
|
|
||||||
|
const pinnedIdentity = pinnedTrackRef?.participant?.identity ?? null
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 1. unpin
|
||||||
|
if (!pinnedIdentity) {
|
||||||
|
const lastIdentity = lastPinnedIdentityRef.current
|
||||||
|
if (!lastIdentity) return
|
||||||
|
|
||||||
|
const lastParticipant =
|
||||||
|
room.localParticipant.identity === lastIdentity
|
||||||
|
? room.localParticipant
|
||||||
|
: room.remoteParticipants.get(lastIdentity)
|
||||||
|
|
||||||
|
announce(
|
||||||
|
lastParticipant?.isLocal
|
||||||
|
? t('self.unpin')
|
||||||
|
: t('unpin', { name: getAnnouncementName(lastParticipant) })
|
||||||
|
)
|
||||||
|
|
||||||
|
lastPinnedIdentityRef.current = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. same pin → do nothing
|
||||||
|
if (lastPinnedIdentityRef.current === pinnedIdentity) return
|
||||||
|
|
||||||
|
// 3. new pin
|
||||||
|
const participant =
|
||||||
|
room.localParticipant.identity === pinnedIdentity
|
||||||
|
? room.localParticipant
|
||||||
|
: room.remoteParticipants.get(pinnedIdentity)
|
||||||
|
|
||||||
|
lastPinnedIdentityRef.current = pinnedIdentity
|
||||||
|
|
||||||
|
announce(
|
||||||
|
participant?.isLocal
|
||||||
|
? t('self.pin')
|
||||||
|
: t('pin', { name: getAnnouncementName(participant) })
|
||||||
|
)
|
||||||
|
}, [pinnedIdentity, announce, getAnnouncementName, room, t])
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { RoomEvent } from 'livekit-client'
|
||||||
|
import type { Participant } from 'livekit-client'
|
||||||
|
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||||
|
import { useRoomContext } from '@livekit/components-react'
|
||||||
|
import { useEffect, useReducer, useRef } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tripwire for speaker promotion.
|
||||||
|
*
|
||||||
|
* Listens to `RoomEvent.ActiveSpeakersChanged` imperatively and forces ONE
|
||||||
|
* re-render of the host component only when an active speaker has none of
|
||||||
|
* their tiles are within the visible span (`maxVisibleTiles`). That render
|
||||||
|
* re-runs `useVisualStableUpdate`, which reads live `participant.isSpeaking`
|
||||||
|
* state and performs the actual swap.
|
||||||
|
*
|
||||||
|
* Speakers already visible are ignored. Everything else costs zero React work.
|
||||||
|
*
|
||||||
|
* Requires the parent to NOT re-render on speaker events itself, i.e.
|
||||||
|
* `useTracks(..., { updateOnlyOn: [] })` upstream.
|
||||||
|
*/
|
||||||
|
export function useSpeakerPromotionTrigger(
|
||||||
|
sortedTiles: TrackReferenceOrPlaceholder[]
|
||||||
|
) {
|
||||||
|
const room = useRoomContext()
|
||||||
|
const [, forceRender] = useReducer((n: number) => n + 1, 0)
|
||||||
|
|
||||||
|
// Refs so the listener reads current values without re-subscribing
|
||||||
|
// and without itself being a render dependency.
|
||||||
|
const tilesRef = useRef(sortedTiles)
|
||||||
|
tilesRef.current = sortedTiles
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onActiveSpeakersChanged = (speakers: Participant[]) => {
|
||||||
|
const tiles = tilesRef.current
|
||||||
|
const hiddenSpeakerExists = speakers.some(
|
||||||
|
(speaker) =>
|
||||||
|
!tiles.some((t) => t.participant.identity === speaker.identity)
|
||||||
|
)
|
||||||
|
if (hiddenSpeakerExists) {
|
||||||
|
forceRender()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
room.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
||||||
|
return () => {
|
||||||
|
room.off(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
||||||
|
}
|
||||||
|
}, [room])
|
||||||
|
}
|
||||||
@@ -168,6 +168,31 @@ export const MainNotificationToast = () => {
|
|||||||
}
|
}
|
||||||
}, [room, triggerNotificationSoundIfRoomIsSmall])
|
}, [room, triggerNotificationSoundIfRoomIsSmall])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleAttributeChanged = (
|
||||||
|
changedAttributes: Record<string, string>,
|
||||||
|
participant: Participant
|
||||||
|
) => {
|
||||||
|
if (!participant.isLocal || !('room_role' in changedAttributes)) return
|
||||||
|
const newRole = changedAttributes['room_role']
|
||||||
|
toastQueue.add(
|
||||||
|
{
|
||||||
|
participant,
|
||||||
|
type: NotificationType.RoleChanged,
|
||||||
|
newRole: newRole,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timeout: NotificationDuration.ROLE_CHANGED,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
room.on(RoomEvent.ParticipantAttributesChanged, handleAttributeChanged)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
room.off(RoomEvent.ParticipantAttributesChanged, handleAttributeChanged)
|
||||||
|
}
|
||||||
|
}, [room])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const removeParticipantNotifications = (participant: Participant) => {
|
const removeParticipantNotifications = (participant: Participant) => {
|
||||||
toastQueue.visibleToasts.forEach((toast) => {
|
toastQueue.visibleToasts.forEach((toast) => {
|
||||||
|
|||||||
@@ -14,4 +14,5 @@ export const NotificationDuration = {
|
|||||||
RECORDING_SAVING: ToastDuration.EXTRA_LONG,
|
RECORDING_SAVING: ToastDuration.EXTRA_LONG,
|
||||||
REACTION_RECEIVED: ToastDuration.SHORT,
|
REACTION_RECEIVED: ToastDuration.SHORT,
|
||||||
RECORDING_REQUESTED: ToastDuration.LONG,
|
RECORDING_REQUESTED: ToastDuration.LONG,
|
||||||
|
ROLE_CHANGED: ToastDuration.LONG,
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user