Compare commits
63 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 21d1834d5e | |||
| 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 | |||
| d15fb37932 | |||
| 051f33bb1e | |||
| 566eacc8fe | |||
| d19023a1ba | |||
| a6e36f02a7 | |||
| c3fd1a89ef | |||
| 8b1ff536b3 | |||
| 6b08b8da1b | |||
| c4ff42f181 | |||
| ee0aa0fe5b | |||
| 09c871fc29 | |||
| 8496959188 | |||
| 77105001e0 | |||
| 2d57d38644 | |||
| c9d13619a6 | |||
| b99ec9bb50 | |||
| b3f26469c8 | |||
| d5c53c7dd4 | |||
| f43ac2e4eb | |||
| ea5dd5bc0e | |||
| 86427fa2b7 | |||
| 3959c3657c | |||
| 191f8abbcc | |||
| d91f8bb6e1 | |||
| d612f9b26b | |||
| 042be17cfa | |||
| d00f4fa695 | |||
| fd36469fc2 | |||
| dc278a6064 | |||
| 047da94494 | |||
| 124a8bf8d9 | |||
| c72c5cae1a | |||
| b564044e70 | |||
| 4717143251 | |||
| 805e983749 | |||
| 6dfafb7f67 |
+8
-6
@@ -4,7 +4,7 @@ __pycache__
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
venv
|
||||
.venv
|
||||
**/.venv
|
||||
|
||||
# System-specific files
|
||||
.DS_Store
|
||||
@@ -24,13 +24,15 @@ data
|
||||
.cache
|
||||
.circleci
|
||||
.git
|
||||
.vscode
|
||||
.iml
|
||||
.idea
|
||||
db.sqlite3
|
||||
.mypy_cache
|
||||
.pylint.d
|
||||
.pytest_cache
|
||||
|
||||
**/.idea
|
||||
**/.vscode
|
||||
**/.pytest_cache
|
||||
**/.mypy_cache
|
||||
**/.ruff_cache
|
||||
|
||||
# Frontend
|
||||
node_modules
|
||||
**/node_modules
|
||||
|
||||
@@ -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
|
||||
|
||||
+15
-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
|
||||
@@ -230,6 +232,7 @@ jobs:
|
||||
OIDC_RS_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_OP_INTROSPECTION_ENDPOINT: https://oidc.example.com/introspect
|
||||
OIDC_OP_URL: https://oidc.example.com
|
||||
MEDIA_BASE_URL: http://localhost:8083
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -278,10 +281,10 @@ jobs:
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.13"
|
||||
cache: "pip"
|
||||
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
- name: Install the dependencies
|
||||
run: uv sync --locked --all-extras
|
||||
|
||||
- name: Install gettext (required to compile messages)
|
||||
run: |
|
||||
@@ -289,10 +292,10 @@ 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
|
||||
|
||||
lint-front:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -31,6 +31,7 @@ MANIFEST
|
||||
|
||||
# Translations # Translations
|
||||
*.pot
|
||||
*.mo
|
||||
|
||||
# Environments
|
||||
.env
|
||||
|
||||
+42
-3
@@ -8,11 +8,40 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(helm) support celery with our Django backend #1124
|
||||
- ✨(helm) support ingress for custom background image #1124
|
||||
- ✨(backend) add authenticated user rate throttling on request-entry #1129
|
||||
- ✨(backend) expose `is_active` field for Application in Django admin #1133
|
||||
- ✨(file-upload) disable by default & limit count by user #1141
|
||||
|
||||
### Changed
|
||||
|
||||
- ♿️(frontend) Caption text size setting for accessibility #1062
|
||||
- ♿️(frontend) sync html lang attribute with i18n for screen readers #1111
|
||||
- ♿️(frontend) improve MoreLink a11y and UX on home page #1112
|
||||
- ♿(frontend) improve chat toast a11y for screen readers #1109
|
||||
- ♿(frontend) improve ui and aria labels for help article links #1108
|
||||
- 🌐(frontend) improve German translation #1125
|
||||
- 🔨(python-env) migrate meet main app to UV #1120
|
||||
- ♻️(backend) align Application model field with `is_active` convention #1133
|
||||
- 🔐(backend) avoids revealing the inactive status of an application #1135
|
||||
- ⚡️(helm) reduce initialDelaySeconds and add periods seconds #1139
|
||||
- 🔒️(backend) avoid information exposure through exception messages #1144
|
||||
- ⬆️(dependencies) update PyJWT to v2.12.0 [SECURITY] #1151
|
||||
- 📌(agents) unpin OpenSSL and related dependencies #1167
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🩹(frontend) remove incorrect reference to ProConnect on the prejoin #1080
|
||||
- ✨(frontend) add Ctrl+Shift+/ to open shortcuts settings #1050
|
||||
- ♿(frontend) announce selected state to screen readers #1081
|
||||
- 🐛(frontend) fix hand icon and queue position alignment and position #1119
|
||||
- 🩹(backend) add page_size to pagination for room endpoints #1131
|
||||
- 🐛(backend) refactor lobby throttling to use participant id #1129
|
||||
- 🩹(backend) ignore non-recording uploads in storage webhook handler #1142
|
||||
- 🐛(frontend) fix dimension mismatch in BackgroundCustomProcessor #1116
|
||||
|
||||
|
||||
## [1.10.0] - 2026-03-05
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -20,12 +49,22 @@ and this project adheres to
|
||||
- 🦺(backend) strengthen API validation for recording options #1063
|
||||
- ⚡️(frontend) optimize few performance caveats #1073
|
||||
- 🔒️(helm) introduce a dedicated Kubernetes Ingress for webhook-livekit #1066
|
||||
- ⬆️(deps) bump rollup from 4.44.2 to 4.59.0 in /src/frontend #1088
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(migrations) use settings in migrations #1058
|
||||
- 💄(frontend) truncate pinned participant name with ellipsis on overflow #1056
|
||||
- ♿(frontend) prevent focus ring clipping on invite dialog #1078
|
||||
- ♿(frontend) dynamic tab title when connected to meeting #1060
|
||||
- 🩹(frontend) remove incorrect reference to ProConnect on the prejoin #1080
|
||||
- ✨(frontend) add Ctrl+Shift+/ to open shortcuts settings #1050
|
||||
- ♿(frontend) announce selected state to screen readers #1081
|
||||
- 💄(frontend) truncate long names with ellipsis in reaction overlay #1099
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) add file upload feature #1030
|
||||
|
||||
## [1.9.0] - 2026-03-02
|
||||
|
||||
|
||||
+41
-23
@@ -13,14 +13,28 @@ RUN apk update && \
|
||||
# ---- Back-end builder image ----
|
||||
FROM base AS back-builder
|
||||
|
||||
WORKDIR /builder
|
||||
|
||||
# Copy required python dependencies
|
||||
COPY ./src/backend /builder
|
||||
ENV UV_COMPILE_BYTECODE=1
|
||||
ENV UV_LINK_MODE=copy
|
||||
|
||||
RUN mkdir /install && \
|
||||
pip install --prefix=/install .
|
||||
# Disable Python downloads, because we want to use the system interpreter
|
||||
# across both images. If using a managed Python version, it needs to be
|
||||
# copied from the build image into the final image;
|
||||
ENV UV_PYTHON_DOWNLOADS=0
|
||||
|
||||
# install uv
|
||||
COPY --from=ghcr.io/astral-sh/uv:0.10.9 /uv /uvx /bin/
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
--mount=type=bind,source=src/backend/uv.lock,target=uv.lock \
|
||||
--mount=type=bind,source=src/backend/pyproject.toml,target=pyproject.toml \
|
||||
uv sync --locked --no-install-project --no-dev
|
||||
COPY src/backend /app
|
||||
RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
uv sync --locked --no-dev
|
||||
|
||||
# ---- mails ----
|
||||
FROM node:20 AS mail-builder
|
||||
@@ -30,7 +44,7 @@ COPY ./src/mail /mail/app
|
||||
WORKDIR /mail/app
|
||||
|
||||
RUN yarn install --frozen-lockfile && \
|
||||
yarn build
|
||||
yarn build
|
||||
|
||||
|
||||
# ---- static link collector ----
|
||||
@@ -39,19 +53,20 @@ ARG MEET_STATIC_ROOT=/data/static
|
||||
|
||||
RUN apk add \
|
||||
pango \
|
||||
libmagic \
|
||||
rdfind
|
||||
|
||||
# Copy installed python dependencies
|
||||
COPY --from=back-builder /install /usr/local
|
||||
|
||||
# Copy Meet application (see .dockerignore)
|
||||
COPY ./src/backend /app/
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the application from the builder
|
||||
COPY --from=back-builder /app /app
|
||||
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
|
||||
# collectstatic
|
||||
RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
|
||||
python manage.py collectstatic --noinput
|
||||
python manage.py collectstatic --noinput
|
||||
|
||||
# Replace duplicated file by a symlink to decrease the overall size of the
|
||||
# final image
|
||||
@@ -68,6 +83,7 @@ RUN apk --no-cache add \
|
||||
gettext \
|
||||
libffi-dev \
|
||||
pango \
|
||||
libmagic \
|
||||
shared-mime-info
|
||||
|
||||
|
||||
@@ -79,14 +95,17 @@ COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||
# docker user (see entrypoint).
|
||||
RUN chmod g=u /etc/passwd
|
||||
|
||||
# Copy installed python dependencies
|
||||
COPY --from=back-builder /install /usr/local
|
||||
|
||||
# Copy Meet application (see .dockerignore)
|
||||
COPY ./src/backend /app/
|
||||
# Copy the application from the builder
|
||||
COPY --from=back-builder /app /app
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
# Generate compiled translation messages
|
||||
RUN DJANGO_CONFIGURATION=Build \
|
||||
python manage.py compilemessages --ignore=".venv/**/*"
|
||||
|
||||
# We wrap commands run in this container by the following entrypoint that
|
||||
# creates a user on-the-fly with the container user ID (see USER) and root group
|
||||
# ID.
|
||||
@@ -101,10 +120,9 @@ USER root:root
|
||||
# Install psql
|
||||
RUN apk add postgresql-client
|
||||
|
||||
# Uninstall Meet and re-install it in editable mode along with development
|
||||
# dependencies
|
||||
RUN pip uninstall -y meet
|
||||
RUN pip install -e .[dev]
|
||||
# Install development dependencies
|
||||
RUN --mount=from=ghcr.io/astral-sh/uv:0.10.9,source=/uv,target=/bin/uv \
|
||||
uv sync --all-extras --locked
|
||||
|
||||
# Restore the un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
@@ -113,7 +131,7 @@ USER ${DOCKER_USER}
|
||||
# Target database host (e.g. database engine following docker compose services
|
||||
# name) & port
|
||||
ENV DB_HOST=postgresql \
|
||||
DB_PORT=5432
|
||||
DB_PORT=5432
|
||||
|
||||
# Run django development server
|
||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||
|
||||
@@ -114,7 +114,8 @@ logs: ## display app-dev logs (follow mode)
|
||||
.PHONY: logs
|
||||
|
||||
run-backend: ## start only the backend application and all needed services
|
||||
@$(COMPOSE) up --force-recreate -d celery-dev
|
||||
@$(COMPOSE) up --force-recreate -d celery-dev --remove-orphans
|
||||
@$(COMPOSE) up --force-recreate -d nginx
|
||||
@echo "Wait for postgresql to be up..."
|
||||
@$(WAIT_DB)
|
||||
.PHONY: run-backend
|
||||
@@ -222,7 +223,7 @@ superuser: ## Create an admin superuser with password "admin"
|
||||
.PHONY: superuser
|
||||
|
||||
back-i18n-compile: ## compile the gettext files
|
||||
@$(MANAGE) compilemessages --ignore="venv/**/*"
|
||||
@$(MANAGE) compilemessages --ignore=".venv/**/*"
|
||||
.PHONY: back-i18n-compile
|
||||
|
||||
back-i18n-generate: ## create the .pot files used for i18n
|
||||
@@ -353,13 +354,13 @@ install-external-secrets: ## install the kubernetes secrets from Vaultwarden
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
start-tilt: ## start the kubernetes cluster using kind
|
||||
tilt up -f ./bin/Tiltfile
|
||||
tilt up --namespace=meet -f ./bin/Tiltfile
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
|
||||
DEV_ENV=dev-keycloak tilt up -f ./bin/Tiltfile
|
||||
DEV_ENV=dev-keycloak tilt up --namespace=meet -f ./bin/Tiltfile
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
start-tilt-dinum: ## start the kubernetes cluster using kind, without Pro Connect for authentication, but with DINUM styles
|
||||
DEV_ENV=dev-dinum tilt up -f ./bin/Tiltfile
|
||||
DEV_ENV=dev-dinum tilt up --namespace=meet -f ./bin/Tiltfile
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
+4
-4
@@ -18,7 +18,6 @@ docker_build(
|
||||
'localhost:5001/meet-backend:latest',
|
||||
context='..',
|
||||
dockerfile='../Dockerfile',
|
||||
build_args={'DOCKER_USER': '1001:127'},
|
||||
only=['./src/backend', './src/mail', './docker'],
|
||||
target = 'backend-production',
|
||||
live_update=[
|
||||
@@ -34,7 +33,6 @@ clean_old_images('localhost:5001/meet-backend')
|
||||
docker_build(
|
||||
'localhost:5001/meet-frontend-dinum:latest',
|
||||
context='..',
|
||||
build_args={'DOCKER_USER': '1001:127'},
|
||||
dockerfile='../docker/dinum-frontend/Dockerfile',
|
||||
only=['./src/frontend', './docker', './.dockerignore'],
|
||||
target = 'frontend-production',
|
||||
@@ -59,7 +57,6 @@ clean_old_images('localhost:5001/meet-frontend-generic')
|
||||
docker_build(
|
||||
'localhost:5001/meet-summary:latest',
|
||||
context='../src/summary',
|
||||
build_args={'DOCKER_USER': '1001:127'},
|
||||
dockerfile='../src/summary/Dockerfile',
|
||||
only=['.'],
|
||||
target = 'production',
|
||||
@@ -72,7 +69,6 @@ clean_old_images('localhost:5001/meet-summary')
|
||||
docker_build(
|
||||
'localhost:5001/meet-agents:latest',
|
||||
context='../src/agents',
|
||||
build_args={'DOCKER_USER': '1001:127'},
|
||||
dockerfile='../src/agents/Dockerfile',
|
||||
only=['.'],
|
||||
target = 'production',
|
||||
@@ -103,7 +99,11 @@ k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .
|
||||
|
||||
k8s_resource('minio-bucket', resource_deps=['minio'])
|
||||
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
|
||||
k8s_resource('meet-celery-backend', resource_deps=['redis'])
|
||||
k8s_resource('meet-celery-summarize', resource_deps=['redis'])
|
||||
k8s_resource('meet-celery-transcribe', resource_deps=['redis'])
|
||||
k8s_resource('meet-backend-migrate', resource_deps=['meet-backend'])
|
||||
k8s_resource('livekit-livekit-server', resource_deps=['redis'])
|
||||
k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
|
||||
k8s_resource('keycloak', resource_deps=['kc-postgresql'])
|
||||
k8s_resource('meet-backend-createsuperuser', resource_deps=['meet-backend-migrate'])
|
||||
|
||||
+4
-2
@@ -58,7 +58,7 @@ services:
|
||||
/usr/bin/mc admin config set meet notify_webhook:meet-webhook endpoint='http://app-dev:8000/api/v1.0/recordings/storage-hook/' auth_token='Bearer password' &&
|
||||
/usr/bin/mc admin service restart meet --wait --json &&
|
||||
sleep 15 &&
|
||||
/usr/bin/mc event add meet/meet-media-storage arn:minio:sqs::meet-webhook:webhook --event put &&
|
||||
/usr/bin/mc event add meet/meet-media-storage arn:minio:sqs::meet-webhook:webhook --event put --prefix "recordings" &&
|
||||
exit 0;"
|
||||
|
||||
app-dev:
|
||||
@@ -80,11 +80,11 @@ services:
|
||||
volumes:
|
||||
- ./src/backend:/app
|
||||
- ./data/static:/data/static
|
||||
- /app/.venv
|
||||
depends_on:
|
||||
- postgresql
|
||||
- mailcatcher
|
||||
- redis
|
||||
- nginx
|
||||
- livekit
|
||||
- createbuckets
|
||||
- createwebhook
|
||||
@@ -106,6 +106,7 @@ services:
|
||||
volumes:
|
||||
- ./src/backend:/app
|
||||
- ./data/static:/data/static
|
||||
- /app/.venv
|
||||
depends_on:
|
||||
- app-dev
|
||||
|
||||
@@ -148,6 +149,7 @@ services:
|
||||
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
depends_on:
|
||||
- keycloak
|
||||
- app-dev
|
||||
networks:
|
||||
- resource-server
|
||||
- default
|
||||
|
||||
@@ -4,10 +4,47 @@ server {
|
||||
server_name localhost;
|
||||
charset utf-8;
|
||||
|
||||
# Proxy auth for media
|
||||
location /media/ {
|
||||
# Auth request configuration
|
||||
auth_request /media-auth;
|
||||
auth_request_set $authHeader $upstream_http_authorization;
|
||||
auth_request_set $authDate $upstream_http_x_amz_date;
|
||||
auth_request_set $authContentSha256 $upstream_http_x_amz_content_sha256;
|
||||
|
||||
# Pass specific headers from the auth response
|
||||
proxy_set_header Authorization $authHeader;
|
||||
proxy_set_header X-Amz-Date $authDate;
|
||||
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
|
||||
|
||||
# Get resource from Minio
|
||||
proxy_pass http://minio:9000/meet-media-storage/;
|
||||
proxy_set_header Host minio:9000;
|
||||
# To use with ds_proxy
|
||||
# proxy_pass http://ds-proxy:4444/upstream/meet-media-storage/;
|
||||
# proxy_set_header Host ds-proxy:4444;
|
||||
add_header Content-Disposition "attachment";
|
||||
}
|
||||
|
||||
location /media-auth {
|
||||
proxy_pass http://app-dev:8000/api/v1.0/files/media-auth/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Original-URL $request_uri;
|
||||
|
||||
# Prevent the body from being passed
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_set_header X-Original-Method $request_method;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://keycloak:8080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -68,13 +68,13 @@ backend:
|
||||
python manage.py createsuperuser --email admin@example.com --password admin
|
||||
restartPolicy: Never
|
||||
|
||||
# Exra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||
extraVolumeMounts:
|
||||
- name: certs
|
||||
mountPath: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem
|
||||
mountPath: /app/.venv/lib/python3.13/site-packages/certifi/cacert.pem
|
||||
subPath: cacert.pem
|
||||
|
||||
# Exra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||
extraVolumes:
|
||||
- name: certs
|
||||
configMap:
|
||||
|
||||
@@ -87,12 +87,12 @@ If you are using an external service, you need to set `REDIS_URL` environment va
|
||||
|
||||
Generate a secure key for `LIVEKIT_API_SECRET` in `env.d/common`.
|
||||
|
||||
We provide a minimal recommanded config for production environment in `livekit-server.yaml`. Set the previously generated API secret key in the config file.
|
||||
We provide a minimal recommended config for production environment in `livekit-server.yaml`. Set the previously generated API secret key in the config file.
|
||||
|
||||
To view other customization options, see [config-sample.yaml](https://github.com/livekit/livekit/blob/master/config-sample.yaml)
|
||||
|
||||
> [!NOTE]
|
||||
> In this example, we configured multiplexing on a single UDP port. For better performances, you can configure a range of UDP ports.
|
||||
> In this example, we configured multiplexing on a single UDP port. For better performance, you can configure a range of UDP ports.
|
||||
|
||||
### Meet
|
||||
|
||||
|
||||
@@ -122,11 +122,11 @@ If you haven't run the script **bin/start-kind.sh**, you'll need to manually cre
|
||||
$ kubectl create namespace meet
|
||||
```
|
||||
|
||||
If you have already run the script, you can skip this step and proceed to the next instruction. NOTE: Before you proceed, and is using the kind method, make sure you download this repo examples/ directory and its contents to the location where you will be executing the helm command. Helm will look for "examples/<name>values.yaml" from based on the path it is being executed.
|
||||
If you have already run the script, you can skip this step and proceed to the next instruction. NOTE: Before you proceed, and is using the kind method, make sure you download this repo examples/helm directory and its contents to the location where you will be executing the helm command. Helm will look for "examples/helm/<name>values.yaml" from based on the path it is being executed.
|
||||
|
||||
```
|
||||
$ kubectl config set-context --current --namespace=meet
|
||||
$ helm install keycloak oci://registry-1.docker.io/bitnamicharts/keycloak -f examples/keycloak.values.yaml
|
||||
$ helm install keycloak oci://registry-1.docker.io/bitnamicharts/keycloak -f examples/helm/keycloak.values.yaml
|
||||
$ #wait until
|
||||
$ kubectl get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
@@ -150,7 +150,7 @@ OIDC_RP_SIGN_ALGO: RS256
|
||||
OIDC_RP_SCOPES: "openid email"
|
||||
```
|
||||
|
||||
You can find these values in **examples/keycloak.values.yaml**
|
||||
You can find these values in **examples/helm/keycloak.values.yaml**
|
||||
|
||||
### Find livekit server connexion values
|
||||
|
||||
@@ -159,7 +159,7 @@ LaSuite Meet use livekit for streaming part so if you have a livekit provider, o
|
||||
Livekit need a redis (and meet too) so we will start by deploying a redis :
|
||||
|
||||
```
|
||||
$ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/redis.values.yaml
|
||||
$ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/helm/redis.values.yaml
|
||||
$ kubectl get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
keycloak-0 1/1 Running 0 26m
|
||||
@@ -172,7 +172,7 @@ When the redis is ready we can deploy livekit-server.
|
||||
```
|
||||
$ helm repo add livekit https://helm.livekit.io
|
||||
$ helm repo update
|
||||
$ helm install livekit livekit/livekit-server -f examples/livekit.values.yaml
|
||||
$ helm install livekit livekit/livekit-server -f examples/helm/livekit.values.yaml
|
||||
$ kubectl get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
keycloak-0 1/1 Running 0 30m
|
||||
@@ -199,7 +199,7 @@ CELERY_RESULT_BACKEND: redis://default:pass@redis-master:6379/1
|
||||
LaSuite Meet uses a postgresql db as backend so if you have a provider, obtain the necessary information to use it. If you do not have, you can install a postgresql testing environment as follows:
|
||||
|
||||
```
|
||||
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml
|
||||
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/helm/postgresql.values.yaml
|
||||
$ kubectl get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
keycloak-0 1/1 Running 0 45m
|
||||
@@ -226,7 +226,7 @@ Now you are ready to deploy LaSuite Meet without AI. AI required more dependenci
|
||||
```
|
||||
$ helm repo add meet https://suitenumerique.github.io/meet/
|
||||
$ helm repo update
|
||||
$ helm install meet meet/meet -f examples/meet.values.yaml
|
||||
$ helm install meet meet/meet -f examples/helm/meet.values.yaml
|
||||
```
|
||||
|
||||
## Test your deployment
|
||||
|
||||
@@ -23,9 +23,12 @@ MEET_BASE_URL="http://localhost:8072"
|
||||
|
||||
# Media
|
||||
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
AWS_S3_DOMAIN_REPLACE=http://localhost:9000
|
||||
AWS_S3_ENDPOINT_URL=http://minio:9000
|
||||
AWS_S3_ACCESS_KEY_ID=meet
|
||||
AWS_S3_SECRET_ACCESS_KEY=password
|
||||
MEDIA_BASE_URL=http://localhost:8083
|
||||
FILE_UPLOAD_ENABLED=True
|
||||
|
||||
# OIDC
|
||||
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs
|
||||
|
||||
@@ -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.9.0"
|
||||
version = "1.10.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
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
"""API filters for meet' core application."""
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import django_filters
|
||||
from django_filters import BooleanFilter
|
||||
|
||||
from core import models
|
||||
|
||||
|
||||
class FileFilter(django_filters.FilterSet):
|
||||
"""
|
||||
Custom filter for filtering files.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = models.File
|
||||
fields = ["type"]
|
||||
|
||||
|
||||
class ListFileFilter(FileFilter):
|
||||
"""Filter class dedicated to the file viewset list method."""
|
||||
|
||||
is_creator_me = django_filters.BooleanFilter(
|
||||
method="filter_is_creator_me", label=_("Creator is me")
|
||||
)
|
||||
|
||||
is_deleted = BooleanFilter(field_name="deleted_at", method="filter_is_deleted")
|
||||
|
||||
class Meta:
|
||||
model = models.File
|
||||
fields = ["is_creator_me", "type", "upload_state", "is_deleted"]
|
||||
|
||||
def filter_is_deleted(self, queryset, name, value):
|
||||
"""
|
||||
Filter files based on whether they are deleted or not.
|
||||
|
||||
Example:
|
||||
- /api/v1.0/files/?is_deleted=false
|
||||
→ Filters files that were not deleted
|
||||
"""
|
||||
if value is None:
|
||||
return queryset
|
||||
|
||||
lookup = "__".join([name, "isnull"])
|
||||
return queryset.filter(**{lookup: not value})
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def filter_is_creator_me(self, queryset, name, value):
|
||||
"""
|
||||
Filter files based on the `creator` being the current user.
|
||||
|
||||
Example:
|
||||
- /api/v1.0/files/?is_creator_me=true
|
||||
→ Filters files created by the logged-in user
|
||||
- /api/v1.0/files/?is_creator_me=false
|
||||
→ Filters files created by other users
|
||||
"""
|
||||
user = self.request.user
|
||||
|
||||
if not user.is_authenticated:
|
||||
return queryset
|
||||
|
||||
if value:
|
||||
return queryset.filter(creator=user)
|
||||
|
||||
return queryset.exclude(creator=user)
|
||||
@@ -1,5 +1,8 @@
|
||||
"""Permission handlers for the Meet core app."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import Http404
|
||||
|
||||
from rest_framework import permissions
|
||||
|
||||
from ..models import RoleChoices
|
||||
@@ -106,3 +109,30 @@ class HasLiveKitRoomAccess(permissions.BasePermission):
|
||||
if not request.auth or not hasattr(request.auth, "video"):
|
||||
return False
|
||||
return request.auth.video.room == str(obj.id)
|
||||
|
||||
|
||||
class FilePermission(IsAuthenticated):
|
||||
"""
|
||||
Permissions applying to the file API endpoint.
|
||||
Handling soft deletions specificities
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Allow access only to authenticated users."""
|
||||
if not settings.FILE_UPLOAD_ENABLED:
|
||||
raise Http404
|
||||
|
||||
return super().has_permission(request, view)
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""
|
||||
Return a 404 on deleted files or if the user is not the owner
|
||||
"""
|
||||
|
||||
if obj.deleted_at is not None or obj.hard_deleted_at is not None:
|
||||
raise Http404
|
||||
|
||||
if obj.creator != request.user:
|
||||
raise Http404
|
||||
|
||||
return obj.get_abilities(request.user).get(view.action, False)
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
"""Client serializers for the Meet core app."""
|
||||
|
||||
# pylint: disable=abstract-method,no-name-in-module
|
||||
import logging
|
||||
from os.path import splitext
|
||||
from typing import Literal
|
||||
from urllib.parse import quote
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
|
||||
# pylint: disable=abstract-method,no-name-in-module
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from django_pydantic_field.rest_framework import SchemaField
|
||||
@@ -15,6 +20,8 @@ from timezone_field.rest_framework import TimeZoneSerializerField
|
||||
|
||||
from core import models, utils
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
"""Serialize users."""
|
||||
@@ -27,6 +34,15 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
||||
|
||||
|
||||
class UserLightSerializer(serializers.ModelSerializer):
|
||||
"""Serialize users with limited fields."""
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["id", "full_name", "short_name"]
|
||||
read_only_fields = ["id", "full_name", "short_name"]
|
||||
|
||||
|
||||
class ResourceAccessSerializerMixin:
|
||||
"""
|
||||
A serializer mixin to share controlling that the logged-in user submitting a room access object
|
||||
@@ -377,3 +393,143 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
||||
)
|
||||
|
||||
return attrs
|
||||
|
||||
|
||||
class ListFileSerializer(serializers.ModelSerializer):
|
||||
"""Serialize File model for the API."""
|
||||
|
||||
url = serializers.SerializerMethodField(read_only=True)
|
||||
creator = UserLightSerializer(read_only=True)
|
||||
abilities = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.File
|
||||
fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"title",
|
||||
"type",
|
||||
"creator",
|
||||
"deleted_at",
|
||||
"hard_deleted_at",
|
||||
"filename",
|
||||
"upload_state",
|
||||
"mimetype",
|
||||
"size",
|
||||
"description",
|
||||
"url",
|
||||
"abilities",
|
||||
]
|
||||
read_only_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"creator",
|
||||
"deleted_at",
|
||||
"hard_deleted_at",
|
||||
"filename",
|
||||
"upload_state",
|
||||
"mimetype",
|
||||
"size",
|
||||
"url",
|
||||
"abilities",
|
||||
]
|
||||
|
||||
def get_url(self, obj):
|
||||
"""Return the URL of the file."""
|
||||
if obj.is_pending_upload:
|
||||
return None
|
||||
|
||||
return f"{settings.MEDIA_BASE_URL}{settings.MEDIA_URL}{quote(obj.file_key)}"
|
||||
|
||||
def get_abilities(self, file) -> dict:
|
||||
"""Return abilities of the logged-in user on the instance."""
|
||||
request = self.context.get("request")
|
||||
if not request:
|
||||
return {}
|
||||
|
||||
return file.get_abilities(request.user)
|
||||
|
||||
|
||||
class FileSerializer(ListFileSerializer):
|
||||
"""Default serializer File model for the API."""
|
||||
|
||||
def create(self, validated_data):
|
||||
raise NotImplementedError("Create method can not be used.")
|
||||
|
||||
|
||||
class CreateFileSerializer(ListFileSerializer):
|
||||
"""Serializer used to create a new file"""
|
||||
|
||||
title = serializers.CharField(max_length=255, required=False)
|
||||
policy = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = models.File
|
||||
fields = [*ListFileSerializer.Meta.fields, "policy"]
|
||||
read_only_fields = [
|
||||
*(
|
||||
field
|
||||
for field in ListFileSerializer.Meta.read_only_fields
|
||||
if field != "filename"
|
||||
),
|
||||
"policy",
|
||||
]
|
||||
|
||||
def get_fields(self):
|
||||
"""Force the id field to be writable."""
|
||||
fields = super().get_fields()
|
||||
fields["id"].read_only = False
|
||||
|
||||
return fields
|
||||
|
||||
def validate_id(self, value):
|
||||
"""Ensure the provided ID does not already exist when creating a new file."""
|
||||
request = self.context.get("request")
|
||||
|
||||
# Only check this on POST (creation)
|
||||
if request and models.File.objects.filter(id=value).exists():
|
||||
raise serializers.ValidationError(
|
||||
"A file with this ID already exists. You cannot override it.",
|
||||
code="file_create_existing_id",
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Validate extension and fill title."""
|
||||
# we run the default validation first to make sure the base data in attrs is ok
|
||||
attrs = super().validate(attrs)
|
||||
|
||||
filename_root, ext = splitext(attrs["filename"])
|
||||
|
||||
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
|
||||
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[attrs["type"]]
|
||||
if ext.lower() not in config_for_file_type["allowed_extensions"]:
|
||||
logger.info(
|
||||
"create_item: file extension not allowed %s for filename %s",
|
||||
ext,
|
||||
attrs["filename"],
|
||||
)
|
||||
raise serializers.ValidationError(
|
||||
{"filename": _("This file extension is not allowed.")},
|
||||
code="item_create_file_extension_not_allowed",
|
||||
)
|
||||
|
||||
# The title will be the filename if not provided
|
||||
if not attrs.get("title", None):
|
||||
attrs["title"] = filename_root
|
||||
|
||||
return attrs
|
||||
|
||||
def get_policy(self, file):
|
||||
"""Return the policy to use if the item is a file."""
|
||||
|
||||
if file.upload_state == models.FileUploadStateChoices.READY:
|
||||
return None
|
||||
|
||||
return utils.generate_upload_policy(file)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
raise NotImplementedError("Update method can not be used.")
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Throttling modules for the API."""
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from lasuite.drf.throttling import MonitoredThrottleMixin
|
||||
from rest_framework.throttling import AnonRateThrottle
|
||||
from rest_framework.throttling import AnonRateThrottle, UserRateThrottle
|
||||
from sentry_sdk import capture_message
|
||||
|
||||
|
||||
@@ -14,11 +16,58 @@ class MonitoredAnonRateThrottle(MonitoredThrottleMixin, AnonRateThrottle):
|
||||
"""Throttle for the monitored scoped rate throttle."""
|
||||
|
||||
|
||||
class MonitoredUserRateThrottle(MonitoredThrottleMixin, UserRateThrottle):
|
||||
"""Throttle for the monitored scoped rate throttle."""
|
||||
|
||||
|
||||
class RequestEntryAuthenticatedUserRateThrottle(MonitoredUserRateThrottle):
|
||||
"""Throttle authenticated user requesting room entry"""
|
||||
|
||||
scope = "request_entry"
|
||||
|
||||
def get_cache_key(self, request, view):
|
||||
"""Use the authenticated user ID as the throttle cache key."""
|
||||
|
||||
if request.user and not request.user.is_authenticated:
|
||||
return None # Defer to RequestEntryAnonRateThrottle for anonymous users.
|
||||
|
||||
return super().get_cache_key(request, view)
|
||||
|
||||
|
||||
class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||
"""Throttle Anonymous user requesting room entry"""
|
||||
|
||||
scope = "request_entry"
|
||||
|
||||
def get_cache_key(self, request, view):
|
||||
"""Use the lobby participant cookie ID as the throttle cache key.
|
||||
|
||||
Only throttle if a cookie is already set. If no cookie exists yet,
|
||||
return None to skip throttling — the cookie will be set on the first
|
||||
response, and throttling will apply from the second request onward.
|
||||
|
||||
Keying on the cookie rather than the IP address prevents penalising
|
||||
multiple users behind the same NAT/proxy, and is consistent with how
|
||||
LobbyService identifies participants.
|
||||
|
||||
Note: as per DRF documentation, application-level throttling is not a
|
||||
security measure against brute-force or DoS attacks. This throttle exists
|
||||
solely to guard against accidental hammering from buggy clients.
|
||||
"""
|
||||
|
||||
if request.user and request.user.is_authenticated:
|
||||
return None # Only throttle unauthenticated requests.
|
||||
|
||||
participant_id = request.COOKIES.get(settings.LOBBY_COOKIE_NAME)
|
||||
|
||||
if participant_id is None:
|
||||
return None # No throttling for cookieless requests
|
||||
|
||||
return self.cache_format % {
|
||||
"scope": self.scope,
|
||||
"ident": participant_id,
|
||||
}
|
||||
|
||||
|
||||
class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||
"""Throttle Anonymous user requesting room generation callback"""
|
||||
|
||||
@@ -1,16 +1,26 @@
|
||||
"""API endpoints"""
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import uuid
|
||||
from logging import getLogger
|
||||
from urllib.parse import urlparse
|
||||
from urllib.parse import unquote, urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.storage import default_storage
|
||||
from django.db.models import Q
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from rest_framework import decorators, mixins, pagination, viewsets
|
||||
from django_filters import rest_framework as django_filters
|
||||
from rest_framework import (
|
||||
decorators,
|
||||
filters,
|
||||
mixins,
|
||||
pagination,
|
||||
viewsets,
|
||||
)
|
||||
from rest_framework import (
|
||||
exceptions as drf_exceptions,
|
||||
)
|
||||
@@ -22,10 +32,13 @@ from rest_framework import (
|
||||
)
|
||||
|
||||
from core import enums, models, utils
|
||||
from core.api.filters import ListFileFilter
|
||||
from core.enums import MEDIA_STORAGE_URL_PATTERN
|
||||
from core.recording.enums import FileExtension
|
||||
from core.recording.event.authentication import StorageEventAuthentication
|
||||
from core.recording.event.exceptions import (
|
||||
InvalidBucketError,
|
||||
InvalidFilepathError,
|
||||
InvalidFileTypeError,
|
||||
ParsingEventDataError,
|
||||
)
|
||||
@@ -56,6 +69,7 @@ from core.services.participants_management import (
|
||||
)
|
||||
from core.services.room_creation import RoomCreation
|
||||
from core.services.subtitle import SubtitleException, SubtitleService
|
||||
from core.tasks.file import process_file_deletion
|
||||
|
||||
from ..authentication.livekit import LiveKitTokenAuthentication
|
||||
from . import permissions, serializers, throttling
|
||||
@@ -77,20 +91,20 @@ class NestedGenericViewSet(viewsets.GenericViewSet):
|
||||
lookup_fields: list[str] = ["pk"]
|
||||
lookup_url_kwargs: list[str] = []
|
||||
|
||||
def __getattribute__(self, item):
|
||||
def __getattribute__(self, file):
|
||||
"""
|
||||
This method is overridden to allow to get the last lookup field or lookup url kwarg
|
||||
when accessing the `lookup_field` or `lookup_url_kwarg` attribute. This is useful
|
||||
to keep compatibility with all methods used by the parent class `GenericViewSet`.
|
||||
"""
|
||||
if item in ["lookup_field", "lookup_url_kwarg"]:
|
||||
return getattr(self, item + "s", [None])[-1]
|
||||
if file in ["lookup_field", "lookup_url_kwarg"]:
|
||||
return getattr(self, file + "s", [None])[-1]
|
||||
|
||||
return super().__getattribute__(item)
|
||||
return super().__getattribute__(file)
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Get the list of items for this view.
|
||||
Get the list of files for this view.
|
||||
|
||||
`lookup_fields` attribute is enumerated here to perform the nested lookup.
|
||||
"""
|
||||
@@ -201,6 +215,7 @@ class RoomViewSet(
|
||||
API endpoints to access and perform actions on rooms.
|
||||
"""
|
||||
|
||||
pagination_class = Pagination
|
||||
permission_classes = [permissions.RoomPermissions]
|
||||
queryset = models.Room.objects.all()
|
||||
serializer_class = serializers.RoomSerializer
|
||||
@@ -369,7 +384,10 @@ class RoomViewSet(
|
||||
methods=["post"],
|
||||
url_path="request-entry",
|
||||
permission_classes=[],
|
||||
throttle_classes=[throttling.RequestEntryAnonRateThrottle],
|
||||
throttle_classes=[
|
||||
throttling.RequestEntryAuthenticatedUserRateThrottle,
|
||||
throttling.RequestEntryAnonRateThrottle,
|
||||
],
|
||||
)
|
||||
def request_entry(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Request entry to a room"""
|
||||
@@ -470,9 +488,7 @@ class RoomViewSet(
|
||||
if status_code == drf_status.HTTP_500_INTERNAL_SERVER_ERROR:
|
||||
raise e
|
||||
|
||||
return drf_response.Response(
|
||||
{"status": "error", "message": str(e)}, status=status_code
|
||||
)
|
||||
return drf_response.Response({"status": "error"}, status=status_code)
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
@@ -739,14 +755,19 @@ class RecordingViewSet(
|
||||
recording_id = parser.get_recording_id(request.data)
|
||||
|
||||
except ParsingEventDataError as e:
|
||||
raise drf_exceptions.PermissionDenied(f"Invalid request data: {e}") from e
|
||||
raise drf_exceptions.PermissionDenied("Invalid request data.") from e
|
||||
|
||||
except InvalidBucketError as e:
|
||||
raise drf_exceptions.PermissionDenied("Invalid bucket specified") from e
|
||||
raise drf_exceptions.PermissionDenied("Invalid bucket specified.") from e
|
||||
|
||||
except InvalidFileTypeError as e:
|
||||
except InvalidFilepathError:
|
||||
return drf_response.Response(
|
||||
{"message": f"Ignore this file type, {e}"},
|
||||
{"message": "Notification ignored."},
|
||||
)
|
||||
|
||||
except InvalidFileTypeError:
|
||||
return drf_response.Response(
|
||||
{"message": "Notification ignored."},
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -793,7 +814,7 @@ class RecordingViewSet(
|
||||
# Extract the original URL from the request header
|
||||
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
|
||||
if not original_url:
|
||||
logger.debug("Missing HTTP_X_ORIGINAL_URL header in subrequest")
|
||||
logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest")
|
||||
raise drf_exceptions.PermissionDenied()
|
||||
|
||||
logger.debug("Original url: '%s'", original_url)
|
||||
@@ -810,7 +831,7 @@ class RecordingViewSet(
|
||||
try:
|
||||
return match.groupdict()
|
||||
except (ValueError, AttributeError) as exc:
|
||||
logger.debug("Failed to extract parameters from subrequest URL: %s", exc)
|
||||
logger.warning("Failed to extract parameters from subrequest URL: %s", exc)
|
||||
raise drf_exceptions.PermissionDenied() from exc
|
||||
|
||||
@decorators.action(detail=False, methods=["get"], url_path="media-auth")
|
||||
@@ -834,7 +855,7 @@ class RecordingViewSet(
|
||||
recording_id = url_params["recording_id"]
|
||||
|
||||
extension = url_params["extension"]
|
||||
if extension not in [item.value for item in FileExtension]:
|
||||
if extension not in [file.value for file in FileExtension]:
|
||||
raise drf_exceptions.ValidationError({"detail": "Unsupported extension."})
|
||||
|
||||
try:
|
||||
@@ -858,3 +879,324 @@ class RecordingViewSet(
|
||||
request = utils.generate_s3_authorization_headers(recording.key)
|
||||
|
||||
return drf_response.Response("authorized", headers=request.headers, status=200)
|
||||
|
||||
|
||||
# pylint: disable=too-many-public-methods
|
||||
class FileViewSet(
|
||||
SerializerPerActionMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
FileViewSet API.
|
||||
|
||||
This viewset provides CRUD operations and additional actions for managing files.
|
||||
|
||||
### API Endpoints:
|
||||
1. **List**: Retrieve a paginated list of files.
|
||||
Example: GET /files/?page=2
|
||||
2. **Retrieve**: Get a specific file by its ID.
|
||||
Example: GET /files/{id}/
|
||||
3. **Create**: Create a new file.
|
||||
Example: POST /files/
|
||||
4. **Update**: Update a file by its ID.
|
||||
Example: PUT /files/{id}/
|
||||
5. **Delete**: Soft delete a file by its ID.
|
||||
Example: DELETE /files/{id}/
|
||||
|
||||
|
||||
### Ordering: created_at, updated_at, title
|
||||
|
||||
Example:
|
||||
- Ascending: GET /api/v1.0/files/?ordering=created_at
|
||||
|
||||
### Filtering:
|
||||
- `is_creator_me=true`: Returns files created by the current user.
|
||||
- `is_creator_me=false`: Returns files created by other users.
|
||||
- `is_deleted=false`: Returns files that are not (soft) deleted
|
||||
|
||||
Example:
|
||||
- GET /api/v1.0/files/?is_creator_me=true
|
||||
- GET /api/v1.0/files/?is_creator_me=false&is_deleted=false
|
||||
|
||||
### Notes:
|
||||
- Implements soft delete logic to retain file
|
||||
"""
|
||||
|
||||
ordering = ["-updated_at"]
|
||||
ordering_fields = ["created_at", "updated_at", "title"]
|
||||
pagination_class = Pagination
|
||||
permission_classes = [
|
||||
permissions.FilePermission,
|
||||
]
|
||||
queryset = models.File.objects.filter(hard_deleted_at__isnull=True)
|
||||
default_serializer_class = serializers.FileSerializer
|
||||
serializer_classes = {
|
||||
"list": serializers.ListFileSerializer,
|
||||
"create": serializers.CreateFileSerializer,
|
||||
}
|
||||
filter_backends = (django_filters.DjangoFilterBackend, filters.OrderingFilter)
|
||||
filterset_class = ListFileFilter
|
||||
|
||||
def get_queryset(self):
|
||||
"""Get queryset that defaults to the current request user."""
|
||||
user = self.request.user
|
||||
queryset = super().get_queryset().select_related("creator")
|
||||
|
||||
if not user.is_authenticated:
|
||||
return queryset.none()
|
||||
|
||||
# For now, we force the filtering on the current user in all cases, might evolve later
|
||||
queryset = queryset.filter(creator=user)
|
||||
return queryset
|
||||
|
||||
def get_response_for_queryset(self, queryset, context=None):
|
||||
"""Return paginated response for the queryset if requested."""
|
||||
context = context or self.get_serializer_context()
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True, context=context)
|
||||
result = self.get_paginated_response(serializer.data)
|
||||
return result
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True, context=context)
|
||||
return drf_response.Response(serializer.data)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Set the current user as creator of the newly created file."""
|
||||
|
||||
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
|
||||
file_type = serializer.validated_data["type"]
|
||||
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[file_type]
|
||||
|
||||
count = models.File.objects.filter(
|
||||
creator=self.request.user,
|
||||
deleted_at__isnull=True,
|
||||
type=file_type,
|
||||
).count()
|
||||
|
||||
if count >= config_for_file_type["max_count_by_user"]:
|
||||
logger.info(
|
||||
"create_item: user reached max files per user for type %s",
|
||||
file_type,
|
||||
)
|
||||
raise serializers.PermissionDenied(
|
||||
_("You have reached the maximum number of files for this type.")
|
||||
)
|
||||
|
||||
serializer.save(creator=self.request.user)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Override to implement a soft delete instead of dumping the record in database."""
|
||||
instance.soft_delete()
|
||||
|
||||
@decorators.action(detail=True, methods=["post"], url_path="upload-ended")
|
||||
@FeatureFlag.require("file_upload")
|
||||
def upload_ended(self, request, *args, **kwargs):
|
||||
"""
|
||||
Check the actual uploaded file and mark it as ready.
|
||||
"""
|
||||
|
||||
file = self.get_object()
|
||||
|
||||
if not file.is_pending_upload:
|
||||
raise drf_exceptions.ValidationError(
|
||||
{"file": "This action is only available for files in PENDING state."},
|
||||
code="file_upload_state_not_pending",
|
||||
)
|
||||
|
||||
s3_client = default_storage.connection.meta.client
|
||||
|
||||
head_response = s3_client.head_object(
|
||||
Bucket=default_storage.bucket_name, Key=file.file_key
|
||||
)
|
||||
file_size = head_response["ContentLength"]
|
||||
|
||||
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
|
||||
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[file.type]
|
||||
if file_size > config_for_file_type["max_size"]:
|
||||
self._complete_file_deletion(file)
|
||||
logger.info(
|
||||
"upload_ended: file size (%s) for file %s higher than the allowed max size",
|
||||
file_size,
|
||||
file.file_key,
|
||||
)
|
||||
raise drf_exceptions.ValidationError(
|
||||
detail="The file size is higher than the allowed max size.",
|
||||
code="file_size_exceeded",
|
||||
)
|
||||
|
||||
# python-magic recommends using at least the first 2048 bytes
|
||||
# to reduce incorrect identification.
|
||||
# This is a tradeoff between pulling in the whole file and the most likely relevant bytes
|
||||
# of the file for mime type identification.
|
||||
if file_size > 2048:
|
||||
range_response = s3_client.get_object(
|
||||
Bucket=default_storage.bucket_name,
|
||||
Key=file.file_key,
|
||||
Range="bytes=0-2047",
|
||||
)
|
||||
file_head = range_response["Body"].read()
|
||||
else:
|
||||
file_head = s3_client.get_object(
|
||||
Bucket=default_storage.bucket_name, Key=file.file_key
|
||||
)["Body"].read()
|
||||
|
||||
# Use improved MIME type detection combining magic bytes and file extension
|
||||
logger.info("upload_ended: detecting mimetype for file: %s", file.file_key)
|
||||
mimetype = utils.detect_mimetype(file_head, filename=file.filename)
|
||||
|
||||
if settings.FILE_UPLOAD_APPLY_RESTRICTIONS:
|
||||
config_for_file_type = settings.FILE_UPLOAD_RESTRICTIONS[file.type]
|
||||
allowed_file_mimetypes = config_for_file_type["allowed_mimetypes"]
|
||||
if mimetype not in allowed_file_mimetypes:
|
||||
self._complete_file_deletion(file)
|
||||
logger.warning(
|
||||
"upload_ended: mimetype not allowed %s for file %s",
|
||||
mimetype,
|
||||
file.file_key,
|
||||
)
|
||||
raise drf_exceptions.ValidationError(
|
||||
detail="The file type is not allowed.",
|
||||
code="file_type_not_allowed",
|
||||
)
|
||||
|
||||
file.upload_state = models.FileUploadStateChoices.READY
|
||||
file.mimetype = mimetype
|
||||
file.size = file_size
|
||||
|
||||
file.save(update_fields=["upload_state", "mimetype", "size"])
|
||||
|
||||
if head_response["ContentType"] != mimetype:
|
||||
logger.info(
|
||||
"upload_ended: content type mismatch between object storage and file,"
|
||||
" updating from %s to %s",
|
||||
head_response["ContentType"],
|
||||
mimetype,
|
||||
)
|
||||
s3_client.copy_object(
|
||||
Bucket=default_storage.bucket_name,
|
||||
Key=file.file_key,
|
||||
CopySource={
|
||||
"Bucket": default_storage.bucket_name,
|
||||
"Key": file.file_key,
|
||||
},
|
||||
ContentType=mimetype,
|
||||
Metadata=head_response["Metadata"],
|
||||
MetadataDirective="REPLACE",
|
||||
)
|
||||
|
||||
# Not yet implemented
|
||||
# Change the file.upload_state when this will be done
|
||||
# malware_detection.analyse_file(file.file_key, file_id=file.id)
|
||||
|
||||
serializer = self.get_serializer(file)
|
||||
|
||||
return drf_response.Response(serializer.data, status=drf_status.HTTP_200_OK)
|
||||
|
||||
def _complete_file_deletion(self, file):
|
||||
"""Delete a file completely."""
|
||||
file.soft_delete()
|
||||
file.hard_delete()
|
||||
process_file_deletion.delay(file.id)
|
||||
|
||||
def _authorize_subrequest(self, request, pattern):
|
||||
"""
|
||||
Authorize access based on the original URL of an Nginx subrequest
|
||||
and user permissions. Returns a dictionary of URL parameters if authorized.
|
||||
|
||||
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
|
||||
See corresponding ingress configuration in Helm chart and read about the
|
||||
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
|
||||
is configured to do this.
|
||||
|
||||
Based on the original url and the logged in user, we must decide if we authorize Nginx
|
||||
to let this request go through (by returning a 200 code) or if we block it (by returning
|
||||
a 403 error). Note that we return 403 errors without any further details for security
|
||||
reasons.
|
||||
|
||||
Parameters:
|
||||
- pattern: The regex pattern to extract identifiers from the URL.
|
||||
|
||||
Returns:
|
||||
- A dictionary of URL parameters if the request is authorized.
|
||||
Raises:
|
||||
- PermissionDenied if authorization fails.
|
||||
"""
|
||||
# Extract the original URL from the request header
|
||||
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
|
||||
if not original_url:
|
||||
logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest")
|
||||
raise drf_exceptions.PermissionDenied()
|
||||
|
||||
parsed_url = urlparse(original_url)
|
||||
match = pattern.search(unquote(parsed_url.path))
|
||||
|
||||
if not match:
|
||||
logger.warning(
|
||||
"Subrequest URL '%s' did not match pattern '%s'",
|
||||
parsed_url.path,
|
||||
pattern,
|
||||
)
|
||||
raise drf_exceptions.PermissionDenied()
|
||||
|
||||
try:
|
||||
url_params = match.groupdict()
|
||||
except (ValueError, AttributeError) as exc:
|
||||
logger.warning("Failed to extract parameters from subrequest URL: %s", exc)
|
||||
raise drf_exceptions.PermissionDenied() from exc
|
||||
|
||||
pk = url_params.get("pk")
|
||||
if not pk:
|
||||
logger.warning("File ID (pk) not found in URL parameters: %s", url_params)
|
||||
raise drf_exceptions.PermissionDenied()
|
||||
|
||||
# Fetch the file and check if the user has access
|
||||
queryset = models.File.objects.all()
|
||||
# No suspicious analysis implemented yet
|
||||
# queryset = self._filter_suspicious_files(queryset, request.user)
|
||||
try:
|
||||
file = queryset.get(pk=pk)
|
||||
except models.File.DoesNotExist as exc:
|
||||
logger.warning("File with ID '%s' does not exist", pk)
|
||||
raise drf_exceptions.PermissionDenied() from exc
|
||||
|
||||
user_abilities = file.get_abilities(request.user)
|
||||
if not user_abilities.get(self.action, False):
|
||||
logger.warning(
|
||||
"User '%s' lacks permission for file '%s'", request.user.id, pk
|
||||
)
|
||||
raise drf_exceptions.PermissionDenied()
|
||||
|
||||
logger.debug(
|
||||
"Subrequest authorization successful. Extracted parameters: %s", url_params
|
||||
)
|
||||
return url_params, request.user.id, file
|
||||
|
||||
@decorators.action(detail=False, methods=["get"], url_path="media-auth")
|
||||
@FeatureFlag.require("file_upload")
|
||||
def media_auth(self, request, *args, **kwargs):
|
||||
"""
|
||||
This view is used by an Nginx subrequest to control access to an file's
|
||||
attachment file.
|
||||
|
||||
When we let the request go through, we compute authorization headers that will be added to
|
||||
the request going through thanks to the nginx.ingress.kubernetes.io/auth-response-headers
|
||||
annotation. The request will then be proxied to the object storage backend who will
|
||||
respond with the file after checking the signature included in headers.
|
||||
"""
|
||||
url_params, _, file = self._authorize_subrequest(
|
||||
request, MEDIA_STORAGE_URL_PATTERN
|
||||
)
|
||||
|
||||
if file.is_pending_upload:
|
||||
logger.warning("File '%s' is not ready", file.id)
|
||||
raise drf_exceptions.PermissionDenied()
|
||||
|
||||
# Generate S3 authorization headers using the extracted URL parameters
|
||||
request = utils.generate_s3_authorization_headers(f"{url_params.get('key'):s}")
|
||||
|
||||
return drf_response.Response("authorized", headers=request.headers, status=200)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
Core application factories
|
||||
"""
|
||||
|
||||
from io import BytesIO
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.core.files.storage import default_storage
|
||||
from django.utils.text import slugify
|
||||
|
||||
import factory.fuzzy
|
||||
@@ -126,7 +129,7 @@ class ApplicationFactory(factory.django.DjangoModelFactory):
|
||||
model = models.Application
|
||||
|
||||
name = factory.Faker("company")
|
||||
active = True
|
||||
is_active = True
|
||||
client_id = factory.LazyFunction(utils.generate_client_id)
|
||||
client_secret = factory.LazyFunction(utils.generate_client_secret)
|
||||
scopes = []
|
||||
@@ -153,3 +156,42 @@ class ApplicationDomainFactory(factory.django.DjangoModelFactory):
|
||||
|
||||
domain = factory.Faker("domain_name")
|
||||
application = factory.SubFactory(ApplicationFactory)
|
||||
|
||||
|
||||
class FileFactory(factory.django.DjangoModelFactory):
|
||||
"""A factory to create files"""
|
||||
|
||||
class Meta:
|
||||
model = models.File
|
||||
skip_postgeneration_save = True
|
||||
|
||||
title = factory.Sequence(lambda n: f"file{n}")
|
||||
creator = factory.SubFactory(UserFactory)
|
||||
deleted_at = None
|
||||
type = factory.fuzzy.FuzzyChoice([t[0] for t in models.FileTypeChoices.choices])
|
||||
filename = factory.lazy_attribute(lambda o: fake.file_name())
|
||||
upload_state = None
|
||||
size = None
|
||||
|
||||
@factory.post_generation
|
||||
def update_upload_state(self, create, extracted, **kwargs):
|
||||
"""Change the upload state of a file."""
|
||||
if create and extracted:
|
||||
self.upload_state = extracted
|
||||
self.save()
|
||||
|
||||
@factory.post_generation
|
||||
def upload_bytes(self, create, extracted, **kwargs):
|
||||
"""Save content of the file into the storage"""
|
||||
if create and extracted is not None:
|
||||
content = (
|
||||
extracted
|
||||
if isinstance(extracted, bytes)
|
||||
else str(extracted).encode("utf-8")
|
||||
)
|
||||
|
||||
self.filename = kwargs.get("filename", self.filename or "content.txt")
|
||||
self.size = len(content)
|
||||
self.save()
|
||||
|
||||
default_storage.save(self.file_key, BytesIO(content))
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# Generated by Django 5.2.11 on 2026-03-03 15:22
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0016_recording_options'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='File',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('type', models.CharField(choices=[('background_image', 'Background image')], max_length=25)),
|
||||
('title', models.CharField(max_length=255, verbose_name='title')),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('hard_deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('filename', models.CharField(max_length=255)),
|
||||
('upload_state', models.CharField(choices=[('pending', 'Pending'), ('ready', 'Ready')], max_length=25)),
|
||||
('mimetype', models.CharField(blank=True, max_length=255, null=True)),
|
||||
('size', models.BigIntegerField(blank=True, null=True)),
|
||||
('description', models.TextField(blank=True, null=True)),
|
||||
('malware_detection_info', models.JSONField(blank=True, default=dict, help_text='Malware detection info when the analysis status is unsafe.', null=True)),
|
||||
('creator', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.RESTRICT, related_name='files_created', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'File',
|
||||
'verbose_name_plural': 'Files',
|
||||
'db_table': 'file',
|
||||
'ordering': ('created_at',),
|
||||
'indexes': [models.Index(fields=['creator', 'type', '-created_at'], name='file_creator_730cce_idx')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.12 on 2026-03-11 14:39
|
||||
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0017_file'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='application',
|
||||
old_name='active',
|
||||
new_name='is_active',
|
||||
),
|
||||
]
|
||||
+188
-2
@@ -1,11 +1,14 @@
|
||||
"""
|
||||
Declare and configure the models for the Meet core application
|
||||
# pylint: disable=too-many-lines
|
||||
"""
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from logging import getLogger
|
||||
from os.path import splitext
|
||||
from typing import List, Optional
|
||||
|
||||
from django.conf import settings
|
||||
@@ -14,7 +17,7 @@ from django.contrib.auth.base_user import AbstractBaseUser
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core import mail, validators
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.db import models
|
||||
from django.db import models, transaction
|
||||
from django.utils import timezone
|
||||
from django.utils.text import capfirst, slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -756,7 +759,7 @@ class Application(BaseModel):
|
||||
verbose_name=_("Application name"),
|
||||
help_text=_("Descriptive name for this application."),
|
||||
)
|
||||
active = models.BooleanField(default=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
client_id = models.CharField(
|
||||
max_length=100, unique=True, default=utils.generate_client_id
|
||||
)
|
||||
@@ -829,3 +832,186 @@ class ApplicationDomain(BaseModel):
|
||||
|
||||
self.domain = self.domain.lower().strip()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class FileUploadStateChoices(models.TextChoices):
|
||||
"""Possible states of a file."""
|
||||
|
||||
PENDING = "pending", _("Pending")
|
||||
# Commented out for now, as we may need this when we implement the malware detection logic.
|
||||
# ANALYZING = "analyzing", _("Analyzing")
|
||||
# SUSPICIOUS = "suspicious", _("Suspicious")
|
||||
# FILE_TOO_LARGE_TO_ANALYZE = (
|
||||
# "file_too_large_to_analyze",
|
||||
# _("File too large to analyze"),
|
||||
# )
|
||||
READY = "ready", _("Ready")
|
||||
|
||||
|
||||
class FileTypeChoices(models.TextChoices):
|
||||
"""Defines the possible types of a file."""
|
||||
|
||||
BACKGROUND_IMAGE = "background_image", _("Background image")
|
||||
|
||||
|
||||
class File(BaseModel):
|
||||
"""File uploaded by a user."""
|
||||
|
||||
type = models.CharField(
|
||||
max_length=25,
|
||||
choices=FileTypeChoices.choices,
|
||||
null=False,
|
||||
blank=False,
|
||||
)
|
||||
title = models.CharField(_("title"), max_length=255)
|
||||
creator = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.RESTRICT,
|
||||
related_name="files_created",
|
||||
blank=True,
|
||||
null=True,
|
||||
)
|
||||
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||
hard_deleted_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
filename = models.CharField(max_length=255, null=False, blank=False)
|
||||
|
||||
upload_state = models.CharField(
|
||||
max_length=25,
|
||||
choices=FileUploadStateChoices.choices,
|
||||
)
|
||||
mimetype = models.CharField(max_length=255, null=True, blank=True)
|
||||
size = models.BigIntegerField(null=True, blank=True)
|
||||
description = models.TextField(null=True, blank=True)
|
||||
malware_detection_info = models.JSONField(
|
||||
null=True,
|
||||
blank=True,
|
||||
default=dict,
|
||||
help_text=_("Malware detection info when the analysis status is unsafe."),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "file"
|
||||
verbose_name = _("File")
|
||||
verbose_name_plural = _("Files")
|
||||
ordering = ("created_at",)
|
||||
indexes = [
|
||||
models.Index(fields=["creator", "type", "-created_at"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return str(self.title)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Set the upload state to pending if it's the first save and it's a file."""
|
||||
|
||||
if self.created_at is None:
|
||||
self.upload_state = FileUploadStateChoices.PENDING
|
||||
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
def delete(self, using=None, keep_parents=False):
|
||||
if self.deleted_at is None:
|
||||
raise RuntimeError("The file must be soft deleted before being deleted.")
|
||||
|
||||
return super().delete(using, keep_parents)
|
||||
|
||||
@property
|
||||
def is_pending_upload(self):
|
||||
"""Return whether the file is in a pending upload state"""
|
||||
return self.upload_state == FileUploadStateChoices.PENDING
|
||||
|
||||
@property
|
||||
def extension(self):
|
||||
"""Return the extension related to the filename."""
|
||||
if self.filename is None:
|
||||
raise RuntimeError(
|
||||
"The file must have a filename to compute its extension."
|
||||
)
|
||||
|
||||
_, extension = splitext(self.filename)
|
||||
|
||||
if extension:
|
||||
return extension.lstrip(".")
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
def key_base(self):
|
||||
"""Key base of the location where the file is stored in object storage."""
|
||||
if not self.pk:
|
||||
raise RuntimeError(
|
||||
"The file instance must be saved before requesting a storage key."
|
||||
)
|
||||
|
||||
return f"{settings.FILE_UPLOAD_PATH}/{self.pk!s}"
|
||||
|
||||
@property
|
||||
def file_key(self):
|
||||
"""Key used to store the file in object storage."""
|
||||
_, extension = splitext(self.filename)
|
||||
# We store only the extension in the storage system to avoid
|
||||
# leaking Personal Information in logs, etc.
|
||||
return f"{self.key_base}{extension!s}"
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""
|
||||
Compute and return abilities for a given user on the file.
|
||||
"""
|
||||
# Characteristics that are based only on specific access
|
||||
is_creator = user == self.creator
|
||||
retrieve = is_creator
|
||||
is_deleted = self.deleted_at is not None
|
||||
can_update = is_creator and not is_deleted and user.is_authenticated
|
||||
can_hard_delete = is_creator and user.is_authenticated
|
||||
can_destroy = can_hard_delete and not is_deleted
|
||||
|
||||
return {
|
||||
"destroy": can_destroy,
|
||||
"hard_delete": can_hard_delete,
|
||||
"retrieve": retrieve,
|
||||
"media_auth": retrieve and not is_deleted,
|
||||
"partial_update": can_update,
|
||||
"update": can_update,
|
||||
"upload_ended": can_update and user.is_authenticated,
|
||||
}
|
||||
|
||||
@transaction.atomic
|
||||
def soft_delete(self):
|
||||
"""
|
||||
Soft delete the file.
|
||||
We still keep the .delete() method untouched for programmatic purposes.
|
||||
"""
|
||||
if self.deleted_at:
|
||||
raise RuntimeError("This file is already deleted.")
|
||||
|
||||
self.deleted_at = timezone.now()
|
||||
self.save(update_fields=["deleted_at"])
|
||||
|
||||
def hard_delete(self):
|
||||
"""
|
||||
Hard delete the file.
|
||||
We still keep the .delete() method untouched for programmatic purposes.
|
||||
"""
|
||||
if self.hard_deleted_at:
|
||||
raise ValidationError(
|
||||
{
|
||||
"hard_deleted_at": ValidationError(
|
||||
_("This file is already hard deleted."),
|
||||
code="file_hard_delete_already_effective",
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
if self.deleted_at is None:
|
||||
raise ValidationError(
|
||||
{
|
||||
"hard_deleted_at": ValidationError(
|
||||
_("To hard delete a file, it must first be soft deleted."),
|
||||
code="file_hard_delete_should_soft_delete_first",
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
self.hard_deleted_at = timezone.now()
|
||||
self.save(update_fields=["hard_deleted_at"])
|
||||
|
||||
@@ -9,6 +9,8 @@ from typing import Any, Dict, Optional, Protocol
|
||||
from django.conf import settings
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
from core.enums import FILE_EXT_REGEX, UUID_REGEX
|
||||
|
||||
from .exceptions import (
|
||||
InvalidBucketError,
|
||||
InvalidFilepathError,
|
||||
@@ -86,7 +88,7 @@ class MinioParser:
|
||||
|
||||
# pylint: disable=line-too-long
|
||||
self._filepath_regex = re.compile(
|
||||
r"(?P<url_encoded_folder_path>(?:[^%]+%2F)+)?(?P<recording_id>[0-9a-fA-F\-]{36})\.(?P<extension>[a-zA-Z0-9]+)"
|
||||
rf"(?P<url_encoded_folder_path>(?:[^%]+%2F)+)?{settings.RECORDING_OUTPUT_FOLDER}%2F(?P<recording_id>{UUID_REGEX})\.(?P<extension>{FILE_EXT_REGEX})"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -16,7 +16,7 @@ class RecordingEventsError(Exception):
|
||||
|
||||
|
||||
class RecordingEventsService:
|
||||
"""Handles recording-related Livekit webhook events."""
|
||||
"""Handles recording-related LiveKit webhook events."""
|
||||
|
||||
@staticmethod
|
||||
def handle_update(recording, egress_status):
|
||||
|
||||
@@ -129,7 +129,7 @@ class LobbyService:
|
||||
) -> Tuple[LobbyParticipant, Optional[Dict]]:
|
||||
"""Request entry to a room for a participant.
|
||||
|
||||
This usual status transitions is:
|
||||
The usual status transitions are:
|
||||
UNKNOWN -> WAITING -> (ACCEPTED | DENIED)
|
||||
|
||||
Flow:
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# ruff: noqa: PLC0415
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
def task(*d_args, **d_kwargs):
|
||||
"""
|
||||
Decorator compatible with Celery's @app.task, but works without Celery.
|
||||
|
||||
If Celery is available, returns a real Celery task.
|
||||
If not, returns the original function and provides `.delay()`/`.apply_async()`
|
||||
as synchronous fallbacks (so existing call sites don't break).
|
||||
|
||||
Notes:
|
||||
Mostly LLM-generated.
|
||||
"""
|
||||
|
||||
def _fallback_wrap(func):
|
||||
def delay(*args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
def apply_async(args=None, kwargs=None, **_options):
|
||||
return func(*(args or ()), **(kwargs or {}))
|
||||
|
||||
func.delay = delay
|
||||
func.apply_async = apply_async
|
||||
return func
|
||||
|
||||
# Handle bare decorator usage: @task
|
||||
if len(d_args) == 1 and callable(d_args[0]) and not d_kwargs:
|
||||
func = d_args[0]
|
||||
if settings.CELERY_ENABLED:
|
||||
from meet.celery_app import app as _celery_app
|
||||
|
||||
return _celery_app.task(func)
|
||||
return _fallback_wrap(func)
|
||||
|
||||
# Handle parameterized usage: @task(...), e.g. @task(bind=True)
|
||||
def _decorate(func):
|
||||
if settings.CELERY_ENABLED:
|
||||
from meet.celery_app import app as _celery_app
|
||||
|
||||
return _celery_app.task(*d_args, **d_kwargs)(func)
|
||||
return _fallback_wrap(func)
|
||||
|
||||
return _decorate
|
||||
|
||||
|
||||
__all__ = ("task",)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""
|
||||
Tasks related to files.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
from core.models import File
|
||||
from core.tasks._task import task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@task
|
||||
def process_file_deletion(file_id):
|
||||
"""
|
||||
Process the deletion of a file.
|
||||
Definitely delete it in the database.
|
||||
Delete the files from the storage.
|
||||
"""
|
||||
logger.info("Processing item deletion for %s", file_id)
|
||||
try:
|
||||
file = File.objects.get(id=file_id)
|
||||
except File.DoesNotExist:
|
||||
logger.error("Item %s does not exist", file_id)
|
||||
return
|
||||
|
||||
if file.hard_deleted_at is None:
|
||||
logger.error("To process an item deletion, it must be hard deleted first.")
|
||||
return
|
||||
|
||||
logger.info("Deleting file %s", file.file_key)
|
||||
default_storage.delete(file.file_key)
|
||||
|
||||
file.delete()
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Custom template tags for the core application of People."""
|
||||
"""Custom template tags for the core application of Meet."""
|
||||
|
||||
import base64
|
||||
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
"""
|
||||
Tests for files API endpoint in meet's core app: create
|
||||
"""
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
from uuid import uuid4
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
from freezegun import freeze_time
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.models import File, FileTypeChoices, FileUploadStateChoices
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_files_create_anonymous():
|
||||
"""Anonymous users should not be allowed to create items."""
|
||||
response = APIClient().post(
|
||||
"/api/v1.0/files/",
|
||||
{
|
||||
"title": "My file",
|
||||
"type": FileTypeChoices.BACKGROUND_IMAGE,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_401_UNAUTHORIZED
|
||||
assert not File.objects.exists()
|
||||
|
||||
|
||||
def test_api_files_create_authenticated_success():
|
||||
"""
|
||||
Authenticated users should be able to create files and should automatically be declared
|
||||
as the owner of the newly created file.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/files/",
|
||||
{
|
||||
"title": "my file",
|
||||
"filename": "my_file.png",
|
||||
"type": FileTypeChoices.BACKGROUND_IMAGE,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201, response.json()
|
||||
file = File.objects.get()
|
||||
assert file.title == "my file"
|
||||
assert file.creator == user
|
||||
assert file.type == FileTypeChoices.BACKGROUND_IMAGE
|
||||
assert file.upload_state == FileUploadStateChoices.PENDING
|
||||
|
||||
|
||||
def test_api_files_create_file_authenticated_no_filename():
|
||||
"""
|
||||
Creating a file item without providing a filename should fail.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/files/",
|
||||
{
|
||||
"title": "my item",
|
||||
"type": FileTypeChoices.BACKGROUND_IMAGE,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"filename": ["This field is required."]}
|
||||
|
||||
|
||||
def test_api_files_create_file_authenticated_success():
|
||||
"""
|
||||
Authenticated users should be able to create a file file and must provide a filename.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
now = timezone.now()
|
||||
with freeze_time(now):
|
||||
response = client.post(
|
||||
"/api/v1.0/files/",
|
||||
{
|
||||
"type": FileTypeChoices.BACKGROUND_IMAGE,
|
||||
"title": "Eiffle tower",
|
||||
"filename": "file.png",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 201
|
||||
file = File.objects.get()
|
||||
assert file.title == "Eiffle tower"
|
||||
assert file.type == FileTypeChoices.BACKGROUND_IMAGE
|
||||
assert file.filename == "file.png"
|
||||
|
||||
response_data = response.json()
|
||||
assert response_data["creator"] is not None, response_data
|
||||
|
||||
assert response.json().get("policy") is not None
|
||||
|
||||
policy = response.json()["policy"]
|
||||
policy_parsed = urlparse(policy)
|
||||
|
||||
assert policy_parsed.scheme == "http"
|
||||
assert policy_parsed.netloc == "localhost:9000"
|
||||
assert policy_parsed.path == f"/meet-media-storage/files/{file.id!s}.png"
|
||||
|
||||
query_params = parse_qs(policy_parsed.query)
|
||||
|
||||
assert query_params.pop("X-Amz-Algorithm") == ["AWS4-HMAC-SHA256"]
|
||||
assert query_params.pop("X-Amz-Credential") == [
|
||||
f"meet/{now.strftime('%Y%m%d')}/us-east-1/s3/aws4_request"
|
||||
]
|
||||
assert query_params.pop("X-Amz-Date") == [now.strftime("%Y%m%dT%H%M%SZ")]
|
||||
assert query_params.pop("X-Amz-Expires") == ["60"]
|
||||
assert query_params.pop("X-Amz-SignedHeaders") == ["host;x-amz-acl"]
|
||||
assert query_params.pop("X-Amz-Signature") is not None
|
||||
|
||||
assert len(query_params) == 0
|
||||
|
||||
|
||||
def test_api_files_create_file_authenticated_extension_not_allowed():
|
||||
"""
|
||||
Creating a file item with an extension not allowed should fail.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
"/api/v1.0/files/",
|
||||
{
|
||||
"type": FileTypeChoices.BACKGROUND_IMAGE,
|
||||
"title": "Paris tower",
|
||||
"filename": "file.notallowed",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"filename": ["This file extension is not allowed."]}
|
||||
|
||||
|
||||
def test_api_files_create_file_authenticated_extension_case_insensitive():
|
||||
"""
|
||||
Creating a file item with an extension, no matter the case used, should be allowed.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
"/api/v1.0/files/",
|
||||
{
|
||||
"type": FileTypeChoices.BACKGROUND_IMAGE,
|
||||
"filename": "file.JPG",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 201, response.json()
|
||||
file = File.objects.get()
|
||||
assert file.title == "file"
|
||||
|
||||
|
||||
def test_api_files_create_file_disabled(settings):
|
||||
"""
|
||||
Creating a file is denied if file upload is disabled
|
||||
"""
|
||||
settings.FILE_UPLOAD_ENABLED = False
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
"/api/v1.0/files/",
|
||||
{
|
||||
"type": FileTypeChoices.BACKGROUND_IMAGE,
|
||||
"filename": "file.JPG",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 404
|
||||
assert not File.objects.exists()
|
||||
|
||||
|
||||
def test_api_files_create_file_authenticated_not_checking_extension(settings):
|
||||
"""
|
||||
Creating a file with an extension not allowed should not fail when restrictions are disabled.
|
||||
"""
|
||||
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = False
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
"/api/v1.0/files/",
|
||||
{
|
||||
"type": FileTypeChoices.BACKGROUND_IMAGE,
|
||||
"filename": "file.notallowed",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 201, response.json()
|
||||
file = File.objects.get()
|
||||
assert file.title == "file"
|
||||
|
||||
|
||||
def test_api_files_create_file_authenticated_no_extension_but_checking_it_should_fail(
|
||||
settings,
|
||||
):
|
||||
"""
|
||||
Creating a file without an extension but checking the extension should fail.
|
||||
"""
|
||||
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = True
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
"/api/v1.0/files/",
|
||||
{
|
||||
"type": FileTypeChoices.BACKGROUND_IMAGE,
|
||||
"filename": "file",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"filename": ["This file extension is not allowed."]}
|
||||
|
||||
|
||||
def test_api_files_create_file_authenticated_hidden_file_but_checking_extension_should_fail(
|
||||
settings,
|
||||
):
|
||||
"""
|
||||
Creating a hidden file (starting with a dot) but checking the extension should fail.
|
||||
"""
|
||||
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = True
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
"/api/v1.0/files/",
|
||||
{
|
||||
"type": FileTypeChoices.BACKGROUND_IMAGE,
|
||||
"filename": ".file",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"filename": ["This file extension is not allowed."]}
|
||||
|
||||
|
||||
def test_api_files_create_file_too_many(
|
||||
settings,
|
||||
):
|
||||
"""
|
||||
Creating a file is forbidden if above user limit.
|
||||
"""
|
||||
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = True
|
||||
settings.FILE_UPLOAD_RESTRICTIONS = {
|
||||
"background_image": {
|
||||
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
|
||||
"max_count_by_user": 1,
|
||||
},
|
||||
}
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
response = client.post(
|
||||
"/api/v1.0/files/",
|
||||
{
|
||||
"type": FileTypeChoices.BACKGROUND_IMAGE,
|
||||
"filename": "1.png",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/files/",
|
||||
{
|
||||
"type": FileTypeChoices.BACKGROUND_IMAGE,
|
||||
"filename": "2.png",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You have reached the maximum number of files for this type."
|
||||
}
|
||||
assert File.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_files_create_force_id_success():
|
||||
"""It should be possible to force the item ID when creating a item."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
forced_id = uuid4()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/files/",
|
||||
{
|
||||
"id": str(forced_id),
|
||||
"title": "my item",
|
||||
"type": FileTypeChoices.BACKGROUND_IMAGE,
|
||||
"filename": "my_file.png",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201, response.json()
|
||||
files = File.objects.all()
|
||||
assert len(files) == 1
|
||||
assert files[0].id == forced_id
|
||||
|
||||
|
||||
def test_api_files_create_force_id_existing():
|
||||
"""
|
||||
It should not be possible to use the ID of an existing file when forcing ID on creation.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
file = factories.FileFactory()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/files/",
|
||||
{
|
||||
"id": str(file.id),
|
||||
"title": "my file",
|
||||
"type": FileTypeChoices.BACKGROUND_IMAGE,
|
||||
"filename": "my_file.png",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"id": ["A file with this ID already exists. You cannot override it."]
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db(transaction=True)
|
||||
def test_api_files_create_file_race_condition():
|
||||
"""
|
||||
It should be possible to create several files at the same time
|
||||
without causing any race conditions or data integrity issues.
|
||||
"""
|
||||
|
||||
def create_item(title):
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
return client.post(
|
||||
"/api/v1.0/files/",
|
||||
{
|
||||
"title": title,
|
||||
"type": FileTypeChoices.BACKGROUND_IMAGE,
|
||||
"filename": "my_file.png",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
future1 = executor.submit(create_item, "my item 1")
|
||||
future2 = executor.submit(create_item, "my item 2")
|
||||
|
||||
response1 = future1.result()
|
||||
response2 = future2.result()
|
||||
|
||||
assert response1.status_code == 201
|
||||
assert response2.status_code == 201
|
||||
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
Tests for files API endpoint in meet's core app: delete
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_files_delete_anonymous():
|
||||
"""Anonymous users should not be allowed to destroy a file."""
|
||||
file = factories.FileFactory()
|
||||
existing_items = models.File.objects.all().count()
|
||||
|
||||
response = APIClient().delete(
|
||||
f"/api/v1.0/files/{file.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert models.File.objects.count() == existing_items
|
||||
|
||||
|
||||
def test_api_files_delete_authenticated_owner():
|
||||
"""
|
||||
Authenticated users should be able to delete a item they own.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
file = factories.FileFactory(creator=user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/files/{file.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
|
||||
# Make sure it is only a soft delete
|
||||
file.refresh_from_db()
|
||||
assert file.deleted_at is not None
|
||||
@@ -0,0 +1,160 @@
|
||||
"""
|
||||
Tests for files API endpoint in meet's core app: list
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
from faker import Faker
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
fake = Faker()
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_files_list_anonymous_not_allowed():
|
||||
"""
|
||||
Anonymous users should not be allowed to list files whatever the
|
||||
"""
|
||||
response = APIClient().get("/api/v1.0/files/")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_files_list_authentificated_user_allowed():
|
||||
"""
|
||||
Authentificated users should be allowed to list files
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/files/")
|
||||
assert response.status_code == 200
|
||||
assert response.data == {"count": 0, "next": None, "previous": None, "results": []}
|
||||
|
||||
|
||||
def test_api_files_list_format():
|
||||
"""Validate the format of files as returned by the list view."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
file = factories.FileFactory(
|
||||
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||
title="item 1",
|
||||
creator=user,
|
||||
)
|
||||
|
||||
# A file from another user should not appear
|
||||
factories.FileFactory(
|
||||
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||
title="item 2",
|
||||
)
|
||||
|
||||
# hard deleted item should not appear
|
||||
factories.FileFactory(
|
||||
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||
hard_deleted_at=timezone.now(),
|
||||
title="hard deleted item",
|
||||
creator=user,
|
||||
)
|
||||
|
||||
response = client.get("/api/v1.0/files/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
results = content.pop("results")
|
||||
assert content == {
|
||||
"count": 1,
|
||||
"next": None,
|
||||
"previous": None,
|
||||
}
|
||||
assert len(results) == 1
|
||||
assert results == [
|
||||
{
|
||||
"id": str(file.id),
|
||||
"created_at": file.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"creator": {
|
||||
"id": str(file.creator.id),
|
||||
"full_name": file.creator.full_name,
|
||||
"short_name": file.creator.short_name,
|
||||
},
|
||||
"title": file.title,
|
||||
"updated_at": file.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"type": models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||
"upload_state": file.upload_state,
|
||||
"url": None,
|
||||
"mimetype": file.mimetype,
|
||||
"filename": file.filename,
|
||||
"size": None,
|
||||
"description": None,
|
||||
"deleted_at": None,
|
||||
"hard_deleted_at": None,
|
||||
"abilities": {
|
||||
"destroy": True,
|
||||
"hard_delete": True,
|
||||
"media_auth": True,
|
||||
"partial_update": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"upload_ended": True,
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2)
|
||||
def test_api_files_list_pagination(
|
||||
_mock_page_size,
|
||||
):
|
||||
"""Pagination should work as expected."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
file_ids = [
|
||||
str(file.id)
|
||||
for file in factories.FileFactory.create_batch(
|
||||
3,
|
||||
creator=user,
|
||||
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||
)
|
||||
]
|
||||
# Get page 1
|
||||
response = client.get(
|
||||
"/api/v1.0/files/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == 3
|
||||
assert content["next"] == "http://testserver/api/v1.0/files/?page=2"
|
||||
assert content["previous"] is None
|
||||
|
||||
assert len(content["results"]) == 2
|
||||
for item in content["results"]:
|
||||
file_ids.remove(item["id"])
|
||||
|
||||
# Get page 2
|
||||
response = client.get(
|
||||
"/api/v1.0/files/?page=2",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == 3
|
||||
assert content["next"] is None
|
||||
assert content["previous"] == "http://testserver/api/v1.0/files/"
|
||||
|
||||
assert len(content["results"]) == 1
|
||||
for item in content["results"]:
|
||||
file_ids.remove(item["id"])
|
||||
assert file_ids == []
|
||||
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
Tests for files API endpoint in meet's core app: list
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from faker import Faker
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
fake = Faker()
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
# Filters: unknown field
|
||||
|
||||
|
||||
def test_api_files_list_filter_unknown_field():
|
||||
"""
|
||||
Trying to filter by an unknown field should do nothing.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.FileFactory(type=models.FileTypeChoices.BACKGROUND_IMAGE)
|
||||
expected_ids = {
|
||||
str(file.id)
|
||||
for file in factories.FileFactory.create_batch(
|
||||
2, creator=user, type=models.FileTypeChoices.BACKGROUND_IMAGE
|
||||
)
|
||||
}
|
||||
|
||||
response = client.get("/api/v1.0/files/?unknown=true")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 2
|
||||
assert {result["id"] for result in results} == expected_ids
|
||||
|
||||
|
||||
# Filters: is_creator_me
|
||||
|
||||
|
||||
def test_api_files_list_filter_is_creator_me_true():
|
||||
"""
|
||||
Authenticated users should be able to filter files they created.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.FileFactory.create_batch(
|
||||
2, creator=user, type=models.FileTypeChoices.BACKGROUND_IMAGE
|
||||
)
|
||||
factories.FileFactory.create_batch(2, type=models.FileTypeChoices.BACKGROUND_IMAGE)
|
||||
|
||||
response = client.get("/api/v1.0/files/?is_creator_me=true")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 2
|
||||
|
||||
# Ensure all results are created by the current user
|
||||
for result in results:
|
||||
assert result["creator"] == {
|
||||
"id": str(user.id),
|
||||
"full_name": user.full_name,
|
||||
"short_name": user.short_name,
|
||||
}
|
||||
|
||||
|
||||
def test_api_files_list_filter_is_creator_me_invalid():
|
||||
"""Filtering with an invalid `is_creator_me` value should do nothing."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.FileFactory.create_batch(
|
||||
3, creator=user, type=models.FileTypeChoices.BACKGROUND_IMAGE
|
||||
)
|
||||
|
||||
response = client.get("/api/v1.0/files/?is_creator_me=invalid")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 3
|
||||
|
||||
|
||||
# Filters: type
|
||||
|
||||
|
||||
def test_api_files_list_filter_type_and_upload_status():
|
||||
"""
|
||||
Authenticated users should be able to filter files by their type and upload status.
|
||||
|
||||
This test will make more sense when other types are added to the API
|
||||
"""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
file = factories.FileFactory(
|
||||
creator=user,
|
||||
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||
update_upload_state=models.FileUploadStateChoices.PENDING,
|
||||
)
|
||||
assert file.upload_state == models.FileUploadStateChoices.PENDING
|
||||
|
||||
expected_files = factories.FileFactory.create_batch(
|
||||
2,
|
||||
creator=user,
|
||||
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||
update_upload_state=models.FileUploadStateChoices.READY,
|
||||
)
|
||||
expected_files_ids = {str(file.id) for file in expected_files}
|
||||
|
||||
# Filter by type: background_image & upload state
|
||||
response = client.get("/api/v1.0/files/?type=background_image&upload_state=ready")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["count"] == 2
|
||||
|
||||
results = response.json()["results"]
|
||||
|
||||
# Ensure all results are background images
|
||||
results_ids = {result["id"] for result in results}
|
||||
assert results_ids == expected_files_ids
|
||||
for result in results:
|
||||
assert result["type"] == models.FileTypeChoices.BACKGROUND_IMAGE
|
||||
assert result["upload_state"] == models.FileUploadStateChoices.READY
|
||||
|
||||
# Second request without the upload_state filter, to check that all 3 show up
|
||||
response = client.get("/api/v1.0/files/?type=background_image")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["count"] == 3
|
||||
|
||||
results = response.json()["results"]
|
||||
|
||||
# Ensure all results are background images
|
||||
results_ids = {result["id"] for result in results}
|
||||
assert results_ids == {str(file.id) for file in expected_files + [file]}
|
||||
for result in results:
|
||||
assert result["type"] == models.FileTypeChoices.BACKGROUND_IMAGE
|
||||
|
||||
|
||||
def test_api_files_list_filter_is_deleted():
|
||||
"""
|
||||
Authenticated users should be able to filter files by their deletion status.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
not_deleted_file = factories.FileFactory(creator=user)
|
||||
|
||||
deleted_files = factories.FileFactory.create_batch(2, creator=user)
|
||||
for file in deleted_files:
|
||||
file.soft_delete()
|
||||
|
||||
# No filters
|
||||
response_no_filters = client.get("/api/v1.0/files/")
|
||||
|
||||
assert response_no_filters.status_code == 200
|
||||
assert response_no_filters.json()["count"] == 3
|
||||
|
||||
results = response_no_filters.json()["results"]
|
||||
|
||||
results_ids = {result["id"] for result in results}
|
||||
assert results_ids == {str(file.id) for file in [*deleted_files, not_deleted_file]}
|
||||
|
||||
# Filters deleted
|
||||
response_filter_deleted = client.get("/api/v1.0/files/?is_deleted=true")
|
||||
|
||||
assert response_filter_deleted.status_code == 200
|
||||
assert response_filter_deleted.json()["count"] == 2
|
||||
|
||||
results = response_filter_deleted.json()["results"]
|
||||
|
||||
results_ids = {result["id"] for result in results}
|
||||
assert results_ids == {str(file.id) for file in deleted_files}
|
||||
|
||||
# Filters not deleted
|
||||
response_filter_not_deleted = client.get("/api/v1.0/files/?is_deleted=false")
|
||||
|
||||
assert response_filter_not_deleted.status_code == 200
|
||||
assert response_filter_not_deleted.json()["count"] == 1
|
||||
|
||||
results = response_filter_not_deleted.json()["results"]
|
||||
|
||||
# Ensure all results are deleted
|
||||
results_ids = {result["id"] for result in results}
|
||||
assert results_ids == {str(file.id) for file in [not_deleted_file]}
|
||||
|
||||
|
||||
def test_api_files_list_filter_unknown_type():
|
||||
"""
|
||||
Filtering by an unknown type should return an empty list
|
||||
"""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.FileFactory.create_batch(3, creator=user)
|
||||
|
||||
response = client.get("/api/v1.0/files/?type=unknown")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"type": ["Select a valid choice. unknown is not one of the available choices."]
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Test the ordering of items."""
|
||||
|
||||
import operator
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_files_list_ordering_default():
|
||||
"""items should be ordered by descending "updated_at" by default"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.FileFactory.create_batch(
|
||||
4, creator=user, type=models.FileTypeChoices.BACKGROUND_IMAGE
|
||||
)
|
||||
response = client.get("/api/v1.0/files/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 4
|
||||
|
||||
# Check that results are sorted by descending "updated_at" as expected
|
||||
for i in range(3):
|
||||
assert operator.ge(results[i]["updated_at"], results[i + 1]["updated_at"])
|
||||
|
||||
|
||||
def test_api_files_list_ordering_by_fields():
|
||||
"""It should be possible to order by several fields"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.FileFactory.create_batch(
|
||||
4, creator=user, type=models.FileTypeChoices.BACKGROUND_IMAGE
|
||||
)
|
||||
|
||||
for parameter in [
|
||||
"created_at",
|
||||
"-created_at",
|
||||
"updated_at",
|
||||
"-updated_at",
|
||||
]:
|
||||
is_descending = parameter.startswith("-")
|
||||
field = parameter.lstrip("-")
|
||||
querystring = f"?ordering={parameter}"
|
||||
|
||||
response = client.get(f"/api/v1.0/files/{querystring:s}")
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 4
|
||||
|
||||
# Check that results are sorted by the field in querystring as expected
|
||||
compare = operator.ge if is_descending else operator.le
|
||||
for i in range(3):
|
||||
operator1 = (
|
||||
results[i][field].lower()
|
||||
if isinstance(results[i][field], str)
|
||||
else results[i][field]
|
||||
)
|
||||
operator2 = (
|
||||
results[i + 1][field].lower()
|
||||
if isinstance(results[i + 1][field], str)
|
||||
else results[i + 1][field]
|
||||
)
|
||||
assert compare(operator1, operator2)
|
||||
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
Test file uploads API endpoint for users in meet's core app.
|
||||
"""
|
||||
|
||||
from io import BytesIO
|
||||
from urllib.parse import quote, urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.storage import default_storage
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from freezegun import freeze_time
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_files_media_auth_anonymous_not_authorized():
|
||||
"""Anonymous users should not be allowed to retrieve a file"""
|
||||
file = factories.FileFactory(
|
||||
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||
update_upload_state=models.FileUploadStateChoices.READY,
|
||||
)
|
||||
|
||||
original_url = f"http://localhost/media/{file.file_key:s}"
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/files/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_files_media_get_own():
|
||||
"""
|
||||
Authenticated user should be allowed to retrieve their own file.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
file = factories.FileFactory(
|
||||
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||
update_upload_state=models.FileUploadStateChoices.READY,
|
||||
creator=user,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
default_storage.save(
|
||||
file.file_key,
|
||||
BytesIO(b"my prose"),
|
||||
)
|
||||
|
||||
original_url = f"http://localhost/media/{file.file_key:s}"
|
||||
now = timezone.now()
|
||||
with freeze_time(now):
|
||||
response = client.get(
|
||||
"/api/v1.0/files/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
authorization = response["Authorization"]
|
||||
assert "AWS4-HMAC-SHA256 Credential=" in authorization
|
||||
assert (
|
||||
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
||||
in authorization
|
||||
)
|
||||
assert response["X-Amz-Date"] == now.strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
||||
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/meet-media-storage/{file.file_key:s}"
|
||||
response = requests.get(
|
||||
file_url,
|
||||
headers={
|
||||
"authorization": authorization,
|
||||
"x-amz-date": response["x-amz-date"],
|
||||
"x-amz-content-sha256": response["x-amz-content-sha256"],
|
||||
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
|
||||
},
|
||||
timeout=1,
|
||||
)
|
||||
assert response.content.decode("utf-8") == "my prose"
|
||||
|
||||
|
||||
def test_api_files_media_auth_file_pending():
|
||||
"""
|
||||
Users who have a specific access to an file, whatever the role, should not be able to
|
||||
retrieve related attachments if the file is not ready.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
file = factories.FileFactory(
|
||||
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||
upload_state=models.FileUploadStateChoices.PENDING,
|
||||
creator=user,
|
||||
)
|
||||
|
||||
key = file.file_key
|
||||
|
||||
original_url = quote(f"http://localhost/media/{key:s}")
|
||||
response = client.get(
|
||||
"/api/v1.0/files/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_api_files_media_auth_own_file_deleted():
|
||||
"""
|
||||
This function tests the access restrictions on deleted files through the media
|
||||
authorization API endpoint. It ensures that a user cannot retrieve a file that is deleted.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
file = factories.FileFactory(
|
||||
type=models.FileTypeChoices.BACKGROUND_IMAGE,
|
||||
update_upload_state=models.FileUploadStateChoices.READY,
|
||||
creator=user,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
default_storage.save(
|
||||
file.file_key,
|
||||
BytesIO(b"my prose"),
|
||||
)
|
||||
file.soft_delete()
|
||||
|
||||
original_url = f"http://localhost/media/{file.file_key:s}"
|
||||
response = client.get(
|
||||
"/api/v1.0/files/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Tests for files API endpoint in meet's core app: update
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.api import serializers
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_files_update_anonymous_forbidden():
|
||||
"""
|
||||
Anonymous users should not be allowed to update an file when link
|
||||
configuration does not allow it.
|
||||
"""
|
||||
|
||||
file = factories.FileFactory()
|
||||
|
||||
old_file_values = serializers.FileSerializer(instance=file).data
|
||||
new_file_values = serializers.FileSerializer(instance=factories.FileFactory()).data
|
||||
|
||||
response = APIClient().put(
|
||||
f"/api/v1.0/files/{file.id!s}/",
|
||||
new_file_values,
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
file.refresh_from_db()
|
||||
item_values = serializers.FileSerializer(instance=file).data
|
||||
assert item_values == old_file_values
|
||||
|
||||
|
||||
def test_api_files_update_description_and_title():
|
||||
"""
|
||||
Test the description and title of a file can be updated.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
file = factories.FileFactory(
|
||||
description="Old description",
|
||||
title="Old title",
|
||||
creator=user,
|
||||
)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/files/{file.id!s}/",
|
||||
{"description": "New description", "title": "New title"},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["description"] == "New description"
|
||||
assert result["title"] == "New title"
|
||||
|
||||
file.refresh_from_db()
|
||||
assert file.description == "New description"
|
||||
assert file.title == "New title"
|
||||
@@ -0,0 +1,272 @@
|
||||
"""Test related to item upload ended API."""
|
||||
|
||||
import logging
|
||||
from io import BytesIO
|
||||
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories, models
|
||||
from core.models import FileTypeChoices, FileUploadStateChoices
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_file_upload_ended_anonymous():
|
||||
"""Anonymous users should not be allowed to end an upload."""
|
||||
file = factories.FileFactory()
|
||||
response = APIClient().post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_file_upload_ended_non_creator_not_found():
|
||||
"""Users without write permissions should not be allowed to end an upload."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
file = factories.FileFactory()
|
||||
|
||||
response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_api_file_upload_ended_on_wrong_upload_state():
|
||||
"""
|
||||
Users should not be allowed to end an upload on files that are not in the PENDING upload state.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
file = factories.FileFactory(
|
||||
type=FileTypeChoices.BACKGROUND_IMAGE,
|
||||
creator=user,
|
||||
update_upload_state=FileUploadStateChoices.READY,
|
||||
)
|
||||
|
||||
response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"file": "This action is only available for files in PENDING state."
|
||||
}
|
||||
|
||||
|
||||
def test_api_file_upload_ended_success(settings):
|
||||
"""
|
||||
Users should be able to end an upload on files that are files and in the UPLOADING upload state.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = True
|
||||
settings.FILE_UPLOAD_RESTRICTIONS = {
|
||||
"background_image": {
|
||||
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
|
||||
"allowed_mimetypes": ["text/html", "text/plain"],
|
||||
},
|
||||
}
|
||||
|
||||
file = factories.FileFactory(
|
||||
type=FileTypeChoices.BACKGROUND_IMAGE,
|
||||
filename="my_file.txt",
|
||||
mimetype="text/html",
|
||||
creator=user,
|
||||
)
|
||||
|
||||
default_storage.save(
|
||||
file.file_key,
|
||||
BytesIO(b"my prose"),
|
||||
)
|
||||
|
||||
response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
file.refresh_from_db()
|
||||
assert file.upload_state == FileUploadStateChoices.READY
|
||||
assert file.mimetype == "text/plain"
|
||||
assert file.size == 8
|
||||
|
||||
assert response.json()["mimetype"] == "text/plain"
|
||||
|
||||
|
||||
def test_api_file_upload_ended_mimetype_not_allowed(settings, caplog):
|
||||
"""
|
||||
Test that the API returns a 400 when the mimetype is not allowed.
|
||||
File should be deleted and the file should be deleted from the storage.
|
||||
"""
|
||||
settings.RESTRICT_UPLOAD_FILE_TYPE = True
|
||||
settings.FILE_UPLOAD_RESTRICTIONS = {
|
||||
"background_image": {
|
||||
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
|
||||
"allowed_mimetypes": ["application/pdf"],
|
||||
}
|
||||
}
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
file = factories.FileFactory(
|
||||
type=FileTypeChoices.BACKGROUND_IMAGE, filename="my_file.txt", creator=user
|
||||
)
|
||||
|
||||
default_storage.save(
|
||||
file.file_key,
|
||||
BytesIO(b"my prose"),
|
||||
)
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert (
|
||||
f"upload_ended: mimetype not allowed text/plain for file {file.file_key}"
|
||||
in caplog.text
|
||||
)
|
||||
|
||||
assert not models.File.objects.filter(id=file.id).exists()
|
||||
assert not default_storage.exists(file.file_key)
|
||||
|
||||
|
||||
def test_api_file_upload_ended_mimetype_not_allowed_not_checking_mimetype(settings):
|
||||
"""
|
||||
Test that the API returns a 200 when the mimetype is not allowed but not checking the mimetype.
|
||||
"""
|
||||
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = False
|
||||
settings.FILE_UPLOAD_RESTRICTIONS = {
|
||||
"background_image": {
|
||||
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
|
||||
"allowed_mimetypes": ["application/pdf"],
|
||||
}
|
||||
}
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
file = factories.FileFactory(
|
||||
type=FileTypeChoices.BACKGROUND_IMAGE, filename="my_file.txt", creator=user
|
||||
)
|
||||
|
||||
default_storage.save(
|
||||
file.file_key,
|
||||
BytesIO(b"my prose"),
|
||||
)
|
||||
|
||||
response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
file.refresh_from_db()
|
||||
assert file.upload_state == FileUploadStateChoices.READY
|
||||
assert file.mimetype == "text/plain"
|
||||
assert file.size == 8
|
||||
|
||||
assert response.json()["mimetype"] == "text/plain"
|
||||
|
||||
|
||||
def test_api_upload_ended_mismatch_mimetype_with_object_storage(settings, caplog):
|
||||
"""
|
||||
Object on storage should have the same mimetype than the one saved in the
|
||||
File object.
|
||||
"""
|
||||
settings.FILE_UPLOAD_APPLY_RESTRICTIONS = True
|
||||
settings.FILE_UPLOAD_RESTRICTIONS = {
|
||||
"background_image": {
|
||||
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
|
||||
"allowed_mimetypes": ["text/html", "application/pdf"],
|
||||
}
|
||||
}
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
file = factories.FileFactory(
|
||||
type=FileTypeChoices.BACKGROUND_IMAGE,
|
||||
filename="my_file.pdf",
|
||||
title="my_file.pdf",
|
||||
creator=user,
|
||||
)
|
||||
|
||||
s3_client = default_storage.connection.meta.client
|
||||
|
||||
s3_client.put_object(
|
||||
Bucket=default_storage.bucket_name,
|
||||
Key=file.file_key,
|
||||
ContentType="text/html",
|
||||
Body=BytesIO(
|
||||
b'<meta http-equiv="refresh" content="0; url=https://fichiers.numerique.gouv.fr">'
|
||||
),
|
||||
Metadata={
|
||||
"foo": "bar",
|
||||
},
|
||||
)
|
||||
|
||||
head_object = s3_client.head_object(
|
||||
Bucket=default_storage.bucket_name, Key=file.file_key
|
||||
)
|
||||
|
||||
assert head_object["ContentType"] == "text/html"
|
||||
with caplog.at_level(logging.INFO, logger="core.api.viewsets"):
|
||||
response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
|
||||
assert (
|
||||
"upload_ended: content type mismatch between object storage and file,"
|
||||
" updating from text/html to application/pdf" in caplog.text
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
file.refresh_from_db()
|
||||
|
||||
assert file.mimetype == "application/pdf"
|
||||
|
||||
head_object = s3_client.head_object(
|
||||
Bucket=default_storage.bucket_name, Key=file.file_key
|
||||
)
|
||||
assert head_object["ContentType"] == "application/pdf"
|
||||
assert head_object["Metadata"] == {"foo": "bar"}
|
||||
|
||||
|
||||
def test_api_upload_ended_file_size_exceeded(settings, caplog):
|
||||
"""
|
||||
Test when the file size exceed the allowed max upload file size
|
||||
should return a 400 and delete the file.
|
||||
"""
|
||||
|
||||
settings.FILE_UPLOAD_RESTRICTIONS = {
|
||||
"background_image": {
|
||||
**settings.FILE_UPLOAD_RESTRICTIONS["background_image"],
|
||||
"max_size": 0,
|
||||
}
|
||||
}
|
||||
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
file = factories.FileFactory(
|
||||
type=FileTypeChoices.BACKGROUND_IMAGE, filename="my_file.txt", creator=user
|
||||
)
|
||||
|
||||
default_storage.save(
|
||||
file.file_key,
|
||||
BytesIO(b"my prose"),
|
||||
)
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="core.api.viewsets"):
|
||||
response = client.post(f"/api/v1.0/files/{file.id!s}/upload-ended/")
|
||||
assert (
|
||||
f"upload_ended: file size (8) for file {file.file_key} higher than the allowed max size"
|
||||
in caplog.text
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
assert not models.File.objects.filter(id=file.id).exists()
|
||||
assert not default_storage.exists(file.file_key)
|
||||
@@ -32,7 +32,7 @@ def valid_minio_event():
|
||||
"s3": {
|
||||
"bucket": {"name": "test-bucket"},
|
||||
"object": {
|
||||
"key": "recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||
"key": "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||
"contentType": "audio/ogg",
|
||||
},
|
||||
}
|
||||
@@ -51,7 +51,7 @@ def test_parse_valid_event(minio_parser, valid_minio_event):
|
||||
"""Test parsing a valid Minio event."""
|
||||
event = minio_parser.parse(valid_minio_event)
|
||||
assert isinstance(event, StorageEvent)
|
||||
assert event.filepath == "recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg"
|
||||
assert event.filepath == "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg"
|
||||
assert event.filetype == "audio/ogg"
|
||||
assert event.bucket_name == "test-bucket"
|
||||
assert event.metadata is None
|
||||
@@ -130,11 +130,13 @@ def test_validate_invalid_filetype(minio_parser):
|
||||
"invalid_filepath",
|
||||
[
|
||||
"invalid_filepath", # totally invalid string
|
||||
"recording/46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||
"recording/46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing extension
|
||||
"recordings/46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||
"recordings/46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing extension
|
||||
"46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing url_encoded_folder_path and extension
|
||||
"", # empty string
|
||||
"recording%2F46d1a1212426484d8fb309b5d886f7a8.ogg",
|
||||
"46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # no folder at all
|
||||
"uploads%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # wrong folder name
|
||||
"folder%2Fuploads%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # nested but no recordings/
|
||||
],
|
||||
)
|
||||
def test_validate_invalid_filepath(invalid_filepath, minio_parser):
|
||||
@@ -152,7 +154,7 @@ def test_validate_invalid_filepath(invalid_filepath, minio_parser):
|
||||
def test_validate_valid_event(minio_parser):
|
||||
"""Test validation with valid event data."""
|
||||
event = StorageEvent(
|
||||
filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||
filepath="recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||
filetype="audio/ogg",
|
||||
bucket_name="test-bucket",
|
||||
metadata=None,
|
||||
@@ -170,7 +172,7 @@ def test_get_recording_id_success(minio_parser, valid_minio_event):
|
||||
def test_validate_filepath_with_folder(minio_parser):
|
||||
"""Test validation of filepath with folder structure."""
|
||||
event = StorageEvent(
|
||||
filepath="parent_folder%2Ffolder%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||
filepath="parent_folder%2Frecordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||
filetype="audio/ogg",
|
||||
bucket_name="test-bucket",
|
||||
metadata=None,
|
||||
@@ -219,7 +221,7 @@ def test_validate_custom_filetypes():
|
||||
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes={"audio/mp3"})
|
||||
|
||||
event = StorageEvent(
|
||||
filepath="parent_folder%2Ffolder%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||
filepath="parent_folder%2Frecordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||
filetype="audio/mp3",
|
||||
bucket_name="test-bucket",
|
||||
metadata=None,
|
||||
|
||||
@@ -14,6 +14,7 @@ from ...factories import RecordingFactory
|
||||
from ...models import Recording, RecordingStatusChoices
|
||||
from ...recording.event.exceptions import (
|
||||
InvalidBucketError,
|
||||
InvalidFilepathError,
|
||||
InvalidFileTypeError,
|
||||
ParsingEventDataError,
|
||||
)
|
||||
@@ -94,7 +95,7 @@ def test_save_recording_parsing_error(recording_settings, mock_get_parser, clien
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {"detail": "Invalid request data: Error message"}
|
||||
assert response.json() == {"detail": "Invalid request data."}
|
||||
|
||||
|
||||
def test_save_recording_bucket_error(recording_settings, mock_get_parser, client):
|
||||
@@ -111,7 +112,7 @@ def test_save_recording_bucket_error(recording_settings, mock_get_parser, client
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {"detail": "Invalid bucket specified"}
|
||||
assert response.json() == {"detail": "Invalid bucket specified."}
|
||||
|
||||
|
||||
def test_save_recording_filetype_error(recording_settings, mock_get_parser):
|
||||
@@ -132,7 +133,28 @@ def test_save_recording_filetype_error(recording_settings, mock_get_parser):
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "Ignore this file type, unsupported '.json'"}
|
||||
assert response.json() == {"message": "Notification ignored."}
|
||||
|
||||
|
||||
def test_save_recording_filepath_error(recording_settings, mock_get_parser):
|
||||
"""Test handling of unsupported filepath in recording event data."""
|
||||
|
||||
mock_parser = mock.Mock()
|
||||
mock_parser.get_recording_id.side_effect = InvalidFilepathError(
|
||||
"Invalid filepath structure: parent/folder/recording.jpeg"
|
||||
)
|
||||
mock_get_parser.return_value = mock_parser
|
||||
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/storage-hook/",
|
||||
{"recording_data": "valid-data"},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "Notification ignored."}
|
||||
|
||||
|
||||
def test_save_recording_unknown_recording(recording_settings, mock_get_parser, client):
|
||||
|
||||
@@ -120,3 +120,39 @@ def test_api_rooms_list_authenticated_distinct():
|
||||
content = response.json()
|
||||
assert len(content["results"]) == 1
|
||||
assert content["results"][0]["id"] == str(room.id)
|
||||
|
||||
|
||||
def test_api_rooms_list_pagination_page_size():
|
||||
"""Users should be able to customize the number of results per page via page_size param."""
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
RoomFactory.create_batch(11, users=[user])
|
||||
|
||||
response = client.get("/api/v1.0/rooms/?page_size=2")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert content["count"] == 11
|
||||
assert len(content["results"]) == 2
|
||||
assert content["next"] == "http://testserver/api/v1.0/rooms/?page=2&page_size=2"
|
||||
assert content["previous"] is None
|
||||
|
||||
response = client.get("/api/v1.0/rooms/?page=6&page_size=2")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert content["count"] == 11
|
||||
assert len(content["results"]) == 1
|
||||
assert content["next"] is None
|
||||
assert content["previous"] == "http://testserver/api/v1.0/rooms/?page=5&page_size=2"
|
||||
|
||||
response = client.get("/api/v1.0/rooms/?page_size=3")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert content["count"] == 11
|
||||
assert len(content["results"]) == 3
|
||||
assert content["next"] == "http://testserver/api/v1.0/rooms/?page=2&page_size=3"
|
||||
assert content["previous"] is None
|
||||
|
||||
@@ -631,3 +631,109 @@ def test_list_waiting_participants_empty(settings):
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"participants": []}
|
||||
|
||||
|
||||
@mock.patch.object(utils, "notify_participants", return_value=None)
|
||||
@mock.patch.object(
|
||||
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
||||
)
|
||||
def test_request_entry_throttling_anonymous_without_cookie(
|
||||
mock_notify_participants, mock_generate_livekit_config, settings
|
||||
):
|
||||
"""Anonymous users without a cookie should not be throttled."""
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "1/minute"
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.cookies.get("mocked-cookie") is not None
|
||||
|
||||
client.cookies.clear() # Simulate a new cookieless request
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@mock.patch.object(utils, "notify_participants", return_value=None)
|
||||
@mock.patch.object(
|
||||
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
||||
)
|
||||
def test_request_entry_throttling_anonymous_with_cookie(
|
||||
mock_notify_participants, mock_generate_livekit_config, settings
|
||||
):
|
||||
"""Anonymous users with a cookie should be throttled after exceeding the rate limit."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
|
||||
|
||||
participant_id = str(uuid.uuid4())
|
||||
client.cookies.load({"mocked-cookie": participant_id})
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
)
|
||||
|
||||
assert response.status_code == 429
|
||||
|
||||
|
||||
@mock.patch.object(utils, "notify_participants", return_value=None)
|
||||
@mock.patch.object(
|
||||
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
||||
)
|
||||
def test_request_entry_throttling_authenticated_user(
|
||||
mock_notify_participants, mock_generate_livekit_config, settings
|
||||
):
|
||||
"""Authenticated users should be throttled."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"]["request_entry"] = "2/minute"
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
)
|
||||
|
||||
assert response.status_code == 429
|
||||
|
||||
@@ -77,7 +77,6 @@ def test_missing_auth_header(client, serialized_event_data, mock_livekit_config)
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"status": "error",
|
||||
"message": "Authorization header missing",
|
||||
}
|
||||
|
||||
|
||||
@@ -91,7 +90,7 @@ def test_invalid_payload(client, auth_token, mock_livekit_config):
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"status": "error", "message": "Invalid webhook payload"}
|
||||
assert response.json() == {"status": "error"}
|
||||
|
||||
|
||||
def test_unknown_event_type(client, mock_livekit_config):
|
||||
@@ -116,7 +115,6 @@ def test_unknown_event_type(client, mock_livekit_config):
|
||||
assert response.status_code == 422
|
||||
assert response.json() == {
|
||||
"status": "error",
|
||||
"message": "Unknown webhook type: unknown_event_type",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -904,7 +904,7 @@ def test_api_rooms_token_unknown_application(settings):
|
||||
|
||||
def test_api_rooms_token_inactive_application(settings):
|
||||
"""Token for inactive application should be rejected."""
|
||||
application = ApplicationFactory(active=False)
|
||||
application = ApplicationFactory(is_active=False)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
|
||||
@@ -23,7 +23,7 @@ def test_api_applications_generate_token_success(settings):
|
||||
"""Valid credentials should return a JWT token."""
|
||||
UserFactory(email="User.Family@example.com")
|
||||
application = ApplicationFactory(
|
||||
active=True,
|
||||
is_active=True,
|
||||
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
||||
)
|
||||
|
||||
@@ -79,7 +79,7 @@ def test_api_applications_generate_token_invalid_client_id():
|
||||
def test_api_applications_generate_token_invalid_client_secret():
|
||||
"""Invalid client_secret should return 401."""
|
||||
user = UserFactory(email="user@example.com")
|
||||
application = ApplicationFactory(active=True)
|
||||
application = ApplicationFactory(is_active=True)
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
@@ -100,7 +100,7 @@ def test_api_applications_generate_token_invalid_client_secret():
|
||||
def test_api_applications_generate_token_inactive_application():
|
||||
"""Inactive application should return 401."""
|
||||
user = UserFactory(email="user@example.com")
|
||||
application = ApplicationFactory(active=False)
|
||||
application = ApplicationFactory(is_active=False)
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application.client_secret = plain_secret
|
||||
@@ -122,9 +122,31 @@ def test_api_applications_generate_token_inactive_application():
|
||||
assert "Application is inactive" in str(response.data)
|
||||
|
||||
|
||||
def test_api_applications_generate_token_inactive_application_wrong_secret():
|
||||
"""An inactive application with a wrong secret should return 401."""
|
||||
user = UserFactory(email="user@example.com")
|
||||
application = ApplicationFactory(is_active=False)
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
{
|
||||
"client_id": application.client_id,
|
||||
"client_secret": "wrong-secret",
|
||||
"grant_type": "client_credentials",
|
||||
"scope": user.email,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Invalid credentials" in str(response.data)
|
||||
assert "inactive" not in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_applications_generate_token_invalid_email_format():
|
||||
"""Invalid email format should return 400."""
|
||||
application = ApplicationFactory(active=True)
|
||||
application = ApplicationFactory(is_active=True)
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application.client_secret = plain_secret
|
||||
@@ -149,7 +171,7 @@ def test_api_applications_generate_token_invalid_email_format():
|
||||
def test_api_applications_generate_token_domain_not_authorized():
|
||||
"""Application without domain authorization should return 403."""
|
||||
user = UserFactory(email="user@denied.com")
|
||||
application = ApplicationFactory(active=True)
|
||||
application = ApplicationFactory(is_active=True)
|
||||
ApplicationDomainFactory(application=application, domain="allowed.com")
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
@@ -176,7 +198,7 @@ def test_api_applications_generate_token_domain_authorized():
|
||||
"""Application with domain authorization should succeed."""
|
||||
user = UserFactory(email="user@allowed.com")
|
||||
application = ApplicationFactory(
|
||||
active=True,
|
||||
is_active=True,
|
||||
scopes=[ApplicationScope.ROOMS_LIST],
|
||||
)
|
||||
ApplicationDomainFactory(application=application, domain="allowed.com")
|
||||
@@ -203,7 +225,7 @@ def test_api_applications_generate_token_domain_authorized():
|
||||
|
||||
def test_api_applications_generate_token_user_not_found():
|
||||
"""Non-existent user should return 404."""
|
||||
application = ApplicationFactory(active=True)
|
||||
application = ApplicationFactory(is_active=True)
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application.client_secret = plain_secret
|
||||
@@ -231,7 +253,7 @@ def test_api_applications_token_payload_structure(settings):
|
||||
user = UserFactory(email="user@example.com")
|
||||
|
||||
application = ApplicationFactory(
|
||||
active=True,
|
||||
is_active=True,
|
||||
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
||||
)
|
||||
|
||||
@@ -284,7 +306,7 @@ def test_api_applications_token_new_user(settings):
|
||||
assert len(User.objects.all()) == 0
|
||||
|
||||
application = ApplicationFactory(
|
||||
active=True,
|
||||
is_active=True,
|
||||
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
||||
)
|
||||
|
||||
@@ -342,7 +364,7 @@ def test_api_applications_token_existing_user(settings):
|
||||
assert len(User.objects.all()) == 1
|
||||
|
||||
application = ApplicationFactory(
|
||||
active=True,
|
||||
is_active=True,
|
||||
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
||||
)
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ def test_models_application_name_maxlength():
|
||||
def test_models_application_active_default():
|
||||
"""An application should be active by default."""
|
||||
application = Application.objects.create(name="Test App")
|
||||
assert application.active is True
|
||||
assert application.is_active is True
|
||||
|
||||
|
||||
def test_models_application_scopes_default():
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
Test utils.detect_mimetype
|
||||
Originally taken from https://github.com/suitenumerique/drive/blob/564822d31f071c6dfacd112ef4b7146c73077cd9/src/backend/core/api/utils.py#L166 # pylint:disable=line-too-long
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from core import utils
|
||||
|
||||
|
||||
def test_detect_mimetype_from_content_pdf():
|
||||
"""Test detect_mimetype detects PDF from content (magic bytes)."""
|
||||
# PDF magic bytes: %PDF
|
||||
pdf_content = b"%PDF-1.4\n"
|
||||
mimetype = utils.detect_mimetype(pdf_content, filename="document.pdf")
|
||||
assert mimetype == "application/pdf"
|
||||
|
||||
|
||||
def test_detect_mimetype_from_content_png():
|
||||
"""Test detect_mimetype detects PNG from content (magic bytes)."""
|
||||
# PNG magic bytes: \x89PNG\r\n\x1a\n
|
||||
png_content = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"
|
||||
mimetype = utils.detect_mimetype(png_content, filename="image.png")
|
||||
assert mimetype == "image/png"
|
||||
|
||||
|
||||
def test_detect_mimetype_from_content_jpeg():
|
||||
"""Test detect_mimetype detects JPEG from content (magic bytes)."""
|
||||
# JPEG magic bytes: \xff\xd8\xff
|
||||
jpeg_content = b"\xff\xd8\xff\xe0\x00\x10JFIF"
|
||||
mimetype = utils.detect_mimetype(jpeg_content, filename="photo.jpg")
|
||||
assert mimetype == "image/jpeg"
|
||||
|
||||
|
||||
def test_detect_mimetype_from_content_text_plain():
|
||||
"""Test detect_mimetype detects plain text from content."""
|
||||
text_content = b"This is plain text content"
|
||||
mimetype = utils.detect_mimetype(text_content, filename="file.txt")
|
||||
assert mimetype == "text/plain"
|
||||
|
||||
|
||||
def test_detect_mimetype_empty_file():
|
||||
"""Test detect_mimetype handles empty files."""
|
||||
empty_content = b""
|
||||
mimetype = utils.detect_mimetype(empty_content, filename="empty.txt")
|
||||
# Empty files should be detected as application/x-empty by magic bytes
|
||||
assert mimetype == "application/x-empty"
|
||||
|
||||
|
||||
def test_detect_mimetype_uses_extension_when_content_generic():
|
||||
"""Test detect_mimetype uses extension when content detection returns generic type."""
|
||||
# Generic binary content that might be detected as application/octet-stream
|
||||
generic_content = b"\x00\x01\x02\x03\x04\x05"
|
||||
# But if we have a specific extension, we should use it
|
||||
mimetype = utils.detect_mimetype(generic_content, filename="document.pdf")
|
||||
# Should prefer extension-based detection for specific types
|
||||
assert mimetype == "application/pdf"
|
||||
|
||||
|
||||
def test_detect_mimetype_uses_extension_for_json():
|
||||
"""Test detect_mimetype uses extension for JSON files."""
|
||||
# JSON content might be detected as text/plain
|
||||
json_content = b'{"key": "value"}'
|
||||
mimetype = utils.detect_mimetype(json_content, filename="data.json")
|
||||
# Should use extension to get application/json
|
||||
assert mimetype == "application/json"
|
||||
|
||||
|
||||
def test_detect_mimetype_without_filename():
|
||||
"""Test detect_mimetype works without filename (content-only detection)."""
|
||||
pdf_content = b"%PDF-1.4\n"
|
||||
mimetype = utils.detect_mimetype(pdf_content, filename=None)
|
||||
assert mimetype == "application/pdf"
|
||||
|
||||
|
||||
def test_detect_mimetype_fallback_to_extension():
|
||||
"""Test detect_mimetype falls back to extension when content detection is generic."""
|
||||
# Content that might be detected as text/plain
|
||||
content = b"some content"
|
||||
mimetype = utils.detect_mimetype(content, filename="script.js")
|
||||
# Should use extension to get JavaScript MIME type
|
||||
# (can be text/javascript or application/javascript)
|
||||
assert mimetype in ["text/javascript", "application/javascript"]
|
||||
|
||||
|
||||
def test_detect_mimetype_generic_content_no_extension():
|
||||
"""Test detect_mimetype with generic content and no extension."""
|
||||
generic_content = b"\x00\x01\x02\x03"
|
||||
mimetype = utils.detect_mimetype(generic_content, filename="file")
|
||||
# Should return content-based detection (likely application/octet-stream)
|
||||
assert mimetype in ["application/octet-stream", "application/x-empty"]
|
||||
|
||||
|
||||
def test_detect_mimetype_xml_file():
|
||||
"""Test detect_mimetype detects XML files."""
|
||||
xml_content = b'<?xml version="1.0"?><root></root>'
|
||||
mimetype = utils.detect_mimetype(xml_content, filename="data.xml")
|
||||
# Should detect as XML (either from content or extension)
|
||||
assert mimetype in ["application/xml", "text/xml"]
|
||||
|
||||
|
||||
def test_detect_mimetype_csv_file():
|
||||
"""Test detect_mimetype detects CSV files."""
|
||||
csv_content = b"name,age\nJohn,30\nJane,25"
|
||||
mimetype = utils.detect_mimetype(csv_content, filename="data.csv")
|
||||
# CSV might be detected as text/plain, but extension should help
|
||||
assert mimetype in ["text/csv", "text/plain"]
|
||||
|
||||
|
||||
def test_detect_mimetype_zip_file():
|
||||
"""Test detect_mimetype detects ZIP files from magic bytes."""
|
||||
# ZIP magic bytes: PK\x03\x04
|
||||
zip_content = b"PK\x03\x04\x14\x00\x00\x00"
|
||||
mimetype = utils.detect_mimetype(zip_content, filename="archive.zip")
|
||||
assert mimetype == "application/zip"
|
||||
|
||||
|
||||
def test_detect_mimetype_prefers_content_over_extension():
|
||||
"""Test detect_mimetype prefers content detection when both are available and specific."""
|
||||
# PDF content but wrong extension
|
||||
pdf_content = b"%PDF-1.4\n"
|
||||
mimetype = utils.detect_mimetype(pdf_content, filename="document.txt")
|
||||
# Should prefer content detection (PDF) over extension (txt)
|
||||
assert mimetype == "application/pdf"
|
||||
|
||||
|
||||
@pytest.mark.xfail(
|
||||
reason="Fails in our repo, but passes in the original repo, leaving it there"
|
||||
)
|
||||
def test_detect_mimetype_powerpoint_pptx():
|
||||
"""Test detect_mimetype correctly detects PowerPoint .pptx files."""
|
||||
# .pptx files are ZIP archives, so content might be detected as application/zip or octet-stream
|
||||
# But with the extension, it should be detected as PowerPoint MIME type
|
||||
# Using minimal ZIP-like content that might be detected as generic
|
||||
pptx_content = b"PK\x03\x04" # ZIP magic bytes (PPTX is a ZIP archive)
|
||||
mimetype = utils.detect_mimetype(pptx_content, filename="presentation.pptx")
|
||||
# Should use extension to get PowerPoint MIME type
|
||||
assert mimetype in [
|
||||
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
"application/vnd.ms-powerpoint.presentation.macroEnabled.12",
|
||||
]
|
||||
|
||||
|
||||
def test_detect_mimetype_powerpoint_ppt():
|
||||
"""Test detect_mimetype correctly detects PowerPoint .ppt files (older format)."""
|
||||
# .ppt files might be detected as application/octet-stream by magic bytes
|
||||
# But with the extension, it should be detected as PowerPoint MIME type
|
||||
ppt_content = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1" # OLE2 compound document header
|
||||
mimetype = utils.detect_mimetype(ppt_content, filename="presentation.ppt")
|
||||
# Should use extension to get PowerPoint MIME type
|
||||
assert mimetype in [
|
||||
"application/vnd.ms-powerpoint",
|
||||
"application/mspowerpoint",
|
||||
]
|
||||
|
||||
|
||||
def test_detect_mimetype_ole_storage():
|
||||
"""
|
||||
Microsoft files .xls .doc .ppt can use the mimetype application/x-ole-storage
|
||||
for Microsoft OLE2/Compound File Binary Format. In that case we want to rely on the extension
|
||||
"""
|
||||
|
||||
xls_ole_content = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"
|
||||
mimetype = utils.detect_mimetype(xls_ole_content, filename="document.xls")
|
||||
|
||||
assert mimetype == "application/vnd.ms-excel"
|
||||
@@ -14,6 +14,7 @@ router = DefaultRouter()
|
||||
router.register("users", viewsets.UserViewSet, basename="users")
|
||||
router.register("rooms", viewsets.RoomViewSet, basename="rooms")
|
||||
router.register("recordings", viewsets.RecordingViewSet, basename="recordings")
|
||||
router.register("files", viewsets.FileViewSet, basename="files")
|
||||
router.register(
|
||||
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
|
||||
)
|
||||
|
||||
@@ -7,6 +7,8 @@ Utils functions used in the core app
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import random
|
||||
import secrets
|
||||
import string
|
||||
@@ -17,7 +19,9 @@ from django.conf import settings
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
import aiohttp
|
||||
import boto3
|
||||
import botocore
|
||||
import magic
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit.api import ( # pylint: disable=E0611
|
||||
AccessToken,
|
||||
@@ -29,6 +33,8 @@ from livekit.api import ( # pylint: disable=E0611
|
||||
VideoGrants,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def generate_color(identity: str) -> str:
|
||||
"""Generates a consistent HSL color based on a given identity string.
|
||||
@@ -344,3 +350,108 @@ def generate_room_slug():
|
||||
for size in sizes
|
||||
]
|
||||
return "-".join(parts)
|
||||
|
||||
|
||||
def detect_mimetype(file_buffer: bytes, filename: str | None = None) -> str:
|
||||
"""
|
||||
Detect MIME type using multiple methods for better accuracy.
|
||||
|
||||
This function combines:
|
||||
1. Magic bytes detection (python-magic) - most reliable for actual file content
|
||||
2. File extension detection (mimetypes) - useful as fallback or for validation
|
||||
|
||||
Args:
|
||||
file_buffer: The file content buffer (first bytes of the file)
|
||||
filename: Optional filename to extract extension from
|
||||
|
||||
Returns:
|
||||
str: The detected MIME type
|
||||
|
||||
Notes:
|
||||
Originally from https://github.com/suitenumerique/drive/blob/564822d31f071c6dfacd112ef4b7146c73077cd9/src/backend/core/api/utils.py#L166 # pylint:disable=line-too-long
|
||||
"""
|
||||
# Initialize magic detector
|
||||
mime_detector = magic.Magic(mime=True)
|
||||
|
||||
# Method 1: Detect from file content (magic bytes) - most reliable
|
||||
mimetype_from_content = mime_detector.from_buffer(file_buffer)
|
||||
|
||||
# If we have a filename, try extension-based detection as well
|
||||
mimetype_from_extension = None
|
||||
if filename:
|
||||
# Use mimetypes module to guess from extension
|
||||
# Use guess_file_type (Python 3.13+) instead of deprecated guess_type
|
||||
mimetype_from_extension, _ = mimetypes.guess_file_type(filename, strict=False)
|
||||
|
||||
logger.debug("detect_mimetype: mimetype_from_content: %s", mimetype_from_content)
|
||||
logger.debug(
|
||||
"detect_mimetype: mimetype_from_extension: %s", mimetype_from_extension
|
||||
)
|
||||
|
||||
# Strategy: Prefer content-based detection, but use extension if:
|
||||
# 1. Content detection returns generic types (application/octet-stream, text/plain)
|
||||
# 2. Content detection fails or returns None
|
||||
# 3. Extension detection provides a more specific type
|
||||
|
||||
# Generic/unreliable MIME types that we should try to improve
|
||||
generic_types = {
|
||||
"application/octet-stream",
|
||||
"application/x-ole-storage", # used by .xls, .doc and .ppt
|
||||
"application/zip",
|
||||
"text/plain",
|
||||
}
|
||||
|
||||
# If content detection gives us a generic type and we have extension info
|
||||
if mimetype_from_content in generic_types and mimetype_from_extension:
|
||||
# Use extension-based detection if it's more specific
|
||||
if mimetype_from_extension not in generic_types:
|
||||
return mimetype_from_extension
|
||||
|
||||
# If content detection failed, returned None or is a generic type, use extension if available
|
||||
if not mimetype_from_content or mimetype_from_content in generic_types:
|
||||
if mimetype_from_extension:
|
||||
return mimetype_from_extension
|
||||
|
||||
# Default to content-based detection (most reliable)
|
||||
return mimetype_from_content or "application/octet-stream"
|
||||
|
||||
|
||||
def generate_upload_policy(file):
|
||||
"""
|
||||
Generate a S3 upload policy for a given file.
|
||||
|
||||
Notes:
|
||||
Originally taken from https://github.com/suitenumerique/drive/blob/564822d31f071c6dfacd112ef4b7146c73077cd9/src/backend/core/api/utils.py#L102 # pylint: disable=line-too-long
|
||||
"""
|
||||
|
||||
key = file.file_key
|
||||
|
||||
# This settings should be used if the backend application and the frontend application
|
||||
# can't connect to the object storage with the same domain. This is the case in the
|
||||
# docker compose stack used in development. The frontend application will use localhost
|
||||
# to connect to the object storage while the backend application will use the object storage
|
||||
# service name declared in the docker compose stack.
|
||||
# This is needed because the domain name is used to compute the signature. So it can't be
|
||||
# changed dynamically by the frontend application.
|
||||
if settings.AWS_S3_DOMAIN_REPLACE:
|
||||
s3_client = boto3.client(
|
||||
"s3",
|
||||
aws_access_key_id=settings.AWS_S3_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=settings.AWS_S3_SECRET_ACCESS_KEY,
|
||||
endpoint_url=settings.AWS_S3_DOMAIN_REPLACE,
|
||||
config=botocore.client.Config(
|
||||
region_name=settings.AWS_S3_REGION_NAME,
|
||||
signature_version=settings.AWS_S3_SIGNATURE_VERSION,
|
||||
),
|
||||
)
|
||||
else:
|
||||
s3_client = default_storage.connection.meta.client
|
||||
|
||||
# Generate the policy
|
||||
policy = s3_client.generate_presigned_url(
|
||||
ClientMethod="put_object",
|
||||
Params={"Bucket": default_storage.bucket_name, "Key": key, "ACL": "private"},
|
||||
ExpiresIn=settings.AWS_S3_UPLOAD_POLICY_EXPIRATION,
|
||||
)
|
||||
|
||||
return policy
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
|
||||
"POT-Creation-Date: 2026-03-12 13:46+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -29,141 +29,168 @@ msgstr "Berechtigungen"
|
||||
msgid "Important dates"
|
||||
msgstr "Wichtige Daten"
|
||||
|
||||
#: core/admin.py:128 core/admin.py:228
|
||||
#: core/admin.py:132 core/admin.py:275
|
||||
msgid "No owner"
|
||||
msgstr "Kein Eigentümer"
|
||||
|
||||
#: core/admin.py:131 core/admin.py:231
|
||||
#: core/admin.py:135 core/admin.py:278
|
||||
msgid "Multiple owners"
|
||||
msgstr "Mehrere Eigentümer"
|
||||
|
||||
#: core/admin.py:143
|
||||
#: core/admin.py:148
|
||||
msgid "Resend notification to external service"
|
||||
msgstr "Benachrichtigung erneut an externen Dienst senden"
|
||||
|
||||
#: core/admin.py:166
|
||||
#: core/admin.py:171
|
||||
#, python-format
|
||||
msgid "Failed to notify for recording %(id)s"
|
||||
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen"
|
||||
|
||||
#: core/admin.py:174
|
||||
#: core/admin.py:179
|
||||
#, python-format
|
||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen: %(error)s"
|
||||
|
||||
#: core/admin.py:182
|
||||
#: core/admin.py:187
|
||||
#, python-format
|
||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||
msgstr "Benachrichtigungen für %(count)s Aufnahme(n) erfolgreich gesendet."
|
||||
|
||||
#: core/admin.py:190
|
||||
#: core/admin.py:195
|
||||
#, python-format
|
||||
msgid "Skipped %(count)s expired recording(s)."
|
||||
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
|
||||
|
||||
#: core/admin.py:294
|
||||
#: core/admin.py:200
|
||||
msgid "Mark selected recordings as 'Failed to Stop'"
|
||||
msgstr "Ausgewählte Aufnahmen als ‚Fehler beim Stoppen‘ markieren"
|
||||
|
||||
#: core/admin.py:218
|
||||
#, python-format
|
||||
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
||||
msgstr "%(count)s Aufnahme(n) erfolgreich als ‚Fehler beim Stoppen‘ markiert."
|
||||
|
||||
#: core/admin.py:226
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Skipped %(count)s expired recording(s)."
|
||||
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
||||
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
|
||||
|
||||
#: core/admin.py:342
|
||||
msgid "No scopes"
|
||||
msgstr "Keine Scopes"
|
||||
|
||||
#: core/admin.py:296
|
||||
#: core/admin.py:344
|
||||
msgid "Scopes"
|
||||
msgstr "Scopes"
|
||||
|
||||
#: core/api/serializers.py:68
|
||||
#: core/api/filters.py:25
|
||||
msgid "Creator is me"
|
||||
msgstr "Ersteller bin ich"
|
||||
|
||||
#: core/api/serializers.py:88
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr ""
|
||||
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
|
||||
"hinzuzufügen."
|
||||
|
||||
#: core/models.py:34
|
||||
#: core/api/serializers.py:516
|
||||
msgid "This file extension is not allowed."
|
||||
msgstr "Diese Dateiendung ist nicht erlaubt."
|
||||
|
||||
#: core/api/serializers.py:533
|
||||
msgid "You have reached the maximum number of files for this type."
|
||||
msgstr "Sie haben die maximale Anzahl an Dateien dieses Typs erreicht."
|
||||
|
||||
#: core/models.py:37
|
||||
msgid "Member"
|
||||
msgstr "Mitglied"
|
||||
|
||||
#: core/models.py:35
|
||||
#: core/models.py:38
|
||||
msgid "Administrator"
|
||||
msgstr "Administrator"
|
||||
|
||||
#: core/models.py:36
|
||||
#: core/models.py:39
|
||||
msgid "Owner"
|
||||
msgstr "Eigentümer"
|
||||
|
||||
#: core/models.py:52
|
||||
#: core/models.py:55
|
||||
msgid "Initiated"
|
||||
msgstr "Gestartet"
|
||||
|
||||
#: core/models.py:53
|
||||
#: core/models.py:56
|
||||
msgid "Active"
|
||||
msgstr "Aktiv"
|
||||
|
||||
#: core/models.py:54
|
||||
#: core/models.py:57
|
||||
msgid "Stopped"
|
||||
msgstr "Beendet"
|
||||
|
||||
#: core/models.py:55
|
||||
#: core/models.py:58
|
||||
msgid "Saved"
|
||||
msgstr "Gespeichert"
|
||||
|
||||
#: core/models.py:56
|
||||
#: core/models.py:59
|
||||
msgid "Aborted"
|
||||
msgstr "Abgebrochen"
|
||||
|
||||
#: core/models.py:57
|
||||
#: core/models.py:60
|
||||
msgid "Failed to Start"
|
||||
msgstr "Start fehlgeschlagen"
|
||||
|
||||
#: core/models.py:58
|
||||
#: core/models.py:61
|
||||
msgid "Failed to Stop"
|
||||
msgstr "Stopp fehlgeschlagen"
|
||||
|
||||
#: core/models.py:59
|
||||
#: core/models.py:62
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Benachrichtigung erfolgreich"
|
||||
|
||||
#: core/models.py:86
|
||||
#: core/models.py:89
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "BILDSCHIRMAUFZEICHNUNG"
|
||||
|
||||
#: core/models.py:87
|
||||
#: core/models.py:90
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSKRIPT"
|
||||
|
||||
#: core/models.py:93
|
||||
#: core/models.py:96
|
||||
msgid "Public Access"
|
||||
msgstr "Öffentlicher Zugriff"
|
||||
|
||||
#: core/models.py:94
|
||||
#: core/models.py:97
|
||||
msgid "Trusted Access"
|
||||
msgstr "Vertrauenswürdiger Zugriff"
|
||||
|
||||
#: core/models.py:95
|
||||
#: core/models.py:98
|
||||
msgid "Restricted Access"
|
||||
msgstr "Eingeschränkter Zugriff"
|
||||
|
||||
#: core/models.py:107
|
||||
#: core/models.py:110
|
||||
msgid "id"
|
||||
msgstr "ID"
|
||||
|
||||
#: core/models.py:108
|
||||
#: core/models.py:111
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "Primärschlüssel des Eintrags als UUID"
|
||||
|
||||
#: core/models.py:114
|
||||
#: core/models.py:117
|
||||
msgid "created on"
|
||||
msgstr "erstellt am"
|
||||
|
||||
#: core/models.py:115
|
||||
#: core/models.py:118
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
|
||||
|
||||
#: core/models.py:120
|
||||
#: core/models.py:123
|
||||
msgid "updated on"
|
||||
msgstr "aktualisiert am"
|
||||
|
||||
#: core/models.py:121
|
||||
#: core/models.py:124
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
|
||||
|
||||
#: core/models.py:141
|
||||
#: core/models.py:144
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
@@ -171,11 +198,11 @@ msgstr ""
|
||||
"Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen "
|
||||
"und die Zeichen @/./+/-/_ enthalten."
|
||||
|
||||
#: core/models.py:147
|
||||
#: core/models.py:150
|
||||
msgid "sub"
|
||||
msgstr "Sub"
|
||||
|
||||
#: core/models.py:149
|
||||
#: core/models.py:152
|
||||
msgid ""
|
||||
"Optional for pending users; required upon account activation. 255 characters "
|
||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
@@ -183,55 +210,55 @@ msgstr ""
|
||||
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
|
||||
"Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt."
|
||||
|
||||
#: core/models.py:158
|
||||
#: core/models.py:161
|
||||
msgid "identity email address"
|
||||
msgstr "Identitäts-E-Mail-Adresse"
|
||||
|
||||
#: core/models.py:163
|
||||
#: core/models.py:166
|
||||
msgid "admin email address"
|
||||
msgstr "Administrator-E-Mail-Adresse"
|
||||
|
||||
#: core/models.py:165
|
||||
#: core/models.py:168
|
||||
msgid "full name"
|
||||
msgstr "Vollständiger Name"
|
||||
|
||||
#: core/models.py:167
|
||||
#: core/models.py:170
|
||||
msgid "short name"
|
||||
msgstr "Kurzname"
|
||||
|
||||
#: core/models.py:173
|
||||
#: core/models.py:176
|
||||
msgid "language"
|
||||
msgstr "Sprache"
|
||||
|
||||
#: core/models.py:174
|
||||
#: core/models.py:177
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
|
||||
|
||||
#: core/models.py:180
|
||||
#: core/models.py:183
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
|
||||
|
||||
#: core/models.py:183
|
||||
#: core/models.py:186
|
||||
msgid "device"
|
||||
msgstr "Gerät"
|
||||
|
||||
#: core/models.py:185
|
||||
#: core/models.py:188
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
|
||||
|
||||
#: core/models.py:188
|
||||
#: core/models.py:191
|
||||
msgid "staff status"
|
||||
msgstr "Mitarbeiterstatus"
|
||||
|
||||
#: core/models.py:190
|
||||
#: core/models.py:193
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
|
||||
|
||||
#: core/models.py:193
|
||||
#: core/models.py:196
|
||||
msgid "active"
|
||||
msgstr "aktiv"
|
||||
|
||||
#: core/models.py:196
|
||||
#: core/models.py:199
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
@@ -239,66 +266,66 @@ msgstr ""
|
||||
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
|
||||
"anstelle des Löschens des Kontos."
|
||||
|
||||
#: core/models.py:209
|
||||
#: core/models.py:212
|
||||
msgid "user"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#: core/models.py:210
|
||||
#: core/models.py:213
|
||||
msgid "users"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#: core/models.py:269
|
||||
#: core/models.py:272
|
||||
msgid "Resource"
|
||||
msgstr "Ressource"
|
||||
|
||||
#: core/models.py:270
|
||||
#: core/models.py:273
|
||||
msgid "Resources"
|
||||
msgstr "Ressourcen"
|
||||
|
||||
#: core/models.py:324
|
||||
#: core/models.py:331
|
||||
msgid "Resource access"
|
||||
msgstr "Ressourcenzugriff"
|
||||
|
||||
#: core/models.py:325
|
||||
#: core/models.py:332
|
||||
msgid "Resource accesses"
|
||||
msgstr "Ressourcenzugriffe"
|
||||
|
||||
#: core/models.py:331
|
||||
#: core/models.py:338
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr ""
|
||||
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
|
||||
"bereits."
|
||||
|
||||
#: core/models.py:387
|
||||
#: core/models.py:394
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Visio-Raumkonfiguration"
|
||||
|
||||
#: core/models.py:388
|
||||
#: core/models.py:395
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
|
||||
|
||||
#: core/models.py:395
|
||||
#: core/models.py:402
|
||||
msgid "Room PIN code"
|
||||
msgstr "PIN-Code für den Raum"
|
||||
|
||||
#: core/models.py:396
|
||||
#: core/models.py:403
|
||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||
msgstr ""
|
||||
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
|
||||
|
||||
#: core/models.py:402 core/models.py:556
|
||||
#: core/models.py:409 core/models.py:563
|
||||
msgid "Room"
|
||||
msgstr "Raum"
|
||||
|
||||
#: core/models.py:403
|
||||
#: core/models.py:410
|
||||
msgid "Rooms"
|
||||
msgstr "Räume"
|
||||
|
||||
#: core/models.py:567
|
||||
#: core/models.py:574
|
||||
msgid "Worker ID"
|
||||
msgstr "Worker-ID"
|
||||
|
||||
#: core/models.py:569
|
||||
#: core/models.py:576
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
@@ -307,108 +334,149 @@ msgstr ""
|
||||
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
|
||||
"ermöglicht."
|
||||
|
||||
#: core/models.py:577
|
||||
#: core/models.py:584
|
||||
msgid "Recording mode"
|
||||
msgstr "Aufzeichnungsmodus"
|
||||
|
||||
#: core/models.py:578
|
||||
#: core/models.py:585
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
|
||||
|
||||
#: core/models.py:583 core/models.py:584
|
||||
#: core/models.py:590 core/models.py:591
|
||||
msgid "Recording options"
|
||||
msgstr "Aufnahmeoptionen"
|
||||
|
||||
#: core/models.py:590
|
||||
#: core/models.py:597
|
||||
msgid "Recording"
|
||||
msgstr "Aufzeichnung"
|
||||
|
||||
#: core/models.py:591
|
||||
#: core/models.py:598
|
||||
msgid "Recordings"
|
||||
msgstr "Aufzeichnungen"
|
||||
|
||||
#: core/models.py:699
|
||||
#: core/models.py:706
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Beziehung Aufzeichnung/Benutzer"
|
||||
|
||||
#: core/models.py:700
|
||||
#: core/models.py:707
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Beziehungen Aufzeichnung/Benutzer"
|
||||
|
||||
#: core/models.py:706
|
||||
#: core/models.py:713
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
|
||||
|
||||
#: core/models.py:712
|
||||
#: core/models.py:719
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
|
||||
|
||||
#: core/models.py:718
|
||||
#: core/models.py:725
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
|
||||
|
||||
#: core/models.py:735
|
||||
#: core/models.py:742
|
||||
msgid "Create rooms"
|
||||
msgstr "Räume erstellen"
|
||||
|
||||
#: core/models.py:736
|
||||
#: core/models.py:743
|
||||
msgid "List rooms"
|
||||
msgstr "Räume auflisten"
|
||||
|
||||
#: core/models.py:737
|
||||
#: core/models.py:744
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Raumdetails abrufen"
|
||||
|
||||
#: core/models.py:738
|
||||
#: core/models.py:745
|
||||
msgid "Update rooms"
|
||||
msgstr "Räume aktualisieren"
|
||||
|
||||
#: core/models.py:739
|
||||
#: core/models.py:746
|
||||
msgid "Delete rooms"
|
||||
msgstr "Räume löschen"
|
||||
|
||||
#: core/models.py:752
|
||||
#: core/models.py:759
|
||||
msgid "Application name"
|
||||
msgstr "Anwendungsname"
|
||||
|
||||
#: core/models.py:753
|
||||
#: core/models.py:760
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Beschreibender Name für diese Anwendung."
|
||||
|
||||
#: core/models.py:763
|
||||
#: core/models.py:770
|
||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||
msgstr ""
|
||||
"Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
|
||||
|
||||
#: core/models.py:774
|
||||
#: core/models.py:781
|
||||
msgid "Application"
|
||||
msgstr "Anwendung"
|
||||
|
||||
#: core/models.py:775
|
||||
#: core/models.py:782
|
||||
msgid "Applications"
|
||||
msgstr "Anwendungen"
|
||||
|
||||
#: core/models.py:798
|
||||
#: core/models.py:805
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Geben Sie eine gültige Domain ein"
|
||||
|
||||
#: core/models.py:801
|
||||
#: core/models.py:808
|
||||
msgid "Domain"
|
||||
msgstr "Domain"
|
||||
|
||||
#: core/models.py:802
|
||||
#: core/models.py:809
|
||||
msgid "Email domain this application can act on behalf of."
|
||||
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
|
||||
|
||||
#: core/models.py:814
|
||||
#: core/models.py:821
|
||||
msgid "Application domain"
|
||||
msgstr "Anwendungsdomain"
|
||||
|
||||
#: core/models.py:815
|
||||
#: core/models.py:822
|
||||
msgid "Application domains"
|
||||
msgstr "Anwendungsdomains"
|
||||
|
||||
#: core/recording/event/notification.py:94
|
||||
#: core/models.py:840
|
||||
msgid "Pending"
|
||||
msgstr "Ausstehend"
|
||||
|
||||
#: core/models.py:848
|
||||
msgid "Ready"
|
||||
msgstr "Bereit"
|
||||
|
||||
#: core/models.py:854
|
||||
msgid "Background image"
|
||||
msgstr "Hintergrundbild"
|
||||
|
||||
#: core/models.py:866
|
||||
msgid "title"
|
||||
msgstr "Titel"
|
||||
|
||||
#: core/models.py:890
|
||||
msgid "Malware detection info when the analysis status is unsafe."
|
||||
msgstr ""
|
||||
"Informationen zur Malware-Erkennung, wenn der Analyse-Status unsicher ist."
|
||||
|
||||
#: core/models.py:895
|
||||
msgid "File"
|
||||
msgstr "Datei"
|
||||
|
||||
#: core/models.py:896
|
||||
msgid "Files"
|
||||
msgstr "Dateien"
|
||||
|
||||
#: core/models.py:1000
|
||||
msgid "This file is already hard deleted."
|
||||
msgstr "Diese Datei wurde bereits endgültig gelöscht."
|
||||
|
||||
#: core/models.py:1010
|
||||
#, fuzzy
|
||||
#| msgid "To hard delete a file, it must first be soft deleted."
|
||||
msgid "To hard delete a file, it must first be soft deleted."
|
||||
msgstr ""
|
||||
"Um eine Datei endgültig zu löschen, muss sie zuvor weich gelöscht worden "
|
||||
"sein."
|
||||
|
||||
#: core/recording/event/notification.py:116
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Ihre Aufzeichnung ist bereit"
|
||||
|
||||
@@ -536,18 +604,18 @@ msgstr ""
|
||||
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
|
||||
"an unser Support-Team unter %(support_email)s. "
|
||||
|
||||
#: meet/settings.py:169
|
||||
#: meet/settings.py:223
|
||||
msgid "English"
|
||||
msgstr "Englisch"
|
||||
|
||||
#: meet/settings.py:170
|
||||
#: meet/settings.py:224
|
||||
msgid "French"
|
||||
msgstr "Französisch"
|
||||
|
||||
#: meet/settings.py:171
|
||||
#: meet/settings.py:225
|
||||
msgid "Dutch"
|
||||
msgstr "Niederländisch"
|
||||
|
||||
#: meet/settings.py:172
|
||||
#: meet/settings.py:226
|
||||
msgid "German"
|
||||
msgstr "Deutsch"
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
|
||||
"POT-Creation-Date: 2026-03-12 13:46+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -29,139 +29,166 @@ msgstr "Permissions"
|
||||
msgid "Important dates"
|
||||
msgstr "Important dates"
|
||||
|
||||
#: core/admin.py:128 core/admin.py:228
|
||||
#: core/admin.py:132 core/admin.py:275
|
||||
msgid "No owner"
|
||||
msgstr "No owner"
|
||||
|
||||
#: core/admin.py:131 core/admin.py:231
|
||||
#: core/admin.py:135 core/admin.py:278
|
||||
msgid "Multiple owners"
|
||||
msgstr "Multiple owners"
|
||||
|
||||
#: core/admin.py:143
|
||||
#: core/admin.py:148
|
||||
msgid "Resend notification to external service"
|
||||
msgstr "Resend notification to external service"
|
||||
|
||||
#: core/admin.py:166
|
||||
#: core/admin.py:171
|
||||
#, python-format
|
||||
msgid "Failed to notify for recording %(id)s"
|
||||
msgstr "Failed to notify for recording %(id)s"
|
||||
|
||||
#: core/admin.py:174
|
||||
#: core/admin.py:179
|
||||
#, python-format
|
||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||
msgstr "Failed to notify for recording %(id)s: %(error)s"
|
||||
|
||||
#: core/admin.py:182
|
||||
#: core/admin.py:187
|
||||
#, python-format
|
||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||
msgstr "Successfully sent notifications for %(count)s recording(s)."
|
||||
|
||||
#: core/admin.py:190
|
||||
#: core/admin.py:195
|
||||
#, python-format
|
||||
msgid "Skipped %(count)s expired recording(s)."
|
||||
msgstr "Skipped %(count)s expired recording(s)."
|
||||
|
||||
#: core/admin.py:294
|
||||
#: core/admin.py:200
|
||||
msgid "Mark selected recordings as 'Failed to Stop'"
|
||||
msgstr "Mark selected recordings as 'Failed to Stop'"
|
||||
|
||||
#: core/admin.py:218
|
||||
#, python-format
|
||||
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
||||
msgstr "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
||||
|
||||
#: core/admin.py:226
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Skipped %(count)s expired recording(s)."
|
||||
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
||||
msgstr "Skipped %(count)s expired recording(s)."
|
||||
|
||||
#: core/admin.py:342
|
||||
msgid "No scopes"
|
||||
msgstr "No scopes"
|
||||
|
||||
#: core/admin.py:296
|
||||
#: core/admin.py:344
|
||||
msgid "Scopes"
|
||||
msgstr "Scopes"
|
||||
|
||||
#: core/api/serializers.py:68
|
||||
#: core/api/filters.py:25
|
||||
msgid "Creator is me"
|
||||
msgstr "Creator is me"
|
||||
|
||||
#: core/api/serializers.py:88
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr "You must be administrator or owner of a room to add accesses to it."
|
||||
|
||||
#: core/models.py:34
|
||||
#: core/api/serializers.py:516
|
||||
msgid "This file extension is not allowed."
|
||||
msgstr "This file extension is not allowed."
|
||||
|
||||
#: core/api/serializers.py:533
|
||||
msgid "You have reached the maximum number of files for this type."
|
||||
msgstr "You have reached the maximum number of files for this type."
|
||||
|
||||
#: core/models.py:37
|
||||
msgid "Member"
|
||||
msgstr "Member"
|
||||
|
||||
#: core/models.py:35
|
||||
#: core/models.py:38
|
||||
msgid "Administrator"
|
||||
msgstr "Administrator"
|
||||
|
||||
#: core/models.py:36
|
||||
#: core/models.py:39
|
||||
msgid "Owner"
|
||||
msgstr "Owner"
|
||||
|
||||
#: core/models.py:52
|
||||
#: core/models.py:55
|
||||
msgid "Initiated"
|
||||
msgstr "Initiated"
|
||||
|
||||
#: core/models.py:53
|
||||
#: core/models.py:56
|
||||
msgid "Active"
|
||||
msgstr "Active"
|
||||
|
||||
#: core/models.py:54
|
||||
#: core/models.py:57
|
||||
msgid "Stopped"
|
||||
msgstr "Stopped"
|
||||
|
||||
#: core/models.py:55
|
||||
#: core/models.py:58
|
||||
msgid "Saved"
|
||||
msgstr "Saved"
|
||||
|
||||
#: core/models.py:56
|
||||
#: core/models.py:59
|
||||
msgid "Aborted"
|
||||
msgstr "Aborted"
|
||||
|
||||
#: core/models.py:57
|
||||
#: core/models.py:60
|
||||
msgid "Failed to Start"
|
||||
msgstr "Failed to Start"
|
||||
|
||||
#: core/models.py:58
|
||||
#: core/models.py:61
|
||||
msgid "Failed to Stop"
|
||||
msgstr "Failed to Stop"
|
||||
|
||||
#: core/models.py:59
|
||||
#: core/models.py:62
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Notification succeeded"
|
||||
|
||||
#: core/models.py:86
|
||||
#: core/models.py:89
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "SCREEN_RECORDING"
|
||||
|
||||
#: core/models.py:87
|
||||
#: core/models.py:90
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSCRIPT"
|
||||
|
||||
#: core/models.py:93
|
||||
#: core/models.py:96
|
||||
msgid "Public Access"
|
||||
msgstr "Public Access"
|
||||
|
||||
#: core/models.py:94
|
||||
#: core/models.py:97
|
||||
msgid "Trusted Access"
|
||||
msgstr "Trusted Access"
|
||||
|
||||
#: core/models.py:95
|
||||
#: core/models.py:98
|
||||
msgid "Restricted Access"
|
||||
msgstr "Restricted Access"
|
||||
|
||||
#: core/models.py:107
|
||||
#: core/models.py:110
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: core/models.py:108
|
||||
#: core/models.py:111
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "primary key for the record as UUID"
|
||||
|
||||
#: core/models.py:114
|
||||
#: core/models.py:117
|
||||
msgid "created on"
|
||||
msgstr "created on"
|
||||
|
||||
#: core/models.py:115
|
||||
#: core/models.py:118
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "date and time at which a record was created"
|
||||
|
||||
#: core/models.py:120
|
||||
#: core/models.py:123
|
||||
msgid "updated on"
|
||||
msgstr "updated on"
|
||||
|
||||
#: core/models.py:121
|
||||
#: core/models.py:124
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "date and time at which a record was last updated"
|
||||
|
||||
#: core/models.py:141
|
||||
#: core/models.py:144
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
@@ -169,11 +196,11 @@ msgstr ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
|
||||
#: core/models.py:147
|
||||
#: core/models.py:150
|
||||
msgid "sub"
|
||||
msgstr "sub"
|
||||
|
||||
#: core/models.py:149
|
||||
#: core/models.py:152
|
||||
msgid ""
|
||||
"Optional for pending users; required upon account activation. 255 characters "
|
||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
@@ -181,55 +208,55 @@ msgstr ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
|
||||
#: core/models.py:158
|
||||
#: core/models.py:161
|
||||
msgid "identity email address"
|
||||
msgstr "identity email address"
|
||||
|
||||
#: core/models.py:163
|
||||
#: core/models.py:166
|
||||
msgid "admin email address"
|
||||
msgstr "admin email address"
|
||||
|
||||
#: core/models.py:165
|
||||
#: core/models.py:168
|
||||
msgid "full name"
|
||||
msgstr "full name"
|
||||
|
||||
#: core/models.py:167
|
||||
#: core/models.py:170
|
||||
msgid "short name"
|
||||
msgstr "short name"
|
||||
|
||||
#: core/models.py:173
|
||||
#: core/models.py:176
|
||||
msgid "language"
|
||||
msgstr "language"
|
||||
|
||||
#: core/models.py:174
|
||||
#: core/models.py:177
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "The language in which the user wants to see the interface."
|
||||
|
||||
#: core/models.py:180
|
||||
#: core/models.py:183
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "The timezone in which the user wants to see times."
|
||||
|
||||
#: core/models.py:183
|
||||
#: core/models.py:186
|
||||
msgid "device"
|
||||
msgstr "device"
|
||||
|
||||
#: core/models.py:185
|
||||
#: core/models.py:188
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Whether the user is a device or a real user."
|
||||
|
||||
#: core/models.py:188
|
||||
#: core/models.py:191
|
||||
msgid "staff status"
|
||||
msgstr "staff status"
|
||||
|
||||
#: core/models.py:190
|
||||
#: core/models.py:193
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Whether the user can log into this admin site."
|
||||
|
||||
#: core/models.py:193
|
||||
#: core/models.py:196
|
||||
msgid "active"
|
||||
msgstr "active"
|
||||
|
||||
#: core/models.py:196
|
||||
#: core/models.py:199
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
@@ -237,63 +264,63 @@ msgstr ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
|
||||
#: core/models.py:209
|
||||
#: core/models.py:212
|
||||
msgid "user"
|
||||
msgstr "user"
|
||||
|
||||
#: core/models.py:210
|
||||
#: core/models.py:213
|
||||
msgid "users"
|
||||
msgstr "users"
|
||||
|
||||
#: core/models.py:269
|
||||
#: core/models.py:272
|
||||
msgid "Resource"
|
||||
msgstr "Resource"
|
||||
|
||||
#: core/models.py:270
|
||||
#: core/models.py:273
|
||||
msgid "Resources"
|
||||
msgstr "Resources"
|
||||
|
||||
#: core/models.py:324
|
||||
#: core/models.py:331
|
||||
msgid "Resource access"
|
||||
msgstr "Resource access"
|
||||
|
||||
#: core/models.py:325
|
||||
#: core/models.py:332
|
||||
msgid "Resource accesses"
|
||||
msgstr "Resource accesses"
|
||||
|
||||
#: core/models.py:331
|
||||
#: core/models.py:338
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr "Resource access with this User and Resource already exists."
|
||||
|
||||
#: core/models.py:387
|
||||
#: core/models.py:394
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Visio room configuration"
|
||||
|
||||
#: core/models.py:388
|
||||
#: core/models.py:395
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr "Values for Visio parameters to configure the room."
|
||||
|
||||
#: core/models.py:395
|
||||
#: core/models.py:402
|
||||
msgid "Room PIN code"
|
||||
msgstr "Room PIN code"
|
||||
|
||||
#: core/models.py:396
|
||||
#: core/models.py:403
|
||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||
msgstr "Unique n-digit code that identifies this room in telephony mode."
|
||||
|
||||
#: core/models.py:402 core/models.py:556
|
||||
#: core/models.py:409 core/models.py:563
|
||||
msgid "Room"
|
||||
msgstr "Room"
|
||||
|
||||
#: core/models.py:403
|
||||
#: core/models.py:410
|
||||
msgid "Rooms"
|
||||
msgstr "Rooms"
|
||||
|
||||
#: core/models.py:567
|
||||
#: core/models.py:574
|
||||
msgid "Worker ID"
|
||||
msgstr "Worker ID"
|
||||
|
||||
#: core/models.py:569
|
||||
#: core/models.py:576
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
@@ -301,111 +328,151 @@ msgstr ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
|
||||
#: core/models.py:577
|
||||
#: core/models.py:584
|
||||
msgid "Recording mode"
|
||||
msgstr "Recording mode"
|
||||
|
||||
#: core/models.py:578
|
||||
#: core/models.py:585
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Defines the mode of recording being called."
|
||||
|
||||
#: core/models.py:583 core/models.py:584
|
||||
#: core/models.py:590 core/models.py:591
|
||||
msgid "Recording options"
|
||||
msgstr "Recording options"
|
||||
|
||||
#: core/models.py:590
|
||||
#: core/models.py:597
|
||||
msgid "Recording"
|
||||
msgstr "Recording"
|
||||
|
||||
#: core/models.py:591
|
||||
#: core/models.py:598
|
||||
msgid "Recordings"
|
||||
msgstr "Recordings"
|
||||
|
||||
#: core/models.py:699
|
||||
#: core/models.py:706
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Recording/user relation"
|
||||
|
||||
#: core/models.py:700
|
||||
#: core/models.py:707
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Recording/user relations"
|
||||
|
||||
#: core/models.py:706
|
||||
#: core/models.py:713
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "This user is already in this recording."
|
||||
|
||||
#: core/models.py:712
|
||||
#: core/models.py:719
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "This team is already in this recording."
|
||||
|
||||
#: core/models.py:718
|
||||
#: core/models.py:725
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Either user or team must be set, not both."
|
||||
|
||||
#: core/models.py:735
|
||||
#: core/models.py:742
|
||||
#, fuzzy
|
||||
#| msgid "created on"
|
||||
msgid "Create rooms"
|
||||
msgstr "Create rooms"
|
||||
|
||||
#: core/models.py:736
|
||||
#: core/models.py:743
|
||||
msgid "List rooms"
|
||||
msgstr "List rooms"
|
||||
|
||||
#: core/models.py:737
|
||||
#: core/models.py:744
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Retrieve room details"
|
||||
|
||||
#: core/models.py:738
|
||||
#: core/models.py:745
|
||||
#, fuzzy
|
||||
#| msgid "updated on"
|
||||
msgid "Update rooms"
|
||||
msgstr "Update rooms"
|
||||
|
||||
#: core/models.py:739
|
||||
#: core/models.py:746
|
||||
msgid "Delete rooms"
|
||||
msgstr "Delete rooms"
|
||||
|
||||
#: core/models.py:752
|
||||
#: core/models.py:759
|
||||
msgid "Application name"
|
||||
msgstr "Application name"
|
||||
|
||||
#: core/models.py:753
|
||||
#: core/models.py:760
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Descriptive name for this application."
|
||||
|
||||
#: core/models.py:763
|
||||
#: core/models.py:770
|
||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||
msgstr "Hashed on Save. Copy it now if this is a new secret."
|
||||
|
||||
#: core/models.py:774
|
||||
#: core/models.py:781
|
||||
msgid "Application"
|
||||
msgstr "Application"
|
||||
|
||||
#: core/models.py:775
|
||||
#: core/models.py:782
|
||||
msgid "Applications"
|
||||
msgstr "Applications"
|
||||
|
||||
#: core/models.py:798
|
||||
#: core/models.py:805
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Enter a valid domain"
|
||||
|
||||
#: core/models.py:801
|
||||
#: core/models.py:808
|
||||
msgid "Domain"
|
||||
msgstr "Domain"
|
||||
|
||||
#: core/models.py:802
|
||||
#: core/models.py:809
|
||||
msgid "Email domain this application can act on behalf of."
|
||||
msgstr "Email domain this application can act on behalf of."
|
||||
|
||||
#: core/models.py:814
|
||||
#: core/models.py:821
|
||||
msgid "Application domain"
|
||||
msgstr "Application domain"
|
||||
|
||||
#: core/models.py:815
|
||||
#: core/models.py:822
|
||||
msgid "Application domains"
|
||||
msgstr "Application domains"
|
||||
|
||||
#: core/recording/event/notification.py:94
|
||||
#: core/models.py:840
|
||||
#, fuzzy
|
||||
#| msgid "Recording"
|
||||
msgid "Pending"
|
||||
msgstr "Pending"
|
||||
|
||||
#: core/models.py:848
|
||||
msgid "Ready"
|
||||
msgstr "Ready"
|
||||
|
||||
#: core/models.py:854
|
||||
msgid "Background image"
|
||||
msgstr "Background image"
|
||||
|
||||
#: core/models.py:866
|
||||
msgid "title"
|
||||
msgstr "title"
|
||||
|
||||
#: core/models.py:890
|
||||
msgid "Malware detection info when the analysis status is unsafe."
|
||||
msgstr "Malware detection info when the analysis status is unsafe."
|
||||
|
||||
#: core/models.py:895
|
||||
msgid "File"
|
||||
msgstr "File"
|
||||
|
||||
#: core/models.py:896
|
||||
msgid "Files"
|
||||
msgstr "Files"
|
||||
|
||||
#: core/models.py:1000
|
||||
#, fuzzy
|
||||
#| msgid "This user is already in this recording."
|
||||
msgid "This file is already hard deleted."
|
||||
msgstr "This file is already hard deleted."
|
||||
|
||||
#: core/models.py:1010
|
||||
msgid "To hard delete a file, it must first be soft deleted."
|
||||
msgstr "To hard delete a file, it must first be soft deleted."
|
||||
|
||||
#: core/recording/event/notification.py:116
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Your recording is ready"
|
||||
|
||||
@@ -533,18 +600,18 @@ msgstr ""
|
||||
" If you have any questions or need assistance, please contact our support "
|
||||
"team at %(support_email)s. "
|
||||
|
||||
#: meet/settings.py:169
|
||||
#: meet/settings.py:223
|
||||
msgid "English"
|
||||
msgstr "English"
|
||||
|
||||
#: meet/settings.py:170
|
||||
#: meet/settings.py:224
|
||||
msgid "French"
|
||||
msgstr "French"
|
||||
|
||||
#: meet/settings.py:171
|
||||
#: meet/settings.py:225
|
||||
msgid "Dutch"
|
||||
msgstr "Dutch"
|
||||
|
||||
#: meet/settings.py:172
|
||||
#: meet/settings.py:226
|
||||
msgid "German"
|
||||
msgstr "German"
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
|
||||
"POT-Creation-Date: 2026-03-12 13:46+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -29,143 +29,171 @@ msgstr "Permissions"
|
||||
msgid "Important dates"
|
||||
msgstr "Dates importantes"
|
||||
|
||||
#: core/admin.py:128 core/admin.py:228
|
||||
#: core/admin.py:132 core/admin.py:275
|
||||
msgid "No owner"
|
||||
msgstr "Pas de propriétaire"
|
||||
|
||||
#: core/admin.py:131 core/admin.py:231
|
||||
#: core/admin.py:135 core/admin.py:278
|
||||
msgid "Multiple owners"
|
||||
msgstr "Plusieurs propriétaires"
|
||||
|
||||
#: core/admin.py:143
|
||||
#: core/admin.py:148
|
||||
msgid "Resend notification to external service"
|
||||
msgstr "Renvoyer la notification au service externe"
|
||||
|
||||
#: core/admin.py:166
|
||||
#: core/admin.py:171
|
||||
#, python-format
|
||||
msgid "Failed to notify for recording %(id)s"
|
||||
msgstr "Échec de la notification pour l’enregistrement %(id)s"
|
||||
|
||||
#: core/admin.py:174
|
||||
#: core/admin.py:179
|
||||
#, python-format
|
||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||
msgstr "Échec de la notification pour l’enregistrement %(id)s : %(error)s"
|
||||
|
||||
#: core/admin.py:182
|
||||
#: core/admin.py:187
|
||||
#, python-format
|
||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||
msgstr "Notifications envoyées avec succès pour %(count)s enregistrement(s)."
|
||||
|
||||
#: core/admin.py:190
|
||||
#: core/admin.py:195
|
||||
#, python-format
|
||||
msgid "Skipped %(count)s expired recording(s)."
|
||||
msgstr "%(count)s enregistrement(s) expiré(s) ignoré(s)."
|
||||
|
||||
#: core/admin.py:294
|
||||
#: core/admin.py:200
|
||||
msgid "Mark selected recordings as 'Failed to Stop'"
|
||||
msgstr "Marquer les enregistrements sélectionnés comme « Échec 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:296
|
||||
#: core/admin.py:344
|
||||
msgid "Scopes"
|
||||
msgstr "Scopes"
|
||||
|
||||
#: core/api/serializers.py:68
|
||||
#: core/api/filters.py:25
|
||||
msgid "Creator is me"
|
||||
msgstr "Je suis le créateur"
|
||||
|
||||
#: core/api/serializers.py:88
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr ""
|
||||
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
|
||||
"des accès."
|
||||
|
||||
#: core/models.py:34
|
||||
#: core/api/serializers.py:516
|
||||
msgid "This file extension is not allowed."
|
||||
msgstr "Cette extension n'est pas autorisée"
|
||||
|
||||
#: core/api/serializers.py:533
|
||||
msgid "You have reached the maximum number of files for this type."
|
||||
msgstr "Vous avez atteint le nombre maximum de fichiers de ce type"
|
||||
|
||||
#: core/models.py:37
|
||||
msgid "Member"
|
||||
msgstr "Membre"
|
||||
|
||||
#: core/models.py:35
|
||||
#: core/models.py:38
|
||||
msgid "Administrator"
|
||||
msgstr "Administrateur"
|
||||
|
||||
#: core/models.py:36
|
||||
#: core/models.py:39
|
||||
msgid "Owner"
|
||||
msgstr "Propriétaire"
|
||||
|
||||
#: core/models.py:52
|
||||
#: core/models.py:55
|
||||
msgid "Initiated"
|
||||
msgstr "Initié"
|
||||
|
||||
#: core/models.py:53
|
||||
#: core/models.py:56
|
||||
msgid "Active"
|
||||
msgstr "Actif"
|
||||
|
||||
#: core/models.py:54
|
||||
#: core/models.py:57
|
||||
msgid "Stopped"
|
||||
msgstr "Arrêté"
|
||||
|
||||
#: core/models.py:55
|
||||
#: core/models.py:58
|
||||
msgid "Saved"
|
||||
msgstr "Enregistré"
|
||||
|
||||
#: core/models.py:56
|
||||
#: core/models.py:59
|
||||
msgid "Aborted"
|
||||
msgstr "Abandonné"
|
||||
|
||||
#: core/models.py:57
|
||||
#: core/models.py:60
|
||||
msgid "Failed to Start"
|
||||
msgstr "Échec au démarrage"
|
||||
|
||||
#: core/models.py:58
|
||||
#: core/models.py:61
|
||||
msgid "Failed to Stop"
|
||||
msgstr "Échec à l'arrêt"
|
||||
|
||||
#: core/models.py:59
|
||||
#: core/models.py:62
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Notification réussie"
|
||||
|
||||
#: core/models.py:86
|
||||
#: core/models.py:89
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "ENREGISTREMENT_ÉCRAN"
|
||||
|
||||
#: core/models.py:87
|
||||
#: core/models.py:90
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSCRIPTION"
|
||||
|
||||
#: core/models.py:93
|
||||
#: core/models.py:96
|
||||
msgid "Public Access"
|
||||
msgstr "Accès public"
|
||||
|
||||
#: core/models.py:94
|
||||
#: core/models.py:97
|
||||
msgid "Trusted Access"
|
||||
msgstr "Accès de confiance"
|
||||
|
||||
#: core/models.py:95
|
||||
#: core/models.py:98
|
||||
msgid "Restricted Access"
|
||||
msgstr "Accès restreint"
|
||||
|
||||
#: core/models.py:107
|
||||
#: core/models.py:110
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: core/models.py:108
|
||||
#: core/models.py:111
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
|
||||
|
||||
#: core/models.py:114
|
||||
#: core/models.py:117
|
||||
msgid "created on"
|
||||
msgstr "créé le"
|
||||
|
||||
#: core/models.py:115
|
||||
#: core/models.py:118
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "date et heure auxquelles un enregistrement a été créé"
|
||||
|
||||
#: core/models.py:120
|
||||
#: core/models.py:123
|
||||
msgid "updated on"
|
||||
msgstr "mis à jour le"
|
||||
|
||||
#: core/models.py:121
|
||||
#: core/models.py:124
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
|
||||
"fois"
|
||||
|
||||
#: core/models.py:141
|
||||
#: core/models.py:144
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
@@ -173,11 +201,11 @@ msgstr ""
|
||||
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des "
|
||||
"chiffres et les caractères @/./+/-/_."
|
||||
|
||||
#: core/models.py:147
|
||||
#: core/models.py:150
|
||||
msgid "sub"
|
||||
msgstr "sub"
|
||||
|
||||
#: core/models.py:149
|
||||
#: core/models.py:152
|
||||
msgid ""
|
||||
"Optional for pending users; required upon account activation. 255 characters "
|
||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
@@ -185,55 +213,55 @@ msgstr ""
|
||||
"Optionnel pour les utilisateurs en attente ; requis lors de l'activation du "
|
||||
"compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement."
|
||||
|
||||
#: core/models.py:158
|
||||
#: core/models.py:161
|
||||
msgid "identity email address"
|
||||
msgstr "adresse e-mail d'identité"
|
||||
|
||||
#: core/models.py:163
|
||||
#: core/models.py:166
|
||||
msgid "admin email address"
|
||||
msgstr "adresse e-mail d'administrateur"
|
||||
|
||||
#: core/models.py:165
|
||||
#: core/models.py:168
|
||||
msgid "full name"
|
||||
msgstr "nom complet"
|
||||
|
||||
#: core/models.py:167
|
||||
#: core/models.py:170
|
||||
msgid "short name"
|
||||
msgstr "nom court"
|
||||
|
||||
#: core/models.py:173
|
||||
#: core/models.py:176
|
||||
msgid "language"
|
||||
msgstr "langue"
|
||||
|
||||
#: core/models.py:174
|
||||
#: core/models.py:177
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
|
||||
|
||||
#: core/models.py:180
|
||||
#: core/models.py:183
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
|
||||
|
||||
#: core/models.py:183
|
||||
#: core/models.py:186
|
||||
msgid "device"
|
||||
msgstr "appareil"
|
||||
|
||||
#: core/models.py:185
|
||||
#: core/models.py:188
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
|
||||
|
||||
#: core/models.py:188
|
||||
#: core/models.py:191
|
||||
msgid "staff status"
|
||||
msgstr "statut du personnel"
|
||||
|
||||
#: core/models.py:190
|
||||
#: core/models.py:193
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
|
||||
|
||||
#: core/models.py:193
|
||||
#: core/models.py:196
|
||||
msgid "active"
|
||||
msgstr "actif"
|
||||
|
||||
#: core/models.py:196
|
||||
#: core/models.py:199
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
@@ -241,65 +269,65 @@ msgstr ""
|
||||
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
|
||||
"au lieu de supprimer des comptes."
|
||||
|
||||
#: core/models.py:209
|
||||
#: core/models.py:212
|
||||
msgid "user"
|
||||
msgstr "utilisateur"
|
||||
|
||||
#: core/models.py:210
|
||||
#: core/models.py:213
|
||||
msgid "users"
|
||||
msgstr "utilisateurs"
|
||||
|
||||
#: core/models.py:269
|
||||
#: core/models.py:272
|
||||
msgid "Resource"
|
||||
msgstr "Ressource"
|
||||
|
||||
#: core/models.py:270
|
||||
#: core/models.py:273
|
||||
msgid "Resources"
|
||||
msgstr "Ressources"
|
||||
|
||||
#: core/models.py:324
|
||||
#: core/models.py:331
|
||||
msgid "Resource access"
|
||||
msgstr "Accès aux ressources"
|
||||
|
||||
#: core/models.py:325
|
||||
#: core/models.py:332
|
||||
msgid "Resource accesses"
|
||||
msgstr "Accès aux ressources"
|
||||
|
||||
#: core/models.py:331
|
||||
#: core/models.py:338
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr ""
|
||||
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
|
||||
|
||||
#: core/models.py:387
|
||||
#: core/models.py:394
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Configuration de la salle de visioconférence"
|
||||
|
||||
#: core/models.py:388
|
||||
#: core/models.py:395
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
|
||||
|
||||
#: core/models.py:395
|
||||
#: core/models.py:402
|
||||
msgid "Room PIN code"
|
||||
msgstr "Code PIN de la salle"
|
||||
|
||||
#: core/models.py:396
|
||||
#: core/models.py:403
|
||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||
msgstr ""
|
||||
"Code unique à n chiffres qui identifie cette salle en mode téléphonique."
|
||||
|
||||
#: core/models.py:402 core/models.py:556
|
||||
#: core/models.py:409 core/models.py:563
|
||||
msgid "Room"
|
||||
msgstr "Salle"
|
||||
|
||||
#: core/models.py:403
|
||||
#: core/models.py:410
|
||||
msgid "Rooms"
|
||||
msgstr "Salles"
|
||||
|
||||
#: core/models.py:567
|
||||
#: core/models.py:574
|
||||
msgid "Worker ID"
|
||||
msgstr "ID du Worker"
|
||||
|
||||
#: core/models.py:569
|
||||
#: core/models.py:576
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
@@ -307,109 +335,150 @@ msgstr ""
|
||||
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
|
||||
"conservé même lorsque le Worker s'arrête, permettant un suivi facile."
|
||||
|
||||
#: core/models.py:577
|
||||
#: core/models.py:584
|
||||
msgid "Recording mode"
|
||||
msgstr "Mode d'enregistrement"
|
||||
|
||||
#: core/models.py:578
|
||||
#: core/models.py:585
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Définit le mode d'enregistrement appelé."
|
||||
|
||||
#: core/models.py:583 core/models.py:584
|
||||
#: core/models.py:590 core/models.py:591
|
||||
msgid "Recording options"
|
||||
msgstr "Options d'enregistrement"
|
||||
|
||||
#: core/models.py:590
|
||||
#: core/models.py:597
|
||||
msgid "Recording"
|
||||
msgstr "Enregistrement"
|
||||
|
||||
#: core/models.py:591
|
||||
#: core/models.py:598
|
||||
msgid "Recordings"
|
||||
msgstr "Enregistrements"
|
||||
|
||||
#: core/models.py:699
|
||||
#: core/models.py:706
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Relation enregistrement/utilisateur"
|
||||
|
||||
#: core/models.py:700
|
||||
#: core/models.py:707
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Relations enregistrement/utilisateur"
|
||||
|
||||
#: core/models.py:706
|
||||
#: core/models.py:713
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "Cet utilisateur est déjà dans cet enregistrement."
|
||||
|
||||
#: core/models.py:712
|
||||
#: core/models.py:719
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "Cette équipe est déjà dans cet enregistrement."
|
||||
|
||||
#: core/models.py:718
|
||||
#: core/models.py:725
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
|
||||
|
||||
#: core/models.py:735
|
||||
#: core/models.py:742
|
||||
msgid "Create rooms"
|
||||
msgstr "Créer des salles"
|
||||
|
||||
#: core/models.py:736
|
||||
#: core/models.py:743
|
||||
msgid "List rooms"
|
||||
msgstr "Lister les salles"
|
||||
|
||||
#: core/models.py:737
|
||||
#: core/models.py:744
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Afficher les détails d’une salle"
|
||||
|
||||
#: core/models.py:738
|
||||
#: core/models.py:745
|
||||
msgid "Update rooms"
|
||||
msgstr "Mettre à jour les salles"
|
||||
|
||||
#: core/models.py:739
|
||||
#: core/models.py:746
|
||||
msgid "Delete rooms"
|
||||
msgstr "Supprimer les salles"
|
||||
|
||||
#: core/models.py:752
|
||||
#: core/models.py:759
|
||||
msgid "Application name"
|
||||
msgstr "Nom de l’application"
|
||||
|
||||
#: core/models.py:753
|
||||
#: core/models.py:760
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Nom descriptif de cette application."
|
||||
|
||||
#: core/models.py:763
|
||||
#: core/models.py:770
|
||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||
msgstr ""
|
||||
"Haché lors de l’enregistrement. Copiez-le maintenant s’il s’agit d’un "
|
||||
"nouveau secret."
|
||||
|
||||
#: core/models.py:774
|
||||
#: core/models.py:781
|
||||
msgid "Application"
|
||||
msgstr "Application"
|
||||
|
||||
#: core/models.py:775
|
||||
#: core/models.py:782
|
||||
msgid "Applications"
|
||||
msgstr "Applications"
|
||||
|
||||
#: core/models.py:798
|
||||
#: core/models.py:805
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Saisissez un domaine valide"
|
||||
|
||||
#: core/models.py:801
|
||||
#: core/models.py:808
|
||||
msgid "Domain"
|
||||
msgstr "Domaine"
|
||||
|
||||
#: core/models.py:802
|
||||
#: core/models.py:809
|
||||
msgid "Email domain this application can act on behalf of."
|
||||
msgstr "Domaine de messagerie au nom duquel cette application peut agir."
|
||||
|
||||
#: core/models.py:814
|
||||
#: core/models.py:821
|
||||
msgid "Application domain"
|
||||
msgstr "Domaine d’application"
|
||||
|
||||
#: core/models.py:815
|
||||
#: core/models.py:822
|
||||
msgid "Application domains"
|
||||
msgstr "Domaines d’application"
|
||||
|
||||
#: core/recording/event/notification.py:94
|
||||
#: core/models.py:840
|
||||
msgid "Pending"
|
||||
msgstr "En attente"
|
||||
|
||||
#: core/models.py:848
|
||||
msgid "Ready"
|
||||
msgstr "Prêt"
|
||||
|
||||
#: core/models.py:854
|
||||
msgid "Background image"
|
||||
msgstr "Image de fond"
|
||||
|
||||
#: core/models.py:866
|
||||
msgid "title"
|
||||
msgstr "Titre"
|
||||
|
||||
#: core/models.py:890
|
||||
msgid "Malware detection info when the analysis status is unsafe."
|
||||
msgstr ""
|
||||
"Information concernant la détection de Malware cand le statut n'est pas sain"
|
||||
|
||||
#: core/models.py:895
|
||||
msgid "File"
|
||||
msgstr "Fichier"
|
||||
|
||||
#: core/models.py:896
|
||||
msgid "Files"
|
||||
msgstr "Fichiers"
|
||||
|
||||
#: core/models.py:1000
|
||||
#, fuzzy
|
||||
#| msgid "This user is already in this recording."
|
||||
msgid "This file is already hard deleted."
|
||||
msgstr "Ce fichier a été supprimé."
|
||||
|
||||
#: core/models.py:1010
|
||||
msgid "To hard delete a file, it must first be soft deleted."
|
||||
msgstr ""
|
||||
"Pour supprimer définitivement un fichier il doit d'abord avoir été marqué "
|
||||
"comme supprimé (soft delete)"
|
||||
|
||||
#: core/recording/event/notification.py:116
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Votre enregistrement est prêt"
|
||||
|
||||
@@ -537,18 +606,18 @@ msgstr ""
|
||||
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
|
||||
"équipe d'assistance à %(support_email)s. "
|
||||
|
||||
#: meet/settings.py:169
|
||||
#: meet/settings.py:223
|
||||
msgid "English"
|
||||
msgstr "Anglais"
|
||||
|
||||
#: meet/settings.py:170
|
||||
#: meet/settings.py:224
|
||||
msgid "French"
|
||||
msgstr "Français"
|
||||
|
||||
#: meet/settings.py:171
|
||||
#: meet/settings.py:225
|
||||
msgid "Dutch"
|
||||
msgstr "Néerlandais"
|
||||
|
||||
#: meet/settings.py:172
|
||||
#: meet/settings.py:226
|
||||
msgid "German"
|
||||
msgstr "Allemand"
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-12-29 15:15+0000\n"
|
||||
"POT-Creation-Date: 2026-03-12 13:46+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -29,140 +29,167 @@ msgstr "Rechten"
|
||||
msgid "Important dates"
|
||||
msgstr "Belangrijke datums"
|
||||
|
||||
#: core/admin.py:128 core/admin.py:228
|
||||
#: core/admin.py:132 core/admin.py:275
|
||||
msgid "No owner"
|
||||
msgstr "Geen eigenaar"
|
||||
|
||||
#: core/admin.py:131 core/admin.py:231
|
||||
#: core/admin.py:135 core/admin.py:278
|
||||
msgid "Multiple owners"
|
||||
msgstr "Meerdere eigenaren"
|
||||
|
||||
#: core/admin.py:143
|
||||
#: core/admin.py:148
|
||||
msgid "Resend notification to external service"
|
||||
msgstr "Melding opnieuw verzenden naar externe dienst"
|
||||
|
||||
#: core/admin.py:166
|
||||
#: core/admin.py:171
|
||||
#, python-format
|
||||
msgid "Failed to notify for recording %(id)s"
|
||||
msgstr "Melding voor opname %(id)s mislukt"
|
||||
|
||||
#: core/admin.py:174
|
||||
#: core/admin.py:179
|
||||
#, python-format
|
||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||
msgstr "Melding voor opname %(id)s mislukt: %(error)s"
|
||||
|
||||
#: core/admin.py:182
|
||||
#: core/admin.py:187
|
||||
#, python-format
|
||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||
msgstr "Meldingen succesvol verzonden voor %(count)s opname(n)."
|
||||
|
||||
#: core/admin.py:190
|
||||
#: core/admin.py:195
|
||||
#, python-format
|
||||
msgid "Skipped %(count)s expired recording(s)."
|
||||
msgstr "%(count)s verlopen opname(n) overgeslagen."
|
||||
|
||||
#: core/admin.py:294
|
||||
#: core/admin.py:200
|
||||
msgid "Mark selected recordings as 'Failed to Stop'"
|
||||
msgstr "Geselecteerde opnames markeren als 'Mislukt bij stoppen'"
|
||||
|
||||
#: core/admin.py:218
|
||||
#, python-format
|
||||
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
||||
msgstr "%(count)s opname(s) succesvol gemarkeerd als 'Mislukt bij stoppen'."
|
||||
|
||||
#: core/admin.py:226
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Skipped %(count)s expired recording(s)."
|
||||
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
||||
msgstr "%(count)s opname(s) met een niet-toegestane status overgeslagen."
|
||||
|
||||
#: core/admin.py:342
|
||||
msgid "No scopes"
|
||||
msgstr "Geen scopes"
|
||||
|
||||
#: core/admin.py:296
|
||||
#: core/admin.py:344
|
||||
msgid "Scopes"
|
||||
msgstr "Scopes"
|
||||
|
||||
#: core/api/serializers.py:68
|
||||
#: core/api/filters.py:25
|
||||
msgid "Creator is me"
|
||||
msgstr "Maker ben ik"
|
||||
|
||||
#: core/api/serializers.py:88
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr ""
|
||||
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
|
||||
|
||||
#: core/models.py:34
|
||||
#: core/api/serializers.py:516
|
||||
msgid "This file extension is not allowed."
|
||||
msgstr "Deze bestandsextensie is niet toegestaan."
|
||||
|
||||
#: core/api/serializers.py:533
|
||||
msgid "You have reached the maximum number of files for this type."
|
||||
msgstr "Het maximale aantal bestanden voor dit type is bereikt."
|
||||
|
||||
#: core/models.py:37
|
||||
msgid "Member"
|
||||
msgstr "Lid"
|
||||
|
||||
#: core/models.py:35
|
||||
#: core/models.py:38
|
||||
msgid "Administrator"
|
||||
msgstr "Beheerder"
|
||||
|
||||
#: core/models.py:36
|
||||
#: core/models.py:39
|
||||
msgid "Owner"
|
||||
msgstr "Eigenaar"
|
||||
|
||||
#: core/models.py:52
|
||||
#: core/models.py:55
|
||||
msgid "Initiated"
|
||||
msgstr "Gestart"
|
||||
|
||||
#: core/models.py:53
|
||||
#: core/models.py:56
|
||||
msgid "Active"
|
||||
msgstr "Actief"
|
||||
|
||||
#: core/models.py:54
|
||||
#: core/models.py:57
|
||||
msgid "Stopped"
|
||||
msgstr "Gestopt"
|
||||
|
||||
#: core/models.py:55
|
||||
#: core/models.py:58
|
||||
msgid "Saved"
|
||||
msgstr "Opgeslagen"
|
||||
|
||||
#: core/models.py:56
|
||||
#: core/models.py:59
|
||||
msgid "Aborted"
|
||||
msgstr "Afgebroken"
|
||||
|
||||
#: core/models.py:57
|
||||
#: core/models.py:60
|
||||
msgid "Failed to Start"
|
||||
msgstr "Starten mislukt"
|
||||
|
||||
#: core/models.py:58
|
||||
#: core/models.py:61
|
||||
msgid "Failed to Stop"
|
||||
msgstr "Stoppen mislukt"
|
||||
|
||||
#: core/models.py:59
|
||||
#: core/models.py:62
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Notificatie geslaagd"
|
||||
|
||||
#: core/models.py:86
|
||||
#: core/models.py:89
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "SCHERM_OPNAME"
|
||||
|
||||
#: core/models.py:87
|
||||
#: core/models.py:90
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSCRIPT"
|
||||
|
||||
#: core/models.py:93
|
||||
#: core/models.py:96
|
||||
msgid "Public Access"
|
||||
msgstr "Openbare toegang"
|
||||
|
||||
#: core/models.py:94
|
||||
#: core/models.py:97
|
||||
msgid "Trusted Access"
|
||||
msgstr "Vertrouwde toegang"
|
||||
|
||||
#: core/models.py:95
|
||||
#: core/models.py:98
|
||||
msgid "Restricted Access"
|
||||
msgstr "Beperkte toegang"
|
||||
|
||||
#: core/models.py:107
|
||||
#: core/models.py:110
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: core/models.py:108
|
||||
#: core/models.py:111
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "primaire sleutel voor het record als UUID"
|
||||
|
||||
#: core/models.py:114
|
||||
#: core/models.py:117
|
||||
msgid "created on"
|
||||
msgstr "aangemaakt op"
|
||||
|
||||
#: core/models.py:115
|
||||
#: core/models.py:118
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "datum en tijd waarop een record werd aangemaakt"
|
||||
|
||||
#: core/models.py:120
|
||||
#: core/models.py:123
|
||||
msgid "updated on"
|
||||
msgstr "bijgewerkt op"
|
||||
|
||||
#: core/models.py:121
|
||||
#: core/models.py:124
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt"
|
||||
|
||||
#: core/models.py:141
|
||||
#: core/models.py:144
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
@@ -170,11 +197,11 @@ msgstr ""
|
||||
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
|
||||
"_ tekens bevatten."
|
||||
|
||||
#: core/models.py:147
|
||||
#: core/models.py:150
|
||||
msgid "sub"
|
||||
msgstr "sub"
|
||||
|
||||
#: core/models.py:149
|
||||
#: core/models.py:152
|
||||
msgid ""
|
||||
"Optional for pending users; required upon account activation. 255 characters "
|
||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
@@ -182,55 +209,55 @@ msgstr ""
|
||||
"Optioneel voor gebruikers in afwachting; vereist bij accountactivering. "
|
||||
"Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan."
|
||||
|
||||
#: core/models.py:158
|
||||
#: core/models.py:161
|
||||
msgid "identity email address"
|
||||
msgstr "identiteit e-mailadres"
|
||||
|
||||
#: core/models.py:163
|
||||
#: core/models.py:166
|
||||
msgid "admin email address"
|
||||
msgstr "beheerder e-mailadres"
|
||||
|
||||
#: core/models.py:165
|
||||
#: core/models.py:168
|
||||
msgid "full name"
|
||||
msgstr "volledige naam"
|
||||
|
||||
#: core/models.py:167
|
||||
#: core/models.py:170
|
||||
msgid "short name"
|
||||
msgstr "korte naam"
|
||||
|
||||
#: core/models.py:173
|
||||
#: core/models.py:176
|
||||
msgid "language"
|
||||
msgstr "taal"
|
||||
|
||||
#: core/models.py:174
|
||||
#: core/models.py:177
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "De taal waarin de gebruiker de interface wil zien."
|
||||
|
||||
#: core/models.py:180
|
||||
#: core/models.py:183
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
|
||||
|
||||
#: core/models.py:183
|
||||
#: core/models.py:186
|
||||
msgid "device"
|
||||
msgstr "apparaat"
|
||||
|
||||
#: core/models.py:185
|
||||
#: core/models.py:188
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
|
||||
|
||||
#: core/models.py:188
|
||||
#: core/models.py:191
|
||||
msgid "staff status"
|
||||
msgstr "personeelsstatus"
|
||||
|
||||
#: core/models.py:190
|
||||
#: core/models.py:193
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Of de gebruiker kan inloggen op deze beheersite."
|
||||
|
||||
#: core/models.py:193
|
||||
#: core/models.py:196
|
||||
msgid "active"
|
||||
msgstr "actief"
|
||||
|
||||
#: core/models.py:196
|
||||
#: core/models.py:199
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
@@ -238,64 +265,64 @@ msgstr ""
|
||||
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
|
||||
"plaats van accounts te verwijderen."
|
||||
|
||||
#: core/models.py:209
|
||||
#: core/models.py:212
|
||||
msgid "user"
|
||||
msgstr "gebruiker"
|
||||
|
||||
#: core/models.py:210
|
||||
#: core/models.py:213
|
||||
msgid "users"
|
||||
msgstr "gebruikers"
|
||||
|
||||
#: core/models.py:269
|
||||
#: core/models.py:272
|
||||
msgid "Resource"
|
||||
msgstr "Bron"
|
||||
|
||||
#: core/models.py:270
|
||||
#: core/models.py:273
|
||||
msgid "Resources"
|
||||
msgstr "Bronnen"
|
||||
|
||||
#: core/models.py:324
|
||||
#: core/models.py:331
|
||||
msgid "Resource access"
|
||||
msgstr "Brontoegang"
|
||||
|
||||
#: core/models.py:325
|
||||
#: core/models.py:332
|
||||
msgid "Resource accesses"
|
||||
msgstr "Brontoegangsrechten"
|
||||
|
||||
#: core/models.py:331
|
||||
#: core/models.py:338
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
|
||||
|
||||
#: core/models.py:387
|
||||
#: core/models.py:394
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Visio-ruimteconfiguratie"
|
||||
|
||||
#: core/models.py:388
|
||||
#: core/models.py:395
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
|
||||
|
||||
#: core/models.py:395
|
||||
#: core/models.py:402
|
||||
msgid "Room PIN code"
|
||||
msgstr "Pincode van de kamer"
|
||||
|
||||
#: core/models.py:396
|
||||
#: core/models.py:403
|
||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||
msgstr ""
|
||||
"Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
|
||||
|
||||
#: core/models.py:402 core/models.py:556
|
||||
#: core/models.py:409 core/models.py:563
|
||||
msgid "Room"
|
||||
msgstr "Ruimte"
|
||||
|
||||
#: core/models.py:403
|
||||
#: core/models.py:410
|
||||
msgid "Rooms"
|
||||
msgstr "Ruimtes"
|
||||
|
||||
#: core/models.py:567
|
||||
#: core/models.py:574
|
||||
msgid "Worker ID"
|
||||
msgstr "Worker ID"
|
||||
|
||||
#: core/models.py:569
|
||||
#: core/models.py:576
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
@@ -303,108 +330,148 @@ msgstr ""
|
||||
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
|
||||
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
|
||||
|
||||
#: core/models.py:577
|
||||
#: core/models.py:584
|
||||
msgid "Recording mode"
|
||||
msgstr "Opnamemodus"
|
||||
|
||||
#: core/models.py:578
|
||||
#: core/models.py:585
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Definieert de modus van opname die wordt aangeroepen."
|
||||
|
||||
#: core/models.py:583 core/models.py:584
|
||||
#: core/models.py:590 core/models.py:591
|
||||
msgid "Recording options"
|
||||
msgstr "Opnameopties"
|
||||
|
||||
#: core/models.py:590
|
||||
#: core/models.py:597
|
||||
msgid "Recording"
|
||||
msgstr "Opname"
|
||||
|
||||
#: core/models.py:591
|
||||
#: core/models.py:598
|
||||
msgid "Recordings"
|
||||
msgstr "Opnames"
|
||||
|
||||
#: core/models.py:699
|
||||
#: core/models.py:706
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Opname/gebruiker-relatie"
|
||||
|
||||
#: core/models.py:700
|
||||
#: core/models.py:707
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Opname/gebruiker-relaties"
|
||||
|
||||
#: core/models.py:706
|
||||
#: core/models.py:713
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "Deze gebruiker is al in deze opname."
|
||||
|
||||
#: core/models.py:712
|
||||
#: core/models.py:719
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "Dit team is al in deze opname."
|
||||
|
||||
#: core/models.py:718
|
||||
#: core/models.py:725
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
|
||||
|
||||
#: core/models.py:735
|
||||
#: core/models.py:742
|
||||
msgid "Create rooms"
|
||||
msgstr "Ruimtes aanmaken"
|
||||
|
||||
#: core/models.py:736
|
||||
#: core/models.py:743
|
||||
msgid "List rooms"
|
||||
msgstr "Ruimtes weergeven"
|
||||
|
||||
#: core/models.py:737
|
||||
#: core/models.py:744
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Details van een ruimte ophalen"
|
||||
|
||||
#: core/models.py:738
|
||||
#: core/models.py:745
|
||||
msgid "Update rooms"
|
||||
msgstr "Ruimtes bijwerken"
|
||||
|
||||
#: core/models.py:739
|
||||
#: core/models.py:746
|
||||
msgid "Delete rooms"
|
||||
msgstr "Ruimtes verwijderen"
|
||||
|
||||
#: core/models.py:752
|
||||
#: core/models.py:759
|
||||
msgid "Application name"
|
||||
msgstr "Naam van de applicatie"
|
||||
|
||||
#: core/models.py:753
|
||||
#: core/models.py:760
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Beschrijvende naam voor deze applicatie."
|
||||
|
||||
#: core/models.py:763
|
||||
#: core/models.py:770
|
||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||
msgstr ""
|
||||
"Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
|
||||
|
||||
#: core/models.py:774
|
||||
#: core/models.py:781
|
||||
msgid "Application"
|
||||
msgstr "Applicatie"
|
||||
|
||||
#: core/models.py:775
|
||||
#: core/models.py:782
|
||||
msgid "Applications"
|
||||
msgstr "Applicaties"
|
||||
|
||||
#: core/models.py:798
|
||||
#: core/models.py:805
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Voer een geldig domein in"
|
||||
|
||||
#: core/models.py:801
|
||||
#: core/models.py:808
|
||||
msgid "Domain"
|
||||
msgstr "Domein"
|
||||
|
||||
#: core/models.py:802
|
||||
#: core/models.py:809
|
||||
msgid "Email domain this application can act on behalf of."
|
||||
msgstr "E-maildomein namens welke deze applicatie kan handelen."
|
||||
|
||||
#: core/models.py:814
|
||||
#: core/models.py:821
|
||||
msgid "Application domain"
|
||||
msgstr "Applicatiedomein"
|
||||
|
||||
#: core/models.py:815
|
||||
#: core/models.py:822
|
||||
msgid "Application domains"
|
||||
msgstr "Applicatiedomeinen"
|
||||
|
||||
#: core/recording/event/notification.py:94
|
||||
#: core/models.py:840
|
||||
msgid "Pending"
|
||||
msgstr "In afwachting"
|
||||
|
||||
#: core/models.py:848
|
||||
msgid "Ready"
|
||||
msgstr "Klaar"
|
||||
|
||||
#: core/models.py:854
|
||||
msgid "Background image"
|
||||
msgstr "Achtergrondafbeelding"
|
||||
|
||||
#: core/models.py:866
|
||||
msgid "title"
|
||||
msgstr "Titel"
|
||||
|
||||
#: core/models.py:890
|
||||
msgid "Malware detection info when the analysis status is unsafe."
|
||||
msgstr "Informatie over malwaredetectie wanneer de analysestatus onveilig is."
|
||||
|
||||
#: core/models.py:895
|
||||
msgid "File"
|
||||
msgstr "Bestand"
|
||||
|
||||
#: core/models.py:896
|
||||
msgid "Files"
|
||||
msgstr "Bestanden"
|
||||
|
||||
#: core/models.py:1000
|
||||
msgid "This file is already hard deleted."
|
||||
msgstr "Dit bestand is al definitief verwijderd."
|
||||
|
||||
#: core/models.py:1010
|
||||
#, fuzzy
|
||||
#| msgid "To hard delete a file, it must first be soft deleted."
|
||||
msgid "To hard delete a file, it must first be soft deleted."
|
||||
msgstr ""
|
||||
"Om een bestand definitief te verwijderen, moet het eerst zacht verwijderd "
|
||||
"zijn."
|
||||
|
||||
#: core/recording/event/notification.py:116
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Je opname is klaar"
|
||||
|
||||
@@ -532,18 +599,18 @@ msgstr ""
|
||||
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
|
||||
"team via %(support_email)s. "
|
||||
|
||||
#: meet/settings.py:169
|
||||
#: meet/settings.py:223
|
||||
msgid "English"
|
||||
msgstr "Engels"
|
||||
|
||||
#: meet/settings.py:170
|
||||
#: meet/settings.py:224
|
||||
msgid "French"
|
||||
msgstr "Frans"
|
||||
|
||||
#: meet/settings.py:171
|
||||
#: meet/settings.py:225
|
||||
msgid "Dutch"
|
||||
msgstr "Nederlands"
|
||||
|
||||
#: meet/settings.py:172
|
||||
#: meet/settings.py:226
|
||||
msgid "German"
|
||||
msgstr "Duits"
|
||||
|
||||
@@ -28,6 +28,10 @@ from sentry_sdk.integrations.logging import ignore_logger
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = path.dirname(path.dirname(path.abspath(__file__)))
|
||||
|
||||
KB = 1024
|
||||
MB = 1024 * KB
|
||||
GB = 1024 * MB
|
||||
|
||||
|
||||
def get_release():
|
||||
"""
|
||||
@@ -121,6 +125,9 @@ class Base(Configuration):
|
||||
STATIC_ROOT = path.join(DATA_DIR, "static")
|
||||
MEDIA_URL = "/media/"
|
||||
MEDIA_ROOT = path.join(DATA_DIR, "media")
|
||||
MEDIA_BASE_URL = values.Value(
|
||||
"", environ_name="MEDIA_BASE_URL", environ_prefix=None
|
||||
)
|
||||
|
||||
SITE_ID = 1
|
||||
|
||||
@@ -154,6 +161,48 @@ class Base(Configuration):
|
||||
environ_name="AWS_STORAGE_BUCKET_NAME",
|
||||
environ_prefix=None,
|
||||
)
|
||||
AWS_S3_SIGNATURE_VERSION = values.Value(
|
||||
"s3v4",
|
||||
environ_name="AWS_S3_SIGNATURE_VERSION",
|
||||
environ_prefix=None,
|
||||
)
|
||||
AWS_S3_UPLOAD_POLICY_EXPIRATION = values.Value(
|
||||
60, # 1 minute
|
||||
environ_name="AWS_S3_UPLOAD_POLICY_EXPIRATION",
|
||||
environ_prefix=None,
|
||||
)
|
||||
AWS_S3_DOMAIN_REPLACE = values.Value(
|
||||
environ_name="AWS_S3_DOMAIN_REPLACE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
FILE_UPLOAD_ENABLED = values.BooleanValue(
|
||||
# False to avoid a breaking change for now
|
||||
default=False,
|
||||
environ_name="FILE_UPLOAD_ENABLED",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
FILE_UPLOAD_PATH = values.Value(
|
||||
"files", environ_name="FILE_UPLOAD_PATH", environ_prefix=None
|
||||
)
|
||||
|
||||
FILE_UPLOAD_APPLY_RESTRICTIONS = values.BooleanValue(
|
||||
default=True, environ_name="FILE_UPLOAD_APPLY_RESTRICTIONS", environ_prefix=None
|
||||
)
|
||||
|
||||
FILE_UPLOAD_RESTRICTIONS = values.DictValue(
|
||||
{
|
||||
"background_image": {
|
||||
"max_size": 2 * MB,
|
||||
"max_count_by_user": 10,
|
||||
"allowed_extensions": [".jpeg", ".jpg", ".png"],
|
||||
"allowed_mimetypes": ["image/jpeg", "image/png"],
|
||||
},
|
||||
},
|
||||
environ_name="FILE_UPLOAD_RESTRICTIONS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/3.1/topics/i18n/
|
||||
@@ -394,8 +443,13 @@ class Base(Configuration):
|
||||
THUMBNAIL_ALIASES = {}
|
||||
|
||||
# Celery
|
||||
CELERY_BROKER_URL = values.Value("redis://redis:6379/0")
|
||||
CELERY_BROKER_TRANSPORT_OPTIONS = values.DictValue({})
|
||||
# Defaults to False to avoid breaking change, async task will be run
|
||||
# synchronously
|
||||
CELERY_ENABLED = values.BooleanValue(False, environ_prefix=None)
|
||||
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(False, environ_prefix=None)
|
||||
CELERY_TASK_DEFAULT_QUEUE = values.Value("meet-backend", environ_prefix=None)
|
||||
CELERY_BROKER_URL = values.Value("redis://redis:6379/0", environ_prefix=None)
|
||||
CELERY_BROKER_TRANSPORT_OPTIONS = values.DictValue({}, environ_prefix=None)
|
||||
|
||||
# Session
|
||||
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
|
||||
@@ -926,7 +980,8 @@ class Test(Base):
|
||||
APPLICATION_JWT_SECRET_KEY = "devKey" # noqa:S105
|
||||
APPLICATION_JWT_AUDIENCE = "Test inc."
|
||||
|
||||
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
|
||||
CELERY_TASK_ALWAYS_EAGER = True
|
||||
FILE_UPLOAD_ENABLED = True
|
||||
|
||||
def __init__(self):
|
||||
# pylint: disable=invalid-name
|
||||
|
||||
+16
-14
@@ -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.9.0"
|
||||
version = "1.10.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",
|
||||
@@ -33,6 +32,7 @@ dependencies = [
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.9.0",
|
||||
"django-countries==8.2.0",
|
||||
"django-filter==25.2",
|
||||
"django-lasuite[all]==0.0.24",
|
||||
"django-parler==2.3",
|
||||
"redis==5.2.1",
|
||||
@@ -40,7 +40,7 @@ dependencies = [
|
||||
"django-storages[s3]==1.14.6",
|
||||
"django-timezone-field>=5.1",
|
||||
"django-pydantic-field==0.5.4",
|
||||
"django==5.2.11",
|
||||
"django==5.2.12",
|
||||
"djangorestframework==3.16.1",
|
||||
"drf_spectacular==0.29.0",
|
||||
"dockerflow==2026.1.26",
|
||||
@@ -52,8 +52,9 @@ dependencies = [
|
||||
"nested-multipart-parser==1.6.0",
|
||||
"psycopg[binary]==3.3.2",
|
||||
"pydantic==2.12.4",
|
||||
"PyJWT==2.11.0",
|
||||
"PyJWT==2.12.0",
|
||||
"python-frontmatter==1.1.0",
|
||||
"python-magic==0.4.27",
|
||||
"requests==2.32.5",
|
||||
"sentry-sdk==2.53.0",
|
||||
"whitenoise==6.11.0",
|
||||
@@ -68,7 +69,7 @@ dependencies = [
|
||||
"Homepage" = "https://github.com/suitenumerique/meet"
|
||||
"Repository" = "https://github.com/suitenumerique/meet"
|
||||
|
||||
[project.optional-dependencies]
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"django-extensions==4.1",
|
||||
"drf-spectacular-sidecar==2026.1.1",
|
||||
@@ -88,12 +89,13 @@ dev = [
|
||||
"types-requests==2.32.4.20260107",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
packages = { find = { where = ["."], exclude = ["tests"] } }
|
||||
zip-safe = true
|
||||
|
||||
[tool.distutils.bdist_wheel]
|
||||
universal = true
|
||||
[tool.uv.build-backend]
|
||||
module-root = ""
|
||||
source-exclude = [
|
||||
"**/tests/**",
|
||||
"**/test_*.py",
|
||||
"**/tests.py",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
exclude = [
|
||||
|
||||
Generated
+2365
File diff suppressed because it is too large
Load Diff
Generated
+165
-90
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/material-symbols-outlined": "5.2.34",
|
||||
"@fontsource/material-icons-outlined": "5.2.6",
|
||||
@@ -1106,9 +1106,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@hono/node-server": {
|
||||
"version": "1.19.9",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz",
|
||||
"integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==",
|
||||
"version": "1.19.10",
|
||||
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.10.tgz",
|
||||
"integrity": "sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3959,9 +3959,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||
"version": "4.44.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.2.tgz",
|
||||
"integrity": "sha512-g0dF8P1e2QYPOj1gu7s/3LVP6kze9A7m6x0BZ9iTdXK8N5c2V7cpBKHV3/9A4Zd8xxavdhK0t4PnqjkqVmUc9Q==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
|
||||
"integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -3973,9 +3973,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-android-arm64": {
|
||||
"version": "4.44.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.2.tgz",
|
||||
"integrity": "sha512-Yt5MKrOosSbSaAK5Y4J+vSiID57sOvpBNBR6K7xAaQvk3MkcNVV0f9fE20T+41WYN8hDn6SGFlFrKudtx4EoxA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -3987,9 +3987,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-arm64": {
|
||||
"version": "4.44.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.2.tgz",
|
||||
"integrity": "sha512-EsnFot9ZieM35YNA26nhbLTJBHD0jTwWpPwmRVDzjylQT6gkar+zenfb8mHxWpRrbn+WytRRjE0WKsfaxBkVUA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -4001,9 +4001,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-darwin-x64": {
|
||||
"version": "4.44.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.2.tgz",
|
||||
"integrity": "sha512-dv/t1t1RkCvJdWWxQ2lWOO+b7cMsVw5YFaS04oHpZRWehI1h0fV1gF4wgGCTyQHHjJDfbNpwOi6PXEafRBBezw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -4015,9 +4015,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-arm64": {
|
||||
"version": "4.44.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.2.tgz",
|
||||
"integrity": "sha512-W4tt4BLorKND4qeHElxDoim0+BsprFTwb+vriVQnFFtT/P6v/xO5I99xvYnVzKWrK6j7Hb0yp3x7V5LUbaeOMg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -4029,9 +4029,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-freebsd-x64": {
|
||||
"version": "4.44.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.2.tgz",
|
||||
"integrity": "sha512-tdT1PHopokkuBVyHjvYehnIe20fxibxFCEhQP/96MDSOcyjM/shlTkZZLOufV3qO6/FQOSiJTBebhVc12JyPTA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -4043,9 +4043,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
|
||||
"version": "4.44.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.2.tgz",
|
||||
"integrity": "sha512-+xmiDGGaSfIIOXMzkhJ++Oa0Gwvl9oXUeIiwarsdRXSe27HUIvjbSIpPxvnNsRebsNdUo7uAiQVgBD1hVriwSQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -4057,9 +4057,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
|
||||
"version": "4.44.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.2.tgz",
|
||||
"integrity": "sha512-bDHvhzOfORk3wt8yxIra8N4k/N0MnKInCW5OGZaeDYa/hMrdPaJzo7CSkjKZqX4JFUWjUGm88lI6QJLCM7lDrA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
|
||||
"integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -4071,9 +4071,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-gnu": {
|
||||
"version": "4.44.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.2.tgz",
|
||||
"integrity": "sha512-NMsDEsDiYghTbeZWEGnNi4F0hSbGnsuOG+VnNvxkKg0IGDvFh7UVpM/14mnMwxRxUf9AdAVJgHPvKXf6FpMB7A==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -4085,9 +4085,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||
"version": "4.44.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.2.tgz",
|
||||
"integrity": "sha512-lb5bxXnxXglVq+7imxykIp5xMq+idehfl+wOgiiix0191av84OqbjUED+PRC5OA8eFJYj5xAGcpAZ0pF2MnW+A==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -4098,10 +4098,10 @@
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-loongarch64-gnu": {
|
||||
"version": "4.44.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.2.tgz",
|
||||
"integrity": "sha512-Yl5Rdpf9pIc4GW1PmkUGHdMtbx0fBLE1//SxDmuf3X0dUC57+zMepow2LK0V21661cjXdTn8hO2tXDdAWAqE5g==",
|
||||
"node_modules/@rollup/rollup-linux-loong64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
@@ -4112,10 +4112,38 @@
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
|
||||
"version": "4.44.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.2.tgz",
|
||||
"integrity": "sha512-03vUDH+w55s680YYryyr78jsO1RWU9ocRMaeV2vMniJJW/6HhoTBwyyiiTPVHNWLnhsnwcQ0oH3S9JSBEKuyqw==",
|
||||
"node_modules/@rollup/rollup-linux-loong64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-ppc64-musl": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -4127,9 +4155,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
|
||||
"version": "4.44.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.2.tgz",
|
||||
"integrity": "sha512-iYtAqBg5eEMG4dEfVlkqo05xMOk6y/JXIToRca2bAWuqjrJYJlx/I7+Z+4hSrsWU8GdJDFPL4ktV3dy4yBSrzg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -4141,9 +4169,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-riscv64-musl": {
|
||||
"version": "4.44.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.2.tgz",
|
||||
"integrity": "sha512-e6vEbgaaqz2yEHqtkPXa28fFuBGmUJ0N2dOJK8YUfijejInt9gfCSA7YDdJ4nYlv67JfP3+PSWFX4IVw/xRIPg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
@@ -4155,9 +4183,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-s390x-gnu": {
|
||||
"version": "4.44.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.2.tgz",
|
||||
"integrity": "sha512-evFOtkmVdY3udE+0QKrV5wBx7bKI0iHz5yEVx5WqDJkxp9YQefy4Mpx3RajIVcM6o7jxTvVd/qpC1IXUhGc1Mw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -4169,9 +4197,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-gnu": {
|
||||
"version": "4.44.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.2.tgz",
|
||||
"integrity": "sha512-/bXb0bEsWMyEkIsUL2Yt5nFB5naLAwyOWMEviQfQY1x3l5WsLKgvZf66TM7UTfED6erckUVUJQ/jJ1FSpm3pRQ==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -4183,9 +4211,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||
"version": "4.44.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.2.tgz",
|
||||
"integrity": "sha512-3D3OB1vSSBXmkGEZR27uiMRNiwN08/RVAcBKwhUYPaiZ8bcvdeEwWPvbnXvvXHY+A/7xluzcN+kaiOFNiOZwWg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
|
||||
"integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -4196,10 +4224,38 @@
|
||||
"linux"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openbsd-x64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
|
||||
"integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-openharmony-arm64": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
|
||||
"integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-arm64-msvc": {
|
||||
"version": "4.44.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.2.tgz",
|
||||
"integrity": "sha512-VfU0fsMK+rwdK8mwODqYeM2hDrF2WiHaSmCBrS7gColkQft95/8tphyzv2EupVxn3iE0FI78wzffoULH1G+dkw==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -4211,9 +4267,9 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-ia32-msvc": {
|
||||
"version": "4.44.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.2.tgz",
|
||||
"integrity": "sha512-+qMUrkbUurpE6DVRjiJCNGZBGo9xM4Y0FXU5cjgudWqIBWbcLkjE3XprJUsOFgC6xjBClwVa9k6O3A7K3vxb5Q==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -4224,10 +4280,24 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-gnu": {
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
|
||||
"integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@rollup/rollup-win32-x64-msvc": {
|
||||
"version": "4.44.2",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.2.tgz",
|
||||
"integrity": "sha512-3+QZROYfJ25PDcxFF66UEk8jGWigHJeecZILvkPkyQN7oc5BvFo4YEXFkOs154j3FTMp9mn9Ky8RCOwastduEA==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
|
||||
"integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -10061,9 +10131,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.44.2",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.2.tgz",
|
||||
"integrity": "sha512-PVoapzTwSEcelaWGth3uR66u7ZRo6qhPHc0f2uRO9fX6XDVNrIiGYS0Pj9+R8yIIYSD/mCx2b16Ws9itljKSPg==",
|
||||
"version": "4.59.0",
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
|
||||
"integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -10077,26 +10147,31 @@
|
||||
"npm": ">=8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rollup/rollup-android-arm-eabi": "4.44.2",
|
||||
"@rollup/rollup-android-arm64": "4.44.2",
|
||||
"@rollup/rollup-darwin-arm64": "4.44.2",
|
||||
"@rollup/rollup-darwin-x64": "4.44.2",
|
||||
"@rollup/rollup-freebsd-arm64": "4.44.2",
|
||||
"@rollup/rollup-freebsd-x64": "4.44.2",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.44.2",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.44.2",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.44.2",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.44.2",
|
||||
"@rollup/rollup-linux-loongarch64-gnu": "4.44.2",
|
||||
"@rollup/rollup-linux-powerpc64le-gnu": "4.44.2",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.44.2",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.44.2",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.44.2",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.44.2",
|
||||
"@rollup/rollup-linux-x64-musl": "4.44.2",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.44.2",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.44.2",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.44.2",
|
||||
"@rollup/rollup-android-arm-eabi": "4.59.0",
|
||||
"@rollup/rollup-android-arm64": "4.59.0",
|
||||
"@rollup/rollup-darwin-arm64": "4.59.0",
|
||||
"@rollup/rollup-darwin-x64": "4.59.0",
|
||||
"@rollup/rollup-freebsd-arm64": "4.59.0",
|
||||
"@rollup/rollup-freebsd-x64": "4.59.0",
|
||||
"@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
|
||||
"@rollup/rollup-linux-arm-musleabihf": "4.59.0",
|
||||
"@rollup/rollup-linux-arm64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-arm64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-loong64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-loong64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-ppc64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-ppc64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-riscv64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-riscv64-musl": "4.59.0",
|
||||
"@rollup/rollup-linux-s390x-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-x64-gnu": "4.59.0",
|
||||
"@rollup/rollup-linux-x64-musl": "4.59.0",
|
||||
"@rollup/rollup-openbsd-x64": "4.59.0",
|
||||
"@rollup/rollup-openharmony-arm64": "4.59.0",
|
||||
"@rollup/rollup-win32-arm64-msvc": "4.59.0",
|
||||
"@rollup/rollup-win32-ia32-msvc": "4.59.0",
|
||||
"@rollup/rollup-win32-x64-gnu": "4.59.0",
|
||||
"@rollup/rollup-win32-x64-msvc": "4.59.0",
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.9.0",
|
||||
"version": "1.10.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Suspense } from 'react'
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLang } from 'hoofd'
|
||||
import { useTitle } from 'hoofd'
|
||||
import { Switch, Route } from 'wouter'
|
||||
import { I18nProvider } from 'react-aria-components'
|
||||
import { Layout } from './layout/Layout'
|
||||
@@ -17,7 +17,7 @@ import { useIsSdkContext } from '@/features/sdk/hooks/useIsSdkContext'
|
||||
|
||||
function App() {
|
||||
const { i18n } = useTranslation()
|
||||
useLang(i18n.language)
|
||||
useTitle(import.meta.env.VITE_APP_TITLE ?? '')
|
||||
|
||||
const isSDKContext = useIsSdkContext()
|
||||
|
||||
|
||||
@@ -2,23 +2,25 @@ import { A, Text } from '@/primitives'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useConfig } from '@/api/useConfig'
|
||||
|
||||
const appTitle = import.meta.env.VITE_APP_TITLE ?? 'LaSuite Meet'
|
||||
|
||||
export const MoreLink = () => {
|
||||
const { t } = useTranslation('home')
|
||||
const { data } = useConfig()
|
||||
|
||||
if (!data?.manifest_link) return
|
||||
if (!data?.manifest_link) return null
|
||||
|
||||
return (
|
||||
<Text as={'p'} variant={'sm'} style={{ padding: '1rem 0' }}>
|
||||
<Text as="p" variant="sm" style={{ padding: '1rem 0' }}>
|
||||
<A
|
||||
href={data?.manifest_link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
aria-label={t('moreLinkLabel')}
|
||||
externalIcon
|
||||
aria-label={t('moreLinkLabel', { appTitle })}
|
||||
>
|
||||
{t('moreLink')}
|
||||
</A>{' '}
|
||||
{t('moreAbout', { appTitle: `${import.meta.env.VITE_APP_TITLE}` })}
|
||||
{t('moreLink')} {t('moreAbout', { appTitle })}
|
||||
</A>
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -258,23 +258,10 @@ export const Home = () => {
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
<Separator />
|
||||
<div
|
||||
className={css({
|
||||
display: { base: 'none', lg: 'inline' },
|
||||
})}
|
||||
>
|
||||
<MoreLink />
|
||||
</div>
|
||||
<MoreLink />
|
||||
</LeftColumn>
|
||||
<RightColumn>
|
||||
<IntroSlider />
|
||||
<div
|
||||
className={css({
|
||||
display: { base: 'inline', lg: 'none' },
|
||||
})}
|
||||
>
|
||||
<MoreLink />
|
||||
</div>
|
||||
</RightColumn>
|
||||
</Columns>
|
||||
<LaterMeetingDialog
|
||||
|
||||
@@ -18,10 +18,15 @@ import {
|
||||
ANIMATION_DURATION,
|
||||
ReactionPortals,
|
||||
} from '@/features/rooms/livekit/components/ReactionPortal'
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
import { PanelId } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
|
||||
export const MainNotificationToast = () => {
|
||||
const room = useRoomContext()
|
||||
const { triggerNotificationSound } = useNotificationSound()
|
||||
const { t } = useTranslation('notifications')
|
||||
const announce = useScreenReaderAnnounce()
|
||||
|
||||
const [reactions, setReactions] = useState<Reaction[]>([])
|
||||
const instanceIdRef = useRef(0)
|
||||
@@ -41,12 +46,21 @@ export const MainNotificationToast = () => {
|
||||
},
|
||||
{ timeout: NotificationDuration.MESSAGE }
|
||||
)
|
||||
if (layoutStore.activePanelId !== PanelId.CHAT) {
|
||||
announce(
|
||||
t('chatMessageReceived', {
|
||||
name: participant.name || t('defaultName'),
|
||||
message: chatMessage.message,
|
||||
}),
|
||||
'polite'
|
||||
)
|
||||
}
|
||||
}
|
||||
room.on(RoomEvent.ChatMessage, handleChatMessage)
|
||||
return () => {
|
||||
room.off(RoomEvent.ChatMessage, handleChatMessage)
|
||||
}
|
||||
}, [room, triggerNotificationSound])
|
||||
}, [room, triggerNotificationSound, announce, t])
|
||||
|
||||
const handleEmoji = (emoji: string, participant: Participant) => {
|
||||
if (!emoji || !Object.values(Emoji).includes(emoji as Emoji)) return
|
||||
|
||||
@@ -79,11 +79,20 @@ export const NoAccessView = ({
|
||||
})}
|
||||
>
|
||||
{t(`${i18nKey}.body`)}
|
||||
<br />
|
||||
{helpArticle && (
|
||||
<A href={helpArticle} target="_blank">
|
||||
{t(`${i18nKey}.linkMore`)}
|
||||
</A>
|
||||
<>
|
||||
{' '}
|
||||
<A
|
||||
href={helpArticle}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
externalIcon
|
||||
color="note"
|
||||
aria-label={t(`${i18nKey}.linkAriaLabel`)}
|
||||
>
|
||||
{t(`${i18nKey}.linkMore`)}
|
||||
</A>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</VStack>
|
||||
|
||||
@@ -151,12 +151,16 @@ export const ScreenRecordingSidePanel = () => {
|
||||
</H>
|
||||
<Text variant="body" fullWidth>
|
||||
{recordingMaxDuration
|
||||
? t('body', {
|
||||
max_duration: recordingMaxDuration,
|
||||
})
|
||||
? t('body', { max_duration: recordingMaxDuration })
|
||||
: t('bodyWithoutMaxDuration')}{' '}
|
||||
{data?.support?.help_article_recording && (
|
||||
<A href={data.support.help_article_recording} target="_blank">
|
||||
<A
|
||||
href={data.support.help_article_recording}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
externalIcon
|
||||
aria-label={t('linkAriaLabel')}
|
||||
>
|
||||
{t('linkMore')}
|
||||
</A>
|
||||
)}
|
||||
|
||||
@@ -187,12 +187,16 @@ export const TranscriptSidePanel = () => {
|
||||
</H>
|
||||
<Text variant="body" fullWidth>
|
||||
{recordingMaxDuration
|
||||
? t('body', {
|
||||
max_duration: recordingMaxDuration,
|
||||
})
|
||||
? t('body', { max_duration: recordingMaxDuration })
|
||||
: t('bodyWithoutMaxDuration')}{' '}
|
||||
{data?.support?.help_article_transcript && (
|
||||
<A href={data.support.help_article_transcript} target="_blank">
|
||||
<A
|
||||
href={data.support.help_article_transcript}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
externalIcon
|
||||
aria-label={t('linkAriaLabel')}
|
||||
>
|
||||
{t('linkMore')}
|
||||
</A>
|
||||
)}
|
||||
@@ -208,6 +212,7 @@ export const TranscriptSidePanel = () => {
|
||||
href={data.transcription_destination}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
externalIcon
|
||||
>
|
||||
{data.transcription_destination.replace('https://', '')}
|
||||
</A>
|
||||
|
||||
@@ -72,7 +72,8 @@ export const RecordingDownload = () => {
|
||||
|
||||
if (
|
||||
data.status !== RecordingStatus.Saved &&
|
||||
data.status !== RecordingStatus.NotificationSucceed
|
||||
data.status !== RecordingStatus.NotificationSucceed &&
|
||||
data.status !== RecordingStatus.FailedToStop
|
||||
) {
|
||||
return <ErrorScreen title={t('unsaved.title')} body={t('unsaved.body')} />
|
||||
}
|
||||
|
||||
@@ -183,7 +183,13 @@ export const ParticipantTile: (
|
||||
}}
|
||||
>
|
||||
{isHandRaised && !isScreenShare && (
|
||||
<span>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.1rem',
|
||||
}}
|
||||
>
|
||||
<span>{positionInQueue}</span>
|
||||
<RiHand
|
||||
color="black"
|
||||
|
||||
@@ -102,6 +102,11 @@ export function FloatingReaction({
|
||||
paddingTop: '0.15rem',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
lineHeight: '16px',
|
||||
maxWidth: '12rem',
|
||||
display: 'inline-block',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
})}
|
||||
>
|
||||
{name}
|
||||
|
||||
@@ -148,17 +148,22 @@ export const Tools = () => {
|
||||
className={css({
|
||||
textStyle: 'sm',
|
||||
paddingX: '0.75rem',
|
||||
paddingTop: '0.25rem',
|
||||
marginBottom: '1rem',
|
||||
})}
|
||||
>
|
||||
{t('body')}{' '}
|
||||
{data?.support?.help_article_more_tools && (
|
||||
<>
|
||||
<A href={data?.support?.help_article_more_tools} target="_blank">
|
||||
{t('moreLink')}
|
||||
</A>
|
||||
.
|
||||
</>
|
||||
<A
|
||||
href={data.support.help_article_more_tools}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
externalIcon
|
||||
color="note"
|
||||
aria-label={t('linkAriaLabel')}
|
||||
>
|
||||
{t('moreLink')}
|
||||
</A>
|
||||
)}
|
||||
</Text>
|
||||
{isTranscriptEnabled && (
|
||||
|
||||
+1
-1
@@ -187,7 +187,7 @@ export class BackgroundCustomProcessor implements BackgroundProcessorInterface {
|
||||
0,
|
||||
0,
|
||||
PROCESSING_WIDTH,
|
||||
PROCESSING_WIDTH
|
||||
PROCESSING_HEIGHT
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||
export function useGridLayout(
|
||||
/** HTML element that contains the grid. */
|
||||
gridElement: React.RefObject<HTMLDivElement>,
|
||||
/** Count of tracks that should get layed out */
|
||||
/** Count of tracks that should get laid out */
|
||||
trackCount: number,
|
||||
options: {
|
||||
gridLayouts?: GridLayoutDefinition[]
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useTitle } from 'hoofd'
|
||||
import { useRoomData } from './useRoomData'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
const APP_TITLE = import.meta.env.VITE_APP_TITLE ?? ''
|
||||
|
||||
/**
|
||||
* Updates the browser tab title with the room name to help users easily find
|
||||
* the meeting tab among many open tabs. Works on both the join screen and
|
||||
* once connected.
|
||||
*/
|
||||
export const useRoomPageTitle = () => {
|
||||
const roomData = useRoomData()
|
||||
|
||||
const pageTitle = useMemo(() => {
|
||||
if (!roomData) {
|
||||
return APP_TITLE
|
||||
}
|
||||
|
||||
const roomLabel = roomData.name || roomData.slug || ''
|
||||
|
||||
if (!roomLabel) return APP_TITLE
|
||||
|
||||
return `${APP_TITLE} - ${roomLabel} `
|
||||
}, [roomData])
|
||||
|
||||
useTitle(pageTitle)
|
||||
}
|
||||
@@ -151,7 +151,12 @@ export function Chat({ ...props }: ChatProps) {
|
||||
minHeight={0}
|
||||
overflowY="scroll"
|
||||
>
|
||||
<ul className="lk-list lk-chat-messages" ref={ulRef}>
|
||||
<ul
|
||||
className="lk-list lk-chat-messages"
|
||||
ref={ulRef}
|
||||
role="log"
|
||||
aria-relevant="additions"
|
||||
>
|
||||
{renderedMessages}
|
||||
</ul>
|
||||
</Div>
|
||||
|
||||
@@ -30,6 +30,7 @@ import { useSidePanel } from '../hooks/useSidePanel'
|
||||
import { RecordingProvider } from '@/features/recording'
|
||||
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
||||
import { useConnectionObserver } from '../hooks/useConnectionObserver'
|
||||
import { useRoomPageTitle } from '../hooks/useRoomPageTitle'
|
||||
import { useNoiseReduction } from '../hooks/useNoiseReduction'
|
||||
import { useRegisterKeyboardShortcut } from '@/features/shortcuts/useRegisterKeyboardShortcut'
|
||||
import { useSettingsDialog } from '@/features/settings'
|
||||
@@ -113,6 +114,7 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
)
|
||||
|
||||
useConnectionObserver()
|
||||
useRoomPageTitle()
|
||||
useVideoResolutionSubscription()
|
||||
|
||||
useRegisterKeyboardShortcut({
|
||||
|
||||
@@ -4,6 +4,7 @@ import { css } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { accessibilityStore } from '@/stores/accessibility'
|
||||
import { CaptionsSettings } from '@/features/subtitle/component/CaptionsSettings'
|
||||
|
||||
export type AccessibilityTabProps = Pick<TabPanelProps, 'id'>
|
||||
|
||||
@@ -32,6 +33,7 @@ export const AccessibilityTab = ({ id }: AccessibilityTabProps) => {
|
||||
wrapperProps={{ noMargin: true, fullWidth: true }}
|
||||
/>
|
||||
</li>
|
||||
<CaptionsSettings />
|
||||
</ul>
|
||||
</TabPanel>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Field, H } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import {
|
||||
accessibilityStore,
|
||||
type CaptionTextSize,
|
||||
CAPTION_TEXT_SIZE_OPTIONS,
|
||||
} from '@/stores/accessibility'
|
||||
|
||||
export const CaptionsSettings = () => {
|
||||
const { t } = useTranslation('settings', {
|
||||
keyPrefix: 'accessibility.captions',
|
||||
})
|
||||
const snap = useSnapshot(accessibilityStore)
|
||||
|
||||
const captionTextSizeItems = useMemo(
|
||||
() =>
|
||||
CAPTION_TEXT_SIZE_OPTIONS.map((size) => ({
|
||||
value: size,
|
||||
label: t(`textSize.options.${size}`),
|
||||
})),
|
||||
[t]
|
||||
)
|
||||
|
||||
return (
|
||||
<li>
|
||||
<H
|
||||
lvl={3}
|
||||
className={css({
|
||||
marginBottom: '0.5rem',
|
||||
})}
|
||||
>
|
||||
{t('heading')}
|
||||
</H>
|
||||
<Field
|
||||
type="select"
|
||||
label={t('textSize.label')}
|
||||
items={captionTextSizeItems}
|
||||
selectedKey={snap.captionTextSize}
|
||||
onSelectionChange={(key) => {
|
||||
accessibilityStore.captionTextSize = key as CaptionTextSize
|
||||
}}
|
||||
wrapperProps={{ noMargin: true, fullWidth: true }}
|
||||
/>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,25 @@ import { useRoomContext } from '@livekit/components-react'
|
||||
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
||||
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
||||
import { Participant, RoomEvent } from 'livekit-client'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import {
|
||||
accessibilityStore,
|
||||
CAPTION_TEXT_SIZE_OPTIONS,
|
||||
type CaptionTextSize,
|
||||
} from '@/stores/accessibility'
|
||||
|
||||
const FONT_SIZE_CONFIG: Record<
|
||||
CaptionTextSize,
|
||||
{ fontSize: string; lineHeight: string }
|
||||
> = {
|
||||
small: { fontSize: '0.875rem', lineHeight: '1.2rem' },
|
||||
medium: { fontSize: '1.5rem', lineHeight: '1.7rem' },
|
||||
large: { fontSize: '2.25rem', lineHeight: '2.5rem' },
|
||||
}
|
||||
|
||||
const CAPTION_FONT_SIZES = Object.fromEntries(
|
||||
CAPTION_TEXT_SIZE_OPTIONS.map((size) => [size, FONT_SIZE_CONFIG[size]])
|
||||
) as Record<CaptionTextSize, { fontSize: string; lineHeight: string }>
|
||||
|
||||
export interface TranscriptionSegment {
|
||||
id: string
|
||||
@@ -73,8 +92,10 @@ const useTranscriptionState = () => {
|
||||
}
|
||||
|
||||
const Transcription = ({ row }: { row: TranscriptionRow }) => {
|
||||
const { captionTextSize } = useSnapshot(accessibilityStore)
|
||||
const participantColor = getParticipantColor(row.participant)
|
||||
const participantName = getParticipantName(row.participant)
|
||||
const { fontSize, lineHeight } = CAPTION_FONT_SIZES[captionTextSize]
|
||||
|
||||
const getDisplayText = (row: TranscriptionRow): string => {
|
||||
return row.segments
|
||||
@@ -116,10 +137,9 @@ const Transcription = ({ row }: { row: TranscriptionRow }) => {
|
||||
</Text>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: '1.5rem',
|
||||
lineHeight: '1.7rem',
|
||||
fontWeight: '400',
|
||||
})}
|
||||
style={{ fontSize, lineHeight }}
|
||||
>
|
||||
{displayText}
|
||||
</p>
|
||||
@@ -198,7 +218,6 @@ export const Subtitles = () => {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return rows
|
||||
}, [transcriptionSegments])
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import resourcesToBackend from 'i18next-resources-to-backend'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
import LanguageDetector from 'i18next-browser-languagedetector'
|
||||
const i18nDefaultNamespace = 'global'
|
||||
const fallbackLng = 'fr'
|
||||
|
||||
i18n.setDefaultNamespace(i18nDefaultNamespace)
|
||||
i18n
|
||||
@@ -15,7 +16,7 @@ i18n
|
||||
.use(LanguageDetector)
|
||||
.init({
|
||||
supportedLngs: ['en', 'fr', 'nl', 'de'],
|
||||
fallbackLng: 'fr',
|
||||
fallbackLng,
|
||||
ns: i18nDefaultNamespace,
|
||||
detection: {
|
||||
order: ['localStorage', 'navigator'],
|
||||
@@ -24,3 +25,10 @@ i18n
|
||||
escapeValue: false,
|
||||
},
|
||||
})
|
||||
.then(() => {
|
||||
document.documentElement.setAttribute('lang', i18n.language || fallbackLng)
|
||||
})
|
||||
|
||||
i18n.on('languageChanged', (lang) => {
|
||||
document.documentElement.setAttribute('lang', lang)
|
||||
})
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
},
|
||||
"scope": "Diese Erklärung zur Barrierefreiheit gilt für die Website visio.numerique.gouv.fr",
|
||||
"complianceStatus": {
|
||||
"title": "Stand der Vereinbarkeit",
|
||||
"title": "Stand der Gesetzeskonformität",
|
||||
"body": "visio.numerique.gouv.fr ist nicht konform mit RGAA 4.1. Die Website wurde noch nicht geprüft. Das Team bemüht sich jedoch, eine für alle zugängliche Seite gemäß den Empfehlungen des RGAA zu erstellen."
|
||||
},
|
||||
"improvement": {
|
||||
"title": "Verbesserung und Kontakt",
|
||||
"body": "Wenn Sie auf Inhalte oder Dienste nicht zugreifen können, können Sie die verantwortliche Person von lasuite.numerique.gouv.fr kontaktieren, um eine barrierefreie Alternative zu erhalten oder die Inhalte in einer anderen Form zu bekommen.",
|
||||
"body": "Wenn du auf Inhalte oder Dienste nicht zugreifen können, kannst du die verantwortliche Person von lasuite.numerique.gouv.fr kontaktieren, um eine barrierefreie Alternative zu erhalten oder die Inhalte in einer anderen Form zu bekommen.",
|
||||
"contact": {
|
||||
"email": "E-Mail: visio@numerique.gouv.fr",
|
||||
"address": "Adresse: DINUM, 20 avenue de Ségur, 75007 Paris"
|
||||
@@ -22,11 +22,11 @@
|
||||
},
|
||||
"recourse": {
|
||||
"title": "Rechtsbehelfe",
|
||||
"introduction": "Dieses Verfahren ist in folgendem Fall zu verwenden: Sie haben der für die Website zuständigen Stelle ein Problem mit der Barrierefreiheit gemeldet, das Sie daran hindert, auf Inhalte oder Dienste des Portals zuzugreifen, und keine zufriedenstellende Antwort erhalten.",
|
||||
"introduction": "Dieses Verfahren ist in folgendem Fall zu verwenden: Du hast der für die Website zuständigen Stelle ein Problem mit der Barrierefreiheit gemeldet, das dich daran hindert, auf Inhalte oder Dienste des Portals zuzugreifen, und keine zufriedenstellende Antwort erhalten.",
|
||||
"options": {
|
||||
"intro": "Sie können:",
|
||||
"option1": "Dem*der Beauftragten für Rechte eine Nachricht schreiben",
|
||||
"option2": "Den*die Vertreter*in des*der Beauftragten für Rechte in Ihrer Region kontaktieren",
|
||||
"intro": "Du kannst:",
|
||||
"option1": "Dem*der Rechtsbeauftragten eine Nachricht schreiben",
|
||||
"option2": "Den*die Vertreter*in des*der Rechtsbeauftragten in Ihrer Region kontaktieren",
|
||||
"option3": "Einen Brief per Post senden (kostenfrei, ohne Briefmarke): </br> Défenseur des droits, Libre réponse 71120, 75342 Paris CEDEX 07"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
"heading": "Beim Laden der Seite ist ein Fehler aufgetreten"
|
||||
},
|
||||
"feedback": {
|
||||
"context": "Ihr Feedback prägt das Produkt!",
|
||||
"cta": "Teilen Sie uns Ihre Meinung mit - neues Fenster"
|
||||
"context": "Dein Feedback gestaltet das Produkt!",
|
||||
"cta": "Teile uns deine Meinung mit – neuer Tab"
|
||||
},
|
||||
"forbidden": {
|
||||
"heading": "Zugriff verweigert"
|
||||
@@ -17,13 +17,13 @@
|
||||
"login": {
|
||||
"buttonLabel": "Anmelden",
|
||||
"proconnectButtonLabel": "Mit ProConnect anmelden",
|
||||
"proconnectLinkLabel": "Was ist ProConnect? – neues Fenster",
|
||||
"proconnectLinkLabel": "Was ist ProConnect? – neuer Tab",
|
||||
"proconnectLink": "Was ist ProConnect?"
|
||||
},
|
||||
"logout": "Abmelden",
|
||||
"notFound": {
|
||||
"heading": "Überprüfen Sie Ihren Meeting-Code",
|
||||
"body": "Stellen Sie sicher, dass Sie den richtigen Meeting-Code in der URL eingegeben haben. Beispiel:"
|
||||
"heading": "Überprüfe deinen Meeting-Code",
|
||||
"body": "Stelle sicher, dass du den richtigen Meeting-Code in der URL eingegeben haben. Beispiel:"
|
||||
},
|
||||
"selected": "ausgewählt",
|
||||
"submit": "OK",
|
||||
@@ -38,16 +38,16 @@
|
||||
"accessibility": "Barrierefreiheit: nicht konform",
|
||||
"ariaLabel": "neues Fenster",
|
||||
"codeAnnotation": "Unser Code ist offen und verfügbar in diesem",
|
||||
"code": "Open-Source-Code-Repository",
|
||||
"technicalDetails": "Technisches Datenblatt",
|
||||
"code": "Open-Source-Repository",
|
||||
"technicalDetails": "Technische Angaben",
|
||||
"termsOfService": "Nutzungsbedingungen"
|
||||
},
|
||||
"mentions": "Sofern nicht anders angegeben, sind die Inhalte dieser Website verfügbar unter der",
|
||||
"license": "Etalab 2.0 Lizenz"
|
||||
},
|
||||
"loginHint": {
|
||||
"title": "Melden Sie sich mit Ihrem Konto an",
|
||||
"body": "Statt zu warten, melden Sie sich mit Ihrem ProConnect-Konto an.",
|
||||
"title": "Melde dich mit deinem Konto an",
|
||||
"body": "Melde dich mit deinem Konto an, statt zu warten.",
|
||||
"button": {
|
||||
"ariaLabel": "Hinweis schließen",
|
||||
"label": "OK"
|
||||
@@ -55,7 +55,7 @@
|
||||
},
|
||||
"skipLink": "Zum Hauptinhalt springen",
|
||||
"clipboardContent": {
|
||||
"url": "Um an der Videokonferenz teilzunehmen, klicken Sie auf diesen Link: {{roomUrl}}",
|
||||
"numberAndPin": "Um telefonisch teilzunehmen, wählen Sie {{phoneNumber}} und geben Sie diesen Code ein: {{pinCode}}"
|
||||
"url": "Um an der Videokonferenz teilzunehmen, folge diesem Link: {{roomUrl}}",
|
||||
"numberAndPin": "Um per Telefon teilzunehmen, wähle {{phoneNumber}} und gib diesen Code ein: {{pinCode}}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
{
|
||||
"createMeeting": "Meeting erstellen",
|
||||
"heading": "Einfache und sichere Videokonferenzen",
|
||||
"intro": "Kommunizieren und arbeiten Sie mühelos, ohne Ihre digitale Souveränität zu gefährden",
|
||||
"joinInputError": "Verwenden Sie einen Meeting-Link oder -Code. Beispiele:",
|
||||
"intro": "Kommuniziere und arbeite mühelos, ohne deine digitale Souveränität zu gefährden",
|
||||
"joinInputError": "Verwende einen Meeting-Link oder -Code. Beispiele:",
|
||||
"joinInputExample": "URL oder 10-stelliger Code",
|
||||
"joinInputLabel": "Meeting-Link",
|
||||
"joinInputSubmit": "Meeting beitreten",
|
||||
"joinMeeting": "Meeting beitreten",
|
||||
"joinMeetingTipContent": "Sie können einem Meeting beitreten, indem Sie den vollständigen Link in die Adressleiste Ihres Browsers einfügen.",
|
||||
"joinMeetingTipHeading": "Wussten Sie schon?",
|
||||
"loginToCreateMeeting": "Melden Sie sich an, um ein Meeting zu erstellen",
|
||||
"moreLinkLabel": "Mehr erfahren – neues Tab",
|
||||
"joinMeetingTipContent": "Du kannst einem Meeting beitreten, indem du den vollständigen Link in die Adressleiste deines Browsers einfügst.",
|
||||
"joinMeetingTipHeading": "Wusstest du schon?",
|
||||
"loginToCreateMeeting": "Melde dich an, um ein Meeting zu erstellen",
|
||||
"moreLinkLabel": "Mehr über {{appTitle}} erfahren – neuer Tab",
|
||||
"moreLink": "Mehr erfahren",
|
||||
"moreAbout": "über {{appTitle}}",
|
||||
"createMenu": {
|
||||
"laterOption": "Meeting für später planen",
|
||||
"instantOption": "Sofort-Meeting starten"
|
||||
"instantOption": "Meeting sofort starten"
|
||||
},
|
||||
"laterMeetingDialog": {
|
||||
"heading": "Ihre Zugangsdaten",
|
||||
"description": "Teilen Sie diese Informationen mit den Gästen. Sie können dem Meeting beitreten, ohne sich anmelden zu müssen. Dieses Meeting ist dauerhaft und kann wiederverwendet werden.",
|
||||
"heading": "Zugangsdaten",
|
||||
"description": "Teile diese Informationen mit den Gästen. Sie können dem Meeting beitreten, ohne sich anmelden zu müssen. Dieses Meeting ist dauerhaft und kann wiederverwendet werden.",
|
||||
"permissions": "Personen mit diesem Link benötigen keine Genehmigung, um diesem Meeting beizutreten.",
|
||||
"copy": "Informationen kopieren",
|
||||
"copied": "Informationen kopiert",
|
||||
"copyUrl": "Meeting-Link kopieren",
|
||||
"phone": {
|
||||
"call": "Rufen Sie an:",
|
||||
"call": "Ruf an:",
|
||||
"pinCode": "Code:"
|
||||
}
|
||||
},
|
||||
@@ -45,16 +45,16 @@
|
||||
"tooltip": "Formular ausfüllen"
|
||||
},
|
||||
"slide1": {
|
||||
"title": "Wechseln Sie zur Einfachheit. Testen Sie uns jetzt!",
|
||||
"body": "Entdecken Sie eine intuitive und zugängliche Lösung, entwickelt für alle Mitarbeitenden im öffentlichen Dienst, ihre Partner und viele weitere."
|
||||
"title": "Mach Schluss mit kompliziert – probier uns jetzt aus!",
|
||||
"body": "Entdecke eine intuitive und leicht zugängliche Lösung, entwickelt für alle Mitarbeitenden im öffentlichen Dienst, ihre Partner und viele weitere."
|
||||
},
|
||||
"slide2": {
|
||||
"title": "Gruppenanrufe ohne Einschränkungen",
|
||||
"body": "Unbegrenzte Meeting-Dauer mit flüssiger, hochwertiger Kommunikation – unabhängig von der Gruppengröße."
|
||||
"title": "Gruppenmeetings ohne Einschränkungen",
|
||||
"body": "Unbegrenzte Meeting-Dauer mit flüssiger Kommunikation in hoher Qualität – unabhängig von der Gruppengröße."
|
||||
},
|
||||
"slide3": {
|
||||
"title": "Verwandeln Sie Ihre Meetings mit KI",
|
||||
"body": "Erhalten Sie präzise und verwertbare Transkripte zur Steigerung Ihrer Produktivität. Funktion in der Beta – jetzt testen!"
|
||||
"title": "Verwandele deine Meetings mit KI",
|
||||
"body": "Erhalte präzise und verwertbare Transkripte zur Steigerung deiner Produktivität. Beta-Funktion – jetzt testen!"
|
||||
},
|
||||
"carouselLabel": "Einführungs-Diashow",
|
||||
"slidePosition": "Folie {{current}} von {{total}}"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"title": "Rechtliche Hinweise",
|
||||
"creator": {
|
||||
"title": "Herausgeber",
|
||||
"body": "Der Visio-Dienst wird herausgegeben von der Interministeriellen Digitaldirektion des Staates (DINUM) mit Sitz:",
|
||||
"title": "Herausgebende",
|
||||
"body": "Der Visio-Dienst wird herausgegeben von der Interministeriellen Digitaldirektion Frankreichs (DINUM) mit Sitz:",
|
||||
"contact": {
|
||||
"title": "Kontaktdaten",
|
||||
"address": "20 avenue de Ségur",
|
||||
@@ -31,7 +31,7 @@
|
||||
"title": "Wiederverwendung von Inhalten und Links",
|
||||
"body1": "Sofern nicht ausdrücklich anders angegeben und geistige Eigentumsrechte Dritter betroffen sind, werden die Inhalte dieser Website unter der ",
|
||||
"license": "offenen Lizenz Etalab 2.0",
|
||||
"body2": "bereitgestellt. Insbesondere dürfen Sie diese Inhalte frei vervielfältigen, kopieren, ändern, extrahieren, umwandeln, verbreiten, weiterleiten, veröffentlichen, übertragen und nutzen – unter der Bedingung, dass die Quelle und das Datum der letzten Aktualisierung genannt werden und Dritte nicht durch die Informationen in die Irre geführt werden.",
|
||||
"body3": "Jede öffentliche oder private Website ist berechtigt, ohne vorherige Genehmigung, einen Link (einschließlich eines Deep Links) zu den auf dieser Website veröffentlichten Informationen zu setzen."
|
||||
"body2": "bereitgestellt. Insbesondere dürfen diese Inhalte frei vervielfältigt, kopiert, geändert, extrahiert, umgewandelt, verbreitet, weitergeleitet, veröffentlicht, übertragen und genutzt werden – unter der Bedingung, dass die Quelle und das Datum der letzten Aktualisierung genannt werden und Dritte nicht durch die Informationen in die Irre geführt werden.",
|
||||
"body3": "Jede öffentliche oder private Website ist berechtigt, ohne vorherige Genehmigung, Links (einschließlich Deep Links) zu den auf dieser Website veröffentlichten Informationen zu setzen."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"defaultName": "Ein Mitwirkender",
|
||||
"defaultName": "Eine Person",
|
||||
"joined": {
|
||||
"description": "{{name}} ist dem Raum beigetreten"
|
||||
},
|
||||
@@ -7,46 +7,47 @@
|
||||
"description": "{{name}} hat die Hand gehoben.",
|
||||
"cta": "Warteliste öffnen"
|
||||
},
|
||||
"muted": "{{name}} hat dein Mikrofon stummgeschaltet. Kein Teilnehmer kann dich hören.",
|
||||
"muted": "{{name}} hat dein Mikrofon stummgeschaltet.",
|
||||
"openChat": "Chat öffnen",
|
||||
"chatMessageReceived": "{{name}} sagt: {{message}}",
|
||||
"lowerHand": {
|
||||
"auto": "Es scheint, dass du angefangen hast zu sprechen, daher wird deine Hand gesenkt.",
|
||||
"auto": "Du hast begonnen zu sprechen – deine Hand wird daher automatisch gesenkt.",
|
||||
"dismiss": "Hand oben lassen"
|
||||
},
|
||||
"reaction": {
|
||||
"description": "{{name}} hat mit {{emoji}} reagiert"
|
||||
},
|
||||
"permissionsRemoved": {
|
||||
"camera": "Der Organisator hat das Video für alle Teilnehmer deaktiviert.",
|
||||
"microphone": "Der Organisator hat das Mikrofon für alle Teilnehmer deaktiviert.",
|
||||
"screen_share": "Der Organisator hat die Bildschirmfreigabe für alle Teilnehmer deaktiviert."
|
||||
"camera": "Die Moderation hat das Video für alle Teilnehmenden deaktiviert.",
|
||||
"microphone": "Die Moderation hat das Mikrofon für alle Teilnehmenden deaktiviert.",
|
||||
"screen_share": "Die Moderation hat die Bildschirmfreigabe für alle Teilnehmenden deaktiviert."
|
||||
},
|
||||
"waitingParticipants": {
|
||||
"one": "Eine Person möchte diesem Anruf beitreten.",
|
||||
"several": "Mehrere Personen möchten diesem Anruf beitreten.",
|
||||
"one": "Eine Person möchte diesem Meeting beitreten.",
|
||||
"several": "Mehrere Personen möchten diesem Meeting beitreten.",
|
||||
"open": "Öffnen",
|
||||
"accept": "Annehmen"
|
||||
"accept": "Hereinlassen"
|
||||
},
|
||||
"transcript": {
|
||||
"started": "{{name}} hat die Transkription des Treffens gestartet.",
|
||||
"stopped": "{{name}} hat die Transkription des Treffens gestoppt.",
|
||||
"started": "{{name}} hat die Meeting-Transkription gestartet.",
|
||||
"stopped": "{{name}} hat die Meeting-Transkription gestoppt.",
|
||||
"limitReached": "Die Transkription hat die maximal zulässige Dauer überschritten und wird automatisch gespeichert.",
|
||||
"requested": "{{name}} will die Transkription starten."
|
||||
"requested": "{{name}} möchte die Meeting-Transkription starten."
|
||||
},
|
||||
"screenRecording": {
|
||||
"started": "{{name}} hat die Aufzeichnung des Treffens gestartet.",
|
||||
"stopped": "{{name}} hat die Aufzeichnung des Treffens gestoppt.",
|
||||
"started": "{{name}} hat die Meeting-Aufzeichnung gestartet.",
|
||||
"stopped": "{{name}} hat die Meeting-Aufzeichnung gestoppt.",
|
||||
"limitReached": "Die Aufzeichnung hat die maximal zulässige Dauer überschritten und wird automatisch gespeichert.",
|
||||
"requested": "{{name}} will die Aufnahme starten."
|
||||
"requested": "{{name}} möchte die Meeting-Aufzeichnung starten."
|
||||
},
|
||||
"recordingSave": {
|
||||
"transcript": {
|
||||
"message": "Wir finalisieren Ihre Aufnahme! Sie erhalten eine E-Mail an <strong>{{email}}</strong>, sobald die Transkription fertig ist.",
|
||||
"default": "Wir finalisieren Ihre Aufnahme! Sie erhalten eine E-Mail, sobald die Transkription fertig ist."
|
||||
"message": "Deine Transkription wird gespeichert! Du erhältst eine E-Mail auf <strong>{{email}}</strong>, sobald die Transkription bereit ist.",
|
||||
"default": "Deine Transkription wird gespeichert! Du erhältst das Transkript per E-Mail, sobald es bereit ist."
|
||||
},
|
||||
"screenRecording": {
|
||||
"message": "Wir finalisieren Ihre Aufnahme! Sie erhalten eine E-Mail an <strong>{{email}}</strong>, sobald sie fertig ist.",
|
||||
"default": "Wir finalisieren Ihre Aufnahme! Sie erhalten eine E-Mail, sobald sie fertig ist."
|
||||
"message": "Deine Aufzeichnung wird gespeichert! Du erhältst eine E-Mail auf <strong>{{email}}</strong>, sobald sie bereit ist.",
|
||||
"default": "Deine Aufzeichnung wird gespeichert! Du erhältst eine E-Mail, sobald sie bereit ist."
|
||||
}
|
||||
},
|
||||
"openMenu": "Menü öffnen"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"error": {
|
||||
"title": "Aufzeichnung nicht verfügbar",
|
||||
"body": "Die Aufzeichnung ist nicht verfügbar oder wurde möglicherweise gelöscht. Nur der Organisator der Besprechung hat Zugriff darauf. Wenden Sie sich bei Bedarf gerne an ihn."
|
||||
"body": "Die Aufzeichnung ist nicht verfügbar oder wurde möglicherweise gelöscht. Nur die*der Organisator:in der Besprechung hat Zugriff darauf. Wende dich bei Bedarf gerne an die Person."
|
||||
},
|
||||
"expired": {
|
||||
"title": "Aufzeichnung abgelaufen",
|
||||
@@ -9,20 +9,20 @@
|
||||
},
|
||||
"authentication": {
|
||||
"title": "Authentifizierung erforderlich",
|
||||
"body": "Bitte melden Sie sich an, um auf diese Aufzeichnung zuzugreifen."
|
||||
"body": "Bitte melde dich sich an, um auf diese Aufzeichnung zuzugreifen."
|
||||
},
|
||||
"unsaved": {
|
||||
"title": "Download nicht verfügbar",
|
||||
"body": "Diese Aufzeichnung ist noch nicht zum Herunterladen bereit. Bitte versuchen Sie es später erneut."
|
||||
"body": "Diese Aufzeichnung ist noch nicht zum Herunterladen bereit. Bitte versuche es später erneut."
|
||||
},
|
||||
"success": {
|
||||
"title": "Ihre Aufzeichnung ist bereit!",
|
||||
"body": "Aufzeichnung des Treffens {{room}} vom {{created_at}}.",
|
||||
"title": "Deine Aufzeichnung ist bereit!",
|
||||
"body": "Aufzeichnung des Meetings {{room}} vom {{created_at}}.",
|
||||
"expiration": "Achtung, diese Aufzeichnung wird nach {{expiration_days}} Tag(en) gelöscht.",
|
||||
"button": "Herunterladen",
|
||||
"warning": {
|
||||
"title": "Linkfreigabe deaktiviert",
|
||||
"body": "Die Freigabe der Aufzeichnung per Link ist noch nicht verfügbar. Nur Organisatoren können sie herunterladen."
|
||||
"body": "Die Freigabe der Aufzeichnung per Link ist noch nicht verfügbar. Nur Organisator:innen können sie herunterladen."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
{
|
||||
"feedback": {
|
||||
"heading": {
|
||||
"normal": "Sie haben das Meeting verlassen",
|
||||
"duplicateIdentity": "Sie haben dem Meeting von einem anderen Gerät aus beigetreten",
|
||||
"participantRemoved": "Sie wurden von einem Administrator aus dem Anruf entfernt"
|
||||
"normal": "Du hast das Meeting verlassen",
|
||||
"duplicateIdentity": "Du bist dem Meeting von einem anderen Gerät aus beigetreten",
|
||||
"participantRemoved": "Du wurdest von der*dem Organisator:in aus dem Meeting entfernt"
|
||||
},
|
||||
"home": "Zur Startseite zurückkehren",
|
||||
"back": "Dem Meeting erneut beitreten"
|
||||
},
|
||||
"selectDevice": {
|
||||
"loading": "Laden…",
|
||||
"select": "Wählen Sie einen Wert",
|
||||
"permissionsNeeded": "Genehmigung erforderlich",
|
||||
"select": "Wähle einen Wert",
|
||||
"permissionsNeeded": "Berechtigung erforderlich",
|
||||
"settings": {
|
||||
"audio": "Audioeinstellungen",
|
||||
"video": "Videoeinstellungen"
|
||||
@@ -19,7 +19,7 @@
|
||||
"effects": "Effekte anwenden",
|
||||
"videoinput": {
|
||||
"choose": "Kamera auswählen",
|
||||
"permissionsNeeded": "Kamera auswählen - genehmigung erforderlich",
|
||||
"permissionsNeeded": "Kamera auswählen – Berechtigung erforderlich",
|
||||
"disable": "Kamera deaktivieren",
|
||||
"enable": "Kamera aktivieren",
|
||||
"turnedOff": "Kamera deaktiviert",
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"audioinput": {
|
||||
"choose": "Mikrofon auswählen",
|
||||
"permissionsNeeded": "Mikrofon auswählen - genehmigung erforderlich",
|
||||
"permissionsNeeded": "Mikrofon auswählen – Berechtigung erforderlich",
|
||||
"disable": "Mikrofon deaktivieren",
|
||||
"enable": "Mikrofon aktivieren",
|
||||
"turnedOff": "Mikrofon deaktiviert",
|
||||
@@ -37,30 +37,30 @@
|
||||
"label": "Mikrofon"
|
||||
},
|
||||
"audiooutput": {
|
||||
"choose": "Lautsprecher auswählen",
|
||||
"permissionsNeeded": "Lautsprecher auswählen - genehmigung erforderlich"
|
||||
"choose": "Audioausgabe auswählen",
|
||||
"permissionsNeeded": "Audioausgabe auswählen – Berechtigung erforderlich"
|
||||
}
|
||||
},
|
||||
"join": {
|
||||
"effects": {
|
||||
"description": "Effekte anwenden",
|
||||
"title": "Effekte",
|
||||
"subTitle": "Konfigurieren Sie die Effekte Ihrer Kamera."
|
||||
"subTitle": "Konfiguriere die Effekte deiner Kamera."
|
||||
},
|
||||
"heading": "Dem Meeting beitreten?",
|
||||
"joinLabel": "Beitreten",
|
||||
"joinMeeting": "Meeting beitreten",
|
||||
"toggleOff": "Klicken, um auszuschalten",
|
||||
"toggleOn": "Klicken, um einzuschalten",
|
||||
"usernameHint": "Wird anderen Teilnehmern angezeigt",
|
||||
"usernameLabel": "Ihr Name",
|
||||
"usernameHint": "Wird anderen Teilnehmenden angezeigt",
|
||||
"usernameLabel": "Dein Name",
|
||||
"errors": {
|
||||
"usernameEmpty": "Ihr Name darf nicht leer sein"
|
||||
"usernameEmpty": "Dein Name darf nicht leer sein"
|
||||
},
|
||||
"cameraDisabled": "Kamera ist deaktiviert.",
|
||||
"cameraStarting": "Kamera wird gestartet…",
|
||||
"cameraNotGranted": "Möchten Sie, dass andere Sie während der Besprechung sehen können?",
|
||||
"cameraAndMicNotGranted": "Möchten Sie, dass andere Sie während der Besprechung sehen und hören können?",
|
||||
"cameraNotGranted": "Möchtest du, dass andere dich während des Meetings sehen können?",
|
||||
"cameraAndMicNotGranted": "Möchtest du, dass andere dich während des Meetings sehen und hören können?",
|
||||
"permissionsButton": {
|
||||
"cameraAndMicNotGranted": "Zugriff auf Kamera und Mikrofon erlauben",
|
||||
"cameraNotGranted": "Zugriff auf Kamera erlauben"
|
||||
@@ -71,28 +71,28 @@
|
||||
},
|
||||
"waiting": {
|
||||
"title": "Beitrittsanfrage wird gesendet...",
|
||||
"body": "Sie können diesem Anruf beitreten, sobald jemand Sie autorisiert"
|
||||
"body": "Du kannst diesem Meeting beitreten, sobald deine Anfrage angenommen wird"
|
||||
},
|
||||
"denied": {
|
||||
"title": "Sie können diesem Anruf nicht beitreten",
|
||||
"body": "Ihre Beitrittsanfrage wurde abgelehnt."
|
||||
"title": "Du kannst diesem Meeting nicht beitreten",
|
||||
"body": "Deine Beitrittsanfrage wurde abgelehnt."
|
||||
},
|
||||
"timeoutInvite": {
|
||||
"title": "Sie können diesem Anruf nicht beitreten",
|
||||
"body": "Niemand hat auf Ihre Anfrage reagiert"
|
||||
"title": "Du kannst diesem Meeting nicht beitreten",
|
||||
"body": "Niemand hat auf deine Anfrage reagiert"
|
||||
}
|
||||
},
|
||||
"leaveRoomPrompt": "Dadurch verlassen Sie das Meeting.",
|
||||
"leaveRoomPrompt": "Hiermit verlässt du das Meeting.",
|
||||
"shareDialog": {
|
||||
"copy": "Informationen kopieren",
|
||||
"copy": "Infos kopieren",
|
||||
"copyUrl": "Link kopieren",
|
||||
"copied": "Informationen kopiert",
|
||||
"heading": "Ihr Meeting ist bereit",
|
||||
"description": "Teilen Sie diesen Link mit Personen, die Sie zum Meeting einladen möchten.",
|
||||
"copied": "Infos kopiert",
|
||||
"heading": "Dein Meeting ist bereit",
|
||||
"description": "Teile diesen Link mit Personen, die du zum Meeting einladen möchten.",
|
||||
"permissions": "Personen mit diesem Link benötigen keine Erlaubnis, um diesem Meeting beizutreten.",
|
||||
"closeDialog": "Dialogfenster schließen",
|
||||
"phone": {
|
||||
"call": "Rufen Sie an:",
|
||||
"call": "Ruf an:",
|
||||
"pinCode": "Code:"
|
||||
}
|
||||
},
|
||||
@@ -108,62 +108,62 @@
|
||||
"videoinput": "Kamera konnte nicht aktiviert werden"
|
||||
},
|
||||
"body": {
|
||||
"audioinput": "Ihr Mikrofon wird bereits in einem anderen Tab oder von einer anderen Anwendung verwendet, was die Aktivierung in diesem Videoanruf verhindert. Wenn Sie möchten, schließen Sie den anderen Tab oder die Anwendung, um es hier zu verwenden.",
|
||||
"videoinput": "Ihre Kamera wird bereits in einem anderen Tab oder von einer anderen Anwendung verwendet, was die Aktivierung in diesem Videoanruf verhindert. Wenn Sie möchten, schließen Sie den anderen Tab oder die Anwendung, um sie hier zu verwenden."
|
||||
"audioinput": "Dein Mikrofon wird bereits in einem anderen Tab oder von einer anderen App verwendet. Dies verhindert die Aktivierung in diesem Meeting. Wenn du dein Mikrofon hier verwenden möchtest, schließe den anderen Tab oder die App.",
|
||||
"videoinput": "Deine Kamera wird bereits in einem anderen Tab oder von einer anderen App verwendet. Dies verhindert die Aktivierung in diesem Meeting. Wenn du deine Kamera hier verwenden möchtest, schließe den anderen Tab oder die App."
|
||||
}
|
||||
},
|
||||
"close": "OK"
|
||||
},
|
||||
"permissionErrorDialog": {
|
||||
"heading": {
|
||||
"camera": "{{appTitle}} darf Ihre Kamera nicht verwenden",
|
||||
"microphone": "{{appTitle}} darf Ihr Mikrofon nicht verwenden",
|
||||
"cameraAndMicrophone": "{{appTitle}} darf weder Ihr Mikrofon noch Ihre Kamera verwenden",
|
||||
"camera": "{{appTitle}} hat keine Berechtigung, um deine Kamera zu verwenden",
|
||||
"microphone": "{{appTitle}} hat keine Berechtigung, um dein Mikrofon zu verwenden",
|
||||
"cameraAndMicrophone": "{{appTitle}} hat keine Berechtigung, um dein Mikrofon und deine Kamera zu verwenden",
|
||||
"default": "{{appTitle}} hat keine Berechtigung für bestimmte Zugriffe"
|
||||
},
|
||||
"body": {
|
||||
"openMenu": {
|
||||
"others": "Klicken Sie auf das Einstellungen-Symbol ICON_PLACEHOLDER in der Adressleiste Ihres Browsers",
|
||||
"safari": "Klicken Sie auf das Menü 'Safari' und öffnen Sie 'Einstellungen für {{appDomain}}'."
|
||||
"others": "Klicke das Einstellungs-Symbol ICON_PLACEHOLDER in der Adressleiste deines Browsers",
|
||||
"safari": "Klicke auf das Menü 'Safari' und öffne 'Einstellungen für {{appDomain}}'."
|
||||
},
|
||||
"details": {
|
||||
"camera": "Zugriff auf die Kamera erlauben",
|
||||
"microphone": "Zugriff auf das Mikrofon erlauben",
|
||||
"cameraAndMicrophone": "Zugriff auf Kamera und Mikrofon erlauben",
|
||||
"default": "Aktivieren Sie die erforderlichen Berechtigungen."
|
||||
"default": "Erlaube die erforderlichen Berechtigungen."
|
||||
}
|
||||
}
|
||||
},
|
||||
"permissionsButton": {
|
||||
"tooltip": "Mehr Infos",
|
||||
"ariaLabel": "Berechtigungsproblem. Mehr Infos anzeigen"
|
||||
"ariaLabel": "Problem mit Berechtigungen. Mehr Infos anzeigen"
|
||||
},
|
||||
"error": {
|
||||
"createRoom": {
|
||||
"heading": "Authentifizierung erforderlich",
|
||||
"body": "Dieser Raum wurde noch nicht erstellt. Bitte authentifizieren Sie sich, um ihn zu erstellen, oder warten Sie, bis ein authentifizierter Benutzer dies tut."
|
||||
"body": "Dieser Raum wurde noch nicht erstellt. Bitte authentifiziere dich, um ihn zu erstellen, oder warte, bis eine authentifizierte Person dies tut."
|
||||
},
|
||||
"screenShare": {
|
||||
"title": "Bildschirmfreigabe nicht möglich",
|
||||
"ariaLabel": "Bildschirmfreigabe nicht möglich",
|
||||
"message": "Ihr Browser darf möglicherweise den Bildschirm Ihres Computers nicht aufzeichnen.",
|
||||
"macInstructions": "Gehen Sie zu Ihren",
|
||||
"message": "Deinem Browser fehlt möglicherweise die Berechtigung, den Bildschirm deines Geräts abzugreifen.",
|
||||
"macInstructions": "Gehe zu den",
|
||||
"macSystemPreferences": "Systemeinstellungen",
|
||||
"helpLinkText": "Weitere Informationen finden Sie unter",
|
||||
"helpLinkLabel": "Präsentationsproblem",
|
||||
"helpLinkText": "Weitere Informationen findest du unter",
|
||||
"helpLinkLabel": "Bildschirmfreigabeproblem",
|
||||
"closeButton": "Schließen",
|
||||
"newTab": "Neues Fenster"
|
||||
"newTab": "Neuer Tab"
|
||||
}
|
||||
},
|
||||
"isIdleDisconnectModal": {
|
||||
"title": "Bist du noch da?",
|
||||
"body": "Du bist der einzige Teilnehmer. Dieses Gespräch endet in {{duration}}. Möchtest du das Gespräch fortsetzen?",
|
||||
"body": "Du bist die*der einzige Teilnehmende. Diese Meeting endet in {{duration}}. Möchtest du das Meeting fortsetzen?",
|
||||
"settingsPrefix": "Um diese Nachricht nicht mehr zu sehen, gehe zu",
|
||||
"settingsLink": "Einstellungen > Allgemein",
|
||||
"settingsSuffix": ".",
|
||||
"stayButton": "Gespräch fortsetzen",
|
||||
"stayButton": "Meeting fortsetzen",
|
||||
"leaveButton": "Jetzt verlassen",
|
||||
"countdownAnnouncement": "Das Gespräch endet in {{duration}}."
|
||||
"countdownAnnouncement": "Das Meeting endet in {{duration}}."
|
||||
},
|
||||
"controls": {
|
||||
"microphone": "Mikrofon",
|
||||
@@ -201,8 +201,8 @@
|
||||
"participants": {
|
||||
"open": "Alle ausblenden",
|
||||
"closed": "Alle anzeigen",
|
||||
"count_one": "{{count}} Teilnehmer",
|
||||
"count_other": "{{count}} Teilnehmer"
|
||||
"count_one": "{{count}} Teilnehmende",
|
||||
"count_other": "{{count}} Teilnehmende"
|
||||
},
|
||||
"tools": {
|
||||
"open": "Weitere Tools ausblenden",
|
||||
@@ -217,16 +217,16 @@
|
||||
"button": "Reaktion senden",
|
||||
"send": "Reaktion {{emoji}} senden",
|
||||
"announce": "{{name}} : {{emoji}}",
|
||||
"you": "Sie",
|
||||
"you": "du",
|
||||
"emojis": {
|
||||
"thumbs-up": "Daumen hoch",
|
||||
"thumbs-down": "Daumen runter",
|
||||
"clapping-hands": "Klatschende Hände",
|
||||
"red-heart": "rotes Herz",
|
||||
"red-heart": "Rotes Herz",
|
||||
"face-with-tears-of-joy": "Gesicht mit Freudentränen",
|
||||
"face-with-open-mouth": "überraschter Gesichtsausdruck",
|
||||
"party-popper": "Party-Popper",
|
||||
"folded-hands": "gefaltete Hände"
|
||||
"face-with-open-mouth": "Überraschter Gesichtsausdruck",
|
||||
"party-popper": "Konfettikanone",
|
||||
"folded-hands": "Gefaltete Hände"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -237,7 +237,7 @@
|
||||
"settings": "Einstellungen",
|
||||
"screenRecording": "Aufzeichnung",
|
||||
"transcript": "Transkription",
|
||||
"username": "Ihren Namen aktualisieren",
|
||||
"username": "Deinen Namen aktualisieren",
|
||||
"effects": "Effekte anwenden",
|
||||
"switchCamera": "Kamera wechseln",
|
||||
"fullscreen": {
|
||||
@@ -248,9 +248,9 @@
|
||||
}
|
||||
},
|
||||
"effects": {
|
||||
"activateCamera": "Ihre Kamera ist deaktiviert. Wählen Sie eine Option, um sie zu aktivieren.",
|
||||
"cameraDisabled": "Ihre Kamera ist deaktiviert.",
|
||||
"notAvailable": "Videoeffekte werden bald in Ihrem Browser verfügbar sein. Wir arbeiten daran! In der Zwischenzeit können Sie Google Chrome für die beste Leistung oder Firefox verwenden :(",
|
||||
"activateCamera": "Deine Kamera ist deaktiviert. Wähle eine Option, um sie zu aktivieren.",
|
||||
"cameraDisabled": "Deine Kamera ist deaktiviert.",
|
||||
"notAvailable": "Videoeffekte werden bald in deinem Browser verfügbar sein. Wir arbeiten daran! In der Zwischenzeit kannst du Google Chrome für die beste Leistung oder Firefox verwenden :(",
|
||||
"heading": "Unschärfe",
|
||||
"clear": "Effekt deaktivieren",
|
||||
"blur": {
|
||||
@@ -300,8 +300,8 @@
|
||||
"ariaLabel": "Seitenleiste",
|
||||
"backToTools": "Zurück zu den Meeting-Tools",
|
||||
"heading": {
|
||||
"participants": "Teilnehmer",
|
||||
"effects": "Effekte",
|
||||
"participants": "Teilnehmende",
|
||||
"effects": "Hintergrund & Effekte",
|
||||
"chat": "Nachrichten im Chat",
|
||||
"transcript": "Transkribieren",
|
||||
"screenRecording": "Aufzeichnen",
|
||||
@@ -310,8 +310,8 @@
|
||||
"info": "Meeting-Informationen"
|
||||
},
|
||||
"content": {
|
||||
"participants": "Teilnehmer",
|
||||
"effects": "Effekte",
|
||||
"participants": "Teilnehmende",
|
||||
"effects": "Hintergrund & Effekte",
|
||||
"chat": "Nachrichten",
|
||||
"transcript": "transkribieren",
|
||||
"screenRecording": "aufzeichnen",
|
||||
@@ -322,11 +322,13 @@
|
||||
"closeButton": "{{content}} ausblenden"
|
||||
},
|
||||
"chat": {
|
||||
"disclaimer": "Die Nachrichten sind nur für Teilnehmer zum Zeitpunkt des Sendens sichtbar. Alle Nachrichten werden am Ende des Anrufs gelöscht."
|
||||
"disclaimer": "Die Nachrichten sind nur für Teilnehmende zum Zeitpunkt des Sendens sichtbar. Alle Nachrichten werden am Ende des Meetings gelöscht.",
|
||||
"messagesLog": "Chat-Nachrichten"
|
||||
},
|
||||
"moreTools": {
|
||||
"body": "Greifen Sie auf weitere Tools zu, um Ihre Meetings zu verbessern.",
|
||||
"moreLink": "Mehr erfahren",
|
||||
"body": "Nutze weitere Tools, um deine Meetings zu verbessern.",
|
||||
"linkAriaLabel": "Dokumentation zu den Tools öffnen – öffnet in neuem Tab",
|
||||
"moreLink": "Dokumentation öffnen",
|
||||
"tools": {
|
||||
"transcript": {
|
||||
"title": "Transkribieren",
|
||||
@@ -342,73 +344,77 @@
|
||||
"roomInformation": {
|
||||
"title": "Verbindungsinformationen",
|
||||
"button": {
|
||||
"ariaLabel": "Kopieren Sie die Informationen aus Ihrer Besprechung",
|
||||
"ariaLabel": "Kopiere die Informationen aus dem Meeting",
|
||||
"copy": "Informationen kopieren",
|
||||
"copied": "Informationen kopiert"
|
||||
},
|
||||
"phone": {
|
||||
"call": "Rufen Sie an:",
|
||||
"call": "Ruf an:",
|
||||
"pinCode": "Code:"
|
||||
}
|
||||
}
|
||||
},
|
||||
"transcript": {
|
||||
"heading": "Transkribieren Sie Ihr Meeting mit dem KI-Assistenten",
|
||||
"body": "Transkribieren Sie bis zu {{max_duration}} Meetingdauer.",
|
||||
"bodyWithoutMaxDuration": "Transkribieren Sie Ihr Meeting ohne Begrenzung.",
|
||||
"heading": "Transkribiere dein Meeting mithilfe von KI",
|
||||
"body": "Transkribiere bis zu {{max_duration}} Meetingdauer.",
|
||||
"bodyWithoutMaxDuration": "Transkribieren dein Meeting ohne Zeitbegrenzung.",
|
||||
"details": {
|
||||
"receiver": "Das Transkript wird an den Organisator und die Mitorganisatoren gesendet.",
|
||||
"receiver": "Das Transkript wird an die*den Organisator:in und die Mitorganisierenden gesendet.",
|
||||
"destination": "Ein neues Dokument wird erstellt auf",
|
||||
"destinationUnknown": "Ein neues Dokument wird erstellt",
|
||||
"language": "Meeting-Sprache:",
|
||||
"recording": "Auch eine Aufzeichnung starten"
|
||||
},
|
||||
"button": {
|
||||
"start": "Meeting transkribieren starten",
|
||||
"stop": "Meeting transkribieren beenden",
|
||||
"start": "Meeting-Transkription starten",
|
||||
"stop": "Meeting-Transkription beenden",
|
||||
"saving": "Speichern…",
|
||||
"starting": "Wird gestartet…",
|
||||
"anotherModeStarted": "Eine Aufnahme läuft. <br/> Wechseln Sie das Menü und stoppen Sie sie."
|
||||
"anotherModeStarted": "Eine Aufnahme läuft. <br/> Wechsele das Menü und stoppe sie."
|
||||
},
|
||||
"linkMore": "Mehr erfahren",
|
||||
"linkMore": "Dokumentation öffnen",
|
||||
"linkAriaLabel": "Dokumentation zur Transkription öffnen – öffnet in neuem Tab",
|
||||
"notAdminOrOwner": {
|
||||
"heading": "Zugriff eingeschränkt",
|
||||
"body": "Aus Sicherheitsgründen kann nur der Ersteller oder ein Administrator des Meetings eine Transkription (Beta) starten.",
|
||||
"linkMore": "Mehr erfahren",
|
||||
"body": "Aus Sicherheitsgründen kann nur die*der Ersteller:in bzw. Admin des Meetings eine Transkription (Beta) starten.",
|
||||
"linkMore": "Dokumentation öffnen",
|
||||
"linkAriaLabel": "Dokumentation zum Transkriptionszugang öffnen – öffnet in neuem Tab",
|
||||
"dividerLabel": "ODER",
|
||||
"login": {
|
||||
"heading": "Anmeldung erforderlich",
|
||||
"body": "Nur der Ersteller des Meetings oder ein Administrator kann die Transkription starten. Melden Sie sich an, um Ihre Berechtigungen zu überprüfen."
|
||||
"body": "Nur die*der Ersteller:in bzw. Admin kann die Transkription starten. Melde dich an, um deine Berechtigungen zu überprüfen."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Anfrage an Moderator",
|
||||
"body": "Der Moderator wird benachrichtigt und kann die Transkription starten.",
|
||||
"buttonLabel": "Anfordern"
|
||||
"heading": "Transkription anfragen",
|
||||
"body": "Die Moderation wird benachrichtigt und kann die Transkription starten.",
|
||||
"buttonLabel": "Anfragen"
|
||||
}
|
||||
},
|
||||
"premium": {
|
||||
"heading": "Premium-Funktion",
|
||||
"body": "Diese Funktion ist öffentlichen Bediensteten vorbehalten. Wenn Ihre E-Mail-Adresse nicht autorisiert ist, kontaktieren Sie bitte den Support, um Zugriff zu erhalten.",
|
||||
"linkMore": "Mehr erfahren",
|
||||
"body": "Diese Funktion ist öffentlichen Bediensteten vorbehalten. Wenn deine E-Mail-Adresse nicht autorisiert ist, kontaktiere bitte den Support, um Zugriff zu erhalten.",
|
||||
"linkMore": "Dokumentation öffnen",
|
||||
"linkAriaLabel": "Dokumentation zum Premium-Zugang öffnen – öffnet in neuem Tab",
|
||||
"dividerLabel": "ODER",
|
||||
"login": {
|
||||
"heading": "Anmeldung erforderlich",
|
||||
"body": "Nur der Ersteller des Meetings oder ein Administrator kann die Transkription starten. Melden Sie sich an, um Ihre Berechtigungen zu überprüfen."
|
||||
"body": "Nur die*der Ersteller:in bzw. Admin kann die Transkription starten. Melde dich an, um deine Berechtigungen zu überprüfen."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Anfrage an Moderator",
|
||||
"body": "Der Moderator wird benachrichtigt und kann die Transkription starten.",
|
||||
"heading": "Transkription anfragen",
|
||||
"body": "Die Moderation wird benachrichtigt und kann die Transkription starten.",
|
||||
"buttonLabel": "Anfordern"
|
||||
}
|
||||
}
|
||||
},
|
||||
"screenRecording": {
|
||||
"heading": "Diesen Anruf für später aufzeichnen",
|
||||
"body": "Zeichnen Sie bis zu {{max_duration}} des Meetings auf.",
|
||||
"bodyWithoutMaxDuration": "Zeichnen Sie Ihr Meeting unbegrenzt auf.",
|
||||
"linkMore": "Mehr erfahren",
|
||||
"heading": "Dieses Meeting für später aufzeichnen",
|
||||
"body": "Zeichne bis zu {{max_duration}} des Meetings auf.",
|
||||
"bodyWithoutMaxDuration": "Zeichne dein Meeting ohne Zeitbegrenzung auf.",
|
||||
"linkMore": "Dokumentation öffnen",
|
||||
"linkAriaLabel": "Dokumentation zur Aufzeichnung öffnen – öffnet in neuem Tab",
|
||||
"details": {
|
||||
"receiver": "Die Aufzeichnung wird an den Organisator und die Mitorganisatoren gesendet.",
|
||||
"receiver": "Die Aufzeichnung wird an die*den Organisator:in und die Mitorganisierenden gesendet.",
|
||||
"destination": "Diese Aufzeichnung wird vorübergehend auf unseren Servern gespeichert",
|
||||
"loading": "Aufzeichnung wird gestartet",
|
||||
"linkMore": "Mehr erfahren",
|
||||
@@ -419,20 +425,21 @@
|
||||
"stop": "Meeting-Aufzeichnung beenden",
|
||||
"saving": "Wird gespeichert…",
|
||||
"starting": "Wird gestartet…",
|
||||
"anotherModeStarted": "Eine Transkription läuft. <br/> Wechseln Sie das Menü und stoppen Sie sie."
|
||||
"anotherModeStarted": "Eine Transkription läuft. <br/> Wechsele das Menü und stoppen sie."
|
||||
},
|
||||
"notAdminOrOwner": {
|
||||
"heading": "Zugriff eingeschränkt",
|
||||
"body": "Aus Sicherheitsgründen kann nur der Ersteller oder ein Administrator des Meetings eine Videoaufnahme (Beta) starten.",
|
||||
"linkMore": "Mehr erfahren",
|
||||
"body": "Aus Sicherheitsgründen kann nur die*der Ersteller:in bzw. Admin des Meetings eine Videoaufnahme (Beta) starten.",
|
||||
"linkMore": "Dokumentation öffnen",
|
||||
"linkAriaLabel": "Dokumentation zum Aufzeichnungszugang öffnen – öffnet in neuem Tab",
|
||||
"dividerLabel": "ODER",
|
||||
"login": {
|
||||
"heading": "Anmeldung erforderlich",
|
||||
"body": "Nur der Ersteller der Besprechung oder ein Administrator kann die Aufzeichnung starten. Melden Sie sich an, um Ihre Berechtigungen zu überprüfen."
|
||||
"body": "Nur die*der Ersteller:in bzw. Admin kann die Aufzeichnung starten. Melde dich an, um deine Berechtigungen zu überprüfen."
|
||||
},
|
||||
"request": {
|
||||
"heading": "Aufnahme anfordern",
|
||||
"body": "Der Moderator wird benachrichtigt und kann die Aufnahme für Sie starten.",
|
||||
"heading": "Aufnahme anfragen",
|
||||
"body": "Die Moderation wird benachrichtigt und kann die Aufnahme starten.",
|
||||
"buttonLabel": "Anfrage senden"
|
||||
}
|
||||
},
|
||||
@@ -447,10 +454,10 @@
|
||||
"button": "OK"
|
||||
},
|
||||
"admin": {
|
||||
"description": "Diese Einstellungen für Organisatoren ermöglichen dir die Kontrolle über dein Meeting. Nur Organisatoren haben Zugriff auf diese Optionen.",
|
||||
"description": "Diese Einstellungen für Organisierende ermöglichen dir die Kontrolle über dein Meeting. Nur Organisierende haben Zugriff auf diese Optionen.",
|
||||
"access": {
|
||||
"title": "Raumzugang",
|
||||
"description": "Diese Einstellungen gelten auch für zukünftige Ausgaben dieses Meetings.",
|
||||
"description": "Diese Einstellungen gelten auch für zukünftige Meetings in diesem Raum.",
|
||||
"type": "Zugangsarten für Meetings",
|
||||
"levels": {
|
||||
"public": {
|
||||
@@ -468,28 +475,28 @@
|
||||
}
|
||||
},
|
||||
"moderation": {
|
||||
"title": "Moderation des Meetings",
|
||||
"description": "Diese Einstellungen beschränken die Aktionen, die Mitwirkende während des Meetings durchführen können.",
|
||||
"title": "Meeting-Moderation",
|
||||
"description": "Diese Einstellungen beschränken die Aktionen, die Teilnehmende während des Meetings durchführen können.",
|
||||
"microphone": {
|
||||
"label": "Mikrofon einschalten",
|
||||
"description": ""
|
||||
},
|
||||
"camera": {
|
||||
"label": "Video aktivieren",
|
||||
"label": "Video einschalten",
|
||||
"description": ""
|
||||
},
|
||||
"screenshare": {
|
||||
"label": "Ihren Bildschirm teilen",
|
||||
"description": "Wenn Sie diese Option deaktivieren, können Teilnehmer ihren Bildschirm nicht mehr teilen, und laufende Bildschirmfreigaben werden sofort beendet."
|
||||
"label": "Bildschirm teilen",
|
||||
"description": "Wenn du diese Option deaktivierst, können Teilnehmende ihren Bildschirm nicht mehr teilen. Laufende Bildschirmfreigaben werden sofort beendet."
|
||||
}
|
||||
}
|
||||
},
|
||||
"rating": {
|
||||
"submit": "Absenden",
|
||||
"question": "Wie bewertest du die Qualität deines Gesprächs?",
|
||||
"question": "Wie bewertest du die Qualität dieses Meetings?",
|
||||
"levels": {
|
||||
"min": "sehr schlecht",
|
||||
"max": "ausgezeichnet"
|
||||
"max": "hervorragend"
|
||||
}
|
||||
},
|
||||
"openFeedback": {
|
||||
@@ -499,7 +506,7 @@
|
||||
"skip": "Überspringen"
|
||||
},
|
||||
"confirmationMessage": {
|
||||
"heading": "Vielen Dank für deine Rückmeldung",
|
||||
"heading": "Vielen Dank für dein Feedback",
|
||||
"body": "Unser Produktteam nimmt sich Zeit, dein Feedback sorgfältig zu prüfen. Wir melden uns so bald wie möglich bei dir."
|
||||
},
|
||||
"authenticationMessage": {
|
||||
@@ -511,9 +518,9 @@
|
||||
"participants": {
|
||||
"subheading": "Im Raum",
|
||||
"you": "Du",
|
||||
"unknown": "Unbekannter Teilnehmer",
|
||||
"unknown": "Unbekannte Person",
|
||||
"host": "Host",
|
||||
"contributors": "Mitwirkende",
|
||||
"contributors": "Teilnehmende",
|
||||
"collapsable": {
|
||||
"open": "{{name}}-Liste öffnen",
|
||||
"close": "{{name}}-Liste schließen"
|
||||
@@ -522,7 +529,7 @@
|
||||
"muteParticipant": "{{name}}’s Mikrofon ausschalten",
|
||||
"muteParticipantAlert": {
|
||||
"heading": "{{name}} stummschalten",
|
||||
"description": "{{name}} für alle Teilnehmer stummschalten? {{name}} kann sich nur selbst wieder einschalten.",
|
||||
"description": "{{name}} für alle Teilnehmenden stummschalten? {{name}} kann sich nur selbst wieder einschalten.",
|
||||
"confirm": "Stummschalten",
|
||||
"cancel": "Abbrechen"
|
||||
},
|
||||
@@ -547,8 +554,8 @@
|
||||
},
|
||||
"participantMenu": {
|
||||
"remove": {
|
||||
"label": "Vom Anruf ausschließen",
|
||||
"ariaLabel": "{{name}} vom Anruf ausschließen"
|
||||
"label": "Vom Meeting ausschließen",
|
||||
"ariaLabel": "{{name}} vom Meeting ausschließen"
|
||||
},
|
||||
"unpin": {
|
||||
"label": "Lösen",
|
||||
@@ -582,8 +589,8 @@
|
||||
"started": "Aufzeichnung läuft"
|
||||
},
|
||||
"limitReachedAlert": {
|
||||
"title": "Aufnahmelimit überschritten",
|
||||
"description": "Die Aufnahme hat die zulässige Höchstdauer{{duration_message}} überschritten. Sie wird jetzt automatisch gespeichert.",
|
||||
"title": "Aufzeichnungslimit überschritten",
|
||||
"description": "Die Aufzeichnung hat die zulässige Höchstdauer{{duration_message}} überschritten. Sie wird jetzt automatisch gespeichert.",
|
||||
"durationMessage": " von {{duration}}",
|
||||
"button": "OK"
|
||||
}
|
||||
@@ -595,12 +602,12 @@
|
||||
"enable": "Anheften",
|
||||
"disable": "Lösen"
|
||||
},
|
||||
"effects": "Visuelle Effekte anwenden",
|
||||
"effects": "Hintergründe und Effekte anwenden",
|
||||
"muteParticipant": "{{name}} stummschalten",
|
||||
"fullScreen": "Vollbild"
|
||||
},
|
||||
"shortcutsPanel": {
|
||||
"title": "Tastenkombinationen",
|
||||
"title": "Tastenkürzel",
|
||||
"categories": {
|
||||
"navigation": "Navigation",
|
||||
"media": "Medien",
|
||||
@@ -611,13 +618,13 @@
|
||||
"focus-toolbar": "Fokus auf die untere Symbolleiste",
|
||||
"toggle-microphone": "Mikrofon umschalten",
|
||||
"toggle-camera": "Kamera umschalten",
|
||||
"push-to-talk": "Push-to-talk (gedrückt halten zum Einschalten)",
|
||||
"push-to-talk": "Push-to-talk",
|
||||
"reaction": "Reaktionspanel",
|
||||
"fullscreen": "Vollbild umschalten",
|
||||
"recording": "Aufnahmepanel umschalten",
|
||||
"raise-hand": "Hand heben oder senken",
|
||||
"toggle-chat": "Chat anzeigen/ausblenden",
|
||||
"toggle-participants": "Teilnehmer anzeigen/ausblenden",
|
||||
"toggle-participants": "Teilnehmende anzeigen/ausblenden",
|
||||
"open-shortcuts-settings": "Tastenkürzel-Einstellungen öffnen"
|
||||
},
|
||||
"sr": {
|
||||
@@ -635,8 +642,8 @@
|
||||
}
|
||||
},
|
||||
"fullScreenWarning": {
|
||||
"message": "Um eine Endlosschleife zu vermeiden, teile nicht deinen gesamten Bildschirm. Teile stattdessen einen Tab oder ein anderes Fenster.",
|
||||
"stop": "Präsentation beenden",
|
||||
"message": "Damit dein Bildschirm nicht unendlich gespiegelt wird, teile nicht deinen gesamten Bildschirm. Teile stattdessen einen Tab oder ein anderes Fenster.",
|
||||
"stop": "Teilen beenden",
|
||||
"ignore": "Ignorieren"
|
||||
},
|
||||
"participantTile": {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"joinButton": "Mit Visio beitreten",
|
||||
"copyLinkTooltip": "Link kopieren",
|
||||
"resetLabel": "Zurücksetzen",
|
||||
"participantLimit": "Bis zu 150 Teilnehmer.",
|
||||
"popupBlocked": "Popup wurde blockiert. Bitte erlauben Sie Popups für diese Website."
|
||||
"participantLimit": "Bis zu 150 Teilnehmende.",
|
||||
"popupBlocked": "Popup wurde blockiert. Bitte erlaube Popups für diese Website."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,46 +1,46 @@
|
||||
{
|
||||
"account": {
|
||||
"currentlyLoggedAs": "Sie sind derzeit angemeldet als <0>{{user}}</0>",
|
||||
"currentlyLoggedAs": "Du bist derzeit angemeldet als <0>{{user}}</0>",
|
||||
"heading": "Konto",
|
||||
"youAreNotLoggedIn": "Sie sind nicht angemeldet.",
|
||||
"nameLabel": "Ihr Name",
|
||||
"youAreNotLoggedIn": "Du bist nicht angemeldet.",
|
||||
"nameLabel": "Dein Name",
|
||||
"authentication": "Authentifizierung",
|
||||
"nameError": "Ihr Name darf nicht leer sein"
|
||||
"nameError": "Dein Name darf nicht leer sein"
|
||||
},
|
||||
"preferences": {
|
||||
"title": "Einstellungen",
|
||||
"idleDisconnectModal": {
|
||||
"label": "Anrufe ohne Teilnehmer verlassen",
|
||||
"description": "Verlässt automatisch einen Anruf nach einigen Minuten, wenn kein anderer Teilnehmer beitritt"
|
||||
"label": "Meetings ohne Teilnehmende verlassen",
|
||||
"description": "Meetings nach einigen Minuten automatisch verlassen, wenn keine andere Person beitritt"
|
||||
}
|
||||
},
|
||||
"audio": {
|
||||
"microphone": {
|
||||
"heading": "Mikrofon",
|
||||
"label": "Wählen Sie Ihre Audioeingabe",
|
||||
"label": "Audioeingabe wählen",
|
||||
"disabled": "Mikrofon deaktiviert"
|
||||
},
|
||||
"noiseReduction": {
|
||||
"label": "Rauschunterdrückung",
|
||||
"heading": "Rauschunterdrückung",
|
||||
"label": "Geräuschunterdrückung",
|
||||
"heading": "Geräuschunterdrückung",
|
||||
"ariaLabel": {
|
||||
"enable": "Rauschunterdrückung aktivieren",
|
||||
"disable": "Rauschunterdrückung deaktivieren"
|
||||
"enable": "Geräuschunterdrückung aktivieren",
|
||||
"disable": "Geräuschunterdrückung deaktivieren"
|
||||
}
|
||||
},
|
||||
"speakers": {
|
||||
"heading": "Lautsprecher",
|
||||
"label": "Wählen Sie Ihre Audioausgabe",
|
||||
"label": "Audioausgabe wählen",
|
||||
"test": "Testen",
|
||||
"ongoingTest": "Soundtest läuft…",
|
||||
"safariWarning": "Die Lautsprecherauswahl ist auf Safari aufgrund von Browser-Einschränkungen noch nicht verfügbar."
|
||||
"safariWarning": "Die Audioausgabeauswahl ist auf Safari aufgrund von Browser-Einschränkungen noch nicht verfügbar."
|
||||
},
|
||||
"permissionsRequired": "Berechtigungen erforderlich"
|
||||
},
|
||||
"video": {
|
||||
"camera": {
|
||||
"heading": "Kamera",
|
||||
"label": "Wählen Sie Ihre Videoeingabe aus",
|
||||
"label": "Videoeingabe wählen",
|
||||
"disabled": "Kamera deaktiviert",
|
||||
"previewAriaLabel": {
|
||||
"enabled": "Videovorschau aktiviert",
|
||||
@@ -50,18 +50,18 @@
|
||||
"resolution": {
|
||||
"heading": "Auflösung",
|
||||
"publish": {
|
||||
"label": "Wählen Sie Ihre maximale Sendeauflösung (max.)",
|
||||
"label": "Wähle die maximale Auflösung beim Senden",
|
||||
"items": {
|
||||
"high": "Hohe Auflösung",
|
||||
"medium": "Standardauflösung",
|
||||
"medium": "Mittlere Auflösung",
|
||||
"low": "Niedrige Auflösung"
|
||||
}
|
||||
},
|
||||
"subscribe": {
|
||||
"label": "Wählen Sie Ihre Empfangsauflösung (max.)",
|
||||
"label": "Wähle die maximale Auflösung beim Empfangen",
|
||||
"items": {
|
||||
"high": "Hohe Auflösung (automatisch)",
|
||||
"medium": "Standardauflösung",
|
||||
"medium": "Mittlere Auflösung",
|
||||
"low": "Niedrige Auflösung"
|
||||
}
|
||||
}
|
||||
@@ -82,15 +82,15 @@
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"heading": "Tonbenachrichtigungen",
|
||||
"label": "Tonbenachrichtigungen für",
|
||||
"heading": "Benachrichtigungen mit Ton",
|
||||
"label": "Benachrichtigungen mit Ton für",
|
||||
"actions": {
|
||||
"disable": "Deaktivieren",
|
||||
"enable": "Aktivieren"
|
||||
},
|
||||
"items": {
|
||||
"participantJoined": {
|
||||
"label": "Teilnehmer beigetreten"
|
||||
"label": "Person beigetreten"
|
||||
},
|
||||
"handRaised": {
|
||||
"label": "Hand gehoben"
|
||||
@@ -116,6 +116,17 @@
|
||||
"accessibility": {
|
||||
"announceReactions": {
|
||||
"label": "Reaktionen vorlesen"
|
||||
},
|
||||
"captions": {
|
||||
"heading": "Untertitel",
|
||||
"textSize": {
|
||||
"label": "Untertitel-Schriftgröße",
|
||||
"options": {
|
||||
"small": "Klein",
|
||||
"medium": "Mittel",
|
||||
"large": "Groß"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tabs": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user