Compare commits

...

62 Commits

Author SHA1 Message Date
lebaudantoine df576aa556 👷(ci) run summary test suite
Configure the CI to run the summary tests' suite.
2026-03-18 20:02:43 +01:00
lebaudantoine 3172fd1015 (summary) add tests for the task endpoint
Ensure the API correctly validates and accepts the parameters
required to register a task in the queue.
2026-03-18 20:01:06 +01:00
lebaudantoine bc18d2478e 🧑‍💻(summary) add make command to run summary test suite
Introduce a dedicated make target to execute tests for the
summary component of the stack.
2026-03-18 18:52:00 +01:00
lebaudantoine e024b0648b (summary) add minimal test suite for health endpoint
Based on cameldev's work, introduce basic tests to ensure the
health endpoint responds correctly.
2026-03-18 18:50:32 +01:00
lebaudantoine 2424ce17ec 👷(ci) re-enable Trivy scan in CI for image builds
Restore Trivy scanning jobs for built images after the situation
was clarified, ensuring vulnerability checks are active again.
2026-03-18 10:26:23 +01:00
camillem 4715905334 docs: fix typo and markdown 2026-03-18 10:24:09 +01:00
renovate[bot] 7c05aedcfe ⬆️(dependencies) update PyJWT to v2.12.0 [SECURITY] 2026-03-17 18:33:39 +01:00
lebaudantoine 7347fc7c86 🚨(doc) fix changelog linting
Merge an outside contribution, didn't notice it broke the changelog fix it.
2026-03-17 16:45:04 +01:00
Hadrien Blanc ada7d9a666 🐛(frontend) fix dimension mismatch in BackgroundCustomProcessor
The getImageData call was using PROCESSING_WIDTH for both dimensions
instead of PROCESSING_WIDTH and PROCESSING_HEIGHT. This caused the
source image data to be 256x256 instead of the expected 256x144, leading
to buffer overflow when writing to the segmentation mask and potential
visual artifacts in Firefox background effects.
2026-03-17 16:41:40 +01:00
lebaudantoine 0cb3fb8e3c 🔧(ci) explicitly set Docker Hub CI permissions to read-only
Define read-only permissions in the workflow to clarify the
expected access level and follow the principle of least
privilege.
2026-03-15 16:53:07 +01:00
lebaudantoine dcb788b57b 🔒️(backend) avoid information exposure through exception messages
Sanitize error handling to prevent leaking internal details when
invalid or malicious requests are sent to the API.

Return generic error responses to reduce the risk of information
disclosure during probing attempts.
2026-03-13 17:33:55 +01:00
lebaudantoine 73bcb9d598 🐛(backend) fix unescaped dot in regex pattern
The dot before (?P<extension>...) was not escaped and matched any
character instead of a literal period.

Escape it to align with MEDIA_STORAGE_URL_PATTERN, which correctly
uses \. for the file extension separator.
2026-03-13 16:19:36 +01:00
lebaudantoine f3e90c3999 ♻️(backend) clarify storage hook message for intentionally ignored req
Update the response message to explicitly state that certain
notifications are ignored on purpose, avoiding confusion
during debugging or log inspection.
2026-03-13 15:42:30 +01:00
lebaudantoine cb4ed3c9d7 🩹(backend) ignore non-recording uploads in storage webhook handler
With the introduction of background file uploads, a misconfigured
MinIO webhook could trigger the storage hook endpoint for unrelated
files.

While the dev setup now filters events via the MinIO lifecycle
configuration, add a safeguard at the application level.

Enforce a stricter filepath regex when parsing storage hook events
and ignore files outside the recording output directory.

Return a clean 200 response to acknowledge the webhook while
avoiding unnecessary processing.
2026-03-13 15:34:02 +01:00
lebaudantoine f8b0746e73 ♻️(backend) factorize regex used in the S3 event parser
Avoid duplicating patterns that match the same logical unit by
sharing common parts between the two regexes.

This logical factorization improves maintainability and ensures
consistent parsing behavior across S3 event handlers.
2026-03-13 15:34:02 +01:00
lebaudantoine d8ad7a743e ♻️(backend) align recording file regex with MEDIA_URL-based pattern
Initially I avoided coupling the recording path regex with the
setting that provides the Django static URL. However the new
file viewset already relies on it, and Drive does as well.

For consistency, update the recording regex to use the same
STATIC_URL-based approach.

While the coupling may not feel ideal, having two different
regex strategies for similar file paths would be worse.
2026-03-13 15:34:02 +01:00
lebaudantoine 0ba445895c ♻️(backend) merge room name regex and centralize patterns in enums
Combine the regex used in Drive with the one from the recording
feature and centralize them in the enums module.

Although the module name suggests only enums, it also hosts
shared constants used across the codebase.
2026-03-13 15:34:02 +01:00
Florent Chehab 3b719ab9ba (settings) disable file upload by default & max count
Add FILE_UPLOAD_ENABLED setting (default to False, to avoid
a breaking change). Also adds a max_count_by_user sub setting
to restrict the number of uploaded files per user.
2026-03-12 14:55:33 +01:00
Florent Chehab 8887e811d3 🔨(tilt) improve tilt stack stability
* Increase the backoff limit on jobs
* Add redis dependency for livekit
2026-03-12 14:41:58 +01:00
Florent Chehab cb9e994749 ️(helm) reduce initialDelaySeconds and add periods seconds
InitialDelaySeconds was set to 30s which caused the tilt stack
startup to be very slow. In this commit I split the initial delay
and the period arg.
5s seems to be relevant given the nature of the app.
2026-03-12 14:41:57 +01:00
Florent Chehab 1841533d2c 🔨(makefile) add default to meet namespace
Avoids resources being deployed in the wrong namespace
depending on user config.
2026-03-12 14:41:40 +01:00
lebaudantoine 7da99f2116 🔐(backend) avoids revealing the inactive status of an application
Authenticate the application secret before checking whether the
application is inactive.

This avoids revealing the inactive status of an application when
an incorrect secret is provided, preventing an authentication
state oracle and client_id enumeration.
2026-03-12 13:40:11 +01:00
lebaudantoine b0af5e7f35 🧪(backend) add failing test for client_id enumeration issue
Currently the inactive status is revealed before verifying the
secret, creating an authentication state oracle.

Introduce a failing test to capture the issue before
applying the fix.
2026-03-12 13:39:44 +01:00
lebaudantoine dd6bb0ed3e 🔧(backend) trigger webhook only for recording file uploads
With the introduction of file background uploads, only trigger the
webhook for files related to recordings.

Avoid firing the "recording saved" event for other file uploads,
preventing unnecessary queries and false triggers.
2026-03-12 13:33:39 +01:00
lebaudantoine 1306f0bcfe (backend) expose is_active field for Application in Django admin
Add minimal admin support to allow administrators to mark an
application account as inactive if it becomes rogue or compromised.

This capability was missing when the application concept was
initially introduced in the backend.
2026-03-12 13:27:08 +01:00
lebaudantoine ca4494c09e ♻️(backend) align Application model field with is_active convention
Most models in the project use `is_active` rather than `active`.
The Application model was not aligned with this convention.

Rename the field and add a migration to standardize the naming.
Update related tests accordingly.

This change should not introduce breaking changes for external
applications.
2026-03-12 13:26:37 +01:00
lebaudantoine f57db0acc8 ♻️(backend) refactor uploaded file's key format
Remove the user-provided filename while preserving the extension,
and switch to the format `uuid.extension`.

This is more natural than the previous `uuid/.extension` format
and avoids confusion.

Update related tests accordingly.
2026-03-12 10:39:59 +01:00
Florent Chehab 342f992556 🔨(python-env) migrate meet main app to UV
Migrate main meet app to use UV for dependancy management.
Also optimized the backend image build sequence for faster rebuilds
when dependencies don't change.
Also removed compiled django translations files are they are done in the build
process now.

Changes inspired by drive repo.
2026-03-12 10:25:35 +01:00
lebaudantoine d0cf3974ad 🚸(frontend) allow downloading recordings with "failed to stop" status
When support manually marks a recording as "failed to stop," enable
users to download the recording from the frontend.

This ensures users can recover their recordings even if the
automatic stop process fails.
2026-03-11 19:41:45 +01:00
lebaudantoine 0f3eb35c83 🩹(backend) add Django admin action to update recording status manually
Making the status read-only improved security and enforced least
privilege, but in production some recordings could not be properly
stopped, leaving them in an active state.

Introduce an admin action to allow support staff to mark recordings
as "failed to stop" or update their status when necessary.
2026-03-11 19:41:45 +01:00
lebaudantoine d15fb37932 📝(doc) fix changelog formatting
Wrap lines that were excessively long to improve readability.
2026-03-11 19:19:00 +01:00
lebaudantoine 051f33bb1e (backend) add tests for request-entry throttling in the lobby system
Cover throttling behavior for both authenticated users and
anonymous participants identified via the lobby cookie.
2026-03-11 18:07:39 +01:00
lebaudantoine 566eacc8fe (backend) add authenticated user rate throttling on request-entry
Throttle the request-entry endpoint for authenticated users also
to guard against accidental hammering from buggy clients.

Authenticated users are throttled via RequestEntryUserRateThrottle.
Anonymous users are throttled using the lobby participant cookie
through RequestEntryAnonRateThrottle.
2026-03-11 18:07:39 +01:00
lebaudantoine d19023a1ba 🐛(backend) refactor lobby throttling to use participant id instead of IP
use the lobby participant cookie ID as the throttle cache key
rather than the client IP address.

This avoids penalising multiple users behind the same NAT or proxy
and aligns throttling with how LobbyService identifies participants.

This bug was spotted in production where users from ministries behind
NAT was blocked by throttling while using visio.

If no cookie is present yet, skip throttling for the request. The
cookie will be set on the first response and throttling will apply
from subsequent requests.

This throttle is intended to protect against accidental hammering
from buggy clients, not as a security control against DoS attacks.

This is not a security measure, we should use a WAF.
2026-03-11 18:07:39 +01:00
lebaudantoine a6e36f02a7 🌐(frontend) remove references to ProConnect from the login hint
ProConnect (DINUM SSO) should not be mentioned in this context.
Clean up the login hint to avoid confusion.
2026-03-11 17:06:07 +01:00
lebaudantoine c3fd1a89ef 🩹(backend) add page_size to pagination for room endpoints
Align room endpoints with the pagination behavior used across
other API endpoints and resolve issue #1055.
2026-03-11 16:27:02 +01:00
ColorfulRhino 8b1ff536b3 🌐(frontend) improve German translation
Enhancements to the German localization include:

- Refine phrases to sound more natural
- Ensure consistency in words usage
- Adapt phrases to be inclusive and gender-neutral
- Standardize to the "du" form of address, aligning with modern
  practices and other open-source projects like GitLab and the Android
  Open Source Project
2026-03-10 22:56:25 +01:00
Hadrien Blanc 6b08b8da1b 📝 Fix documentation and comment typos 2026-03-10 22:56:08 +01:00
lebaudantoine c4ff42f181 📝(doc) update changelog 2026-03-10 18:36:04 +01:00
lebaudantoine ee0aa0fe5b 🔖(helm) release chart 0.0.17 2026-03-10 18:36:04 +01:00
lebaudantoine 09c871fc29 🩹(helm) fix MinIO ingress port configuration
The wrong port was configured, causing CORS issues
when uploading files directly to the MinIO bucket.

Port 9001 is reserved for the MinIO console;
use the correct API port instead.
2026-03-10 18:36:04 +01:00
Florent Chehab 8496959188 (infra) add celery backend to helm & improvements
* Add a simple celery-backend deployment to helm chart, which
  uses the same docker image as the backend, and runs a single
  worker.
* Update dev keycloack values accordingly
* Update Tiltfile to properly set service dependencies
* Update minio deployment to increase the default max body size limit
  (relevant to uploading files)
2026-03-10 18:36:04 +01:00
Florent Chehab 77105001e0 (settings) configure celery & run task in dedicated queue
Improve celery configuration from env variables and set
meet backend related tasks to run in the `meet-backend` queue
to ease sharing a single redis instance for multiple celery.
2026-03-10 18:36:04 +01:00
lebaudantoine 2d57d38644 🔨(helm) update helm chart for custom background image
Add media_files_svc and ingress_media_files to helm chart
for serving medias related to the new files.

This setup is more straightforward than merging the
existing recording related media ones.

Also updated values to enable file upload by default in dev.

Mostly done by @lebaudantoine.
2026-03-10 18:36:04 +01:00
dependabot[bot] c9d13619a6 ⬆️(deps) bump rollup from 4.44.2 to 4.59.0 in /src/frontend #1088
Bumps [rollup](https://github.com/rollup/rollup) from 4.44.2 to 4.59.0.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.44.2...v4.59.0)

---
updated-dependencies:
- dependency-name: rollup
  dependency-version: 4.59.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-10 16:52:00 +01:00
Florent Chehab b99ec9bb50 🔨(tilt) use default user in dev
This change avoid permission issues when syncing files
with tilt.
2026-03-10 15:51:00 +01:00
Cyril b3f26469c8 🐛(frontend) fix hand icon and queue position alignment
Use inline-flex with alignItems center so number and icon align vertically.
2026-03-10 12:12:42 +01:00
Cyril d5c53c7dd4 ️(frontend) improve ui and qria labels for help article links
Screen readers now announce full link text instead of "Learn more".
2026-03-10 11:36:10 +01:00
Cyril f43ac2e4eb ♻️(refactor) apply caption text size to subtitles
Use accessibilityStore.captionTextSize in Transcription component.
2026-03-10 11:15:52 +01:00
Cyril ea5dd5bc0e (feat) add caption text size setting in Accessibility tab
Extract CaptionsSettings component, Settings > Accessibility > Captions.
2026-03-10 11:14:40 +01:00
Cyril 86427fa2b7 🌐(i18n) add captions text size setting translations
EN, FR, DE, NL for Accessibility > Captions > Text size.
2026-03-10 11:14:39 +01:00
Cyril 3959c3657c ️(frontend) add caption text size to accessibility store
Persist captionTextSize (small/medium/large) in user preferences.
2026-03-10 11:14:08 +01:00
Cyril 191f8abbcc ️(frontend) improve chat a11y for screen readers
Chat messages announced via role="log" when open, toast+announce when closed.
2026-03-09 16:06:08 +01:00
Cyril d91f8bb6e1 Merge branch 'fix/homepage-knowmore-link' 2026-03-09 14:41:26 +01:00
lebaudantoine d612f9b26b 🩹(changelog) fix changelog organisation for v1.10 2026-03-09 14:32:30 +01:00
Cyril 042be17cfa ️(frontend) improve MoreLink a11y and UX on home page
Render MoreLink once in LeftColumn, make full phrase clickable and add icon.
2026-03-09 14:27:39 +01:00
Cyril d00f4fa695 ️(frontend) sync html lang attribute with i18n for screen readers
Set document.documentElement.lang in i18n init and on languageChanged,
2026-03-09 13:32:10 +01:00
Ömer Fatih İlhan fd36469fc2 kubernetes documentation helm path updated 2026-03-06 16:16:14 +01:00
Florent Chehab dc278a6064 (backend) add file upload feature & tests
For the coming features we will need to store files on the meet side.
(for instance user backgrounds).

This commits adds a new Model to manage files, and the associated
serializers & viewsets. All are tested.

This work was heavily inspired by the work done by our friends at
https://github.com/suitenumerique/drive
It build on the same architecture design (upload directly to S3 but
download goes through our proxy), but model is much much simplier
(no folders, no file sharing, etc.).
2026-03-06 11:31:39 +01:00
Florent Chehab 047da94494 (backend) add mimetype detection logic from drive
Reused the logic developed by the team working on drive.
This is usefull for our own upload file backend (that will
come in later commits).

Dockefile was updated to add a required system dependency.

We might want to put this shared logic in a lib.
2026-03-06 11:31:39 +01:00
Florent Chehab 124a8bf8d9 🔨(docker) improve docker ignore
Tweak syntax to ignore folders recursively (important).
And add few missing ones.
2026-03-06 11:22:39 +01:00
Cyril c72c5cae1a ️(frontend) dynamic tab title when connected to meeting
Display room name, date and app title in browser tab
2026-03-05 17:58:00 +01:00
129 changed files with 7611 additions and 949 deletions
+8 -6
View File
@@ -4,7 +4,7 @@ __pycache__
**/__pycache__
**/*.pyc
venv
.venv
**/.venv
# System-specific files
.DS_Store
@@ -24,13 +24,15 @@ data
.cache
.circleci
.git
.vscode
.iml
.idea
db.sqlite3
.mypy_cache
.pylint.d
.pytest_cache
**/.idea
**/.vscode
**/.pytest_cache
**/.mypy_cache
**/.ruff_cache
# Frontend
node_modules
**/node_modules
+48 -33
View File
@@ -12,6 +12,9 @@ on:
branches:
- 'main'
permissions:
contents: read
env:
DOCKER_USER: 1001:127
DOCKER_CONTAINER_REGISTRY_HOSTNAME: docker.io
@@ -20,6 +23,8 @@ env:
jobs:
build-and-push-backend:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
@@ -43,12 +48,12 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# with:
# docker-build-args: '--target backend-production -f Dockerfile'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '--target backend-production -f Dockerfile'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -63,6 +68,8 @@ jobs:
build-and-push-frontend-generic:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
@@ -86,12 +93,12 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# with:
# docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -107,6 +114,8 @@ jobs:
build-and-push-frontend-dinum:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
@@ -130,12 +139,12 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# with:
# docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
with:
docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -151,6 +160,8 @@ jobs:
build-and-push-summary:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
@@ -174,13 +185,13 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# continue-on-error: true
# with:
# docker-build-args: '-f src/summary/Dockerfile --target production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/summary/Dockerfile --target production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
docker-context: './src/summary'
-
name: Build and push
@@ -197,6 +208,8 @@ jobs:
build-and-push-agents:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
-
name: Checkout repository
@@ -220,14 +233,14 @@ jobs:
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
# -
# name: Run trivy scan
# uses: numerique-gouv/action-trivy-cache@main
# continue-on-error: true
# with:
# docker-build-args: '-f src/agents/Dockerfile --target production'
# docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
# docker-context: './src/agents'
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
continue-on-error: true
with:
docker-build-args: '-f src/agents/Dockerfile --target production'
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
docker-context: './src/agents'
-
name: Build and push
uses: docker/build-push-action@v6
@@ -242,6 +255,8 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
notify-argocd:
permissions:
contents: read
needs:
- build-and-push-frontend-generic
- build-and-push-frontend-dinum
+55 -12
View File
@@ -124,15 +124,17 @@ jobs:
uses: actions/setup-python@v6
with:
python-version: "3.13"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Install the project
run: uv sync --locked --all-extras
- name: Check code formatting with ruff
run: ~/.local/bin/ruff format . --diff
run: uv run ruff format . --diff
- name: Lint code with ruff
run: ~/.local/bin/ruff check .
run: uv run ruff check .
- name: Lint code with pylint
run: ~/.local/bin/pylint meet demo core
run: uv run pylint meet demo core
lint-agents:
runs-on: ubuntu-latest
@@ -230,6 +232,7 @@ jobs:
OIDC_RS_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
OIDC_OP_INTROSPECTION_ENDPOINT: https://oidc.example.com/introspect
OIDC_OP_URL: https://oidc.example.com
MEDIA_BASE_URL: http://localhost:8083
steps:
- name: Checkout repository
@@ -278,10 +281,10 @@ jobs:
uses: actions/setup-python@v6
with:
python-version: "3.13"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Install uv
uses: astral-sh/setup-uv@v7
- name: Install the dependencies
run: uv sync --locked --all-extras
- name: Install gettext (required to compile messages)
run: |
@@ -289,10 +292,50 @@ jobs:
sudo apt-get install -y gettext
- name: Generate a MO file from strings extracted from the project
run: python manage.py compilemessages
run: uv run python manage.py compilemessages
- name: Run tests
run: ~/.local/bin/pytest -n 2
run: uv run pytest -n 2
test-summary:
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: src/summary
env:
APP_API_TOKEN: "test-api-token"
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
AWS_S3_ENDPOINT_URL: "minio:9000"
AWS_S3_ACCESS_KEY_ID: "meet"
AWS_S3_SECRET_ACCESS_KEY: "password"
WHISPERX_BASE_URL: "https://configure-your-url.com"
WHISPERX_ASR_MODEL: "large-v2"
WHISPERX_API_KEY: "test-whisperx-secret"
WHISPERX_DEFAULT_LANGUAGE: "fr"
LLM_BASE_URL: "https://configure-your-url.com"
LLM_API_KEY: "test-llm-secret"
LLM_MODEL: "test-llm-model"
WEBHOOK_API_TOKEN: "test-webhook-secret"
WEBHOOK_URL: "https://configure-your-url.com"
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Install Python
uses: actions/setup-python@v6
with:
python-version: "3.13"
cache: "pip"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Run summary tests
run: ~/.local/bin/pytest
lint-front:
runs-on: ubuntu-latest
+1
View File
@@ -31,6 +31,7 @@ MANIFEST
# Translations # Translations
*.pot
*.mo
# Environments
.env
+40 -5
View File
@@ -8,14 +8,39 @@ and this project adheres to
## [Unreleased]
## [1.10.0] - 2026-03-05
### Added
- ✨(helm) support celery with our Django backend #1124
- ✨(helm) support ingress for custom background image #1124
- ✨(backend) add authenticated user rate throttling on request-entry #1129
- ✨(backend) expose `is_active` field for Application in Django admin #1133
- ✨(file-upload) disable by default & limit count by user #1141
### Changed
- ♿️(frontend) Caption text size setting for accessibility #1062
- ♿️(frontend) sync html lang attribute with i18n for screen readers #1111
- ♿️(frontend) improve MoreLink a11y and UX on home page #1112
- ♿(frontend) improve chat toast a11y for screen readers #1109
- ♿(frontend) improve ui and aria labels for help article links #1108
- 🌐(frontend) improve German translation #1125
- 🔨(python-env) migrate meet main app to UV #1120
- ♻️(backend) align Application model field with `is_active` convention #1133
- 🔐(backend) avoids revealing the inactive status of an application #1135
- ⚡️(helm) reduce initialDelaySeconds and add periods seconds #1139
- 🔒️(backend) avoid information exposure through exception messages #1144
- ⬆️(dependencies) update PyJWT to v2.12.0 [SECURITY] #1151
### Fixed
- 🩹(frontend) remove incorrect reference to ProConnect on the prejoin #1080
- ✨(frontend) add Ctrl+Shift+/ to open shortcuts settings #1050
- ♿(frontend) announce selected state to screen readers #1081
- 💄(frontend) truncate long names with ellipsis in reaction overlay #1099
- 🐛(frontend) fix hand icon and queue position alignment and position #1119
- 🩹(backend) add page_size to pagination for room endpoints #1131
- 🐛(backend) refactor lobby throttling to use participant id #1129
- 🩹(backend) ignore non-recording uploads in storage webhook handler #1142
- 🐛(frontend) fix dimension mismatch in BackgroundCustomProcessor #1116
## [1.10.0] - 2026-03-05
### Changed
@@ -23,12 +48,22 @@ and this project adheres to
- 🦺(backend) strengthen API validation for recording options #1063
- ⚡️(frontend) optimize few performance caveats #1073
- 🔒️(helm) introduce a dedicated Kubernetes Ingress for webhook-livekit #1066
- ⬆️(deps) bump rollup from 4.44.2 to 4.59.0 in /src/frontend #1088
### Fixed
- 🐛(migrations) use settings in migrations #1058
- 💄(frontend) truncate pinned participant name with ellipsis on overflow #1056
- ♿(frontend) prevent focus ring clipping on invite dialog #1078
- ♿(frontend) dynamic tab title when connected to meeting #1060
- 🩹(frontend) remove incorrect reference to ProConnect on the prejoin #1080
- ✨(frontend) add Ctrl+Shift+/ to open shortcuts settings #1050
- ♿(frontend) announce selected state to screen readers #1081
- 💄(frontend) truncate long names with ellipsis in reaction overlay #1099
### Added
- ✨(backend) add file upload feature #1030
## [1.9.0] - 2026-03-02
+41 -23
View File
@@ -13,14 +13,28 @@ RUN apk update && \
# ---- Back-end builder image ----
FROM base AS back-builder
WORKDIR /builder
# Copy required python dependencies
COPY ./src/backend /builder
ENV UV_COMPILE_BYTECODE=1
ENV UV_LINK_MODE=copy
RUN mkdir /install && \
pip install --prefix=/install .
# Disable Python downloads, because we want to use the system interpreter
# across both images. If using a managed Python version, it needs to be
# copied from the build image into the final image;
ENV UV_PYTHON_DOWNLOADS=0
# install uv
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/
WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \
--mount=type=bind,source=src/backend/uv.lock,target=uv.lock \
--mount=type=bind,source=src/backend/pyproject.toml,target=pyproject.toml \
uv sync --locked --no-install-project --no-dev
COPY src/backend /app
RUN --mount=type=cache,target=/root/.cache/uv \
uv sync --locked --no-dev
# ---- mails ----
FROM node:20 AS mail-builder
@@ -30,7 +44,7 @@ COPY ./src/mail /mail/app
WORKDIR /mail/app
RUN yarn install --frozen-lockfile && \
yarn build
yarn build
# ---- static link collector ----
@@ -39,19 +53,20 @@ ARG MEET_STATIC_ROOT=/data/static
RUN apk add \
pango \
libmagic \
rdfind
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
# Copy Meet application (see .dockerignore)
COPY ./src/backend /app/
WORKDIR /app
# Copy the application from the builder
COPY --from=back-builder /app /app
ENV PATH="/app/.venv/bin:$PATH"
# collectstatic
RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
python manage.py collectstatic --noinput
python manage.py collectstatic --noinput
# Replace duplicated file by a symlink to decrease the overall size of the
# final image
@@ -68,6 +83,7 @@ RUN apk --no-cache add \
gettext \
libffi-dev \
pango \
libmagic \
shared-mime-info
@@ -79,14 +95,17 @@ COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
# docker user (see entrypoint).
RUN chmod g=u /etc/passwd
# Copy installed python dependencies
COPY --from=back-builder /install /usr/local
# Copy Meet application (see .dockerignore)
COPY ./src/backend /app/
# Copy the application from the builder
COPY --from=back-builder /app /app
WORKDIR /app
ENV PATH="/app/.venv/bin:$PATH"
# Generate compiled translation messages
RUN DJANGO_CONFIGURATION=Build \
python manage.py compilemessages --ignore=".venv/**/*"
# We wrap commands run in this container by the following entrypoint that
# creates a user on-the-fly with the container user ID (see USER) and root group
# ID.
@@ -101,10 +120,9 @@ USER root:root
# Install psql
RUN apk add postgresql-client
# Uninstall Meet and re-install it in editable mode along with development
# dependencies
RUN pip uninstall -y meet
RUN pip install -e .[dev]
# Install development dependencies
RUN --mount=from=ghcr.io/astral-sh/uv:0.10.9,source=/uv,target=/bin/uv \
uv sync --all-extras --locked
# Restore the un-privileged user running the application
ARG DOCKER_USER
@@ -113,7 +131,7 @@ USER ${DOCKER_USER}
# Target database host (e.g. database engine following docker compose services
# name) & port
ENV DB_HOST=postgresql \
DB_PORT=5432
DB_PORT=5432
# Run django development server
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
+12 -5
View File
@@ -114,7 +114,8 @@ logs: ## display app-dev logs (follow mode)
.PHONY: logs
run-backend: ## start only the backend application and all needed services
@$(COMPOSE) up --force-recreate -d celery-dev
@$(COMPOSE) up --force-recreate -d celery-dev --remove-orphans
@$(COMPOSE) up --force-recreate -d nginx
@echo "Wait for postgresql to be up..."
@$(WAIT_DB)
.PHONY: run-backend
@@ -190,6 +191,7 @@ lint-pylint: ## lint back-end python sources with pylint only on changed files f
test: ## run project tests
@$(MAKE) test-back-parallel
@$(MAKE) test-summary
.PHONY: test
test-back: ## run back-end tests
@@ -202,6 +204,11 @@ test-back-parallel: ## run all back-end tests in parallel
bin/pytest -n auto $${args:-${1}}
.PHONY: test-back-parallel
test-summary: ## run summary tests
@args="$(filter-out $@,$(MAKECMDGOALS))" && \
bin/pytest-summary $${args:-${1}}
.PHONY: test-summary
makemigrations: ## run django makemigrations for the Meet project.
@echo "$(BOLD)Running makemigrations$(RESET)"
@$(COMPOSE) up -d postgresql
@@ -222,7 +229,7 @@ superuser: ## Create an admin superuser with password "admin"
.PHONY: superuser
back-i18n-compile: ## compile the gettext files
@$(MANAGE) compilemessages --ignore="venv/**/*"
@$(MANAGE) compilemessages --ignore=".venv/**/*"
.PHONY: back-i18n-compile
back-i18n-generate: ## create the .pot files used for i18n
@@ -353,13 +360,13 @@ install-external-secrets: ## install the kubernetes secrets from Vaultwarden
.PHONY: build-k8s-cluster
start-tilt: ## start the kubernetes cluster using kind
tilt up -f ./bin/Tiltfile
tilt up --namespace=meet -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
DEV_ENV=dev-keycloak tilt up -f ./bin/Tiltfile
DEV_ENV=dev-keycloak tilt up --namespace=meet -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
start-tilt-dinum: ## start the kubernetes cluster using kind, without Pro Connect for authentication, but with DINUM styles
DEV_ENV=dev-dinum tilt up -f ./bin/Tiltfile
DEV_ENV=dev-dinum tilt up --namespace=meet -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
+4 -4
View File
@@ -18,7 +18,6 @@ docker_build(
'localhost:5001/meet-backend:latest',
context='..',
dockerfile='../Dockerfile',
build_args={'DOCKER_USER': '1001:127'},
only=['./src/backend', './src/mail', './docker'],
target = 'backend-production',
live_update=[
@@ -34,7 +33,6 @@ clean_old_images('localhost:5001/meet-backend')
docker_build(
'localhost:5001/meet-frontend-dinum:latest',
context='..',
build_args={'DOCKER_USER': '1001:127'},
dockerfile='../docker/dinum-frontend/Dockerfile',
only=['./src/frontend', './docker', './.dockerignore'],
target = 'frontend-production',
@@ -59,7 +57,6 @@ clean_old_images('localhost:5001/meet-frontend-generic')
docker_build(
'localhost:5001/meet-summary:latest',
context='../src/summary',
build_args={'DOCKER_USER': '1001:127'},
dockerfile='../src/summary/Dockerfile',
only=['.'],
target = 'production',
@@ -72,7 +69,6 @@ clean_old_images('localhost:5001/meet-summary')
docker_build(
'localhost:5001/meet-agents:latest',
context='../src/agents',
build_args={'DOCKER_USER': '1001:127'},
dockerfile='../src/agents/Dockerfile',
only=['.'],
target = 'production',
@@ -103,7 +99,11 @@ k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .
k8s_resource('minio-bucket', resource_deps=['minio'])
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
k8s_resource('meet-celery-backend', resource_deps=['redis'])
k8s_resource('meet-celery-summarize', resource_deps=['redis'])
k8s_resource('meet-celery-transcribe', resource_deps=['redis'])
k8s_resource('meet-backend-migrate', resource_deps=['meet-backend'])
k8s_resource('livekit-livekit-server', resource_deps=['redis'])
k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
k8s_resource('keycloak', resource_deps=['kc-postgresql'])
k8s_resource('meet-backend-createsuperuser', resource_deps=['meet-backend-migrate'])
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
_dc_run \
app-summary-dev \
python -m pytest "$@"
+4 -2
View File
@@ -58,7 +58,7 @@ services:
/usr/bin/mc admin config set meet notify_webhook:meet-webhook endpoint='http://app-dev:8000/api/v1.0/recordings/storage-hook/' auth_token='Bearer password' &&
/usr/bin/mc admin service restart meet --wait --json &&
sleep 15 &&
/usr/bin/mc event add meet/meet-media-storage arn:minio:sqs::meet-webhook:webhook --event put &&
/usr/bin/mc event add meet/meet-media-storage arn:minio:sqs::meet-webhook:webhook --event put --prefix "recordings" &&
exit 0;"
app-dev:
@@ -80,11 +80,11 @@ services:
volumes:
- ./src/backend:/app
- ./data/static:/data/static
- /app/.venv
depends_on:
- postgresql
- mailcatcher
- redis
- nginx
- livekit
- createbuckets
- createwebhook
@@ -106,6 +106,7 @@ services:
volumes:
- ./src/backend:/app
- ./data/static:/data/static
- /app/.venv
depends_on:
- app-dev
@@ -148,6 +149,7 @@ services:
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
depends_on:
- keycloak
- app-dev
networks:
- resource-server
- default
@@ -4,10 +4,47 @@ server {
server_name localhost;
charset utf-8;
# Proxy auth for media
location /media/ {
# Auth request configuration
auth_request /media-auth;
auth_request_set $authHeader $upstream_http_authorization;
auth_request_set $authDate $upstream_http_x_amz_date;
auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
# Pass specific headers from the auth response
proxy_set_header Authorization $authHeader;
proxy_set_header X-Amz-Date $authDate;
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
# Get resource from Minio
proxy_pass http://minio:9000/meet-media-storage/;
proxy_set_header Host minio:9000;
# To use with ds_proxy
# proxy_pass http://ds-proxy:4444/upstream/meet-media-storage/;
# proxy_set_header Host ds-proxy:4444;
add_header Content-Disposition "attachment";
}
location /media-auth {
proxy_pass http://app-dev:8000/api/v1.0/files/media-auth/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Original-URL $request_uri;
# Prevent the body from being passed
proxy_pass_request_body off;
proxy_set_header Content-Length "";
proxy_set_header X-Original-Method $request_method;
}
location / {
proxy_pass http://keycloak:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Port $server_port;
}
}
+3 -4
View File
@@ -61,11 +61,10 @@ services:
`docker compose up -d`
```
Your keycloak instance is now available on https://doc.yourdomain.tld
Your keycloak instance is now available on https://id.yourdomain.tld
> [!CAUTION]
> Version of the images are set to latest, you should pin it to the desired version to avoid unwanted upgrades when pulling latest image. You can find available versions on [Keycloak registry](https://quay.io/repository/keycloak/keycloak?tab=tags).
```
## Creating an OIDC Client for Meet Application
@@ -76,7 +75,7 @@ Your keycloak instance is now available on https://doc.yourdomain.tld
3. Enter the name of the realm - `meet`.
4. Click "Create".
#### Step 2: Create a New Client
### Step 2: Create a New Client
1. Navigate to the "Clients" tab.
2. Click on the "Create client" button.
@@ -86,7 +85,7 @@ Your keycloak instance is now available on https://doc.yourdomain.tld
1. Set the "Web Origins" to the URL of your meet application - e.g. `https://meet.example.com`.
1. Click "Save".
#### Step 3: Get Client Credentials
### Step 3: Get Client Credentials
1. Go to the "Credentials" tab.
2. Copy the client ID (`meet` in this example) and the client secret.
+3 -3
View File
@@ -68,13 +68,13 @@ backend:
python manage.py createsuperuser --email admin@example.com --password admin
restartPolicy: Never
# Exra volume to manage our local custom CA and avoid to set ssl_verify: false
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
extraVolumeMounts:
- name: certs
mountPath: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem
mountPath: /app/.venv/lib/python3.13/site-packages/certifi/cacert.pem
subPath: cacert.pem
# Exra volume to manage our local custom CA and avoid to set ssl_verify: false
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
extraVolumes:
- name: certs
configMap:
+2 -2
View File
@@ -87,12 +87,12 @@ If you are using an external service, you need to set `REDIS_URL` environment va
Generate a secure key for `LIVEKIT_API_SECRET` in `env.d/common`.
We provide a minimal recommanded config for production environment in `livekit-server.yaml`. Set the previously generated API secret key in the config file.
We provide a minimal recommended config for production environment in `livekit-server.yaml`. Set the previously generated API secret key in the config file.
To view other customization options, see [config-sample.yaml](https://github.com/livekit/livekit/blob/master/config-sample.yaml)
> [!NOTE]
> In this example, we configured multiplexing on a single UDP port. For better performances, you can configure a range of UDP ports.
> In this example, we configured multiplexing on a single UDP port. For better performance, you can configure a range of UDP ports.
### Meet
+7 -7
View File
@@ -122,11 +122,11 @@ If you haven't run the script **bin/start-kind.sh**, you'll need to manually cre
$ kubectl create namespace meet
```
If you have already run the script, you can skip this step and proceed to the next instruction. NOTE: Before you proceed, and is using the kind method, make sure you download this repo examples/ directory and its contents to the location where you will be executing the helm command. Helm will look for "examples/<name>values.yaml" from based on the path it is being executed.
If you have already run the script, you can skip this step and proceed to the next instruction. NOTE: Before you proceed, and is using the kind method, make sure you download this repo examples/helm directory and its contents to the location where you will be executing the helm command. Helm will look for "examples/helm/<name>values.yaml" from based on the path it is being executed.
```
$ kubectl config set-context --current --namespace=meet
$ helm install keycloak oci://registry-1.docker.io/bitnamicharts/keycloak -f examples/keycloak.values.yaml
$ helm install keycloak oci://registry-1.docker.io/bitnamicharts/keycloak -f examples/helm/keycloak.values.yaml
$ #wait until
$ kubectl get po
NAME READY STATUS RESTARTS AGE
@@ -150,7 +150,7 @@ OIDC_RP_SIGN_ALGO: RS256
OIDC_RP_SCOPES: "openid email"
```
You can find these values in **examples/keycloak.values.yaml**
You can find these values in **examples/helm/keycloak.values.yaml**
### Find livekit server connexion values
@@ -159,7 +159,7 @@ LaSuite Meet use livekit for streaming part so if you have a livekit provider, o
Livekit need a redis (and meet too) so we will start by deploying a redis :
```
$ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/redis.values.yaml
$ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/helm/redis.values.yaml
$ kubectl get po
NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 26m
@@ -172,7 +172,7 @@ When the redis is ready we can deploy livekit-server.
```
$ helm repo add livekit https://helm.livekit.io
$ helm repo update
$ helm install livekit livekit/livekit-server -f examples/livekit.values.yaml
$ helm install livekit livekit/livekit-server -f examples/helm/livekit.values.yaml
$ kubectl get po
NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 30m
@@ -199,7 +199,7 @@ CELERY_RESULT_BACKEND: redis://default:pass@redis-master:6379/1
LaSuite Meet uses a postgresql db as backend so if you have a provider, obtain the necessary information to use it. If you do not have, you can install a postgresql testing environment as follows:
```
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/helm/postgresql.values.yaml
$ kubectl get po
NAME READY STATUS RESTARTS AGE
keycloak-0 1/1 Running 0 45m
@@ -226,7 +226,7 @@ Now you are ready to deploy LaSuite Meet without AI. AI required more dependenci
```
$ helm repo add meet https://suitenumerique.github.io/meet/
$ helm repo update
$ helm install meet meet/meet -f examples/meet.values.yaml
$ helm install meet meet/meet -f examples/helm/meet.values.yaml
```
## Test your deployment
+3
View File
@@ -23,9 +23,12 @@ MEET_BASE_URL="http://localhost:8072"
# Media
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_DOMAIN_REPLACE=http://localhost:9000
AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
MEDIA_BASE_URL=http://localhost:8083
FILE_UPLOAD_ENABLED=True
# OIDC
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs
+35 -2
View File
@@ -197,6 +197,38 @@ def resend_notification(modeladmin, request, queryset): # pylint: disable=unuse
)
@admin.action(description=_("Mark selected recordings as 'Failed to Stop'"))
def mark_as_failed_to_stop(modeladmin, request, queryset):
"""Force selected recordings status to failed_to_stop."""
eligible_statuses = [
models.RecordingStatusChoices.ACTIVE,
models.RecordingStatusChoices.INITIATED,
models.RecordingStatusChoices.STOPPED,
]
eligible = queryset.filter(status__in=eligible_statuses)
skipped = queryset.exclude(status__in=eligible_statuses).count()
updated = eligible.update(status=models.RecordingStatusChoices.FAILED_TO_STOP)
if updated > 0:
modeladmin.message_user(
request,
_("%(count)s recording(s) successfully marked as 'Failed to Stop'.")
% {"count": updated},
level=messages.SUCCESS,
)
if skipped > 0:
modeladmin.message_user(
request,
_("Skipped %(count)s recording(s) with an ineligible status.")
% {"count": skipped},
level=messages.WARNING,
)
@admin.register(models.Recording)
class RecordingAdmin(admin.ModelAdmin):
"""Recording admin interface declaration."""
@@ -224,7 +256,7 @@ class RecordingAdmin(admin.ModelAdmin):
"updated_at",
"worker_id",
)
actions = [resend_notification]
actions = [resend_notification, mark_as_failed_to_stop]
def get_queryset(self, request):
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
@@ -276,7 +308,7 @@ class ApplicationAdmin(admin.ModelAdmin):
form = ApplicationAdminForm
list_display = ("id", "name", "client_id", "get_scopes_display")
list_display = ("id", "name", "client_id", "get_scopes_display", "is_active")
fields = [
"name",
"id",
@@ -285,6 +317,7 @@ class ApplicationAdmin(admin.ModelAdmin):
"scopes",
"client_id",
"client_secret",
"is_active",
]
readonly_fields = ["id", "created_at", "updated_at"]
inlines = [ApplicationDomainInline]
+15
View File
@@ -43,6 +43,21 @@ def get_frontend_configuration(request):
"expiration_days": settings.RECORDING_EXPIRATION_DAYS,
"max_duration": settings.RECORDING_MAX_DURATION,
},
"background_image": {
"upload_is_enabled": settings.FILE_UPLOAD_ENABLED,
"max_count_by_user": settings.FILE_UPLOAD_RESTRICTIONS["background_image"][
"max_count_by_user"
],
"max_size": settings.FILE_UPLOAD_RESTRICTIONS["background_image"][
"max_size"
],
"allowed_extensions": settings.FILE_UPLOAD_RESTRICTIONS["background_image"][
"allowed_extensions"
],
"allowed_mimetypes": settings.FILE_UPLOAD_RESTRICTIONS["background_image"][
"allowed_mimetypes"
],
},
"telephony": {
"enabled": settings.ROOM_TELEPHONY_ENABLED,
"phone_number": settings.ROOM_TELEPHONY_PHONE_NUMBER
+1
View File
@@ -13,6 +13,7 @@ class FeatureFlag:
"recording": "RECORDING_ENABLE",
"storage_event": "RECORDING_STORAGE_EVENT_ENABLE",
"subtitle": "ROOM_SUBTITLE_ENABLED",
"file_upload": "FILE_UPLOAD_ENABLED",
}
@classmethod
+67
View File
@@ -0,0 +1,67 @@
"""API filters for meet' core application."""
from django.utils.translation import gettext_lazy as _
import django_filters
from django_filters import BooleanFilter
from core import models
class FileFilter(django_filters.FilterSet):
"""
Custom filter for filtering files.
"""
class Meta:
model = models.File
fields = ["type"]
class ListFileFilter(FileFilter):
"""Filter class dedicated to the file viewset list method."""
is_creator_me = django_filters.BooleanFilter(
method="filter_is_creator_me", label=_("Creator is me")
)
is_deleted = BooleanFilter(field_name="deleted_at", method="filter_is_deleted")
class Meta:
model = models.File
fields = ["is_creator_me", "type", "upload_state", "is_deleted"]
def filter_is_deleted(self, queryset, name, value):
"""
Filter files based on whether they are deleted or not.
Example:
- /api/v1.0/files/?is_deleted=false
→ Filters files that were not deleted
"""
if value is None:
return queryset
lookup = "__".join([name, "isnull"])
return queryset.filter(**{lookup: not value})
# pylint: disable=unused-argument
def filter_is_creator_me(self, queryset, name, value):
"""
Filter files based on the `creator` being the current user.
Example:
- /api/v1.0/files/?is_creator_me=true
→ Filters files created by the logged-in user
- /api/v1.0/files/?is_creator_me=false
→ Filters files created by other users
"""
user = self.request.user
if not user.is_authenticated:
return queryset
if value:
return queryset.filter(creator=user)
return queryset.exclude(creator=user)
+30
View File
@@ -1,5 +1,8 @@
"""Permission handlers for the Meet core app."""
from django.conf import settings
from django.http import Http404
from rest_framework import permissions
from ..models import RoleChoices
@@ -106,3 +109,30 @@ class HasLiveKitRoomAccess(permissions.BasePermission):
if not request.auth or not hasattr(request.auth, "video"):
return False
return request.auth.video.room == str(obj.id)
class FilePermission(IsAuthenticated):
"""
Permissions applying to the file API endpoint.
Handling soft deletions specificities
"""
def has_permission(self, request, view):
"""Allow access only to authenticated users."""
if not settings.FILE_UPLOAD_ENABLED:
raise Http404
return super().has_permission(request, view)
def has_object_permission(self, request, view, obj):
"""
Return a 404 on deleted files or if the user is not the owner
"""
if obj.deleted_at is not None or obj.hard_deleted_at is not None:
raise Http404
if obj.creator != request.user:
raise Http404
return obj.get_abilities(request.user).get(view.action, False)
+156
View File
@@ -1,10 +1,15 @@
"""Client serializers for the Meet core app."""
# pylint: disable=abstract-method,no-name-in-module
import logging
from os.path import splitext
from typing import Literal
from urllib.parse import quote
from django.conf import settings
from django.core.exceptions import SuspiciousOperation
# pylint: disable=abstract-method,no-name-in-module
from django.utils.translation import gettext_lazy as _
from django_pydantic_field.rest_framework import SchemaField
@@ -15,6 +20,8 @@ from timezone_field.rest_framework import TimeZoneSerializerField
from core import models, utils
logger = logging.getLogger(__name__)
class UserSerializer(serializers.ModelSerializer):
"""Serialize users."""
@@ -27,6 +34,15 @@ class UserSerializer(serializers.ModelSerializer):
read_only_fields = ["id", "email", "full_name", "short_name"]
class UserLightSerializer(serializers.ModelSerializer):
"""Serialize users with limited fields."""
class Meta:
model = models.User
fields = ["id", "full_name", "short_name"]
read_only_fields = ["id", "full_name", "short_name"]
class ResourceAccessSerializerMixin:
"""
A serializer mixin to share controlling that the logged-in user submitting a room access object
@@ -377,3 +393,143 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
)
return attrs
class ListFileSerializer(serializers.ModelSerializer):
"""Serialize File model for the API."""
url = serializers.SerializerMethodField(read_only=True)
creator = UserLightSerializer(read_only=True)
abilities = serializers.SerializerMethodField(read_only=True)
class Meta:
model = models.File
fields = [
"id",
"created_at",
"updated_at",
"title",
"type",
"creator",
"deleted_at",
"hard_deleted_at",
"filename",
"upload_state",
"mimetype",
"size",
"description",
"url",
"abilities",
]
read_only_fields = [
"id",
"created_at",
"updated_at",
"creator",
"deleted_at",
"hard_deleted_at",
"filename",
"upload_state",
"mimetype",
"size",
"url",
"abilities",
]
def get_url(self, obj):
"""Return the URL of the file."""
if obj.is_pending_upload:
return None
return f"{settings.MEDIA_BASE_URL}{settings.MEDIA_URL}{quote(obj.file_key)}"
def get_abilities(self, file) -> dict:
"""Return abilities of the logged-in user on the instance."""
request = self.context.get("request")
if not request:
return {}
return file.get_abilities(request.user)
class FileSerializer(ListFileSerializer):
"""Default serializer File model for the API."""
def create(self, validated_data):
raise NotImplementedError("Create method can not be used.")
class CreateFileSerializer(ListFileSerializer):
"""Serializer used to create a new file"""
title = serializers.CharField(max_length=255, required=False)
policy = serializers.SerializerMethodField()
class Meta:
model = models.File
fields = [*ListFileSerializer.Meta.fields, "policy"]
read_only_fields = [
*(
field
for field in ListFileSerializer.Meta.read_only_fields
if field != "filename"
),
"policy",
]
def get_fields(self):
"""Force the id field to be writable."""
fields = super().get_fields()
fields["id"].read_only = False
return fields
def validate_id(self, value):
"""Ensure the provided ID does not already exist when creating a new file."""
request = self.context.get("request")
# Only check this on POST (creation)
if request and models.File.objects.filter(id=value).exists():
raise serializers.ValidationError(
"A file with this ID already exists. You cannot override it.",
code="file_create_existing_id",
)
return value
def validate(self, attrs):
"""Validate extension and fill title."""
# we run the default validation first to make sure the base data in attrs is ok
attrs = super().validate(attrs)
filename_root, ext = splitext(attrs["filename"])
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[attrs["type"]]
if ext.lower() not in config_for_file_type["allowed_extensions"]:
logger.info(
"create_item: file extension not allowed %s for filename %s",
ext,
attrs["filename"],
)
raise serializers.ValidationError(
{"filename": _("This file extension is not allowed.")},
code="item_create_file_extension_not_allowed",
)
# The title will be the filename if not provided
if not attrs.get("title", None):
attrs["title"] = filename_root
return attrs
def get_policy(self, file):
"""Return the policy to use if the item is a file."""
if file.upload_state == models.FileUploadStateChoices.READY:
return None
return utils.generate_upload_policy(file)
def update(self, instance, validated_data):
raise NotImplementedError("Update method can not be used.")
+50 -1
View File
@@ -1,7 +1,9 @@
"""Throttling modules for the API."""
from django.conf import settings
from lasuite.drf.throttling import MonitoredThrottleMixin
from rest_framework.throttling import AnonRateThrottle
from rest_framework.throttling import AnonRateThrottle, UserRateThrottle
from sentry_sdk import capture_message
@@ -14,11 +16,58 @@ class MonitoredAnonRateThrottle(MonitoredThrottleMixin, AnonRateThrottle):
"""Throttle for the monitored scoped rate throttle."""
class MonitoredUserRateThrottle(MonitoredThrottleMixin, UserRateThrottle):
"""Throttle for the monitored scoped rate throttle."""
class RequestEntryAuthenticatedUserRateThrottle(MonitoredUserRateThrottle):
"""Throttle authenticated user requesting room entry"""
scope = "request_entry"
def get_cache_key(self, request, view):
"""Use the authenticated user ID as the throttle cache key."""
if request.user and not request.user.is_authenticated:
return None # Defer to RequestEntryAnonRateThrottle for anonymous users.
return super().get_cache_key(request, view)
class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle Anonymous user requesting room entry"""
scope = "request_entry"
def get_cache_key(self, request, view):
"""Use the lobby participant cookie ID as the throttle cache key.
Only throttle if a cookie is already set. If no cookie exists yet,
return None to skip throttling — the cookie will be set on the first
response, and throttling will apply from the second request onward.
Keying on the cookie rather than the IP address prevents penalising
multiple users behind the same NAT/proxy, and is consistent with how
LobbyService identifies participants.
Note: as per DRF documentation, application-level throttling is not a
security measure against brute-force or DoS attacks. This throttle exists
solely to guard against accidental hammering from buggy clients.
"""
if request.user and request.user.is_authenticated:
return None # Only throttle unauthenticated requests.
participant_id = request.COOKIES.get(settings.LOBBY_COOKIE_NAME)
if participant_id is None:
return None # No throttling for cookieless requests
return self.cache_format % {
"scope": self.scope,
"ident": participant_id,
}
class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
"""Throttle Anonymous user requesting room generation callback"""
+360 -18
View File
@@ -1,16 +1,26 @@
"""API endpoints"""
# pylint: disable=too-many-lines
import uuid
from logging import getLogger
from urllib.parse import urlparse
from urllib.parse import unquote, urlparse
from django.conf import settings
from django.core.files.storage import default_storage
from django.db.models import Q
from django.http import Http404
from django.shortcuts import get_object_or_404
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from rest_framework import decorators, mixins, pagination, viewsets
from django_filters import rest_framework as django_filters
from rest_framework import (
decorators,
filters,
mixins,
pagination,
viewsets,
)
from rest_framework import (
exceptions as drf_exceptions,
)
@@ -22,10 +32,13 @@ from rest_framework import (
)
from core import enums, models, utils
from core.api.filters import ListFileFilter
from core.enums import MEDIA_STORAGE_URL_PATTERN
from core.recording.enums import FileExtension
from core.recording.event.authentication import StorageEventAuthentication
from core.recording.event.exceptions import (
InvalidBucketError,
InvalidFilepathError,
InvalidFileTypeError,
ParsingEventDataError,
)
@@ -56,6 +69,7 @@ from core.services.participants_management import (
)
from core.services.room_creation import RoomCreation
from core.services.subtitle import SubtitleException, SubtitleService
from core.tasks.file import process_file_deletion
from ..authentication.livekit import LiveKitTokenAuthentication
from . import permissions, serializers, throttling
@@ -77,20 +91,20 @@ class NestedGenericViewSet(viewsets.GenericViewSet):
lookup_fields: list[str] = ["pk"]
lookup_url_kwargs: list[str] = []
def __getattribute__(self, item):
def __getattribute__(self, file):
"""
This method is overridden to allow to get the last lookup field or lookup url kwarg
when accessing the `lookup_field` or `lookup_url_kwarg` attribute. This is useful
to keep compatibility with all methods used by the parent class `GenericViewSet`.
"""
if item in ["lookup_field", "lookup_url_kwarg"]:
return getattr(self, item + "s", [None])[-1]
if file in ["lookup_field", "lookup_url_kwarg"]:
return getattr(self, file + "s", [None])[-1]
return super().__getattribute__(item)
return super().__getattribute__(file)
def get_queryset(self):
"""
Get the list of items for this view.
Get the list of files for this view.
`lookup_fields` attribute is enumerated here to perform the nested lookup.
"""
@@ -201,6 +215,7 @@ class RoomViewSet(
API endpoints to access and perform actions on rooms.
"""
pagination_class = Pagination
permission_classes = [permissions.RoomPermissions]
queryset = models.Room.objects.all()
serializer_class = serializers.RoomSerializer
@@ -369,7 +384,10 @@ class RoomViewSet(
methods=["post"],
url_path="request-entry",
permission_classes=[],
throttle_classes=[throttling.RequestEntryAnonRateThrottle],
throttle_classes=[
throttling.RequestEntryAuthenticatedUserRateThrottle,
throttling.RequestEntryAnonRateThrottle,
],
)
def request_entry(self, request, pk=None): # pylint: disable=unused-argument
"""Request entry to a room"""
@@ -470,9 +488,7 @@ class RoomViewSet(
if status_code == drf_status.HTTP_500_INTERNAL_SERVER_ERROR:
raise e
return drf_response.Response(
{"status": "error", "message": str(e)}, status=status_code
)
return drf_response.Response({"status": "error"}, status=status_code)
@decorators.action(
detail=False,
@@ -739,14 +755,19 @@ class RecordingViewSet(
recording_id = parser.get_recording_id(request.data)
except ParsingEventDataError as e:
raise drf_exceptions.PermissionDenied(f"Invalid request data: {e}") from e
raise drf_exceptions.PermissionDenied("Invalid request data.") from e
except InvalidBucketError as e:
raise drf_exceptions.PermissionDenied("Invalid bucket specified") from e
raise drf_exceptions.PermissionDenied("Invalid bucket specified.") from e
except InvalidFileTypeError as e:
except InvalidFilepathError:
return drf_response.Response(
{"message": f"Ignore this file type, {e}"},
{"message": "Notification ignored."},
)
except InvalidFileTypeError:
return drf_response.Response(
{"message": "Notification ignored."},
)
try:
@@ -793,7 +814,7 @@ class RecordingViewSet(
# Extract the original URL from the request header
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
if not original_url:
logger.debug("Missing HTTP_X_ORIGINAL_URL header in subrequest")
logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest")
raise drf_exceptions.PermissionDenied()
logger.debug("Original url: '%s'", original_url)
@@ -810,7 +831,7 @@ class RecordingViewSet(
try:
return match.groupdict()
except (ValueError, AttributeError) as exc:
logger.debug("Failed to extract parameters from subrequest URL: %s", exc)
logger.warning("Failed to extract parameters from subrequest URL: %s", exc)
raise drf_exceptions.PermissionDenied() from exc
@decorators.action(detail=False, methods=["get"], url_path="media-auth")
@@ -834,7 +855,7 @@ class RecordingViewSet(
recording_id = url_params["recording_id"]
extension = url_params["extension"]
if extension not in [item.value for item in FileExtension]:
if extension not in [file.value for file in FileExtension]:
raise drf_exceptions.ValidationError({"detail": "Unsupported extension."})
try:
@@ -858,3 +879,324 @@ class RecordingViewSet(
request = utils.generate_s3_authorization_headers(recording.key)
return drf_response.Response("authorized", headers=request.headers, status=200)
# pylint: disable=too-many-public-methods
class FileViewSet(
SerializerPerActionMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.UpdateModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet,
):
"""
FileViewSet API.
This viewset provides CRUD operations and additional actions for managing files.
### API Endpoints:
1. **List**: Retrieve a paginated list of files.
Example: GET /files/?page=2
2. **Retrieve**: Get a specific file by its ID.
Example: GET /files/{id}/
3. **Create**: Create a new file.
Example: POST /files/
4. **Update**: Update a file by its ID.
Example: PUT /files/{id}/
5. **Delete**: Soft delete a file by its ID.
Example: DELETE /files/{id}/
### Ordering: created_at, updated_at, title
Example:
- Ascending: GET /api/v1.0/files/?ordering=created_at
### Filtering:
- `is_creator_me=true`: Returns files created by the current user.
- `is_creator_me=false`: Returns files created by other users.
- `is_deleted=false`: Returns files that are not (soft) deleted
Example:
- GET /api/v1.0/files/?is_creator_me=true
- GET /api/v1.0/files/?is_creator_me=false&is_deleted=false
### Notes:
- Implements soft delete logic to retain file
"""
ordering = ["-updated_at"]
ordering_fields = ["created_at", "updated_at", "title"]
pagination_class = Pagination
permission_classes = [
permissions.FilePermission,
]
queryset = models.File.objects.filter(hard_deleted_at__isnull=True)
default_serializer_class = serializers.FileSerializer
serializer_classes = {
"list": serializers.ListFileSerializer,
"create": serializers.CreateFileSerializer,
}
filter_backends = (django_filters.DjangoFilterBackend, filters.OrderingFilter)
filterset_class = ListFileFilter
def get_queryset(self):
"""Get queryset that defaults to the current request user."""
user = self.request.user
queryset = super().get_queryset().select_related("creator")
if not user.is_authenticated:
return queryset.none()
# For now, we force the filtering on the current user in all cases, might evolve later
queryset = queryset.filter(creator=user)
return queryset
def get_response_for_queryset(self, queryset, context=None):
"""Return paginated response for the queryset if requested."""
context = context or self.get_serializer_context()
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True, context=context)
result = self.get_paginated_response(serializer.data)
return result
serializer = self.get_serializer(queryset, many=True, context=context)
return drf_response.Response(serializer.data)
def perform_create(self, serializer):
"""Set the current user as creator of the newly created file."""
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
file_type = serializer.validated_data["type"]
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[file_type]
count = models.File.objects.filter(
creator=self.request.user,
deleted_at__isnull=True,
type=file_type,
).count()
if count >= config_for_file_type["max_count_by_user"]:
logger.info(
"create_item: user reached max files per user for type %s",
file_type,
)
raise serializers.PermissionDenied(
_("You have reached the maximum number of files for this type.")
)
serializer.save(creator=self.request.user)
def perform_destroy(self, instance):
"""Override to implement a soft delete instead of dumping the record in database."""
instance.soft_delete()
@decorators.action(detail=True, methods=["post"], url_path="upload-ended")
@FeatureFlag.require("file_upload")
def upload_ended(self, request, *args, **kwargs):
"""
Check the actual uploaded file and mark it as ready.
"""
file = self.get_object()
if not file.is_pending_upload:
raise drf_exceptions.ValidationError(
{"file": "This action is only available for files in PENDING state."},
code="file_upload_state_not_pending",
)
s3_client = default_storage.connection.meta.client
head_response = s3_client.head_object(
Bucket=default_storage.bucket_name, Key=file.file_key
)
file_size = head_response["ContentLength"]
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[file.type]
if file_size > config_for_file_type["max_size"]:
self._complete_file_deletion(file)
logger.info(
"upload_ended: file size (%s) for file %s higher than the allowed max size",
file_size,
file.file_key,
)
raise drf_exceptions.ValidationError(
detail="The file size is higher than the allowed max size.",
code="file_size_exceeded",
)
# python-magic recommends using at least the first 2048 bytes
# to reduce incorrect identification.
# This is a tradeoff between pulling in the whole file and the most likely relevant bytes
# of the file for mime type identification.
if file_size > 2048:
range_response = s3_client.get_object(
Bucket=default_storage.bucket_name,
Key=file.file_key,
Range="bytes=0-2047",
)
file_head = range_response["Body"].read()
else:
file_head = s3_client.get_object(
Bucket=default_storage.bucket_name, Key=file.file_key
)["Body"].read()
# Use improved MIME type detection combining magic bytes and file extension
logger.info("upload_ended: detecting mimetype for file: %s", file.file_key)
mimetype = utils.detect_mimetype(file_head, filename=file.filename)
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[file.type]
allowed_file_mimetypes = config_for_file_type["allowed_mimetypes"]
if mimetype not in allowed_file_mimetypes:
self._complete_file_deletion(file)
logger.warning(
"upload_ended: mimetype not allowed %s for file %s",
mimetype,
file.file_key,
)
raise drf_exceptions.ValidationError(
detail="The file type is not allowed.",
code="file_type_not_allowed",
)
file.upload_state = models.FileUploadStateChoices.READY
file.mimetype = mimetype
file.size = file_size
file.save(update_fields=["upload_state", "mimetype", "size"])
if head_response["ContentType"] != mimetype:
logger.info(
"upload_ended: content type mismatch between object storage and file,"
" updating from %s to %s",
head_response["ContentType"],
mimetype,
)
s3_client.copy_object(
Bucket=default_storage.bucket_name,
Key=file.file_key,
CopySource={
"Bucket": default_storage.bucket_name,
"Key": file.file_key,
},
ContentType=mimetype,
Metadata=head_response["Metadata"],
MetadataDirective="REPLACE",
)
# Not yet implemented
# Change the file.upload_state when this will be done
# malware_detection.analyse_file(file.file_key, file_id=file.id)
serializer = self.get_serializer(file)
return drf_response.Response(serializer.data, status=drf_status.HTTP_200_OK)
def _complete_file_deletion(self, file):
"""Delete a file completely."""
file.soft_delete()
file.hard_delete()
process_file_deletion.delay(file.id)
def _authorize_subrequest(self, request, pattern):
"""
Authorize access based on the original URL of an Nginx subrequest
and user permissions. Returns a dictionary of URL parameters if authorized.
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
See corresponding ingress configuration in Helm chart and read about the
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
is configured to do this.
Based on the original url and the logged in user, we must decide if we authorize Nginx
to let this request go through (by returning a 200 code) or if we block it (by returning
a 403 error). Note that we return 403 errors without any further details for security
reasons.
Parameters:
- pattern: The regex pattern to extract identifiers from the URL.
Returns:
- A dictionary of URL parameters if the request is authorized.
Raises:
- PermissionDenied if authorization fails.
"""
# Extract the original URL from the request header
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
if not original_url:
logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest")
raise drf_exceptions.PermissionDenied()
parsed_url = urlparse(original_url)
match = pattern.search(unquote(parsed_url.path))
if not match:
logger.warning(
"Subrequest URL '%s' did not match pattern '%s'",
parsed_url.path,
pattern,
)
raise drf_exceptions.PermissionDenied()
try:
url_params = match.groupdict()
except (ValueError, AttributeError) as exc:
logger.warning("Failed to extract parameters from subrequest URL: %s", exc)
raise drf_exceptions.PermissionDenied() from exc
pk = url_params.get("pk")
if not pk:
logger.warning("File ID (pk) not found in URL parameters: %s", url_params)
raise drf_exceptions.PermissionDenied()
# Fetch the file and check if the user has access
queryset = models.File.objects.all()
# No suspicious analysis implemented yet
# queryset = self._filter_suspicious_files(queryset, request.user)
try:
file = queryset.get(pk=pk)
except models.File.DoesNotExist as exc:
logger.warning("File with ID '%s' does not exist", pk)
raise drf_exceptions.PermissionDenied() from exc
user_abilities = file.get_abilities(request.user)
if not user_abilities.get(self.action, False):
logger.warning(
"User '%s' lacks permission for file '%s'", request.user.id, pk
)
raise drf_exceptions.PermissionDenied()
logger.debug(
"Subrequest authorization successful. Extracted parameters: %s", url_params
)
return url_params, request.user.id, file
@decorators.action(detail=False, methods=["get"], url_path="media-auth")
@FeatureFlag.require("file_upload")
def media_auth(self, request, *args, **kwargs):
"""
This view is used by an Nginx subrequest to control access to an file's
attachment file.
When we let the request go through, we compute authorization headers that will be added to
the request going through thanks to the nginx.ingress.kubernetes.io/auth-response-headers
annotation. The request will then be proxied to the object storage backend who will
respond with the file after checking the signature included in headers.
"""
url_params, _, file = self._authorize_subrequest(
request, MEDIA_STORAGE_URL_PATTERN
)
if file.is_pending_upload:
logger.warning("File '%s' is not ready", file.id)
raise drf_exceptions.PermissionDenied()
# Generate S3 authorization headers using the extracted URL parameters
request = utils.generate_s3_authorization_headers(f"{url_params.get('key'):s}")
return drf_response.Response("authorized", headers=request.headers, status=200)
+7 -1
View File
@@ -14,9 +14,15 @@ FILE_EXT_REGEX = r"[a-zA-Z0-9]{1,10}"
# pylint: disable=line-too-long
RECORDING_STORAGE_URL_PATTERN = re.compile(
f"/media/{settings.RECORDING_OUTPUT_FOLDER}/(?P<recording_id>{UUID_REGEX:s}).(?P<extension>{FILE_EXT_REGEX:s})"
rf"{settings.MEDIA_URL:s}{settings.RECORDING_OUTPUT_FOLDER}/(?P<recording_id>{UUID_REGEX:s})\.(?P<extension>{FILE_EXT_REGEX:s})"
)
MEDIA_STORAGE_URL_PATTERN = re.compile(
f"{settings.MEDIA_URL:s}"
rf"(?P<key>{settings.FILE_UPLOAD_PATH:s}/(?P<pk>{UUID_REGEX:s})\.{FILE_EXT_REGEX:s})$"
)
# Django sets `LANGUAGES` by default with all supported languages. We can use it for
# the choice of languages which should not be limited to the few languages active in
# the app.
@@ -203,7 +203,7 @@ class ApplicationJWTAuthentication(BaseJWTAuthentication):
logger.warning("Application not found: %s", client_id)
raise exceptions.AuthenticationFailed("Application not found.") from e
if not application.active:
if not application.is_active:
logger.warning(
"Inactive application attempted authentication: %s", client_id
)
+3 -3
View File
@@ -61,12 +61,12 @@ class ApplicationViewSet(viewsets.ViewSet):
except models.Application.DoesNotExist as e:
raise drf_exceptions.AuthenticationFailed("Invalid credentials") from e
if not application.active:
raise drf_exceptions.AuthenticationFailed("Application is inactive")
if not check_password(client_secret, application.client_secret):
raise drf_exceptions.AuthenticationFailed("Invalid credentials")
if not application.is_active:
raise drf_exceptions.AuthenticationFailed("Application is inactive")
email = serializer.validated_data["scope"]
try:
validate_email(email)
+43 -1
View File
@@ -2,8 +2,11 @@
Core application factories
"""
from io import BytesIO
from django.conf import settings
from django.contrib.auth.hashers import make_password
from django.core.files.storage import default_storage
from django.utils.text import slugify
import factory.fuzzy
@@ -126,7 +129,7 @@ class ApplicationFactory(factory.django.DjangoModelFactory):
model = models.Application
name = factory.Faker("company")
active = True
is_active = True
client_id = factory.LazyFunction(utils.generate_client_id)
client_secret = factory.LazyFunction(utils.generate_client_secret)
scopes = []
@@ -153,3 +156,42 @@ class ApplicationDomainFactory(factory.django.DjangoModelFactory):
domain = factory.Faker("domain_name")
application = factory.SubFactory(ApplicationFactory)
class FileFactory(factory.django.DjangoModelFactory):
"""A factory to create files"""
class Meta:
model = models.File
skip_postgeneration_save = True
title = factory.Sequence(lambda n: f"file{n}")
creator = factory.SubFactory(UserFactory)
deleted_at = None
type = factory.fuzzy.FuzzyChoice([t[0] for t in models.FileTypeChoices.choices])
filename = factory.lazy_attribute(lambda o: fake.file_name())
upload_state = None
size = None
@factory.post_generation
def update_upload_state(self, create, extracted, **kwargs):
"""Change the upload state of a file."""
if create and extracted:
self.upload_state = extracted
self.save()
@factory.post_generation
def upload_bytes(self, create, extracted, **kwargs):
"""Save content of the file into the storage"""
if create and extracted is not None:
content = (
extracted
if isinstance(extracted, bytes)
else str(extracted).encode("utf-8")
)
self.filename = kwargs.get("filename", self.filename or "content.txt")
self.size = len(content)
self.save()
default_storage.save(self.file_key, BytesIO(content))
+42
View File
@@ -0,0 +1,42 @@
# Generated by Django 5.2.11 on 2026-03-03 15:22
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0016_recording_options'),
]
operations = [
migrations.CreateModel(
name='File',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
('type', models.CharField(choices=[('background_image', 'Background image')], max_length=25)),
('title', models.CharField(max_length=255, verbose_name='title')),
('deleted_at', models.DateTimeField(blank=True, null=True)),
('hard_deleted_at', models.DateTimeField(blank=True, null=True)),
('filename', models.CharField(max_length=255)),
('upload_state', models.CharField(choices=[('pending', 'Pending'), ('ready', 'Ready')], max_length=25)),
('mimetype', models.CharField(blank=True, max_length=255, null=True)),
('size', models.BigIntegerField(blank=True, null=True)),
('description', models.TextField(blank=True, null=True)),
('malware_detection_info', models.JSONField(blank=True, default=dict, help_text='Malware detection info when the analysis status is unsafe.', null=True)),
('creator', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='files_created', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'File',
'verbose_name_plural': 'Files',
'db_table': 'file',
'ordering': ('created_at',),
'indexes': [models.Index(fields=['creator', 'type', '-created_at'], name='file_creator_730cce_idx')],
},
),
]
@@ -0,0 +1,18 @@
# Generated by Django 5.2.12 on 2026-03-11 14:39
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('core', '0017_file'),
]
operations = [
migrations.RenameField(
model_name='application',
old_name='active',
new_name='is_active',
),
]
+188 -2
View File
@@ -1,11 +1,14 @@
"""
Declare and configure the models for the Meet core application
# pylint: disable=too-many-lines
"""
# pylint: disable=too-many-lines
import secrets
import uuid
from datetime import datetime, timedelta
from logging import getLogger
from os.path import splitext
from typing import List, Optional
from django.conf import settings
@@ -14,7 +17,7 @@ from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.postgres.fields import ArrayField
from django.core import mail, validators
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import models
from django.db import models, transaction
from django.utils import timezone
from django.utils.text import capfirst, slugify
from django.utils.translation import gettext_lazy as _
@@ -756,7 +759,7 @@ class Application(BaseModel):
verbose_name=_("Application name"),
help_text=_("Descriptive name for this application."),
)
active = models.BooleanField(default=True)
is_active = models.BooleanField(default=True)
client_id = models.CharField(
max_length=100, unique=True, default=utils.generate_client_id
)
@@ -829,3 +832,186 @@ class ApplicationDomain(BaseModel):
self.domain = self.domain.lower().strip()
super().save(*args, **kwargs)
class FileUploadStateChoices(models.TextChoices):
"""Possible states of a file."""
PENDING = "pending", _("Pending")
# Commented out for now, as we may need this when we implement the malware detection logic.
# ANALYZING = "analyzing", _("Analyzing")
# SUSPICIOUS = "suspicious", _("Suspicious")
# FILE_TOO_LARGE_TO_ANALYZE = (
# "file_too_large_to_analyze",
# _("File too large to analyze"),
# )
READY = "ready", _("Ready")
class FileTypeChoices(models.TextChoices):
"""Defines the possible types of a file."""
BACKGROUND_IMAGE = "background_image", _("Background image")
class File(BaseModel):
"""File uploaded by a user."""
type = models.CharField(
max_length=25,
choices=FileTypeChoices.choices,
null=False,
blank=False,
)
title = models.CharField(_("title"), max_length=255)
creator = models.ForeignKey(
User,
on_delete=models.RESTRICT,
related_name="files_created",
blank=True,
null=True,
)
deleted_at = models.DateTimeField(null=True, blank=True)
hard_deleted_at = models.DateTimeField(null=True, blank=True)
filename = models.CharField(max_length=255, null=False, blank=False)
upload_state = models.CharField(
max_length=25,
choices=FileUploadStateChoices.choices,
)
mimetype = models.CharField(max_length=255, null=True, blank=True)
size = models.BigIntegerField(null=True, blank=True)
description = models.TextField(null=True, blank=True)
malware_detection_info = models.JSONField(
null=True,
blank=True,
default=dict,
help_text=_("Malware detection info when the analysis status is unsafe."),
)
class Meta:
db_table = "file"
verbose_name = _("File")
verbose_name_plural = _("Files")
ordering = ("created_at",)
indexes = [
models.Index(fields=["creator", "type", "-created_at"]),
]
def __str__(self):
return str(self.title)
def save(self, *args, **kwargs):
"""Set the upload state to pending if it's the first save and it's a file."""
if self.created_at is None:
self.upload_state = FileUploadStateChoices.PENDING
return super().save(*args, **kwargs)
def delete(self, using=None, keep_parents=False):
if self.deleted_at is None:
raise RuntimeError("The file must be soft deleted before being deleted.")
return super().delete(using, keep_parents)
@property
def is_pending_upload(self):
"""Return whether the file is in a pending upload state"""
return self.upload_state == FileUploadStateChoices.PENDING
@property
def extension(self):
"""Return the extension related to the filename."""
if self.filename is None:
raise RuntimeError(
"The file must have a filename to compute its extension."
)
_, extension = splitext(self.filename)
if extension:
return extension.lstrip(".")
return None
@property
def key_base(self):
"""Key base of the location where the file is stored in object storage."""
if not self.pk:
raise RuntimeError(
"The file instance must be saved before requesting a storage key."
)
return f"{settings.FILE_UPLOAD_PATH}/{self.pk!s}"
@property
def file_key(self):
"""Key used to store the file in object storage."""
_, extension = splitext(self.filename)
# We store only the extension in the storage system to avoid
# leaking Personal Information in logs, etc.
return f"{self.key_base}{extension!s}"
def get_abilities(self, user):
"""
Compute and return abilities for a given user on the file.
"""
# Characteristics that are based only on specific access
is_creator = user == self.creator
retrieve = is_creator
is_deleted = self.deleted_at is not None
can_update = is_creator and not is_deleted and user.is_authenticated
can_hard_delete = is_creator and user.is_authenticated
can_destroy = can_hard_delete and not is_deleted
return {
"destroy": can_destroy,
"hard_delete": can_hard_delete,
"retrieve": retrieve,
"media_auth": retrieve and not is_deleted,
"partial_update": can_update,
"update": can_update,
"upload_ended": can_update and user.is_authenticated,
}
@transaction.atomic
def soft_delete(self):
"""
Soft delete the file.
We still keep the .delete() method untouched for programmatic purposes.
"""
if self.deleted_at:
raise RuntimeError("This file is already deleted.")
self.deleted_at = timezone.now()
self.save(update_fields=["deleted_at"])
def hard_delete(self):
"""
Hard delete the file.
We still keep the .delete() method untouched for programmatic purposes.
"""
if self.hard_deleted_at:
raise ValidationError(
{
"hard_deleted_at": ValidationError(
_("This file is already hard deleted."),
code="file_hard_delete_already_effective",
)
}
)
if self.deleted_at is None:
raise ValidationError(
{
"hard_deleted_at": ValidationError(
_("To hard delete a file, it must first be soft deleted."),
code="file_hard_delete_should_soft_delete_first",
)
}
)
self.hard_deleted_at = timezone.now()
self.save(update_fields=["hard_deleted_at"])
+3 -1
View File
@@ -9,6 +9,8 @@ from typing import Any, Dict, Optional, Protocol
from django.conf import settings
from django.utils.module_loading import import_string
from core.enums import FILE_EXT_REGEX, UUID_REGEX
from .exceptions import (
InvalidBucketError,
InvalidFilepathError,
@@ -86,7 +88,7 @@ class MinioParser:
# pylint: disable=line-too-long
self._filepath_regex = re.compile(
r"(?P<url_encoded_folder_path>(?:[^%]+%2F)+)?(?P<recording_id>[0-9a-fA-F\-]{36})\.(?P<extension>[a-zA-Z0-9]+)"
rf"(?P<url_encoded_folder_path>(?:[^%]+%2F)+)?{settings.RECORDING_OUTPUT_FOLDER}%2F(?P<recording_id>{UUID_REGEX})\.(?P<extension>{FILE_EXT_REGEX})"
)
@staticmethod
@@ -16,7 +16,7 @@ class RecordingEventsError(Exception):
class RecordingEventsService:
"""Handles recording-related Livekit webhook events."""
"""Handles recording-related LiveKit webhook events."""
@staticmethod
def handle_update(recording, egress_status):
+1 -1
View File
@@ -129,7 +129,7 @@ class LobbyService:
) -> Tuple[LobbyParticipant, Optional[Dict]]:
"""Request entry to a room for a participant.
This usual status transitions is:
The usual status transitions are:
UNKNOWN -> WAITING -> (ACCEPTED | DENIED)
Flow:
+49
View File
@@ -0,0 +1,49 @@
# ruff: noqa: PLC0415
from django.conf import settings
def task(*d_args, **d_kwargs):
"""
Decorator compatible with Celery's @app.task, but works without Celery.
If Celery is available, returns a real Celery task.
If not, returns the original function and provides `.delay()`/`.apply_async()`
as synchronous fallbacks (so existing call sites don't break).
Notes:
Mostly LLM-generated.
"""
def _fallback_wrap(func):
def delay(*args, **kwargs):
return func(*args, **kwargs)
def apply_async(args=None, kwargs=None, **_options):
return func(*(args or ()), **(kwargs or {}))
func.delay = delay
func.apply_async = apply_async
return func
# Handle bare decorator usage: @task
if len(d_args) == 1 and callable(d_args[0]) and not d_kwargs:
func = d_args[0]
if settings.CELERY_ENABLED:
from meet.celery_app import app as _celery_app
return _celery_app.task(func)
return _fallback_wrap(func)
# Handle parameterized usage: @task(...), e.g. @task(bind=True)
def _decorate(func):
if settings.CELERY_ENABLED:
from meet.celery_app import app as _celery_app
return _celery_app.task(*d_args, **d_kwargs)(func)
return _fallback_wrap(func)
return _decorate
__all__ = ("task",)
+36
View File
@@ -0,0 +1,36 @@
"""
Tasks related to files.
"""
import logging
from django.core.files.storage import default_storage
from core.models import File
from core.tasks._task import task
logger = logging.getLogger(__name__)
@task
def process_file_deletion(file_id):
"""
Process the deletion of a file.
Definitely delete it in the database.
Delete the files from the storage.
"""
logger.info("Processing item deletion for %s", file_id)
try:
file = File.objects.get(id=file_id)
except File.DoesNotExist:
logger.error("Item %s does not exist", file_id)
return
if file.hard_deleted_at is None:
logger.error("To process an item deletion, it must be hard deleted first.")
return
logger.info("Deleting file %s", file.file_key)
default_storage.delete(file.file_key)
file.delete()
+1 -1
View File
@@ -1,4 +1,4 @@
"""Custom template tags for the core application of People."""
"""Custom template tags for the core application of Meet."""
import base64
@@ -0,0 +1,385 @@
"""
Tests for files API endpoint in meet's core app: create
"""
from concurrent.futures import ThreadPoolExecutor
from urllib.parse import parse_qs, urlparse
from uuid import uuid4
from django.utils import timezone
import pytest
from freezegun import freeze_time
from rest_framework import status
from rest_framework.test import APIClient
from core import factories
from core.models import File, FileTypeChoices, FileUploadStateChoices
pytestmark = pytest.mark.django_db
def test_api_files_create_anonymous():
"""Anonymous users should not be allowed to create items."""
response = APIClient().post(
"/api/v1.0/files/",
{
"title": "My file",
"type": FileTypeChoices.BACKGROUND_IMAGE,
},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert not File.objects.exists()
def test_api_files_create_authenticated_success():
"""
Authenticated users should be able to create files and should automatically be declared
as the owner of the newly created file.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/files/",
{
"title": "my file",
"filename": "my_file.png",
"type": FileTypeChoices.BACKGROUND_IMAGE,
},
format="json",
)
assert response.status_code == 201, response.json()
file = File.objects.get()
assert file.title == "my file"
assert file.creator == user
assert file.type == FileTypeChoices.BACKGROUND_IMAGE
assert file.upload_state == FileUploadStateChoices.PENDING
def test_api_files_create_file_authenticated_no_filename():
"""
Creating a file item without providing a filename should fail.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/files/",
{
"title": "my item",
"type": FileTypeChoices.BACKGROUND_IMAGE,
},
format="json",
)
assert response.status_code == 400
assert response.json() == {"filename": ["This field is required."]}
def test_api_files_create_file_authenticated_success():
"""
Authenticated users should be able to create a file file and must provide a filename.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
now = timezone.now()
with freeze_time(now):
response = client.post(
"/api/v1.0/files/",
{
"type": FileTypeChoices.BACKGROUND_IMAGE,
"title": "Eiffle tower",
"filename": "file.png",
},
format="json",
)
assert response.status_code == 201
file = File.objects.get()
assert file.title == "Eiffle tower"
assert file.type == FileTypeChoices.BACKGROUND_IMAGE
assert file.filename == "file.png"
response_data = response.json()
assert response_data["creator"] is not None, response_data
assert response.json().get("policy") is not None
policy = response.json()["policy"]
policy_parsed = urlparse(policy)
assert policy_parsed.scheme == "http"
assert policy_parsed.netloc == "localhost:9000"
assert policy_parsed.path == f"/meet-media-storage/files/{file.id!s}.png"
query_params = parse_qs(policy_parsed.query)
assert query_params.pop("X-Amz-Algorithm") == ["AWS4-HMAC-SHA256"]
assert query_params.pop("X-Amz-Credential") == [
f"meet/{now.strftime('%Y%m%d')}/us-east-1/s3/aws4_request"
]
assert query_params.pop("X-Amz-Date") == [now.strftime("%Y%m%dT%H%M%SZ")]
assert query_params.pop("X-Amz-Expires") == ["60"]
assert query_params.pop("X-Amz-SignedHeaders") == ["host;x-amz-acl"]
assert query_params.pop("X-Amz-Signature") is not None
assert len(query_params) == 0
def test_api_files_create_file_authenticated_extension_not_allowed():
"""
Creating a file item with an extension not allowed should fail.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/files/",
{
"type": FileTypeChoices.BACKGROUND_IMAGE,
"title": "Paris tower",
"filename": "file.notallowed",
},
format="json",
)
assert response.status_code == 400
assert response.json() == {"filename": ["This file extension is not allowed."]}
def test_api_files_create_file_authenticated_extension_case_insensitive():
"""
Creating a file item with an extension, no matter the case used, should be allowed.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/files/",
{
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "file.JPG",
},
format="json",
)
assert response.status_code == 201, response.json()
file = File.objects.get()
assert file.title == "file"
def test_api_files_create_file_disabled(settings):
"""
Creating a file is denied if file upload is disabled
"""
settings.FILE_UPLOAD_ENABLED = False
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/files/",
{
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "file.JPG",
},
format="json",
)
assert response.status_code == 404
assert not File.objects.exists()
def test_api_files_create_file_authenticated_not_checking_extension(settings):
"""
Creating a file with an extension not allowed should not fail when restrictions are disabled.
"""
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = False
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/files/",
{
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "file.notallowed",
},
format="json",
)
assert response.status_code == 201, response.json()
file = File.objects.get()
assert file.title == "file"
def test_api_files_create_file_authenticated_no_extension_but_checking_it_should_fail(
settings,
):
"""
Creating a file without an extension but checking the extension should fail.
"""
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = True
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/files/",
{
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "file",
},
format="json",
)
assert response.status_code == 400
assert response.json() == {"filename": ["This file extension is not allowed."]}
def test_api_files_create_file_authenticated_hidden_file_but_checking_extension_should_fail(
settings,
):
"""
Creating a hidden file (starting with a dot) but checking the extension should fail.
"""
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = True
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/files/",
{
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": ".file",
},
)
assert response.status_code == 400
assert response.json() == {"filename": ["This file extension is not allowed."]}
def test_api_files_create_file_too_many(
settings,
):
"""
Creating a file is forbidden if above user limit.
"""
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = True
settings.FILE_UPLOAD_RESTRICTIONS = {
"background_image": {
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
"max_count_by_user": 1,
},
}
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/files/",
{
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "1.png",
},
)
assert response.status_code == 201
response = client.post(
"/api/v1.0/files/",
{
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "2.png",
},
)
assert response.status_code == 403
assert response.json() == {
"detail": "You have reached the maximum number of files for this type."
}
assert File.objects.count() == 1
def test_api_files_create_force_id_success():
"""It should be possible to force the item ID when creating a item."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
forced_id = uuid4()
response = client.post(
"/api/v1.0/files/",
{
"id": str(forced_id),
"title": "my item",
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "my_file.png",
},
format="json",
)
assert response.status_code == 201, response.json()
files = File.objects.all()
assert len(files) == 1
assert files[0].id == forced_id
def test_api_files_create_force_id_existing():
"""
It should not be possible to use the ID of an existing file when forcing ID on creation.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory()
response = client.post(
"/api/v1.0/files/",
{
"id": str(file.id),
"title": "my file",
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "my_file.png",
},
format="json",
)
assert response.status_code == 400
assert response.json() == {
"id": ["A file with this ID already exists. You cannot override it."]
}
@pytest.mark.django_db(transaction=True)
def test_api_files_create_file_race_condition():
"""
It should be possible to create several files at the same time
without causing any race conditions or data integrity issues.
"""
def create_item(title):
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
return client.post(
"/api/v1.0/files/",
{
"title": title,
"type": FileTypeChoices.BACKGROUND_IMAGE,
"filename": "my_file.png",
},
format="json",
)
with ThreadPoolExecutor(max_workers=2) as executor:
future1 = executor.submit(create_item, "my item 1")
future2 = executor.submit(create_item, "my item 2")
response1 = future1.result()
response2 = future2.result()
assert response1.status_code == 201
assert response2.status_code == 201
@@ -0,0 +1,45 @@
"""
Tests for files API endpoint in meet's core app: delete
"""
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def test_api_files_delete_anonymous():
"""Anonymous users should not be allowed to destroy a file."""
file = factories.FileFactory()
existing_items = models.File.objects.all().count()
response = APIClient().delete(
f"/api/v1.0/files/{file.id!s}/",
)
assert response.status_code == 401
assert models.File.objects.count() == existing_items
def test_api_files_delete_authenticated_owner():
"""
Authenticated users should be able to delete a item they own.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory(creator=user)
response = client.delete(
f"/api/v1.0/files/{file.id}/",
)
assert response.status_code == 204
# Make sure it is only a soft delete
file.refresh_from_db()
assert file.deleted_at is not None
@@ -0,0 +1,160 @@
"""
Tests for files API endpoint in meet's core app: list
"""
from unittest import mock
from django.utils import timezone
import pytest
from faker import Faker
from rest_framework.pagination import PageNumberPagination
from rest_framework.test import APIClient
from core import factories, models
fake = Faker()
pytestmark = pytest.mark.django_db
def test_api_files_list_anonymous_not_allowed():
"""
Anonymous users should not be allowed to list files whatever the
"""
response = APIClient().get("/api/v1.0/files/")
assert response.status_code == 401
def test_api_files_list_authentificated_user_allowed():
"""
Authentificated users should be allowed to list files
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
response = client.get("/api/v1.0/files/")
assert response.status_code == 200
assert response.data == {"count": 0, "next": None, "previous": None, "results": []}
def test_api_files_list_format():
"""Validate the format of files as returned by the list view."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
title="item 1",
creator=user,
)
# A file from another user should not appear
factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
title="item 2",
)
# hard deleted item should not appear
factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
hard_deleted_at=timezone.now(),
title="hard deleted item",
creator=user,
)
response = client.get("/api/v1.0/files/")
assert response.status_code == 200
content = response.json()
results = content.pop("results")
assert content == {
"count": 1,
"next": None,
"previous": None,
}
assert len(results) == 1
assert results == [
{
"id": str(file.id),
"created_at": file.created_at.isoformat().replace("+00:00", "Z"),
"creator": {
"id": str(file.creator.id),
"full_name": file.creator.full_name,
"short_name": file.creator.short_name,
},
"title": file.title,
"updated_at": file.updated_at.isoformat().replace("+00:00", "Z"),
"type": models.FileTypeChoices.BACKGROUND_IMAGE,
"upload_state": file.upload_state,
"url": None,
"mimetype": file.mimetype,
"filename": file.filename,
"size": None,
"description": None,
"deleted_at": None,
"hard_deleted_at": None,
"abilities": {
"destroy": True,
"hard_delete": True,
"media_auth": True,
"partial_update": True,
"retrieve": True,
"update": True,
"upload_ended": True,
},
}
]
@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2)
def test_api_files_list_pagination(
_mock_page_size,
):
"""Pagination should work as expected."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file_ids = [
str(file.id)
for file in factories.FileFactory.create_batch(
3,
creator=user,
type=models.FileTypeChoices.BACKGROUND_IMAGE,
)
]
# Get page 1
response = client.get(
"/api/v1.0/files/",
)
assert response.status_code == 200
content = response.json()
assert content["count"] == 3
assert content["next"] == "http://testserver/api/v1.0/files/?page=2"
assert content["previous"] is None
assert len(content["results"]) == 2
for item in content["results"]:
file_ids.remove(item["id"])
# Get page 2
response = client.get(
"/api/v1.0/files/?page=2",
)
assert response.status_code == 200
content = response.json()
assert content["count"] == 3
assert content["next"] is None
assert content["previous"] == "http://testserver/api/v1.0/files/"
assert len(content["results"]) == 1
for item in content["results"]:
file_ids.remove(item["id"])
assert file_ids == []
@@ -0,0 +1,216 @@
"""
Tests for files API endpoint in meet's core app: list
"""
import pytest
from faker import Faker
from rest_framework.test import APIClient
from core import factories, models
fake = Faker()
pytestmark = pytest.mark.django_db
# Filters: unknown field
def test_api_files_list_filter_unknown_field():
"""
Trying to filter by an unknown field should do nothing.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.FileFactory(type=models.FileTypeChoices.BACKGROUND_IMAGE)
expected_ids = {
str(file.id)
for file in factories.FileFactory.create_batch(
2, creator=user, type=models.FileTypeChoices.BACKGROUND_IMAGE
)
}
response = client.get("/api/v1.0/files/?unknown=true")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 2
assert {result["id"] for result in results} == expected_ids
# Filters: is_creator_me
def test_api_files_list_filter_is_creator_me_true():
"""
Authenticated users should be able to filter files they created.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.FileFactory.create_batch(
2, creator=user, type=models.FileTypeChoices.BACKGROUND_IMAGE
)
factories.FileFactory.create_batch(2, type=models.FileTypeChoices.BACKGROUND_IMAGE)
response = client.get("/api/v1.0/files/?is_creator_me=true")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 2
# Ensure all results are created by the current user
for result in results:
assert result["creator"] == {
"id": str(user.id),
"full_name": user.full_name,
"short_name": user.short_name,
}
def test_api_files_list_filter_is_creator_me_invalid():
"""Filtering with an invalid `is_creator_me` value should do nothing."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.FileFactory.create_batch(
3, creator=user, type=models.FileTypeChoices.BACKGROUND_IMAGE
)
response = client.get("/api/v1.0/files/?is_creator_me=invalid")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 3
# Filters: type
def test_api_files_list_filter_type_and_upload_status():
"""
Authenticated users should be able to filter files by their type and upload status.
This test will make more sense when other types are added to the API
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory(
creator=user,
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.PENDING,
)
assert file.upload_state == models.FileUploadStateChoices.PENDING
expected_files = factories.FileFactory.create_batch(
2,
creator=user,
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.READY,
)
expected_files_ids = {str(file.id) for file in expected_files}
# Filter by type: background_image & upload state
response = client.get("/api/v1.0/files/?type=background_image&upload_state=ready")
assert response.status_code == 200
assert response.json()["count"] == 2
results = response.json()["results"]
# Ensure all results are background images
results_ids = {result["id"] for result in results}
assert results_ids == expected_files_ids
for result in results:
assert result["type"] == models.FileTypeChoices.BACKGROUND_IMAGE
assert result["upload_state"] == models.FileUploadStateChoices.READY
# Second request without the upload_state filter, to check that all 3 show up
response = client.get("/api/v1.0/files/?type=background_image")
assert response.status_code == 200
assert response.json()["count"] == 3
results = response.json()["results"]
# Ensure all results are background images
results_ids = {result["id"] for result in results}
assert results_ids == {str(file.id) for file in expected_files + [file]}
for result in results:
assert result["type"] == models.FileTypeChoices.BACKGROUND_IMAGE
def test_api_files_list_filter_is_deleted():
"""
Authenticated users should be able to filter files by their deletion status.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
not_deleted_file = factories.FileFactory(creator=user)
deleted_files = factories.FileFactory.create_batch(2, creator=user)
for file in deleted_files:
file.soft_delete()
# No filters
response_no_filters = client.get("/api/v1.0/files/")
assert response_no_filters.status_code == 200
assert response_no_filters.json()["count"] == 3
results = response_no_filters.json()["results"]
results_ids = {result["id"] for result in results}
assert results_ids == {str(file.id) for file in [*deleted_files, not_deleted_file]}
# Filters deleted
response_filter_deleted = client.get("/api/v1.0/files/?is_deleted=true")
assert response_filter_deleted.status_code == 200
assert response_filter_deleted.json()["count"] == 2
results = response_filter_deleted.json()["results"]
results_ids = {result["id"] for result in results}
assert results_ids == {str(file.id) for file in deleted_files}
# Filters not deleted
response_filter_not_deleted = client.get("/api/v1.0/files/?is_deleted=false")
assert response_filter_not_deleted.status_code == 200
assert response_filter_not_deleted.json()["count"] == 1
results = response_filter_not_deleted.json()["results"]
# Ensure all results are deleted
results_ids = {result["id"] for result in results}
assert results_ids == {str(file.id) for file in [not_deleted_file]}
def test_api_files_list_filter_unknown_type():
"""
Filtering by an unknown type should return an empty list
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.FileFactory.create_batch(3, creator=user)
response = client.get("/api/v1.0/files/?type=unknown")
assert response.status_code == 400
assert response.json() == {
"type": ["Select a valid choice. unknown is not one of the available choices."]
}
@@ -0,0 +1,71 @@
"""Test the ordering of items."""
import operator
import pytest
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def test_api_files_list_ordering_default():
"""items should be ordered by descending "updated_at" by default"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.FileFactory.create_batch(
4, creator=user, type=models.FileTypeChoices.BACKGROUND_IMAGE
)
response = client.get("/api/v1.0/files/")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 4
# Check that results are sorted by descending "updated_at" as expected
for i in range(3):
assert operator.ge(results[i]["updated_at"], results[i + 1]["updated_at"])
def test_api_files_list_ordering_by_fields():
"""It should be possible to order by several fields"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
factories.FileFactory.create_batch(
4, creator=user, type=models.FileTypeChoices.BACKGROUND_IMAGE
)
for parameter in [
"created_at",
"-created_at",
"updated_at",
"-updated_at",
]:
is_descending = parameter.startswith("-")
field = parameter.lstrip("-")
querystring = f"?ordering={parameter}"
response = client.get(f"/api/v1.0/files/{querystring:s}")
assert response.status_code == 200
results = response.json()["results"]
assert len(results) == 4
# Check that results are sorted by the field in querystring as expected
compare = operator.ge if is_descending else operator.le
for i in range(3):
operator1 = (
results[i][field].lower()
if isinstance(results[i][field], str)
else results[i][field]
)
operator2 = (
results[i + 1][field].lower()
if isinstance(results[i + 1][field], str)
else results[i + 1][field]
)
assert compare(operator1, operator2)
@@ -0,0 +1,141 @@
"""
Test file uploads API endpoint for users in meet's core app.
"""
from io import BytesIO
from urllib.parse import quote, urlparse
from django.conf import settings
from django.core.files.storage import default_storage
from django.utils import timezone
import pytest
import requests
from freezegun import freeze_time
from rest_framework.test import APIClient
from core import factories, models
pytestmark = pytest.mark.django_db
def test_api_files_media_auth_anonymous_not_authorized():
"""Anonymous users should not be allowed to retrieve a file"""
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.READY,
)
original_url = f"http://localhost/media/{file.file_key:s}"
response = APIClient().get(
"/api/v1.0/files/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 401
def test_api_files_media_get_own():
"""
Authenticated user should be allowed to retrieve their own file.
"""
user = factories.UserFactory()
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.READY,
creator=user,
)
client = APIClient()
client.force_login(user)
default_storage.save(
file.file_key,
BytesIO(b"my prose"),
)
original_url = f"http://localhost/media/{file.file_key:s}"
now = timezone.now()
with freeze_time(now):
response = client.get(
"/api/v1.0/files/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/meet-media-storage/{file.file_key:s}"
response = requests.get(
file_url,
headers={
"authorization": authorization,
"x-amz-date": response["x-amz-date"],
"x-amz-content-sha256": response["x-amz-content-sha256"],
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
},
timeout=1,
)
assert response.content.decode("utf-8") == "my prose"
def test_api_files_media_auth_file_pending():
"""
Users who have a specific access to an file, whatever the role, should not be able to
retrieve related attachments if the file is not ready.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
upload_state=models.FileUploadStateChoices.PENDING,
creator=user,
)
key = file.file_key
original_url = quote(f"http://localhost/media/{key:s}")
response = client.get(
"/api/v1.0/files/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
def test_api_files_media_auth_own_file_deleted():
"""
This function tests the access restrictions on deleted files through the media
authorization API endpoint. It ensures that a user cannot retrieve a file that is deleted.
"""
user = factories.UserFactory()
file = factories.FileFactory(
type=models.FileTypeChoices.BACKGROUND_IMAGE,
update_upload_state=models.FileUploadStateChoices.READY,
creator=user,
)
client = APIClient()
client.force_login(user)
default_storage.save(
file.file_key,
BytesIO(b"my prose"),
)
file.soft_delete()
original_url = f"http://localhost/media/{file.file_key:s}"
response = client.get(
"/api/v1.0/files/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
@@ -0,0 +1,67 @@
"""
Tests for files API endpoint in meet's core app: update
"""
import pytest
from rest_framework.test import APIClient
from core import factories
from core.api import serializers
pytestmark = pytest.mark.django_db
def test_api_files_update_anonymous_forbidden():
"""
Anonymous users should not be allowed to update an file when link
configuration does not allow it.
"""
file = factories.FileFactory()
old_file_values = serializers.FileSerializer(instance=file).data
new_file_values = serializers.FileSerializer(instance=factories.FileFactory()).data
response = APIClient().put(
f"/api/v1.0/files/{file.id!s}/",
new_file_values,
format="json",
)
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
file.refresh_from_db()
item_values = serializers.FileSerializer(instance=file).data
assert item_values == old_file_values
def test_api_files_update_description_and_title():
"""
Test the description and title of a file can be updated.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory(
description="Old description",
title="Old title",
creator=user,
)
response = client.patch(
f"/api/v1.0/files/{file.id!s}/",
{"description": "New description", "title": "New title"},
format="json",
)
assert response.status_code == 200
result = response.json()
assert result["description"] == "New description"
assert result["title"] == "New title"
file.refresh_from_db()
assert file.description == "New description"
assert file.title == "New title"
@@ -0,0 +1,272 @@
"""Test related to item upload ended API."""
import logging
from io import BytesIO
from django.core.files.storage import default_storage
import pytest
from rest_framework.test import APIClient
from core import factories, models
from core.models import FileTypeChoices, FileUploadStateChoices
pytestmark = pytest.mark.django_db
def test_api_file_upload_ended_anonymous():
"""Anonymous users should not be allowed to end an upload."""
file = factories.FileFactory()
response = APIClient().post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
assert response.status_code == 401
def test_api_file_upload_ended_non_creator_not_found():
"""Users without write permissions should not be allowed to end an upload."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory()
response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
assert response.status_code == 404
def test_api_file_upload_ended_on_wrong_upload_state():
"""
Users should not be allowed to end an upload on files that are not in the PENDING upload state.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory(
type=FileTypeChoices.BACKGROUND_IMAGE,
creator=user,
update_upload_state=FileUploadStateChoices.READY,
)
response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
assert response.status_code == 400
assert response.json() == {
"file": "This action is only available for files in PENDING state."
}
def test_api_file_upload_ended_success(settings):
"""
Users should be able to end an upload on files that are files and in the UPLOADING upload state.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = True
settings.FILE_UPLOAD_RESTRICTIONS = {
"background_image": {
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
"allowed_mimetypes": ["text/html", "text/plain"],
},
}
file = factories.FileFactory(
type=FileTypeChoices.BACKGROUND_IMAGE,
filename="my_file.txt",
mimetype="text/html",
creator=user,
)
default_storage.save(
file.file_key,
BytesIO(b"my prose"),
)
response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
assert response.status_code == 200
file.refresh_from_db()
assert file.upload_state == FileUploadStateChoices.READY
assert file.mimetype == "text/plain"
assert file.size == 8
assert response.json()["mimetype"] == "text/plain"
def test_api_file_upload_ended_mimetype_not_allowed(settings, caplog):
"""
Test that the API returns a 400 when the mimetype is not allowed.
File should be deleted and the file should be deleted from the storage.
"""
settings.RESTRICT_UPLOAD_FILE_TYPE = True
settings.FILE_UPLOAD_RESTRICTIONS = {
"background_image": {
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
"allowed_mimetypes": ["application/pdf"],
}
}
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory(
type=FileTypeChoices.BACKGROUND_IMAGE, filename="my_file.txt", creator=user
)
default_storage.save(
file.file_key,
BytesIO(b"my prose"),
)
with caplog.at_level(logging.WARNING):
response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
assert response.status_code == 400
assert (
f"upload_ended: mimetype not allowed text/plain for file {file.file_key}"
in caplog.text
)
assert not models.File.objects.filter(id=file.id).exists()
assert not default_storage.exists(file.file_key)
def test_api_file_upload_ended_mimetype_not_allowed_not_checking_mimetype(settings):
"""
Test that the API returns a 200 when the mimetype is not allowed but not checking the mimetype.
"""
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = False
settings.FILE_UPLOAD_RESTRICTIONS = {
"background_image": {
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
"allowed_mimetypes": ["application/pdf"],
}
}
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory(
type=FileTypeChoices.BACKGROUND_IMAGE, filename="my_file.txt", creator=user
)
default_storage.save(
file.file_key,
BytesIO(b"my prose"),
)
response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
assert response.status_code == 200
file.refresh_from_db()
assert file.upload_state == FileUploadStateChoices.READY
assert file.mimetype == "text/plain"
assert file.size == 8
assert response.json()["mimetype"] == "text/plain"
def test_api_upload_ended_mismatch_mimetype_with_object_storage(settings, caplog):
"""
Object on storage should have the same mimetype than the one saved in the
File object.
"""
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = True
settings.FILE_UPLOAD_RESTRICTIONS = {
"background_image": {
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
"allowed_mimetypes": ["text/html", "application/pdf"],
}
}
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory(
type=FileTypeChoices.BACKGROUND_IMAGE,
filename="my_file.pdf",
title="my_file.pdf",
creator=user,
)
s3_client = default_storage.connection.meta.client
s3_client.put_object(
Bucket=default_storage.bucket_name,
Key=file.file_key,
ContentType="text/html",
Body=BytesIO(
b'<meta http-equiv="refresh" content="0; url=https://fichiers.numerique.gouv.fr">'
),
Metadata={
"foo": "bar",
},
)
head_object = s3_client.head_object(
Bucket=default_storage.bucket_name, Key=file.file_key
)
assert head_object["ContentType"] == "text/html"
with caplog.at_level(logging.INFO, logger="core.api.viewsets"):
response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
assert (
"upload_ended: content type mismatch between object storage and file,"
" updating from text/html to application/pdf" in caplog.text
)
assert response.status_code == 200
file.refresh_from_db()
assert file.mimetype == "application/pdf"
head_object = s3_client.head_object(
Bucket=default_storage.bucket_name, Key=file.file_key
)
assert head_object["ContentType"] == "application/pdf"
assert head_object["Metadata"] == {"foo": "bar"}
def test_api_upload_ended_file_size_exceeded(settings, caplog):
"""
Test when the file size exceed the allowed max upload file size
should return a 400 and delete the file.
"""
settings.FILE_UPLOAD_RESTRICTIONS = {
"background_image": {
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
"max_size": 0,
}
}
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
file = factories.FileFactory(
type=FileTypeChoices.BACKGROUND_IMAGE, filename="my_file.txt", creator=user
)
default_storage.save(
file.file_key,
BytesIO(b"my prose"),
)
with caplog.at_level(logging.INFO, logger="core.api.viewsets"):
response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
assert (
f"upload_ended: file size (8) for file {file.file_key} higher than the allowed max size"
in caplog.text
)
assert response.status_code == 400
assert not models.File.objects.filter(id=file.id).exists()
assert not default_storage.exists(file.file_key)
@@ -32,7 +32,7 @@ def valid_minio_event():
"s3": {
"bucket": {"name": "test-bucket"},
"object": {
"key": "recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
"key": "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
"contentType": "audio/ogg",
},
}
@@ -51,7 +51,7 @@ def test_parse_valid_event(minio_parser, valid_minio_event):
"""Test parsing a valid Minio event."""
event = minio_parser.parse(valid_minio_event)
assert isinstance(event, StorageEvent)
assert event.filepath == "recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg"
assert event.filepath == "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg"
assert event.filetype == "audio/ogg"
assert event.bucket_name == "test-bucket"
assert event.metadata is None
@@ -130,11 +130,13 @@ def test_validate_invalid_filetype(minio_parser):
"invalid_filepath",
[
"invalid_filepath", # totally invalid string
"recording/46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
"recording/46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing extension
"recordings/46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
"recordings/46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing extension
"46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing url_encoded_folder_path and extension
"", # empty string
"recording%2F46d1a1212426484d8fb309b5d886f7a8.ogg",
"46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # no folder at all
"uploads%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # wrong folder name
"folder%2Fuploads%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # nested but no recordings/
],
)
def test_validate_invalid_filepath(invalid_filepath, minio_parser):
@@ -152,7 +154,7 @@ def test_validate_invalid_filepath(invalid_filepath, minio_parser):
def test_validate_valid_event(minio_parser):
"""Test validation with valid event data."""
event = StorageEvent(
filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filepath="recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filetype="audio/ogg",
bucket_name="test-bucket",
metadata=None,
@@ -170,7 +172,7 @@ def test_get_recording_id_success(minio_parser, valid_minio_event):
def test_validate_filepath_with_folder(minio_parser):
"""Test validation of filepath with folder structure."""
event = StorageEvent(
filepath="parent_folder%2Ffolder%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filepath="parent_folder%2Frecordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filetype="audio/ogg",
bucket_name="test-bucket",
metadata=None,
@@ -219,7 +221,7 @@ def test_validate_custom_filetypes():
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes={"audio/mp3"})
event = StorageEvent(
filepath="parent_folder%2Ffolder%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filepath="parent_folder%2Frecordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
filetype="audio/mp3",
bucket_name="test-bucket",
metadata=None,
@@ -14,6 +14,7 @@ from ...factories import RecordingFactory
from ...models import Recording, RecordingStatusChoices
from ...recording.event.exceptions import (
InvalidBucketError,
InvalidFilepathError,
InvalidFileTypeError,
ParsingEventDataError,
)
@@ -94,7 +95,7 @@ def test_save_recording_parsing_error(recording_settings, mock_get_parser, clien
)
assert response.status_code == 403
assert response.json() == {"detail": "Invalid request data: Error message"}
assert response.json() == {"detail": "Invalid request data."}
def test_save_recording_bucket_error(recording_settings, mock_get_parser, client):
@@ -111,7 +112,7 @@ def test_save_recording_bucket_error(recording_settings, mock_get_parser, client
)
assert response.status_code == 403
assert response.json() == {"detail": "Invalid bucket specified"}
assert response.json() == {"detail": "Invalid bucket specified."}
def test_save_recording_filetype_error(recording_settings, mock_get_parser):
@@ -132,7 +133,28 @@ def test_save_recording_filetype_error(recording_settings, mock_get_parser):
)
assert response.status_code == 200
assert response.json() == {"message": "Ignore this file type, unsupported '.json'"}
assert response.json() == {"message": "Notification ignored."}
def test_save_recording_filepath_error(recording_settings, mock_get_parser):
"""Test handling of unsupported filepath in recording event data."""
mock_parser = mock.Mock()
mock_parser.get_recording_id.side_effect = InvalidFilepathError(
"Invalid filepath structure: parent/folder/recording.jpeg"
)
mock_get_parser.return_value = mock_parser
client = APIClient()
response = client.post(
"/api/v1.0/recordings/storage-hook/",
{"recording_data": "valid-data"},
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 200
assert response.json() == {"message": "Notification ignored."}
def test_save_recording_unknown_recording(recording_settings, mock_get_parser, client):
@@ -120,3 +120,39 @@ def test_api_rooms_list_authenticated_distinct():
content = response.json()
assert len(content["results"]) == 1
assert content["results"][0]["id"] == str(room.id)
def test_api_rooms_list_pagination_page_size():
"""Users should be able to customize the number of results per page via page_size param."""
user = UserFactory()
client = APIClient()
client.force_login(user)
RoomFactory.create_batch(11, users=[user])
response = client.get("/api/v1.0/rooms/?page_size=2")
assert response.status_code == 200
content = response.json()
assert content["count"] == 11
assert len(content["results"]) == 2
assert content["next"] == "http://testserver/api/v1.0/rooms/?page=2&page_size=2"
assert content["previous"] is None
response = client.get("/api/v1.0/rooms/?page=6&page_size=2")
assert response.status_code == 200
content = response.json()
assert content["count"] == 11
assert len(content["results"]) == 1
assert content["next"] is None
assert content["previous"] == "http://testserver/api/v1.0/rooms/?page=5&page_size=2"
response = client.get("/api/v1.0/rooms/?page_size=3")
assert response.status_code == 200
content = response.json()
assert content["count"] == 11
assert len(content["results"]) == 3
assert content["next"] == "http://testserver/api/v1.0/rooms/?page=2&page_size=3"
assert content["previous"] is None
@@ -631,3 +631,109 @@ def test_list_waiting_participants_empty(settings):
assert response.status_code == 200
assert response.json() == {"participants": []}
@mock.patch.object(utils, "notify_participants", return_value=None)
@mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"}
)
def test_request_entry_throttling_anonymous_without_cookie(
mock_notify_participants, mock_generate_livekit_config, settings
):
"""Anonymous users without a cookie should not be throttled."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "1/minute"
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
assert response.cookies.get("mocked-cookie") is not None
client.cookies.clear() # Simulate a new cookieless request
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
@mock.patch.object(utils, "notify_participants", return_value=None)
@mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"}
)
def test_request_entry_throttling_anonymous_with_cookie(
mock_notify_participants, mock_generate_livekit_config, settings
):
"""Anonymous users with a cookie should be throttled after exceeding the rate limit."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
participant_id = str(uuid.uuid4())
client.cookies.load({"mocked-cookie": participant_id})
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 429
@mock.patch.object(utils, "notify_participants", return_value=None)
@mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"}
)
def test_request_entry_throttling_authenticated_user(
mock_notify_participants, mock_generate_livekit_config, settings
):
"""Authenticated users should be throttled."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
user = UserFactory()
client = APIClient()
client.force_login(user)
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 429
@@ -77,7 +77,6 @@ def test_missing_auth_header(client, serialized_event_data, mock_livekit_config)
assert response.status_code == 401
assert response.json() == {
"status": "error",
"message": "Authorization header missing",
}
@@ -91,7 +90,7 @@ def test_invalid_payload(client, auth_token, mock_livekit_config):
)
assert response.status_code == 400
assert response.json() == {"status": "error", "message": "Invalid webhook payload"}
assert response.json() == {"status": "error"}
def test_unknown_event_type(client, mock_livekit_config):
@@ -116,7 +115,6 @@ def test_unknown_event_type(client, mock_livekit_config):
assert response.status_code == 422
assert response.json() == {
"status": "error",
"message": "Unknown webhook type: unknown_event_type",
}
@@ -904,7 +904,7 @@ def test_api_rooms_token_unknown_application(settings):
def test_api_rooms_token_inactive_application(settings):
"""Token for inactive application should be rejected."""
application = ApplicationFactory(active=False)
application = ApplicationFactory(is_active=False)
now = datetime.now(timezone.utc)
payload = {
@@ -23,7 +23,7 @@ def test_api_applications_generate_token_success(settings):
"""Valid credentials should return a JWT token."""
UserFactory(email="User.Family@example.com")
application = ApplicationFactory(
active=True,
is_active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
@@ -79,7 +79,7 @@ def test_api_applications_generate_token_invalid_client_id():
def test_api_applications_generate_token_invalid_client_secret():
"""Invalid client_secret should return 401."""
user = UserFactory(email="user@example.com")
application = ApplicationFactory(active=True)
application = ApplicationFactory(is_active=True)
client = APIClient()
response = client.post(
@@ -100,7 +100,7 @@ def test_api_applications_generate_token_invalid_client_secret():
def test_api_applications_generate_token_inactive_application():
"""Inactive application should return 401."""
user = UserFactory(email="user@example.com")
application = ApplicationFactory(active=False)
application = ApplicationFactory(is_active=False)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
@@ -122,9 +122,31 @@ def test_api_applications_generate_token_inactive_application():
assert "Application is inactive" in str(response.data)
def test_api_applications_generate_token_inactive_application_wrong_secret():
"""An inactive application with a wrong secret should return 401."""
user = UserFactory(email="user@example.com")
application = ApplicationFactory(is_active=False)
client = APIClient()
response = client.post(
"/external-api/v1.0/application/token/",
{
"client_id": application.client_id,
"client_secret": "wrong-secret",
"grant_type": "client_credentials",
"scope": user.email,
},
format="json",
)
assert response.status_code == 401
assert "Invalid credentials" in str(response.data)
assert "inactive" not in str(response.data).lower()
def test_api_applications_generate_token_invalid_email_format():
"""Invalid email format should return 400."""
application = ApplicationFactory(active=True)
application = ApplicationFactory(is_active=True)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
@@ -149,7 +171,7 @@ def test_api_applications_generate_token_invalid_email_format():
def test_api_applications_generate_token_domain_not_authorized():
"""Application without domain authorization should return 403."""
user = UserFactory(email="user@denied.com")
application = ApplicationFactory(active=True)
application = ApplicationFactory(is_active=True)
ApplicationDomainFactory(application=application, domain="allowed.com")
plain_secret = "test-secret-123"
@@ -176,7 +198,7 @@ def test_api_applications_generate_token_domain_authorized():
"""Application with domain authorization should succeed."""
user = UserFactory(email="user@allowed.com")
application = ApplicationFactory(
active=True,
is_active=True,
scopes=[ApplicationScope.ROOMS_LIST],
)
ApplicationDomainFactory(application=application, domain="allowed.com")
@@ -203,7 +225,7 @@ def test_api_applications_generate_token_domain_authorized():
def test_api_applications_generate_token_user_not_found():
"""Non-existent user should return 404."""
application = ApplicationFactory(active=True)
application = ApplicationFactory(is_active=True)
plain_secret = "test-secret-123"
application.client_secret = plain_secret
@@ -231,7 +253,7 @@ def test_api_applications_token_payload_structure(settings):
user = UserFactory(email="user@example.com")
application = ApplicationFactory(
active=True,
is_active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
@@ -284,7 +306,7 @@ def test_api_applications_token_new_user(settings):
assert len(User.objects.all()) == 0
application = ApplicationFactory(
active=True,
is_active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
@@ -342,7 +364,7 @@ def test_api_applications_token_existing_user(settings):
assert len(User.objects.all()) == 1
application = ApplicationFactory(
active=True,
is_active=True,
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
)
@@ -41,7 +41,7 @@ def test_models_application_name_maxlength():
def test_models_application_active_default():
"""An application should be active by default."""
application = Application.objects.create(name="Test App")
assert application.active is True
assert application.is_active is True
def test_models_application_scopes_default():
@@ -0,0 +1,166 @@
"""
Test utils.detect_mimetype
Originally taken from https://github.com/suitenumerique/drive/blob/564822d31f071c6dfacd112ef4b7146c73077cd9/src/backend/core/api/utils.py#L166 # pylint:disable=line-too-long
"""
import pytest
from core import utils
def test_detect_mimetype_from_content_pdf():
"""Test detect_mimetype detects PDF from content (magic bytes)."""
# PDF magic bytes: %PDF
pdf_content = b"%PDF-1.4\n"
mimetype = utils.detect_mimetype(pdf_content, filename="document.pdf")
assert mimetype == "application/pdf"
def test_detect_mimetype_from_content_png():
"""Test detect_mimetype detects PNG from content (magic bytes)."""
# PNG magic bytes: \x89PNG\r\n\x1a\n
png_content = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"
mimetype = utils.detect_mimetype(png_content, filename="image.png")
assert mimetype == "image/png"
def test_detect_mimetype_from_content_jpeg():
"""Test detect_mimetype detects JPEG from content (magic bytes)."""
# JPEG magic bytes: \xff\xd8\xff
jpeg_content = b"\xff\xd8\xff\xe0\x00\x10JFIF"
mimetype = utils.detect_mimetype(jpeg_content, filename="photo.jpg")
assert mimetype == "image/jpeg"
def test_detect_mimetype_from_content_text_plain():
"""Test detect_mimetype detects plain text from content."""
text_content = b"This is plain text content"
mimetype = utils.detect_mimetype(text_content, filename="file.txt")
assert mimetype == "text/plain"
def test_detect_mimetype_empty_file():
"""Test detect_mimetype handles empty files."""
empty_content = b""
mimetype = utils.detect_mimetype(empty_content, filename="empty.txt")
# Empty files should be detected as application/x-empty by magic bytes
assert mimetype == "application/x-empty"
def test_detect_mimetype_uses_extension_when_content_generic():
"""Test detect_mimetype uses extension when content detection returns generic type."""
# Generic binary content that might be detected as application/octet-stream
generic_content = b"\x00\x01\x02\x03\x04\x05"
# But if we have a specific extension, we should use it
mimetype = utils.detect_mimetype(generic_content, filename="document.pdf")
# Should prefer extension-based detection for specific types
assert mimetype == "application/pdf"
def test_detect_mimetype_uses_extension_for_json():
"""Test detect_mimetype uses extension for JSON files."""
# JSON content might be detected as text/plain
json_content = b'{"key": "value"}'
mimetype = utils.detect_mimetype(json_content, filename="data.json")
# Should use extension to get application/json
assert mimetype == "application/json"
def test_detect_mimetype_without_filename():
"""Test detect_mimetype works without filename (content-only detection)."""
pdf_content = b"%PDF-1.4\n"
mimetype = utils.detect_mimetype(pdf_content, filename=None)
assert mimetype == "application/pdf"
def test_detect_mimetype_fallback_to_extension():
"""Test detect_mimetype falls back to extension when content detection is generic."""
# Content that might be detected as text/plain
content = b"some content"
mimetype = utils.detect_mimetype(content, filename="script.js")
# Should use extension to get JavaScript MIME type
# (can be text/javascript or application/javascript)
assert mimetype in ["text/javascript", "application/javascript"]
def test_detect_mimetype_generic_content_no_extension():
"""Test detect_mimetype with generic content and no extension."""
generic_content = b"\x00\x01\x02\x03"
mimetype = utils.detect_mimetype(generic_content, filename="file")
# Should return content-based detection (likely application/octet-stream)
assert mimetype in ["application/octet-stream", "application/x-empty"]
def test_detect_mimetype_xml_file():
"""Test detect_mimetype detects XML files."""
xml_content = b'<?xml version="1.0"?><root></root>'
mimetype = utils.detect_mimetype(xml_content, filename="data.xml")
# Should detect as XML (either from content or extension)
assert mimetype in ["application/xml", "text/xml"]
def test_detect_mimetype_csv_file():
"""Test detect_mimetype detects CSV files."""
csv_content = b"name,age\nJohn,30\nJane,25"
mimetype = utils.detect_mimetype(csv_content, filename="data.csv")
# CSV might be detected as text/plain, but extension should help
assert mimetype in ["text/csv", "text/plain"]
def test_detect_mimetype_zip_file():
"""Test detect_mimetype detects ZIP files from magic bytes."""
# ZIP magic bytes: PK\x03\x04
zip_content = b"PK\x03\x04\x14\x00\x00\x00"
mimetype = utils.detect_mimetype(zip_content, filename="archive.zip")
assert mimetype == "application/zip"
def test_detect_mimetype_prefers_content_over_extension():
"""Test detect_mimetype prefers content detection when both are available and specific."""
# PDF content but wrong extension
pdf_content = b"%PDF-1.4\n"
mimetype = utils.detect_mimetype(pdf_content, filename="document.txt")
# Should prefer content detection (PDF) over extension (txt)
assert mimetype == "application/pdf"
@pytest.mark.xfail(
reason="Fails in our repo, but passes in the original repo, leaving it there"
)
def test_detect_mimetype_powerpoint_pptx():
"""Test detect_mimetype correctly detects PowerPoint .pptx files."""
# .pptx files are ZIP archives, so content might be detected as application/zip or octet-stream
# But with the extension, it should be detected as PowerPoint MIME type
# Using minimal ZIP-like content that might be detected as generic
pptx_content = b"PK\x03\x04" # ZIP magic bytes (PPTX is a ZIP archive)
mimetype = utils.detect_mimetype(pptx_content, filename="presentation.pptx")
# Should use extension to get PowerPoint MIME type
assert mimetype in [
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.ms-powerpoint.presentation.macroEnabled.12",
]
def test_detect_mimetype_powerpoint_ppt():
"""Test detect_mimetype correctly detects PowerPoint .ppt files (older format)."""
# .ppt files might be detected as application/octet-stream by magic bytes
# But with the extension, it should be detected as PowerPoint MIME type
ppt_content = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" # OLE2 compound document header
mimetype = utils.detect_mimetype(ppt_content, filename="presentation.ppt")
# Should use extension to get PowerPoint MIME type
assert mimetype in [
"application/vnd.ms-powerpoint",
"application/mspowerpoint",
]
def test_detect_mimetype_ole_storage():
"""
Microsoft files .xls .doc .ppt can use the mimetype application/x-ole-storage
for Microsoft OLE2/Compound File Binary Format. In that case we want to rely on the extension
"""
xls_ole_content = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"
mimetype = utils.detect_mimetype(xls_ole_content, filename="document.xls")
assert mimetype == "application/vnd.ms-excel"
+1
View File
@@ -14,6 +14,7 @@ router = DefaultRouter()
router.register("users", viewsets.UserViewSet, basename="users")
router.register("rooms", viewsets.RoomViewSet, basename="rooms")
router.register("recordings", viewsets.RecordingViewSet, basename="recordings")
router.register("files", viewsets.FileViewSet, basename="files")
router.register(
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
)
+111
View File
@@ -7,6 +7,8 @@ Utils functions used in the core app
import hashlib
import json
import logging
import mimetypes
import random
import secrets
import string
@@ -17,7 +19,9 @@ from django.conf import settings
from django.core.files.storage import default_storage
import aiohttp
import boto3
import botocore
import magic
from asgiref.sync import async_to_sync
from livekit.api import ( # pylint: disable=E0611
AccessToken,
@@ -29,6 +33,8 @@ from livekit.api import ( # pylint: disable=E0611
VideoGrants,
)
logger = logging.getLogger(__name__)
def generate_color(identity: str) -> str:
"""Generates a consistent HSL color based on a given identity string.
@@ -344,3 +350,108 @@ def generate_room_slug():
for size in sizes
]
return "-".join(parts)
def detect_mimetype(file_buffer: bytes, filename: str | None = None) -> str:
"""
Detect MIME type using multiple methods for better accuracy.
This function combines:
1. Magic bytes detection (python-magic) - most reliable for actual file content
2. File extension detection (mimetypes) - useful as fallback or for validation
Args:
file_buffer: The file content buffer (first bytes of the file)
filename: Optional filename to extract extension from
Returns:
str: The detected MIME type
Notes:
Originally from https://github.com/suitenumerique/drive/blob/564822d31f071c6dfacd112ef4b7146c73077cd9/src/backend/core/api/utils.py#L166 # pylint:disable=line-too-long
"""
# Initialize magic detector
mime_detector = magic.Magic(mime=True)
# Method 1: Detect from file content (magic bytes) - most reliable
mimetype_from_content = mime_detector.from_buffer(file_buffer)
# If we have a filename, try extension-based detection as well
mimetype_from_extension = None
if filename:
# Use mimetypes module to guess from extension
# Use guess_file_type (Python 3.13+) instead of deprecated guess_type
mimetype_from_extension, _ = mimetypes.guess_file_type(filename, strict=False)
logger.debug("detect_mimetype: mimetype_from_content: %s", mimetype_from_content)
logger.debug(
"detect_mimetype: mimetype_from_extension: %s", mimetype_from_extension
)
# Strategy: Prefer content-based detection, but use extension if:
# 1. Content detection returns generic types (application/octet-stream, text/plain)
# 2. Content detection fails or returns None
# 3. Extension detection provides a more specific type
# Generic/unreliable MIME types that we should try to improve
generic_types = {
"application/octet-stream",
"application/x-ole-storage", # used by .xls, .doc and .ppt
"application/zip",
"text/plain",
}
# If content detection gives us a generic type and we have extension info
if mimetype_from_content in generic_types and mimetype_from_extension:
# Use extension-based detection if it's more specific
if mimetype_from_extension not in generic_types:
return mimetype_from_extension
# If content detection failed, returned None or is a generic type, use extension if available
if not mimetype_from_content or mimetype_from_content in generic_types:
if mimetype_from_extension:
return mimetype_from_extension
# Default to content-based detection (most reliable)
return mimetype_from_content or "application/octet-stream"
def generate_upload_policy(file):
"""
Generate a S3 upload policy for a given file.
Notes:
Originally taken from https://github.com/suitenumerique/drive/blob/564822d31f071c6dfacd112ef4b7146c73077cd9/src/backend/core/api/utils.py#L102 # pylint: disable=line-too-long
"""
key = file.file_key
# This settings should be used if the backend application and the frontend application
# can't connect to the object storage with the same domain. This is the case in the
# docker compose stack used in development. The frontend application will use localhost
# to connect to the object storage while the backend application will use the object storage
# service name declared in the docker compose stack.
# This is needed because the domain name is used to compute the signature. So it can't be
# changed dynamically by the frontend application.
if settings.AWS_S3_DOMAIN_REPLACE:
s3_client = boto3.client(
"s3",
aws_access_key_id=settings.AWS_S3_ACCESS_KEY_ID,
aws_secret_access_key=settings.AWS_S3_SECRET_ACCESS_KEY,
endpoint_url=settings.AWS_S3_DOMAIN_REPLACE,
config=botocore.client.Config(
region_name=settings.AWS_S3_REGION_NAME,
signature_version=settings.AWS_S3_SIGNATURE_VERSION,
),
)
else:
s3_client = default_storage.connection.meta.client
# Generate the policy
policy = s3_client.generate_presigned_url(
ClientMethod="put_object",
Params={"Bucket": default_storage.bucket_name, "Key": key, "ACL": "private"},
ExpiresIn=settings.AWS_S3_UPLOAD_POLICY_EXPIRATION,
)
return policy
Binary file not shown.
+162 -94
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
"POT-Creation-Date: 2026-03-12 13:46+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -29,141 +29,168 @@ msgstr "Berechtigungen"
msgid "Important dates"
msgstr "Wichtige Daten"
#: core/admin.py:128 core/admin.py:228
#: core/admin.py:132 core/admin.py:275
msgid "No owner"
msgstr "Kein Eigentümer"
#: core/admin.py:131 core/admin.py:231
#: core/admin.py:135 core/admin.py:278
msgid "Multiple owners"
msgstr "Mehrere Eigentümer"
#: core/admin.py:143
#: core/admin.py:148
msgid "Resend notification to external service"
msgstr "Benachrichtigung erneut an externen Dienst senden"
#: core/admin.py:166
#: core/admin.py:171
#, python-format
msgid "Failed to notify for recording %(id)s"
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen"
#: core/admin.py:174
#: core/admin.py:179
#, python-format
msgid "Failed to notify for recording %(id)s: %(error)s"
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen: %(error)s"
#: core/admin.py:182
#: core/admin.py:187
#, python-format
msgid "Successfully sent notifications for %(count)s recording(s)."
msgstr "Benachrichtigungen für %(count)s Aufnahme(n) erfolgreich gesendet."
#: core/admin.py:190
#: core/admin.py:195
#, python-format
msgid "Skipped %(count)s expired recording(s)."
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
#: core/admin.py:294
#: core/admin.py:200
msgid "Mark selected recordings as 'Failed to Stop'"
msgstr "Ausgewählte Aufnahmen als Fehler beim Stoppen markieren"
#: core/admin.py:218
#, python-format
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
msgstr "%(count)s Aufnahme(n) erfolgreich als Fehler beim Stoppen markiert."
#: core/admin.py:226
#, fuzzy, python-format
#| msgid "Skipped %(count)s expired recording(s)."
msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
#: core/admin.py:342
msgid "No scopes"
msgstr "Keine Scopes"
#: core/admin.py:296
#: core/admin.py:344
msgid "Scopes"
msgstr "Scopes"
#: core/api/serializers.py:68
#: core/api/filters.py:25
msgid "Creator is me"
msgstr "Ersteller bin ich"
#: core/api/serializers.py:88
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
"hinzuzufügen."
#: core/models.py:34
#: core/api/serializers.py:516
msgid "This file extension is not allowed."
msgstr "Diese Dateiendung ist nicht erlaubt."
#: core/api/serializers.py:533
msgid "You have reached the maximum number of files for this type."
msgstr "Sie haben die maximale Anzahl an Dateien dieses Typs erreicht."
#: core/models.py:37
msgid "Member"
msgstr "Mitglied"
#: core/models.py:35
#: core/models.py:38
msgid "Administrator"
msgstr "Administrator"
#: core/models.py:36
#: core/models.py:39
msgid "Owner"
msgstr "Eigentümer"
#: core/models.py:52
#: core/models.py:55
msgid "Initiated"
msgstr "Gestartet"
#: core/models.py:53
#: core/models.py:56
msgid "Active"
msgstr "Aktiv"
#: core/models.py:54
#: core/models.py:57
msgid "Stopped"
msgstr "Beendet"
#: core/models.py:55
#: core/models.py:58
msgid "Saved"
msgstr "Gespeichert"
#: core/models.py:56
#: core/models.py:59
msgid "Aborted"
msgstr "Abgebrochen"
#: core/models.py:57
#: core/models.py:60
msgid "Failed to Start"
msgstr "Start fehlgeschlagen"
#: core/models.py:58
#: core/models.py:61
msgid "Failed to Stop"
msgstr "Stopp fehlgeschlagen"
#: core/models.py:59
#: core/models.py:62
msgid "Notification succeeded"
msgstr "Benachrichtigung erfolgreich"
#: core/models.py:86
#: core/models.py:89
msgid "SCREEN_RECORDING"
msgstr "BILDSCHIRMAUFZEICHNUNG"
#: core/models.py:87
#: core/models.py:90
msgid "TRANSCRIPT"
msgstr "TRANSKRIPT"
#: core/models.py:93
#: core/models.py:96
msgid "Public Access"
msgstr "Öffentlicher Zugriff"
#: core/models.py:94
#: core/models.py:97
msgid "Trusted Access"
msgstr "Vertrauenswürdiger Zugriff"
#: core/models.py:95
#: core/models.py:98
msgid "Restricted Access"
msgstr "Eingeschränkter Zugriff"
#: core/models.py:107
#: core/models.py:110
msgid "id"
msgstr "ID"
#: core/models.py:108
#: core/models.py:111
msgid "primary key for the record as UUID"
msgstr "Primärschlüssel des Eintrags als UUID"
#: core/models.py:114
#: core/models.py:117
msgid "created on"
msgstr "erstellt am"
#: core/models.py:115
#: core/models.py:118
msgid "date and time at which a record was created"
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
#: core/models.py:120
#: core/models.py:123
msgid "updated on"
msgstr "aktualisiert am"
#: core/models.py:121
#: core/models.py:124
msgid "date and time at which a record was last updated"
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
#: core/models.py:141
#: core/models.py:144
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -171,11 +198,11 @@ msgstr ""
"Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen "
"und die Zeichen @/./+/-/_ enthalten."
#: core/models.py:147
#: core/models.py:150
msgid "sub"
msgstr "Sub"
#: core/models.py:149
#: core/models.py:152
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -183,55 +210,55 @@ msgstr ""
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
"Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt."
#: core/models.py:158
#: core/models.py:161
msgid "identity email address"
msgstr "Identitäts-E-Mail-Adresse"
#: core/models.py:163
#: core/models.py:166
msgid "admin email address"
msgstr "Administrator-E-Mail-Adresse"
#: core/models.py:165
#: core/models.py:168
msgid "full name"
msgstr "Vollständiger Name"
#: core/models.py:167
#: core/models.py:170
msgid "short name"
msgstr "Kurzname"
#: core/models.py:173
#: core/models.py:176
msgid "language"
msgstr "Sprache"
#: core/models.py:174
#: core/models.py:177
msgid "The language in which the user wants to see the interface."
msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
#: core/models.py:180
#: core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
#: core/models.py:183
#: core/models.py:186
msgid "device"
msgstr "Gerät"
#: core/models.py:185
#: core/models.py:188
msgid "Whether the user is a device or a real user."
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
#: core/models.py:188
#: core/models.py:191
msgid "staff status"
msgstr "Mitarbeiterstatus"
#: core/models.py:190
#: core/models.py:193
msgid "Whether the user can log into this admin site."
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
#: core/models.py:193
#: core/models.py:196
msgid "active"
msgstr "aktiv"
#: core/models.py:196
#: core/models.py:199
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -239,66 +266,66 @@ msgstr ""
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
"anstelle des Löschens des Kontos."
#: core/models.py:209
#: core/models.py:212
msgid "user"
msgstr "Benutzer"
#: core/models.py:210
#: core/models.py:213
msgid "users"
msgstr "Benutzer"
#: core/models.py:269
#: core/models.py:272
msgid "Resource"
msgstr "Ressource"
#: core/models.py:270
#: core/models.py:273
msgid "Resources"
msgstr "Ressourcen"
#: core/models.py:324
#: core/models.py:331
msgid "Resource access"
msgstr "Ressourcenzugriff"
#: core/models.py:325
#: core/models.py:332
msgid "Resource accesses"
msgstr "Ressourcenzugriffe"
#: core/models.py:331
#: core/models.py:338
msgid "Resource access with this User and Resource already exists."
msgstr ""
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
"bereits."
#: core/models.py:387
#: core/models.py:394
msgid "Visio room configuration"
msgstr "Visio-Raumkonfiguration"
#: core/models.py:388
#: core/models.py:395
msgid "Values for Visio parameters to configure the room."
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
#: core/models.py:395
#: core/models.py:402
msgid "Room PIN code"
msgstr "PIN-Code für den Raum"
#: core/models.py:396
#: core/models.py:403
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
#: core/models.py:402 core/models.py:556
#: core/models.py:409 core/models.py:563
msgid "Room"
msgstr "Raum"
#: core/models.py:403
#: core/models.py:410
msgid "Rooms"
msgstr "Räume"
#: core/models.py:567
#: core/models.py:574
msgid "Worker ID"
msgstr "Worker-ID"
#: core/models.py:569
#: core/models.py:576
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -307,108 +334,149 @@ msgstr ""
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
"ermöglicht."
#: core/models.py:577
#: core/models.py:584
msgid "Recording mode"
msgstr "Aufzeichnungsmodus"
#: core/models.py:578
#: core/models.py:585
msgid "Defines the mode of recording being called."
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
#: core/models.py:583 core/models.py:584
#: core/models.py:590 core/models.py:591
msgid "Recording options"
msgstr "Aufnahmeoptionen"
#: core/models.py:590
#: core/models.py:597
msgid "Recording"
msgstr "Aufzeichnung"
#: core/models.py:591
#: core/models.py:598
msgid "Recordings"
msgstr "Aufzeichnungen"
#: core/models.py:699
#: core/models.py:706
msgid "Recording/user relation"
msgstr "Beziehung Aufzeichnung/Benutzer"
#: core/models.py:700
#: core/models.py:707
msgid "Recording/user relations"
msgstr "Beziehungen Aufzeichnung/Benutzer"
#: core/models.py:706
#: core/models.py:713
msgid "This user is already in this recording."
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
#: core/models.py:712
#: core/models.py:719
msgid "This team is already in this recording."
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
#: core/models.py:718
#: core/models.py:725
msgid "Either user or team must be set, not both."
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
#: core/models.py:735
#: core/models.py:742
msgid "Create rooms"
msgstr "Räume erstellen"
#: core/models.py:736
#: core/models.py:743
msgid "List rooms"
msgstr "Räume auflisten"
#: core/models.py:737
#: core/models.py:744
msgid "Retrieve room details"
msgstr "Raumdetails abrufen"
#: core/models.py:738
#: core/models.py:745
msgid "Update rooms"
msgstr "Räume aktualisieren"
#: core/models.py:739
#: core/models.py:746
msgid "Delete rooms"
msgstr "Räume löschen"
#: core/models.py:752
#: core/models.py:759
msgid "Application name"
msgstr "Anwendungsname"
#: core/models.py:753
#: core/models.py:760
msgid "Descriptive name for this application."
msgstr "Beschreibender Name für diese Anwendung."
#: core/models.py:763
#: core/models.py:770
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr ""
"Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
#: core/models.py:774
#: core/models.py:781
msgid "Application"
msgstr "Anwendung"
#: core/models.py:775
#: core/models.py:782
msgid "Applications"
msgstr "Anwendungen"
#: core/models.py:798
#: core/models.py:805
msgid "Enter a valid domain"
msgstr "Geben Sie eine gültige Domain ein"
#: core/models.py:801
#: core/models.py:808
msgid "Domain"
msgstr "Domain"
#: core/models.py:802
#: core/models.py:809
msgid "Email domain this application can act on behalf of."
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
#: core/models.py:814
#: core/models.py:821
msgid "Application domain"
msgstr "Anwendungsdomain"
#: core/models.py:815
#: core/models.py:822
msgid "Application domains"
msgstr "Anwendungsdomains"
#: core/recording/event/notification.py:94
#: core/models.py:840
msgid "Pending"
msgstr "Ausstehend"
#: core/models.py:848
msgid "Ready"
msgstr "Bereit"
#: core/models.py:854
msgid "Background image"
msgstr "Hintergrundbild"
#: core/models.py:866
msgid "title"
msgstr "Titel"
#: core/models.py:890
msgid "Malware detection info when the analysis status is unsafe."
msgstr ""
"Informationen zur Malware-Erkennung, wenn der Analyse-Status unsicher ist."
#: core/models.py:895
msgid "File"
msgstr "Datei"
#: core/models.py:896
msgid "Files"
msgstr "Dateien"
#: core/models.py:1000
msgid "This file is already hard deleted."
msgstr "Diese Datei wurde bereits endgültig gelöscht."
#: core/models.py:1010
#, 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."
msgstr ""
"Um eine Datei endgültig zu löschen, muss sie zuvor weich gelöscht worden "
"sein."
#: core/recording/event/notification.py:116
msgid "Your recording is ready"
msgstr "Ihre Aufzeichnung ist bereit"
@@ -536,18 +604,18 @@ msgstr ""
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
"an unser Support-Team unter %(support_email)s. "
#: meet/settings.py:169
#: meet/settings.py:223
msgid "English"
msgstr "Englisch"
#: meet/settings.py:170
#: meet/settings.py:224
msgid "French"
msgstr "Französisch"
#: meet/settings.py:171
#: meet/settings.py:225
msgid "Dutch"
msgstr "Niederländisch"
#: meet/settings.py:172
#: meet/settings.py:226
msgid "German"
msgstr "Deutsch"
Binary file not shown.
+161 -94
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
"POT-Creation-Date: 2026-03-12 13:46+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -29,139 +29,166 @@ msgstr "Permissions"
msgid "Important dates"
msgstr "Important dates"
#: core/admin.py:128 core/admin.py:228
#: core/admin.py:132 core/admin.py:275
msgid "No owner"
msgstr "No owner"
#: core/admin.py:131 core/admin.py:231
#: core/admin.py:135 core/admin.py:278
msgid "Multiple owners"
msgstr "Multiple owners"
#: core/admin.py:143
#: core/admin.py:148
msgid "Resend notification to external service"
msgstr "Resend notification to external service"
#: core/admin.py:166
#: core/admin.py:171
#, python-format
msgid "Failed to notify for recording %(id)s"
msgstr "Failed to notify for recording %(id)s"
#: core/admin.py:174
#: core/admin.py:179
#, python-format
msgid "Failed to notify for recording %(id)s: %(error)s"
msgstr "Failed to notify for recording %(id)s: %(error)s"
#: core/admin.py:182
#: core/admin.py:187
#, python-format
msgid "Successfully sent notifications for %(count)s recording(s)."
msgstr "Successfully sent notifications for %(count)s recording(s)."
#: core/admin.py:190
#: core/admin.py:195
#, python-format
msgid "Skipped %(count)s expired recording(s)."
msgstr "Skipped %(count)s expired recording(s)."
#: core/admin.py:294
#: core/admin.py:200
msgid "Mark selected recordings as 'Failed to Stop'"
msgstr "Mark selected recordings as 'Failed to Stop'"
#: core/admin.py:218
#, python-format
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
msgstr "%(count)s recording(s) successfully marked as 'Failed to Stop'."
#: core/admin.py:226
#, fuzzy, python-format
#| msgid "Skipped %(count)s expired recording(s)."
msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "Skipped %(count)s expired recording(s)."
#: core/admin.py:342
msgid "No scopes"
msgstr "No scopes"
#: core/admin.py:296
#: core/admin.py:344
msgid "Scopes"
msgstr "Scopes"
#: core/api/serializers.py:68
#: core/api/filters.py:25
msgid "Creator is me"
msgstr "Creator is me"
#: core/api/serializers.py:88
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr "You must be administrator or owner of a room to add accesses to it."
#: core/models.py:34
#: core/api/serializers.py:516
msgid "This file extension is not allowed."
msgstr "This file extension is not allowed."
#: core/api/serializers.py:533
msgid "You have reached the maximum number of files for this type."
msgstr "You have reached the maximum number of files for this type."
#: core/models.py:37
msgid "Member"
msgstr "Member"
#: core/models.py:35
#: core/models.py:38
msgid "Administrator"
msgstr "Administrator"
#: core/models.py:36
#: core/models.py:39
msgid "Owner"
msgstr "Owner"
#: core/models.py:52
#: core/models.py:55
msgid "Initiated"
msgstr "Initiated"
#: core/models.py:53
#: core/models.py:56
msgid "Active"
msgstr "Active"
#: core/models.py:54
#: core/models.py:57
msgid "Stopped"
msgstr "Stopped"
#: core/models.py:55
#: core/models.py:58
msgid "Saved"
msgstr "Saved"
#: core/models.py:56
#: core/models.py:59
msgid "Aborted"
msgstr "Aborted"
#: core/models.py:57
#: core/models.py:60
msgid "Failed to Start"
msgstr "Failed to Start"
#: core/models.py:58
#: core/models.py:61
msgid "Failed to Stop"
msgstr "Failed to Stop"
#: core/models.py:59
#: core/models.py:62
msgid "Notification succeeded"
msgstr "Notification succeeded"
#: core/models.py:86
#: core/models.py:89
msgid "SCREEN_RECORDING"
msgstr "SCREEN_RECORDING"
#: core/models.py:87
#: core/models.py:90
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:93
#: core/models.py:96
msgid "Public Access"
msgstr "Public Access"
#: core/models.py:94
#: core/models.py:97
msgid "Trusted Access"
msgstr "Trusted Access"
#: core/models.py:95
#: core/models.py:98
msgid "Restricted Access"
msgstr "Restricted Access"
#: core/models.py:107
#: core/models.py:110
msgid "id"
msgstr "id"
#: core/models.py:108
#: core/models.py:111
msgid "primary key for the record as UUID"
msgstr "primary key for the record as UUID"
#: core/models.py:114
#: core/models.py:117
msgid "created on"
msgstr "created on"
#: core/models.py:115
#: core/models.py:118
msgid "date and time at which a record was created"
msgstr "date and time at which a record was created"
#: core/models.py:120
#: core/models.py:123
msgid "updated on"
msgstr "updated on"
#: core/models.py:121
#: core/models.py:124
msgid "date and time at which a record was last updated"
msgstr "date and time at which a record was last updated"
#: core/models.py:141
#: core/models.py:144
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -169,11 +196,11 @@ msgstr ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
#: core/models.py:147
#: core/models.py:150
msgid "sub"
msgstr "sub"
#: core/models.py:149
#: core/models.py:152
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -181,55 +208,55 @@ msgstr ""
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
"characters only."
#: core/models.py:158
#: core/models.py:161
msgid "identity email address"
msgstr "identity email address"
#: core/models.py:163
#: core/models.py:166
msgid "admin email address"
msgstr "admin email address"
#: core/models.py:165
#: core/models.py:168
msgid "full name"
msgstr "full name"
#: core/models.py:167
#: core/models.py:170
msgid "short name"
msgstr "short name"
#: core/models.py:173
#: core/models.py:176
msgid "language"
msgstr "language"
#: core/models.py:174
#: core/models.py:177
msgid "The language in which the user wants to see the interface."
msgstr "The language in which the user wants to see the interface."
#: core/models.py:180
#: core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr "The timezone in which the user wants to see times."
#: core/models.py:183
#: core/models.py:186
msgid "device"
msgstr "device"
#: core/models.py:185
#: core/models.py:188
msgid "Whether the user is a device or a real user."
msgstr "Whether the user is a device or a real user."
#: core/models.py:188
#: core/models.py:191
msgid "staff status"
msgstr "staff status"
#: core/models.py:190
#: core/models.py:193
msgid "Whether the user can log into this admin site."
msgstr "Whether the user can log into this admin site."
#: core/models.py:193
#: core/models.py:196
msgid "active"
msgstr "active"
#: core/models.py:196
#: core/models.py:199
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -237,63 +264,63 @@ msgstr ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
#: core/models.py:209
#: core/models.py:212
msgid "user"
msgstr "user"
#: core/models.py:210
#: core/models.py:213
msgid "users"
msgstr "users"
#: core/models.py:269
#: core/models.py:272
msgid "Resource"
msgstr "Resource"
#: core/models.py:270
#: core/models.py:273
msgid "Resources"
msgstr "Resources"
#: core/models.py:324
#: core/models.py:331
msgid "Resource access"
msgstr "Resource access"
#: core/models.py:325
#: core/models.py:332
msgid "Resource accesses"
msgstr "Resource accesses"
#: core/models.py:331
#: core/models.py:338
msgid "Resource access with this User and Resource already exists."
msgstr "Resource access with this User and Resource already exists."
#: core/models.py:387
#: core/models.py:394
msgid "Visio room configuration"
msgstr "Visio room configuration"
#: core/models.py:388
#: core/models.py:395
msgid "Values for Visio parameters to configure the room."
msgstr "Values for Visio parameters to configure the room."
#: core/models.py:395
#: core/models.py:402
msgid "Room PIN code"
msgstr "Room PIN code"
#: core/models.py:396
#: core/models.py:403
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr "Unique n-digit code that identifies this room in telephony mode."
#: core/models.py:402 core/models.py:556
#: core/models.py:409 core/models.py:563
msgid "Room"
msgstr "Room"
#: core/models.py:403
#: core/models.py:410
msgid "Rooms"
msgstr "Rooms"
#: core/models.py:567
#: core/models.py:574
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:569
#: core/models.py:576
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -301,111 +328,151 @@ msgstr ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
#: core/models.py:577
#: core/models.py:584
msgid "Recording mode"
msgstr "Recording mode"
#: core/models.py:578
#: core/models.py:585
msgid "Defines the mode of recording being called."
msgstr "Defines the mode of recording being called."
#: core/models.py:583 core/models.py:584
#: core/models.py:590 core/models.py:591
msgid "Recording options"
msgstr "Recording options"
#: core/models.py:590
#: core/models.py:597
msgid "Recording"
msgstr "Recording"
#: core/models.py:591
#: core/models.py:598
msgid "Recordings"
msgstr "Recordings"
#: core/models.py:699
#: core/models.py:706
msgid "Recording/user relation"
msgstr "Recording/user relation"
#: core/models.py:700
#: core/models.py:707
msgid "Recording/user relations"
msgstr "Recording/user relations"
#: core/models.py:706
#: core/models.py:713
msgid "This user is already in this recording."
msgstr "This user is already in this recording."
#: core/models.py:712
#: core/models.py:719
msgid "This team is already in this recording."
msgstr "This team is already in this recording."
#: core/models.py:718
#: core/models.py:725
msgid "Either user or team must be set, not both."
msgstr "Either user or team must be set, not both."
#: core/models.py:735
#: core/models.py:742
#, fuzzy
#| msgid "created on"
msgid "Create rooms"
msgstr "Create rooms"
#: core/models.py:736
#: core/models.py:743
msgid "List rooms"
msgstr "List rooms"
#: core/models.py:737
#: core/models.py:744
msgid "Retrieve room details"
msgstr "Retrieve room details"
#: core/models.py:738
#: core/models.py:745
#, fuzzy
#| msgid "updated on"
msgid "Update rooms"
msgstr "Update rooms"
#: core/models.py:739
#: core/models.py:746
msgid "Delete rooms"
msgstr "Delete rooms"
#: core/models.py:752
#: core/models.py:759
msgid "Application name"
msgstr "Application name"
#: core/models.py:753
#: core/models.py:760
msgid "Descriptive name for this application."
msgstr "Descriptive name for this application."
#: core/models.py:763
#: core/models.py:770
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr "Hashed on Save. Copy it now if this is a new secret."
#: core/models.py:774
#: core/models.py:781
msgid "Application"
msgstr "Application"
#: core/models.py:775
#: core/models.py:782
msgid "Applications"
msgstr "Applications"
#: core/models.py:798
#: core/models.py:805
msgid "Enter a valid domain"
msgstr "Enter a valid domain"
#: core/models.py:801
#: core/models.py:808
msgid "Domain"
msgstr "Domain"
#: core/models.py:802
#: core/models.py:809
msgid "Email domain this application can act on behalf of."
msgstr "Email domain this application can act on behalf of."
#: core/models.py:814
#: core/models.py:821
msgid "Application domain"
msgstr "Application domain"
#: core/models.py:815
#: core/models.py:822
msgid "Application domains"
msgstr "Application domains"
#: core/recording/event/notification.py:94
#: core/models.py:840
#, fuzzy
#| msgid "Recording"
msgid "Pending"
msgstr "Pending"
#: core/models.py:848
msgid "Ready"
msgstr "Ready"
#: core/models.py:854
msgid "Background image"
msgstr "Background image"
#: core/models.py:866
msgid "title"
msgstr "title"
#: core/models.py:890
msgid "Malware detection info when the analysis status is unsafe."
msgstr "Malware detection info when the analysis status is unsafe."
#: core/models.py:895
msgid "File"
msgstr "File"
#: core/models.py:896
msgid "Files"
msgstr "Files"
#: core/models.py:1000
#, fuzzy
#| msgid "This user is already in this recording."
msgid "This file is already hard deleted."
msgstr "This file is already hard deleted."
#: core/models.py:1010
msgid "To hard delete a file, it must first be soft deleted."
msgstr "To hard delete a file, it must first be soft deleted."
#: core/recording/event/notification.py:116
msgid "Your recording is ready"
msgstr "Your recording is ready"
@@ -533,18 +600,18 @@ msgstr ""
" If you have any questions or need assistance, please contact our support "
"team at %(support_email)s. "
#: meet/settings.py:169
#: meet/settings.py:223
msgid "English"
msgstr "English"
#: meet/settings.py:170
#: meet/settings.py:224
msgid "French"
msgstr "French"
#: meet/settings.py:171
#: meet/settings.py:225
msgid "Dutch"
msgstr "Dutch"
#: meet/settings.py:172
#: meet/settings.py:226
msgid "German"
msgstr "German"
Binary file not shown.
+163 -94
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
"POT-Creation-Date: 2026-03-12 13:46+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -29,143 +29,171 @@ msgstr "Permissions"
msgid "Important dates"
msgstr "Dates importantes"
#: core/admin.py:128 core/admin.py:228
#: core/admin.py:132 core/admin.py:275
msgid "No owner"
msgstr "Pas de propriétaire"
#: core/admin.py:131 core/admin.py:231
#: core/admin.py:135 core/admin.py:278
msgid "Multiple owners"
msgstr "Plusieurs propriétaires"
#: core/admin.py:143
#: core/admin.py:148
msgid "Resend notification to external service"
msgstr "Renvoyer la notification au service externe"
#: core/admin.py:166
#: core/admin.py:171
#, python-format
msgid "Failed to notify for recording %(id)s"
msgstr "Échec de la notification pour lenregistrement %(id)s"
#: core/admin.py:174
#: core/admin.py:179
#, python-format
msgid "Failed to notify for recording %(id)s: %(error)s"
msgstr "Échec de la notification pour lenregistrement %(id)s : %(error)s"
#: core/admin.py:182
#: core/admin.py:187
#, python-format
msgid "Successfully sent notifications for %(count)s recording(s)."
msgstr "Notifications envoyées avec succès pour %(count)s enregistrement(s)."
#: core/admin.py:190
#: core/admin.py:195
#, python-format
msgid "Skipped %(count)s expired recording(s)."
msgstr "%(count)s enregistrement(s) expiré(s) ignoré(s)."
#: core/admin.py:294
#: core/admin.py:200
msgid "Mark selected recordings as 'Failed to Stop'"
msgstr "Marquer les enregistrements sélectionnés comme « Échec darrêt »"
#: core/admin.py:218
#, python-format
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
msgstr ""
"%(count)s enregistrement(s) marqué(s) avec succès comme « Échec darrêt »."
#: core/admin.py:226
#, fuzzy, python-format
#| msgid "Skipped %(count)s expired recording(s)."
msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "%(count)s enregistrement(s) avec un statut inéligible ignoré(s)."
#: core/admin.py:342
msgid "No scopes"
msgstr "Aucun scopes"
#: core/admin.py:296
#: core/admin.py:344
msgid "Scopes"
msgstr "Scopes"
#: core/api/serializers.py:68
#: core/api/filters.py:25
msgid "Creator is me"
msgstr "Je suis le créateur"
#: core/api/serializers.py:88
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
"des accès."
#: core/models.py:34
#: core/api/serializers.py:516
msgid "This file extension is not allowed."
msgstr "Cette extension n'est pas autorisée"
#: core/api/serializers.py:533
msgid "You have reached the maximum number of files for this type."
msgstr "Vous avez atteint le nombre maximum de fichiers de ce type"
#: core/models.py:37
msgid "Member"
msgstr "Membre"
#: core/models.py:35
#: core/models.py:38
msgid "Administrator"
msgstr "Administrateur"
#: core/models.py:36
#: core/models.py:39
msgid "Owner"
msgstr "Propriétaire"
#: core/models.py:52
#: core/models.py:55
msgid "Initiated"
msgstr "Initié"
#: core/models.py:53
#: core/models.py:56
msgid "Active"
msgstr "Actif"
#: core/models.py:54
#: core/models.py:57
msgid "Stopped"
msgstr "Arrêté"
#: core/models.py:55
#: core/models.py:58
msgid "Saved"
msgstr "Enregistré"
#: core/models.py:56
#: core/models.py:59
msgid "Aborted"
msgstr "Abandonné"
#: core/models.py:57
#: core/models.py:60
msgid "Failed to Start"
msgstr "Échec au démarrage"
#: core/models.py:58
#: core/models.py:61
msgid "Failed to Stop"
msgstr "Échec à l'arrêt"
#: core/models.py:59
#: core/models.py:62
msgid "Notification succeeded"
msgstr "Notification réussie"
#: core/models.py:86
#: core/models.py:89
msgid "SCREEN_RECORDING"
msgstr "ENREGISTREMENT_ÉCRAN"
#: core/models.py:87
#: core/models.py:90
msgid "TRANSCRIPT"
msgstr "TRANSCRIPTION"
#: core/models.py:93
#: core/models.py:96
msgid "Public Access"
msgstr "Accès public"
#: core/models.py:94
#: core/models.py:97
msgid "Trusted Access"
msgstr "Accès de confiance"
#: core/models.py:95
#: core/models.py:98
msgid "Restricted Access"
msgstr "Accès restreint"
#: core/models.py:107
#: core/models.py:110
msgid "id"
msgstr "id"
#: core/models.py:108
#: core/models.py:111
msgid "primary key for the record as UUID"
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
#: core/models.py:114
#: core/models.py:117
msgid "created on"
msgstr "créé le"
#: core/models.py:115
#: core/models.py:118
msgid "date and time at which a record was created"
msgstr "date et heure auxquelles un enregistrement a été créé"
#: core/models.py:120
#: core/models.py:123
msgid "updated on"
msgstr "mis à jour le"
#: core/models.py:121
#: core/models.py:124
msgid "date and time at which a record was last updated"
msgstr ""
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
"fois"
#: core/models.py:141
#: core/models.py:144
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -173,11 +201,11 @@ msgstr ""
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des "
"chiffres et les caractères @/./+/-/_."
#: core/models.py:147
#: core/models.py:150
msgid "sub"
msgstr "sub"
#: core/models.py:149
#: core/models.py:152
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -185,55 +213,55 @@ msgstr ""
"Optionnel pour les utilisateurs en attente ; requis lors de l'activation du "
"compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement."
#: core/models.py:158
#: core/models.py:161
msgid "identity email address"
msgstr "adresse e-mail d'identité"
#: core/models.py:163
#: core/models.py:166
msgid "admin email address"
msgstr "adresse e-mail d'administrateur"
#: core/models.py:165
#: core/models.py:168
msgid "full name"
msgstr "nom complet"
#: core/models.py:167
#: core/models.py:170
msgid "short name"
msgstr "nom court"
#: core/models.py:173
#: core/models.py:176
msgid "language"
msgstr "langue"
#: core/models.py:174
#: core/models.py:177
msgid "The language in which the user wants to see the interface."
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
#: core/models.py:180
#: core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
#: core/models.py:183
#: core/models.py:186
msgid "device"
msgstr "appareil"
#: core/models.py:185
#: core/models.py:188
msgid "Whether the user is a device or a real user."
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
#: core/models.py:188
#: core/models.py:191
msgid "staff status"
msgstr "statut du personnel"
#: core/models.py:190
#: core/models.py:193
msgid "Whether the user can log into this admin site."
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
#: core/models.py:193
#: core/models.py:196
msgid "active"
msgstr "actif"
#: core/models.py:196
#: core/models.py:199
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -241,65 +269,65 @@ msgstr ""
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
"au lieu de supprimer des comptes."
#: core/models.py:209
#: core/models.py:212
msgid "user"
msgstr "utilisateur"
#: core/models.py:210
#: core/models.py:213
msgid "users"
msgstr "utilisateurs"
#: core/models.py:269
#: core/models.py:272
msgid "Resource"
msgstr "Ressource"
#: core/models.py:270
#: core/models.py:273
msgid "Resources"
msgstr "Ressources"
#: core/models.py:324
#: core/models.py:331
msgid "Resource access"
msgstr "Accès aux ressources"
#: core/models.py:325
#: core/models.py:332
msgid "Resource accesses"
msgstr "Accès aux ressources"
#: core/models.py:331
#: core/models.py:338
msgid "Resource access with this User and Resource already exists."
msgstr ""
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
#: core/models.py:387
#: core/models.py:394
msgid "Visio room configuration"
msgstr "Configuration de la salle de visioconférence"
#: core/models.py:388
#: core/models.py:395
msgid "Values for Visio parameters to configure the room."
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
#: core/models.py:395
#: core/models.py:402
msgid "Room PIN code"
msgstr "Code PIN de la salle"
#: core/models.py:396
#: core/models.py:403
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Code unique à n chiffres qui identifie cette salle en mode téléphonique."
#: core/models.py:402 core/models.py:556
#: core/models.py:409 core/models.py:563
msgid "Room"
msgstr "Salle"
#: core/models.py:403
#: core/models.py:410
msgid "Rooms"
msgstr "Salles"
#: core/models.py:567
#: core/models.py:574
msgid "Worker ID"
msgstr "ID du Worker"
#: core/models.py:569
#: core/models.py:576
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -307,109 +335,150 @@ msgstr ""
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
"conservé même lorsque le Worker s'arrête, permettant un suivi facile."
#: core/models.py:577
#: core/models.py:584
msgid "Recording mode"
msgstr "Mode d'enregistrement"
#: core/models.py:578
#: core/models.py:585
msgid "Defines the mode of recording being called."
msgstr "Définit le mode d'enregistrement appelé."
#: core/models.py:583 core/models.py:584
#: core/models.py:590 core/models.py:591
msgid "Recording options"
msgstr "Options d'enregistrement"
#: core/models.py:590
#: core/models.py:597
msgid "Recording"
msgstr "Enregistrement"
#: core/models.py:591
#: core/models.py:598
msgid "Recordings"
msgstr "Enregistrements"
#: core/models.py:699
#: core/models.py:706
msgid "Recording/user relation"
msgstr "Relation enregistrement/utilisateur"
#: core/models.py:700
#: core/models.py:707
msgid "Recording/user relations"
msgstr "Relations enregistrement/utilisateur"
#: core/models.py:706
#: core/models.py:713
msgid "This user is already in this recording."
msgstr "Cet utilisateur est déjà dans cet enregistrement."
#: core/models.py:712
#: core/models.py:719
msgid "This team is already in this recording."
msgstr "Cette équipe est déjà dans cet enregistrement."
#: core/models.py:718
#: core/models.py:725
msgid "Either user or team must be set, not both."
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
#: core/models.py:735
#: core/models.py:742
msgid "Create rooms"
msgstr "Créer des salles"
#: core/models.py:736
#: core/models.py:743
msgid "List rooms"
msgstr "Lister les salles"
#: core/models.py:737
#: core/models.py:744
msgid "Retrieve room details"
msgstr "Afficher les détails dune salle"
#: core/models.py:738
#: core/models.py:745
msgid "Update rooms"
msgstr "Mettre à jour les salles"
#: core/models.py:739
#: core/models.py:746
msgid "Delete rooms"
msgstr "Supprimer les salles"
#: core/models.py:752
#: core/models.py:759
msgid "Application name"
msgstr "Nom de lapplication"
#: core/models.py:753
#: core/models.py:760
msgid "Descriptive name for this application."
msgstr "Nom descriptif de cette application."
#: core/models.py:763
#: core/models.py:770
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr ""
"Haché lors de lenregistrement. Copiez-le maintenant sil sagit dun "
"nouveau secret."
#: core/models.py:774
#: core/models.py:781
msgid "Application"
msgstr "Application"
#: core/models.py:775
#: core/models.py:782
msgid "Applications"
msgstr "Applications"
#: core/models.py:798
#: core/models.py:805
msgid "Enter a valid domain"
msgstr "Saisissez un domaine valide"
#: core/models.py:801
#: core/models.py:808
msgid "Domain"
msgstr "Domaine"
#: core/models.py:802
#: core/models.py:809
msgid "Email domain this application can act on behalf of."
msgstr "Domaine de messagerie au nom duquel cette application peut agir."
#: core/models.py:814
#: core/models.py:821
msgid "Application domain"
msgstr "Domaine dapplication"
#: core/models.py:815
#: core/models.py:822
msgid "Application domains"
msgstr "Domaines dapplication"
#: core/recording/event/notification.py:94
#: core/models.py:840
msgid "Pending"
msgstr "En attente"
#: core/models.py:848
msgid "Ready"
msgstr "Prêt"
#: core/models.py:854
msgid "Background image"
msgstr "Image de fond"
#: core/models.py:866
msgid "title"
msgstr "Titre"
#: core/models.py:890
msgid "Malware detection info when the analysis status is unsafe."
msgstr ""
"Information concernant la détection de Malware cand le statut n'est pas sain"
#: core/models.py:895
msgid "File"
msgstr "Fichier"
#: core/models.py:896
msgid "Files"
msgstr "Fichiers"
#: core/models.py:1000
#, fuzzy
#| msgid "This user is already in this recording."
msgid "This file is already hard deleted."
msgstr "Ce fichier a été supprimé."
#: core/models.py:1010
msgid "To hard delete a file, it must first be soft deleted."
msgstr ""
"Pour supprimer définitivement un fichier il doit d'abord avoir été marqué "
"comme supprimé (soft delete)"
#: core/recording/event/notification.py:116
msgid "Your recording is ready"
msgstr "Votre enregistrement est prêt"
@@ -537,18 +606,18 @@ msgstr ""
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
"équipe d'assistance à %(support_email)s. "
#: meet/settings.py:169
#: meet/settings.py:223
msgid "English"
msgstr "Anglais"
#: meet/settings.py:170
#: meet/settings.py:224
msgid "French"
msgstr "Français"
#: meet/settings.py:171
#: meet/settings.py:225
msgid "Dutch"
msgstr "Néerlandais"
#: meet/settings.py:172
#: meet/settings.py:226
msgid "German"
msgstr "Allemand"
Binary file not shown.
+161 -94
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
"POT-Creation-Date: 2026-03-12 13:46+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -29,140 +29,167 @@ msgstr "Rechten"
msgid "Important dates"
msgstr "Belangrijke datums"
#: core/admin.py:128 core/admin.py:228
#: core/admin.py:132 core/admin.py:275
msgid "No owner"
msgstr "Geen eigenaar"
#: core/admin.py:131 core/admin.py:231
#: core/admin.py:135 core/admin.py:278
msgid "Multiple owners"
msgstr "Meerdere eigenaren"
#: core/admin.py:143
#: core/admin.py:148
msgid "Resend notification to external service"
msgstr "Melding opnieuw verzenden naar externe dienst"
#: core/admin.py:166
#: core/admin.py:171
#, python-format
msgid "Failed to notify for recording %(id)s"
msgstr "Melding voor opname %(id)s mislukt"
#: core/admin.py:174
#: core/admin.py:179
#, python-format
msgid "Failed to notify for recording %(id)s: %(error)s"
msgstr "Melding voor opname %(id)s mislukt: %(error)s"
#: core/admin.py:182
#: core/admin.py:187
#, python-format
msgid "Successfully sent notifications for %(count)s recording(s)."
msgstr "Meldingen succesvol verzonden voor %(count)s opname(n)."
#: core/admin.py:190
#: core/admin.py:195
#, python-format
msgid "Skipped %(count)s expired recording(s)."
msgstr "%(count)s verlopen opname(n) overgeslagen."
#: core/admin.py:294
#: core/admin.py:200
msgid "Mark selected recordings as 'Failed to Stop'"
msgstr "Geselecteerde opnames markeren als 'Mislukt bij stoppen'"
#: core/admin.py:218
#, python-format
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
msgstr "%(count)s opname(s) succesvol gemarkeerd als 'Mislukt bij stoppen'."
#: core/admin.py:226
#, fuzzy, python-format
#| msgid "Skipped %(count)s expired recording(s)."
msgid "Skipped %(count)s recording(s) with an ineligible status."
msgstr "%(count)s opname(s) met een niet-toegestane status overgeslagen."
#: core/admin.py:342
msgid "No scopes"
msgstr "Geen scopes"
#: core/admin.py:296
#: core/admin.py:344
msgid "Scopes"
msgstr "Scopes"
#: core/api/serializers.py:68
#: core/api/filters.py:25
msgid "Creator is me"
msgstr "Maker ben ik"
#: core/api/serializers.py:88
msgid "You must be administrator or owner of a room to add accesses to it."
msgstr ""
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
#: core/models.py:34
#: core/api/serializers.py:516
msgid "This file extension is not allowed."
msgstr "Deze bestandsextensie is niet toegestaan."
#: core/api/serializers.py:533
msgid "You have reached the maximum number of files for this type."
msgstr "Het maximale aantal bestanden voor dit type is bereikt."
#: core/models.py:37
msgid "Member"
msgstr "Lid"
#: core/models.py:35
#: core/models.py:38
msgid "Administrator"
msgstr "Beheerder"
#: core/models.py:36
#: core/models.py:39
msgid "Owner"
msgstr "Eigenaar"
#: core/models.py:52
#: core/models.py:55
msgid "Initiated"
msgstr "Gestart"
#: core/models.py:53
#: core/models.py:56
msgid "Active"
msgstr "Actief"
#: core/models.py:54
#: core/models.py:57
msgid "Stopped"
msgstr "Gestopt"
#: core/models.py:55
#: core/models.py:58
msgid "Saved"
msgstr "Opgeslagen"
#: core/models.py:56
#: core/models.py:59
msgid "Aborted"
msgstr "Afgebroken"
#: core/models.py:57
#: core/models.py:60
msgid "Failed to Start"
msgstr "Starten mislukt"
#: core/models.py:58
#: core/models.py:61
msgid "Failed to Stop"
msgstr "Stoppen mislukt"
#: core/models.py:59
#: core/models.py:62
msgid "Notification succeeded"
msgstr "Notificatie geslaagd"
#: core/models.py:86
#: core/models.py:89
msgid "SCREEN_RECORDING"
msgstr "SCHERM_OPNAME"
#: core/models.py:87
#: core/models.py:90
msgid "TRANSCRIPT"
msgstr "TRANSCRIPT"
#: core/models.py:93
#: core/models.py:96
msgid "Public Access"
msgstr "Openbare toegang"
#: core/models.py:94
#: core/models.py:97
msgid "Trusted Access"
msgstr "Vertrouwde toegang"
#: core/models.py:95
#: core/models.py:98
msgid "Restricted Access"
msgstr "Beperkte toegang"
#: core/models.py:107
#: core/models.py:110
msgid "id"
msgstr "id"
#: core/models.py:108
#: core/models.py:111
msgid "primary key for the record as UUID"
msgstr "primaire sleutel voor het record als UUID"
#: core/models.py:114
#: core/models.py:117
msgid "created on"
msgstr "aangemaakt op"
#: core/models.py:115
#: core/models.py:118
msgid "date and time at which a record was created"
msgstr "datum en tijd waarop een record werd aangemaakt"
#: core/models.py:120
#: core/models.py:123
msgid "updated on"
msgstr "bijgewerkt op"
#: core/models.py:121
#: core/models.py:124
msgid "date and time at which a record was last updated"
msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt"
#: core/models.py:141
#: core/models.py:144
msgid ""
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
"_ characters."
@@ -170,11 +197,11 @@ msgstr ""
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
"_ tekens bevatten."
#: core/models.py:147
#: core/models.py:150
msgid "sub"
msgstr "sub"
#: core/models.py:149
#: core/models.py:152
msgid ""
"Optional for pending users; required upon account activation. 255 characters "
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
@@ -182,55 +209,55 @@ msgstr ""
"Optioneel voor gebruikers in afwachting; vereist bij accountactivering. "
"Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan."
#: core/models.py:158
#: core/models.py:161
msgid "identity email address"
msgstr "identiteit e-mailadres"
#: core/models.py:163
#: core/models.py:166
msgid "admin email address"
msgstr "beheerder e-mailadres"
#: core/models.py:165
#: core/models.py:168
msgid "full name"
msgstr "volledige naam"
#: core/models.py:167
#: core/models.py:170
msgid "short name"
msgstr "korte naam"
#: core/models.py:173
#: core/models.py:176
msgid "language"
msgstr "taal"
#: core/models.py:174
#: core/models.py:177
msgid "The language in which the user wants to see the interface."
msgstr "De taal waarin de gebruiker de interface wil zien."
#: core/models.py:180
#: core/models.py:183
msgid "The timezone in which the user wants to see times."
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
#: core/models.py:183
#: core/models.py:186
msgid "device"
msgstr "apparaat"
#: core/models.py:185
#: core/models.py:188
msgid "Whether the user is a device or a real user."
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
#: core/models.py:188
#: core/models.py:191
msgid "staff status"
msgstr "personeelsstatus"
#: core/models.py:190
#: core/models.py:193
msgid "Whether the user can log into this admin site."
msgstr "Of de gebruiker kan inloggen op deze beheersite."
#: core/models.py:193
#: core/models.py:196
msgid "active"
msgstr "actief"
#: core/models.py:196
#: core/models.py:199
msgid ""
"Whether this user should be treated as active. Unselect this instead of "
"deleting accounts."
@@ -238,64 +265,64 @@ msgstr ""
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
"plaats van accounts te verwijderen."
#: core/models.py:209
#: core/models.py:212
msgid "user"
msgstr "gebruiker"
#: core/models.py:210
#: core/models.py:213
msgid "users"
msgstr "gebruikers"
#: core/models.py:269
#: core/models.py:272
msgid "Resource"
msgstr "Bron"
#: core/models.py:270
#: core/models.py:273
msgid "Resources"
msgstr "Bronnen"
#: core/models.py:324
#: core/models.py:331
msgid "Resource access"
msgstr "Brontoegang"
#: core/models.py:325
#: core/models.py:332
msgid "Resource accesses"
msgstr "Brontoegangsrechten"
#: core/models.py:331
#: core/models.py:338
msgid "Resource access with this User and Resource already exists."
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
#: core/models.py:387
#: core/models.py:394
msgid "Visio room configuration"
msgstr "Visio-ruimteconfiguratie"
#: core/models.py:388
#: core/models.py:395
msgid "Values for Visio parameters to configure the room."
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
#: core/models.py:395
#: core/models.py:402
msgid "Room PIN code"
msgstr "Pincode van de kamer"
#: core/models.py:396
#: core/models.py:403
msgid "Unique n-digit code that identifies this room in telephony mode."
msgstr ""
"Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
#: core/models.py:402 core/models.py:556
#: core/models.py:409 core/models.py:563
msgid "Room"
msgstr "Ruimte"
#: core/models.py:403
#: core/models.py:410
msgid "Rooms"
msgstr "Ruimtes"
#: core/models.py:567
#: core/models.py:574
msgid "Worker ID"
msgstr "Worker ID"
#: core/models.py:569
#: core/models.py:576
msgid ""
"Enter an identifier for the worker recording.This ID is retained even when "
"the worker stops, allowing for easy tracking."
@@ -303,108 +330,148 @@ msgstr ""
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
#: core/models.py:577
#: core/models.py:584
msgid "Recording mode"
msgstr "Opnamemodus"
#: core/models.py:578
#: core/models.py:585
msgid "Defines the mode of recording being called."
msgstr "Definieert de modus van opname die wordt aangeroepen."
#: core/models.py:583 core/models.py:584
#: core/models.py:590 core/models.py:591
msgid "Recording options"
msgstr "Opnameopties"
#: core/models.py:590
#: core/models.py:597
msgid "Recording"
msgstr "Opname"
#: core/models.py:591
#: core/models.py:598
msgid "Recordings"
msgstr "Opnames"
#: core/models.py:699
#: core/models.py:706
msgid "Recording/user relation"
msgstr "Opname/gebruiker-relatie"
#: core/models.py:700
#: core/models.py:707
msgid "Recording/user relations"
msgstr "Opname/gebruiker-relaties"
#: core/models.py:706
#: core/models.py:713
msgid "This user is already in this recording."
msgstr "Deze gebruiker is al in deze opname."
#: core/models.py:712
#: core/models.py:719
msgid "This team is already in this recording."
msgstr "Dit team is al in deze opname."
#: core/models.py:718
#: core/models.py:725
msgid "Either user or team must be set, not both."
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
#: core/models.py:735
#: core/models.py:742
msgid "Create rooms"
msgstr "Ruimtes aanmaken"
#: core/models.py:736
#: core/models.py:743
msgid "List rooms"
msgstr "Ruimtes weergeven"
#: core/models.py:737
#: core/models.py:744
msgid "Retrieve room details"
msgstr "Details van een ruimte ophalen"
#: core/models.py:738
#: core/models.py:745
msgid "Update rooms"
msgstr "Ruimtes bijwerken"
#: core/models.py:739
#: core/models.py:746
msgid "Delete rooms"
msgstr "Ruimtes verwijderen"
#: core/models.py:752
#: core/models.py:759
msgid "Application name"
msgstr "Naam van de applicatie"
#: core/models.py:753
#: core/models.py:760
msgid "Descriptive name for this application."
msgstr "Beschrijvende naam voor deze applicatie."
#: core/models.py:763
#: core/models.py:770
msgid "Hashed on Save. Copy it now if this is a new secret."
msgstr ""
"Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
#: core/models.py:774
#: core/models.py:781
msgid "Application"
msgstr "Applicatie"
#: core/models.py:775
#: core/models.py:782
msgid "Applications"
msgstr "Applicaties"
#: core/models.py:798
#: core/models.py:805
msgid "Enter a valid domain"
msgstr "Voer een geldig domein in"
#: core/models.py:801
#: core/models.py:808
msgid "Domain"
msgstr "Domein"
#: core/models.py:802
#: core/models.py:809
msgid "Email domain this application can act on behalf of."
msgstr "E-maildomein namens welke deze applicatie kan handelen."
#: core/models.py:814
#: core/models.py:821
msgid "Application domain"
msgstr "Applicatiedomein"
#: core/models.py:815
#: core/models.py:822
msgid "Application domains"
msgstr "Applicatiedomeinen"
#: core/recording/event/notification.py:94
#: core/models.py:840
msgid "Pending"
msgstr "In afwachting"
#: core/models.py:848
msgid "Ready"
msgstr "Klaar"
#: core/models.py:854
msgid "Background image"
msgstr "Achtergrondafbeelding"
#: core/models.py:866
msgid "title"
msgstr "Titel"
#: core/models.py:890
msgid "Malware detection info when the analysis status is unsafe."
msgstr "Informatie over malwaredetectie wanneer de analysestatus onveilig is."
#: core/models.py:895
msgid "File"
msgstr "Bestand"
#: core/models.py:896
msgid "Files"
msgstr "Bestanden"
#: core/models.py:1000
msgid "This file is already hard deleted."
msgstr "Dit bestand is al definitief verwijderd."
#: core/models.py:1010
#, 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."
msgstr ""
"Om een bestand definitief te verwijderen, moet het eerst zacht verwijderd "
"zijn."
#: core/recording/event/notification.py:116
msgid "Your recording is ready"
msgstr "Je opname is klaar"
@@ -532,18 +599,18 @@ msgstr ""
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
"team via %(support_email)s. "
#: meet/settings.py:169
#: meet/settings.py:223
msgid "English"
msgstr "Engels"
#: meet/settings.py:170
#: meet/settings.py:224
msgid "French"
msgstr "Frans"
#: meet/settings.py:171
#: meet/settings.py:225
msgid "Dutch"
msgstr "Nederlands"
#: meet/settings.py:172
#: meet/settings.py:226
msgid "German"
msgstr "Duits"
+58 -3
View File
@@ -28,6 +28,10 @@ from sentry_sdk.integrations.logging import ignore_logger
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = path.dirname(path.dirname(path.abspath(__file__)))
KB = 1024
MB = 1024 * KB
GB = 1024 * MB
def get_release():
"""
@@ -121,6 +125,9 @@ class Base(Configuration):
STATIC_ROOT = path.join(DATA_DIR, "static")
MEDIA_URL = "/media/"
MEDIA_ROOT = path.join(DATA_DIR, "media")
MEDIA_BASE_URL = values.Value(
"", environ_name="MEDIA_BASE_URL", environ_prefix=None
)
SITE_ID = 1
@@ -154,6 +161,48 @@ class Base(Configuration):
environ_name="AWS_STORAGE_BUCKET_NAME",
environ_prefix=None,
)
AWS_S3_SIGNATURE_VERSION = values.Value(
"s3v4",
environ_name="AWS_S3_SIGNATURE_VERSION",
environ_prefix=None,
)
AWS_S3_UPLOAD_POLICY_EXPIRATION = values.Value(
60, # 1 minute
environ_name="AWS_S3_UPLOAD_POLICY_EXPIRATION",
environ_prefix=None,
)
AWS_S3_DOMAIN_REPLACE = values.Value(
environ_name="AWS_S3_DOMAIN_REPLACE",
environ_prefix=None,
)
FILE_UPLOAD_ENABLED = values.BooleanValue(
# False to avoid a breaking change for now
default=False,
environ_name="FILE_UPLOAD_ENABLED",
environ_prefix=None,
)
FILE_UPLOAD_PATH = values.Value(
"files", environ_name="FILE_UPLOAD_PATH", environ_prefix=None
)
FILE_UPLOAD_APPLY_RESTRICTIONS = values.BooleanValue(
default=True, environ_name="FILE_UPLOAD_APPLY_RESTRICTIONS", environ_prefix=None
)
FILE_UPLOAD_RESTRICTIONS = values.DictValue(
{
"background_image": {
"max_size": 2 * MB,
"max_count_by_user": 10,
"allowed_extensions": [".jpeg", ".jpg", ".png"],
"allowed_mimetypes": ["image/jpeg", "image/png"],
},
},
environ_name="FILE_UPLOAD_RESTRICTIONS",
environ_prefix=None,
)
# Internationalization
# https://docs.djangoproject.com/en/3.1/topics/i18n/
@@ -394,8 +443,13 @@ class Base(Configuration):
THUMBNAIL_ALIASES = {}
# Celery
CELERY_BROKER_URL = values.Value("redis://redis:6379/0")
CELERY_BROKER_TRANSPORT_OPTIONS = values.DictValue({})
# Defaults to False to avoid breaking change, async task will be run
# synchronously
CELERY_ENABLED = values.BooleanValue(False, environ_prefix=None)
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(False, environ_prefix=None)
CELERY_TASK_DEFAULT_QUEUE = values.Value("meet-backend", environ_prefix=None)
CELERY_BROKER_URL = values.Value("redis://redis:6379/0", environ_prefix=None)
CELERY_BROKER_TRANSPORT_OPTIONS = values.DictValue({}, environ_prefix=None)
# Session
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
@@ -926,7 +980,8 @@ class Test(Base):
APPLICATION_JWT_SECRET_KEY = "devKey" # noqa:S105
APPLICATION_JWT_AUDIENCE = "Test inc."
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
CELERY_TASK_ALWAYS_EAGER = True
FILE_UPLOAD_ENABLED = True
def __init__(self):
# pylint: disable=invalid-name
+14 -12
View File
@@ -2,8 +2,8 @@
# Meet package
#
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
requires = ["uv_build>=0.10.9,<0.11.0"]
build-backend = "uv_build"
[project]
name = "meet"
@@ -21,8 +21,7 @@ classifiers = [
]
description = "A simple video and phone conferencing tool, powered by LiveKit"
keywords = ["Django", "Contacts", "Templates", "RBAC"]
license = { file = "LICENSE" }
readme = "README.md"
license = "MIT"
requires-python = ">=3.13"
dependencies = [
"boto3==1.42.49",
@@ -33,6 +32,7 @@ dependencies = [
"django-configurations==2.5.1",
"django-cors-headers==4.9.0",
"django-countries==8.2.0",
"django-filter==25.2",
"django-lasuite[all]==0.0.24",
"django-parler==2.3",
"redis==5.2.1",
@@ -52,8 +52,9 @@ dependencies = [
"nested-multipart-parser==1.6.0",
"psycopg[binary]==3.3.2",
"pydantic==2.12.4",
"PyJWT==2.11.0",
"PyJWT==2.12.0",
"python-frontmatter==1.1.0",
"python-magic==0.4.27",
"requests==2.32.5",
"sentry-sdk==2.53.0",
"whitenoise==6.11.0",
@@ -68,7 +69,7 @@ dependencies = [
"Homepage" = "https://github.com/suitenumerique/meet"
"Repository" = "https://github.com/suitenumerique/meet"
[project.optional-dependencies]
[dependency-groups]
dev = [
"django-extensions==4.1",
"drf-spectacular-sidecar==2026.1.1",
@@ -88,12 +89,13 @@ dev = [
"types-requests==2.32.4.20260107",
]
[tool.setuptools]
packages = { find = { where = ["."], exclude = ["tests"] } }
zip-safe = true
[tool.distutils.bdist_wheel]
universal = true
[tool.uv.build-backend]
module-root = ""
source-exclude = [
"**/tests/**",
"**/test_*.py",
"**/tests.py",
]
[tool.ruff]
exclude = [
+2365
View File
File diff suppressed because it is too large Load Diff
+160 -85
View File
@@ -3959,9 +3959,9 @@
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.2.tgz",
"integrity": "sha512-g0dF8P1e2QYPOj1gu7s/3LVP6kze9A7m6x0BZ9iTdXK8N5c2V7cpBKHV3/9A4Zd8xxavdhK0t4PnqjkqVmUc9Q==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
"cpu": [
"arm"
],
@@ -3973,9 +3973,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.2.tgz",
"integrity": "sha512-Yt5MKrOosSbSaAK5Y4J+vSiID57sOvpBNBR6K7xAaQvk3MkcNVV0f9fE20T+41WYN8hDn6SGFlFrKudtx4EoxA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
"cpu": [
"arm64"
],
@@ -3987,9 +3987,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.2.tgz",
"integrity": "sha512-EsnFot9ZieM35YNA26nhbLTJBHD0jTwWpPwmRVDzjylQT6gkar+zenfb8mHxWpRrbn+WytRRjE0WKsfaxBkVUA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
"cpu": [
"arm64"
],
@@ -4001,9 +4001,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.2.tgz",
"integrity": "sha512-dv/t1t1RkCvJdWWxQ2lWOO+b7cMsVw5YFaS04oHpZRWehI1h0fV1gF4wgGCTyQHHjJDfbNpwOi6PXEafRBBezw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
"cpu": [
"x64"
],
@@ -4015,9 +4015,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.2.tgz",
"integrity": "sha512-W4tt4BLorKND4qeHElxDoim0+BsprFTwb+vriVQnFFtT/P6v/xO5I99xvYnVzKWrK6j7Hb0yp3x7V5LUbaeOMg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
"cpu": [
"arm64"
],
@@ -4029,9 +4029,9 @@
]
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.2.tgz",
"integrity": "sha512-tdT1PHopokkuBVyHjvYehnIe20fxibxFCEhQP/96MDSOcyjM/shlTkZZLOufV3qO6/FQOSiJTBebhVc12JyPTA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
"cpu": [
"x64"
],
@@ -4043,9 +4043,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.2.tgz",
"integrity": "sha512-+xmiDGGaSfIIOXMzkhJ++Oa0Gwvl9oXUeIiwarsdRXSe27HUIvjbSIpPxvnNsRebsNdUo7uAiQVgBD1hVriwSQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
"cpu": [
"arm"
],
@@ -4057,9 +4057,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.2.tgz",
"integrity": "sha512-bDHvhzOfORk3wt8yxIra8N4k/N0MnKInCW5OGZaeDYa/hMrdPaJzo7CSkjKZqX4JFUWjUGm88lI6QJLCM7lDrA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
"cpu": [
"arm"
],
@@ -4071,9 +4071,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.2.tgz",
"integrity": "sha512-NMsDEsDiYghTbeZWEGnNi4F0hSbGnsuOG+VnNvxkKg0IGDvFh7UVpM/14mnMwxRxUf9AdAVJgHPvKXf6FpMB7A==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
"cpu": [
"arm64"
],
@@ -4085,9 +4085,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.2.tgz",
"integrity": "sha512-lb5bxXnxXglVq+7imxykIp5xMq+idehfl+wOgiiix0191av84OqbjUED+PRC5OA8eFJYj5xAGcpAZ0pF2MnW+A==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
"cpu": [
"arm64"
],
@@ -4098,10 +4098,10 @@
"linux"
]
},
"node_modules/@rollup/rollup-linux-loongarch64-gnu": {
"version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.2.tgz",
"integrity": "sha512-Yl5Rdpf9pIc4GW1PmkUGHdMtbx0fBLE1//SxDmuf3X0dUC57+zMepow2LK0V21661cjXdTn8hO2tXDdAWAqE5g==",
"node_modules/@rollup/rollup-linux-loong64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
"cpu": [
"loong64"
],
@@ -4112,10 +4112,38 @@
"linux"
]
},
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
"version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.2.tgz",
"integrity": "sha512-03vUDH+w55s680YYryyr78jsO1RWU9ocRMaeV2vMniJJW/6HhoTBwyyiiTPVHNWLnhsnwcQ0oH3S9JSBEKuyqw==",
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
"cpu": [
"ppc64"
],
@@ -4127,9 +4155,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.2.tgz",
"integrity": "sha512-iYtAqBg5eEMG4dEfVlkqo05xMOk6y/JXIToRca2bAWuqjrJYJlx/I7+Z+4hSrsWU8GdJDFPL4ktV3dy4yBSrzg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
"cpu": [
"riscv64"
],
@@ -4141,9 +4169,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.2.tgz",
"integrity": "sha512-e6vEbgaaqz2yEHqtkPXa28fFuBGmUJ0N2dOJK8YUfijejInt9gfCSA7YDdJ4nYlv67JfP3+PSWFX4IVw/xRIPg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
"cpu": [
"riscv64"
],
@@ -4155,9 +4183,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.2.tgz",
"integrity": "sha512-evFOtkmVdY3udE+0QKrV5wBx7bKI0iHz5yEVx5WqDJkxp9YQefy4Mpx3RajIVcM6o7jxTvVd/qpC1IXUhGc1Mw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
"cpu": [
"s390x"
],
@@ -4169,9 +4197,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.2.tgz",
"integrity": "sha512-/bXb0bEsWMyEkIsUL2Yt5nFB5naLAwyOWMEviQfQY1x3l5WsLKgvZf66TM7UTfED6erckUVUJQ/jJ1FSpm3pRQ==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
"cpu": [
"x64"
],
@@ -4183,9 +4211,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.2.tgz",
"integrity": "sha512-3D3OB1vSSBXmkGEZR27uiMRNiwN08/RVAcBKwhUYPaiZ8bcvdeEwWPvbnXvvXHY+A/7xluzcN+kaiOFNiOZwWg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
"cpu": [
"x64"
],
@@ -4196,10 +4224,38 @@
"linux"
]
},
"node_modules/@rollup/rollup-openbsd-x64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
]
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.2.tgz",
"integrity": "sha512-VfU0fsMK+rwdK8mwODqYeM2hDrF2WiHaSmCBrS7gColkQft95/8tphyzv2EupVxn3iE0FI78wzffoULH1G+dkw==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
"cpu": [
"arm64"
],
@@ -4211,9 +4267,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.2.tgz",
"integrity": "sha512-+qMUrkbUurpE6DVRjiJCNGZBGo9xM4Y0FXU5cjgudWqIBWbcLkjE3XprJUsOFgC6xjBClwVa9k6O3A7K3vxb5Q==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
"cpu": [
"ia32"
],
@@ -4224,10 +4280,24 @@
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.44.2",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.2.tgz",
"integrity": "sha512-3+QZROYfJ25PDcxFF66UEk8jGWigHJeecZILvkPkyQN7oc5BvFo4YEXFkOs154j3FTMp9mn9Ky8RCOwastduEA==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
"cpu": [
"x64"
],
@@ -10061,9 +10131,9 @@
}
},
"node_modules/rollup": {
"version": "4.44.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.2.tgz",
"integrity": "sha512-PVoapzTwSEcelaWGth3uR66u7ZRo6qhPHc0f2uRO9fX6XDVNrIiGYS0Pj9+R8yIIYSD/mCx2b16Ws9itljKSPg==",
"version": "4.59.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10077,26 +10147,31 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.44.2",
"@rollup/rollup-android-arm64": "4.44.2",
"@rollup/rollup-darwin-arm64": "4.44.2",
"@rollup/rollup-darwin-x64": "4.44.2",
"@rollup/rollup-freebsd-arm64": "4.44.2",
"@rollup/rollup-freebsd-x64": "4.44.2",
"@rollup/rollup-linux-arm-gnueabihf": "4.44.2",
"@rollup/rollup-linux-arm-musleabihf": "4.44.2",
"@rollup/rollup-linux-arm64-gnu": "4.44.2",
"@rollup/rollup-linux-arm64-musl": "4.44.2",
"@rollup/rollup-linux-loongarch64-gnu": "4.44.2",
"@rollup/rollup-linux-powerpc64le-gnu": "4.44.2",
"@rollup/rollup-linux-riscv64-gnu": "4.44.2",
"@rollup/rollup-linux-riscv64-musl": "4.44.2",
"@rollup/rollup-linux-s390x-gnu": "4.44.2",
"@rollup/rollup-linux-x64-gnu": "4.44.2",
"@rollup/rollup-linux-x64-musl": "4.44.2",
"@rollup/rollup-win32-arm64-msvc": "4.44.2",
"@rollup/rollup-win32-ia32-msvc": "4.44.2",
"@rollup/rollup-win32-x64-msvc": "4.44.2",
"@rollup/rollup-android-arm-eabi": "4.59.0",
"@rollup/rollup-android-arm64": "4.59.0",
"@rollup/rollup-darwin-arm64": "4.59.0",
"@rollup/rollup-darwin-x64": "4.59.0",
"@rollup/rollup-freebsd-arm64": "4.59.0",
"@rollup/rollup-freebsd-x64": "4.59.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
"@rollup/rollup-linux-arm64-musl": "4.59.0",
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
"@rollup/rollup-linux-loong64-musl": "4.59.0",
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
"@rollup/rollup-linux-x64-gnu": "4.59.0",
"@rollup/rollup-linux-x64-musl": "4.59.0",
"@rollup/rollup-openbsd-x64": "4.59.0",
"@rollup/rollup-openharmony-arm64": "4.59.0",
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
"@rollup/rollup-win32-x64-gnu": "4.59.0",
"@rollup/rollup-win32-x64-msvc": "4.59.0",
"fsevents": "~2.3.2"
}
},
+2 -2
View File
@@ -4,7 +4,7 @@ import { Suspense } from 'react'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { QueryClientProvider } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import { useLang } from 'hoofd'
import { useTitle } from 'hoofd'
import { Switch, Route } from 'wouter'
import { I18nProvider } from 'react-aria-components'
import { Layout } from './layout/Layout'
@@ -17,7 +17,7 @@ import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext'
function App() {
const { i18n } = useTranslation()
useLang(i18n.language)
useTitle(import.meta.env.VITE_APP_TITLE ?? '')
const isSDKContext = useIsSdkContext()
@@ -2,23 +2,25 @@ import { A, Text } from '@/primitives'
import { useTranslation } from 'react-i18next'
import { useConfig } from '@/api/useConfig'
const appTitle = import.meta.env.VITE_APP_TITLE ?? 'LaSuite Meet'
export const MoreLink = () => {
const { t } = useTranslation('home')
const { data } = useConfig()
if (!data?.manifest_link) return
if (!data?.manifest_link) return null
return (
<Text as={'p'} variant={'sm'} style={{ padding: '1rem 0' }}>
<Text as="p" variant="sm" style={{ padding: '1rem 0' }}>
<A
href={data?.manifest_link}
target="_blank"
rel="noopener noreferrer"
aria-label={t('moreLinkLabel')}
externalIcon
aria-label={t('moreLinkLabel', { appTitle })}
>
{t('moreLink')}
</A>{' '}
{t('moreAbout', { appTitle: `${import.meta.env.VITE_APP_TITLE}` })}
{t('moreLink')} {t('moreAbout', { appTitle })}
</A>
</Text>
)
}
+1 -14
View File
@@ -258,23 +258,10 @@ export const Home = () => {
</DialogTrigger>
</div>
<Separator />
<div
className={css({
display: { base: 'none', lg: 'inline' },
})}
>
<MoreLink />
</div>
<MoreLink />
</LeftColumn>
<RightColumn>
<IntroSlider />
<div
className={css({
display: { base: 'inline', lg: 'none' },
})}
>
<MoreLink />
</div>
</RightColumn>
</Columns>
<LaterMeetingDialog
@@ -18,10 +18,15 @@ import {
ANIMATION_DURATION,
ReactionPortals,
} from '@/features/rooms/livekit/components/ReactionPortal'
import { layoutStore } from '@/stores/layout'
import { PanelId } from '@/features/rooms/livekit/hooks/useSidePanel'
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
export const MainNotificationToast = () => {
const room = useRoomContext()
const { triggerNotificationSound } = useNotificationSound()
const { t } = useTranslation('notifications')
const announce = useScreenReaderAnnounce()
const [reactions, setReactions] = useState<Reaction[]>([])
const instanceIdRef = useRef(0)
@@ -41,12 +46,21 @@ export const MainNotificationToast = () => {
},
{ timeout: NotificationDuration.MESSAGE }
)
if (layoutStore.activePanelId !== PanelId.CHAT) {
announce(
t('chatMessageReceived', {
name: participant.name || t('defaultName'),
message: chatMessage.message,
}),
'polite'
)
}
}
room.on(RoomEvent.ChatMessage, handleChatMessage)
return () => {
room.off(RoomEvent.ChatMessage, handleChatMessage)
}
}, [room, triggerNotificationSound])
}, [room, triggerNotificationSound, announce, t])
const handleEmoji = (emoji: string, participant: Participant) => {
if (!emoji || !Object.values(Emoji).includes(emoji as Emoji)) return
@@ -79,11 +79,20 @@ export const NoAccessView = ({
})}
>
{t(`${i18nKey}.body`)}
<br />
{helpArticle && (
<A href={helpArticle} target="_blank">
{t(`${i18nKey}.linkMore`)}
</A>
<>
{' '}
<A
href={helpArticle}
target="_blank"
rel="noopener noreferrer"
externalIcon
color="note"
aria-label={t(`${i18nKey}.linkAriaLabel`)}
>
{t(`${i18nKey}.linkMore`)}
</A>
</>
)}
</Text>
</VStack>
@@ -151,12 +151,16 @@ export const ScreenRecordingSidePanel = () => {
</H>
<Text variant="body" fullWidth>
{recordingMaxDuration
? t('body', {
max_duration: recordingMaxDuration,
})
? t('body', { max_duration: recordingMaxDuration })
: t('bodyWithoutMaxDuration')}{' '}
{data?.support?.help_article_recording && (
<A href={data.support.help_article_recording} target="_blank">
<A
href={data.support.help_article_recording}
target="_blank"
rel="noopener noreferrer"
externalIcon
aria-label={t('linkAriaLabel')}
>
{t('linkMore')}
</A>
)}
@@ -187,12 +187,16 @@ export const TranscriptSidePanel = () => {
</H>
<Text variant="body" fullWidth>
{recordingMaxDuration
? t('body', {
max_duration: recordingMaxDuration,
})
? t('body', { max_duration: recordingMaxDuration })
: t('bodyWithoutMaxDuration')}{' '}
{data?.support?.help_article_transcript && (
<A href={data.support.help_article_transcript} target="_blank">
<A
href={data.support.help_article_transcript}
target="_blank"
rel="noopener noreferrer"
externalIcon
aria-label={t('linkAriaLabel')}
>
{t('linkMore')}
</A>
)}
@@ -208,6 +212,7 @@ export const TranscriptSidePanel = () => {
href={data.transcription_destination}
target="_blank"
rel="noopener noreferrer"
externalIcon
>
{data.transcription_destination.replace('https://', '')}
</A>
@@ -72,7 +72,8 @@ export const RecordingDownload = () => {
if (
data.status !== RecordingStatus.Saved &&
data.status !== RecordingStatus.NotificationSucceed
data.status !== RecordingStatus.NotificationSucceed &&
data.status !== RecordingStatus.FailedToStop
) {
return <ErrorScreen title={t('unsaved.title')} body={t('unsaved.body')} />
}
@@ -183,7 +183,13 @@ export const ParticipantTile: (
}}
>
{isHandRaised && !isScreenShare && (
<span>
<span
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.1rem',
}}
>
<span>{positionInQueue}</span>
<RiHand
color="black"
@@ -148,17 +148,22 @@ export const Tools = () => {
className={css({
textStyle: 'sm',
paddingX: '0.75rem',
paddingTop: '0.25rem',
marginBottom: '1rem',
})}
>
{t('body')}{' '}
{data?.support?.help_article_more_tools && (
<>
<A href={data?.support?.help_article_more_tools} target="_blank">
{t('moreLink')}
</A>
.
</>
<A
href={data.support.help_article_more_tools}
target="_blank"
rel="noopener noreferrer"
externalIcon
color="note"
aria-label={t('linkAriaLabel')}
>
{t('moreLink')}
</A>
)}
</Text>
{isTranscriptEnabled && (
@@ -187,7 +187,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
0,
0,
PROCESSING_WIDTH,
PROCESSING_WIDTH
PROCESSING_HEIGHT
)
}
@@ -22,7 +22,7 @@ import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
export function useGridLayout(
/** HTML element that contains the grid. */
gridElement: React.RefObject<HTMLDivElement>,
/** Count of tracks that should get layed out */
/** Count of tracks that should get laid out */
trackCount: number,
options: {
gridLayouts?: GridLayoutDefinition[]
@@ -0,0 +1,28 @@
import { useTitle } from 'hoofd'
import { useRoomData } from './useRoomData'
import { useMemo } from 'react'
const APP_TITLE = import.meta.env.VITE_APP_TITLE ?? ''
/**
* Updates the browser tab title with the room name to help users easily find
* the meeting tab among many open tabs. Works on both the join screen and
* once connected.
*/
export const useRoomPageTitle = () => {
const roomData = useRoomData()
const pageTitle = useMemo(() => {
if (!roomData) {
return APP_TITLE
}
const roomLabel = roomData.name || roomData.slug || ''
if (!roomLabel) return APP_TITLE
return `${APP_TITLE} - ${roomLabel} `
}, [roomData])
useTitle(pageTitle)
}
@@ -151,7 +151,12 @@ export function Chat({ ...props }: ChatProps) {
minHeight={0}
overflowY="scroll"
>
<ul className="lk-list lk-chat-messages" ref={ulRef}>
<ul
className="lk-list lk-chat-messages"
ref={ulRef}
role="log"
aria-relevant="additions"
>
{renderedMessages}
</ul>
</Div>
@@ -30,6 +30,7 @@ import { useSidePanel } from '../hooks/useSidePanel'
import { RecordingProvider } from '@/features/recording'
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
import { useConnectionObserver } from '../hooks/useConnectionObserver'
import { useRoomPageTitle } from '../hooks/useRoomPageTitle'
import { useNoiseReduction } from '../hooks/useNoiseReduction'
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
import { useSettingsDialog } from '@/features/settings'
@@ -113,6 +114,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
)
useConnectionObserver()
useRoomPageTitle()
useVideoResolutionSubscription()
useRegisterKeyboardShortcut({
@@ -4,6 +4,7 @@ import { css } from '@/styled-system/css'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import { accessibilityStore } from '@/stores/accessibility'
import { CaptionsSettings } from '@/features/subtitle/component/CaptionsSettings'
export type AccessibilityTabProps = Pick<TabPanelProps, 'id'>
@@ -32,6 +33,7 @@ export const AccessibilityTab = ({ id }: AccessibilityTabProps) => {
wrapperProps={{ noMargin: true, fullWidth: true }}
/>
</li>
<CaptionsSettings />
</ul>
</TabPanel>
)
@@ -0,0 +1,49 @@
import { Field, H } from '@/primitives'
import { css } from '@/styled-system/css'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useSnapshot } from 'valtio'
import {
accessibilityStore,
type CaptionTextSize,
CAPTION_TEXT_SIZE_OPTIONS,
} from '@/stores/accessibility'
export const CaptionsSettings = () => {
const { t } = useTranslation('settings', {
keyPrefix: 'accessibility.captions',
})
const snap = useSnapshot(accessibilityStore)
const captionTextSizeItems = useMemo(
() =>
CAPTION_TEXT_SIZE_OPTIONS.map((size) => ({
value: size,
label: t(`textSize.options.${size}`),
})),
[t]
)
return (
<li>
<H
lvl={3}
className={css({
marginBottom: '0.5rem',
})}
>
{t('heading')}
</H>
<Field
type="select"
label={t('textSize.label')}
items={captionTextSizeItems}
selectedKey={snap.captionTextSize}
onSelectionChange={(key) => {
accessibilityStore.captionTextSize = key as CaptionTextSize
}}
wrapperProps={{ noMargin: true, fullWidth: true }}
/>
</li>
)
}
@@ -8,6 +8,25 @@ import { useRoomContext } from '@livekit/components-react'
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
import { Participant, RoomEvent } from 'livekit-client'
import { useSnapshot } from 'valtio'
import {
accessibilityStore,
CAPTION_TEXT_SIZE_OPTIONS,
type CaptionTextSize,
} from '@/stores/accessibility'
const FONT_SIZE_CONFIG: Record<
CaptionTextSize,
{ fontSize: string; lineHeight: string }
> = {
small: { fontSize: '0.875rem', lineHeight: '1.2rem' },
medium: { fontSize: '1.5rem', lineHeight: '1.7rem' },
large: { fontSize: '2.25rem', lineHeight: '2.5rem' },
}
const CAPTION_FONT_SIZES = Object.fromEntries(
CAPTION_TEXT_SIZE_OPTIONS.map((size) => [size, FONT_SIZE_CONFIG[size]])
) as Record<CaptionTextSize, { fontSize: string; lineHeight: string }>
export interface TranscriptionSegment {
id: string
@@ -73,8 +92,10 @@ const useTranscriptionState = () => {
}
const Transcription = ({ row }: { row: TranscriptionRow }) => {
const { captionTextSize } = useSnapshot(accessibilityStore)
const participantColor = getParticipantColor(row.participant)
const participantName = getParticipantName(row.participant)
const { fontSize, lineHeight } = CAPTION_FONT_SIZES[captionTextSize]
const getDisplayText = (row: TranscriptionRow): string => {
return row.segments
@@ -116,10 +137,9 @@ const Transcription = ({ row }: { row: TranscriptionRow }) => {
</Text>
<p
className={css({
fontSize: '1.5rem',
lineHeight: '1.7rem',
fontWeight: '400',
})}
style={{ fontSize, lineHeight }}
>
{displayText}
</p>
@@ -198,7 +218,6 @@ export const Subtitles = () => {
)
}
}
return rows
}, [transcriptionSegments])
+9 -1
View File
@@ -3,6 +3,7 @@ import resourcesToBackend from 'i18next-resources-to-backend'
import { initReactI18next } from 'react-i18next'
import LanguageDetector from 'i18next-browser-languagedetector'
const i18nDefaultNamespace = 'global'
const fallbackLng = 'fr'
i18n.setDefaultNamespace(i18nDefaultNamespace)
i18n
@@ -15,7 +16,7 @@ i18n
.use(LanguageDetector)
.init({
supportedLngs: ['en', 'fr', 'nl', 'de'],
fallbackLng: 'fr',
fallbackLng,
ns: i18nDefaultNamespace,
detection: {
order: ['localStorage', 'navigator'],
@@ -24,3 +25,10 @@ i18n
escapeValue: false,
},
})
.then(() => {
document.documentElement.setAttribute('lang', i18n.language || fallbackLng)
})
i18n.on('languageChanged', (lang) => {
document.documentElement.setAttribute('lang', lang)
})
@@ -8,12 +8,12 @@
},
"scope": "Diese Erklärung zur Barrierefreiheit gilt für die Website visio.numerique.gouv.fr",
"complianceStatus": {
"title": "Stand der Vereinbarkeit",
"title": "Stand der Gesetzeskonformität",
"body": "visio.numerique.gouv.fr ist nicht konform mit RGAA 4.1. Die Website wurde noch nicht geprüft. Das Team bemüht sich jedoch, eine für alle zugängliche Seite gemäß den Empfehlungen des RGAA zu erstellen."
},
"improvement": {
"title": "Verbesserung und Kontakt",
"body": "Wenn Sie auf Inhalte oder Dienste nicht zugreifen können, können Sie die verantwortliche Person von lasuite.numerique.gouv.fr kontaktieren, um eine barrierefreie Alternative zu erhalten oder die Inhalte in einer anderen Form zu bekommen.",
"body": "Wenn du auf Inhalte oder Dienste nicht zugreifen können, kannst du die verantwortliche Person von lasuite.numerique.gouv.fr kontaktieren, um eine barrierefreie Alternative zu erhalten oder die Inhalte in einer anderen Form zu bekommen.",
"contact": {
"email": "E-Mail: visio@numerique.gouv.fr",
"address": "Adresse: DINUM, 20 avenue de Ségur, 75007 Paris"
@@ -22,11 +22,11 @@
},
"recourse": {
"title": "Rechtsbehelfe",
"introduction": "Dieses Verfahren ist in folgendem Fall zu verwenden: Sie haben der für die Website zuständigen Stelle ein Problem mit der Barrierefreiheit gemeldet, das Sie daran hindert, auf Inhalte oder Dienste des Portals zuzugreifen, und keine zufriedenstellende Antwort erhalten.",
"introduction": "Dieses Verfahren ist in folgendem Fall zu verwenden: Du hast der für die Website zuständigen Stelle ein Problem mit der Barrierefreiheit gemeldet, das dich daran hindert, auf Inhalte oder Dienste des Portals zuzugreifen, und keine zufriedenstellende Antwort erhalten.",
"options": {
"intro": "Sie können:",
"option1": "Dem*der Beauftragten für Rechte eine Nachricht schreiben",
"option2": "Den*die Vertreter*in des*der Beauftragten für Rechte in Ihrer Region kontaktieren",
"intro": "Du kannst:",
"option1": "Dem*der Rechtsbeauftragten eine Nachricht schreiben",
"option2": "Den*die Vertreter*in des*der Rechtsbeauftragten in Ihrer Region kontaktieren",
"option3": "Einen Brief per Post senden (kostenfrei, ohne Briefmarke): </br> Défenseur des droits, Libre réponse 71120, 75342 Paris CEDEX 07"
}
}
+11 -11
View File
@@ -6,8 +6,8 @@
"heading": "Beim Laden der Seite ist ein Fehler aufgetreten"
},
"feedback": {
"context": "Ihr Feedback prägt das Produkt!",
"cta": "Teilen Sie uns Ihre Meinung mit - neues Fenster"
"context": "Dein Feedback gestaltet das Produkt!",
"cta": "Teile uns deine Meinung mit neuer Tab"
},
"forbidden": {
"heading": "Zugriff verweigert"
@@ -17,13 +17,13 @@
"login": {
"buttonLabel": "Anmelden",
"proconnectButtonLabel": "Mit ProConnect anmelden",
"proconnectLinkLabel": "Was ist ProConnect? neues Fenster",
"proconnectLinkLabel": "Was ist ProConnect? neuer Tab",
"proconnectLink": "Was ist ProConnect?"
},
"logout": "Abmelden",
"notFound": {
"heading": "Überprüfen Sie Ihren Meeting-Code",
"body": "Stellen Sie sicher, dass Sie den richtigen Meeting-Code in der URL eingegeben haben. Beispiel:"
"heading": "Überprüfe deinen Meeting-Code",
"body": "Stelle sicher, dass du den richtigen Meeting-Code in der URL eingegeben haben. Beispiel:"
},
"selected": "ausgewählt",
"submit": "OK",
@@ -38,16 +38,16 @@
"accessibility": "Barrierefreiheit: nicht konform",
"ariaLabel": "neues Fenster",
"codeAnnotation": "Unser Code ist offen und verfügbar in diesem",
"code": "Open-Source-Code-Repository",
"technicalDetails": "Technisches Datenblatt",
"code": "Open-Source-Repository",
"technicalDetails": "Technische Angaben",
"termsOfService": "Nutzungsbedingungen"
},
"mentions": "Sofern nicht anders angegeben, sind die Inhalte dieser Website verfügbar unter der",
"license": "Etalab 2.0 Lizenz"
},
"loginHint": {
"title": "Melden Sie sich mit Ihrem Konto an",
"body": "Statt zu warten, melden Sie sich mit Ihrem ProConnect-Konto an.",
"title": "Melde dich mit deinem Konto an",
"body": "Melde dich mit deinem Konto an, statt zu warten.",
"button": {
"ariaLabel": "Hinweis schließen",
"label": "OK"
@@ -55,7 +55,7 @@
},
"skipLink": "Zum Hauptinhalt springen",
"clipboardContent": {
"url": "Um an der Videokonferenz teilzunehmen, klicken Sie auf diesen Link: {{roomUrl}}",
"numberAndPin": "Um telefonisch teilzunehmen, wählen Sie {{phoneNumber}} und geben Sie diesen Code ein: {{pinCode}}"
"url": "Um an der Videokonferenz teilzunehmen, folge diesem Link: {{roomUrl}}",
"numberAndPin": "Um per Telefon teilzunehmen, wähle {{phoneNumber}} und gib diesen Code ein: {{pinCode}}"
}
}
+16 -16
View File
@@ -1,31 +1,31 @@
{
"createMeeting": "Meeting erstellen",
"heading": "Einfache und sichere Videokonferenzen",
"intro": "Kommunizieren und arbeiten Sie mühelos, ohne Ihre digitale Souveränität zu gefährden",
"joinInputError": "Verwenden Sie einen Meeting-Link oder -Code. Beispiele:",
"intro": "Kommuniziere und arbeite mühelos, ohne deine digitale Souveränität zu gefährden",
"joinInputError": "Verwende einen Meeting-Link oder -Code. Beispiele:",
"joinInputExample": "URL oder 10-stelliger Code",
"joinInputLabel": "Meeting-Link",
"joinInputSubmit": "Meeting beitreten",
"joinMeeting": "Meeting beitreten",
"joinMeetingTipContent": "Sie können einem Meeting beitreten, indem Sie den vollständigen Link in die Adressleiste Ihres Browsers einfügen.",
"joinMeetingTipHeading": "Wussten Sie schon?",
"loginToCreateMeeting": "Melden Sie sich an, um ein Meeting zu erstellen",
"moreLinkLabel": "Mehr erfahren neues Tab",
"joinMeetingTipContent": "Du kannst einem Meeting beitreten, indem du den vollständigen Link in die Adressleiste deines Browsers einfügst.",
"joinMeetingTipHeading": "Wusstest du schon?",
"loginToCreateMeeting": "Melde dich an, um ein Meeting zu erstellen",
"moreLinkLabel": "Mehr über {{appTitle}} erfahren neuer Tab",
"moreLink": "Mehr erfahren",
"moreAbout": "über {{appTitle}}",
"createMenu": {
"laterOption": "Meeting für später planen",
"instantOption": "Sofort-Meeting starten"
"instantOption": "Meeting sofort starten"
},
"laterMeetingDialog": {
"heading": "Ihre Zugangsdaten",
"description": "Teilen Sie diese Informationen mit den Gästen. Sie können dem Meeting beitreten, ohne sich anmelden zu müssen. Dieses Meeting ist dauerhaft und kann wiederverwendet werden.",
"heading": "Zugangsdaten",
"description": "Teile diese Informationen mit den Gästen. Sie können dem Meeting beitreten, ohne sich anmelden zu müssen. Dieses Meeting ist dauerhaft und kann wiederverwendet werden.",
"permissions": "Personen mit diesem Link benötigen keine Genehmigung, um diesem Meeting beizutreten.",
"copy": "Informationen kopieren",
"copied": "Informationen kopiert",
"copyUrl": "Meeting-Link kopieren",
"phone": {
"call": "Rufen Sie an:",
"call": "Ruf an:",
"pinCode": "Code:"
}
},
@@ -45,16 +45,16 @@
"tooltip": "Formular ausfüllen"
},
"slide1": {
"title": "Wechseln Sie zur Einfachheit. Testen Sie uns jetzt!",
"body": "Entdecken Sie eine intuitive und zugängliche Lösung, entwickelt für alle Mitarbeitenden im öffentlichen Dienst, ihre Partner und viele weitere."
"title": "Mach Schluss mit kompliziert probier uns jetzt aus!",
"body": "Entdecke eine intuitive und leicht zugängliche Lösung, entwickelt für alle Mitarbeitenden im öffentlichen Dienst, ihre Partner und viele weitere."
},
"slide2": {
"title": "Gruppenanrufe ohne Einschränkungen",
"body": "Unbegrenzte Meeting-Dauer mit flüssiger, hochwertiger Kommunikation unabhängig von der Gruppengröße."
"title": "Gruppenmeetings ohne Einschränkungen",
"body": "Unbegrenzte Meeting-Dauer mit flüssiger Kommunikation in hoher Qualität unabhängig von der Gruppengröße."
},
"slide3": {
"title": "Verwandeln Sie Ihre Meetings mit KI",
"body": "Erhalten Sie präzise und verwertbare Transkripte zur Steigerung Ihrer Produktivität. Funktion in der Beta jetzt testen!"
"title": "Verwandele deine Meetings mit KI",
"body": "Erhalte präzise und verwertbare Transkripte zur Steigerung deiner Produktivität. Beta-Funktion jetzt testen!"
},
"carouselLabel": "Einführungs-Diashow",
"slidePosition": "Folie {{current}} von {{total}}"
+4 -4
View File
@@ -1,8 +1,8 @@
{
"title": "Rechtliche Hinweise",
"creator": {
"title": "Herausgeber",
"body": "Der Visio-Dienst wird herausgegeben von der Interministeriellen Digitaldirektion des Staates (DINUM) mit Sitz:",
"title": "Herausgebende",
"body": "Der Visio-Dienst wird herausgegeben von der Interministeriellen Digitaldirektion Frankreichs (DINUM) mit Sitz:",
"contact": {
"title": "Kontaktdaten",
"address": "20 avenue de Ségur",
@@ -31,7 +31,7 @@
"title": "Wiederverwendung von Inhalten und Links",
"body1": "Sofern nicht ausdrücklich anders angegeben und geistige Eigentumsrechte Dritter betroffen sind, werden die Inhalte dieser Website unter der ",
"license": "offenen Lizenz Etalab 2.0",
"body2": "bereitgestellt. Insbesondere dürfen Sie diese Inhalte frei vervielfältigen, kopieren, ändern, extrahieren, umwandeln, verbreiten, weiterleiten, veröffentlichen, übertragen und nutzen unter der Bedingung, dass die Quelle und das Datum der letzten Aktualisierung genannt werden und Dritte nicht durch die Informationen in die Irre geführt werden.",
"body3": "Jede öffentliche oder private Website ist berechtigt, ohne vorherige Genehmigung, einen Link (einschließlich eines Deep Links) zu den auf dieser Website veröffentlichten Informationen zu setzen."
"body2": "bereitgestellt. Insbesondere dürfen diese Inhalte frei vervielfältigt, kopiert, geändert, extrahiert, umgewandelt, verbreitet, weitergeleitet, veröffentlicht, übertragen und genutzt werden unter der Bedingung, dass die Quelle und das Datum der letzten Aktualisierung genannt werden und Dritte nicht durch die Informationen in die Irre geführt werden.",
"body3": "Jede öffentliche oder private Website ist berechtigt, ohne vorherige Genehmigung, Links (einschließlich Deep Links) zu den auf dieser Website veröffentlichten Informationen zu setzen."
}
}
+20 -19
View File
@@ -1,5 +1,5 @@
{
"defaultName": "Ein Mitwirkender",
"defaultName": "Eine Person",
"joined": {
"description": "{{name}} ist dem Raum beigetreten"
},
@@ -7,46 +7,47 @@
"description": "{{name}} hat die Hand gehoben.",
"cta": "Warteliste öffnen"
},
"muted": "{{name}} hat dein Mikrofon stummgeschaltet. Kein Teilnehmer kann dich hören.",
"muted": "{{name}} hat dein Mikrofon stummgeschaltet.",
"openChat": "Chat öffnen",
"chatMessageReceived": "{{name}} sagt: {{message}}",
"lowerHand": {
"auto": "Es scheint, dass du angefangen hast zu sprechen, daher wird deine Hand gesenkt.",
"auto": "Du hast begonnen zu sprechen deine Hand wird daher automatisch gesenkt.",
"dismiss": "Hand oben lassen"
},
"reaction": {
"description": "{{name}} hat mit {{emoji}} reagiert"
},
"permissionsRemoved": {
"camera": "Der Organisator hat das Video für alle Teilnehmer deaktiviert.",
"microphone": "Der Organisator hat das Mikrofon für alle Teilnehmer deaktiviert.",
"screen_share": "Der Organisator hat die Bildschirmfreigabe für alle Teilnehmer deaktiviert."
"camera": "Die Moderation hat das Video für alle Teilnehmenden deaktiviert.",
"microphone": "Die Moderation hat das Mikrofon für alle Teilnehmenden deaktiviert.",
"screen_share": "Die Moderation hat die Bildschirmfreigabe für alle Teilnehmenden deaktiviert."
},
"waitingParticipants": {
"one": "Eine Person möchte diesem Anruf beitreten.",
"several": "Mehrere Personen möchten diesem Anruf beitreten.",
"one": "Eine Person möchte diesem Meeting beitreten.",
"several": "Mehrere Personen möchten diesem Meeting beitreten.",
"open": "Öffnen",
"accept": "Annehmen"
"accept": "Hereinlassen"
},
"transcript": {
"started": "{{name}} hat die Transkription des Treffens gestartet.",
"stopped": "{{name}} hat die Transkription des Treffens gestoppt.",
"started": "{{name}} hat die Meeting-Transkription gestartet.",
"stopped": "{{name}} hat die Meeting-Transkription gestoppt.",
"limitReached": "Die Transkription hat die maximal zulässige Dauer überschritten und wird automatisch gespeichert.",
"requested": "{{name}} will die Transkription starten."
"requested": "{{name}} möchte die Meeting-Transkription starten."
},
"screenRecording": {
"started": "{{name}} hat die Aufzeichnung des Treffens gestartet.",
"stopped": "{{name}} hat die Aufzeichnung des Treffens gestoppt.",
"started": "{{name}} hat die Meeting-Aufzeichnung gestartet.",
"stopped": "{{name}} hat die Meeting-Aufzeichnung gestoppt.",
"limitReached": "Die Aufzeichnung hat die maximal zulässige Dauer überschritten und wird automatisch gespeichert.",
"requested": "{{name}} will die Aufnahme starten."
"requested": "{{name}} möchte die Meeting-Aufzeichnung starten."
},
"recordingSave": {
"transcript": {
"message": "Wir finalisieren Ihre Aufnahme! Sie erhalten eine E-Mail an <strong>{{email}}</strong>, sobald die Transkription fertig ist.",
"default": "Wir finalisieren Ihre Aufnahme! Sie erhalten eine E-Mail, sobald die Transkription fertig ist."
"message": "Deine Transkription wird gespeichert! Du erhältst eine E-Mail auf <strong>{{email}}</strong>, sobald die Transkription bereit ist.",
"default": "Deine Transkription wird gespeichert! Du erhältst das Transkript per E-Mail, sobald es bereit ist."
},
"screenRecording": {
"message": "Wir finalisieren Ihre Aufnahme! Sie erhalten eine E-Mail an <strong>{{email}}</strong>, sobald sie fertig ist.",
"default": "Wir finalisieren Ihre Aufnahme! Sie erhalten eine E-Mail, sobald sie fertig ist."
"message": "Deine Aufzeichnung wird gespeichert! Du erhältst eine E-Mail auf <strong>{{email}}</strong>, sobald sie bereit ist.",
"default": "Deine Aufzeichnung wird gespeichert! Du erhältst eine E-Mail, sobald sie bereit ist."
}
},
"openMenu": "Menü öffnen"
+6 -6
View File
@@ -1,7 +1,7 @@
{
"error": {
"title": "Aufzeichnung nicht verfügbar",
"body": "Die Aufzeichnung ist nicht verfügbar oder wurde möglicherweise gelöscht. Nur der Organisator der Besprechung hat Zugriff darauf. Wenden Sie sich bei Bedarf gerne an ihn."
"body": "Die Aufzeichnung ist nicht verfügbar oder wurde möglicherweise gelöscht. Nur die*der Organisator:in der Besprechung hat Zugriff darauf. Wende dich bei Bedarf gerne an die Person."
},
"expired": {
"title": "Aufzeichnung abgelaufen",
@@ -9,20 +9,20 @@
},
"authentication": {
"title": "Authentifizierung erforderlich",
"body": "Bitte melden Sie sich an, um auf diese Aufzeichnung zuzugreifen."
"body": "Bitte melde dich sich an, um auf diese Aufzeichnung zuzugreifen."
},
"unsaved": {
"title": "Download nicht verfügbar",
"body": "Diese Aufzeichnung ist noch nicht zum Herunterladen bereit. Bitte versuchen Sie es später erneut."
"body": "Diese Aufzeichnung ist noch nicht zum Herunterladen bereit. Bitte versuche es später erneut."
},
"success": {
"title": "Ihre Aufzeichnung ist bereit!",
"body": "Aufzeichnung des Treffens {{room}} vom {{created_at}}.",
"title": "Deine Aufzeichnung ist bereit!",
"body": "Aufzeichnung des Meetings {{room}} vom {{created_at}}.",
"expiration": "Achtung, diese Aufzeichnung wird nach {{expiration_days}} Tag(en) gelöscht.",
"button": "Herunterladen",
"warning": {
"title": "Linkfreigabe deaktiviert",
"body": "Die Freigabe der Aufzeichnung per Link ist noch nicht verfügbar. Nur Organisatoren können sie herunterladen."
"body": "Die Freigabe der Aufzeichnung per Link ist noch nicht verfügbar. Nur Organisator:innen können sie herunterladen."
}
}
}
+124 -117
View File
@@ -1,17 +1,17 @@
{
"feedback": {
"heading": {
"normal": "Sie haben das Meeting verlassen",
"duplicateIdentity": "Sie haben dem Meeting von einem anderen Gerät aus beigetreten",
"participantRemoved": "Sie wurden von einem Administrator aus dem Anruf entfernt"
"normal": "Du hast das Meeting verlassen",
"duplicateIdentity": "Du bist dem Meeting von einem anderen Gerät aus beigetreten",
"participantRemoved": "Du wurdest von der*dem Organisator:in aus dem Meeting entfernt"
},
"home": "Zur Startseite zurückkehren",
"back": "Dem Meeting erneut beitreten"
},
"selectDevice": {
"loading": "Laden…",
"select": "Wählen Sie einen Wert",
"permissionsNeeded": "Genehmigung erforderlich",
"select": "Wähle einen Wert",
"permissionsNeeded": "Berechtigung erforderlich",
"settings": {
"audio": "Audioeinstellungen",
"video": "Videoeinstellungen"
@@ -19,7 +19,7 @@
"effects": "Effekte anwenden",
"videoinput": {
"choose": "Kamera auswählen",
"permissionsNeeded": "Kamera auswählen - genehmigung erforderlich",
"permissionsNeeded": "Kamera auswählen Berechtigung erforderlich",
"disable": "Kamera deaktivieren",
"enable": "Kamera aktivieren",
"turnedOff": "Kamera deaktiviert",
@@ -29,7 +29,7 @@
},
"audioinput": {
"choose": "Mikrofon auswählen",
"permissionsNeeded": "Mikrofon auswählen - genehmigung erforderlich",
"permissionsNeeded": "Mikrofon auswählen Berechtigung erforderlich",
"disable": "Mikrofon deaktivieren",
"enable": "Mikrofon aktivieren",
"turnedOff": "Mikrofon deaktiviert",
@@ -37,30 +37,30 @@
"label": "Mikrofon"
},
"audiooutput": {
"choose": "Lautsprecher auswählen",
"permissionsNeeded": "Lautsprecher auswählen - genehmigung erforderlich"
"choose": "Audioausgabe auswählen",
"permissionsNeeded": "Audioausgabe auswählen Berechtigung erforderlich"
}
},
"join": {
"effects": {
"description": "Effekte anwenden",
"title": "Effekte",
"subTitle": "Konfigurieren Sie die Effekte Ihrer Kamera."
"subTitle": "Konfiguriere die Effekte deiner Kamera."
},
"heading": "Dem Meeting beitreten?",
"joinLabel": "Beitreten",
"joinMeeting": "Meeting beitreten",
"toggleOff": "Klicken, um auszuschalten",
"toggleOn": "Klicken, um einzuschalten",
"usernameHint": "Wird anderen Teilnehmern angezeigt",
"usernameLabel": "Ihr Name",
"usernameHint": "Wird anderen Teilnehmenden angezeigt",
"usernameLabel": "Dein Name",
"errors": {
"usernameEmpty": "Ihr Name darf nicht leer sein"
"usernameEmpty": "Dein Name darf nicht leer sein"
},
"cameraDisabled": "Kamera ist deaktiviert.",
"cameraStarting": "Kamera wird gestartet…",
"cameraNotGranted": "Möchten Sie, dass andere Sie während der Besprechung sehen können?",
"cameraAndMicNotGranted": "Möchten Sie, dass andere Sie während der Besprechung sehen und hören können?",
"cameraNotGranted": "Möchtest du, dass andere dich während des Meetings sehen können?",
"cameraAndMicNotGranted": "Möchtest du, dass andere dich während des Meetings sehen und hören können?",
"permissionsButton": {
"cameraAndMicNotGranted": "Zugriff auf Kamera und Mikrofon erlauben",
"cameraNotGranted": "Zugriff auf Kamera erlauben"
@@ -71,28 +71,28 @@
},
"waiting": {
"title": "Beitrittsanfrage wird gesendet...",
"body": "Sie können diesem Anruf beitreten, sobald jemand Sie autorisiert"
"body": "Du kannst diesem Meeting beitreten, sobald deine Anfrage angenommen wird"
},
"denied": {
"title": "Sie können diesem Anruf nicht beitreten",
"body": "Ihre Beitrittsanfrage wurde abgelehnt."
"title": "Du kannst diesem Meeting nicht beitreten",
"body": "Deine Beitrittsanfrage wurde abgelehnt."
},
"timeoutInvite": {
"title": "Sie können diesem Anruf nicht beitreten",
"body": "Niemand hat auf Ihre Anfrage reagiert"
"title": "Du kannst diesem Meeting nicht beitreten",
"body": "Niemand hat auf deine Anfrage reagiert"
}
},
"leaveRoomPrompt": "Dadurch verlassen Sie das Meeting.",
"leaveRoomPrompt": "Hiermit verlässt du das Meeting.",
"shareDialog": {
"copy": "Informationen kopieren",
"copy": "Infos kopieren",
"copyUrl": "Link kopieren",
"copied": "Informationen kopiert",
"heading": "Ihr Meeting ist bereit",
"description": "Teilen Sie diesen Link mit Personen, die Sie zum Meeting einladen möchten.",
"copied": "Infos kopiert",
"heading": "Dein Meeting ist bereit",
"description": "Teile diesen Link mit Personen, die du zum Meeting einladen möchten.",
"permissions": "Personen mit diesem Link benötigen keine Erlaubnis, um diesem Meeting beizutreten.",
"closeDialog": "Dialogfenster schließen",
"phone": {
"call": "Rufen Sie an:",
"call": "Ruf an:",
"pinCode": "Code:"
}
},
@@ -108,62 +108,62 @@
"videoinput": "Kamera konnte nicht aktiviert werden"
},
"body": {
"audioinput": "Ihr Mikrofon wird bereits in einem anderen Tab oder von einer anderen Anwendung verwendet, was die Aktivierung in diesem Videoanruf verhindert. Wenn Sie möchten, schließen Sie den anderen Tab oder die Anwendung, um es hier zu verwenden.",
"videoinput": "Ihre Kamera wird bereits in einem anderen Tab oder von einer anderen Anwendung verwendet, was die Aktivierung in diesem Videoanruf verhindert. Wenn Sie möchten, schließen Sie den anderen Tab oder die Anwendung, um sie hier zu verwenden."
"audioinput": "Dein Mikrofon wird bereits in einem anderen Tab oder von einer anderen App verwendet. Dies verhindert die Aktivierung in diesem Meeting. Wenn du dein Mikrofon hier verwenden möchtest, schließe den anderen Tab oder die App.",
"videoinput": "Deine Kamera wird bereits in einem anderen Tab oder von einer anderen App verwendet. Dies verhindert die Aktivierung in diesem Meeting. Wenn du deine Kamera hier verwenden möchtest, schließe den anderen Tab oder die App."
}
},
"close": "OK"
},
"permissionErrorDialog": {
"heading": {
"camera": "{{appTitle}} darf Ihre Kamera nicht verwenden",
"microphone": "{{appTitle}} darf Ihr Mikrofon nicht verwenden",
"cameraAndMicrophone": "{{appTitle}} darf weder Ihr Mikrofon noch Ihre Kamera verwenden",
"camera": "{{appTitle}} hat keine Berechtigung, um deine Kamera zu verwenden",
"microphone": "{{appTitle}} hat keine Berechtigung, um dein Mikrofon zu verwenden",
"cameraAndMicrophone": "{{appTitle}} hat keine Berechtigung, um dein Mikrofon und deine Kamera zu verwenden",
"default": "{{appTitle}} hat keine Berechtigung für bestimmte Zugriffe"
},
"body": {
"openMenu": {
"others": "Klicken Sie auf das Einstellungen-Symbol ICON_PLACEHOLDER in der Adressleiste Ihres Browsers",
"safari": "Klicken Sie auf das Menü 'Safari' und öffnen Sie 'Einstellungen für {{appDomain}}'."
"others": "Klicke das Einstellungs-Symbol ICON_PLACEHOLDER in der Adressleiste deines Browsers",
"safari": "Klicke auf das Menü 'Safari' und öffne 'Einstellungen für {{appDomain}}'."
},
"details": {
"camera": "Zugriff auf die Kamera erlauben",
"microphone": "Zugriff auf das Mikrofon erlauben",
"cameraAndMicrophone": "Zugriff auf Kamera und Mikrofon erlauben",
"default": "Aktivieren Sie die erforderlichen Berechtigungen."
"default": "Erlaube die erforderlichen Berechtigungen."
}
}
},
"permissionsButton": {
"tooltip": "Mehr Infos",
"ariaLabel": "Berechtigungsproblem. Mehr Infos anzeigen"
"ariaLabel": "Problem mit Berechtigungen. Mehr Infos anzeigen"
},
"error": {
"createRoom": {
"heading": "Authentifizierung erforderlich",
"body": "Dieser Raum wurde noch nicht erstellt. Bitte authentifizieren Sie sich, um ihn zu erstellen, oder warten Sie, bis ein authentifizierter Benutzer dies tut."
"body": "Dieser Raum wurde noch nicht erstellt. Bitte authentifiziere dich, um ihn zu erstellen, oder warte, bis eine authentifizierte Person dies tut."
},
"screenShare": {
"title": "Bildschirmfreigabe nicht möglich",
"ariaLabel": "Bildschirmfreigabe nicht möglich",
"message": "Ihr Browser darf möglicherweise den Bildschirm Ihres Computers nicht aufzeichnen.",
"macInstructions": "Gehen Sie zu Ihren",
"message": "Deinem Browser fehlt möglicherweise die Berechtigung, den Bildschirm deines Geräts abzugreifen.",
"macInstructions": "Gehe zu den",
"macSystemPreferences": "Systemeinstellungen",
"helpLinkText": "Weitere Informationen finden Sie unter",
"helpLinkLabel": "Präsentationsproblem",
"helpLinkText": "Weitere Informationen findest du unter",
"helpLinkLabel": "Bildschirmfreigabeproblem",
"closeButton": "Schließen",
"newTab": "Neues Fenster"
"newTab": "Neuer Tab"
}
},
"isIdleDisconnectModal": {
"title": "Bist du noch da?",
"body": "Du bist der einzige Teilnehmer. Dieses Gespräch endet in {{duration}}. Möchtest du das Gespräch fortsetzen?",
"body": "Du bist die*der einzige Teilnehmende. Diese Meeting endet in {{duration}}. Möchtest du das Meeting fortsetzen?",
"settingsPrefix": "Um diese Nachricht nicht mehr zu sehen, gehe zu",
"settingsLink": "Einstellungen > Allgemein",
"settingsSuffix": ".",
"stayButton": "Gespräch fortsetzen",
"stayButton": "Meeting fortsetzen",
"leaveButton": "Jetzt verlassen",
"countdownAnnouncement": "Das Gespräch endet in {{duration}}."
"countdownAnnouncement": "Das Meeting endet in {{duration}}."
},
"controls": {
"microphone": "Mikrofon",
@@ -201,8 +201,8 @@
"participants": {
"open": "Alle ausblenden",
"closed": "Alle anzeigen",
"count_one": "{{count}} Teilnehmer",
"count_other": "{{count}} Teilnehmer"
"count_one": "{{count}} Teilnehmende",
"count_other": "{{count}} Teilnehmende"
},
"tools": {
"open": "Weitere Tools ausblenden",
@@ -217,16 +217,16 @@
"button": "Reaktion senden",
"send": "Reaktion {{emoji}} senden",
"announce": "{{name}} : {{emoji}}",
"you": "Sie",
"you": "du",
"emojis": {
"thumbs-up": "Daumen hoch",
"thumbs-down": "Daumen runter",
"clapping-hands": "Klatschende Hände",
"red-heart": "rotes Herz",
"red-heart": "Rotes Herz",
"face-with-tears-of-joy": "Gesicht mit Freudentränen",
"face-with-open-mouth": "überraschter Gesichtsausdruck",
"party-popper": "Party-Popper",
"folded-hands": "gefaltete Hände"
"face-with-open-mouth": "Überraschter Gesichtsausdruck",
"party-popper": "Konfettikanone",
"folded-hands": "Gefaltete Hände"
}
}
},
@@ -237,7 +237,7 @@
"settings": "Einstellungen",
"screenRecording": "Aufzeichnung",
"transcript": "Transkription",
"username": "Ihren Namen aktualisieren",
"username": "Deinen Namen aktualisieren",
"effects": "Effekte anwenden",
"switchCamera": "Kamera wechseln",
"fullscreen": {
@@ -248,9 +248,9 @@
}
},
"effects": {
"activateCamera": "Ihre Kamera ist deaktiviert. Wählen Sie eine Option, um sie zu aktivieren.",
"cameraDisabled": "Ihre Kamera ist deaktiviert.",
"notAvailable": "Videoeffekte werden bald in Ihrem Browser verfügbar sein. Wir arbeiten daran! In der Zwischenzeit können Sie Google Chrome für die beste Leistung oder Firefox verwenden :(",
"activateCamera": "Deine Kamera ist deaktiviert. Wähle eine Option, um sie zu aktivieren.",
"cameraDisabled": "Deine Kamera ist deaktiviert.",
"notAvailable": "Videoeffekte werden bald in deinem Browser verfügbar sein. Wir arbeiten daran! In der Zwischenzeit kannst du Google Chrome für die beste Leistung oder Firefox verwenden :(",
"heading": "Unschärfe",
"clear": "Effekt deaktivieren",
"blur": {
@@ -300,8 +300,8 @@
"ariaLabel": "Seitenleiste",
"backToTools": "Zurück zu den Meeting-Tools",
"heading": {
"participants": "Teilnehmer",
"effects": "Effekte",
"participants": "Teilnehmende",
"effects": "Hintergrund & Effekte",
"chat": "Nachrichten im Chat",
"transcript": "Transkribieren",
"screenRecording": "Aufzeichnen",
@@ -310,8 +310,8 @@
"info": "Meeting-Informationen"
},
"content": {
"participants": "Teilnehmer",
"effects": "Effekte",
"participants": "Teilnehmende",
"effects": "Hintergrund & Effekte",
"chat": "Nachrichten",
"transcript": "transkribieren",
"screenRecording": "aufzeichnen",
@@ -322,11 +322,13 @@
"closeButton": "{{content}} ausblenden"
},
"chat": {
"disclaimer": "Die Nachrichten sind nur für Teilnehmer zum Zeitpunkt des Sendens sichtbar. Alle Nachrichten werden am Ende des Anrufs gelöscht."
"disclaimer": "Die Nachrichten sind nur für Teilnehmende zum Zeitpunkt des Sendens sichtbar. Alle Nachrichten werden am Ende des Meetings gelöscht.",
"messagesLog": "Chat-Nachrichten"
},
"moreTools": {
"body": "Greifen Sie auf weitere Tools zu, um Ihre Meetings zu verbessern.",
"moreLink": "Mehr erfahren",
"body": "Nutze weitere Tools, um deine Meetings zu verbessern.",
"linkAriaLabel": "Dokumentation zu den Tools öffnen öffnet in neuem Tab",
"moreLink": "Dokumentation öffnen",
"tools": {
"transcript": {
"title": "Transkribieren",
@@ -342,73 +344,77 @@
"roomInformation": {
"title": "Verbindungsinformationen",
"button": {
"ariaLabel": "Kopieren Sie die Informationen aus Ihrer Besprechung",
"ariaLabel": "Kopiere die Informationen aus dem Meeting",
"copy": "Informationen kopieren",
"copied": "Informationen kopiert"
},
"phone": {
"call": "Rufen Sie an:",
"call": "Ruf an:",
"pinCode": "Code:"
}
}
},
"transcript": {
"heading": "Transkribieren Sie Ihr Meeting mit dem KI-Assistenten",
"body": "Transkribieren Sie bis zu {{max_duration}} Meetingdauer.",
"bodyWithoutMaxDuration": "Transkribieren Sie Ihr Meeting ohne Begrenzung.",
"heading": "Transkribiere dein Meeting mithilfe von KI",
"body": "Transkribiere bis zu {{max_duration}} Meetingdauer.",
"bodyWithoutMaxDuration": "Transkribieren dein Meeting ohne Zeitbegrenzung.",
"details": {
"receiver": "Das Transkript wird an den Organisator und die Mitorganisatoren gesendet.",
"receiver": "Das Transkript wird an die*den Organisator:in und die Mitorganisierenden gesendet.",
"destination": "Ein neues Dokument wird erstellt auf",
"destinationUnknown": "Ein neues Dokument wird erstellt",
"language": "Meeting-Sprache:",
"recording": "Auch eine Aufzeichnung starten"
},
"button": {
"start": "Meeting transkribieren starten",
"stop": "Meeting transkribieren beenden",
"start": "Meeting-Transkription starten",
"stop": "Meeting-Transkription beenden",
"saving": "Speichern…",
"starting": "Wird gestartet…",
"anotherModeStarted": "Eine Aufnahme läuft. <br/> Wechseln Sie das Menü und stoppen Sie sie."
"anotherModeStarted": "Eine Aufnahme läuft. <br/> Wechsele das Menü und stoppe sie."
},
"linkMore": "Mehr erfahren",
"linkMore": "Dokumentation öffnen",
"linkAriaLabel": "Dokumentation zur Transkription öffnen öffnet in neuem Tab",
"notAdminOrOwner": {
"heading": "Zugriff eingeschränkt",
"body": "Aus Sicherheitsgründen kann nur der Ersteller oder ein Administrator des Meetings eine Transkription (Beta) starten.",
"linkMore": "Mehr erfahren",
"body": "Aus Sicherheitsgründen kann nur die*der Ersteller:in bzw. Admin des Meetings eine Transkription (Beta) starten.",
"linkMore": "Dokumentation öffnen",
"linkAriaLabel": "Dokumentation zum Transkriptionszugang öffnen öffnet in neuem Tab",
"dividerLabel": "ODER",
"login": {
"heading": "Anmeldung erforderlich",
"body": "Nur der Ersteller des Meetings oder ein Administrator kann die Transkription starten. Melden Sie sich an, um Ihre Berechtigungen zu überprüfen."
"body": "Nur die*der Ersteller:in bzw. Admin kann die Transkription starten. Melde dich an, um deine Berechtigungen zu überprüfen."
},
"request": {
"heading": "Anfrage an Moderator",
"body": "Der Moderator wird benachrichtigt und kann die Transkription starten.",
"buttonLabel": "Anfordern"
"heading": "Transkription anfragen",
"body": "Die Moderation wird benachrichtigt und kann die Transkription starten.",
"buttonLabel": "Anfragen"
}
},
"premium": {
"heading": "Premium-Funktion",
"body": "Diese Funktion ist öffentlichen Bediensteten vorbehalten. Wenn Ihre E-Mail-Adresse nicht autorisiert ist, kontaktieren Sie bitte den Support, um Zugriff zu erhalten.",
"linkMore": "Mehr erfahren",
"body": "Diese Funktion ist öffentlichen Bediensteten vorbehalten. Wenn deine E-Mail-Adresse nicht autorisiert ist, kontaktiere bitte den Support, um Zugriff zu erhalten.",
"linkMore": "Dokumentation öffnen",
"linkAriaLabel": "Dokumentation zum Premium-Zugang öffnen öffnet in neuem Tab",
"dividerLabel": "ODER",
"login": {
"heading": "Anmeldung erforderlich",
"body": "Nur der Ersteller des Meetings oder ein Administrator kann die Transkription starten. Melden Sie sich an, um Ihre Berechtigungen zu überprüfen."
"body": "Nur die*der Ersteller:in bzw. Admin kann die Transkription starten. Melde dich an, um deine Berechtigungen zu überprüfen."
},
"request": {
"heading": "Anfrage an Moderator",
"body": "Der Moderator wird benachrichtigt und kann die Transkription starten.",
"heading": "Transkription anfragen",
"body": "Die Moderation wird benachrichtigt und kann die Transkription starten.",
"buttonLabel": "Anfordern"
}
}
},
"screenRecording": {
"heading": "Diesen Anruf für später aufzeichnen",
"body": "Zeichnen Sie bis zu {{max_duration}} des Meetings auf.",
"bodyWithoutMaxDuration": "Zeichnen Sie Ihr Meeting unbegrenzt auf.",
"linkMore": "Mehr erfahren",
"heading": "Dieses Meeting für später aufzeichnen",
"body": "Zeichne bis zu {{max_duration}} des Meetings auf.",
"bodyWithoutMaxDuration": "Zeichne dein Meeting ohne Zeitbegrenzung auf.",
"linkMore": "Dokumentation öffnen",
"linkAriaLabel": "Dokumentation zur Aufzeichnung öffnen öffnet in neuem Tab",
"details": {
"receiver": "Die Aufzeichnung wird an den Organisator und die Mitorganisatoren gesendet.",
"receiver": "Die Aufzeichnung wird an die*den Organisator:in und die Mitorganisierenden gesendet.",
"destination": "Diese Aufzeichnung wird vorübergehend auf unseren Servern gespeichert",
"loading": "Aufzeichnung wird gestartet",
"linkMore": "Mehr erfahren",
@@ -419,20 +425,21 @@
"stop": "Meeting-Aufzeichnung beenden",
"saving": "Wird gespeichert…",
"starting": "Wird gestartet…",
"anotherModeStarted": "Eine Transkription läuft. <br/> Wechseln Sie das Menü und stoppen Sie sie."
"anotherModeStarted": "Eine Transkription läuft. <br/> Wechsele das Menü und stoppen sie."
},
"notAdminOrOwner": {
"heading": "Zugriff eingeschränkt",
"body": "Aus Sicherheitsgründen kann nur der Ersteller oder ein Administrator des Meetings eine Videoaufnahme (Beta) starten.",
"linkMore": "Mehr erfahren",
"body": "Aus Sicherheitsgründen kann nur die*der Ersteller:in bzw. Admin des Meetings eine Videoaufnahme (Beta) starten.",
"linkMore": "Dokumentation öffnen",
"linkAriaLabel": "Dokumentation zum Aufzeichnungszugang öffnen öffnet in neuem Tab",
"dividerLabel": "ODER",
"login": {
"heading": "Anmeldung erforderlich",
"body": "Nur der Ersteller der Besprechung oder ein Administrator kann die Aufzeichnung starten. Melden Sie sich an, um Ihre Berechtigungen zu überprüfen."
"body": "Nur die*der Ersteller:in bzw. Admin kann die Aufzeichnung starten. Melde dich an, um deine Berechtigungen zu überprüfen."
},
"request": {
"heading": "Aufnahme anfordern",
"body": "Der Moderator wird benachrichtigt und kann die Aufnahme für Sie starten.",
"heading": "Aufnahme anfragen",
"body": "Die Moderation wird benachrichtigt und kann die Aufnahme starten.",
"buttonLabel": "Anfrage senden"
}
},
@@ -447,10 +454,10 @@
"button": "OK"
},
"admin": {
"description": "Diese Einstellungen für Organisatoren ermöglichen dir die Kontrolle über dein Meeting. Nur Organisatoren haben Zugriff auf diese Optionen.",
"description": "Diese Einstellungen für Organisierende ermöglichen dir die Kontrolle über dein Meeting. Nur Organisierende haben Zugriff auf diese Optionen.",
"access": {
"title": "Raumzugang",
"description": "Diese Einstellungen gelten auch für zukünftige Ausgaben dieses Meetings.",
"description": "Diese Einstellungen gelten auch für zukünftige Meetings in diesem Raum.",
"type": "Zugangsarten für Meetings",
"levels": {
"public": {
@@ -468,28 +475,28 @@
}
},
"moderation": {
"title": "Moderation des Meetings",
"description": "Diese Einstellungen beschränken die Aktionen, die Mitwirkende während des Meetings durchführen können.",
"title": "Meeting-Moderation",
"description": "Diese Einstellungen beschränken die Aktionen, die Teilnehmende während des Meetings durchführen können.",
"microphone": {
"label": "Mikrofon einschalten",
"description": ""
},
"camera": {
"label": "Video aktivieren",
"label": "Video einschalten",
"description": ""
},
"screenshare": {
"label": "Ihren Bildschirm teilen",
"description": "Wenn Sie diese Option deaktivieren, können Teilnehmer ihren Bildschirm nicht mehr teilen, und laufende Bildschirmfreigaben werden sofort beendet."
"label": "Bildschirm teilen",
"description": "Wenn du diese Option deaktivierst, können Teilnehmende ihren Bildschirm nicht mehr teilen. Laufende Bildschirmfreigaben werden sofort beendet."
}
}
},
"rating": {
"submit": "Absenden",
"question": "Wie bewertest du die Qualität deines Gesprächs?",
"question": "Wie bewertest du die Qualität dieses Meetings?",
"levels": {
"min": "sehr schlecht",
"max": "ausgezeichnet"
"max": "hervorragend"
}
},
"openFeedback": {
@@ -499,7 +506,7 @@
"skip": "Überspringen"
},
"confirmationMessage": {
"heading": "Vielen Dank für deine Rückmeldung",
"heading": "Vielen Dank für dein Feedback",
"body": "Unser Produktteam nimmt sich Zeit, dein Feedback sorgfältig zu prüfen. Wir melden uns so bald wie möglich bei dir."
},
"authenticationMessage": {
@@ -511,9 +518,9 @@
"participants": {
"subheading": "Im Raum",
"you": "Du",
"unknown": "Unbekannter Teilnehmer",
"unknown": "Unbekannte Person",
"host": "Host",
"contributors": "Mitwirkende",
"contributors": "Teilnehmende",
"collapsable": {
"open": "{{name}}-Liste öffnen",
"close": "{{name}}-Liste schließen"
@@ -522,7 +529,7 @@
"muteParticipant": "{{name}}s Mikrofon ausschalten",
"muteParticipantAlert": {
"heading": "{{name}} stummschalten",
"description": "{{name}} für alle Teilnehmer stummschalten? {{name}} kann sich nur selbst wieder einschalten.",
"description": "{{name}} für alle Teilnehmenden stummschalten? {{name}} kann sich nur selbst wieder einschalten.",
"confirm": "Stummschalten",
"cancel": "Abbrechen"
},
@@ -547,8 +554,8 @@
},
"participantMenu": {
"remove": {
"label": "Vom Anruf ausschließen",
"ariaLabel": "{{name}} vom Anruf ausschließen"
"label": "Vom Meeting ausschließen",
"ariaLabel": "{{name}} vom Meeting ausschließen"
},
"unpin": {
"label": "Lösen",
@@ -582,8 +589,8 @@
"started": "Aufzeichnung läuft"
},
"limitReachedAlert": {
"title": "Aufnahmelimit überschritten",
"description": "Die Aufnahme hat die zulässige Höchstdauer{{duration_message}} überschritten. Sie wird jetzt automatisch gespeichert.",
"title": "Aufzeichnungslimit überschritten",
"description": "Die Aufzeichnung hat die zulässige Höchstdauer{{duration_message}} überschritten. Sie wird jetzt automatisch gespeichert.",
"durationMessage": " von {{duration}}",
"button": "OK"
}
@@ -595,12 +602,12 @@
"enable": "Anheften",
"disable": "Lösen"
},
"effects": "Visuelle Effekte anwenden",
"effects": "Hintergründe und Effekte anwenden",
"muteParticipant": "{{name}} stummschalten",
"fullScreen": "Vollbild"
},
"shortcutsPanel": {
"title": "Tastenkombinationen",
"title": "Tastenkürzel",
"categories": {
"navigation": "Navigation",
"media": "Medien",
@@ -611,13 +618,13 @@
"focus-toolbar": "Fokus auf die untere Symbolleiste",
"toggle-microphone": "Mikrofon umschalten",
"toggle-camera": "Kamera umschalten",
"push-to-talk": "Push-to-talk (gedrückt halten zum Einschalten)",
"push-to-talk": "Push-to-talk",
"reaction": "Reaktionspanel",
"fullscreen": "Vollbild umschalten",
"recording": "Aufnahmepanel umschalten",
"raise-hand": "Hand heben oder senken",
"toggle-chat": "Chat anzeigen/ausblenden",
"toggle-participants": "Teilnehmer anzeigen/ausblenden",
"toggle-participants": "Teilnehmende anzeigen/ausblenden",
"open-shortcuts-settings": "Tastenkürzel-Einstellungen öffnen"
},
"sr": {
@@ -635,8 +642,8 @@
}
},
"fullScreenWarning": {
"message": "Um eine Endlosschleife zu vermeiden, teile nicht deinen gesamten Bildschirm. Teile stattdessen einen Tab oder ein anderes Fenster.",
"stop": "Präsentation beenden",
"message": "Damit dein Bildschirm nicht unendlich gespiegelt wird, teile nicht deinen gesamten Bildschirm. Teile stattdessen einen Tab oder ein anderes Fenster.",
"stop": "Teilen beenden",
"ignore": "Ignorieren"
},
"participantTile": {
+2 -2
View File
@@ -4,7 +4,7 @@
"joinButton": "Mit Visio beitreten",
"copyLinkTooltip": "Link kopieren",
"resetLabel": "Zurücksetzen",
"participantLimit": "Bis zu 150 Teilnehmer.",
"popupBlocked": "Popup wurde blockiert. Bitte erlauben Sie Popups für diese Website."
"participantLimit": "Bis zu 150 Teilnehmende.",
"popupBlocked": "Popup wurde blockiert. Bitte erlaube Popups für diese Website."
}
}
+32 -21
View File
@@ -1,46 +1,46 @@
{
"account": {
"currentlyLoggedAs": "Sie sind derzeit angemeldet als <0>{{user}}</0>",
"currentlyLoggedAs": "Du bist derzeit angemeldet als <0>{{user}}</0>",
"heading": "Konto",
"youAreNotLoggedIn": "Sie sind nicht angemeldet.",
"nameLabel": "Ihr Name",
"youAreNotLoggedIn": "Du bist nicht angemeldet.",
"nameLabel": "Dein Name",
"authentication": "Authentifizierung",
"nameError": "Ihr Name darf nicht leer sein"
"nameError": "Dein Name darf nicht leer sein"
},
"preferences": {
"title": "Einstellungen",
"idleDisconnectModal": {
"label": "Anrufe ohne Teilnehmer verlassen",
"description": "Verlässt automatisch einen Anruf nach einigen Minuten, wenn kein anderer Teilnehmer beitritt"
"label": "Meetings ohne Teilnehmende verlassen",
"description": "Meetings nach einigen Minuten automatisch verlassen, wenn keine andere Person beitritt"
}
},
"audio": {
"microphone": {
"heading": "Mikrofon",
"label": "Wählen Sie Ihre Audioeingabe",
"label": "Audioeingabe wählen",
"disabled": "Mikrofon deaktiviert"
},
"noiseReduction": {
"label": "Rauschunterdrückung",
"heading": "Rauschunterdrückung",
"label": "Geräuschunterdrückung",
"heading": "Geräuschunterdrückung",
"ariaLabel": {
"enable": "Rauschunterdrückung aktivieren",
"disable": "Rauschunterdrückung deaktivieren"
"enable": "Geräuschunterdrückung aktivieren",
"disable": "Geräuschunterdrückung deaktivieren"
}
},
"speakers": {
"heading": "Lautsprecher",
"label": "Wählen Sie Ihre Audioausgabe",
"label": "Audioausgabe wählen",
"test": "Testen",
"ongoingTest": "Soundtest läuft…",
"safariWarning": "Die Lautsprecherauswahl ist auf Safari aufgrund von Browser-Einschränkungen noch nicht verfügbar."
"safariWarning": "Die Audioausgabeauswahl ist auf Safari aufgrund von Browser-Einschränkungen noch nicht verfügbar."
},
"permissionsRequired": "Berechtigungen erforderlich"
},
"video": {
"camera": {
"heading": "Kamera",
"label": "Wählen Sie Ihre Videoeingabe aus",
"label": "Videoeingabe wählen",
"disabled": "Kamera deaktiviert",
"previewAriaLabel": {
"enabled": "Videovorschau aktiviert",
@@ -50,18 +50,18 @@
"resolution": {
"heading": "Auflösung",
"publish": {
"label": "Wählen Sie Ihre maximale Sendeauflösung (max.)",
"label": "Wähle die maximale Auflösung beim Senden",
"items": {
"high": "Hohe Auflösung",
"medium": "Standardauflösung",
"medium": "Mittlere Auflösung",
"low": "Niedrige Auflösung"
}
},
"subscribe": {
"label": "Wählen Sie Ihre Empfangsauflösung (max.)",
"label": "Wähle die maximale Auflösung beim Empfangen",
"items": {
"high": "Hohe Auflösung (automatisch)",
"medium": "Standardauflösung",
"medium": "Mittlere Auflösung",
"low": "Niedrige Auflösung"
}
}
@@ -82,15 +82,15 @@
}
},
"notifications": {
"heading": "Tonbenachrichtigungen",
"label": "Tonbenachrichtigungen für",
"heading": "Benachrichtigungen mit Ton",
"label": "Benachrichtigungen mit Ton für",
"actions": {
"disable": "Deaktivieren",
"enable": "Aktivieren"
},
"items": {
"participantJoined": {
"label": "Teilnehmer beigetreten"
"label": "Person beigetreten"
},
"handRaised": {
"label": "Hand gehoben"
@@ -116,6 +116,17 @@
"accessibility": {
"announceReactions": {
"label": "Reaktionen vorlesen"
},
"captions": {
"heading": "Untertitel",
"textSize": {
"label": "Untertitel-Schriftgröße",
"options": {
"small": "Klein",
"medium": "Mittel",
"large": "Groß"
}
}
}
},
"tabs": {
+1 -1
View File
@@ -47,7 +47,7 @@
},
"loginHint": {
"title": "Log in with your account",
"body": "Instead of waiting, log in with your ProConnect account.",
"body": "Instead of waiting, log in with your account.",
"button": {
"ariaLabel": "Close the suggestion",
"label": "OK"
+1 -1
View File
@@ -10,7 +10,7 @@
"joinMeetingTipContent": "You can join a meeting by pasting its full link in the browser's address bar.",
"joinMeetingTipHeading": "Did you know?",
"loginToCreateMeeting": "Login to create a meeting",
"moreLinkLabel": "Learn more - new tab",
"moreLinkLabel": "Learn more about {{appTitle}} - new tab",
"moreLink": "Learn more",
"moreAbout": "about {{appTitle}}",
"createMenu": {
@@ -9,6 +9,7 @@
},
"muted": "{{name}} has muted your microphone. No participant can hear you.",
"openChat": "Open chat",
"chatMessageReceived": "{{name}} says: {{message}}",
"lowerHand": {
"auto": "It seems you have started speaking, so your hand will be lowered.",
"dismiss": "Keep hand raised"

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