Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 45f374610f | |||
| b419a2bfd2 | |||
| ee8d96bee7 | |||
| c65ff2d75d | |||
| ea1c90d8ca | |||
| 3e963e3e6d | |||
| 82769128a1 | |||
| 05f4ce6b2e | |||
| 508984ecfa | |||
| a05507f73d | |||
| a27def119c | |||
| fce94f38ce | |||
| 83bd9c5ce3 | |||
| bfbfade99a | |||
| 16daf7b8d3 | |||
| fcad79d662 | |||
| 2424ce17ec | |||
| 4715905334 | |||
| 7c05aedcfe | |||
| 7347fc7c86 | |||
| ada7d9a666 | |||
| 0cb3fb8e3c | |||
| dcb788b57b | |||
| 73bcb9d598 | |||
| f3e90c3999 | |||
| cb4ed3c9d7 | |||
| f8b0746e73 | |||
| d8ad7a743e | |||
| 0ba445895c | |||
| 3b719ab9ba | |||
| 8887e811d3 | |||
| cb9e994749 | |||
| 1841533d2c | |||
| 7da99f2116 | |||
| b0af5e7f35 | |||
| dd6bb0ed3e | |||
| 1306f0bcfe | |||
| ca4494c09e | |||
| f57db0acc8 | |||
| 342f992556 | |||
| d0cf3974ad | |||
| 0f3eb35c83 |
+1
-1
@@ -4,7 +4,7 @@ __pycache__
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
venv
|
||||
.venv
|
||||
**/.venv
|
||||
|
||||
# System-specific files
|
||||
.DS_Store
|
||||
|
||||
@@ -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
|
||||
|
||||
+54
-12
@@ -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
|
||||
@@ -279,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: |
|
||||
@@ -290,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
|
||||
|
||||
@@ -31,6 +31,7 @@ MANIFEST
|
||||
|
||||
# Translations # Translations
|
||||
*.pot
|
||||
*.mo
|
||||
|
||||
# Environments
|
||||
.env
|
||||
|
||||
+22
-2
@@ -8,26 +8,46 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- ♿️(frontend) fix sidepanel accessibility aria-label #1182
|
||||
- ♿️(frontend) fix more tools heading hierarchy #1181
|
||||
|
||||
## [1.11.0] - 2026-03-19
|
||||
|
||||
### 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
|
||||
- ✨(frontend) custom background #1067
|
||||
|
||||
### 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 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
|
||||
- 📌(agents) unpin OpenSSL and related dependencies #1167
|
||||
- ♿️(frontend) add caption font and background color customization #1122
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(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
|
||||
|
||||
|
||||
+39
-23
@@ -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 ----
|
||||
@@ -42,17 +56,17 @@ RUN apk add \
|
||||
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
|
||||
@@ -81,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.
|
||||
@@ -103,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
|
||||
@@ -115,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"]
|
||||
|
||||
@@ -191,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
|
||||
@@ -203,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
|
||||
@@ -223,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
|
||||
@@ -354,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
|
||||
|
||||
@@ -103,6 +103,7 @@ 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'])
|
||||
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
_dc_run \
|
||||
app-summary-dev \
|
||||
python -m pytest "$@"
|
||||
+3
-1
@@ -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,6 +80,7 @@ services:
|
||||
volumes:
|
||||
- ./src/backend:/app
|
||||
- ./data/static:/data/static
|
||||
- /app/.venv
|
||||
depends_on:
|
||||
- postgresql
|
||||
- mailcatcher
|
||||
@@ -105,6 +106,7 @@ services:
|
||||
volumes:
|
||||
- ./src/backend:/app
|
||||
- ./data/static:/data/static
|
||||
- /app/.venv
|
||||
depends_on:
|
||||
- app-dev
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -71,7 +71,7 @@ backend:
|
||||
# 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
|
||||
|
||||
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||
|
||||
@@ -27,7 +27,8 @@ 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
|
||||
MEDIA_BASE_URL=http://localhost:3000
|
||||
FILE_UPLOAD_ENABLED=True
|
||||
|
||||
# OIDC
|
||||
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs
|
||||
|
||||
@@ -4,8 +4,6 @@ FROM python:3.13-slim AS base
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libglib2.0-0 \
|
||||
libgobject-2.0-0 \
|
||||
"openssl=3.5.4-1~deb13u2" \
|
||||
"libssl3t64=3.5.4-1~deb13u2" \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.10.0"
|
||||
version = "1.11.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.3.10",
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Permission handlers for the Meet core app."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import Http404
|
||||
|
||||
from rest_framework import permissions
|
||||
@@ -116,6 +117,13 @@ class FilePermission(IsAuthenticated):
|
||||
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
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""API endpoints"""
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import re
|
||||
import uuid
|
||||
from logging import getLogger
|
||||
from urllib.parse import unquote, urlparse
|
||||
@@ -12,6 +11,7 @@ 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 django_filters import rest_framework as django_filters
|
||||
from rest_framework import (
|
||||
@@ -33,10 +33,12 @@ 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,
|
||||
)
|
||||
@@ -78,17 +80,6 @@ from .feature_flag import FeatureFlag
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
FILE_FOLDER = settings.FILE_UPLOAD_PATH
|
||||
UUID_REGEX = (
|
||||
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
|
||||
)
|
||||
FILE_EXT_REGEX = r"[\d\w]+"
|
||||
MEDIA_STORAGE_URL_PATTERN = re.compile(
|
||||
f"{settings.MEDIA_URL:s}"
|
||||
rf"(?P<key>{FILE_FOLDER:s}/(?P<pk>{UUID_REGEX:s})/\.{FILE_EXT_REGEX:s})$"
|
||||
)
|
||||
|
||||
|
||||
class NestedGenericViewSet(viewsets.GenericViewSet):
|
||||
"""
|
||||
A generic Viewset aims to be used in a nested route context.
|
||||
@@ -497,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,
|
||||
@@ -766,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:
|
||||
@@ -973,6 +967,26 @@ class FileViewSet(
|
||||
|
||||
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):
|
||||
@@ -980,6 +994,7 @@ class FileViewSet(
|
||||
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.
|
||||
@@ -1162,6 +1177,7 @@ class FileViewSet(
|
||||
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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -129,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 = []
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -759,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
|
||||
)
|
||||
@@ -952,7 +952,7 @@ class File(BaseModel):
|
||||
_, 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}"
|
||||
return f"{self.key_base}{extension!s}"
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -118,7 +118,7 @@ def test_api_files_create_file_authenticated_success():
|
||||
|
||||
assert policy_parsed.scheme == "http"
|
||||
assert policy_parsed.netloc == "localhost:9000"
|
||||
assert policy_parsed.path == f"/meet-media-storage/files/{file.id!s}/.png"
|
||||
assert policy_parsed.path == f"/meet-media-storage/files/{file.id!s}.png"
|
||||
|
||||
query_params = parse_qs(policy_parsed.query)
|
||||
|
||||
@@ -174,6 +174,26 @@ def test_api_files_create_file_authenticated_extension_case_insensitive():
|
||||
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.
|
||||
@@ -239,6 +259,48 @@ def test_api_files_create_file_authenticated_hidden_file_but_checking_extension_
|
||||
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()
|
||||
|
||||
@@ -39,7 +39,7 @@ def test_api_files_update_anonymous_forbidden():
|
||||
|
||||
def test_api_files_update_description_and_title():
|
||||
"""
|
||||
Test the description and title of an file can be updated.
|
||||
Test the description and title of a file can be updated.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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():
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-02-26 17:34+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,11 +29,11 @@ msgstr "Berechtigungen"
|
||||
msgid "Important dates"
|
||||
msgstr "Wichtige Daten"
|
||||
|
||||
#: core/admin.py:132 core/admin.py:243
|
||||
#: core/admin.py:132 core/admin.py:275
|
||||
msgid "No owner"
|
||||
msgstr "Kein Eigentümer"
|
||||
|
||||
#: core/admin.py:135 core/admin.py:246
|
||||
#: core/admin.py:135 core/admin.py:278
|
||||
msgid "Multiple owners"
|
||||
msgstr "Mehrere Eigentümer"
|
||||
|
||||
@@ -61,117 +61,136 @@ msgstr "Benachrichtigungen für %(count)s Aufnahme(n) erfolgreich gesendet."
|
||||
msgid "Skipped %(count)s expired recording(s)."
|
||||
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
|
||||
|
||||
#: core/admin.py:309
|
||||
#: 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:311
|
||||
#: core/admin.py:344
|
||||
msgid "Scopes"
|
||||
msgstr "Scopes"
|
||||
|
||||
#: core/api/filters.py:24
|
||||
#: core/api/filters.py:25
|
||||
msgid "Creator is me"
|
||||
msgstr "Ersteller bin ich"
|
||||
|
||||
#: core/api/serializers.py:84
|
||||
#: 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/api/serializers.py:443
|
||||
#: core/api/serializers.py:516
|
||||
msgid "This file extension is not allowed."
|
||||
msgstr "Diese Dateiendung ist nicht erlaubt."
|
||||
|
||||
#: core/models.py:35
|
||||
#: 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:36
|
||||
#: core/models.py:38
|
||||
msgid "Administrator"
|
||||
msgstr "Administrator"
|
||||
|
||||
#: core/models.py:37
|
||||
#: core/models.py:39
|
||||
msgid "Owner"
|
||||
msgstr "Eigentümer"
|
||||
|
||||
#: core/models.py:53
|
||||
#: core/models.py:55
|
||||
msgid "Initiated"
|
||||
msgstr "Gestartet"
|
||||
|
||||
#: core/models.py:54
|
||||
#: core/models.py:56
|
||||
msgid "Active"
|
||||
msgstr "Aktiv"
|
||||
|
||||
#: core/models.py:55
|
||||
#: core/models.py:57
|
||||
msgid "Stopped"
|
||||
msgstr "Beendet"
|
||||
|
||||
#: core/models.py:56
|
||||
#: core/models.py:58
|
||||
msgid "Saved"
|
||||
msgstr "Gespeichert"
|
||||
|
||||
#: core/models.py:57
|
||||
#: core/models.py:59
|
||||
msgid "Aborted"
|
||||
msgstr "Abgebrochen"
|
||||
|
||||
#: core/models.py:58
|
||||
#: core/models.py:60
|
||||
msgid "Failed to Start"
|
||||
msgstr "Start fehlgeschlagen"
|
||||
|
||||
#: core/models.py:59
|
||||
#: core/models.py:61
|
||||
msgid "Failed to Stop"
|
||||
msgstr "Stopp fehlgeschlagen"
|
||||
|
||||
#: core/models.py:60
|
||||
#: core/models.py:62
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Benachrichtigung erfolgreich"
|
||||
|
||||
#: core/models.py:87
|
||||
#: core/models.py:89
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "BILDSCHIRMAUFZEICHNUNG"
|
||||
|
||||
#: core/models.py:88
|
||||
#: core/models.py:90
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSKRIPT"
|
||||
|
||||
#: core/models.py:94
|
||||
#: core/models.py:96
|
||||
msgid "Public Access"
|
||||
msgstr "Öffentlicher Zugriff"
|
||||
|
||||
#: core/models.py:95
|
||||
#: core/models.py:97
|
||||
msgid "Trusted Access"
|
||||
msgstr "Vertrauenswürdiger Zugriff"
|
||||
|
||||
#: core/models.py:96
|
||||
#: core/models.py:98
|
||||
msgid "Restricted Access"
|
||||
msgstr "Eingeschränkter Zugriff"
|
||||
|
||||
#: core/models.py:108
|
||||
#: core/models.py:110
|
||||
msgid "id"
|
||||
msgstr "ID"
|
||||
|
||||
#: core/models.py:109
|
||||
#: core/models.py:111
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "Primärschlüssel des Eintrags als UUID"
|
||||
|
||||
#: core/models.py:115
|
||||
#: core/models.py:117
|
||||
msgid "created on"
|
||||
msgstr "erstellt am"
|
||||
|
||||
#: core/models.py:116
|
||||
#: 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:121
|
||||
#: core/models.py:123
|
||||
msgid "updated on"
|
||||
msgstr "aktualisiert am"
|
||||
|
||||
#: core/models.py:122
|
||||
#: 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:142
|
||||
#: core/models.py:144
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
@@ -179,11 +198,11 @@ msgstr ""
|
||||
"Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen "
|
||||
"und die Zeichen @/./+/-/_ enthalten."
|
||||
|
||||
#: core/models.py:148
|
||||
#: core/models.py:150
|
||||
msgid "sub"
|
||||
msgstr "Sub"
|
||||
|
||||
#: core/models.py:150
|
||||
#: core/models.py:152
|
||||
msgid ""
|
||||
"Optional for pending users; required upon account activation. 255 characters "
|
||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
@@ -191,55 +210,55 @@ msgstr ""
|
||||
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
|
||||
"Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt."
|
||||
|
||||
#: core/models.py:159
|
||||
#: core/models.py:161
|
||||
msgid "identity email address"
|
||||
msgstr "Identitäts-E-Mail-Adresse"
|
||||
|
||||
#: core/models.py:164
|
||||
#: core/models.py:166
|
||||
msgid "admin email address"
|
||||
msgstr "Administrator-E-Mail-Adresse"
|
||||
|
||||
#: core/models.py:166
|
||||
#: core/models.py:168
|
||||
msgid "full name"
|
||||
msgstr "Vollständiger Name"
|
||||
|
||||
#: core/models.py:168
|
||||
#: core/models.py:170
|
||||
msgid "short name"
|
||||
msgstr "Kurzname"
|
||||
|
||||
#: core/models.py:174
|
||||
#: core/models.py:176
|
||||
msgid "language"
|
||||
msgstr "Sprache"
|
||||
|
||||
#: core/models.py:175
|
||||
#: 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:181
|
||||
#: 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:184
|
||||
#: core/models.py:186
|
||||
msgid "device"
|
||||
msgstr "Gerät"
|
||||
|
||||
#: core/models.py:186
|
||||
#: 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:189
|
||||
#: core/models.py:191
|
||||
msgid "staff status"
|
||||
msgstr "Mitarbeiterstatus"
|
||||
|
||||
#: core/models.py:191
|
||||
#: 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:194
|
||||
#: core/models.py:196
|
||||
msgid "active"
|
||||
msgstr "aktiv"
|
||||
|
||||
#: core/models.py:197
|
||||
#: core/models.py:199
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
@@ -247,66 +266,66 @@ msgstr ""
|
||||
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
|
||||
"anstelle des Löschens des Kontos."
|
||||
|
||||
#: core/models.py:210
|
||||
#: core/models.py:212
|
||||
msgid "user"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#: core/models.py:211
|
||||
#: core/models.py:213
|
||||
msgid "users"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#: core/models.py:270
|
||||
#: core/models.py:272
|
||||
msgid "Resource"
|
||||
msgstr "Ressource"
|
||||
|
||||
#: core/models.py:271
|
||||
#: core/models.py:273
|
||||
msgid "Resources"
|
||||
msgstr "Ressourcen"
|
||||
|
||||
#: core/models.py:329
|
||||
#: core/models.py:331
|
||||
msgid "Resource access"
|
||||
msgstr "Ressourcenzugriff"
|
||||
|
||||
#: core/models.py:330
|
||||
#: core/models.py:332
|
||||
msgid "Resource accesses"
|
||||
msgstr "Ressourcenzugriffe"
|
||||
|
||||
#: core/models.py:336
|
||||
#: 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:392
|
||||
#: core/models.py:394
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Visio-Raumkonfiguration"
|
||||
|
||||
#: core/models.py:393
|
||||
#: 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:400
|
||||
#: core/models.py:402
|
||||
msgid "Room PIN code"
|
||||
msgstr "PIN-Code für den Raum"
|
||||
|
||||
#: core/models.py:401
|
||||
#: 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:407 core/models.py:561
|
||||
#: core/models.py:409 core/models.py:563
|
||||
msgid "Room"
|
||||
msgstr "Raum"
|
||||
|
||||
#: core/models.py:408
|
||||
#: core/models.py:410
|
||||
msgid "Rooms"
|
||||
msgstr "Räume"
|
||||
|
||||
#: core/models.py:572
|
||||
#: core/models.py:574
|
||||
msgid "Worker ID"
|
||||
msgstr "Worker-ID"
|
||||
|
||||
#: core/models.py:574
|
||||
#: 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."
|
||||
@@ -315,120 +334,120 @@ msgstr ""
|
||||
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
|
||||
"ermöglicht."
|
||||
|
||||
#: core/models.py:582
|
||||
#: core/models.py:584
|
||||
msgid "Recording mode"
|
||||
msgstr "Aufzeichnungsmodus"
|
||||
|
||||
#: core/models.py:583
|
||||
#: core/models.py:585
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
|
||||
|
||||
#: core/models.py:588 core/models.py:589
|
||||
#: core/models.py:590 core/models.py:591
|
||||
msgid "Recording options"
|
||||
msgstr "Aufnahmeoptionen"
|
||||
|
||||
#: core/models.py:595
|
||||
#: core/models.py:597
|
||||
msgid "Recording"
|
||||
msgstr "Aufzeichnung"
|
||||
|
||||
#: core/models.py:596
|
||||
#: core/models.py:598
|
||||
msgid "Recordings"
|
||||
msgstr "Aufzeichnungen"
|
||||
|
||||
#: core/models.py:704
|
||||
#: core/models.py:706
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Beziehung Aufzeichnung/Benutzer"
|
||||
|
||||
#: core/models.py:705
|
||||
#: core/models.py:707
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Beziehungen Aufzeichnung/Benutzer"
|
||||
|
||||
#: core/models.py:711
|
||||
#: core/models.py:713
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
|
||||
|
||||
#: core/models.py:717
|
||||
#: core/models.py:719
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
|
||||
|
||||
#: core/models.py:723
|
||||
#: 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:740
|
||||
#: core/models.py:742
|
||||
msgid "Create rooms"
|
||||
msgstr "Räume erstellen"
|
||||
|
||||
#: core/models.py:741
|
||||
#: core/models.py:743
|
||||
msgid "List rooms"
|
||||
msgstr "Räume auflisten"
|
||||
|
||||
#: core/models.py:742
|
||||
#: core/models.py:744
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Raumdetails abrufen"
|
||||
|
||||
#: core/models.py:743
|
||||
#: core/models.py:745
|
||||
msgid "Update rooms"
|
||||
msgstr "Räume aktualisieren"
|
||||
|
||||
#: core/models.py:744
|
||||
#: core/models.py:746
|
||||
msgid "Delete rooms"
|
||||
msgstr "Räume löschen"
|
||||
|
||||
#: core/models.py:757
|
||||
#: core/models.py:759
|
||||
msgid "Application name"
|
||||
msgstr "Anwendungsname"
|
||||
|
||||
#: core/models.py:758
|
||||
#: core/models.py:760
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Beschreibender Name für diese Anwendung."
|
||||
|
||||
#: core/models.py:768
|
||||
#: 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:779
|
||||
#: core/models.py:781
|
||||
msgid "Application"
|
||||
msgstr "Anwendung"
|
||||
|
||||
#: core/models.py:780
|
||||
#: core/models.py:782
|
||||
msgid "Applications"
|
||||
msgstr "Anwendungen"
|
||||
|
||||
#: core/models.py:803
|
||||
#: core/models.py:805
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Geben Sie eine gültige Domain ein"
|
||||
|
||||
#: core/models.py:806
|
||||
#: core/models.py:808
|
||||
msgid "Domain"
|
||||
msgstr "Domain"
|
||||
|
||||
#: core/models.py:807
|
||||
#: 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:819
|
||||
#: core/models.py:821
|
||||
msgid "Application domain"
|
||||
msgstr "Anwendungsdomain"
|
||||
|
||||
#: core/models.py:820
|
||||
#: core/models.py:822
|
||||
msgid "Application domains"
|
||||
msgstr "Anwendungsdomains"
|
||||
|
||||
#: core/models.py:838
|
||||
#: core/models.py:840
|
||||
msgid "Pending"
|
||||
msgstr "Ausstehend"
|
||||
|
||||
#: core/models.py:846
|
||||
#: core/models.py:848
|
||||
msgid "Ready"
|
||||
msgstr "Bereit"
|
||||
|
||||
#: core/models.py:852
|
||||
#: core/models.py:854
|
||||
msgid "Background image"
|
||||
msgstr "Hintergrundbild"
|
||||
|
||||
#: core/models.py:864
|
||||
#: core/models.py:866
|
||||
msgid "title"
|
||||
msgstr "Titel"
|
||||
|
||||
@@ -445,11 +464,11 @@ msgstr "Datei"
|
||||
msgid "Files"
|
||||
msgstr "Dateien"
|
||||
|
||||
#: core/models.py:970
|
||||
#: core/models.py:1000
|
||||
msgid "This file is already hard deleted."
|
||||
msgstr "Diese Datei wurde bereits endgültig gelöscht."
|
||||
|
||||
#: core/models.py:980
|
||||
#: 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."
|
||||
@@ -585,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:224
|
||||
#: meet/settings.py:223
|
||||
msgid "English"
|
||||
msgstr "Englisch"
|
||||
|
||||
#: meet/settings.py:225
|
||||
#: meet/settings.py:224
|
||||
msgid "French"
|
||||
msgstr "Französisch"
|
||||
|
||||
#: meet/settings.py:226
|
||||
#: meet/settings.py:225
|
||||
msgid "Dutch"
|
||||
msgstr "Niederländisch"
|
||||
|
||||
#: meet/settings.py:227
|
||||
#: meet/settings.py:226
|
||||
msgid "German"
|
||||
msgstr "Deutsch"
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-02-26 17:26+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,11 +29,11 @@ msgstr "Permissions"
|
||||
msgid "Important dates"
|
||||
msgstr "Important dates"
|
||||
|
||||
#: core/admin.py:132 core/admin.py:243
|
||||
#: core/admin.py:132 core/admin.py:275
|
||||
msgid "No owner"
|
||||
msgstr "No owner"
|
||||
|
||||
#: core/admin.py:135 core/admin.py:246
|
||||
#: core/admin.py:135 core/admin.py:278
|
||||
msgid "Multiple owners"
|
||||
msgstr "Multiple owners"
|
||||
|
||||
@@ -61,115 +61,134 @@ msgstr "Successfully sent notifications for %(count)s recording(s)."
|
||||
msgid "Skipped %(count)s expired recording(s)."
|
||||
msgstr "Skipped %(count)s expired recording(s)."
|
||||
|
||||
#: core/admin.py:309
|
||||
#: 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:311
|
||||
#: core/admin.py:344
|
||||
msgid "Scopes"
|
||||
msgstr "Scopes"
|
||||
|
||||
#: core/api/filters.py:24
|
||||
#: core/api/filters.py:25
|
||||
msgid "Creator is me"
|
||||
msgstr "Creator is me"
|
||||
|
||||
#: core/api/serializers.py:84
|
||||
#: 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/api/serializers.py:443
|
||||
#: core/api/serializers.py:516
|
||||
msgid "This file extension is not allowed."
|
||||
msgstr "This file extension is not allowed."
|
||||
|
||||
#: core/models.py:35
|
||||
#: 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:36
|
||||
#: core/models.py:38
|
||||
msgid "Administrator"
|
||||
msgstr "Administrator"
|
||||
|
||||
#: core/models.py:37
|
||||
#: core/models.py:39
|
||||
msgid "Owner"
|
||||
msgstr "Owner"
|
||||
|
||||
#: core/models.py:53
|
||||
#: core/models.py:55
|
||||
msgid "Initiated"
|
||||
msgstr "Initiated"
|
||||
|
||||
#: core/models.py:54
|
||||
#: core/models.py:56
|
||||
msgid "Active"
|
||||
msgstr "Active"
|
||||
|
||||
#: core/models.py:55
|
||||
#: core/models.py:57
|
||||
msgid "Stopped"
|
||||
msgstr "Stopped"
|
||||
|
||||
#: core/models.py:56
|
||||
#: core/models.py:58
|
||||
msgid "Saved"
|
||||
msgstr "Saved"
|
||||
|
||||
#: core/models.py:57
|
||||
#: core/models.py:59
|
||||
msgid "Aborted"
|
||||
msgstr "Aborted"
|
||||
|
||||
#: core/models.py:58
|
||||
#: core/models.py:60
|
||||
msgid "Failed to Start"
|
||||
msgstr "Failed to Start"
|
||||
|
||||
#: core/models.py:59
|
||||
#: core/models.py:61
|
||||
msgid "Failed to Stop"
|
||||
msgstr "Failed to Stop"
|
||||
|
||||
#: core/models.py:60
|
||||
#: core/models.py:62
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Notification succeeded"
|
||||
|
||||
#: core/models.py:87
|
||||
#: core/models.py:89
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "SCREEN_RECORDING"
|
||||
|
||||
#: core/models.py:88
|
||||
#: core/models.py:90
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSCRIPT"
|
||||
|
||||
#: core/models.py:94
|
||||
#: core/models.py:96
|
||||
msgid "Public Access"
|
||||
msgstr "Public Access"
|
||||
|
||||
#: core/models.py:95
|
||||
#: core/models.py:97
|
||||
msgid "Trusted Access"
|
||||
msgstr "Trusted Access"
|
||||
|
||||
#: core/models.py:96
|
||||
#: core/models.py:98
|
||||
msgid "Restricted Access"
|
||||
msgstr "Restricted Access"
|
||||
|
||||
#: core/models.py:108
|
||||
#: core/models.py:110
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: core/models.py:109
|
||||
#: core/models.py:111
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "primary key for the record as UUID"
|
||||
|
||||
#: core/models.py:115
|
||||
#: core/models.py:117
|
||||
msgid "created on"
|
||||
msgstr "created on"
|
||||
|
||||
#: core/models.py:116
|
||||
#: 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:121
|
||||
#: core/models.py:123
|
||||
msgid "updated on"
|
||||
msgstr "updated on"
|
||||
|
||||
#: core/models.py:122
|
||||
#: 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:142
|
||||
#: core/models.py:144
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
@@ -177,11 +196,11 @@ msgstr ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
|
||||
#: core/models.py:148
|
||||
#: core/models.py:150
|
||||
msgid "sub"
|
||||
msgstr "sub"
|
||||
|
||||
#: core/models.py:150
|
||||
#: core/models.py:152
|
||||
msgid ""
|
||||
"Optional for pending users; required upon account activation. 255 characters "
|
||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
@@ -189,55 +208,55 @@ msgstr ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
|
||||
#: core/models.py:159
|
||||
#: core/models.py:161
|
||||
msgid "identity email address"
|
||||
msgstr "identity email address"
|
||||
|
||||
#: core/models.py:164
|
||||
#: core/models.py:166
|
||||
msgid "admin email address"
|
||||
msgstr "admin email address"
|
||||
|
||||
#: core/models.py:166
|
||||
#: core/models.py:168
|
||||
msgid "full name"
|
||||
msgstr "full name"
|
||||
|
||||
#: core/models.py:168
|
||||
#: core/models.py:170
|
||||
msgid "short name"
|
||||
msgstr "short name"
|
||||
|
||||
#: core/models.py:174
|
||||
#: core/models.py:176
|
||||
msgid "language"
|
||||
msgstr "language"
|
||||
|
||||
#: core/models.py:175
|
||||
#: 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:181
|
||||
#: 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:184
|
||||
#: core/models.py:186
|
||||
msgid "device"
|
||||
msgstr "device"
|
||||
|
||||
#: core/models.py:186
|
||||
#: 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:189
|
||||
#: core/models.py:191
|
||||
msgid "staff status"
|
||||
msgstr "staff status"
|
||||
|
||||
#: core/models.py:191
|
||||
#: 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:194
|
||||
#: core/models.py:196
|
||||
msgid "active"
|
||||
msgstr "active"
|
||||
|
||||
#: core/models.py:197
|
||||
#: core/models.py:199
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
@@ -245,63 +264,63 @@ msgstr ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
|
||||
#: core/models.py:210
|
||||
#: core/models.py:212
|
||||
msgid "user"
|
||||
msgstr "user"
|
||||
|
||||
#: core/models.py:211
|
||||
#: core/models.py:213
|
||||
msgid "users"
|
||||
msgstr "users"
|
||||
|
||||
#: core/models.py:270
|
||||
#: core/models.py:272
|
||||
msgid "Resource"
|
||||
msgstr "Resource"
|
||||
|
||||
#: core/models.py:271
|
||||
#: core/models.py:273
|
||||
msgid "Resources"
|
||||
msgstr "Resources"
|
||||
|
||||
#: core/models.py:329
|
||||
#: core/models.py:331
|
||||
msgid "Resource access"
|
||||
msgstr "Resource access"
|
||||
|
||||
#: core/models.py:330
|
||||
#: core/models.py:332
|
||||
msgid "Resource accesses"
|
||||
msgstr "Resource accesses"
|
||||
|
||||
#: core/models.py:336
|
||||
#: 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:392
|
||||
#: core/models.py:394
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Visio room configuration"
|
||||
|
||||
#: core/models.py:393
|
||||
#: 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:400
|
||||
#: core/models.py:402
|
||||
msgid "Room PIN code"
|
||||
msgstr "Room PIN code"
|
||||
|
||||
#: core/models.py:401
|
||||
#: 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:407 core/models.py:561
|
||||
#: core/models.py:409 core/models.py:563
|
||||
msgid "Room"
|
||||
msgstr "Room"
|
||||
|
||||
#: core/models.py:408
|
||||
#: core/models.py:410
|
||||
msgid "Rooms"
|
||||
msgstr "Rooms"
|
||||
|
||||
#: core/models.py:572
|
||||
#: core/models.py:574
|
||||
msgid "Worker ID"
|
||||
msgstr "Worker ID"
|
||||
|
||||
#: core/models.py:574
|
||||
#: 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."
|
||||
@@ -309,125 +328,125 @@ msgstr ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
|
||||
#: core/models.py:582
|
||||
#: core/models.py:584
|
||||
msgid "Recording mode"
|
||||
msgstr "Recording mode"
|
||||
|
||||
#: core/models.py:583
|
||||
#: core/models.py:585
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Defines the mode of recording being called."
|
||||
|
||||
#: core/models.py:588 core/models.py:589
|
||||
#: core/models.py:590 core/models.py:591
|
||||
msgid "Recording options"
|
||||
msgstr "Recording options"
|
||||
|
||||
#: core/models.py:595
|
||||
#: core/models.py:597
|
||||
msgid "Recording"
|
||||
msgstr "Recording"
|
||||
|
||||
#: core/models.py:596
|
||||
#: core/models.py:598
|
||||
msgid "Recordings"
|
||||
msgstr "Recordings"
|
||||
|
||||
#: core/models.py:704
|
||||
#: core/models.py:706
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Recording/user relation"
|
||||
|
||||
#: core/models.py:705
|
||||
#: core/models.py:707
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Recording/user relations"
|
||||
|
||||
#: core/models.py:711
|
||||
#: core/models.py:713
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "This user is already in this recording."
|
||||
|
||||
#: core/models.py:717
|
||||
#: core/models.py:719
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "This team is already in this recording."
|
||||
|
||||
#: core/models.py:723
|
||||
#: 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:740
|
||||
#: core/models.py:742
|
||||
#, fuzzy
|
||||
#| msgid "created on"
|
||||
msgid "Create rooms"
|
||||
msgstr "Create rooms"
|
||||
|
||||
#: core/models.py:741
|
||||
#: core/models.py:743
|
||||
msgid "List rooms"
|
||||
msgstr "List rooms"
|
||||
|
||||
#: core/models.py:742
|
||||
#: core/models.py:744
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Retrieve room details"
|
||||
|
||||
#: core/models.py:743
|
||||
#: core/models.py:745
|
||||
#, fuzzy
|
||||
#| msgid "updated on"
|
||||
msgid "Update rooms"
|
||||
msgstr "Update rooms"
|
||||
|
||||
#: core/models.py:744
|
||||
#: core/models.py:746
|
||||
msgid "Delete rooms"
|
||||
msgstr "Delete rooms"
|
||||
|
||||
#: core/models.py:757
|
||||
#: core/models.py:759
|
||||
msgid "Application name"
|
||||
msgstr "Application name"
|
||||
|
||||
#: core/models.py:758
|
||||
#: core/models.py:760
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Descriptive name for this application."
|
||||
|
||||
#: core/models.py:768
|
||||
#: 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:779
|
||||
#: core/models.py:781
|
||||
msgid "Application"
|
||||
msgstr "Application"
|
||||
|
||||
#: core/models.py:780
|
||||
#: core/models.py:782
|
||||
msgid "Applications"
|
||||
msgstr "Applications"
|
||||
|
||||
#: core/models.py:803
|
||||
#: core/models.py:805
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Enter a valid domain"
|
||||
|
||||
#: core/models.py:806
|
||||
#: core/models.py:808
|
||||
msgid "Domain"
|
||||
msgstr "Domain"
|
||||
|
||||
#: core/models.py:807
|
||||
#: 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:819
|
||||
#: core/models.py:821
|
||||
msgid "Application domain"
|
||||
msgstr "Application domain"
|
||||
|
||||
#: core/models.py:820
|
||||
#: core/models.py:822
|
||||
msgid "Application domains"
|
||||
msgstr "Application domains"
|
||||
|
||||
#: core/models.py:838
|
||||
#: core/models.py:840
|
||||
#, fuzzy
|
||||
#| msgid "Recording"
|
||||
msgid "Pending"
|
||||
msgstr "Pending"
|
||||
|
||||
#: core/models.py:846
|
||||
#: core/models.py:848
|
||||
msgid "Ready"
|
||||
msgstr "Ready"
|
||||
|
||||
#: core/models.py:852
|
||||
#: core/models.py:854
|
||||
msgid "Background image"
|
||||
msgstr "Background image"
|
||||
|
||||
#: core/models.py:864
|
||||
#: core/models.py:866
|
||||
msgid "title"
|
||||
msgstr "title"
|
||||
|
||||
@@ -443,13 +462,13 @@ msgstr "File"
|
||||
msgid "Files"
|
||||
msgstr "Files"
|
||||
|
||||
#: core/models.py:970
|
||||
#: 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:980
|
||||
#: 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."
|
||||
|
||||
@@ -581,18 +600,18 @@ msgstr ""
|
||||
" If you have any questions or need assistance, please contact our support "
|
||||
"team at %(support_email)s. "
|
||||
|
||||
#: meet/settings.py:224
|
||||
#: meet/settings.py:223
|
||||
msgid "English"
|
||||
msgstr "English"
|
||||
|
||||
#: meet/settings.py:225
|
||||
#: meet/settings.py:224
|
||||
msgid "French"
|
||||
msgstr "French"
|
||||
|
||||
#: meet/settings.py:226
|
||||
#: meet/settings.py:225
|
||||
msgid "Dutch"
|
||||
msgstr "Dutch"
|
||||
|
||||
#: meet/settings.py:227
|
||||
#: meet/settings.py:226
|
||||
msgid "German"
|
||||
msgstr "German"
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-02-26 17:26+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,11 +29,11 @@ msgstr "Permissions"
|
||||
msgid "Important dates"
|
||||
msgstr "Dates importantes"
|
||||
|
||||
#: core/admin.py:132 core/admin.py:243
|
||||
#: core/admin.py:132 core/admin.py:275
|
||||
msgid "No owner"
|
||||
msgstr "Pas de propriétaire"
|
||||
|
||||
#: core/admin.py:135 core/admin.py:246
|
||||
#: core/admin.py:135 core/admin.py:278
|
||||
msgid "Multiple owners"
|
||||
msgstr "Plusieurs propriétaires"
|
||||
|
||||
@@ -61,119 +61,139 @@ msgstr "Notifications envoyées avec succès pour %(count)s enregistrement(s)."
|
||||
msgid "Skipped %(count)s expired recording(s)."
|
||||
msgstr "%(count)s enregistrement(s) expiré(s) ignoré(s)."
|
||||
|
||||
#: core/admin.py:309
|
||||
#: core/admin.py:200
|
||||
msgid "Mark selected recordings as 'Failed to Stop'"
|
||||
msgstr "Marquer les enregistrements sélectionnés comme « Échec d’arrê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 d’arrê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:311
|
||||
#: core/admin.py:344
|
||||
msgid "Scopes"
|
||||
msgstr "Scopes"
|
||||
|
||||
#: core/api/filters.py:24
|
||||
#: core/api/filters.py:25
|
||||
msgid "Creator is me"
|
||||
msgstr "Je suis le créateur"
|
||||
|
||||
#: core/api/serializers.py:84
|
||||
#: 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/api/serializers.py:443
|
||||
#: core/api/serializers.py:516
|
||||
msgid "This file extension is not allowed."
|
||||
msgstr "Cette extension n'est pas autorisée"
|
||||
|
||||
#: core/models.py:35
|
||||
#: 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:36
|
||||
#: core/models.py:38
|
||||
msgid "Administrator"
|
||||
msgstr "Administrateur"
|
||||
|
||||
#: core/models.py:37
|
||||
#: core/models.py:39
|
||||
msgid "Owner"
|
||||
msgstr "Propriétaire"
|
||||
|
||||
#: core/models.py:53
|
||||
#: core/models.py:55
|
||||
msgid "Initiated"
|
||||
msgstr "Initié"
|
||||
|
||||
#: core/models.py:54
|
||||
#: core/models.py:56
|
||||
msgid "Active"
|
||||
msgstr "Actif"
|
||||
|
||||
#: core/models.py:55
|
||||
#: core/models.py:57
|
||||
msgid "Stopped"
|
||||
msgstr "Arrêté"
|
||||
|
||||
#: core/models.py:56
|
||||
#: core/models.py:58
|
||||
msgid "Saved"
|
||||
msgstr "Enregistré"
|
||||
|
||||
#: core/models.py:57
|
||||
#: core/models.py:59
|
||||
msgid "Aborted"
|
||||
msgstr "Abandonné"
|
||||
|
||||
#: core/models.py:58
|
||||
#: core/models.py:60
|
||||
msgid "Failed to Start"
|
||||
msgstr "Échec au démarrage"
|
||||
|
||||
#: core/models.py:59
|
||||
#: core/models.py:61
|
||||
msgid "Failed to Stop"
|
||||
msgstr "Échec à l'arrêt"
|
||||
|
||||
#: core/models.py:60
|
||||
#: core/models.py:62
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Notification réussie"
|
||||
|
||||
#: core/models.py:87
|
||||
#: core/models.py:89
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "ENREGISTREMENT_ÉCRAN"
|
||||
|
||||
#: core/models.py:88
|
||||
#: core/models.py:90
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSCRIPTION"
|
||||
|
||||
#: core/models.py:94
|
||||
#: core/models.py:96
|
||||
msgid "Public Access"
|
||||
msgstr "Accès public"
|
||||
|
||||
#: core/models.py:95
|
||||
#: core/models.py:97
|
||||
msgid "Trusted Access"
|
||||
msgstr "Accès de confiance"
|
||||
|
||||
#: core/models.py:96
|
||||
#: core/models.py:98
|
||||
msgid "Restricted Access"
|
||||
msgstr "Accès restreint"
|
||||
|
||||
#: core/models.py:108
|
||||
#: core/models.py:110
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: core/models.py:109
|
||||
#: 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:115
|
||||
#: core/models.py:117
|
||||
msgid "created on"
|
||||
msgstr "créé le"
|
||||
|
||||
#: core/models.py:116
|
||||
#: 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:121
|
||||
#: core/models.py:123
|
||||
msgid "updated on"
|
||||
msgstr "mis à jour le"
|
||||
|
||||
#: core/models.py:122
|
||||
#: 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:142
|
||||
#: core/models.py:144
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
@@ -181,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:148
|
||||
#: core/models.py:150
|
||||
msgid "sub"
|
||||
msgstr "sub"
|
||||
|
||||
#: core/models.py:150
|
||||
#: core/models.py:152
|
||||
msgid ""
|
||||
"Optional for pending users; required upon account activation. 255 characters "
|
||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
@@ -193,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:159
|
||||
#: core/models.py:161
|
||||
msgid "identity email address"
|
||||
msgstr "adresse e-mail d'identité"
|
||||
|
||||
#: core/models.py:164
|
||||
#: core/models.py:166
|
||||
msgid "admin email address"
|
||||
msgstr "adresse e-mail d'administrateur"
|
||||
|
||||
#: core/models.py:166
|
||||
#: core/models.py:168
|
||||
msgid "full name"
|
||||
msgstr "nom complet"
|
||||
|
||||
#: core/models.py:168
|
||||
#: core/models.py:170
|
||||
msgid "short name"
|
||||
msgstr "nom court"
|
||||
|
||||
#: core/models.py:174
|
||||
#: core/models.py:176
|
||||
msgid "language"
|
||||
msgstr "langue"
|
||||
|
||||
#: core/models.py:175
|
||||
#: 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:181
|
||||
#: 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:184
|
||||
#: core/models.py:186
|
||||
msgid "device"
|
||||
msgstr "appareil"
|
||||
|
||||
#: core/models.py:186
|
||||
#: 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:189
|
||||
#: core/models.py:191
|
||||
msgid "staff status"
|
||||
msgstr "statut du personnel"
|
||||
|
||||
#: core/models.py:191
|
||||
#: 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:194
|
||||
#: core/models.py:196
|
||||
msgid "active"
|
||||
msgstr "actif"
|
||||
|
||||
#: core/models.py:197
|
||||
#: core/models.py:199
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
@@ -249,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:210
|
||||
#: core/models.py:212
|
||||
msgid "user"
|
||||
msgstr "utilisateur"
|
||||
|
||||
#: core/models.py:211
|
||||
#: core/models.py:213
|
||||
msgid "users"
|
||||
msgstr "utilisateurs"
|
||||
|
||||
#: core/models.py:270
|
||||
#: core/models.py:272
|
||||
msgid "Resource"
|
||||
msgstr "Ressource"
|
||||
|
||||
#: core/models.py:271
|
||||
#: core/models.py:273
|
||||
msgid "Resources"
|
||||
msgstr "Ressources"
|
||||
|
||||
#: core/models.py:329
|
||||
#: core/models.py:331
|
||||
msgid "Resource access"
|
||||
msgstr "Accès aux ressources"
|
||||
|
||||
#: core/models.py:330
|
||||
#: core/models.py:332
|
||||
msgid "Resource accesses"
|
||||
msgstr "Accès aux ressources"
|
||||
|
||||
#: core/models.py:336
|
||||
#: 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:392
|
||||
#: core/models.py:394
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Configuration de la salle de visioconférence"
|
||||
|
||||
#: core/models.py:393
|
||||
#: 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:400
|
||||
#: core/models.py:402
|
||||
msgid "Room PIN code"
|
||||
msgstr "Code PIN de la salle"
|
||||
|
||||
#: core/models.py:401
|
||||
#: 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:407 core/models.py:561
|
||||
#: core/models.py:409 core/models.py:563
|
||||
msgid "Room"
|
||||
msgstr "Salle"
|
||||
|
||||
#: core/models.py:408
|
||||
#: core/models.py:410
|
||||
msgid "Rooms"
|
||||
msgstr "Salles"
|
||||
|
||||
#: core/models.py:572
|
||||
#: core/models.py:574
|
||||
msgid "Worker ID"
|
||||
msgstr "ID du Worker"
|
||||
|
||||
#: core/models.py:574
|
||||
#: 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."
|
||||
@@ -315,127 +335,128 @@ 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:582
|
||||
#: core/models.py:584
|
||||
msgid "Recording mode"
|
||||
msgstr "Mode d'enregistrement"
|
||||
|
||||
#: core/models.py:583
|
||||
#: core/models.py:585
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Définit le mode d'enregistrement appelé."
|
||||
|
||||
#: core/models.py:588 core/models.py:589
|
||||
#: core/models.py:590 core/models.py:591
|
||||
msgid "Recording options"
|
||||
msgstr "Options d'enregistrement"
|
||||
|
||||
#: core/models.py:595
|
||||
#: core/models.py:597
|
||||
msgid "Recording"
|
||||
msgstr "Enregistrement"
|
||||
|
||||
#: core/models.py:596
|
||||
#: core/models.py:598
|
||||
msgid "Recordings"
|
||||
msgstr "Enregistrements"
|
||||
|
||||
#: core/models.py:704
|
||||
#: core/models.py:706
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Relation enregistrement/utilisateur"
|
||||
|
||||
#: core/models.py:705
|
||||
#: core/models.py:707
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Relations enregistrement/utilisateur"
|
||||
|
||||
#: core/models.py:711
|
||||
#: core/models.py:713
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "Cet utilisateur est déjà dans cet enregistrement."
|
||||
|
||||
#: core/models.py:717
|
||||
#: core/models.py:719
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "Cette équipe est déjà dans cet enregistrement."
|
||||
|
||||
#: core/models.py:723
|
||||
#: 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:740
|
||||
#: core/models.py:742
|
||||
msgid "Create rooms"
|
||||
msgstr "Créer des salles"
|
||||
|
||||
#: core/models.py:741
|
||||
#: core/models.py:743
|
||||
msgid "List rooms"
|
||||
msgstr "Lister les salles"
|
||||
|
||||
#: core/models.py:742
|
||||
#: core/models.py:744
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Afficher les détails d’une salle"
|
||||
|
||||
#: core/models.py:743
|
||||
#: core/models.py:745
|
||||
msgid "Update rooms"
|
||||
msgstr "Mettre à jour les salles"
|
||||
|
||||
#: core/models.py:744
|
||||
#: core/models.py:746
|
||||
msgid "Delete rooms"
|
||||
msgstr "Supprimer les salles"
|
||||
|
||||
#: core/models.py:757
|
||||
#: core/models.py:759
|
||||
msgid "Application name"
|
||||
msgstr "Nom de l’application"
|
||||
|
||||
#: core/models.py:758
|
||||
#: core/models.py:760
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Nom descriptif de cette application."
|
||||
|
||||
#: core/models.py:768
|
||||
#: core/models.py:770
|
||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||
msgstr ""
|
||||
"Haché lors de l’enregistrement. Copiez-le maintenant s’il s’agit d’un "
|
||||
"nouveau secret."
|
||||
|
||||
#: core/models.py:779
|
||||
#: core/models.py:781
|
||||
msgid "Application"
|
||||
msgstr "Application"
|
||||
|
||||
#: core/models.py:780
|
||||
#: core/models.py:782
|
||||
msgid "Applications"
|
||||
msgstr "Applications"
|
||||
|
||||
#: core/models.py:803
|
||||
#: core/models.py:805
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Saisissez un domaine valide"
|
||||
|
||||
#: core/models.py:806
|
||||
#: core/models.py:808
|
||||
msgid "Domain"
|
||||
msgstr "Domaine"
|
||||
|
||||
#: core/models.py:807
|
||||
#: 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:819
|
||||
#: core/models.py:821
|
||||
msgid "Application domain"
|
||||
msgstr "Domaine d’application"
|
||||
|
||||
#: core/models.py:820
|
||||
#: core/models.py:822
|
||||
msgid "Application domains"
|
||||
msgstr "Domaines d’application"
|
||||
|
||||
#: core/models.py:838
|
||||
#: core/models.py:840
|
||||
msgid "Pending"
|
||||
msgstr "En attente"
|
||||
|
||||
#: core/models.py:846
|
||||
#: core/models.py:848
|
||||
msgid "Ready"
|
||||
msgstr "Prêt"
|
||||
|
||||
#: core/models.py:852
|
||||
#: core/models.py:854
|
||||
msgid "Background image"
|
||||
msgstr "Image de fond"
|
||||
|
||||
#: core/models.py:864
|
||||
#: 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"
|
||||
msgstr ""
|
||||
"Information concernant la détection de Malware cand le statut n'est pas sain"
|
||||
|
||||
#: core/models.py:895
|
||||
msgid "File"
|
||||
@@ -445,15 +466,17 @@ msgstr "Fichier"
|
||||
msgid "Files"
|
||||
msgstr "Fichiers"
|
||||
|
||||
#: core/models.py:970
|
||||
#: 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:980
|
||||
#: 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)"
|
||||
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"
|
||||
@@ -583,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:224
|
||||
#: meet/settings.py:223
|
||||
msgid "English"
|
||||
msgstr "Anglais"
|
||||
|
||||
#: meet/settings.py:225
|
||||
#: meet/settings.py:224
|
||||
msgid "French"
|
||||
msgstr "Français"
|
||||
|
||||
#: meet/settings.py:226
|
||||
#: meet/settings.py:225
|
||||
msgid "Dutch"
|
||||
msgstr "Néerlandais"
|
||||
|
||||
#: meet/settings.py:227
|
||||
#: meet/settings.py:226
|
||||
msgid "German"
|
||||
msgstr "Allemand"
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-02-26 17:34+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,11 +29,11 @@ msgstr "Rechten"
|
||||
msgid "Important dates"
|
||||
msgstr "Belangrijke datums"
|
||||
|
||||
#: core/admin.py:132 core/admin.py:243
|
||||
#: core/admin.py:132 core/admin.py:275
|
||||
msgid "No owner"
|
||||
msgstr "Geen eigenaar"
|
||||
|
||||
#: core/admin.py:135 core/admin.py:246
|
||||
#: core/admin.py:135 core/admin.py:278
|
||||
msgid "Multiple owners"
|
||||
msgstr "Meerdere eigenaren"
|
||||
|
||||
@@ -61,116 +61,135 @@ msgstr "Meldingen succesvol verzonden voor %(count)s opname(n)."
|
||||
msgid "Skipped %(count)s expired recording(s)."
|
||||
msgstr "%(count)s verlopen opname(n) overgeslagen."
|
||||
|
||||
#: core/admin.py:309
|
||||
#: 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:311
|
||||
#: core/admin.py:344
|
||||
msgid "Scopes"
|
||||
msgstr "Scopes"
|
||||
|
||||
#: core/api/filters.py:24
|
||||
#: core/api/filters.py:25
|
||||
msgid "Creator is me"
|
||||
msgstr "Maker ben ik"
|
||||
|
||||
#: core/api/serializers.py:84
|
||||
#: 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/api/serializers.py:443
|
||||
#: core/api/serializers.py:516
|
||||
msgid "This file extension is not allowed."
|
||||
msgstr "Deze bestandsextensie is niet toegestaan."
|
||||
|
||||
#: core/models.py:35
|
||||
#: 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:36
|
||||
#: core/models.py:38
|
||||
msgid "Administrator"
|
||||
msgstr "Beheerder"
|
||||
|
||||
#: core/models.py:37
|
||||
#: core/models.py:39
|
||||
msgid "Owner"
|
||||
msgstr "Eigenaar"
|
||||
|
||||
#: core/models.py:53
|
||||
#: core/models.py:55
|
||||
msgid "Initiated"
|
||||
msgstr "Gestart"
|
||||
|
||||
#: core/models.py:54
|
||||
#: core/models.py:56
|
||||
msgid "Active"
|
||||
msgstr "Actief"
|
||||
|
||||
#: core/models.py:55
|
||||
#: core/models.py:57
|
||||
msgid "Stopped"
|
||||
msgstr "Gestopt"
|
||||
|
||||
#: core/models.py:56
|
||||
#: core/models.py:58
|
||||
msgid "Saved"
|
||||
msgstr "Opgeslagen"
|
||||
|
||||
#: core/models.py:57
|
||||
#: core/models.py:59
|
||||
msgid "Aborted"
|
||||
msgstr "Afgebroken"
|
||||
|
||||
#: core/models.py:58
|
||||
#: core/models.py:60
|
||||
msgid "Failed to Start"
|
||||
msgstr "Starten mislukt"
|
||||
|
||||
#: core/models.py:59
|
||||
#: core/models.py:61
|
||||
msgid "Failed to Stop"
|
||||
msgstr "Stoppen mislukt"
|
||||
|
||||
#: core/models.py:60
|
||||
#: core/models.py:62
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Notificatie geslaagd"
|
||||
|
||||
#: core/models.py:87
|
||||
#: core/models.py:89
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "SCHERM_OPNAME"
|
||||
|
||||
#: core/models.py:88
|
||||
#: core/models.py:90
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSCRIPT"
|
||||
|
||||
#: core/models.py:94
|
||||
#: core/models.py:96
|
||||
msgid "Public Access"
|
||||
msgstr "Openbare toegang"
|
||||
|
||||
#: core/models.py:95
|
||||
#: core/models.py:97
|
||||
msgid "Trusted Access"
|
||||
msgstr "Vertrouwde toegang"
|
||||
|
||||
#: core/models.py:96
|
||||
#: core/models.py:98
|
||||
msgid "Restricted Access"
|
||||
msgstr "Beperkte toegang"
|
||||
|
||||
#: core/models.py:108
|
||||
#: core/models.py:110
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: core/models.py:109
|
||||
#: core/models.py:111
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "primaire sleutel voor het record als UUID"
|
||||
|
||||
#: core/models.py:115
|
||||
#: core/models.py:117
|
||||
msgid "created on"
|
||||
msgstr "aangemaakt op"
|
||||
|
||||
#: core/models.py:116
|
||||
#: 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:121
|
||||
#: core/models.py:123
|
||||
msgid "updated on"
|
||||
msgstr "bijgewerkt op"
|
||||
|
||||
#: core/models.py:122
|
||||
#: 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:142
|
||||
#: core/models.py:144
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
@@ -178,11 +197,11 @@ msgstr ""
|
||||
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
|
||||
"_ tekens bevatten."
|
||||
|
||||
#: core/models.py:148
|
||||
#: core/models.py:150
|
||||
msgid "sub"
|
||||
msgstr "sub"
|
||||
|
||||
#: core/models.py:150
|
||||
#: core/models.py:152
|
||||
msgid ""
|
||||
"Optional for pending users; required upon account activation. 255 characters "
|
||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
@@ -190,55 +209,55 @@ msgstr ""
|
||||
"Optioneel voor gebruikers in afwachting; vereist bij accountactivering. "
|
||||
"Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan."
|
||||
|
||||
#: core/models.py:159
|
||||
#: core/models.py:161
|
||||
msgid "identity email address"
|
||||
msgstr "identiteit e-mailadres"
|
||||
|
||||
#: core/models.py:164
|
||||
#: core/models.py:166
|
||||
msgid "admin email address"
|
||||
msgstr "beheerder e-mailadres"
|
||||
|
||||
#: core/models.py:166
|
||||
#: core/models.py:168
|
||||
msgid "full name"
|
||||
msgstr "volledige naam"
|
||||
|
||||
#: core/models.py:168
|
||||
#: core/models.py:170
|
||||
msgid "short name"
|
||||
msgstr "korte naam"
|
||||
|
||||
#: core/models.py:174
|
||||
#: core/models.py:176
|
||||
msgid "language"
|
||||
msgstr "taal"
|
||||
|
||||
#: core/models.py:175
|
||||
#: 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:181
|
||||
#: 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:184
|
||||
#: core/models.py:186
|
||||
msgid "device"
|
||||
msgstr "apparaat"
|
||||
|
||||
#: core/models.py:186
|
||||
#: 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:189
|
||||
#: core/models.py:191
|
||||
msgid "staff status"
|
||||
msgstr "personeelsstatus"
|
||||
|
||||
#: core/models.py:191
|
||||
#: 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:194
|
||||
#: core/models.py:196
|
||||
msgid "active"
|
||||
msgstr "actief"
|
||||
|
||||
#: core/models.py:197
|
||||
#: core/models.py:199
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
@@ -246,64 +265,64 @@ msgstr ""
|
||||
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
|
||||
"plaats van accounts te verwijderen."
|
||||
|
||||
#: core/models.py:210
|
||||
#: core/models.py:212
|
||||
msgid "user"
|
||||
msgstr "gebruiker"
|
||||
|
||||
#: core/models.py:211
|
||||
#: core/models.py:213
|
||||
msgid "users"
|
||||
msgstr "gebruikers"
|
||||
|
||||
#: core/models.py:270
|
||||
#: core/models.py:272
|
||||
msgid "Resource"
|
||||
msgstr "Bron"
|
||||
|
||||
#: core/models.py:271
|
||||
#: core/models.py:273
|
||||
msgid "Resources"
|
||||
msgstr "Bronnen"
|
||||
|
||||
#: core/models.py:329
|
||||
#: core/models.py:331
|
||||
msgid "Resource access"
|
||||
msgstr "Brontoegang"
|
||||
|
||||
#: core/models.py:330
|
||||
#: core/models.py:332
|
||||
msgid "Resource accesses"
|
||||
msgstr "Brontoegangsrechten"
|
||||
|
||||
#: core/models.py:336
|
||||
#: 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:392
|
||||
#: core/models.py:394
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Visio-ruimteconfiguratie"
|
||||
|
||||
#: core/models.py:393
|
||||
#: 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:400
|
||||
#: core/models.py:402
|
||||
msgid "Room PIN code"
|
||||
msgstr "Pincode van de kamer"
|
||||
|
||||
#: core/models.py:401
|
||||
#: 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:407 core/models.py:561
|
||||
#: core/models.py:409 core/models.py:563
|
||||
msgid "Room"
|
||||
msgstr "Ruimte"
|
||||
|
||||
#: core/models.py:408
|
||||
#: core/models.py:410
|
||||
msgid "Rooms"
|
||||
msgstr "Ruimtes"
|
||||
|
||||
#: core/models.py:572
|
||||
#: core/models.py:574
|
||||
msgid "Worker ID"
|
||||
msgstr "Worker ID"
|
||||
|
||||
#: core/models.py:574
|
||||
#: 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."
|
||||
@@ -311,120 +330,120 @@ 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:582
|
||||
#: core/models.py:584
|
||||
msgid "Recording mode"
|
||||
msgstr "Opnamemodus"
|
||||
|
||||
#: core/models.py:583
|
||||
#: core/models.py:585
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Definieert de modus van opname die wordt aangeroepen."
|
||||
|
||||
#: core/models.py:588 core/models.py:589
|
||||
#: core/models.py:590 core/models.py:591
|
||||
msgid "Recording options"
|
||||
msgstr "Opnameopties"
|
||||
|
||||
#: core/models.py:595
|
||||
#: core/models.py:597
|
||||
msgid "Recording"
|
||||
msgstr "Opname"
|
||||
|
||||
#: core/models.py:596
|
||||
#: core/models.py:598
|
||||
msgid "Recordings"
|
||||
msgstr "Opnames"
|
||||
|
||||
#: core/models.py:704
|
||||
#: core/models.py:706
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Opname/gebruiker-relatie"
|
||||
|
||||
#: core/models.py:705
|
||||
#: core/models.py:707
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Opname/gebruiker-relaties"
|
||||
|
||||
#: core/models.py:711
|
||||
#: core/models.py:713
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "Deze gebruiker is al in deze opname."
|
||||
|
||||
#: core/models.py:717
|
||||
#: core/models.py:719
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "Dit team is al in deze opname."
|
||||
|
||||
#: core/models.py:723
|
||||
#: 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:740
|
||||
#: core/models.py:742
|
||||
msgid "Create rooms"
|
||||
msgstr "Ruimtes aanmaken"
|
||||
|
||||
#: core/models.py:741
|
||||
#: core/models.py:743
|
||||
msgid "List rooms"
|
||||
msgstr "Ruimtes weergeven"
|
||||
|
||||
#: core/models.py:742
|
||||
#: core/models.py:744
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Details van een ruimte ophalen"
|
||||
|
||||
#: core/models.py:743
|
||||
#: core/models.py:745
|
||||
msgid "Update rooms"
|
||||
msgstr "Ruimtes bijwerken"
|
||||
|
||||
#: core/models.py:744
|
||||
#: core/models.py:746
|
||||
msgid "Delete rooms"
|
||||
msgstr "Ruimtes verwijderen"
|
||||
|
||||
#: core/models.py:757
|
||||
#: core/models.py:759
|
||||
msgid "Application name"
|
||||
msgstr "Naam van de applicatie"
|
||||
|
||||
#: core/models.py:758
|
||||
#: core/models.py:760
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Beschrijvende naam voor deze applicatie."
|
||||
|
||||
#: core/models.py:768
|
||||
#: 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:779
|
||||
#: core/models.py:781
|
||||
msgid "Application"
|
||||
msgstr "Applicatie"
|
||||
|
||||
#: core/models.py:780
|
||||
#: core/models.py:782
|
||||
msgid "Applications"
|
||||
msgstr "Applicaties"
|
||||
|
||||
#: core/models.py:803
|
||||
#: core/models.py:805
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Voer een geldig domein in"
|
||||
|
||||
#: core/models.py:806
|
||||
#: core/models.py:808
|
||||
msgid "Domain"
|
||||
msgstr "Domein"
|
||||
|
||||
#: core/models.py:807
|
||||
#: 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:819
|
||||
#: core/models.py:821
|
||||
msgid "Application domain"
|
||||
msgstr "Applicatiedomein"
|
||||
|
||||
#: core/models.py:820
|
||||
#: core/models.py:822
|
||||
msgid "Application domains"
|
||||
msgstr "Applicatiedomeinen"
|
||||
|
||||
#: core/models.py:838
|
||||
#: core/models.py:840
|
||||
msgid "Pending"
|
||||
msgstr "In afwachting"
|
||||
|
||||
#: core/models.py:846
|
||||
#: core/models.py:848
|
||||
msgid "Ready"
|
||||
msgstr "Klaar"
|
||||
|
||||
#: core/models.py:852
|
||||
#: core/models.py:854
|
||||
msgid "Background image"
|
||||
msgstr "Achtergrondafbeelding"
|
||||
|
||||
#: core/models.py:864
|
||||
#: core/models.py:866
|
||||
msgid "title"
|
||||
msgstr "Titel"
|
||||
|
||||
@@ -440,11 +459,11 @@ msgstr "Bestand"
|
||||
msgid "Files"
|
||||
msgstr "Bestanden"
|
||||
|
||||
#: core/models.py:970
|
||||
#: core/models.py:1000
|
||||
msgid "This file is already hard deleted."
|
||||
msgstr "Dit bestand is al definitief verwijderd."
|
||||
|
||||
#: core/models.py:980
|
||||
#: 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."
|
||||
@@ -580,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:224
|
||||
#: meet/settings.py:223
|
||||
msgid "English"
|
||||
msgstr "Engels"
|
||||
|
||||
#: meet/settings.py:225
|
||||
#: meet/settings.py:224
|
||||
msgid "French"
|
||||
msgstr "Frans"
|
||||
|
||||
#: meet/settings.py:226
|
||||
#: meet/settings.py:225
|
||||
msgid "Dutch"
|
||||
msgstr "Nederlands"
|
||||
|
||||
#: meet/settings.py:227
|
||||
#: meet/settings.py:226
|
||||
msgid "German"
|
||||
msgstr "Duits"
|
||||
|
||||
@@ -176,6 +176,13 @@ class Base(Configuration):
|
||||
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
|
||||
)
|
||||
@@ -188,6 +195,7 @@ class Base(Configuration):
|
||||
{
|
||||
"background_image": {
|
||||
"max_size": 2 * MB,
|
||||
"max_count_by_user": 10,
|
||||
"allowed_extensions": [".jpeg", ".jpg", ".png"],
|
||||
"allowed_mimetypes": ["image/jpeg", "image/png"],
|
||||
},
|
||||
@@ -973,6 +981,7 @@ class Test(Base):
|
||||
APPLICATION_JWT_AUDIENCE = "Test inc."
|
||||
|
||||
CELERY_TASK_ALWAYS_EAGER = True
|
||||
FILE_UPLOAD_ENABLED = True
|
||||
|
||||
def __init__(self):
|
||||
# pylint: disable=invalid-name
|
||||
|
||||
+13
-13
@@ -2,12 +2,12 @@
|
||||
# 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"
|
||||
version = "1.10.0"
|
||||
version = "1.11.0"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -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",
|
||||
@@ -53,7 +52,7 @@ 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",
|
||||
@@ -70,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",
|
||||
@@ -90,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 = [
|
||||
|
||||
Generated
+2365
File diff suppressed because it is too large
Load Diff
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "1.10.0",
|
||||
"version": "1.11.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "1.10.0",
|
||||
"version": "1.11.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/material-symbols-outlined": "5.2.34",
|
||||
"@fontsource/material-icons-outlined": "5.2.6",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.10.0",
|
||||
"version": "1.11.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
|
||||
@@ -15,7 +15,19 @@ export const fetchApi = async <T = Record<string, unknown>>(
|
||||
...options?.headers,
|
||||
},
|
||||
})
|
||||
const result = await response.json()
|
||||
|
||||
let result: T
|
||||
if (response.status === 204) {
|
||||
result = undefined as T
|
||||
} else {
|
||||
const contentType = response.headers.get('content-type') ?? ''
|
||||
if (!contentType.includes('application/json')) {
|
||||
result = undefined as T
|
||||
} else {
|
||||
result = (await response.json()) as T
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ApiError(response.status, result)
|
||||
}
|
||||
|
||||
@@ -5,4 +5,5 @@ export const keys = {
|
||||
requestEntry: 'requestEntry',
|
||||
waitingParticipants: 'waitingParticipants',
|
||||
roomCreationCallback: 'roomCreationCallback',
|
||||
files: 'files',
|
||||
}
|
||||
|
||||
@@ -30,6 +30,13 @@ export interface ApiConfig {
|
||||
expiration_days?: number
|
||||
max_duration?: number
|
||||
}
|
||||
background_image: {
|
||||
upload_is_enabled: boolean
|
||||
max_size: number
|
||||
max_count_by_user: number
|
||||
allowed_extensions: string[]
|
||||
allowed_mimetypes: string[]
|
||||
}
|
||||
subtitle: {
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { ApiFileItem } from '@/features/files/api/types.ts'
|
||||
import { keys } from '@/api/queryKeys.ts'
|
||||
|
||||
/**
|
||||
* Upload a file, using XHR so we can report on progress through a handler.
|
||||
*
|
||||
* @param url The URL to PUT the file to.
|
||||
* @param file The file to upload.
|
||||
* @param progressHandler A handler that receives progress updates as a single integer `0 <= x <= 100`.
|
||||
*/
|
||||
export const uploadFile = (
|
||||
url: string,
|
||||
file: File,
|
||||
progressHandler: (progress: number) => void
|
||||
) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhr.open('PUT', url)
|
||||
xhr.setRequestHeader('X-amz-acl', 'private')
|
||||
xhr.setRequestHeader('Content-Type', file.type)
|
||||
|
||||
xhr.addEventListener('error', reject)
|
||||
xhr.addEventListener('abort', reject)
|
||||
|
||||
xhr.addEventListener('readystatechange', () => {
|
||||
if (xhr.readyState === 4) {
|
||||
if (xhr.status === 200) {
|
||||
// Make sure to always set the progress to 100% when the upload is done.
|
||||
// Because 'progress' event listener is not called when the file size is 0.
|
||||
progressHandler(100)
|
||||
return resolve(true)
|
||||
}
|
||||
reject(new Error(`Failed to perform the upload on ${url}.`))
|
||||
}
|
||||
})
|
||||
|
||||
xhr.upload.addEventListener('progress', (progressEvent) => {
|
||||
if (progressEvent.lengthComputable) {
|
||||
progressHandler(
|
||||
Math.floor((progressEvent.loaded / progressEvent.total) * 100)
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
xhr.send(file)
|
||||
})
|
||||
|
||||
/**
|
||||
* Asynchronously creates a new file and uploads it to the server.
|
||||
*
|
||||
* @param {object} params - The parameters for the file creation and upload process.
|
||||
* @param {File} params.file - The file object to be uploaded.
|
||||
* @param {function} params.onProgress - A callback function that receives the upload progress as a number (0 to 100).
|
||||
* @returns {Promise<ApiFileItem>} A promise that resolves when the file has been successfully uploaded and the server process is completed.
|
||||
*/
|
||||
export const createFile = async ({
|
||||
file,
|
||||
onProgress,
|
||||
}: {
|
||||
file: File
|
||||
onProgress: (progress: number) => void
|
||||
}): Promise<ApiFileItem> => {
|
||||
const res = await fetchApi<ApiFileItem>(`/files/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ filename: file.name, type: 'background_image' }),
|
||||
})
|
||||
if (res.upload_state !== 'pending') {
|
||||
throw new Error('State should be pending right after creation')
|
||||
}
|
||||
const policy = res.policy
|
||||
await uploadFile(policy, file, onProgress)
|
||||
return await fetchApi<ApiFileItem>(`/files/${res.id}/upload-ended/`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export const useCreateFile = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: createFile,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [keys.files],
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { keys } from '@/api/queryKeys.ts'
|
||||
|
||||
/**
|
||||
* Deletes a file specified by its unique identifier.
|
||||
*
|
||||
* @param {Object} params - The parameters required for deleting the file.
|
||||
* @param {string} params.fileId - The unique identifier of the file to be deleted.
|
||||
* @returns {Promise<void>} A promise that resolves when the file is successfully deleted.
|
||||
*/
|
||||
export const deleteFile = async ({
|
||||
fileId,
|
||||
}: {
|
||||
fileId: string
|
||||
}): Promise<void> => {
|
||||
await fetchApi<void>(`/files/${fileId}/`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export const useDeleteFile = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: deleteFile,
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: [keys.files],
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { keepPreviousData, useQuery } from '@tanstack/react-query'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import {
|
||||
ApiFileItem,
|
||||
ApiFileType,
|
||||
ApiFileUploadState,
|
||||
} from '@/features/files/api/types.ts'
|
||||
import { useUser } from '@/features/auth'
|
||||
import { useConfig } from '@/api/useConfig.ts'
|
||||
|
||||
type ListFilesResponse = {
|
||||
count: number
|
||||
next: string | null
|
||||
previous: string | null
|
||||
results: ApiFileItem[]
|
||||
}
|
||||
|
||||
type ListFilesFilters = {
|
||||
is_creator_me?: boolean
|
||||
type?: ApiFileType
|
||||
upload_state?: ApiFileUploadState
|
||||
is_deleted?: boolean
|
||||
}
|
||||
|
||||
export type ListFilesParams = {
|
||||
filters?: ListFilesFilters
|
||||
pagination: {
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
}
|
||||
|
||||
export const listMyFiles = async ({
|
||||
filters = {},
|
||||
pagination: { page, pageSize },
|
||||
}: ListFilesParams): Promise<ListFilesResponse> => {
|
||||
const query = new URLSearchParams()
|
||||
query.append('page', page.toString())
|
||||
query.append('page_size', pageSize.toString())
|
||||
if (filters?.is_creator_me ?? true) {
|
||||
query.append('is_creator_me', 'true')
|
||||
}
|
||||
if (filters?.type) {
|
||||
query.append('type', filters.type)
|
||||
}
|
||||
if (filters?.upload_state) {
|
||||
query.append('upload_state', filters.upload_state)
|
||||
}
|
||||
if (typeof filters?.is_deleted === 'boolean') {
|
||||
query.append('is_deleted', filters.is_deleted ? 'true' : 'false')
|
||||
}
|
||||
|
||||
return fetchApi<ListFilesResponse>(`/files?${query.toString()}`, {
|
||||
method: 'GET',
|
||||
})
|
||||
}
|
||||
|
||||
export const useListMyFiles = (params: Parameters<typeof listMyFiles>[0]) => {
|
||||
const { isLoggedIn } = useUser()
|
||||
const { data: appConfig } = useConfig()
|
||||
return useQuery({
|
||||
queryKey: [keys.files, params],
|
||||
queryFn: () => listMyFiles(params),
|
||||
refetchOnMount: 'always',
|
||||
placeholderData: keepPreviousData,
|
||||
enabled:
|
||||
isLoggedIn && appConfig?.background_image?.upload_is_enabled === true,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export type ApiFileCreator = {
|
||||
id: string // UUID
|
||||
full_name: string | null
|
||||
short_name: string | null
|
||||
}
|
||||
|
||||
export type ApiFileType = 'background_image'
|
||||
export type ApiFileUploadState = 'pending' | 'ready'
|
||||
|
||||
export type ApiFileItem = {
|
||||
id: string // UUID
|
||||
created_at: string // ISO datetime string
|
||||
updated_at: string // ISO datetime string
|
||||
title: string
|
||||
type: ApiFileType
|
||||
creator: ApiFileCreator
|
||||
deleted_at: string | null
|
||||
hard_deleted_at: string | null
|
||||
filename: string
|
||||
upload_state: ApiFileUploadState
|
||||
mimetype: string // e.g. "image/png"
|
||||
size: number // file size in bytes
|
||||
description: string | null
|
||||
} & (
|
||||
| {
|
||||
upload_state: 'ready'
|
||||
url: string
|
||||
}
|
||||
| {
|
||||
upload_state: 'pending'
|
||||
policy: string
|
||||
url: null
|
||||
}
|
||||
)
|
||||
@@ -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')} />
|
||||
}
|
||||
|
||||
@@ -215,8 +215,8 @@ export const Conference = ({
|
||||
audio={userConfig.audioEnabled}
|
||||
video={
|
||||
userConfig.videoEnabled && {
|
||||
processor: BackgroundProcessorFactory.deserializeProcessor(
|
||||
userConfig.processorSerialized
|
||||
processor: BackgroundProcessorFactory.fromProcessorConfig(
|
||||
userConfig.processorConfig
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,15 +4,15 @@ import { css } from '@/styled-system/css'
|
||||
import { Screen } from '@/layout/Screen'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
createLocalVideoTrack,
|
||||
createLocalAudioTrack,
|
||||
createLocalVideoTrack,
|
||||
LocalAudioTrack,
|
||||
LocalVideoTrack,
|
||||
Track,
|
||||
} from 'livekit-client'
|
||||
import { H } from '@/primitives/H'
|
||||
import { Field } from '@/primitives/Field'
|
||||
import { Button, Dialog, Text, Form } from '@/primitives'
|
||||
import { Button, Dialog, Form, Text } from '@/primitives'
|
||||
import { VStack } from '@/styled-system/jsx'
|
||||
import { Heading } from 'react-aria-components'
|
||||
import { RiImageCircleAiFill } from '@remixicon/react'
|
||||
@@ -44,8 +44,7 @@ const onError = (e: Error) => console.error('ERROR', e)
|
||||
|
||||
const Effects = ({
|
||||
videoTrack,
|
||||
onSubmit,
|
||||
}: Pick<EffectsConfigurationProps, 'videoTrack' | 'onSubmit'>) => {
|
||||
}: Pick<EffectsConfigurationProps, 'videoTrack'>) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'join.effects' })
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false)
|
||||
const openDialog = () => setIsDialogOpen(true)
|
||||
@@ -81,7 +80,7 @@ const Effects = ({
|
||||
>
|
||||
{t('subTitle')}
|
||||
</Text>
|
||||
<EffectsConfiguration videoTrack={videoTrack} onSubmit={onSubmit} />
|
||||
<EffectsConfiguration videoTrack={videoTrack} />
|
||||
</Dialog>
|
||||
<Button
|
||||
variant="whiteCircle"
|
||||
@@ -111,7 +110,7 @@ export const Join = ({
|
||||
audioDeviceId,
|
||||
audioOutputDeviceId,
|
||||
videoDeviceId,
|
||||
processorSerialized,
|
||||
processorConfig,
|
||||
username,
|
||||
},
|
||||
saveAudioInputEnabled,
|
||||
@@ -120,7 +119,6 @@ export const Join = ({
|
||||
saveAudioInputDeviceId,
|
||||
saveVideoInputDeviceId,
|
||||
saveUsername,
|
||||
saveProcessorSerialized,
|
||||
} = usePersistentUserChoices()
|
||||
|
||||
const initialUserChoices = useRef<LocalUserChoices | null>(null)
|
||||
@@ -132,7 +130,7 @@ export const Join = ({
|
||||
audioDeviceId,
|
||||
audioOutputDeviceId,
|
||||
videoDeviceId,
|
||||
processorSerialized,
|
||||
processorConfig,
|
||||
username,
|
||||
}
|
||||
}
|
||||
@@ -146,8 +144,8 @@ export const Join = ({
|
||||
video: !!initialUserChoices.current &&
|
||||
initialUserChoices.current?.videoEnabled && {
|
||||
deviceId: initialUserChoices.current.videoDeviceId,
|
||||
processor: BackgroundProcessorFactory.deserializeProcessor(
|
||||
initialUserChoices.current.processorSerialized
|
||||
processor: BackgroundProcessorFactory.fromProcessorConfig(
|
||||
initialUserChoices.current.processorConfig
|
||||
),
|
||||
},
|
||||
},
|
||||
@@ -186,9 +184,7 @@ export const Join = ({
|
||||
const track = await createLocalVideoTrack({
|
||||
deviceId: { exact: videoDeviceId },
|
||||
processor:
|
||||
BackgroundProcessorFactory.deserializeProcessor(
|
||||
processorSerialized
|
||||
),
|
||||
BackgroundProcessorFactory.fromProcessorConfig(processorConfig),
|
||||
})
|
||||
setDynamicVideoTrack(track)
|
||||
} catch (error) {
|
||||
@@ -207,7 +203,7 @@ export const Join = ({
|
||||
}, [
|
||||
videoEnabled,
|
||||
videoDeviceId,
|
||||
processorSerialized,
|
||||
processorConfig,
|
||||
previewVideoTrack,
|
||||
dynamicVideoTrack,
|
||||
])
|
||||
@@ -690,12 +686,7 @@ export const Join = ({
|
||||
zIndex: '1',
|
||||
})}
|
||||
>
|
||||
<Effects
|
||||
videoTrack={videoTrack}
|
||||
onSubmit={(processor) =>
|
||||
saveProcessorSerialized(processor?.serialize())
|
||||
}
|
||||
/>
|
||||
<Effects videoTrack={videoTrack} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -149,11 +149,12 @@ export const SidePanel = () => {
|
||||
activeSubPanelId,
|
||||
} = useSidePanel()
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'sidePanel' })
|
||||
const title = t(`heading.${activeSubPanelId || activePanelId}`)
|
||||
|
||||
return (
|
||||
<StyledSidePanel
|
||||
title={t(`heading.${activeSubPanelId || activePanelId}`)}
|
||||
ariaLabel={t('ariaLabel')}
|
||||
title={title}
|
||||
ariaLabel={t('ariaLabel', { title })}
|
||||
onClose={() => {
|
||||
layoutStore.activePanelId = null
|
||||
layoutStore.activeSubPanelId = null
|
||||
|
||||
@@ -65,7 +65,7 @@ const ToolButton = ({
|
||||
<div>
|
||||
<Text
|
||||
margin={false}
|
||||
as="h3"
|
||||
as="h2"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: 0.25,
|
||||
|
||||
+16
-27
@@ -11,11 +11,7 @@ import {
|
||||
TIMEOUT_TICK,
|
||||
timerWorkerScript,
|
||||
} from './TimerWorker'
|
||||
import {
|
||||
BackgroundProcessorInterface,
|
||||
BackgroundOptions,
|
||||
ProcessorType,
|
||||
} from '.'
|
||||
import { BackgroundProcessorInterface, ProcessorConfig, ProcessorType } from '.'
|
||||
|
||||
const PROCESSING_WIDTH = 256
|
||||
const PROCESSING_HEIGHT = 144
|
||||
@@ -33,7 +29,7 @@ const DEFAULT_BLUR = '10'
|
||||
* MediaStreamTrackProcessor.
|
||||
*/
|
||||
export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
options: BackgroundOptions
|
||||
options: ProcessorConfig
|
||||
name: string
|
||||
processedTrack?: MediaStreamTrack | undefined
|
||||
|
||||
@@ -64,15 +60,10 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
type: ProcessorType
|
||||
virtualBackgroundImage?: HTMLImageElement
|
||||
|
||||
constructor(opts: BackgroundOptions) {
|
||||
constructor(opts: ProcessorConfig) {
|
||||
this.name = 'blur'
|
||||
this.options = opts
|
||||
|
||||
if (this.options.blurRadius) {
|
||||
this.type = ProcessorType.BLUR
|
||||
} else {
|
||||
this.type = ProcessorType.VIRTUAL
|
||||
}
|
||||
this.type = opts.type
|
||||
}
|
||||
|
||||
static get isSupported() {
|
||||
@@ -107,6 +98,12 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
}
|
||||
|
||||
_initVirtualBackgroundImage() {
|
||||
if (this.options.type !== 'virtual') {
|
||||
throw new Error(
|
||||
'Virtual background is only supported for virtual background'
|
||||
)
|
||||
}
|
||||
|
||||
const needsUpdate =
|
||||
this.options.imagePath &&
|
||||
this.virtualBackgroundImage &&
|
||||
@@ -118,7 +115,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
}
|
||||
}
|
||||
|
||||
async update(opts: BackgroundOptions): Promise<void> {
|
||||
async update(opts: ProcessorConfig): Promise<void> {
|
||||
this.options = opts
|
||||
this._initVirtualBackgroundImage()
|
||||
}
|
||||
@@ -187,7 +184,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
0,
|
||||
0,
|
||||
PROCESSING_WIDTH,
|
||||
PROCESSING_WIDTH
|
||||
PROCESSING_HEIGHT
|
||||
)
|
||||
}
|
||||
|
||||
@@ -212,6 +209,9 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
* TODO: future improvement with WebGL.
|
||||
*/
|
||||
async blur() {
|
||||
if (this.options.type !== 'blur') {
|
||||
throw new Error('Blurring is only supported for blur background')
|
||||
}
|
||||
const mask = this.imageSegmenterResult!.categoryMask!.getAsUint8Array()
|
||||
for (let i = 0; i < mask.length; ++i) {
|
||||
this.segmentationMask!.data[i * 4 + 3] = 255 - mask[i]
|
||||
@@ -293,7 +293,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
await this.sizeSource()
|
||||
await this.segment()
|
||||
|
||||
if (this.options.blurRadius) {
|
||||
if (this.options.type === 'blur') {
|
||||
await this.blur()
|
||||
} else {
|
||||
await this.drawVirtualBackground()
|
||||
@@ -354,15 +354,4 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
this.timerWorker?.terminate()
|
||||
this.imageSegmenter?.close()
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new BackgroundCustomProcessor(this.options)
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return {
|
||||
type: this.type,
|
||||
options: this.options,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -332,15 +332,4 @@ export class FaceLandmarksProcessor implements TrackProcessor<Track.Kind> {
|
||||
this.timerWorker?.terminate()
|
||||
this.faceLandmarker?.close()
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new FaceLandmarksProcessor(this.options)
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return {
|
||||
type: this.type,
|
||||
options: this.options,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
-32
@@ -1,28 +1,23 @@
|
||||
import { ProcessorOptions, Track } from 'livekit-client'
|
||||
import {
|
||||
BackgroundBlur,
|
||||
BackgroundTransformer,
|
||||
ProcessorWrapper,
|
||||
VirtualBackground,
|
||||
} from '@livekit/track-processors'
|
||||
import {
|
||||
BackgroundOptions,
|
||||
BackgroundProcessorInterface,
|
||||
ProcessorType,
|
||||
} from '.'
|
||||
import { ProcessorConfig, BackgroundProcessorInterface, ProcessorType } from '.'
|
||||
|
||||
export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInterface {
|
||||
processor: ProcessorWrapper<BackgroundOptions>
|
||||
opts: BackgroundOptions
|
||||
processor: ProcessorWrapper<{ imagePath?: string; blurRadius?: number }>
|
||||
opts: ProcessorConfig
|
||||
processorType: ProcessorType
|
||||
|
||||
constructor(opts: BackgroundOptions) {
|
||||
constructor(opts: ProcessorConfig) {
|
||||
this.opts = opts
|
||||
|
||||
if (opts.imagePath) {
|
||||
if (opts.type === 'virtual') {
|
||||
this.processorType = ProcessorType.VIRTUAL
|
||||
this.processor = VirtualBackground(opts.imagePath)
|
||||
} else if (opts.blurRadius !== undefined) {
|
||||
} else if (opts.type === 'blur') {
|
||||
this.processorType = ProcessorType.BLUR
|
||||
this.processor = BackgroundBlur(opts.blurRadius)
|
||||
} else {
|
||||
@@ -44,24 +39,23 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
|
||||
return this.processor.destroy()
|
||||
}
|
||||
|
||||
async update(opts: BackgroundOptions): Promise<void> {
|
||||
const newProcessorType = opts.imagePath
|
||||
? ProcessorType.VIRTUAL
|
||||
: ProcessorType.BLUR
|
||||
async update(opts: ProcessorConfig): Promise<void> {
|
||||
this.opts = opts
|
||||
|
||||
const newProcessorType =
|
||||
opts.type === 'virtual' ? ProcessorType.VIRTUAL : ProcessorType.BLUR
|
||||
|
||||
let processedOpts = opts
|
||||
if (newProcessorType !== this.processorType) {
|
||||
this.processorType = newProcessorType
|
||||
if (newProcessorType === ProcessorType.VIRTUAL) {
|
||||
this.processor.name = 'virtual-background'
|
||||
processedOpts = { ...opts, blurRadius: undefined }
|
||||
} else {
|
||||
this.processor.name = 'background-blur'
|
||||
processedOpts = { ...opts, imagePath: undefined }
|
||||
}
|
||||
}
|
||||
await this.processor.updateTransformerOptions(processedOpts)
|
||||
this.opts = processedOpts
|
||||
await this.processor.updateTransformerOptions(
|
||||
opts as { imagePath?: string; blurRadius?: number }
|
||||
)
|
||||
}
|
||||
|
||||
get name() {
|
||||
@@ -73,17 +67,6 @@ export class UnifiedBackgroundTrackProcessor implements BackgroundProcessorInter
|
||||
}
|
||||
|
||||
get options() {
|
||||
return (this.processor.transformer as BackgroundTransformer).options
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new UnifiedBackgroundTrackProcessor(this.options || this.opts)
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return {
|
||||
type: this.processorType,
|
||||
options: this.options,
|
||||
}
|
||||
return this.opts
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,23 +2,7 @@ import { ProcessorWrapper } from '@livekit/track-processors'
|
||||
import { Track, TrackProcessor } from 'livekit-client'
|
||||
import { BackgroundCustomProcessor } from './BackgroundCustomProcessor'
|
||||
import { UnifiedBackgroundTrackProcessor } from './UnifiedBackgroundTrackProcessor'
|
||||
|
||||
export type BackgroundOptions = {
|
||||
blurRadius?: number
|
||||
imagePath?: string
|
||||
}
|
||||
|
||||
export interface ProcessorSerialized {
|
||||
type: ProcessorType
|
||||
options: BackgroundOptions
|
||||
}
|
||||
|
||||
export interface BackgroundProcessorInterface extends TrackProcessor<Track.Kind> {
|
||||
update(opts: BackgroundOptions): Promise<void>
|
||||
options: BackgroundOptions
|
||||
clone(): BackgroundProcessorInterface
|
||||
serialize(): ProcessorSerialized
|
||||
}
|
||||
import { FaceLandmarksOptions } from './FaceLandmarksProcessor'
|
||||
|
||||
export enum ProcessorType {
|
||||
BLUR = 'blur',
|
||||
@@ -26,6 +10,16 @@ export enum ProcessorType {
|
||||
FACE_LANDMARKS = 'faceLandmarks',
|
||||
}
|
||||
|
||||
export type ProcessorConfig =
|
||||
| { type: ProcessorType.BLUR; blurRadius: number }
|
||||
| { type: ProcessorType.VIRTUAL; imagePath: string; fileId?: string }
|
||||
| ({ type: ProcessorType.FACE_LANDMARKS } & FaceLandmarksOptions)
|
||||
|
||||
export interface BackgroundProcessorInterface extends TrackProcessor<Track.Kind> {
|
||||
update(opts: ProcessorConfig): Promise<void>
|
||||
options: ProcessorConfig
|
||||
}
|
||||
|
||||
export class BackgroundProcessorFactory {
|
||||
static hasModernApiSupport() {
|
||||
return ProcessorWrapper.hasModernApiSupport
|
||||
@@ -36,28 +30,27 @@ export class BackgroundProcessorFactory {
|
||||
}
|
||||
|
||||
static getProcessor(
|
||||
type: ProcessorType,
|
||||
opts: BackgroundOptions
|
||||
config: ProcessorConfig
|
||||
): BackgroundProcessorInterface | undefined {
|
||||
const isBlur = type === ProcessorType.BLUR
|
||||
const isVirtual = type === ProcessorType.VIRTUAL
|
||||
const isBlur = config.type === ProcessorType.BLUR
|
||||
const isVirtual = config.type === ProcessorType.VIRTUAL
|
||||
|
||||
if (!isBlur && !isVirtual) return undefined
|
||||
|
||||
if (ProcessorWrapper.isSupported) {
|
||||
return new UnifiedBackgroundTrackProcessor(opts)
|
||||
return new UnifiedBackgroundTrackProcessor(config)
|
||||
}
|
||||
|
||||
if (BackgroundCustomProcessor.isSupported) {
|
||||
return new BackgroundCustomProcessor(opts)
|
||||
return new BackgroundCustomProcessor(config)
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
static deserializeProcessor(data?: ProcessorSerialized) {
|
||||
if (data?.type) {
|
||||
return BackgroundProcessorFactory.getProcessor(data?.type, data?.options)
|
||||
static fromProcessorConfig(data?: ProcessorConfig) {
|
||||
if (data) {
|
||||
return BackgroundProcessorFactory.getProcessor(data)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
+2
-2
@@ -82,8 +82,8 @@ export const VideoDeviceControl = ({
|
||||
*
|
||||
* See https://github.com/numerique-gouv/meet/pull/309#issuecomment-2622404121
|
||||
*/
|
||||
const processor = BackgroundProcessorFactory.deserializeProcessor(
|
||||
userChoices.processorSerialized
|
||||
const processor = BackgroundProcessorFactory.fromProcessorConfig(
|
||||
userChoices.processorConfig
|
||||
)
|
||||
|
||||
const toggle = trackProps.toggle as (
|
||||
|
||||
@@ -2,14 +2,12 @@ import { useLocalParticipant } from '@livekit/components-react'
|
||||
import { LocalVideoTrack } from 'livekit-client'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { EffectsConfiguration } from './EffectsConfiguration'
|
||||
import { usePersistentUserChoices } from '../../hooks/usePersistentUserChoices'
|
||||
import { useCanPublishTrack } from '@/features/rooms/livekit/hooks/useCanPublishTrack'
|
||||
import { TrackSource } from '@livekit/protocol'
|
||||
|
||||
export const Effects = () => {
|
||||
const { cameraTrack } = useLocalParticipant()
|
||||
const localCameraTrack = cameraTrack?.track as LocalVideoTrack
|
||||
const { saveProcessorSerialized } = usePersistentUserChoices()
|
||||
|
||||
const canPublishCamera = useCanPublishTrack(TrackSource.CAMERA)
|
||||
|
||||
@@ -24,9 +22,6 @@ export const Effects = () => {
|
||||
isDisabled={!canPublishCamera}
|
||||
videoTrack={localCameraTrack}
|
||||
layout="vertical"
|
||||
onSubmit={(processor) =>
|
||||
saveProcessorSerialized(processor?.serialize())
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
+653
-251
@@ -1,16 +1,16 @@
|
||||
import { LocalVideoTrack, Track } from 'livekit-client'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
BackgroundOptions,
|
||||
BackgroundProcessorFactory,
|
||||
BackgroundProcessorInterface,
|
||||
ProcessorConfig,
|
||||
ProcessorType,
|
||||
} from '../blur'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { H, P, Text, ToggleButton } from '@/primitives'
|
||||
import { Button, Dialog, H, P, Text, ToggleButton } from '@/primitives'
|
||||
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { HStack, styled } from '@/styled-system/jsx'
|
||||
import { BlurOn } from '@/components/icons/BlurOn'
|
||||
import { BlurOnStrong } from '@/components/icons/BlurOnStrong'
|
||||
import { useTrackToggle } from '@livekit/components-react'
|
||||
@@ -19,6 +19,19 @@ import { useSyncAfterDelay } from '@/hooks/useSyncAfterDelay'
|
||||
import { FunnyEffects } from './FunnyEffects'
|
||||
import { useHasFunnyEffectsAccess } from '../../hooks/useHasFunnyEffectsAccess'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
import {
|
||||
ListFilesParams,
|
||||
useListMyFiles,
|
||||
} from '@/features/files/api/listFiles.ts'
|
||||
import { useCreateFile } from '@/features/files/api/createFile.ts'
|
||||
import { FileTrigger } from 'react-aria-components'
|
||||
import { RiDeleteBinLine, RiImageAddFill } from '@remixicon/react'
|
||||
import { useDeleteFile } from '@/features/files/api/deleteFile.ts'
|
||||
import { useUser } from '@/features/auth'
|
||||
import { ApiFileItem } from '@/features/files/api/types.ts'
|
||||
import { useConfig } from '@/api/useConfig.ts'
|
||||
import { usePersistentUserChoices } from '@/features/rooms/livekit/hooks/usePersistentUserChoices.ts'
|
||||
import { proxy, useSnapshot } from 'valtio'
|
||||
|
||||
enum BlurRadius {
|
||||
NONE = 0,
|
||||
@@ -40,14 +53,51 @@ const Information = styled('div', {
|
||||
export type EffectsConfigurationProps = {
|
||||
isDisabled?: boolean
|
||||
videoTrack: LocalVideoTrack
|
||||
onSubmit?: (processor?: BackgroundProcessorInterface) => void
|
||||
layout?: 'vertical' | 'horizontal'
|
||||
}
|
||||
|
||||
const listFilesQueryParams: ListFilesParams = {
|
||||
filters: {
|
||||
type: 'background_image',
|
||||
upload_state: 'ready',
|
||||
is_creator_me: true,
|
||||
is_deleted: false,
|
||||
},
|
||||
pagination: {
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
},
|
||||
}
|
||||
|
||||
function deriveIdFromProcessorConfig(config: ProcessorConfig) {
|
||||
if (config.type === ProcessorType.BLUR) {
|
||||
return `blur-${config.blurRadius}`
|
||||
} else if (config.type === ProcessorType.VIRTUAL) {
|
||||
// the imagePath is not stable for custom backgrounds
|
||||
// so we try first with the fileId
|
||||
if (config.fileId) {
|
||||
return `virtual-${config.fileId}`
|
||||
}
|
||||
return `virtual-${config.imagePath}`
|
||||
} else if (config.type === ProcessorType.FACE_LANDMARKS) {
|
||||
return 'face-landmarks'
|
||||
}
|
||||
throw new Error(`Unknown config type in config: ${config}`)
|
||||
}
|
||||
|
||||
// We use a valtio store so that the state is persisted between the join room
|
||||
// and the actual room
|
||||
const uploadNotPossibleLocalState = proxy({
|
||||
imageBackgroundConfig: null as null | {
|
||||
type: ProcessorType.VIRTUAL
|
||||
imagePath: string
|
||||
label: string
|
||||
},
|
||||
})
|
||||
|
||||
export const EffectsConfiguration = ({
|
||||
isDisabled,
|
||||
videoTrack,
|
||||
onSubmit,
|
||||
layout = 'horizontal',
|
||||
}: EffectsConfigurationProps) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
@@ -63,6 +113,365 @@ export const EffectsConfiguration = ({
|
||||
> | null>(null)
|
||||
const effectAnnouncementId = useRef(0)
|
||||
|
||||
const {
|
||||
saveProcessorConfig,
|
||||
userChoices: { processorConfig },
|
||||
} = usePersistentUserChoices()
|
||||
|
||||
const selectedId = useMemo(
|
||||
() =>
|
||||
processorConfig ? deriveIdFromProcessorConfig(processorConfig) : 'none',
|
||||
[processorConfig]
|
||||
)
|
||||
|
||||
const uploadNotPossibleSnap = useSnapshot(uploadNotPossibleLocalState)
|
||||
|
||||
const announceEffectStatusMessage = useCallback(
|
||||
(message: string) => {
|
||||
effectAnnouncementId.current += 1
|
||||
const currentId = effectAnnouncementId.current
|
||||
|
||||
if (effectAnnouncementTimeout.current) {
|
||||
clearTimeout(effectAnnouncementTimeout.current)
|
||||
}
|
||||
|
||||
effectAnnouncementTimeout.current = setTimeout(() => {
|
||||
if (currentId !== effectAnnouncementId.current) return
|
||||
announce(message)
|
||||
}, 80)
|
||||
},
|
||||
[announce]
|
||||
)
|
||||
|
||||
const getVirtualBackgroundName = useCallback(
|
||||
(imagePath?: string) => {
|
||||
if (!imagePath) return ''
|
||||
const match = imagePath.match(/\/backgrounds\/(\d+)\.jpg$/)
|
||||
if (!match) return ''
|
||||
const index = Number(match[1]) - 1
|
||||
if (Number.isNaN(index)) return ''
|
||||
return t(`virtual.presets.descriptions.${index}`)
|
||||
},
|
||||
[t]
|
||||
)
|
||||
|
||||
const updateEffectStatusMessage = useCallback(
|
||||
(config: ProcessorConfig, wasSelectedBeforeToggle: boolean) => {
|
||||
if (wasSelectedBeforeToggle) {
|
||||
announceEffectStatusMessage(t('blur.status.none'))
|
||||
return
|
||||
}
|
||||
|
||||
if (config.type === ProcessorType.BLUR) {
|
||||
const message =
|
||||
config.blurRadius === BlurRadius.LIGHT
|
||||
? t('blur.status.light')
|
||||
: t('blur.status.strong')
|
||||
announceEffectStatusMessage(message)
|
||||
return
|
||||
}
|
||||
|
||||
if (config.type === ProcessorType.VIRTUAL) {
|
||||
const backgroundName = getVirtualBackgroundName(config.imagePath)
|
||||
if (backgroundName) {
|
||||
announceEffectStatusMessage(
|
||||
`${t('virtual.selectedLabel')} ${backgroundName}`
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
},
|
||||
[announceEffectStatusMessage, getVirtualBackgroundName, t]
|
||||
)
|
||||
|
||||
const toggleEffect = useCallback(
|
||||
async (config: ProcessorConfig) => {
|
||||
setProcessorPending(true)
|
||||
const wasSelectedBeforeToggle =
|
||||
selectedId === deriveIdFromProcessorConfig(config)
|
||||
|
||||
if (!videoTrack) {
|
||||
/**
|
||||
* Special case: if no video track is available, then we must pass directly the processor into the
|
||||
* toggle call. Otherwise, the rest of the function below would not have a videoTrack to call
|
||||
* setProcessor on.
|
||||
*
|
||||
* We arrive in this condition when we enter the room with the camera already off.
|
||||
*/
|
||||
const newProcessorTmp = BackgroundProcessorFactory.getProcessor(config)!
|
||||
await toggle(true, {
|
||||
processor: newProcessorTmp,
|
||||
})
|
||||
setTimeout(() => setProcessorPending(false))
|
||||
return
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
await toggle(true)
|
||||
}
|
||||
|
||||
const processor =
|
||||
videoTrack?.getProcessor() as BackgroundProcessorInterface
|
||||
try {
|
||||
if (wasSelectedBeforeToggle) {
|
||||
// Stop processor.
|
||||
await videoTrack.stopProcessor()
|
||||
saveProcessorConfig(undefined)
|
||||
} else if (
|
||||
!processor ||
|
||||
(processor.options.type !== config.type &&
|
||||
!BackgroundProcessorFactory.hasModernApiSupport())
|
||||
) {
|
||||
// Change processor.
|
||||
const newProcessor = BackgroundProcessorFactory.getProcessor(config)!
|
||||
// IMPORTANT: Must explicitly stop previous processor before setting a new one
|
||||
// in browsers without modern API support to prevent UI crashes.
|
||||
// This workaround is needed until this issue is resolved:
|
||||
// https://github.com/livekit/track-processors-js/issues/85
|
||||
if (!BackgroundProcessorFactory.hasModernApiSupport()) {
|
||||
await videoTrack.stopProcessor()
|
||||
}
|
||||
await videoTrack.setProcessor(newProcessor)
|
||||
saveProcessorConfig(config)
|
||||
} else {
|
||||
await processor?.update(config)
|
||||
saveProcessorConfig(config)
|
||||
}
|
||||
|
||||
updateEffectStatusMessage(config, wasSelectedBeforeToggle)
|
||||
} catch (error) {
|
||||
console.error('Error applying effect:', error)
|
||||
} finally {
|
||||
// Without setTimeout the DOM is not refreshing when updating the options.
|
||||
setTimeout(() => setProcessorPending(false))
|
||||
}
|
||||
},
|
||||
[
|
||||
enabled,
|
||||
saveProcessorConfig,
|
||||
selectedId,
|
||||
toggle,
|
||||
updateEffectStatusMessage,
|
||||
videoTrack,
|
||||
]
|
||||
)
|
||||
|
||||
const { data: appConfig } = useConfig()
|
||||
const { isLoggedIn } = useUser()
|
||||
const canUploadBackground =
|
||||
isLoggedIn === true &&
|
||||
appConfig?.background_image?.upload_is_enabled === true
|
||||
// We split the error state in 2 parts so that there are no visual glitches when closing the alert
|
||||
const [personalBackgroundHasError, setPersonalBackgroundHasError] =
|
||||
useState<boolean>(false)
|
||||
const [personalBackgroundError, setPersonalBackgroundError] = useState<
|
||||
'file_too_large' | 'invalid_file_type' | null
|
||||
>(null)
|
||||
const createFileMutation = useCreateFile()
|
||||
const deleteFileMutation = useDeleteFile()
|
||||
const filesQ = useListMyFiles(listFilesQueryParams)
|
||||
const hasReachedMaxNbBackgrounds =
|
||||
(canUploadBackground &&
|
||||
appConfig &&
|
||||
filesQ.data &&
|
||||
filesQ.data.count >= appConfig.background_image.max_count_by_user) ??
|
||||
false
|
||||
|
||||
const getHandleSelectChangeFile = useCallback(
|
||||
(file: ApiFileItem) => {
|
||||
return async () => {
|
||||
await toggleEffect({
|
||||
type: ProcessorType.VIRTUAL,
|
||||
imagePath: file.url!,
|
||||
fileId: file.id,
|
||||
})
|
||||
}
|
||||
},
|
||||
[toggleEffect]
|
||||
)
|
||||
|
||||
const handleNewBackgroundFilePicked = useCallback(
|
||||
async (file: File) => {
|
||||
if (
|
||||
!(
|
||||
appConfig?.background_image?.allowed_mimetypes?.includes(file.type) ??
|
||||
false
|
||||
)
|
||||
) {
|
||||
setPersonalBackgroundError('invalid_file_type')
|
||||
setPersonalBackgroundHasError(true)
|
||||
return
|
||||
}
|
||||
if (file.size > (appConfig?.background_image?.max_size ?? 0)) {
|
||||
setPersonalBackgroundError('file_too_large')
|
||||
setPersonalBackgroundHasError(true)
|
||||
return
|
||||
}
|
||||
|
||||
// When the user is not logged in, we fallback to just loading that image
|
||||
if (!canUploadBackground) {
|
||||
// For the preview to work, we somehow need to create a data URL from the raw file.
|
||||
// revoking is handled with userChoicesStore
|
||||
if (uploadNotPossibleSnap.imageBackgroundConfig) {
|
||||
URL.revokeObjectURL(
|
||||
uploadNotPossibleSnap.imageBackgroundConfig.imagePath
|
||||
)
|
||||
}
|
||||
const imagePath = URL.createObjectURL(file)
|
||||
|
||||
const fileId = `local-image`
|
||||
await toggleEffect({
|
||||
type: ProcessorType.VIRTUAL,
|
||||
imagePath,
|
||||
fileId,
|
||||
})
|
||||
uploadNotPossibleLocalState.imageBackgroundConfig = {
|
||||
type: ProcessorType.VIRTUAL,
|
||||
label: file.name.split('.')[0],
|
||||
imagePath,
|
||||
}
|
||||
} else {
|
||||
// Otherwise we create the file in the backend and automatically select it
|
||||
// when it's uploaded.
|
||||
createFileMutation.mutate(
|
||||
{
|
||||
file,
|
||||
onProgress: (progress) => {
|
||||
console.debug('upload-progress', progress)
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: (file) => {
|
||||
// We automatically select that created file
|
||||
getHandleSelectChangeFile(file)()
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
[
|
||||
appConfig?.background_image?.allowed_mimetypes,
|
||||
appConfig?.background_image?.max_size,
|
||||
canUploadBackground,
|
||||
createFileMutation,
|
||||
getHandleSelectChangeFile,
|
||||
toggleEffect,
|
||||
uploadNotPossibleSnap.imageBackgroundConfig,
|
||||
]
|
||||
)
|
||||
|
||||
const filePickerErrorContext = useMemo(
|
||||
() => ({
|
||||
allowedExtension: appConfig?.background_image?.allowed_extensions ?? [],
|
||||
maxSize: (appConfig?.background_image?.max_size ?? 0) / (1024 * 1024),
|
||||
}),
|
||||
[appConfig?.background_image]
|
||||
)
|
||||
|
||||
const processorOptions = useMemo<{
|
||||
isDisabled: boolean
|
||||
blurBased: {
|
||||
radius: BlurRadius
|
||||
Icon: React.FC
|
||||
ref?: React.Ref<HTMLButtonElement>
|
||||
tooltip: string
|
||||
id: string
|
||||
config: ProcessorConfig
|
||||
isSelected: boolean
|
||||
}[]
|
||||
virtualBackgrounds: {
|
||||
id: string
|
||||
config: ProcessorConfig
|
||||
isSelected: boolean
|
||||
tooltip: string
|
||||
ariaLabel: string
|
||||
thumbnailPath: string
|
||||
index: number
|
||||
}[]
|
||||
remoteCustomVirtualBackgrounds: {
|
||||
id: string
|
||||
config: ProcessorConfig
|
||||
isSelected: boolean
|
||||
tooltip: string
|
||||
file: ApiFileItem
|
||||
}[]
|
||||
}>(() => {
|
||||
return {
|
||||
isDisabled: (processorPendingReveal || isDisabled) ?? false,
|
||||
blurBased: [
|
||||
{
|
||||
key: 'light',
|
||||
radius: BlurRadius.LIGHT,
|
||||
icon: BlurOn,
|
||||
ref: blurLightRef,
|
||||
},
|
||||
{
|
||||
key: 'normal',
|
||||
radius: BlurRadius.NORMAL,
|
||||
icon: BlurOnStrong,
|
||||
ref: undefined,
|
||||
},
|
||||
].map((item) => {
|
||||
const config: ProcessorConfig = {
|
||||
type: ProcessorType.BLUR,
|
||||
blurRadius: item.radius,
|
||||
}
|
||||
const id = deriveIdFromProcessorConfig(config)
|
||||
return {
|
||||
id,
|
||||
tooltip: t(`blur.light.${selectedId === id ? 'clear' : 'apply'}`),
|
||||
radius: item.radius,
|
||||
isSelected: selectedId === id,
|
||||
Icon: item.icon,
|
||||
ref: item.ref,
|
||||
config,
|
||||
}
|
||||
}),
|
||||
virtualBackgrounds: [...Array(8).keys()].map((index) => {
|
||||
const imagePath = `/assets/backgrounds/${index + 1}.jpg`
|
||||
const thumbnailPath = `/assets/backgrounds/thumbnails/${index + 1}.jpg`
|
||||
const config: ProcessorConfig = {
|
||||
type: ProcessorType.VIRTUAL,
|
||||
imagePath,
|
||||
}
|
||||
const id = deriveIdFromProcessorConfig(config)
|
||||
const isSelected = selectedId === id
|
||||
const prefix = isSelected ? 'selectedLabel' : 'apply'
|
||||
const backgroundName = t(`virtual.presets.descriptions.${index}`)
|
||||
const ariaLabel = `${t(`virtual.presets.${prefix}`)} ${backgroundName}`
|
||||
|
||||
return {
|
||||
tooltip: backgroundName,
|
||||
id,
|
||||
config,
|
||||
isSelected: selectedId === id,
|
||||
thumbnailPath,
|
||||
ariaLabel,
|
||||
index,
|
||||
}
|
||||
}),
|
||||
remoteCustomVirtualBackgrounds: (filesQ.data?.results ?? [])
|
||||
.filter((file) => file.url)
|
||||
.map((file) => {
|
||||
const config: ProcessorConfig = {
|
||||
type: ProcessorType.VIRTUAL,
|
||||
imagePath: file.url!,
|
||||
fileId: file.id,
|
||||
}
|
||||
|
||||
const id = deriveIdFromProcessorConfig(config)
|
||||
|
||||
return {
|
||||
tooltip: file.title,
|
||||
id,
|
||||
config,
|
||||
isSelected: selectedId === id,
|
||||
file,
|
||||
}
|
||||
}),
|
||||
}
|
||||
}, [processorPendingReveal, isDisabled, filesQ.data?.results, t, selectedId])
|
||||
|
||||
useEffect(() => {
|
||||
const videoElement = videoRef.current
|
||||
if (!videoElement) return
|
||||
@@ -97,171 +506,6 @@ export const EffectsConfiguration = ({
|
||||
[]
|
||||
)
|
||||
|
||||
const announceEffectStatusMessage = (message: string) => {
|
||||
effectAnnouncementId.current += 1
|
||||
const currentId = effectAnnouncementId.current
|
||||
|
||||
if (effectAnnouncementTimeout.current) {
|
||||
clearTimeout(effectAnnouncementTimeout.current)
|
||||
}
|
||||
|
||||
effectAnnouncementTimeout.current = setTimeout(() => {
|
||||
if (currentId !== effectAnnouncementId.current) return
|
||||
announce(message)
|
||||
}, 80)
|
||||
}
|
||||
|
||||
const clearEffect = async () => {
|
||||
await videoTrack.stopProcessor()
|
||||
onSubmit?.(undefined)
|
||||
}
|
||||
|
||||
const getVirtualBackgroundName = (imagePath?: string) => {
|
||||
if (!imagePath) return ''
|
||||
const match = imagePath.match(/\/backgrounds\/(\d+)\.jpg$/)
|
||||
if (!match) return ''
|
||||
const index = Number(match[1]) - 1
|
||||
if (Number.isNaN(index)) return ''
|
||||
return t(`virtual.descriptions.${index}`)
|
||||
}
|
||||
|
||||
const updateEffectStatusMessage = (
|
||||
type: ProcessorType,
|
||||
options: BackgroundOptions,
|
||||
wasSelectedBeforeToggle: boolean
|
||||
) => {
|
||||
if (wasSelectedBeforeToggle) {
|
||||
announceEffectStatusMessage(t('blur.status.none'))
|
||||
return
|
||||
}
|
||||
|
||||
if (type === ProcessorType.BLUR) {
|
||||
const message =
|
||||
options.blurRadius === BlurRadius.LIGHT
|
||||
? t('blur.status.light')
|
||||
: t('blur.status.strong')
|
||||
announceEffectStatusMessage(message)
|
||||
return
|
||||
}
|
||||
|
||||
if (type === ProcessorType.VIRTUAL) {
|
||||
const backgroundName = getVirtualBackgroundName(options.imagePath)
|
||||
if (backgroundName) {
|
||||
announceEffectStatusMessage(
|
||||
`${t('virtual.selectedLabel')} ${backgroundName}`
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toggleEffect = async (
|
||||
type: ProcessorType,
|
||||
options: BackgroundOptions
|
||||
) => {
|
||||
setProcessorPending(true)
|
||||
const wasSelectedBeforeToggle = isSelected(type, options)
|
||||
|
||||
if (!videoTrack) {
|
||||
/**
|
||||
* Special case: if no video track is available, then we must pass directly the processor into the
|
||||
* toggle call. Otherwise, the rest of the function below would not have a videoTrack to call
|
||||
* setProcessor on.
|
||||
*
|
||||
* We arrive in this condition when we enter the room with the camera already off.
|
||||
*/
|
||||
const newProcessorTmp = BackgroundProcessorFactory.getProcessor(
|
||||
type,
|
||||
options
|
||||
)!
|
||||
await toggle(true, {
|
||||
processor: newProcessorTmp,
|
||||
})
|
||||
setTimeout(() => setProcessorPending(false))
|
||||
return
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
await toggle(true)
|
||||
}
|
||||
|
||||
const processor = getProcessor()
|
||||
try {
|
||||
if (wasSelectedBeforeToggle) {
|
||||
// Stop processor.
|
||||
await clearEffect()
|
||||
} else if (
|
||||
!processor ||
|
||||
(processor.serialize().type !== type &&
|
||||
!BackgroundProcessorFactory.hasModernApiSupport())
|
||||
) {
|
||||
// Change processor.
|
||||
const newProcessor = BackgroundProcessorFactory.getProcessor(
|
||||
type,
|
||||
options
|
||||
)!
|
||||
// IMPORTANT: Must explicitly stop previous processor before setting a new one
|
||||
// in browsers without modern API support to prevent UI crashes.
|
||||
// This workaround is needed until this issue is resolved:
|
||||
// https://github.com/livekit/track-processors-js/issues/85
|
||||
if (!BackgroundProcessorFactory.hasModernApiSupport()) {
|
||||
await videoTrack.stopProcessor()
|
||||
}
|
||||
await videoTrack.setProcessor(newProcessor)
|
||||
onSubmit?.(newProcessor)
|
||||
} else {
|
||||
await processor?.update(options)
|
||||
// We want to trigger onSubmit when options changes so the parent component is aware of it.
|
||||
onSubmit?.(processor)
|
||||
}
|
||||
|
||||
updateEffectStatusMessage(type, options, wasSelectedBeforeToggle)
|
||||
} catch (error) {
|
||||
console.error('Error applying effect:', error)
|
||||
} finally {
|
||||
// Without setTimeout the DOM is not refreshing when updating the options.
|
||||
setTimeout(() => setProcessorPending(false))
|
||||
}
|
||||
}
|
||||
|
||||
const getProcessor = () => {
|
||||
return videoTrack?.getProcessor() as BackgroundProcessorInterface
|
||||
}
|
||||
|
||||
const isSelected = (type: ProcessorType, options: BackgroundOptions) => {
|
||||
const processor = getProcessor()
|
||||
const processorSerialized = processor?.serialize()
|
||||
return (
|
||||
!!processor &&
|
||||
processorSerialized.type === type &&
|
||||
JSON.stringify(processorSerialized.options) === JSON.stringify(options)
|
||||
)
|
||||
}
|
||||
|
||||
const tooltipBlur = (type: ProcessorType, options: BackgroundOptions) => {
|
||||
const strength =
|
||||
options.blurRadius === BlurRadius.LIGHT ? 'light' : 'normal'
|
||||
const action = isSelected(type, options) ? 'clear' : 'apply'
|
||||
|
||||
return t(`${type}.${strength}.${action}`)
|
||||
}
|
||||
|
||||
const ariaLabelVirtualBackground = (
|
||||
index: number,
|
||||
imagePath: string
|
||||
): string => {
|
||||
const isSelectedBackground = isSelected(ProcessorType.VIRTUAL, {
|
||||
imagePath,
|
||||
})
|
||||
const prefix = isSelectedBackground ? 'selectedLabel' : 'apply'
|
||||
const backgroundName = t(`virtual.descriptions.${index}`)
|
||||
return `${t(`virtual.${prefix}`)} ${backgroundName}`
|
||||
}
|
||||
|
||||
const tooltipVirtualBackground = (index: number): string => {
|
||||
return t(`virtual.descriptions.${index}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css(
|
||||
@@ -362,7 +606,7 @@ export const EffectsConfiguration = ({
|
||||
<div>
|
||||
<div>
|
||||
<H
|
||||
lvl={3}
|
||||
lvl={2}
|
||||
style={{
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
@@ -377,65 +621,208 @@ export const EffectsConfiguration = ({
|
||||
gap: '1.25rem',
|
||||
})}
|
||||
>
|
||||
<ToggleButton
|
||||
ref={blurLightRef}
|
||||
variant="bigSquare"
|
||||
aria-label={tooltipBlur(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.LIGHT,
|
||||
})}
|
||||
tooltip={tooltipBlur(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.LIGHT,
|
||||
})}
|
||||
isDisabled={processorPendingReveal || isDisabled}
|
||||
onChange={async () =>
|
||||
await toggleEffect(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.LIGHT,
|
||||
})
|
||||
}
|
||||
isSelected={isSelected(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.LIGHT,
|
||||
})}
|
||||
data-attr="toggle-blur-light"
|
||||
>
|
||||
<BlurOn />
|
||||
</ToggleButton>
|
||||
<ToggleButton
|
||||
variant="bigSquare"
|
||||
aria-label={tooltipBlur(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.NORMAL,
|
||||
})}
|
||||
tooltip={tooltipBlur(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.NORMAL,
|
||||
})}
|
||||
isDisabled={processorPendingReveal || isDisabled}
|
||||
onChange={async () =>
|
||||
await toggleEffect(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.NORMAL,
|
||||
})
|
||||
}
|
||||
isSelected={isSelected(ProcessorType.BLUR, {
|
||||
blurRadius: BlurRadius.NORMAL,
|
||||
})}
|
||||
data-attr="toggle-blur-normal"
|
||||
>
|
||||
<BlurOnStrong />
|
||||
</ToggleButton>
|
||||
{processorOptions.blurBased.map(({ Icon, ...option }) => (
|
||||
<ToggleButton
|
||||
key={option.id}
|
||||
ref={option.ref}
|
||||
variant="bigSquare"
|
||||
aria-label={option.tooltip}
|
||||
tooltip={option.tooltip}
|
||||
isDisabled={processorOptions.isDisabled}
|
||||
onChange={() => toggleEffect(option.config)}
|
||||
isSelected={option.isSelected}
|
||||
data-attr={`toggle-${option.id}`}
|
||||
>
|
||||
<Icon />
|
||||
</ToggleButton>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
marginTop: '1.5rem',
|
||||
})}
|
||||
>
|
||||
|
||||
<div className={css({ marginTop: '1.5rem' })}>
|
||||
<H
|
||||
lvl={3}
|
||||
lvl={2}
|
||||
style={{
|
||||
marginBottom: '1rem',
|
||||
marginBottom: '0.6rem',
|
||||
}}
|
||||
variant="bodyXsBold"
|
||||
>
|
||||
{t('virtual.title')}
|
||||
</H>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
marginBottom: '1rem',
|
||||
})}
|
||||
>
|
||||
<H
|
||||
lvl={2}
|
||||
style={{
|
||||
marginBottom: '0.4rem',
|
||||
}}
|
||||
variant="bodyXsMedium"
|
||||
>
|
||||
{t('virtual.personal.title')}
|
||||
</H>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '1.25rem',
|
||||
paddingBottom: '0.5rem',
|
||||
flexWrap: 'wrap',
|
||||
})}
|
||||
>
|
||||
{canUploadBackground &&
|
||||
processorOptions.remoteCustomVirtualBackgrounds.map(
|
||||
(option) => (
|
||||
<div
|
||||
key={option.id}
|
||||
className={
|
||||
'hoverGroup ' + css({ position: 'relative' })
|
||||
}
|
||||
>
|
||||
<VisualOnlyTooltip tooltip={option.tooltip}>
|
||||
<ToggleButton
|
||||
variant="bigSquare"
|
||||
aria-label={option.tooltip}
|
||||
isDisabled={processorOptions.isDisabled}
|
||||
onChange={getHandleSelectChangeFile(option.file)}
|
||||
isSelected={option.isSelected}
|
||||
className={css({
|
||||
bgSize: 'cover',
|
||||
})}
|
||||
style={{
|
||||
backgroundImage: `url(${option.file.url!})`,
|
||||
}}
|
||||
data-attr={`toggle-virtual-${option.file.id}`}
|
||||
/>
|
||||
</VisualOnlyTooltip>
|
||||
<Button
|
||||
className={
|
||||
'hoverGroupChild ' +
|
||||
css({
|
||||
position: 'absolute',
|
||||
top: '-8px',
|
||||
right: '-8px',
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
})
|
||||
}
|
||||
size={'xs'}
|
||||
variant={'tertiary'}
|
||||
onClick={() => {
|
||||
if (option.isSelected) {
|
||||
// we remove the current effect
|
||||
toggleEffect(option.config)
|
||||
}
|
||||
deleteFileMutation.mutate({
|
||||
fileId: option.file.id,
|
||||
})
|
||||
}}
|
||||
isDisabled={deleteFileMutation.isPending}
|
||||
>
|
||||
<RiDeleteBinLine size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{!canUploadBackground &&
|
||||
uploadNotPossibleSnap.imageBackgroundConfig && (
|
||||
<VisualOnlyTooltip
|
||||
tooltip={
|
||||
uploadNotPossibleSnap.imageBackgroundConfig.label
|
||||
}
|
||||
>
|
||||
<ToggleButton
|
||||
variant="bigSquare"
|
||||
aria-label={
|
||||
uploadNotPossibleSnap.imageBackgroundConfig.label
|
||||
}
|
||||
isDisabled={processorOptions.isDisabled}
|
||||
onChange={() => {
|
||||
toggleEffect(
|
||||
uploadNotPossibleSnap.imageBackgroundConfig!
|
||||
)
|
||||
}}
|
||||
isSelected={
|
||||
deriveIdFromProcessorConfig(
|
||||
uploadNotPossibleSnap.imageBackgroundConfig
|
||||
) === selectedId
|
||||
}
|
||||
className={css({
|
||||
bgSize: 'cover',
|
||||
})}
|
||||
style={{
|
||||
backgroundImage: `url(${uploadNotPossibleSnap.imageBackgroundConfig.imagePath})`,
|
||||
}}
|
||||
data-attr={`toggle-virtual-local`}
|
||||
/>
|
||||
</VisualOnlyTooltip>
|
||||
)}
|
||||
<FileTrigger
|
||||
acceptedFileTypes={
|
||||
appConfig?.background_image?.allowed_mimetypes ?? [
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
]
|
||||
}
|
||||
onSelect={(e) => {
|
||||
if (e && e.item(0)) {
|
||||
const file = e.item(0) as File
|
||||
handleNewBackgroundFilePicked(file)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
variant="bigSquare"
|
||||
aria-label={t('virtual.personal.selectFileTooltip')}
|
||||
tooltip={t('virtual.personal.selectFileTooltip')}
|
||||
isDisabled={
|
||||
(canUploadBackground &&
|
||||
filesQ.data &&
|
||||
filesQ.data.count >=
|
||||
(appConfig?.background_image?.max_count_by_user ??
|
||||
0)) ||
|
||||
processorOptions.isDisabled ||
|
||||
createFileMutation.isPending
|
||||
}
|
||||
data-attr="input-file-select-personal-background"
|
||||
>
|
||||
<RiImageAddFill />
|
||||
</Button>
|
||||
</FileTrigger>
|
||||
</div>
|
||||
{!isLoggedIn && (
|
||||
<Text variant="xsNote">
|
||||
{t('virtual.personal.notLoggedInWarning')}
|
||||
</Text>
|
||||
)}
|
||||
{!canUploadBackground && isLoggedIn && (
|
||||
<Text variant="xsNote">
|
||||
{t('virtual.personal.warningUploadDisabled')}
|
||||
</Text>
|
||||
)}
|
||||
{hasReachedMaxNbBackgrounds && (
|
||||
<Text variant="xsNote">
|
||||
{t('virtual.personal.uploadLimitReached')}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
marginTop: '0.4rem',
|
||||
})}
|
||||
>
|
||||
<H
|
||||
lvl={2}
|
||||
style={{
|
||||
marginBottom: '0.4rem',
|
||||
}}
|
||||
variant="bodyXsMedium"
|
||||
>
|
||||
{t('virtual.presets.title')}
|
||||
</H>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
@@ -444,35 +831,24 @@ export const EffectsConfiguration = ({
|
||||
flexWrap: 'wrap',
|
||||
})}
|
||||
>
|
||||
{[...Array(8).keys()].map((i) => {
|
||||
const imagePath = `/assets/backgrounds/${i + 1}.jpg`
|
||||
const thumbnailPath = `/assets/backgrounds/thumbnails/${i + 1}.jpg`
|
||||
const tooltipText = tooltipVirtualBackground(i)
|
||||
return (
|
||||
<VisualOnlyTooltip key={i} tooltip={tooltipText}>
|
||||
<ToggleButton
|
||||
variant="bigSquare"
|
||||
aria-label={ariaLabelVirtualBackground(i, imagePath)}
|
||||
isDisabled={processorPendingReveal || isDisabled}
|
||||
onChange={async () =>
|
||||
await toggleEffect(ProcessorType.VIRTUAL, {
|
||||
imagePath,
|
||||
})
|
||||
}
|
||||
isSelected={isSelected(ProcessorType.VIRTUAL, {
|
||||
imagePath,
|
||||
})}
|
||||
className={css({
|
||||
bgSize: 'cover',
|
||||
})}
|
||||
style={{
|
||||
backgroundImage: `url(${thumbnailPath})`,
|
||||
}}
|
||||
data-attr={`toggle-virtual-${i}`}
|
||||
/>
|
||||
</VisualOnlyTooltip>
|
||||
)
|
||||
})}
|
||||
{processorOptions.virtualBackgrounds.map((option) => (
|
||||
<VisualOnlyTooltip key={option.id} tooltip={option.tooltip}>
|
||||
<ToggleButton
|
||||
variant="bigSquare"
|
||||
aria-label={option.ariaLabel}
|
||||
isDisabled={processorOptions.isDisabled}
|
||||
onChange={() => toggleEffect(option.config)}
|
||||
isSelected={option.isSelected}
|
||||
className={css({
|
||||
bgSize: 'cover',
|
||||
})}
|
||||
style={{
|
||||
backgroundImage: `url(${option.thumbnailPath})`,
|
||||
}}
|
||||
data-attr={`toggle-virtual-preset-${option.index}`}
|
||||
/>
|
||||
</VisualOnlyTooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -483,6 +859,32 @@ export const EffectsConfiguration = ({
|
||||
</Information>
|
||||
)}
|
||||
</div>
|
||||
<Dialog
|
||||
isOpen={personalBackgroundHasError}
|
||||
type="alert"
|
||||
title={t(`virtual.personal.errors.${personalBackgroundError}.title`)}
|
||||
aria-label={t(
|
||||
`virtual.personal.errors.${personalBackgroundError}.title`
|
||||
)}
|
||||
onClose={() => setPersonalBackgroundHasError(false)}
|
||||
onOpenChange={() => setPersonalBackgroundHasError(false)}
|
||||
>
|
||||
<P>
|
||||
{t(
|
||||
`virtual.personal.errors.${personalBackgroundError}.description`,
|
||||
filePickerErrorContext
|
||||
)}
|
||||
</P>
|
||||
<HStack justifyContent="end" direction="row">
|
||||
<Button
|
||||
variant="text"
|
||||
size="sm"
|
||||
onPress={() => setPersonalBackgroundHasError(false)}
|
||||
>
|
||||
{t('virtual.personal.errors.close')}
|
||||
</Button>
|
||||
</HStack>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export const FunnyEffects = ({
|
||||
showFrench: false,
|
||||
}
|
||||
}
|
||||
return processor.serialize().options
|
||||
return { ...processor.options }
|
||||
}
|
||||
|
||||
const options = getOptions()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { userChoicesStore } from '@/stores/userChoices'
|
||||
import type { VideoResolution } from '@/stores/userChoices'
|
||||
import { ProcessorSerialized } from '@/features/rooms/livekit/components/blur'
|
||||
import { ProcessorConfig } from '@/features/rooms/livekit/components/blur'
|
||||
import type { VideoQuality } from 'livekit-client'
|
||||
|
||||
export function usePersistentUserChoices() {
|
||||
@@ -36,10 +36,8 @@ export function usePersistentUserChoices() {
|
||||
saveNoiseReductionEnabled: (enabled: boolean) => {
|
||||
userChoicesStore.noiseReductionEnabled = enabled
|
||||
},
|
||||
saveProcessorSerialized: (
|
||||
processorSerialized: ProcessorSerialized | undefined
|
||||
) => {
|
||||
userChoicesStore.processorSerialized = processorSerialized
|
||||
saveProcessorConfig: (processorConfig: ProcessorConfig | undefined) => {
|
||||
userChoicesStore.processorConfig = processorConfig
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
||||
const {
|
||||
userChoices: {
|
||||
videoDeviceId,
|
||||
processorSerialized,
|
||||
processorConfig,
|
||||
videoPublishResolution,
|
||||
videoSubscribeQuality,
|
||||
},
|
||||
@@ -78,7 +78,7 @@ export const VideoTab = ({ id }: VideoTabProps) => {
|
||||
resolution: VideoPresets[key].resolution,
|
||||
deviceId: { exact: videoDeviceId },
|
||||
processor:
|
||||
BackgroundProcessorFactory.deserializeProcessor(processorSerialized),
|
||||
BackgroundProcessorFactory.fromProcessorConfig(processorConfig),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,11 @@ import { useSnapshot } from 'valtio'
|
||||
import {
|
||||
accessibilityStore,
|
||||
type CaptionTextSize,
|
||||
type CaptionColor,
|
||||
CAPTION_TEXT_SIZE_OPTIONS,
|
||||
CAPTION_COLOR_OPTIONS,
|
||||
} from '@/stores/accessibility'
|
||||
import { useAreSubtitlesAvailable } from '../hooks/useAreSubtitlesAvailable'
|
||||
|
||||
export const CaptionsSettings = () => {
|
||||
const { t } = useTranslation('settings', {
|
||||
@@ -24,6 +27,27 @@ export const CaptionsSettings = () => {
|
||||
[t]
|
||||
)
|
||||
|
||||
const captionFontColorItems = useMemo(
|
||||
() =>
|
||||
CAPTION_COLOR_OPTIONS.map((color) => ({
|
||||
value: color,
|
||||
label: t(`fontColor.options.${color}`),
|
||||
})),
|
||||
[t]
|
||||
)
|
||||
|
||||
const captionBackgroundColorItems = useMemo(
|
||||
() =>
|
||||
CAPTION_COLOR_OPTIONS.map((color) => ({
|
||||
value: color,
|
||||
label: t(`backgroundColor.options.${color}`),
|
||||
})),
|
||||
[t]
|
||||
)
|
||||
|
||||
const areSubtitlesAvailable = useAreSubtitlesAvailable()
|
||||
if (!areSubtitlesAvailable) return null
|
||||
|
||||
return (
|
||||
<li>
|
||||
<H
|
||||
@@ -34,16 +58,44 @@ export const CaptionsSettings = () => {
|
||||
>
|
||||
{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 }}
|
||||
/>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.75rem',
|
||||
})}
|
||||
>
|
||||
<Field
|
||||
type="select"
|
||||
label={t('textSize.label')}
|
||||
items={captionTextSizeItems}
|
||||
selectedKey={snap.captionTextSize}
|
||||
onSelectionChange={(key) => {
|
||||
accessibilityStore.captionTextSize = key as CaptionTextSize
|
||||
}}
|
||||
wrapperProps={{ noMargin: true, fullWidth: true }}
|
||||
/>
|
||||
<Field
|
||||
type="select"
|
||||
label={t('fontColor.label')}
|
||||
items={captionFontColorItems}
|
||||
selectedKey={snap.captionFontColor}
|
||||
onSelectionChange={(key) => {
|
||||
accessibilityStore.captionFontColor = key as CaptionColor
|
||||
}}
|
||||
wrapperProps={{ noMargin: true, fullWidth: true }}
|
||||
/>
|
||||
<Field
|
||||
type="select"
|
||||
label={t('backgroundColor.label')}
|
||||
items={captionBackgroundColorItems}
|
||||
selectedKey={snap.captionBackgroundColor}
|
||||
onSelectionChange={(key) => {
|
||||
accessibilityStore.captionBackgroundColor = key as CaptionColor
|
||||
}}
|
||||
wrapperProps={{ noMargin: true, fullWidth: true }}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import { useSnapshot } from 'valtio'
|
||||
import {
|
||||
accessibilityStore,
|
||||
CAPTION_TEXT_SIZE_OPTIONS,
|
||||
CAPTION_FONT_COLOR_VALUES,
|
||||
CAPTION_BACKGROUND_COLOR_VALUES,
|
||||
type CaptionTextSize,
|
||||
} from '@/stores/accessibility'
|
||||
|
||||
@@ -92,10 +94,14 @@ const useTranscriptionState = () => {
|
||||
}
|
||||
|
||||
const Transcription = ({ row }: { row: TranscriptionRow }) => {
|
||||
const { captionTextSize } = useSnapshot(accessibilityStore)
|
||||
const { captionTextSize, captionFontColor, captionBackgroundColor } =
|
||||
useSnapshot(accessibilityStore)
|
||||
const participantColor = getParticipantColor(row.participant)
|
||||
const participantName = getParticipantName(row.participant)
|
||||
const { fontSize, lineHeight } = CAPTION_FONT_SIZES[captionTextSize]
|
||||
const fontColor = CAPTION_FONT_COLOR_VALUES[captionFontColor]
|
||||
const backgroundColor =
|
||||
CAPTION_BACKGROUND_COLOR_VALUES[captionBackgroundColor]
|
||||
|
||||
const getDisplayText = (row: TranscriptionRow): string => {
|
||||
return row.segments
|
||||
@@ -128,9 +134,9 @@ const Transcription = ({ row }: { row: TranscriptionRow }) => {
|
||||
/>
|
||||
<div
|
||||
className={css({
|
||||
color: 'white',
|
||||
width: '100%',
|
||||
})}
|
||||
style={{ color: fontColor }}
|
||||
>
|
||||
<Text variant="h3" margin={false}>
|
||||
{participantName}
|
||||
@@ -138,8 +144,10 @@ const Transcription = ({ row }: { row: TranscriptionRow }) => {
|
||||
<p
|
||||
className={css({
|
||||
fontWeight: '400',
|
||||
borderRadius: '4px',
|
||||
padding: '0.125rem 0.25rem',
|
||||
})}
|
||||
style={{ fontSize, lineHeight }}
|
||||
style={{ fontSize, lineHeight, backgroundColor }}
|
||||
>
|
||||
{displayText}
|
||||
</p>
|
||||
|
||||
@@ -273,17 +273,39 @@
|
||||
"title": "Virtueller Hintergrund",
|
||||
"selectedLabel": "Hintergrund angewendet:",
|
||||
"apply": "Ersetze deinen Hintergrund:",
|
||||
"descriptions": {
|
||||
"0": "Gerilltes Holzmöbel",
|
||||
"1": "Besprechungsraum",
|
||||
"2": "Loft mit schwarzer Glaswand",
|
||||
"3": "Esszimmer",
|
||||
"4": "Holzregale",
|
||||
"5": "Holztreppe",
|
||||
"6": "Graue Bibliothek",
|
||||
"7": "Kaffeetheke"
|
||||
"personal": {
|
||||
"title": "Meine Hintergründe",
|
||||
"selectFileTooltip": "Wähle ein Bild aus, das als persönlicher Hintergrund verwendet werden soll",
|
||||
"notLoggedInWarning": "Du bist nicht angemeldet, der persönliche Hintergrund wird nicht von einem Meeting zum anderen gespeichert.",
|
||||
"warningUploadDisabled": "Persönliche Hintergründe werden derzeit nicht von einem Meeting zum anderen gespeichert.",
|
||||
"uploadLimitReached": "Du kannst keine weiteren persönlichen Hintergründe hinzufügen.",
|
||||
"errors": {
|
||||
"close": "Schließen",
|
||||
"file_too_large": {
|
||||
"title": "Datei zu groß",
|
||||
"description": "Die Datei ist zu groß. Bitte wähle eine Datei mit weniger als {{maxSize, number}} MB."
|
||||
},
|
||||
"invalid_file_type": {
|
||||
"title": "Ungültiger Dateityp",
|
||||
"description": "Der Dateityp wird nicht unterstützt. Bitte wähle eine {{allowedExtension}}-Datei."
|
||||
}
|
||||
}
|
||||
},
|
||||
"presets": {
|
||||
"title": "Vorschläge",
|
||||
"descriptions": {
|
||||
"0": "Gerilltes Holzmöbel",
|
||||
"1": "Besprechungsraum",
|
||||
"2": "Loft mit schwarzer Glaswand",
|
||||
"3": "Esszimmer",
|
||||
"4": "Holzregale",
|
||||
"5": "Holztreppe",
|
||||
"6": "Graue Bibliothek",
|
||||
"7": "Kaffeetheke"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"faceLandmarks": {
|
||||
"title": "Visuelle Effekte",
|
||||
"glasses": {
|
||||
@@ -297,7 +319,7 @@
|
||||
}
|
||||
},
|
||||
"sidePanel": {
|
||||
"ariaLabel": "Seitenleiste",
|
||||
"ariaLabel": "Seitenleiste - {{title}}",
|
||||
"backToTools": "Zurück zu den Meeting-Tools",
|
||||
"heading": {
|
||||
"participants": "Teilnehmende",
|
||||
|
||||
@@ -126,6 +126,34 @@
|
||||
"medium": "Mittel",
|
||||
"large": "Groß"
|
||||
}
|
||||
},
|
||||
"fontColor": {
|
||||
"label": "Schriftfarbe",
|
||||
"options": {
|
||||
"default": "Standard",
|
||||
"white": "Weiß",
|
||||
"black": "Schwarz",
|
||||
"blue": "Blau",
|
||||
"green": "Grün",
|
||||
"red": "Rot",
|
||||
"yellow": "Gelb",
|
||||
"cyan": "Cyan",
|
||||
"magenta": "Magenta"
|
||||
}
|
||||
},
|
||||
"backgroundColor": {
|
||||
"label": "Hintergrundfarbe",
|
||||
"options": {
|
||||
"default": "Standard",
|
||||
"white": "Weiß",
|
||||
"black": "Schwarz",
|
||||
"blue": "Blau",
|
||||
"green": "Grün",
|
||||
"red": "Rot",
|
||||
"yellow": "Gelb",
|
||||
"cyan": "Cyan",
|
||||
"magenta": "Magenta"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -270,18 +270,39 @@
|
||||
}
|
||||
},
|
||||
"virtual": {
|
||||
"title": "Virtual background",
|
||||
"title": "Virtual backgrounds",
|
||||
"selectedLabel": "Background applied:",
|
||||
"apply": "Replace your background:",
|
||||
"descriptions": {
|
||||
"0": "Fluted wooden furniture",
|
||||
"1": "Meeting room",
|
||||
"2": "Loft with black glass partition",
|
||||
"3": "Dining room",
|
||||
"4": "Wooden shelves",
|
||||
"5": "Wooden staircase",
|
||||
"6": "Gray library",
|
||||
"7": "Coffee counter"
|
||||
"personal": {
|
||||
"title": "My backgrounds",
|
||||
"selectFileTooltip": "Select an image file to use as a personal background",
|
||||
"notLoggedInWarning": "You are not logged-in, personal backgrounds won't be saved from one meeting to the other.",
|
||||
"warningUploadDisabled": "Personal backgrounds are currently not saved from one meeting to the other.",
|
||||
"uploadLimitReached": "You cannot upload more personal backgrounds.",
|
||||
"errors": {
|
||||
"close": "Close",
|
||||
"file_too_large": {
|
||||
"title": "File too large",
|
||||
"description": "The file is too large. Please choose a file smaller than {{maxSize, number}} MB."
|
||||
},
|
||||
"invalid_file_type": {
|
||||
"title": "Invalid file type",
|
||||
"description": "The file type is not supported. Please choose a {{allowedExtension, list(type: 'disjunction')}} file."
|
||||
}
|
||||
}
|
||||
},
|
||||
"presets": {
|
||||
"title": "Suggestions",
|
||||
"descriptions": {
|
||||
"0": "Fluted wooden furniture",
|
||||
"1": "Meeting room",
|
||||
"2": "Loft with black glass partition",
|
||||
"3": "Dining room",
|
||||
"4": "Wooden shelves",
|
||||
"5": "Wooden staircase",
|
||||
"6": "Gray library",
|
||||
"7": "Coffee counter"
|
||||
}
|
||||
}
|
||||
},
|
||||
"faceLandmarks": {
|
||||
@@ -297,7 +318,7 @@
|
||||
}
|
||||
},
|
||||
"sidePanel": {
|
||||
"ariaLabel": "Sidepanel",
|
||||
"ariaLabel": "Sidepanel - {{title}}",
|
||||
"backToTools": "Back to meeting tools",
|
||||
"heading": {
|
||||
"participants": "Participants",
|
||||
|
||||
@@ -126,6 +126,34 @@
|
||||
"medium": "Medium",
|
||||
"large": "Large"
|
||||
}
|
||||
},
|
||||
"fontColor": {
|
||||
"label": "Font color",
|
||||
"options": {
|
||||
"default": "Default",
|
||||
"white": "White",
|
||||
"black": "Black",
|
||||
"blue": "Blue",
|
||||
"green": "Green",
|
||||
"red": "Red",
|
||||
"yellow": "Yellow",
|
||||
"cyan": "Cyan",
|
||||
"magenta": "Magenta"
|
||||
}
|
||||
},
|
||||
"backgroundColor": {
|
||||
"label": "Background color",
|
||||
"options": {
|
||||
"default": "Default",
|
||||
"white": "White",
|
||||
"black": "Black",
|
||||
"blue": "Blue",
|
||||
"green": "Green",
|
||||
"red": "Red",
|
||||
"yellow": "Yellow",
|
||||
"cyan": "Cyan",
|
||||
"magenta": "Magenta"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -270,18 +270,39 @@
|
||||
}
|
||||
},
|
||||
"virtual": {
|
||||
"title": "Arrière-plan virtuel",
|
||||
"title": "Arrière-plans virtuels",
|
||||
"selectedLabel": "Arrière-plan appliqué :",
|
||||
"apply": "Remplacer votre arrière plan :",
|
||||
"descriptions": {
|
||||
"0": "Meuble cannelé en bois",
|
||||
"1": "Salle de réunion",
|
||||
"2": "Loft avec verrière noire",
|
||||
"3": "Salle à manger",
|
||||
"4": "Étagères en bois",
|
||||
"5": "Escalier en bois",
|
||||
"6": "Bibliothèque grise",
|
||||
"7": "Comptoir de café"
|
||||
"personal": {
|
||||
"title": "Mes arrière-plans",
|
||||
"selectFileTooltip": "Sélectionnez une image à utiliser comme arrière-plan",
|
||||
"notLoggedInWarning": "Vous n'êtes pas connecté, l'arrière-plan personnel ne sera pas sauvegardé d'une réunion à l'autre.",
|
||||
"warningUploadDisabled": "Les arrière-plans personnels ne sont actuellement pas sauvegardés d'une réunion à l'autre.",
|
||||
"uploadLimitReached": "Vous ne pouvez pas ajouter plus d'arrière-plans personnels.",
|
||||
"errors": {
|
||||
"close": "Fermer",
|
||||
"file_too_large": {
|
||||
"title": "Fichier trop volumineux",
|
||||
"description": "Le fichier est trop volumineux. Veuillez choisir un fichier de moins de {{maxSize, number}} Mo."
|
||||
},
|
||||
"invalid_file_type": {
|
||||
"title": "Type de fichier non valide",
|
||||
"description": "Le type de fichier n'est pas pris en charge. Veuillez choisir un fichier {{allowedExtension}}."
|
||||
}
|
||||
}
|
||||
},
|
||||
"presets": {
|
||||
"title": "Suggestions",
|
||||
"descriptions": {
|
||||
"0": "Meuble cannelé en bois",
|
||||
"1": "Salle de réunion",
|
||||
"2": "Loft avec verrière noire",
|
||||
"3": "Salle à manger",
|
||||
"4": "Étagères en bois",
|
||||
"5": "Escalier en bois",
|
||||
"6": "Bibliothèque grise",
|
||||
"7": "Comptoir de café"
|
||||
}
|
||||
}
|
||||
},
|
||||
"faceLandmarks": {
|
||||
@@ -297,7 +318,7 @@
|
||||
}
|
||||
},
|
||||
"sidePanel": {
|
||||
"ariaLabel": "Panneau latéral",
|
||||
"ariaLabel": "Panneau latéral - {{title}}",
|
||||
"backToTools": "Retour aux outils de réunion",
|
||||
"heading": {
|
||||
"participants": "Participants",
|
||||
|
||||
@@ -126,6 +126,34 @@
|
||||
"medium": "Moyen",
|
||||
"large": "Grand"
|
||||
}
|
||||
},
|
||||
"fontColor": {
|
||||
"label": "Couleur du texte",
|
||||
"options": {
|
||||
"default": "Par défaut",
|
||||
"white": "Blanc",
|
||||
"black": "Noir",
|
||||
"blue": "Bleu",
|
||||
"green": "Vert",
|
||||
"red": "Rouge",
|
||||
"yellow": "Jaune",
|
||||
"cyan": "Cyan",
|
||||
"magenta": "Magenta"
|
||||
}
|
||||
},
|
||||
"backgroundColor": {
|
||||
"label": "Couleur de fond",
|
||||
"options": {
|
||||
"default": "Par défaut",
|
||||
"white": "Blanc",
|
||||
"black": "Noir",
|
||||
"blue": "Bleu",
|
||||
"green": "Vert",
|
||||
"red": "Rouge",
|
||||
"yellow": "Jaune",
|
||||
"cyan": "Cyan",
|
||||
"magenta": "Magenta"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -273,15 +273,36 @@
|
||||
"title": "Virtuele achtergrond",
|
||||
"selectedLabel": "Achtergrond toegepast:",
|
||||
"apply": "Vervang je achtergrond:",
|
||||
"descriptions": {
|
||||
"0": "Geprofileerd houten meubel",
|
||||
"1": "Vergaderruimte",
|
||||
"2": "Loft met zwarte glaswand",
|
||||
"3": "Eetkamer",
|
||||
"4": "Houten planken",
|
||||
"5": "Houten trap",
|
||||
"6": "Grijze bibliotheek",
|
||||
"7": "Koffiebar"
|
||||
"personal": {
|
||||
"title": "Mijn achtergronden",
|
||||
"selectFileTooltip": "Selecteer een afbeeldingsbestand om te gebruiken als persoonlijke achtergrond",
|
||||
"notLoggedInWarning": "U bent niet ingelogd, persoonlijke achtergronden worden niet opgeslagen van de ene vergadering naar de andere.",
|
||||
"warningUploadDisabled": "Persoonlijke achtergronden worden momenteel niet opgeslagen van de ene vergadering naar de andere.",
|
||||
"uploadLimitReached": "U kunt geen persoonlijke achtergronden meer uploaden.",
|
||||
"errors": {
|
||||
"close": "Sluiten",
|
||||
"file_too_large": {
|
||||
"title": "Bestand te groot",
|
||||
"description": "Het bestand is te groot. Kies een bestand kleiner dan {{maxSize, number}} MB."
|
||||
},
|
||||
"invalid_file_type": {
|
||||
"title": "Ongeldig bestandstype",
|
||||
"description": "Het bestandstype wordt niet ondersteund. Kies een {{allowedExtension, list(type: 'disjunction')}} bestand."
|
||||
}
|
||||
}
|
||||
},
|
||||
"presets": {
|
||||
"title": "Suggesties",
|
||||
"descriptions": {
|
||||
"0": "Geprofileerd houten meubel",
|
||||
"1": "Vergaderruimte",
|
||||
"2": "Loft met zwarte glaswand",
|
||||
"3": "Eetkamer",
|
||||
"4": "Houten planken",
|
||||
"5": "Houten trap",
|
||||
"6": "Grijze bibliotheek",
|
||||
"7": "Koffiebar"
|
||||
}
|
||||
}
|
||||
},
|
||||
"faceLandmarks": {
|
||||
@@ -297,7 +318,7 @@
|
||||
}
|
||||
},
|
||||
"sidePanel": {
|
||||
"ariaLabel": "Zijbalk",
|
||||
"ariaLabel": "Zijbalk - {{title}}",
|
||||
"backToTools": "Terug naar vergadertools",
|
||||
"heading": {
|
||||
"participants": "Deelnemers",
|
||||
|
||||
@@ -126,6 +126,34 @@
|
||||
"medium": "Gemiddeld",
|
||||
"large": "Groot"
|
||||
}
|
||||
},
|
||||
"fontColor": {
|
||||
"label": "Tekstkleur",
|
||||
"options": {
|
||||
"default": "Standaard",
|
||||
"white": "Wit",
|
||||
"black": "Zwart",
|
||||
"blue": "Blauw",
|
||||
"green": "Groen",
|
||||
"red": "Rood",
|
||||
"yellow": "Geel",
|
||||
"cyan": "Cyaan",
|
||||
"magenta": "Magenta"
|
||||
}
|
||||
},
|
||||
"backgroundColor": {
|
||||
"label": "Achtergrondkleur",
|
||||
"options": {
|
||||
"default": "Standaard",
|
||||
"white": "Wit",
|
||||
"black": "Zwart",
|
||||
"blue": "Blauw",
|
||||
"green": "Groen",
|
||||
"red": "Rood",
|
||||
"yellow": "Geel",
|
||||
"cyan": "Cyaan",
|
||||
"magenta": "Magenta"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -36,6 +36,10 @@ export const text = cva({
|
||||
textStyle: 'body',
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
bodyXsMedium: {
|
||||
textStyle: 'body',
|
||||
fontWeight: 'medium',
|
||||
},
|
||||
body: {
|
||||
textStyle: 'body',
|
||||
},
|
||||
|
||||
@@ -10,14 +10,65 @@ export const CAPTION_TEXT_SIZE_OPTIONS: CaptionTextSize[] = [
|
||||
'large',
|
||||
]
|
||||
|
||||
export type CaptionColor =
|
||||
| 'default'
|
||||
| 'white'
|
||||
| 'black'
|
||||
| 'blue'
|
||||
| 'green'
|
||||
| 'red'
|
||||
| 'yellow'
|
||||
| 'cyan'
|
||||
| 'magenta'
|
||||
|
||||
export const CAPTION_COLOR_OPTIONS: CaptionColor[] = [
|
||||
'default',
|
||||
'white',
|
||||
'black',
|
||||
'blue',
|
||||
'green',
|
||||
'red',
|
||||
'yellow',
|
||||
'cyan',
|
||||
'magenta',
|
||||
]
|
||||
|
||||
export const CAPTION_FONT_COLOR_VALUES: Record<CaptionColor, string> = {
|
||||
default: '#FFFFFF',
|
||||
white: '#FFFFFF',
|
||||
black: '#000000',
|
||||
blue: '#0000FF',
|
||||
green: '#00FF00',
|
||||
red: '#FF0000',
|
||||
yellow: '#FFFF00',
|
||||
cyan: '#00FFFF',
|
||||
magenta: '#FF00FF',
|
||||
}
|
||||
|
||||
export const CAPTION_BACKGROUND_COLOR_VALUES: Record<CaptionColor, string> = {
|
||||
default: 'rgba(0, 0, 0, 0.75)',
|
||||
black: 'rgba(0, 0, 0, 0.75)',
|
||||
white: 'rgba(255, 255, 255, 0.75)',
|
||||
blue: 'rgba(0, 0, 255, 0.75)',
|
||||
green: 'rgba(0, 255, 0, 0.75)',
|
||||
red: 'rgba(255, 0, 0, 0.75)',
|
||||
yellow: 'rgba(255, 255, 0, 0.75)',
|
||||
cyan: 'rgba(0, 255, 255, 0.75)',
|
||||
magenta: 'rgba(255, 0, 255, 0.75)',
|
||||
}
|
||||
|
||||
type AccessibilityState = {
|
||||
announceReactions: boolean
|
||||
captionTextSize: CaptionTextSize
|
||||
captionFontColor: CaptionColor
|
||||
captionBackgroundColor: CaptionColor
|
||||
}
|
||||
|
||||
const DEFAULT_STATE: AccessibilityState = {
|
||||
announceReactions: false,
|
||||
captionTextSize: 'medium',
|
||||
captionFontColor: 'default',
|
||||
captionBackgroundColor: 'default',
|
||||
}
|
||||
|
||||
function getAccessibilityState(): AccessibilityState {
|
||||
@@ -25,10 +76,21 @@ function getAccessibilityState(): AccessibilityState {
|
||||
const stored = localStorage.getItem(STORAGE_KEYS.ACCESSIBILITY)
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored)
|
||||
const validCaptionSizes = CAPTION_TEXT_SIZE_OPTIONS
|
||||
const captionTextSize = validCaptionSizes.includes(parsed.captionTextSize)
|
||||
const captionTextSize = CAPTION_TEXT_SIZE_OPTIONS.includes(
|
||||
parsed.captionTextSize
|
||||
)
|
||||
? parsed.captionTextSize
|
||||
: DEFAULT_STATE.captionTextSize
|
||||
const captionFontColor = CAPTION_COLOR_OPTIONS.includes(
|
||||
parsed.captionFontColor
|
||||
)
|
||||
? parsed.captionFontColor
|
||||
: DEFAULT_STATE.captionFontColor
|
||||
const captionBackgroundColor = CAPTION_COLOR_OPTIONS.includes(
|
||||
parsed.captionBackgroundColor
|
||||
)
|
||||
? parsed.captionBackgroundColor
|
||||
: DEFAULT_STATE.captionBackgroundColor
|
||||
return {
|
||||
...DEFAULT_STATE,
|
||||
...parsed,
|
||||
@@ -37,6 +99,8 @@ function getAccessibilityState(): AccessibilityState {
|
||||
? parsed.announceReactions
|
||||
: DEFAULT_STATE.announceReactions,
|
||||
captionTextSize,
|
||||
captionFontColor,
|
||||
captionBackgroundColor,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { proxy, subscribe } from 'valtio'
|
||||
import { ProcessorSerialized } from '@/features/rooms/livekit/components/blur'
|
||||
import {
|
||||
ProcessorConfig,
|
||||
ProcessorType,
|
||||
} from '@/features/rooms/livekit/components/blur'
|
||||
import {
|
||||
loadUserChoices,
|
||||
saveUserChoices,
|
||||
LocalUserChoices as LocalUserChoicesLK,
|
||||
saveUserChoices,
|
||||
} from '@livekit/components-core'
|
||||
import { VideoQuality } from 'livekit-client'
|
||||
|
||||
export type VideoResolution = 'h720' | 'h360' | 'h180'
|
||||
|
||||
export type LocalUserChoices = LocalUserChoicesLK & {
|
||||
processorSerialized?: ProcessorSerialized
|
||||
processorConfig?: ProcessorConfig
|
||||
noiseReductionEnabled?: boolean
|
||||
audioOutputDeviceId?: string
|
||||
videoPublishResolution?: VideoResolution
|
||||
@@ -28,7 +31,33 @@ function getUserChoicesState(): LocalUserChoices {
|
||||
}
|
||||
|
||||
export const userChoicesStore = proxy<LocalUserChoices>(getUserChoicesState())
|
||||
|
||||
subscribe(userChoicesStore, () => {
|
||||
saveUserChoices(userChoicesStore, false)
|
||||
})
|
||||
|
||||
// we run some logic on store loading to check if the processor config is still valid
|
||||
if (userChoicesStore.processorConfig?.type === ProcessorType.VIRTUAL) {
|
||||
if (userChoicesStore.processorConfig.imagePath.startsWith('blob:')) {
|
||||
// this happens when a not authenticated user had changed their background image
|
||||
// we restore clear the processor config to avoid displaying a black screen.
|
||||
userChoicesStore.processorConfig = undefined
|
||||
} else if (userChoicesStore.processorConfig.fileId) {
|
||||
// Checking if the image is still available / accessible
|
||||
await fetch(userChoicesStore.processorConfig.imagePath, {
|
||||
// We bypass the cache to ensure we have access
|
||||
cache: 'reload',
|
||||
})
|
||||
.then((response) => {
|
||||
// if we cannot fetch the image (likely a 401 from the backend because
|
||||
// the user is not logged in anymore, etc.),
|
||||
// we clear the processor config to avoid displaying a black screen.
|
||||
// This can happen when the user logs out for instance, etc.
|
||||
if (!response.ok) {
|
||||
userChoicesStore.processorConfig = undefined
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
userChoicesStore.processorConfig = undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,3 +95,14 @@ html:has(.lk-video-conference) {
|
||||
U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193,
|
||||
U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
.hoverGroup .hoverGroupChild {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hoverGroup:hover .hoverGroupChild,
|
||||
.hoverGroup:focus-within .hoverGroupChild {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,14 @@ export default defineConfig(({ mode }) => {
|
||||
port: parseInt(env.VITE_PORT) || 3000,
|
||||
host: env.VITE_HOST ?? 'localhost',
|
||||
allowedHosts: ['.nip.io'],
|
||||
// In a local dev setup, we proxy the media server ourselves to avoid CORS issues
|
||||
proxy: {
|
||||
'/media': {
|
||||
target: 'http://localhost:8083',
|
||||
changeOrigin: true,
|
||||
secure: false
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
@@ -71,7 +71,7 @@ backend:
|
||||
SUMMARY_SERVICE_API_TOKEN: password
|
||||
RECORDING_DOWNLOAD_BASE_URL: https://meet.127.0.0.1.nip.io/recording
|
||||
ROOM_TELEPHONY_ENABLED: True
|
||||
SSL_CERT_FILE: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem
|
||||
SSL_CERT_FILE: /app/.venv/lib/python3.13/site-packages/certifi/cacert.pem
|
||||
|
||||
|
||||
migrate:
|
||||
@@ -100,7 +100,7 @@ backend:
|
||||
# Extra volume mounts to manage our local custom CA and avoid to set ssl_verify: false
|
||||
extraVolumeMounts:
|
||||
- name: certs
|
||||
mountPath: /usr/local/lib/python3.13/site-packages/certifi/cacert.pem
|
||||
mountPath: /app/.venv/lib/python3.13/site-packages/certifi/cacert.pem
|
||||
subPath: cacert.pem
|
||||
|
||||
# Extra volumes to manage our local custom CA and avoid to set ssl_verify: false
|
||||
|
||||
@@ -75,7 +75,7 @@ backend:
|
||||
ROOM_TELEPHONY_ENABLED: True
|
||||
ROOM_TELEPHONY_DEFAULT_COUNTRY: 'FR'
|
||||
ROOM_TELEPHONY_PHONE_NUMBER: '+33901020304'
|
||||
SSL_CERT_FILE: /usr/local/lib/python3.13/site-packages/certifi/cacert.pem
|
||||
SSL_CERT_FILE: /app/.venv/lib/python3.13/site-packages/certifi/cacert.pem
|
||||
ROOM_SUBTITLE_ENABLED: True
|
||||
EXTERNAL_API_ENABLED: True
|
||||
APPLICATION_JWT_AUDIENCE: https://meet.127.0.0.1.nip.io/external-api/v1.0/
|
||||
@@ -113,7 +113,7 @@ backend:
|
||||
# Extra volume mounts to manage our local custom CA and avoid to set ssl_verify: false
|
||||
extraVolumeMounts:
|
||||
- name: certs
|
||||
mountPath: /usr/local/lib/python3.13/site-packages/certifi/cacert.pem
|
||||
mountPath: /app/.venv/lib/python3.13/site-packages/certifi/cacert.pem
|
||||
subPath: cacert.pem
|
||||
|
||||
# Extra volumes to manage our local custom CA and avoid to set ssl_verify: false
|
||||
|
||||
@@ -94,7 +94,7 @@ backend:
|
||||
key: BREVO_API_KEY
|
||||
BREVO_API_CONTACT_LIST_IDS: 8
|
||||
ROOM_TELEPHONY_ENABLED: True
|
||||
SSL_CERT_FILE: /usr/local/lib/python3.13/site-packages/certifi/cacert.pem
|
||||
SSL_CERT_FILE: /app/.venv/lib/python3.13/site-packages/certifi/cacert.pem
|
||||
|
||||
|
||||
migrate:
|
||||
@@ -123,7 +123,7 @@ backend:
|
||||
# Extra volume mounts to manage our local custom CA and avoid to set ssl_verify: false
|
||||
extraVolumeMounts:
|
||||
- name: certs
|
||||
mountPath: /usr/local/lib/python3.13/site-packages/certifi/cacert.pem
|
||||
mountPath: /app/.venv/lib/python3.13/site-packages/certifi/cacert.pem
|
||||
subPath: cacert.pem
|
||||
|
||||
# Extra volumes to manage our local custom CA and avoid to set ssl_verify: false
|
||||
|
||||
@@ -112,7 +112,7 @@ spec:
|
||||
/usr/bin/mc mb meet/meet-media-storage && \
|
||||
exit 0
|
||||
restartPolicy: Never
|
||||
backoffLimit: 1
|
||||
backoffLimit: 3
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
@@ -132,7 +132,7 @@ spec:
|
||||
/usr/bin/mc admin config set meet notify_webhook:meet-webhook endpoint="https://meet.127.0.0.1.nip.io/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
|
||||
restartPolicy: Never
|
||||
backoffLimit: 1
|
||||
backoffLimit: 3
|
||||
|
||||
+23
-11
@@ -279,22 +279,28 @@ backend:
|
||||
## @param backend.probes.liveness.path [nullable] Configure path for backend HTTP liveness probe
|
||||
## @param backend.probes.liveness.targetPort [nullable] Configure port for backend HTTP liveness probe
|
||||
## @param backend.probes.liveness.initialDelaySeconds [nullable] Configure initial delay for backend liveness probe
|
||||
## @param backend.probes.liveness.initialDelaySeconds [nullable] Configure timeout for backend liveness probe
|
||||
## @param backend.probes.liveness.periodSeconds [nullable] Configure period for backend liveness probe
|
||||
## @param backend.probes.liveness.timeoutSeconds [nullable] Configure timeout for backend liveness probe
|
||||
## @param backend.probes.startup.path [nullable] Configure path for backend HTTP startup probe
|
||||
## @param backend.probes.startup.targetPort [nullable] Configure port for backend HTTP startup probe
|
||||
## @param backend.probes.startup.initialDelaySeconds [nullable] Configure initial delay for backend startup probe
|
||||
## @param backend.probes.startup.initialDelaySeconds [nullable] Configure timeout for backend startup probe
|
||||
## @param backend.probes.startup.periodSeconds [nullable] Configure period for backend startup probe
|
||||
## @param backend.probes.startup.timeoutSeconds [nullable] Configure timeout for backend startup probe
|
||||
## @param backend.probes.readiness.path [nullable] Configure path for backend HTTP readiness probe
|
||||
## @param backend.probes.readiness.targetPort [nullable] Configure port for backend HTTP readiness probe
|
||||
## @param backend.probes.readiness.initialDelaySeconds [nullable] Configure initial delay for backend readiness probe
|
||||
## @param backend.probes.readiness.initialDelaySeconds [nullable] Configure timeout for backend readiness probe
|
||||
## @param backend.probes.readiness.periodSeconds [nullable] Configure period for backend readiness probe
|
||||
## @param backend.probes.readiness.timeoutSeconds [nullable] Configure timeout for backend readiness probe
|
||||
probes:
|
||||
liveness:
|
||||
path: /__heartbeat__
|
||||
initialDelaySeconds: 30
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 30
|
||||
|
||||
readiness:
|
||||
path: /__lbheartbeat__
|
||||
initialDelaySeconds: 30
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 30
|
||||
|
||||
## @param backend.resources Resource requirements for the backend container
|
||||
resources: {}
|
||||
@@ -555,23 +561,29 @@ summary:
|
||||
|
||||
## @param summary.probes.liveness.path [nullable] Configure path for summary HTTP liveness probe
|
||||
## @param summary.probes.liveness.targetPort [nullable] Configure port for summary HTTP liveness probe
|
||||
## @param summary.probes.liveness.initialDelaySeconds [nullable] Configure initial delay for summary liveness probe
|
||||
## @param summary.probes.liveness.initialDelaySeconds [nullable] Configure timeout for summary liveness probe
|
||||
## @param summary.probes.liveness.initialDelaySeconds [nullable] Configure initial delay summary liveness probe
|
||||
## @param summary.probes.liveness.periodSeconds [nullable] Configure the period for the summary liveness probe
|
||||
## @param summary.probes.liveness.timeoutSeconds [nullable] Configure timeout for summary liveness probe
|
||||
## @param summary.probes.startup.path [nullable] Configure path for summary HTTP startup probe
|
||||
## @param summary.probes.startup.targetPort [nullable] Configure port for summary HTTP startup probe
|
||||
## @param summary.probes.startup.initialDelaySeconds [nullable] Configure initial delay for summary startup probe
|
||||
## @param summary.probes.startup.initialDelaySeconds [nullable] Configure timeout for summary startup probe
|
||||
## @param summary.probes.startup.periodSeconds [nullable] Configure period for the summary startup probe
|
||||
## @param summary.probes.startup.timeoutSeconds [nullable] Configure timeout for summary startup probe
|
||||
## @param summary.probes.readiness.path [nullable] Configure path for summary HTTP readiness probe
|
||||
## @param summary.probes.readiness.targetPort [nullable] Configure port for summary HTTP readiness probe
|
||||
## @param summary.probes.readiness.initialDelaySeconds [nullable] Configure initial delay for summary readiness probe
|
||||
## @param summary.probes.readiness.initialDelaySeconds [nullable] Configure timeout for summary readiness probe
|
||||
## @param summary.probes.readiness.periodSeconds [nullable] Configure period for the summary readiness probe
|
||||
## @param summary.probes.readiness.timeoutSeconds [nullable] Configure timeout for summary readiness probe
|
||||
probes:
|
||||
liveness:
|
||||
path: /__heartbeat__
|
||||
initialDelaySeconds: 30
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 30
|
||||
readiness:
|
||||
path: /__lbheartbeat__
|
||||
initialDelaySeconds: 30
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 30
|
||||
|
||||
|
||||
## @param summary.resources Resource requirements for the summary container
|
||||
resources: {}
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.10.0",
|
||||
"version": "1.11.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mail_mjml",
|
||||
"version": "1.10.0",
|
||||
"version": "1.11.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@html-to/text-cli": "0.5.4",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.10.0",
|
||||
"version": "1.11.0",
|
||||
"description": "An util to generate html and text django's templates from mjml templates",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.10.0",
|
||||
"version": "1.11.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sdk",
|
||||
"version": "1.10.0",
|
||||
"version": "1.11.0",
|
||||
"license": "ISC",
|
||||
"workspaces": [
|
||||
"./library",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.10.0",
|
||||
"version": "1.11.0",
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "summary"
|
||||
version = "1.10.0"
|
||||
version = "1.11.0"
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.105.0",
|
||||
"uvicorn>=0.24.0",
|
||||
@@ -21,8 +21,16 @@ dependencies = [
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"ruff==0.14.4",
|
||||
"pytest==9.0.2",
|
||||
"responses>=0.25.8",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
markers = [
|
||||
"api: Test the API",
|
||||
]
|
||||
testpaths = ["tests"]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
@@ -49,6 +57,7 @@ select = [
|
||||
"T20", # flake8-print
|
||||
"W", # pycodestyle warning
|
||||
]
|
||||
ignore= ["PLR2004"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/*" = [
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the summary service."""
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the API summary service."""
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Integration tests for the health check endpoints."""
|
||||
|
||||
|
||||
class TestHeartbeat:
|
||||
"""Tests for the /__heartbeat__ endpoint."""
|
||||
|
||||
def test_returns_200(self, client):
|
||||
"""The heartbeat endpoint responds with 200 OK without a token."""
|
||||
response = client.get("/__heartbeat__")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestLBHeartbeat:
|
||||
"""Tests for the /__lbheartbeat__ endpoint."""
|
||||
|
||||
def test_returns_200(self, client):
|
||||
"""The load-balancer heartbeat endpoint responds with 200 OK without a token."""
|
||||
response = client.get("/__lbheartbeat__")
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Integration tests for the API tasks endpoints."""
|
||||
|
||||
# tests/unit/test_api_tasks.py
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
class TestTasks:
|
||||
"""Tests for the /tasks endpoint."""
|
||||
|
||||
@patch(
|
||||
"summary.api.route.tasks.process_audio_transcribe_summarize_v2.apply_async",
|
||||
return_value=MagicMock(id="task-id-abc"),
|
||||
)
|
||||
@patch("summary.api.route.tasks.time.time", return_value=1735725600.0)
|
||||
def test_create_task_returns_task_id(self, mock_time, mock_apply_async, client):
|
||||
"""POST /tasks/ with valid payload returns id and dispatches Celery task."""
|
||||
response = client.post(
|
||||
"api/v1/tasks/",
|
||||
headers={"Authorization": "Bearer test-api-token"},
|
||||
json={
|
||||
"owner_id": "owner-123",
|
||||
"filename": "recording.mp4",
|
||||
"email": "user@example.com",
|
||||
"sub": "sub-123",
|
||||
"room": "room-abc",
|
||||
"recording_date": "2026-01-01",
|
||||
"recording_time": "10:00:00",
|
||||
"language": None,
|
||||
"download_link": "http://example.com/file.mp4",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"id": "task-id-abc", "message": "Task created"}
|
||||
|
||||
args = mock_apply_async.call_args.kwargs["args"]
|
||||
assert args == [
|
||||
"owner-123", # owner_id
|
||||
"recording.mp4", # filename
|
||||
"user@example.com", # email
|
||||
"sub-123", # sub
|
||||
1735725600.0, # frozen time
|
||||
"room-abc", # room
|
||||
"2026-01-01", # recording_date
|
||||
"10:00:00", # recording_time
|
||||
None, # language
|
||||
"http://example.com/file.mp4", # download_link
|
||||
None, # context_language
|
||||
]
|
||||
|
||||
def test_create_task_invalid_language(self, client):
|
||||
"""POST /tasks/ with an unsupported language returns 422."""
|
||||
payload = {"language": "klingon"}
|
||||
response = client.post(
|
||||
"/api/v1/tasks/",
|
||||
headers={"Authorization": "Bearer test-api-token"},
|
||||
json=payload,
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
@patch(
|
||||
"summary.api.route.tasks.AsyncResult",
|
||||
return_value=MagicMock(status="PENDING"),
|
||||
)
|
||||
def test_get_task_status_pending(self, mock_result, client):
|
||||
"""GET /tasks/{id} returns PENDING status when the task has not started yet."""
|
||||
response = client.get(
|
||||
"/api/v1/tasks/task-id-abc",
|
||||
headers={"Authorization": "Bearer test-api-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"id": "task-id-abc", "status": "PENDING"}
|
||||
|
||||
@patch(
|
||||
"summary.api.route.tasks.AsyncResult",
|
||||
return_value=MagicMock(status="SUCCESS"),
|
||||
)
|
||||
def test_get_task_status_success(self, mock_result, client):
|
||||
"""GET /tasks/{id} returns SUCCESS status when the task has completed."""
|
||||
response = client.get(
|
||||
"/api/v1/tasks/task-id-abc",
|
||||
headers={"Authorization": "Bearer test-api-token"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "SUCCESS"
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Integration test configuration. Provides shared fixtures."""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import SecretStr
|
||||
|
||||
from summary.core.config import Settings, get_settings
|
||||
from summary.main import app
|
||||
|
||||
|
||||
def get_settings_override():
|
||||
"""Return settings for tests."""
|
||||
return Settings(
|
||||
app_api_token=SecretStr("test-api-token"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client():
|
||||
"""Provide a FastAPI TestClient for tests."""
|
||||
client = TestClient(app)
|
||||
app.dependency_overrides[get_settings] = get_settings_override
|
||||
|
||||
return client
|
||||
Reference in New Issue
Block a user