mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-28 04:39:16 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ee050ab37 | |||
| f6ec01f3c8 | |||
| 3f77a9d884 | |||
| c6de0041da | |||
| 0c98dee3ef | |||
| 768b983f8d | |||
| 3a63b1e410 | |||
| 65b194d432 |
+6
-8
@@ -4,7 +4,7 @@ __pycache__
|
|||||||
**/__pycache__
|
**/__pycache__
|
||||||
**/*.pyc
|
**/*.pyc
|
||||||
venv
|
venv
|
||||||
**/.venv
|
.venv
|
||||||
|
|
||||||
# System-specific files
|
# System-specific files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -24,15 +24,13 @@ data
|
|||||||
.cache
|
.cache
|
||||||
.circleci
|
.circleci
|
||||||
.git
|
.git
|
||||||
|
.vscode
|
||||||
.iml
|
.iml
|
||||||
|
.idea
|
||||||
db.sqlite3
|
db.sqlite3
|
||||||
|
.mypy_cache
|
||||||
.pylint.d
|
.pylint.d
|
||||||
|
.pytest_cache
|
||||||
**/.idea
|
|
||||||
**/.vscode
|
|
||||||
**/.pytest_cache
|
|
||||||
**/.mypy_cache
|
|
||||||
**/.ruff_cache
|
|
||||||
|
|
||||||
# Frontend
|
# Frontend
|
||||||
**/node_modules
|
node_modules
|
||||||
|
|||||||
@@ -43,12 +43,12 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
# -
|
-
|
||||||
# name: Run trivy scan
|
name: Run trivy scan
|
||||||
# uses: numerique-gouv/action-trivy-cache@main
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
# with:
|
with:
|
||||||
# docker-build-args: '--target backend-production -f Dockerfile'
|
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 }}'
|
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -86,12 +86,12 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
# -
|
-
|
||||||
# name: Run trivy scan
|
name: Run trivy scan
|
||||||
# uses: numerique-gouv/action-trivy-cache@main
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
# with:
|
with:
|
||||||
# docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
|
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 }}'
|
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -130,12 +130,12 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
# -
|
-
|
||||||
# name: Run trivy scan
|
name: Run trivy scan
|
||||||
# uses: numerique-gouv/action-trivy-cache@main
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
# with:
|
with:
|
||||||
# docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
|
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 }}'
|
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
@@ -174,13 +174,13 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
# -
|
-
|
||||||
# name: Run trivy scan
|
name: Run trivy scan
|
||||||
# uses: numerique-gouv/action-trivy-cache@main
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
# continue-on-error: true
|
continue-on-error: true
|
||||||
# with:
|
with:
|
||||||
# docker-build-args: '-f src/summary/Dockerfile --target production'
|
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-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
|
||||||
docker-context: './src/summary'
|
docker-context: './src/summary'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
@@ -220,14 +220,14 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
# -
|
-
|
||||||
# name: Run trivy scan
|
name: Run trivy scan
|
||||||
# uses: numerique-gouv/action-trivy-cache@main
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
# continue-on-error: true
|
continue-on-error: true
|
||||||
# with:
|
with:
|
||||||
# docker-build-args: '-f src/agents/Dockerfile --target production'
|
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-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
|
||||||
# docker-context: './src/agents'
|
docker-context: './src/agents'
|
||||||
-
|
-
|
||||||
name: Build and push
|
name: Build and push
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
|
|||||||
+12
-15
@@ -124,17 +124,15 @@ jobs:
|
|||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
- name: Install uv
|
cache: "pip"
|
||||||
uses: astral-sh/setup-uv@v7
|
- name: Install development dependencies
|
||||||
- name: Install the project
|
run: pip install --user .[dev]
|
||||||
run: uv sync --locked --all-extras
|
|
||||||
|
|
||||||
- name: Check code formatting with ruff
|
- name: Check code formatting with ruff
|
||||||
run: uv run ruff format . --diff
|
run: ~/.local/bin/ruff format . --diff
|
||||||
- name: Lint code with ruff
|
- name: Lint code with ruff
|
||||||
run: uv run ruff check .
|
run: ~/.local/bin/ruff check .
|
||||||
- name: Lint code with pylint
|
- name: Lint code with pylint
|
||||||
run: uv run pylint meet demo core
|
run: ~/.local/bin/pylint meet demo core
|
||||||
|
|
||||||
lint-agents:
|
lint-agents:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -232,7 +230,6 @@ jobs:
|
|||||||
OIDC_RS_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
OIDC_RS_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
||||||
OIDC_OP_INTROSPECTION_ENDPOINT: https://oidc.example.com/introspect
|
OIDC_OP_INTROSPECTION_ENDPOINT: https://oidc.example.com/introspect
|
||||||
OIDC_OP_URL: https://oidc.example.com
|
OIDC_OP_URL: https://oidc.example.com
|
||||||
MEDIA_BASE_URL: http://localhost:8083
|
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
@@ -281,10 +278,10 @@ jobs:
|
|||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.13"
|
python-version: "3.13"
|
||||||
- name: Install uv
|
cache: "pip"
|
||||||
uses: astral-sh/setup-uv@v7
|
|
||||||
- name: Install the dependencies
|
- name: Install development dependencies
|
||||||
run: uv sync --locked --all-extras
|
run: pip install --user .[dev]
|
||||||
|
|
||||||
- name: Install gettext (required to compile messages)
|
- name: Install gettext (required to compile messages)
|
||||||
run: |
|
run: |
|
||||||
@@ -292,10 +289,10 @@ jobs:
|
|||||||
sudo apt-get install -y gettext
|
sudo apt-get install -y gettext
|
||||||
|
|
||||||
- name: Generate a MO file from strings extracted from the project
|
- name: Generate a MO file from strings extracted from the project
|
||||||
run: uv run python manage.py compilemessages
|
run: python manage.py compilemessages
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: uv run pytest -n 2
|
run: ~/.local/bin/pytest -n 2
|
||||||
|
|
||||||
lint-front:
|
lint-front:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
# /!\
|
|
||||||
# Security Note: This action is not hardened against prompt injection attacks and should only be used
|
|
||||||
# to review trusted PRs. Configure your repository with "Require approval for all external contributors"
|
|
||||||
# to ensure workflows only run after a maintainer has reviewed the PR.
|
|
||||||
name: Security Review
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
pull-requests: write # Needed for leaving PR comments
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- 'main'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
security:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v6
|
|
||||||
with:
|
|
||||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
|
||||||
fetch-depth: 2
|
|
||||||
|
|
||||||
- uses: anthropics/claude-code-security-review@0c6a49f1fa56a1d472575da86a94dbc1edb78eda
|
|
||||||
with:
|
|
||||||
comment-pr: true
|
|
||||||
exclude-directories: docs,gitlint,LICENSES,bin
|
|
||||||
claude-api-key: ${{ secrets.CLAUDE_API_KEY }}
|
|
||||||
@@ -31,7 +31,6 @@ MANIFEST
|
|||||||
|
|
||||||
# Translations # Translations
|
# Translations # Translations
|
||||||
*.pot
|
*.pot
|
||||||
*.mo
|
|
||||||
|
|
||||||
# Environments
|
# Environments
|
||||||
.env
|
.env
|
||||||
|
|||||||
+1
-65
@@ -10,63 +10,7 @@ and this project adheres to
|
|||||||
|
|
||||||
### Added
|
### 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
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- 🐛(frontend) fix hand icon and queue position alignment and position #1119
|
|
||||||
- 🩹(backend) add page_size to pagination for room endpoints #1131
|
|
||||||
- 🐛(backend) refactor lobby throttling to use participant id #1129
|
|
||||||
- 🩹(backend) ignore non-recording uploads in storage webhook handler #1142
|
|
||||||
|
|
||||||
## [1.10.0] - 2026-03-05
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- 🔒️(backend) enhance API input validation to strengthen security #1053
|
|
||||||
- 🦺(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
|
|
||||||
|
|
||||||
### Added
|
|
||||||
|
|
||||||
- 👷(docker) add arm64 platform support for image builds
|
- 👷(docker) add arm64 platform support for image builds
|
||||||
- ✨(summary) add localization support for transcription context text
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@@ -77,16 +21,8 @@ and this project adheres to
|
|||||||
- 🚚(frontend) rename "wellknown" directory to "well-known" #1009
|
- 🚚(frontend) rename "wellknown" directory to "well-known" #1009
|
||||||
- 🌐(frontend) localize SR modifier labels #1010
|
- 🌐(frontend) localize SR modifier labels #1010
|
||||||
- ⬆️(backend) update python dependencies #1011
|
- ⬆️(backend) update python dependencies #1011
|
||||||
- ♿️(frontend) fix focus ring on tab container components #1012
|
- ♿️(a11y) fix focus ring on tab container components
|
||||||
- ♿️(frontend) upgrade join meeting modal accessibility #1027
|
|
||||||
- ⬆️(python) bump minimal required python version to 3.13 #1033
|
|
||||||
- ♿️(frontend) improve accessibility of the IntroSlider carousel #1026
|
|
||||||
- ♿️(frontend) add skip link component for keyboard navigation #1019
|
|
||||||
- ♿️(frontend) announce mic/camera state to SR on shortcut toggle #1052
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
- 🩹(frontend) fix German language preference update #1021
|
|
||||||
|
|
||||||
## [1.8.0] - 2026-02-20
|
## [1.8.0] - 2026-02-20
|
||||||
|
|
||||||
|
|||||||
+23
-41
@@ -13,28 +13,14 @@ RUN apk update && \
|
|||||||
# ---- Back-end builder image ----
|
# ---- Back-end builder image ----
|
||||||
FROM base AS back-builder
|
FROM base AS back-builder
|
||||||
|
|
||||||
|
WORKDIR /builder
|
||||||
|
|
||||||
ENV UV_COMPILE_BYTECODE=1
|
# Copy required python dependencies
|
||||||
ENV UV_LINK_MODE=copy
|
COPY ./src/backend /builder
|
||||||
|
|
||||||
# Disable Python downloads, because we want to use the system interpreter
|
RUN mkdir /install && \
|
||||||
# across both images. If using a managed Python version, it needs to be
|
pip install --prefix=/install .
|
||||||
# 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 ----
|
# ---- mails ----
|
||||||
FROM node:20 AS mail-builder
|
FROM node:20 AS mail-builder
|
||||||
@@ -44,7 +30,7 @@ COPY ./src/mail /mail/app
|
|||||||
WORKDIR /mail/app
|
WORKDIR /mail/app
|
||||||
|
|
||||||
RUN yarn install --frozen-lockfile && \
|
RUN yarn install --frozen-lockfile && \
|
||||||
yarn build
|
yarn build
|
||||||
|
|
||||||
|
|
||||||
# ---- static link collector ----
|
# ---- static link collector ----
|
||||||
@@ -53,20 +39,19 @@ ARG MEET_STATIC_ROOT=/data/static
|
|||||||
|
|
||||||
RUN apk add \
|
RUN apk add \
|
||||||
pango \
|
pango \
|
||||||
libmagic \
|
|
||||||
rdfind
|
rdfind
|
||||||
|
|
||||||
|
# Copy installed python dependencies
|
||||||
|
COPY --from=back-builder /install /usr/local
|
||||||
|
|
||||||
|
# Copy Meet application (see .dockerignore)
|
||||||
|
COPY ./src/backend /app/
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy the application from the builder
|
|
||||||
COPY --from=back-builder /app /app
|
|
||||||
|
|
||||||
ENV PATH="/app/.venv/bin:$PATH"
|
|
||||||
|
|
||||||
|
|
||||||
# collectstatic
|
# collectstatic
|
||||||
RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
|
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
|
# Replace duplicated file by a symlink to decrease the overall size of the
|
||||||
# final image
|
# final image
|
||||||
@@ -83,7 +68,6 @@ RUN apk --no-cache add \
|
|||||||
gettext \
|
gettext \
|
||||||
libffi-dev \
|
libffi-dev \
|
||||||
pango \
|
pango \
|
||||||
libmagic \
|
|
||||||
shared-mime-info
|
shared-mime-info
|
||||||
|
|
||||||
|
|
||||||
@@ -95,17 +79,14 @@ COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
|||||||
# docker user (see entrypoint).
|
# docker user (see entrypoint).
|
||||||
RUN chmod g=u /etc/passwd
|
RUN chmod g=u /etc/passwd
|
||||||
|
|
||||||
# Copy the application from the builder
|
# Copy installed python dependencies
|
||||||
COPY --from=back-builder /app /app
|
COPY --from=back-builder /install /usr/local
|
||||||
|
|
||||||
|
# Copy Meet application (see .dockerignore)
|
||||||
|
COPY ./src/backend /app/
|
||||||
|
|
||||||
WORKDIR /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
|
# 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
|
# creates a user on-the-fly with the container user ID (see USER) and root group
|
||||||
# ID.
|
# ID.
|
||||||
@@ -120,9 +101,10 @@ USER root:root
|
|||||||
# Install psql
|
# Install psql
|
||||||
RUN apk add postgresql-client
|
RUN apk add postgresql-client
|
||||||
|
|
||||||
# Install development dependencies
|
# Uninstall Meet and re-install it in editable mode along with development
|
||||||
RUN --mount=from=ghcr.io/astral-sh/uv:0.10.9,source=/uv,target=/bin/uv \
|
# dependencies
|
||||||
uv sync --all-extras --locked
|
RUN pip uninstall -y meet
|
||||||
|
RUN pip install -e .[dev]
|
||||||
|
|
||||||
# Restore the un-privileged user running the application
|
# Restore the un-privileged user running the application
|
||||||
ARG DOCKER_USER
|
ARG DOCKER_USER
|
||||||
@@ -131,7 +113,7 @@ USER ${DOCKER_USER}
|
|||||||
# Target database host (e.g. database engine following docker compose services
|
# Target database host (e.g. database engine following docker compose services
|
||||||
# name) & port
|
# name) & port
|
||||||
ENV DB_HOST=postgresql \
|
ENV DB_HOST=postgresql \
|
||||||
DB_PORT=5432
|
DB_PORT=5432
|
||||||
|
|
||||||
# Run django development server
|
# Run django development server
|
||||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||||
|
|||||||
@@ -23,10 +23,9 @@
|
|||||||
# ==============================================================================
|
# ==============================================================================
|
||||||
# VARIABLES
|
# VARIABLES
|
||||||
|
|
||||||
ESC := $(shell printf '\033')
|
BOLD := \033[1m
|
||||||
BOLD := $(ESC)[1m
|
RESET := \033[0m
|
||||||
RESET := $(ESC)[0m
|
GREEN := \033[1;32m
|
||||||
GREEN := $(ESC)[1;32m
|
|
||||||
|
|
||||||
|
|
||||||
# -- Database
|
# -- Database
|
||||||
@@ -86,8 +85,7 @@ bootstrap: \
|
|||||||
demo \
|
demo \
|
||||||
back-i18n-compile \
|
back-i18n-compile \
|
||||||
mails-install \
|
mails-install \
|
||||||
mails-build \
|
mails-build
|
||||||
run
|
|
||||||
.PHONY: bootstrap
|
.PHONY: bootstrap
|
||||||
|
|
||||||
# -- Docker/compose
|
# -- Docker/compose
|
||||||
@@ -114,8 +112,7 @@ logs: ## display app-dev logs (follow mode)
|
|||||||
.PHONY: logs
|
.PHONY: logs
|
||||||
|
|
||||||
run-backend: ## start only the backend application and all needed services
|
run-backend: ## start only the backend application and all needed services
|
||||||
@$(COMPOSE) up --force-recreate -d celery-dev --remove-orphans
|
@$(COMPOSE) up --force-recreate -d celery-dev
|
||||||
@$(COMPOSE) up --force-recreate -d nginx
|
|
||||||
@echo "Wait for postgresql to be up..."
|
@echo "Wait for postgresql to be up..."
|
||||||
@$(WAIT_DB)
|
@$(WAIT_DB)
|
||||||
.PHONY: run-backend
|
.PHONY: run-backend
|
||||||
@@ -223,7 +220,7 @@ superuser: ## Create an admin superuser with password "admin"
|
|||||||
.PHONY: superuser
|
.PHONY: superuser
|
||||||
|
|
||||||
back-i18n-compile: ## compile the gettext files
|
back-i18n-compile: ## compile the gettext files
|
||||||
@$(MANAGE) compilemessages --ignore=".venv/**/*"
|
@$(MANAGE) compilemessages --ignore="venv/**/*"
|
||||||
.PHONY: back-i18n-compile
|
.PHONY: back-i18n-compile
|
||||||
|
|
||||||
back-i18n-generate: ## create the .pot files used for i18n
|
back-i18n-generate: ## create the .pot files used for i18n
|
||||||
@@ -354,13 +351,13 @@ install-external-secrets: ## install the kubernetes secrets from Vaultwarden
|
|||||||
.PHONY: build-k8s-cluster
|
.PHONY: build-k8s-cluster
|
||||||
|
|
||||||
start-tilt: ## start the kubernetes cluster using kind
|
start-tilt: ## start the kubernetes cluster using kind
|
||||||
tilt up --namespace=meet -f ./bin/Tiltfile
|
tilt up -f ./bin/Tiltfile
|
||||||
.PHONY: build-k8s-cluster
|
.PHONY: build-k8s-cluster
|
||||||
|
|
||||||
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
|
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
|
||||||
DEV_ENV=dev-keycloak tilt up --namespace=meet -f ./bin/Tiltfile
|
DEV_ENV=dev-keycloak tilt up -f ./bin/Tiltfile
|
||||||
.PHONY: build-k8s-cluster
|
.PHONY: build-k8s-cluster
|
||||||
|
|
||||||
start-tilt-dinum: ## start the kubernetes cluster using kind, without Pro Connect for authentication, but with DINUM styles
|
start-tilt-dinum: ## start the kubernetes cluster using kind, without Pro Connect for authentication, but with DINUM styles
|
||||||
DEV_ENV=dev-dinum tilt up --namespace=meet -f ./bin/Tiltfile
|
DEV_ENV=dev-dinum tilt up -f ./bin/Tiltfile
|
||||||
.PHONY: build-k8s-cluster
|
.PHONY: build-k8s-cluster
|
||||||
|
|||||||
+4
-4
@@ -18,6 +18,7 @@ docker_build(
|
|||||||
'localhost:5001/meet-backend:latest',
|
'localhost:5001/meet-backend:latest',
|
||||||
context='..',
|
context='..',
|
||||||
dockerfile='../Dockerfile',
|
dockerfile='../Dockerfile',
|
||||||
|
build_args={'DOCKER_USER': '1001:127'},
|
||||||
only=['./src/backend', './src/mail', './docker'],
|
only=['./src/backend', './src/mail', './docker'],
|
||||||
target = 'backend-production',
|
target = 'backend-production',
|
||||||
live_update=[
|
live_update=[
|
||||||
@@ -33,6 +34,7 @@ clean_old_images('localhost:5001/meet-backend')
|
|||||||
docker_build(
|
docker_build(
|
||||||
'localhost:5001/meet-frontend-dinum:latest',
|
'localhost:5001/meet-frontend-dinum:latest',
|
||||||
context='..',
|
context='..',
|
||||||
|
build_args={'DOCKER_USER': '1001:127'},
|
||||||
dockerfile='../docker/dinum-frontend/Dockerfile',
|
dockerfile='../docker/dinum-frontend/Dockerfile',
|
||||||
only=['./src/frontend', './docker', './.dockerignore'],
|
only=['./src/frontend', './docker', './.dockerignore'],
|
||||||
target = 'frontend-production',
|
target = 'frontend-production',
|
||||||
@@ -57,6 +59,7 @@ clean_old_images('localhost:5001/meet-frontend-generic')
|
|||||||
docker_build(
|
docker_build(
|
||||||
'localhost:5001/meet-summary:latest',
|
'localhost:5001/meet-summary:latest',
|
||||||
context='../src/summary',
|
context='../src/summary',
|
||||||
|
build_args={'DOCKER_USER': '1001:127'},
|
||||||
dockerfile='../src/summary/Dockerfile',
|
dockerfile='../src/summary/Dockerfile',
|
||||||
only=['.'],
|
only=['.'],
|
||||||
target = 'production',
|
target = 'production',
|
||||||
@@ -69,6 +72,7 @@ clean_old_images('localhost:5001/meet-summary')
|
|||||||
docker_build(
|
docker_build(
|
||||||
'localhost:5001/meet-agents:latest',
|
'localhost:5001/meet-agents:latest',
|
||||||
context='../src/agents',
|
context='../src/agents',
|
||||||
|
build_args={'DOCKER_USER': '1001:127'},
|
||||||
dockerfile='../src/agents/Dockerfile',
|
dockerfile='../src/agents/Dockerfile',
|
||||||
only=['.'],
|
only=['.'],
|
||||||
target = 'production',
|
target = 'production',
|
||||||
@@ -99,11 +103,7 @@ k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .
|
|||||||
|
|
||||||
k8s_resource('minio-bucket', resource_deps=['minio'])
|
k8s_resource('minio-bucket', resource_deps=['minio'])
|
||||||
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
|
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('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('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
|
||||||
k8s_resource('keycloak', resource_deps=['kc-postgresql'])
|
k8s_resource('keycloak', resource_deps=['kc-postgresql'])
|
||||||
k8s_resource('meet-backend-createsuperuser', resource_deps=['meet-backend-migrate'])
|
k8s_resource('meet-backend-createsuperuser', resource_deps=['meet-backend-migrate'])
|
||||||
|
|||||||
+2
-4
@@ -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 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 &&
|
/usr/bin/mc admin service restart meet --wait --json &&
|
||||||
sleep 15 &&
|
sleep 15 &&
|
||||||
/usr/bin/mc event add meet/meet-media-storage arn:minio:sqs::meet-webhook:webhook --event put --prefix "recordings" &&
|
/usr/bin/mc event add meet/meet-media-storage arn:minio:sqs::meet-webhook:webhook --event put &&
|
||||||
exit 0;"
|
exit 0;"
|
||||||
|
|
||||||
app-dev:
|
app-dev:
|
||||||
@@ -80,11 +80,11 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./src/backend:/app
|
- ./src/backend:/app
|
||||||
- ./data/static:/data/static
|
- ./data/static:/data/static
|
||||||
- /app/.venv
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgresql
|
- postgresql
|
||||||
- mailcatcher
|
- mailcatcher
|
||||||
- redis
|
- redis
|
||||||
|
- nginx
|
||||||
- livekit
|
- livekit
|
||||||
- createbuckets
|
- createbuckets
|
||||||
- createwebhook
|
- createwebhook
|
||||||
@@ -106,7 +106,6 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./src/backend:/app
|
- ./src/backend:/app
|
||||||
- ./data/static:/data/static
|
- ./data/static:/data/static
|
||||||
- /app/.venv
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- app-dev
|
- app-dev
|
||||||
|
|
||||||
@@ -149,7 +148,6 @@ services:
|
|||||||
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
- ./docker/files/etc/nginx/conf.d:/etc/nginx/conf.d:ro
|
||||||
depends_on:
|
depends_on:
|
||||||
- keycloak
|
- keycloak
|
||||||
- app-dev
|
|
||||||
networks:
|
networks:
|
||||||
- resource-server
|
- resource-server
|
||||||
- default
|
- default
|
||||||
|
|||||||
@@ -38,6 +38,20 @@ COPY ./docker/dinum-frontend/assets/ \
|
|||||||
COPY ./docker/dinum-frontend/fonts/ \
|
COPY ./docker/dinum-frontend/fonts/ \
|
||||||
./dist/assets/fonts/
|
./dist/assets/fonts/
|
||||||
|
|
||||||
|
# ---- Outlook add-in builder image ----
|
||||||
|
FROM node:20-alpine AS outlook-addin-builder
|
||||||
|
|
||||||
|
WORKDIR /home/outlook-addin
|
||||||
|
|
||||||
|
COPY ./src/addins/outlook-addin/package.json ./package.json
|
||||||
|
COPY ./src/addins/outlook-addin/package-lock.json ./package-lock.json
|
||||||
|
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY ./src/addins/outlook-addin/ .
|
||||||
|
|
||||||
|
RUN npx webpack --mode production
|
||||||
|
|
||||||
# ---- Front-end image ----
|
# ---- Front-end image ----
|
||||||
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
|
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
|
||||||
|
|
||||||
@@ -59,6 +73,10 @@ COPY --from=meet-builder \
|
|||||||
/home/frontend/dist \
|
/home/frontend/dist \
|
||||||
/usr/share/nginx/html
|
/usr/share/nginx/html
|
||||||
|
|
||||||
|
COPY --from=outlook-addin-builder \
|
||||||
|
/home/outlook-addin/dist \
|
||||||
|
/usr/share/nginx/html/outlook-addin
|
||||||
|
|
||||||
COPY ./src/frontend/default.conf /etc/nginx/conf.d
|
COPY ./src/frontend/default.conf /etc/nginx/conf.d
|
||||||
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||||
|
|
||||||
|
|||||||
@@ -4,47 +4,10 @@ server {
|
|||||||
server_name localhost;
|
server_name localhost;
|
||||||
charset utf-8;
|
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 / {
|
location / {
|
||||||
proxy_pass http://keycloak:8080;
|
proxy_pass http://keycloak:8080;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,13 +68,13 @@ backend:
|
|||||||
python manage.py createsuperuser --email admin@example.com --password admin
|
python manage.py createsuperuser --email admin@example.com --password admin
|
||||||
restartPolicy: Never
|
restartPolicy: Never
|
||||||
|
|
||||||
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
|
# Exra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||||
extraVolumeMounts:
|
extraVolumeMounts:
|
||||||
- name: certs
|
- name: certs
|
||||||
mountPath: /app/.venv/lib/python3.13/site-packages/certifi/cacert.pem
|
mountPath: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem
|
||||||
subPath: cacert.pem
|
subPath: cacert.pem
|
||||||
|
|
||||||
# Extra volume to manage our local custom CA and avoid to set ssl_verify: false
|
# Exra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||||
extraVolumes:
|
extraVolumes:
|
||||||
- name: certs
|
- name: certs
|
||||||
configMap:
|
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`.
|
Generate a secure key for `LIVEKIT_API_SECRET` in `env.d/common`.
|
||||||
|
|
||||||
We provide a minimal recommended config for production environment in `livekit-server.yaml`. Set the previously generated API secret key in the config file.
|
We provide a minimal recommanded 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)
|
To view other customization options, see [config-sample.yaml](https://github.com/livekit/livekit/blob/master/config-sample.yaml)
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> In this example, we configured multiplexing on a single UDP port. For better performance, you can configure a range of UDP ports.
|
> In this example, we configured multiplexing on a single UDP port. For better performances, you can configure a range of UDP ports.
|
||||||
|
|
||||||
### Meet
|
### 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
|
$ 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/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.
|
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.
|
||||||
|
|
||||||
```
|
```
|
||||||
$ kubectl config set-context --current --namespace=meet
|
$ kubectl config set-context --current --namespace=meet
|
||||||
$ helm install keycloak oci://registry-1.docker.io/bitnamicharts/keycloak -f examples/helm/keycloak.values.yaml
|
$ helm install keycloak oci://registry-1.docker.io/bitnamicharts/keycloak -f examples/keycloak.values.yaml
|
||||||
$ #wait until
|
$ #wait until
|
||||||
$ kubectl get po
|
$ kubectl get po
|
||||||
NAME READY STATUS RESTARTS AGE
|
NAME READY STATUS RESTARTS AGE
|
||||||
@@ -150,7 +150,7 @@ OIDC_RP_SIGN_ALGO: RS256
|
|||||||
OIDC_RP_SCOPES: "openid email"
|
OIDC_RP_SCOPES: "openid email"
|
||||||
```
|
```
|
||||||
|
|
||||||
You can find these values in **examples/helm/keycloak.values.yaml**
|
You can find these values in **examples/keycloak.values.yaml**
|
||||||
|
|
||||||
### Find livekit server connexion values
|
### 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 :
|
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/helm/redis.values.yaml
|
$ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/redis.values.yaml
|
||||||
$ kubectl get po
|
$ kubectl get po
|
||||||
NAME READY STATUS RESTARTS AGE
|
NAME READY STATUS RESTARTS AGE
|
||||||
keycloak-0 1/1 Running 0 26m
|
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 add livekit https://helm.livekit.io
|
||||||
$ helm repo update
|
$ helm repo update
|
||||||
$ helm install livekit livekit/livekit-server -f examples/helm/livekit.values.yaml
|
$ helm install livekit livekit/livekit-server -f examples/livekit.values.yaml
|
||||||
$ kubectl get po
|
$ kubectl get po
|
||||||
NAME READY STATUS RESTARTS AGE
|
NAME READY STATUS RESTARTS AGE
|
||||||
keycloak-0 1/1 Running 0 30m
|
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:
|
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/helm/postgresql.values.yaml
|
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml
|
||||||
$ kubectl get po
|
$ kubectl get po
|
||||||
NAME READY STATUS RESTARTS AGE
|
NAME READY STATUS RESTARTS AGE
|
||||||
keycloak-0 1/1 Running 0 45m
|
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 add meet https://suitenumerique.github.io/meet/
|
||||||
$ helm repo update
|
$ helm repo update
|
||||||
$ helm install meet meet/meet -f examples/helm/meet.values.yaml
|
$ helm install meet meet/meet -f examples/meet.values.yaml
|
||||||
```
|
```
|
||||||
|
|
||||||
## Test your deployment
|
## Test your deployment
|
||||||
|
|||||||
@@ -23,12 +23,9 @@ MEET_BASE_URL="http://localhost:8072"
|
|||||||
|
|
||||||
# Media
|
# Media
|
||||||
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
|
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
|
||||||
AWS_S3_DOMAIN_REPLACE=http://localhost:9000
|
|
||||||
AWS_S3_ENDPOINT_URL=http://minio:9000
|
AWS_S3_ENDPOINT_URL=http://minio:9000
|
||||||
AWS_S3_ACCESS_KEY_ID=meet
|
AWS_S3_ACCESS_KEY_ID=meet
|
||||||
AWS_S3_SECRET_ACCESS_KEY=password
|
AWS_S3_SECRET_ACCESS_KEY=password
|
||||||
MEDIA_BASE_URL=http://localhost:8083
|
|
||||||
FILE_UPLOAD_ENABLED=True
|
|
||||||
|
|
||||||
# OIDC
|
# OIDC
|
||||||
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs
|
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
Gitlint extra rule to validate that the message title is of the form
|
Gitlint extra rule to validate that the message title is of the form
|
||||||
"<gitmoji>(<scope>) <subject>"
|
"<gitmoji>(<scope>) <subject>"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import unicode_literals
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"plugins": [
|
||||||
|
"office-addins"
|
||||||
|
],
|
||||||
|
"extends": [
|
||||||
|
"plugin:office-addins/recommended"
|
||||||
|
]
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"presets": [
|
||||||
|
[
|
||||||
|
"@babel/preset-env",
|
||||||
|
{
|
||||||
|
"targets": {
|
||||||
|
"esmodules": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<OfficeApp xmlns="http://schemas.microsoft.com/office/appforoffice/1.1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:bt="http://schemas.microsoft.com/office/officeappbasictypes/1.0" xmlns:mailappor="http://schemas.microsoft.com/office/mailappversionoverrides/1.0" xsi:type="MailApp">
|
||||||
|
<Id>a025f0f6-757a-4790-97f3-99c66c4a5795</Id>
|
||||||
|
<Version>0.0.1.0</Version>
|
||||||
|
<ProviderName>Visio</ProviderName>
|
||||||
|
<DefaultLocale>en-US</DefaultLocale>
|
||||||
|
<DisplayName DefaultValue="Visio"/>
|
||||||
|
<Description DefaultValue="Ajoutez facilement un lien de réunion Visio à vos emails et événements Outlook."/>
|
||||||
|
<IconUrl DefaultValue="https://localhost:3000/assets/icon-64.png"/>
|
||||||
|
<HighResolutionIconUrl DefaultValue="https://localhost:3000/assets/icon-128.png"/>
|
||||||
|
<SupportUrl DefaultValue="https://www.contoso.com/help"/>
|
||||||
|
<AppDomains>
|
||||||
|
<AppDomain>https://localhost:3000</AppDomain>
|
||||||
|
<AppDomain>https://meet.127.0.0.1.nip.io</AppDomain>
|
||||||
|
</AppDomains>
|
||||||
|
<Hosts>
|
||||||
|
<Host Name="Mailbox"/>
|
||||||
|
</Hosts>
|
||||||
|
<Requirements>
|
||||||
|
<Sets>
|
||||||
|
<Set Name="Mailbox" MinVersion="1.1"/>
|
||||||
|
</Sets>
|
||||||
|
</Requirements>
|
||||||
|
<FormSettings>
|
||||||
|
<Form xsi:type="ItemRead">
|
||||||
|
<DesktopSettings>
|
||||||
|
<SourceLocation DefaultValue="https://localhost:3000/taskpane.html"/>
|
||||||
|
<RequestedHeight>250</RequestedHeight>
|
||||||
|
</DesktopSettings>
|
||||||
|
</Form>
|
||||||
|
<Form xsi:type="ItemEdit">
|
||||||
|
<DesktopSettings>
|
||||||
|
<SourceLocation DefaultValue="https://localhost:3000/taskpane.html"/>
|
||||||
|
</DesktopSettings>
|
||||||
|
</Form>
|
||||||
|
</FormSettings>
|
||||||
|
<Permissions>ReadWriteItem</Permissions>
|
||||||
|
<Rule xsi:type="RuleCollection" Mode="Or">
|
||||||
|
<Rule xsi:type="ItemIs" ItemType="Message" FormType="Read"/>
|
||||||
|
<Rule xsi:type="ItemIs" ItemType="Message" FormType="Edit"/>
|
||||||
|
<Rule xsi:type="ItemIs" ItemType="Appointment" FormType="Edit"/>
|
||||||
|
</Rule>
|
||||||
|
<DisableEntityHighlighting>false</DisableEntityHighlighting>
|
||||||
|
<VersionOverrides xmlns="http://schemas.microsoft.com/office/mailappversionoverrides" xsi:type="VersionOverridesV1_0">
|
||||||
|
<Requirements>
|
||||||
|
<bt:Sets DefaultMinVersion="1.3">
|
||||||
|
<bt:Set Name="Mailbox"/>
|
||||||
|
</bt:Sets>
|
||||||
|
</Requirements>
|
||||||
|
<Hosts>
|
||||||
|
<Host xsi:type="MailHost">
|
||||||
|
<DesktopFormFactor>
|
||||||
|
<FunctionFile resid="Commands.Url"/>
|
||||||
|
|
||||||
|
<!-- ─── Mail: Read ─────────────────────────────────────────── -->
|
||||||
|
<ExtensionPoint xsi:type="MessageReadCommandSurface">
|
||||||
|
<OfficeTab id="TabDefault">
|
||||||
|
<Group id="msgReadGroup">
|
||||||
|
<Label resid="GroupLabel"/>
|
||||||
|
<Control xsi:type="Button" id="msgReadOpenPaneButton">
|
||||||
|
<Label resid="TaskpaneButton.Label"/>
|
||||||
|
<Supertip>
|
||||||
|
<Title resid="TaskpaneButton.Label"/>
|
||||||
|
<Description resid="TaskpaneButton.Tooltip"/>
|
||||||
|
</Supertip>
|
||||||
|
<Icon>
|
||||||
|
<bt:Image size="16" resid="Icon.16x16"/>
|
||||||
|
<bt:Image size="32" resid="Icon.32x32"/>
|
||||||
|
<bt:Image size="80" resid="Icon.80x80"/>
|
||||||
|
</Icon>
|
||||||
|
<Action xsi:type="ShowTaskpane">
|
||||||
|
<SourceLocation resid="Taskpane.Url"/>
|
||||||
|
</Action>
|
||||||
|
</Control>
|
||||||
|
</Group>
|
||||||
|
</OfficeTab>
|
||||||
|
</ExtensionPoint>
|
||||||
|
|
||||||
|
<!-- ─── Mail: Compose ─────────────────────────────────────── -->
|
||||||
|
<ExtensionPoint xsi:type="MessageComposeCommandSurface">
|
||||||
|
<OfficeTab id="TabDefault">
|
||||||
|
<Group id="msgComposeGroup">
|
||||||
|
<Label resid="GroupLabel"/>
|
||||||
|
<Control xsi:type="Button" id="msgComposeOpenPaneButton">
|
||||||
|
<Label resid="TaskpaneButton.Label"/>
|
||||||
|
<Supertip>
|
||||||
|
<Title resid="TaskpaneButton.Label"/>
|
||||||
|
<Description resid="TaskpaneButton.Tooltip"/>
|
||||||
|
</Supertip>
|
||||||
|
<Icon>
|
||||||
|
<bt:Image size="16" resid="Icon.16x16"/>
|
||||||
|
<bt:Image size="32" resid="Icon.32x32"/>
|
||||||
|
<bt:Image size="80" resid="Icon.80x80"/>
|
||||||
|
</Icon>
|
||||||
|
<Action xsi:type="ShowTaskpane">
|
||||||
|
<SourceLocation resid="Taskpane.Url"/>
|
||||||
|
</Action>
|
||||||
|
</Control>
|
||||||
|
</Group>
|
||||||
|
</OfficeTab>
|
||||||
|
</ExtensionPoint>
|
||||||
|
|
||||||
|
<!-- ─── Calendar: Compose (New/Edit appointment) ──────────── -->
|
||||||
|
<ExtensionPoint xsi:type="AppointmentOrganizerCommandSurface">
|
||||||
|
<OfficeTab id="TabDefault">
|
||||||
|
<Group id="apptComposeGroup">
|
||||||
|
<Label resid="GroupLabel"/>
|
||||||
|
|
||||||
|
<!-- Button 1: Generate meeting link (function call) -->
|
||||||
|
<Control xsi:type="Button" id="apptGenerateLinkButton">
|
||||||
|
<Label resid="GenerateLink.Label"/>
|
||||||
|
<Supertip>
|
||||||
|
<Title resid="GenerateLink.Label"/>
|
||||||
|
<Description resid="GenerateLink.Tooltip"/>
|
||||||
|
</Supertip>
|
||||||
|
<Icon>
|
||||||
|
<bt:Image size="16" resid="Icon.16x16"/>
|
||||||
|
<bt:Image size="32" resid="Icon.32x32"/>
|
||||||
|
<bt:Image size="80" resid="Icon.80x80"/>
|
||||||
|
</Icon>
|
||||||
|
<Action xsi:type="ExecuteFunction">
|
||||||
|
<FunctionName>generateMeetingLinkFromCalendar</FunctionName>
|
||||||
|
</Action>
|
||||||
|
</Control>
|
||||||
|
|
||||||
|
<!-- Button 2: Open settings taskpane -->
|
||||||
|
<Control xsi:type="Button" id="apptOpenSettingsButton">
|
||||||
|
<Label resid="OpenSettings.Label"/>
|
||||||
|
<Supertip>
|
||||||
|
<Title resid="OpenSettings.Label"/>
|
||||||
|
<Description resid="OpenSettings.Tooltip"/>
|
||||||
|
</Supertip>
|
||||||
|
<Icon>
|
||||||
|
<bt:Image size="16" resid="Icon.16x16"/>
|
||||||
|
<bt:Image size="32" resid="Icon.32x32"/>
|
||||||
|
<bt:Image size="80" resid="Icon.80x80"/>
|
||||||
|
</Icon>
|
||||||
|
<Action xsi:type="ShowTaskpane">
|
||||||
|
<SourceLocation resid="Taskpane.Url"/>
|
||||||
|
</Action>
|
||||||
|
</Control>
|
||||||
|
|
||||||
|
</Group>
|
||||||
|
</OfficeTab>
|
||||||
|
</ExtensionPoint>
|
||||||
|
|
||||||
|
</DesktopFormFactor>
|
||||||
|
</Host>
|
||||||
|
</Hosts>
|
||||||
|
<Resources>
|
||||||
|
<bt:Images>
|
||||||
|
<bt:Image id="Icon.16x16" DefaultValue="https://localhost:3000/assets/icon-16.png"/>
|
||||||
|
<bt:Image id="Icon.32x32" DefaultValue="https://localhost:3000/assets/icon-32.png"/>
|
||||||
|
<bt:Image id="Icon.80x80" DefaultValue="https://localhost:3000/assets/icon-80.png"/>
|
||||||
|
</bt:Images>
|
||||||
|
<bt:Urls>
|
||||||
|
<bt:Url id="Commands.Url" DefaultValue="https://localhost:3000/commands.html"/>
|
||||||
|
<bt:Url id="Taskpane.Url" DefaultValue="https://localhost:3000/taskpane.html"/>
|
||||||
|
</bt:Urls>
|
||||||
|
<bt:ShortStrings>
|
||||||
|
<bt:String id="GroupLabel" DefaultValue="Visio"/>
|
||||||
|
<bt:String id="TaskpaneButton.Label" DefaultValue="Ouvrir le panneau"/>
|
||||||
|
<bt:String id="GenerateLink.Label" DefaultValue="Générer un lien de réunion"/>
|
||||||
|
<bt:String id="OpenSettings.Label" DefaultValue="Paramètres"/>
|
||||||
|
</bt:ShortStrings>
|
||||||
|
<bt:LongStrings>
|
||||||
|
<bt:String id="TaskpaneButton.Tooltip" DefaultValue="Ouvre le panneau de connexion Visio."/>
|
||||||
|
<bt:String id="GenerateLink.Tooltip" DefaultValue="Génère un lien de réunion Visio et l'insère dans l'événement."/>
|
||||||
|
<bt:String id="OpenSettings.Tooltip" DefaultValue="Ouvre les paramètres de connexion Visio."/>
|
||||||
|
</bt:LongStrings>
|
||||||
|
</Resources>
|
||||||
|
</VersionOverrides>
|
||||||
|
</OfficeApp>
|
||||||
+16211
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"name": "office-addin-taskpane-js",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/OfficeDev/Office-Addin-TaskPane-JS.git"
|
||||||
|
},
|
||||||
|
"license": "MIT",
|
||||||
|
"config": {
|
||||||
|
"app_to_debug": "outlook",
|
||||||
|
"app_type_to_debug": "desktop",
|
||||||
|
"dev_server_port": 3000
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "webpack --mode production",
|
||||||
|
"build:dev": "webpack --mode development",
|
||||||
|
"dev-server": "webpack serve --mode development",
|
||||||
|
"lint": "office-addin-lint check",
|
||||||
|
"lint:fix": "office-addin-lint fix",
|
||||||
|
"prettier": "office-addin-lint prettier",
|
||||||
|
"signin": "office-addin-dev-settings m365-account login",
|
||||||
|
"signout": "office-addin-dev-settings m365-account logout",
|
||||||
|
"start": "office-addin-debugging start manifest.xml",
|
||||||
|
"stop": "office-addin-debugging stop manifest.xml",
|
||||||
|
"validate": "office-addin-manifest validate manifest.xml",
|
||||||
|
"watch": "webpack --mode development --watch"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"core-js": "^3.36.0",
|
||||||
|
"regenerator-runtime": "^0.14.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.24.0",
|
||||||
|
"@babel/preset-env": "^7.25.4",
|
||||||
|
"@types/office-js": "^1.0.377",
|
||||||
|
"@types/office-runtime": "^1.0.35",
|
||||||
|
"acorn": "^8.11.3",
|
||||||
|
"babel-loader": "^9.1.3",
|
||||||
|
"copy-webpack-plugin": "^12.0.2",
|
||||||
|
"eslint-plugin-office-addins": "^4.0.3",
|
||||||
|
"file-loader": "^6.2.0",
|
||||||
|
"html-loader": "^5.0.0",
|
||||||
|
"html-webpack-inject-attributes-plugin": "^1.0.6",
|
||||||
|
"html-webpack-plugin": "^5.6.0",
|
||||||
|
"office-addin-cli": "^2.0.3",
|
||||||
|
"office-addin-debugging": "^6.0.3",
|
||||||
|
"office-addin-dev-certs": "^2.0.3",
|
||||||
|
"office-addin-lint": "^3.0.3",
|
||||||
|
"office-addin-manifest": "^2.0.3",
|
||||||
|
"office-addin-prettier-config": "^2.0.1",
|
||||||
|
"os-browserify": "^0.3.0",
|
||||||
|
"process": "^0.11.10",
|
||||||
|
"source-map-loader": "^5.0.0",
|
||||||
|
"webpack": "^5.95.0",
|
||||||
|
"webpack-cli": "^5.1.4",
|
||||||
|
"webpack-dev-server": "5.1.0"
|
||||||
|
},
|
||||||
|
"prettier": "office-addin-prettier-config",
|
||||||
|
"browserslist": [
|
||||||
|
"last 2 versions",
|
||||||
|
"ie 11"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
||||||
|
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
||||||
|
</head>
|
||||||
|
<body></body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
/* global Office */
|
||||||
|
const { loadSession, buildMeetingMessage, BASE_URL } = require("../common");
|
||||||
|
|
||||||
|
Office.onReady(() => {});
|
||||||
|
|
||||||
|
function generateMeetingLinkFromCalendar(event) {
|
||||||
|
const session = loadSession();
|
||||||
|
|
||||||
|
if (!session?.access_token) {
|
||||||
|
Office.context.mailbox.item.notificationMessages.replaceAsync("meetNotif", {
|
||||||
|
type: Office.MailboxEnums.ItemNotificationMessageType.ErrorMessage,
|
||||||
|
message: "Vous n'êtes pas connecté. Ouvrez les paramètres pour vous connecter.",
|
||||||
|
});
|
||||||
|
event.completed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(`${BASE_URL}/external-api/v1.0/rooms/`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": "Bearer " + session.access_token,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
console.log("Room created:", data);
|
||||||
|
|
||||||
|
const { url, message } = buildMeetingMessage(data);
|
||||||
|
const item = Office.context.mailbox.item;
|
||||||
|
|
||||||
|
item.body.getAsync(Office.CoercionType.Html, (getResult) => {
|
||||||
|
if (getResult.status !== Office.AsyncResultStatus.Succeeded) {
|
||||||
|
item.notificationMessages.replaceAsync("meetNotif", {
|
||||||
|
type: Office.MailboxEnums.ItemNotificationMessageType.ErrorMessage,
|
||||||
|
message: `Erreur de lecture: ${getResult.error.message}`,
|
||||||
|
});
|
||||||
|
event.completed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.body.setAsync(getResult.value + message, { coercionType: Office.CoercionType.Html }, (setResult) => {
|
||||||
|
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
|
||||||
|
item.notificationMessages.replaceAsync("meetNotif", {
|
||||||
|
type: Office.MailboxEnums.ItemNotificationMessageType.ErrorMessage,
|
||||||
|
message: `Erreur d'insertion: ${setResult.error.message}`,
|
||||||
|
});
|
||||||
|
event.completed();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.location.setAsync(url, (locationResult) => {
|
||||||
|
if (locationResult.status === Office.AsyncResultStatus.Succeeded) {
|
||||||
|
item.notificationMessages.replaceAsync("meetNotif", {
|
||||||
|
type: Office.MailboxEnums.ItemNotificationMessageType.InformationalMessage,
|
||||||
|
message: "Lien de réunion inséré !",
|
||||||
|
icon: "Icon.80x80",
|
||||||
|
persistent: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
item.notificationMessages.replaceAsync("meetNotif", {
|
||||||
|
type: Office.MailboxEnums.ItemNotificationMessageType.ErrorMessage,
|
||||||
|
message: `Erreur de localisation: ${locationResult.error.message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
event.completed();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
Office.context.mailbox.item.notificationMessages.replaceAsync("meetNotif", {
|
||||||
|
type: Office.MailboxEnums.ItemNotificationMessageType.ErrorMessage,
|
||||||
|
message: `Erreur: ${err.message}`,
|
||||||
|
});
|
||||||
|
event.completed();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Office.actions.associate("generateMeetingLinkFromCalendar", generateMeetingLinkFromCalendar);
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/* global Office */
|
||||||
|
|
||||||
|
const BASE_URL = "https://meet.127.0.0.1.nip.io"; // todo - use env variable
|
||||||
|
|
||||||
|
// ─── Session Storage ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function saveSession(data) {
|
||||||
|
const expiresAt = data.expires_in
|
||||||
|
? new Date(Date.now() + data.expires_in * 1000).toISOString()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
...data,
|
||||||
|
expiresAt,
|
||||||
|
savedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
localStorage.setItem("meetSession", payload);
|
||||||
|
|
||||||
|
const rs = Office.context.roamingSettings;
|
||||||
|
rs.set("meetSession", payload);
|
||||||
|
rs.saveAsync((result) => {
|
||||||
|
if (result.status !== Office.AsyncResultStatus.Succeeded) {
|
||||||
|
console.error("RoamingSettings save failed:", result.error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSession() {
|
||||||
|
let session = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = Office.context.roamingSettings.get("meetSession");
|
||||||
|
if (stored) session = JSON.parse(stored);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("RoamingSettings read failed:", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem("meetSession");
|
||||||
|
if (stored) session = JSON.parse(stored);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("localStorage read failed:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session) return null;
|
||||||
|
|
||||||
|
if (session.expiresAt && new Date() > new Date(session.expiresAt)) {
|
||||||
|
console.warn("Token expired, clearing session.");
|
||||||
|
clearSession();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSession() {
|
||||||
|
localStorage.removeItem("meetSession");
|
||||||
|
try {
|
||||||
|
const rs = Office.context.roamingSettings;
|
||||||
|
rs.remove("meetSession");
|
||||||
|
rs.saveAsync(() => console.log("RoamingSettings cleared."));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Could not clear RoamingSettings:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Meeting Message Builder ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
function buildMeetingMessage(data) {
|
||||||
|
const url = data.url;
|
||||||
|
const phone = data.telephony?.phone_number;
|
||||||
|
const pin = data.telephony?.pin_code;
|
||||||
|
|
||||||
|
const formattedPin = pin
|
||||||
|
? pin.replace(/(\d{3})(\d{3})(\d{4})/, "$1 $2 $3") + "#"
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const formattedPhone = phone
|
||||||
|
? phone.replace(/^\+33(\d)(\d{2})(\d{2})(\d{2})(\d{2})$/, "+33 $1 $2 $3 $4 $5")
|
||||||
|
: phone;
|
||||||
|
|
||||||
|
const message = `<pre style="font-family:inherit; font-size:inherit; border:none; background:none; margin:16px 0;">
|
||||||
|
────────────────────────────────────────
|
||||||
|
Rejoindre la réunion LaSuite Meet
|
||||||
|
|
||||||
|
<a href="${url}">${url}</a>
|
||||||
|
|
||||||
|
Ou appelez (audio uniquement)
|
||||||
|
(FR) ${formattedPhone}
|
||||||
|
Code : ${formattedPin}
|
||||||
|
────────────────────────────────────────</pre>`;
|
||||||
|
|
||||||
|
return { url, message };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { BASE_URL, saveSession, loadSession, clearSession, buildMeetingMessage };
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,58 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Visio</title>
|
||||||
|
<link rel="stylesheet" href="taskpane.css" />
|
||||||
|
<script nonce="NONCE_PLACEHOLDER" src="https://appsforoffice.microsoft.com/lib/1/hosted/office.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="sideload-msg">Veuillez charger le complément.</div>
|
||||||
|
|
||||||
|
<div id="app-body">
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div id="view-loading">
|
||||||
|
<p class="intro-text">Chargement...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Unauthenticated -->
|
||||||
|
<div id="view-unauth" style="display:none;">
|
||||||
|
<p class="intro-text">
|
||||||
|
<span>Ajoutez facilement un lien de réunion Visio à vos événements Outlook.</span>
|
||||||
|
<a href="https://meet.numerique.gouv.fr" target="_blank" class="learn-more">En savoir plus</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr class="divider" />
|
||||||
|
|
||||||
|
<button class="proconnect-button" id="btn-connect">
|
||||||
|
<span class="proconnect-sr-only">S'identifier avec ProConnect</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<a
|
||||||
|
href="https://www.proconnect.gouv.fr/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
title="Qu’est-ce que ProConnect ? - nouvelle fenêtre"
|
||||||
|
>
|
||||||
|
Qu’est-ce que ProConnect ?
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Authenticated -->
|
||||||
|
<div id="view-auth" style="display:none;">
|
||||||
|
<div id="btn-container">
|
||||||
|
<button id="btn-generate">Ajouter une réunion Visio</button>
|
||||||
|
<button id="btn-disconnect">Se déconnecter</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p id="status"></p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
|
||||||
|
const { BASE_URL, loadSession, saveSession, clearSession, buildMeetingMessage } = require("../common");
|
||||||
|
|
||||||
|
// ─── Views ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function showView(name) {
|
||||||
|
document.getElementById("view-loading").style.display = "none";
|
||||||
|
document.getElementById("view-unauth").style.display = "none";
|
||||||
|
document.getElementById("view-auth").style.display = "none";
|
||||||
|
document.getElementById(`view-${name}`).style.display = "block";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(msg) {
|
||||||
|
document.getElementById("status").textContent = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Polling ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function startPolling(session_id, { onSuccess, onTimeout, onError }) {
|
||||||
|
let pollCount = 0;
|
||||||
|
const pollInterval = setInterval(() => {
|
||||||
|
// ─── Timeout after 3 minutes ──────────────────────────────
|
||||||
|
if (pollCount++ > 180) {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
onTimeout?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch(`${BASE_URL}/api/v1.0/addons/sessions/wip/`, {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ session_id }),
|
||||||
|
})
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((sessionData) => {
|
||||||
|
console.log("Polling:", sessionData);
|
||||||
|
if (sessionData.state === "authenticated" && sessionData.access_token) {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
onSuccess?.(sessionData);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
onError?.(err);
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return pollInterval;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Transit Dialog ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function openTransitDialog(transit_token, { onCancel, onError }) {
|
||||||
|
const meetUrl = `${BASE_URL}/addons/transit/?transit_token=${transit_token}`;
|
||||||
|
|
||||||
|
Office.context.ui.displayDialogAsync(
|
||||||
|
meetUrl,
|
||||||
|
{ height: 60, width: 50, displayInIframe: false },
|
||||||
|
(asyncResult) => {
|
||||||
|
if (asyncResult.status === Office.AsyncResultStatus.Failed) {
|
||||||
|
onError?.(asyncResult.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dialog = asyncResult.value;
|
||||||
|
|
||||||
|
dialog.addEventHandler(Office.EventType.DialogMessageReceived, () => {
|
||||||
|
onCancel?.();
|
||||||
|
dialog.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
dialog.addEventHandler(Office.EventType.DialogEventReceived, (arg) => {
|
||||||
|
if (arg.error === 12006) {
|
||||||
|
setStatus("Dialog fermé. En attente d'authentification...");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return dialog;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Auth Flow ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
setStatus("Démarrage de la session...");
|
||||||
|
|
||||||
|
fetch(`${BASE_URL}/api/v1.0/addons/sessions/`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
})
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
const session_id = data.session_id;
|
||||||
|
const transit_token = data.transit_token;
|
||||||
|
setStatus("En attente d'authentification...");
|
||||||
|
|
||||||
|
const pollInterval = startPolling(session_id, {
|
||||||
|
onSuccess: (sessionData) => {
|
||||||
|
saveSession(sessionData);
|
||||||
|
setStatus("Connecté !");
|
||||||
|
showView("auth");
|
||||||
|
},
|
||||||
|
onTimeout: () => {
|
||||||
|
setStatus("Délai d'authentification dépassé. Veuillez réessayer.");
|
||||||
|
showView("unauth");
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
setStatus(`Erreur de polling: ${err.message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
openTransitDialog(transit_token, {
|
||||||
|
onCancel: () => clearInterval(pollInterval),
|
||||||
|
onError: (err) => {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
setStatus(`Erreur dialog: ${err.message}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
setStatus(`Erreur de connexion: ${err.message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnect() {
|
||||||
|
clearSession();
|
||||||
|
setStatus("Déconnecté.");
|
||||||
|
showView("unauth");
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateMeetingLink() {
|
||||||
|
const session = loadSession();
|
||||||
|
if (!session?.access_token) {
|
||||||
|
setStatus("Session introuvable. Veuillez vous reconnecter.");
|
||||||
|
showView("unauth");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = document.getElementById("btn-generate");
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = "Génération...";
|
||||||
|
|
||||||
|
fetch(`${BASE_URL}/external-api/v1.0/rooms/`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Authorization": "Bearer " + session.access_token,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
console.log("Room created:", data);
|
||||||
|
|
||||||
|
const { url, message } = buildMeetingMessage(data);
|
||||||
|
const item = Office.context.mailbox.item;
|
||||||
|
|
||||||
|
item.body.getAsync(Office.CoercionType.Html, (getResult) => {
|
||||||
|
if (getResult.status !== Office.AsyncResultStatus.Succeeded) {
|
||||||
|
setStatus(`Erreur de lecture: ${getResult.error.message}`);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = "Ajouter une réunion Visio";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
item.body.setAsync(
|
||||||
|
getResult.value + message,
|
||||||
|
{ coercionType: Office.CoercionType.Html },
|
||||||
|
(setResult) => {
|
||||||
|
if (setResult.status !== Office.AsyncResultStatus.Succeeded) {
|
||||||
|
setStatus(`Erreur d'insertion: ${setResult.error.message}`);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = "Ajouter une réunion Visio";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── If calendar event, also set location ──────────────
|
||||||
|
if (item.itemType === Office.MailboxEnums.ItemType.Appointment) {
|
||||||
|
item.location.setAsync(url, (locationResult) => {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = "Ajouter une réunion Visio";
|
||||||
|
if (locationResult.status === Office.AsyncResultStatus.Succeeded) {
|
||||||
|
setStatus("Lien de réunion inséré !");
|
||||||
|
} else {
|
||||||
|
setStatus(`Erreur de localisation: ${locationResult.error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = "Ajouter une réunion Visio";
|
||||||
|
setStatus("Lien de réunion inséré !");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = "Ajouter une réunion Visio";
|
||||||
|
setStatus(`Erreur: ${err.message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Init ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Office.onReady((info) => {
|
||||||
|
if (info.host === Office.HostType.Outlook) {
|
||||||
|
document.getElementById("sideload-msg").style.display = "none";
|
||||||
|
document.getElementById("app-body").style.display = "flex";
|
||||||
|
|
||||||
|
document.getElementById("btn-connect").onclick = connect;
|
||||||
|
document.getElementById("btn-disconnect").onclick = disconnect;
|
||||||
|
document.getElementById("btn-generate").onclick = generateMeetingLink;
|
||||||
|
|
||||||
|
const session = loadSession();
|
||||||
|
if (session?.state === "authenticated" && session?.access_token) {
|
||||||
|
setStatus("Connecté.");
|
||||||
|
showView("auth");
|
||||||
|
} else {
|
||||||
|
showView("unauth");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/* eslint-disable no-undef */
|
||||||
|
|
||||||
|
const devCerts = require("office-addin-dev-certs");
|
||||||
|
const CopyWebpackPlugin = require("copy-webpack-plugin");
|
||||||
|
const HtmlWebpackPlugin = require("html-webpack-plugin");
|
||||||
|
const htmlWebpackInjectAttributesPlugin = require("html-webpack-inject-attributes-plugin");
|
||||||
|
|
||||||
|
const urlDev = "https://localhost:3000/";
|
||||||
|
const urlProd = "https://meet.127.0.0.1.nip.io/outlook-addin/";
|
||||||
|
|
||||||
|
async function getHttpsOptions() {
|
||||||
|
const httpsOptions = await devCerts.getHttpsServerOptions();
|
||||||
|
return { ca: httpsOptions.ca, key: httpsOptions.key, cert: httpsOptions.cert };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = async (env, options) => {
|
||||||
|
const dev = options.mode === "development";
|
||||||
|
const config = {
|
||||||
|
devtool: "source-map",
|
||||||
|
entry: {
|
||||||
|
polyfill: ["core-js/stable", "regenerator-runtime/runtime"],
|
||||||
|
taskpane: ["./src/taskpane/taskpane.js", "./src/taskpane/taskpane.html"],
|
||||||
|
commands: "./src/commands/commands.js",
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
clean: true,
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
extensions: [".html", ".js"],
|
||||||
|
},
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.js$/,
|
||||||
|
exclude: /node_modules/,
|
||||||
|
use: {
|
||||||
|
loader: "babel-loader",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.html$/,
|
||||||
|
exclude: /node_modules/,
|
||||||
|
use: "html-loader",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.(png|jpg|jpeg|gif|ico)$/,
|
||||||
|
type: "asset/resource",
|
||||||
|
generator: {
|
||||||
|
filename: "assets/[name][ext][query]",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
new HtmlWebpackPlugin({
|
||||||
|
filename: "taskpane.html",
|
||||||
|
template: "./src/taskpane/taskpane.html",
|
||||||
|
chunks: ["polyfill", "taskpane"],
|
||||||
|
scriptLoading: "defer",
|
||||||
|
attributes: {
|
||||||
|
nonce: "NONCE_PLACEHOLDER",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new CopyWebpackPlugin({
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
from: "assets/*",
|
||||||
|
to: "assets/[name][ext][query]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
from: "manifest*.xml",
|
||||||
|
to: "[name]" + "[ext]",
|
||||||
|
transform(content) {
|
||||||
|
if (dev) {
|
||||||
|
return content;
|
||||||
|
} else {
|
||||||
|
return content.toString().replace(new RegExp(urlDev, "g"), urlProd);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
new HtmlWebpackPlugin({
|
||||||
|
filename: "commands.html",
|
||||||
|
template: "./src/commands/commands.html",
|
||||||
|
chunks: ["polyfill", "commands"],
|
||||||
|
scriptLoading: "defer",
|
||||||
|
attributes: {
|
||||||
|
nonce: "NONCE_PLACEHOLDER",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
new htmlWebpackInjectAttributesPlugin(),
|
||||||
|
],
|
||||||
|
devServer: {
|
||||||
|
headers: {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
type: "https",
|
||||||
|
options:
|
||||||
|
env.WEBPACK_BUILD || options.https !== undefined
|
||||||
|
? options.https
|
||||||
|
: await getHttpsOptions(),
|
||||||
|
},
|
||||||
|
port: process.env.npm_package_config_dev_server_port || 3000,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return config;
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.10.0"
|
version = "1.8.0"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"livekit-agents==1.3.10",
|
"livekit-agents==1.3.10",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Meet core add-ons module."""
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""Authentication session management for add-ons using temporary cache-based sessions."""
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from enum import Enum
|
||||||
|
from logging import getLogger
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.cache import cache
|
||||||
|
from django.core.exceptions import SuspiciousOperation
|
||||||
|
|
||||||
|
from core.models import User
|
||||||
|
from core.services.jwt_token import JwtTokenService
|
||||||
|
|
||||||
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SessionOwnershipError(Exception):
|
||||||
|
"""Raised when the claimed session_id does not match the result_token binding."""
|
||||||
|
|
||||||
|
class SessionState(str, Enum):
|
||||||
|
"""Add-on authentication session states."""
|
||||||
|
|
||||||
|
PENDING = "pending"
|
||||||
|
AUTHENTICATED = "authenticated"
|
||||||
|
|
||||||
|
|
||||||
|
class TokenExchangeService:
|
||||||
|
"""Manage temporary authentication sessions for add-on JWT token exchange."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize the service with the configured token service."""
|
||||||
|
|
||||||
|
self._token_service = JwtTokenService(
|
||||||
|
secret_key=settings.ADDONS_JWT_SECRET_KEY,
|
||||||
|
algorithm=settings.ADDONS_JWT_ALG,
|
||||||
|
issuer=settings.ADDONS_JWT_ISSUER,
|
||||||
|
audience=settings.ADDONS_JWT_AUDIENCE, # todo - precise
|
||||||
|
expiration_seconds=settings.ADDONS_JWT_EXPIRATION_SECONDS,
|
||||||
|
token_type=settings.ADDONS_JWT_TOKEN_TYPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _session_cache_key(self, session_id: str) -> str:
|
||||||
|
"""Generate cache key for a session ID."""
|
||||||
|
return f"{settings.ADDONS_SESSION_KEY_PREFIX}_{session_id}"
|
||||||
|
|
||||||
|
def _token_cache_key(self, result_token: str) -> str:
|
||||||
|
"""Wip."""
|
||||||
|
return f"{settings.ADDONS_SESSION_TOKEN_PREFIX}_{result_token}"
|
||||||
|
|
||||||
|
def init_session(self) -> tuple[str, str, str]:
|
||||||
|
"""Create a new pending authentication session and return its ID."""
|
||||||
|
|
||||||
|
session_id = secrets.token_urlsafe(settings.ADDONS_SESSION_ID_LENGTH)
|
||||||
|
result_token = secrets.token_urlsafe(32) # separate, never in any UR
|
||||||
|
|
||||||
|
expires_at = datetime.now(timezone.utc) + timedelta(
|
||||||
|
seconds=settings.ADDONS_SESSION_TIMEOUT
|
||||||
|
)
|
||||||
|
|
||||||
|
session_data = {
|
||||||
|
"state": SessionState.PENDING,
|
||||||
|
"expires_at": expires_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Store the session itself
|
||||||
|
cache.set(
|
||||||
|
self._session_cache_key(session_id),
|
||||||
|
session_data,
|
||||||
|
timeout=settings.ADDONS_SESSION_TIMEOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Store the token → session_id binding (same TTL)
|
||||||
|
cache.set(
|
||||||
|
self._token_cache_key(result_token),
|
||||||
|
session_id,
|
||||||
|
timeout=settings.ADDONS_SESSION_TIMEOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Transit token → session_id, very short TTL, one-time use
|
||||||
|
transit_token = secrets.token_urlsafe(32)
|
||||||
|
cache.set(
|
||||||
|
f"addon_transit_{transit_token}",
|
||||||
|
session_id,
|
||||||
|
timeout=120
|
||||||
|
)
|
||||||
|
|
||||||
|
print('$$ init transit_token')
|
||||||
|
print(transit_token)
|
||||||
|
|
||||||
|
return session_id, result_token, transit_token
|
||||||
|
|
||||||
|
# todo - wip
|
||||||
|
def get_session(self, session_id: str) -> dict:
|
||||||
|
"""Retrieve session data and clear it if authenticated."""
|
||||||
|
|
||||||
|
return self._get_and_maybe_clear(session_id)
|
||||||
|
|
||||||
|
def get_session_by_token(self, result_token: str, claimed_session_id: str) -> dict:
|
||||||
|
"""Resolve result_token → session_id → session data.
|
||||||
|
|
||||||
|
Verifies that the claimed_session_id matches the token binding,
|
||||||
|
proving the caller initiated this session (ownership check).
|
||||||
|
Clears the session once authenticated (one-time read).
|
||||||
|
"""
|
||||||
|
session_id = cache.get(self._token_cache_key(result_token))
|
||||||
|
if not session_id:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
print("$$$ session_id")
|
||||||
|
print(session_id)
|
||||||
|
|
||||||
|
print("$$$ claimed_session_id")
|
||||||
|
print(claimed_session_id)
|
||||||
|
|
||||||
|
if not secrets.compare_digest(session_id, claimed_session_id):
|
||||||
|
raise SessionOwnershipError("Session ID does not match token binding.")
|
||||||
|
|
||||||
|
return self._get_and_maybe_clear(session_id)
|
||||||
|
|
||||||
|
def _get_and_maybe_clear(self, session_id: str) -> dict:
|
||||||
|
"""Wip."""
|
||||||
|
|
||||||
|
cache_key = self._session_cache_key(session_id)
|
||||||
|
data = cache.get(cache_key)
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
if data.get("state") == SessionState.AUTHENTICATED:
|
||||||
|
# One-time read: clear both the session and the token binding
|
||||||
|
self.clear_session(session_id)
|
||||||
|
|
||||||
|
# Return copy without internal fields
|
||||||
|
internal_fields = {"expires_at"}
|
||||||
|
return {k: v for k, v in data.items() if k not in internal_fields}
|
||||||
|
|
||||||
|
def clear_session(self, session_id: str, result_token: str | None = None) -> None:
|
||||||
|
"""Wip."""
|
||||||
|
cache.delete(self._session_cache_key(session_id))
|
||||||
|
if result_token:
|
||||||
|
cache.delete(self._token_cache_key(result_token))
|
||||||
|
|
||||||
|
def set_access_token(self, user: User, session_id: str):
|
||||||
|
"""Generate and store access token for an authenticated user session."""
|
||||||
|
|
||||||
|
cache_key = self._session_cache_key(session_id)
|
||||||
|
existing_data = cache.get(cache_key)
|
||||||
|
|
||||||
|
if not existing_data:
|
||||||
|
raise SuspiciousOperation("Session not found.")
|
||||||
|
|
||||||
|
expires_at = existing_data.get("expires_at", None)
|
||||||
|
|
||||||
|
if not expires_at:
|
||||||
|
self.clear_session(session_id)
|
||||||
|
raise SuspiciousOperation("Invalid session data.")
|
||||||
|
|
||||||
|
remaining_seconds = int(
|
||||||
|
(
|
||||||
|
datetime.fromisoformat(expires_at) - datetime.now(timezone.utc)
|
||||||
|
).total_seconds()
|
||||||
|
)
|
||||||
|
|
||||||
|
if remaining_seconds <= 0:
|
||||||
|
self.clear_session(session_id)
|
||||||
|
raise SuspiciousOperation("Session expired.")
|
||||||
|
|
||||||
|
if existing_data.get("state") != SessionState.PENDING:
|
||||||
|
self.clear_session(session_id)
|
||||||
|
raise SuspiciousOperation("Access token already set.")
|
||||||
|
|
||||||
|
response = self._token_service.generate_jwt(user, settings.ADDONS_SCOPES)
|
||||||
|
new_data = {
|
||||||
|
**existing_data,
|
||||||
|
**response,
|
||||||
|
"state": SessionState.AUTHENTICATED,
|
||||||
|
}
|
||||||
|
|
||||||
|
cache.set(cache_key, new_data, timeout=remaining_seconds)
|
||||||
|
|
||||||
|
def token_to_session(self, result_token):
|
||||||
|
"""wip."""
|
||||||
|
return None
|
||||||
|
|
||||||
|
def consume_transit_token(self, transit_token: str) -> str | None:
|
||||||
|
"""Resolve and immediately delete the transit token (one-time use)."""
|
||||||
|
key = f"addon_transit_{transit_token}"
|
||||||
|
session_id = cache.get(key)
|
||||||
|
if session_id:
|
||||||
|
cache.delete(key) # consumed — cannot be replayed
|
||||||
|
return session_id
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Add-ons views."""
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import SuspiciousOperation
|
||||||
|
from django.shortcuts import redirect, render
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from django.views.decorators.http import require_http_methods
|
||||||
|
|
||||||
|
from core.addons.service import SessionState, TokenExchangeService
|
||||||
|
|
||||||
|
|
||||||
|
def render_error(request, message, status=400):
|
||||||
|
"""Render simple error page."""
|
||||||
|
return render(request, "addons/error.html", {"message": message}, status=status)
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def transit_page(request):
|
||||||
|
"""Initialize authentication flow for add-on session."""
|
||||||
|
|
||||||
|
transit_token = request.GET.get("transit_token")
|
||||||
|
|
||||||
|
if not transit_token:
|
||||||
|
return render_error(request, _("Transit token is required."), status=400)
|
||||||
|
|
||||||
|
session_id = TokenExchangeService().consume_transit_token(transit_token)
|
||||||
|
|
||||||
|
if not session_id:
|
||||||
|
return render_error(request, _("Invalid or expired transit token."), status=404)
|
||||||
|
|
||||||
|
# Validate the session is still pending
|
||||||
|
data = TokenExchangeService().get_session(session_id)
|
||||||
|
if not data:
|
||||||
|
return render_error(request, _("Session not found or expired."), status=404)
|
||||||
|
|
||||||
|
if data.get("state") != SessionState.PENDING:
|
||||||
|
return render_error(request, _("Invalid session state."), status=400)
|
||||||
|
|
||||||
|
request.session[settings.ADDONS_SESSION_KEY_AUTH] = session_id
|
||||||
|
|
||||||
|
return_to = f"{settings.APPLICATION_BASE_URL}/addons/redirect"
|
||||||
|
return redirect(f"/api/{settings.API_VERSION}/authenticate/?returnTo={return_to}")
|
||||||
|
|
||||||
|
|
||||||
|
@require_http_methods(["GET"])
|
||||||
|
def redirect_page(request):
|
||||||
|
"""Complete authentication and close the popup window."""
|
||||||
|
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return render_error(request, _("Authentication required."), status=401)
|
||||||
|
|
||||||
|
session_id = request.session.pop(settings.ADDONS_SESSION_KEY_AUTH, None)
|
||||||
|
|
||||||
|
if not session_id:
|
||||||
|
return render_error(request, _("No active session found."), status=404)
|
||||||
|
|
||||||
|
try:
|
||||||
|
TokenExchangeService().set_access_token(request.user, session_id)
|
||||||
|
except SuspiciousOperation:
|
||||||
|
return render_error(request, _("Invalid or expired session."), status=400)
|
||||||
|
|
||||||
|
return render(request, "addons/redirect_success.html")
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Add-ons API endpoints"""
|
||||||
|
|
||||||
|
from logging import getLogger
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import SuspiciousOperation
|
||||||
|
|
||||||
|
from rest_framework import (
|
||||||
|
response as drf_response,
|
||||||
|
)
|
||||||
|
from rest_framework import decorators
|
||||||
|
from rest_framework import status as drf_status
|
||||||
|
from rest_framework import viewsets
|
||||||
|
|
||||||
|
from core.addons.service import TokenExchangeService, SessionOwnershipError
|
||||||
|
|
||||||
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthSessionViewSet(viewsets.ViewSet):
|
||||||
|
"""ViewSet for managing add-on authentication sessions via token exchange."""
|
||||||
|
|
||||||
|
authentication_classes = []
|
||||||
|
permission_classes = []
|
||||||
|
throttle_classes = []
|
||||||
|
|
||||||
|
def create(self, request):
|
||||||
|
"""Create a pending session.
|
||||||
|
|
||||||
|
Returns session_id in the body (client forwards it to the 3rd-party view).
|
||||||
|
Sets result_token as an HttpOnly cookie (the only poll credential).
|
||||||
|
"""
|
||||||
|
session_id, result_token, transit_token = TokenExchangeService().init_session()
|
||||||
|
response = drf_response.Response(
|
||||||
|
{"session_id": session_id, "transit_token": transit_token}, status=drf_status.HTTP_201_CREATED
|
||||||
|
)
|
||||||
|
response.set_cookie(
|
||||||
|
key=settings.ADDONS_RESULT_TOKEN_COOKIE_NAME,
|
||||||
|
value=result_token,
|
||||||
|
max_age=6000,
|
||||||
|
httponly=True,
|
||||||
|
secure=True,
|
||||||
|
samesite="None",
|
||||||
|
)
|
||||||
|
return response
|
||||||
|
|
||||||
|
@decorators.action(
|
||||||
|
detail=False,
|
||||||
|
methods=["post"],
|
||||||
|
url_name="wip",
|
||||||
|
url_path="wip",
|
||||||
|
permission_classes=[],
|
||||||
|
authentication_classes=[],
|
||||||
|
)
|
||||||
|
def long_poll(self, request):
|
||||||
|
"""Long-poll endpoint — only the cookie is accepted, never a session_id.
|
||||||
|
|
||||||
|
pk is intentionally ignored; the session is resolved from the cookie.
|
||||||
|
"""
|
||||||
|
|
||||||
|
result_token = request.COOKIES.get(settings.ADDONS_RESULT_TOKEN_COOKIE_NAME)
|
||||||
|
session_id = request.data.get("session_id")
|
||||||
|
|
||||||
|
if not result_token:
|
||||||
|
return drf_response.Response(
|
||||||
|
{"detail": "Missing result token."},
|
||||||
|
status=drf_status.HTTP_401_UNAUTHORIZED,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not session_id:
|
||||||
|
return drf_response.Response(
|
||||||
|
{"detail": "Missing result session id."},
|
||||||
|
status=drf_status.HTTP_401_UNAUTHORIZED,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = TokenExchangeService().get_session_by_token(
|
||||||
|
result_token=result_token,
|
||||||
|
claimed_session_id=session_id,
|
||||||
|
)
|
||||||
|
except SessionOwnershipError as e:
|
||||||
|
raise SuspiciousOperation(str(e)) from e
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
return drf_response.Response(
|
||||||
|
{"detail": "Session not found or expired."},
|
||||||
|
status=drf_status.HTTP_404_NOT_FOUND,
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.get("state") == "pending":
|
||||||
|
return drf_response.Response(
|
||||||
|
{"state": "pending"},
|
||||||
|
status=drf_status.HTTP_202_ACCEPTED,
|
||||||
|
)
|
||||||
|
|
||||||
|
return drf_response.Response(data, status=drf_status.HTTP_200_OK)
|
||||||
|
|
||||||
|
def destroy(self, request, pk=None):
|
||||||
|
"""Explicit session teardown, resolves via cookie, not pk."""
|
||||||
|
|
||||||
|
result_token = request.COOKIES.get(settings.ADDONS_RESULT_TOKEN_COOKIE_NAME)
|
||||||
|
if not result_token:
|
||||||
|
return drf_response.Response(status=drf_status.HTTP_204_NO_CONTENT)
|
||||||
|
|
||||||
|
# We need the session_id to clear both keys — resolve it first
|
||||||
|
session_id = TokenExchangeService().token_to_session(result_token)
|
||||||
|
if session_id:
|
||||||
|
TokenExchangeService().clear_session(session_id, result_token)
|
||||||
|
|
||||||
|
response = drf_response.Response(status=drf_status.HTTP_204_NO_CONTENT)
|
||||||
|
response.delete_cookie(settings.ADDONS_RESULT_TOKEN_COOKIE_NAME)
|
||||||
|
|
||||||
|
return response
|
||||||
@@ -197,38 +197,6 @@ 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)
|
@admin.register(models.Recording)
|
||||||
class RecordingAdmin(admin.ModelAdmin):
|
class RecordingAdmin(admin.ModelAdmin):
|
||||||
"""Recording admin interface declaration."""
|
"""Recording admin interface declaration."""
|
||||||
@@ -256,7 +224,7 @@ class RecordingAdmin(admin.ModelAdmin):
|
|||||||
"updated_at",
|
"updated_at",
|
||||||
"worker_id",
|
"worker_id",
|
||||||
)
|
)
|
||||||
actions = [resend_notification, mark_as_failed_to_stop]
|
actions = [resend_notification]
|
||||||
|
|
||||||
def get_queryset(self, request):
|
def get_queryset(self, request):
|
||||||
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
|
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
|
||||||
@@ -308,7 +276,7 @@ class ApplicationAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
form = ApplicationAdminForm
|
form = ApplicationAdminForm
|
||||||
|
|
||||||
list_display = ("id", "name", "client_id", "get_scopes_display", "is_active")
|
list_display = ("id", "name", "client_id", "get_scopes_display")
|
||||||
fields = [
|
fields = [
|
||||||
"name",
|
"name",
|
||||||
"id",
|
"id",
|
||||||
@@ -317,7 +285,6 @@ class ApplicationAdmin(admin.ModelAdmin):
|
|||||||
"scopes",
|
"scopes",
|
||||||
"client_id",
|
"client_id",
|
||||||
"client_secret",
|
"client_secret",
|
||||||
"is_active",
|
|
||||||
]
|
]
|
||||||
readonly_fields = ["id", "created_at", "updated_at"]
|
readonly_fields = ["id", "created_at", "updated_at"]
|
||||||
inlines = [ApplicationDomainInline]
|
inlines = [ApplicationDomainInline]
|
||||||
|
|||||||
@@ -43,21 +43,6 @@ def get_frontend_configuration(request):
|
|||||||
"expiration_days": settings.RECORDING_EXPIRATION_DAYS,
|
"expiration_days": settings.RECORDING_EXPIRATION_DAYS,
|
||||||
"max_duration": settings.RECORDING_MAX_DURATION,
|
"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": {
|
"telephony": {
|
||||||
"enabled": settings.ROOM_TELEPHONY_ENABLED,
|
"enabled": settings.ROOM_TELEPHONY_ENABLED,
|
||||||
"phone_number": settings.ROOM_TELEPHONY_PHONE_NUMBER
|
"phone_number": settings.ROOM_TELEPHONY_PHONE_NUMBER
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ class FeatureFlag:
|
|||||||
"recording": "RECORDING_ENABLE",
|
"recording": "RECORDING_ENABLE",
|
||||||
"storage_event": "RECORDING_STORAGE_EVENT_ENABLE",
|
"storage_event": "RECORDING_STORAGE_EVENT_ENABLE",
|
||||||
"subtitle": "ROOM_SUBTITLE_ENABLED",
|
"subtitle": "ROOM_SUBTITLE_ENABLED",
|
||||||
"file_upload": "FILE_UPLOAD_ENABLED",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
"""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,8 +1,5 @@
|
|||||||
"""Permission handlers for the Meet core app."""
|
"""Permission handlers for the Meet core app."""
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.http import Http404
|
|
||||||
|
|
||||||
from rest_framework import permissions
|
from rest_framework import permissions
|
||||||
|
|
||||||
from ..models import RoleChoices
|
from ..models import RoleChoices
|
||||||
@@ -109,30 +106,3 @@ class HasLiveKitRoomAccess(permissions.BasePermission):
|
|||||||
if not request.auth or not hasattr(request.auth, "video"):
|
if not request.auth or not hasattr(request.auth, "video"):
|
||||||
return False
|
return False
|
||||||
return request.auth.video.room == str(obj.id)
|
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,27 +1,16 @@
|
|||||||
"""Client serializers for the Meet core app."""
|
"""Client serializers for the Meet core app."""
|
||||||
|
|
||||||
# pylint: disable=abstract-method,no-name-in-module
|
# 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.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from django_pydantic_field.rest_framework import SchemaField
|
from livekit.api import ParticipantPermission
|
||||||
from pydantic import BaseModel, Field
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
from rest_framework.exceptions import PermissionDenied
|
from rest_framework.exceptions import PermissionDenied
|
||||||
from timezone_field.rest_framework import TimeZoneSerializerField
|
from timezone_field.rest_framework import TimeZoneSerializerField
|
||||||
|
|
||||||
from core import models, utils
|
from core import models, utils
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class UserSerializer(serializers.ModelSerializer):
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
"""Serialize users."""
|
"""Serialize users."""
|
||||||
@@ -34,15 +23,6 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
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:
|
class ResourceAccessSerializerMixin:
|
||||||
"""
|
"""
|
||||||
A serializer mixin to share controlling that the logged-in user submitting a room access object
|
A serializer mixin to share controlling that the logged-in user submitting a room access object
|
||||||
@@ -221,27 +201,6 @@ class BaseValidationOnlySerializer(serializers.Serializer):
|
|||||||
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
|
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
|
||||||
|
|
||||||
|
|
||||||
class RecordingOptions(BaseModel):
|
|
||||||
"""Configuration options for recording.
|
|
||||||
|
|
||||||
Attributes:
|
|
||||||
language: ISO 639-1 language code compatible with whisperX.
|
|
||||||
When `None`, the transcription engine will attempt to
|
|
||||||
auto-detect the spoken language.
|
|
||||||
transcribe: Whether to transcribe the recorded audio.
|
|
||||||
When `None`, falls back to the application default.
|
|
||||||
original_mode: The original recording mode before any override.
|
|
||||||
Must be one of the valid RecordingModeChoices values when provided.
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
language: str | None = None
|
|
||||||
transcribe: bool | None = None
|
|
||||||
original_mode: Literal["screen_recording", "transcript"] | None = None
|
|
||||||
|
|
||||||
model_config = {"extra": "forbid"}
|
|
||||||
|
|
||||||
|
|
||||||
class StartRecordingSerializer(BaseValidationOnlySerializer):
|
class StartRecordingSerializer(BaseValidationOnlySerializer):
|
||||||
"""Validate start recording requests."""
|
"""Validate start recording requests."""
|
||||||
|
|
||||||
@@ -254,11 +213,10 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
|
|||||||
"screen_recording or transcript.",
|
"screen_recording or transcript.",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
options = SchemaField(
|
options = serializers.JSONField(
|
||||||
schema=RecordingOptions | None,
|
|
||||||
required=False,
|
required=False,
|
||||||
allow_null=True,
|
allow_null=True,
|
||||||
help_text="Recording options",
|
default=dict,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -303,28 +261,6 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ParticipantPermission(BaseModel):
|
|
||||||
"""Mirror the LiveKit ParticipantPermission protobuf.
|
|
||||||
|
|
||||||
Control what a participant is allowed to publish, subscribe, and do within a room.
|
|
||||||
Unknown fields are rejected.
|
|
||||||
"""
|
|
||||||
|
|
||||||
can_subscribe: bool | None = None
|
|
||||||
can_publish: bool | None = None
|
|
||||||
can_publish_data: bool | None = None
|
|
||||||
can_publish_sources: list[int] = Field(
|
|
||||||
default_factory=list
|
|
||||||
) # TrackSource enum values
|
|
||||||
hidden: bool | None = None
|
|
||||||
recorder: bool | None = None
|
|
||||||
can_update_metadata: bool | None = None
|
|
||||||
agent: bool | None = None
|
|
||||||
can_subscribe_metrics: bool | None = None
|
|
||||||
|
|
||||||
model_config = {"extra": "forbid"}
|
|
||||||
|
|
||||||
|
|
||||||
class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
||||||
"""Validate participant update data."""
|
"""Validate participant update data."""
|
||||||
|
|
||||||
@@ -336,11 +272,10 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
|||||||
allow_null=True,
|
allow_null=True,
|
||||||
help_text="Participant attributes as JSON object",
|
help_text="Participant attributes as JSON object",
|
||||||
)
|
)
|
||||||
permission = SchemaField(
|
permission = serializers.DictField(
|
||||||
schema=ParticipantPermission | None,
|
|
||||||
required=False,
|
required=False,
|
||||||
allow_null=True,
|
allow_null=True,
|
||||||
help_text="Participant permissions",
|
help_text="Participant permission as JSON object",
|
||||||
)
|
)
|
||||||
name = serializers.CharField(
|
name = serializers.CharField(
|
||||||
max_length=255,
|
max_length=255,
|
||||||
@@ -350,33 +285,6 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
|||||||
help_text="Display name for the participant",
|
help_text="Display name for the participant",
|
||||||
)
|
)
|
||||||
|
|
||||||
def validate_permission(self, permission):
|
|
||||||
"""Validate that the given permission does not include forbidden or unimplemented fields."""
|
|
||||||
|
|
||||||
if permission is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
suspicious_fields = [
|
|
||||||
field
|
|
||||||
for field in settings.PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS
|
|
||||||
if getattr(permission, field) is not None
|
|
||||||
]
|
|
||||||
if suspicious_fields:
|
|
||||||
raise SuspiciousOperation(
|
|
||||||
f"Setting the following participant permissions is not allowed: "
|
|
||||||
f"{', '.join(suspicious_fields)}."
|
|
||||||
)
|
|
||||||
if permission.can_subscribe_metrics is not None:
|
|
||||||
raise serializers.ValidationError(
|
|
||||||
{
|
|
||||||
"permission": {
|
|
||||||
"can_subscribe_metrics": "This permission is not implemented."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return permission
|
|
||||||
|
|
||||||
def validate(self, attrs):
|
def validate(self, attrs):
|
||||||
"""Ensure at least one update field is provided."""
|
"""Ensure at least one update field is provided."""
|
||||||
update_fields = ["metadata", "attributes", "permission", "name"]
|
update_fields = ["metadata", "attributes", "permission", "name"]
|
||||||
@@ -392,144 +300,12 @@ class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
|||||||
f"{', '.join(update_fields)}."
|
f"{', '.join(update_fields)}."
|
||||||
)
|
)
|
||||||
|
|
||||||
return attrs
|
if "permission" in attrs:
|
||||||
|
try:
|
||||||
|
ParticipantPermission(**attrs["permission"])
|
||||||
class ListFileSerializer(serializers.ModelSerializer):
|
except ValueError as e:
|
||||||
"""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(
|
raise serializers.ValidationError(
|
||||||
{"filename": _("This file extension is not allowed.")},
|
{"permission": f"Invalid permission: {str(e)}"}
|
||||||
code="item_create_file_extension_not_allowed",
|
) from e
|
||||||
)
|
|
||||||
|
|
||||||
# The title will be the filename if not provided
|
|
||||||
if not attrs.get("title", None):
|
|
||||||
attrs["title"] = filename_root
|
|
||||||
|
|
||||||
return attrs
|
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,9 +1,7 @@
|
|||||||
"""Throttling modules for the API."""
|
"""Throttling modules for the API."""
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
from lasuite.drf.throttling import MonitoredThrottleMixin
|
from lasuite.drf.throttling import MonitoredThrottleMixin
|
||||||
from rest_framework.throttling import AnonRateThrottle, UserRateThrottle
|
from rest_framework.throttling import AnonRateThrottle
|
||||||
from sentry_sdk import capture_message
|
from sentry_sdk import capture_message
|
||||||
|
|
||||||
|
|
||||||
@@ -16,58 +14,11 @@ class MonitoredAnonRateThrottle(MonitoredThrottleMixin, AnonRateThrottle):
|
|||||||
"""Throttle for the monitored scoped rate throttle."""
|
"""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):
|
class RequestEntryAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||||
"""Throttle Anonymous user requesting room entry"""
|
"""Throttle Anonymous user requesting room entry"""
|
||||||
|
|
||||||
scope = "request_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):
|
class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||||
"""Throttle Anonymous user requesting room generation callback"""
|
"""Throttle Anonymous user requesting room generation callback"""
|
||||||
|
|||||||
@@ -1,26 +1,16 @@
|
|||||||
"""API endpoints"""
|
"""API endpoints"""
|
||||||
# pylint: disable=too-many-lines
|
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from urllib.parse import unquote, urlparse
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.files.storage import default_storage
|
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
from django.utils.translation import gettext_lazy as _
|
|
||||||
|
|
||||||
from django_filters import rest_framework as django_filters
|
from rest_framework import decorators, mixins, pagination, viewsets
|
||||||
from rest_framework import (
|
|
||||||
decorators,
|
|
||||||
filters,
|
|
||||||
mixins,
|
|
||||||
pagination,
|
|
||||||
viewsets,
|
|
||||||
)
|
|
||||||
from rest_framework import (
|
from rest_framework import (
|
||||||
exceptions as drf_exceptions,
|
exceptions as drf_exceptions,
|
||||||
)
|
)
|
||||||
@@ -32,13 +22,10 @@ from rest_framework import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from core import enums, models, utils
|
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.enums import FileExtension
|
||||||
from core.recording.event.authentication import StorageEventAuthentication
|
from core.recording.event.authentication import StorageEventAuthentication
|
||||||
from core.recording.event.exceptions import (
|
from core.recording.event.exceptions import (
|
||||||
InvalidBucketError,
|
InvalidBucketError,
|
||||||
InvalidFilepathError,
|
|
||||||
InvalidFileTypeError,
|
InvalidFileTypeError,
|
||||||
ParsingEventDataError,
|
ParsingEventDataError,
|
||||||
)
|
)
|
||||||
@@ -69,7 +56,6 @@ from core.services.participants_management import (
|
|||||||
)
|
)
|
||||||
from core.services.room_creation import RoomCreation
|
from core.services.room_creation import RoomCreation
|
||||||
from core.services.subtitle import SubtitleException, SubtitleService
|
from core.services.subtitle import SubtitleException, SubtitleService
|
||||||
from core.tasks.file import process_file_deletion
|
|
||||||
|
|
||||||
from ..authentication.livekit import LiveKitTokenAuthentication
|
from ..authentication.livekit import LiveKitTokenAuthentication
|
||||||
from . import permissions, serializers, throttling
|
from . import permissions, serializers, throttling
|
||||||
@@ -91,20 +77,20 @@ class NestedGenericViewSet(viewsets.GenericViewSet):
|
|||||||
lookup_fields: list[str] = ["pk"]
|
lookup_fields: list[str] = ["pk"]
|
||||||
lookup_url_kwargs: list[str] = []
|
lookup_url_kwargs: list[str] = []
|
||||||
|
|
||||||
def __getattribute__(self, file):
|
def __getattribute__(self, item):
|
||||||
"""
|
"""
|
||||||
This method is overridden to allow to get the last lookup field or lookup url kwarg
|
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
|
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`.
|
to keep compatibility with all methods used by the parent class `GenericViewSet`.
|
||||||
"""
|
"""
|
||||||
if file in ["lookup_field", "lookup_url_kwarg"]:
|
if item in ["lookup_field", "lookup_url_kwarg"]:
|
||||||
return getattr(self, file + "s", [None])[-1]
|
return getattr(self, item + "s", [None])[-1]
|
||||||
|
|
||||||
return super().__getattribute__(file)
|
return super().__getattribute__(item)
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
"""
|
"""
|
||||||
Get the list of files for this view.
|
Get the list of items for this view.
|
||||||
|
|
||||||
`lookup_fields` attribute is enumerated here to perform the nested lookup.
|
`lookup_fields` attribute is enumerated here to perform the nested lookup.
|
||||||
"""
|
"""
|
||||||
@@ -215,7 +201,6 @@ class RoomViewSet(
|
|||||||
API endpoints to access and perform actions on rooms.
|
API endpoints to access and perform actions on rooms.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
pagination_class = Pagination
|
|
||||||
permission_classes = [permissions.RoomPermissions]
|
permission_classes = [permissions.RoomPermissions]
|
||||||
queryset = models.Room.objects.all()
|
queryset = models.Room.objects.all()
|
||||||
serializer_class = serializers.RoomSerializer
|
serializer_class = serializers.RoomSerializer
|
||||||
@@ -311,14 +296,12 @@ class RoomViewSet(
|
|||||||
)
|
)
|
||||||
|
|
||||||
mode = serializer.validated_data["mode"]
|
mode = serializer.validated_data["mode"]
|
||||||
options = serializer.validated_data.get("options")
|
options = serializer.validated_data["options"]
|
||||||
room = self.get_object()
|
room = self.get_object()
|
||||||
|
|
||||||
# May raise exception if an active or initiated recording already exist for the room
|
# May raise exception if an active or initiated recording already exist for the room
|
||||||
recording = models.Recording.objects.create(
|
recording = models.Recording.objects.create(
|
||||||
room=room,
|
room=room, mode=mode, options=options
|
||||||
mode=mode,
|
|
||||||
options=options.model_dump(exclude_none=True) if options else {},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
models.RecordingAccess.objects.create(
|
models.RecordingAccess.objects.create(
|
||||||
@@ -384,10 +367,7 @@ class RoomViewSet(
|
|||||||
methods=["post"],
|
methods=["post"],
|
||||||
url_path="request-entry",
|
url_path="request-entry",
|
||||||
permission_classes=[],
|
permission_classes=[],
|
||||||
throttle_classes=[
|
throttle_classes=[throttling.RequestEntryAnonRateThrottle],
|
||||||
throttling.RequestEntryAuthenticatedUserRateThrottle,
|
|
||||||
throttling.RequestEntryAnonRateThrottle,
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
def request_entry(self, request, pk=None): # pylint: disable=unused-argument
|
def request_entry(self, request, pk=None): # pylint: disable=unused-argument
|
||||||
"""Request entry to a room"""
|
"""Request entry to a room"""
|
||||||
@@ -627,15 +607,13 @@ class RoomViewSet(
|
|||||||
serializer = serializers.UpdateParticipantSerializer(data=request.data)
|
serializer = serializers.UpdateParticipantSerializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
permission = serializer.validated_data.get("permission")
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ParticipantsManagement().update(
|
ParticipantsManagement().update(
|
||||||
room_name=str(room.pk),
|
room_name=str(room.pk),
|
||||||
identity=str(serializer.validated_data["participant_identity"]),
|
identity=str(serializer.validated_data["participant_identity"]),
|
||||||
metadata=serializer.validated_data.get("metadata"),
|
metadata=serializer.validated_data.get("metadata"),
|
||||||
attributes=serializer.validated_data.get("attributes"),
|
attributes=serializer.validated_data.get("attributes"),
|
||||||
permission=permission.model_dump() if permission else None,
|
permission=serializer.validated_data.get("permission"),
|
||||||
name=serializer.validated_data.get("name"),
|
name=serializer.validated_data.get("name"),
|
||||||
)
|
)
|
||||||
except ParticipantsManagementException:
|
except ParticipantsManagementException:
|
||||||
@@ -762,14 +740,9 @@ class RecordingViewSet(
|
|||||||
except InvalidBucketError as e:
|
except InvalidBucketError as e:
|
||||||
raise drf_exceptions.PermissionDenied("Invalid bucket specified") from e
|
raise drf_exceptions.PermissionDenied("Invalid bucket specified") from e
|
||||||
|
|
||||||
except InvalidFilepathError:
|
except InvalidFileTypeError as e:
|
||||||
return drf_response.Response(
|
return drf_response.Response(
|
||||||
{"message": "Notification ignored."},
|
{"message": f"Ignore this file type, {e}"},
|
||||||
)
|
|
||||||
|
|
||||||
except InvalidFileTypeError:
|
|
||||||
return drf_response.Response(
|
|
||||||
{"message": "Notification ignored."},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -816,7 +789,7 @@ class RecordingViewSet(
|
|||||||
# Extract the original URL from the request header
|
# Extract the original URL from the request header
|
||||||
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
|
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
|
||||||
if not original_url:
|
if not original_url:
|
||||||
logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest")
|
logger.debug("Missing HTTP_X_ORIGINAL_URL header in subrequest")
|
||||||
raise drf_exceptions.PermissionDenied()
|
raise drf_exceptions.PermissionDenied()
|
||||||
|
|
||||||
logger.debug("Original url: '%s'", original_url)
|
logger.debug("Original url: '%s'", original_url)
|
||||||
@@ -833,7 +806,7 @@ class RecordingViewSet(
|
|||||||
try:
|
try:
|
||||||
return match.groupdict()
|
return match.groupdict()
|
||||||
except (ValueError, AttributeError) as exc:
|
except (ValueError, AttributeError) as exc:
|
||||||
logger.warning("Failed to extract parameters from subrequest URL: %s", exc)
|
logger.debug("Failed to extract parameters from subrequest URL: %s", exc)
|
||||||
raise drf_exceptions.PermissionDenied() from exc
|
raise drf_exceptions.PermissionDenied() from exc
|
||||||
|
|
||||||
@decorators.action(detail=False, methods=["get"], url_path="media-auth")
|
@decorators.action(detail=False, methods=["get"], url_path="media-auth")
|
||||||
@@ -857,7 +830,7 @@ class RecordingViewSet(
|
|||||||
recording_id = url_params["recording_id"]
|
recording_id = url_params["recording_id"]
|
||||||
|
|
||||||
extension = url_params["extension"]
|
extension = url_params["extension"]
|
||||||
if extension not in [file.value for file in FileExtension]:
|
if extension not in [item.value for item in FileExtension]:
|
||||||
raise drf_exceptions.ValidationError({"detail": "Unsupported extension."})
|
raise drf_exceptions.ValidationError({"detail": "Unsupported extension."})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -881,324 +854,3 @@ class RecordingViewSet(
|
|||||||
request = utils.generate_s3_authorization_headers(recording.key)
|
request = utils.generate_s3_authorization_headers(recording.key)
|
||||||
|
|
||||||
return drf_response.Response("authorized", headers=request.headers, status=200)
|
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,15 +14,9 @@ FILE_EXT_REGEX = r"[a-zA-Z0-9]{1,10}"
|
|||||||
|
|
||||||
# pylint: disable=line-too-long
|
# pylint: disable=line-too-long
|
||||||
RECORDING_STORAGE_URL_PATTERN = re.compile(
|
RECORDING_STORAGE_URL_PATTERN = re.compile(
|
||||||
f"{settings.MEDIA_URL:s}{settings.RECORDING_OUTPUT_FOLDER}/(?P<recording_id>{UUID_REGEX:s})\.(?P<extension>{FILE_EXT_REGEX:s})"
|
f"/media/{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
|
# 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 choice of languages which should not be limited to the few languages active in
|
||||||
# the app.
|
# the app.
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ class ApplicationJWTAuthentication(BaseJWTAuthentication):
|
|||||||
logger.warning("Application not found: %s", client_id)
|
logger.warning("Application not found: %s", client_id)
|
||||||
raise exceptions.AuthenticationFailed("Application not found.") from e
|
raise exceptions.AuthenticationFailed("Application not found.") from e
|
||||||
|
|
||||||
if not application.is_active:
|
if not application.active:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Inactive application attempted authentication: %s", client_id
|
"Inactive application attempted authentication: %s", client_id
|
||||||
)
|
)
|
||||||
@@ -214,6 +214,25 @@ class ApplicationJWTAuthentication(BaseJWTAuthentication):
|
|||||||
raise exceptions.AuthenticationFailed("Invalid token type.")
|
raise exceptions.AuthenticationFailed("Invalid token type.")
|
||||||
|
|
||||||
|
|
||||||
|
class AddonsJWTAuthentication(BaseJWTAuthentication):
|
||||||
|
"""JWT authentication for addons API access.
|
||||||
|
|
||||||
|
Validates JWT tokens issued by addons for authenticating users.
|
||||||
|
Tokens must include user_id to identify the authenticated user.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
"""Initialize authentication backend with application JWT settings from Django settings."""
|
||||||
|
super().__init__(
|
||||||
|
secret_key=settings.ADDONS_JWT_SECRET_KEY,
|
||||||
|
algorithm=settings.ADDONS_JWT_ALG,
|
||||||
|
issuer=settings.ADDONS_JWT_ISSUER,
|
||||||
|
audience=settings.ADDONS_JWT_AUDIENCE,
|
||||||
|
expiration_seconds=settings.ADDONS_JWT_EXPIRATION_SECONDS,
|
||||||
|
token_type=settings.ADDONS_JWT_TOKEN_TYPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ResourceServerBackend(LaSuiteBackend):
|
class ResourceServerBackend(LaSuiteBackend):
|
||||||
"""OIDC Resource Server backend for user creation and retrieval."""
|
"""OIDC Resource Server backend for user creation and retrieval."""
|
||||||
|
|
||||||
|
|||||||
@@ -61,12 +61,12 @@ class ApplicationViewSet(viewsets.ViewSet):
|
|||||||
except models.Application.DoesNotExist as e:
|
except models.Application.DoesNotExist as e:
|
||||||
raise drf_exceptions.AuthenticationFailed("Invalid credentials") from 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):
|
if not check_password(client_secret, application.client_secret):
|
||||||
raise drf_exceptions.AuthenticationFailed("Invalid credentials")
|
raise drf_exceptions.AuthenticationFailed("Invalid credentials")
|
||||||
|
|
||||||
if not application.is_active:
|
|
||||||
raise drf_exceptions.AuthenticationFailed("Application is inactive")
|
|
||||||
|
|
||||||
email = serializer.validated_data["scope"]
|
email = serializer.validated_data["scope"]
|
||||||
try:
|
try:
|
||||||
validate_email(email)
|
validate_email(email)
|
||||||
@@ -173,6 +173,7 @@ class RoomViewSet(
|
|||||||
|
|
||||||
authentication_classes = [
|
authentication_classes = [
|
||||||
authentication.ApplicationJWTAuthentication,
|
authentication.ApplicationJWTAuthentication,
|
||||||
|
authentication.AddonsJWTAuthentication,
|
||||||
ResourceServerAuthentication,
|
ResourceServerAuthentication,
|
||||||
]
|
]
|
||||||
permission_classes = [
|
permission_classes = [
|
||||||
|
|||||||
@@ -2,11 +2,8 @@
|
|||||||
Core application factories
|
Core application factories
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from io import BytesIO
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth.hashers import make_password
|
from django.contrib.auth.hashers import make_password
|
||||||
from django.core.files.storage import default_storage
|
|
||||||
from django.utils.text import slugify
|
from django.utils.text import slugify
|
||||||
|
|
||||||
import factory.fuzzy
|
import factory.fuzzy
|
||||||
@@ -129,7 +126,7 @@ class ApplicationFactory(factory.django.DjangoModelFactory):
|
|||||||
model = models.Application
|
model = models.Application
|
||||||
|
|
||||||
name = factory.Faker("company")
|
name = factory.Faker("company")
|
||||||
is_active = True
|
active = True
|
||||||
client_id = factory.LazyFunction(utils.generate_client_id)
|
client_id = factory.LazyFunction(utils.generate_client_id)
|
||||||
client_secret = factory.LazyFunction(utils.generate_client_secret)
|
client_secret = factory.LazyFunction(utils.generate_client_secret)
|
||||||
scopes = []
|
scopes = []
|
||||||
@@ -156,42 +153,3 @@ class ApplicationDomainFactory(factory.django.DjangoModelFactory):
|
|||||||
|
|
||||||
domain = factory.Faker("domain_name")
|
domain = factory.Faker("domain_name")
|
||||||
application = factory.SubFactory(ApplicationFactory)
|
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))
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ class Migration(migrations.Migration):
|
|||||||
('sub', models.CharField(blank=True, help_text='Optional for pending users; required upon account activation. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
|
('sub', models.CharField(blank=True, help_text='Optional for pending users; required upon account activation. 255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only.', max_length=255, null=True, unique=True, validators=[django.core.validators.RegexValidator(message='Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/_ characters.', regex='^[\\w.@+-]+\\Z')], verbose_name='sub')),
|
||||||
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
|
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
|
||||||
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
|
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
|
||||||
('language', models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
|
('language', models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
|
||||||
('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)),
|
('timezone', timezone_field.fields.TimeZoneField(choices_display='WITH_GMT_OFFSET', default='UTC', help_text='The timezone in which the user wants to see times.', use_pytz=False)),
|
||||||
('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')),
|
('is_device', models.BooleanField(default=False, help_text='Whether the user is a device or a real user.', verbose_name='device')),
|
||||||
('is_staff', models.BooleanField(default=False, help_text='Whether the user can log into this admin site.', verbose_name='staff status')),
|
('is_staff', models.BooleanField(default=False, help_text='Whether the user can log into this admin site.', verbose_name='staff status')),
|
||||||
@@ -96,7 +96,7 @@ class Migration(migrations.Migration):
|
|||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='resource',
|
model_name='resource',
|
||||||
name='users',
|
name='users',
|
||||||
field=models.ManyToManyField(related_name='resources', through='core.ResourceAccess', through_fields=('resource', 'user'), to=settings.AUTH_USER_MODEL),
|
field=models.ManyToManyField(related_name='resources', through='core.ResourceAccess', to=settings.AUTH_USER_MODEL),
|
||||||
),
|
),
|
||||||
migrations.AddConstraint(
|
migrations.AddConstraint(
|
||||||
model_name='resourceaccess',
|
model_name='resourceaccess',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Generated by Django 5.0.7 on 2024-08-07 14:39
|
# Generated by Django 5.0.7 on 2024-08-07 14:39
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
@@ -13,6 +13,6 @@ class Migration(migrations.Migration):
|
|||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='user',
|
model_name='user',
|
||||||
name='language',
|
name='language',
|
||||||
field=models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# Generated by Django 5.1.8 on 2025-04-22 14:52
|
# Generated by Django 5.1.8 on 2025-04-22 14:52
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
@@ -13,6 +13,6 @@ class Migration(migrations.Migration):
|
|||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='user',
|
model_name='user',
|
||||||
name='language',
|
name='language',
|
||||||
field=models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('nl-nl', 'Dutch'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
# 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')],
|
|
||||||
},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# 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',
|
|
||||||
),
|
|
||||||
]
|
|
||||||
+2
-188
@@ -1,14 +1,11 @@
|
|||||||
"""
|
"""
|
||||||
Declare and configure the models for the Meet core application
|
Declare and configure the models for the Meet core application
|
||||||
# pylint: disable=too-many-lines
|
|
||||||
"""
|
"""
|
||||||
# pylint: disable=too-many-lines
|
|
||||||
|
|
||||||
import secrets
|
import secrets
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
from os.path import splitext
|
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -17,7 +14,7 @@ from django.contrib.auth.base_user import AbstractBaseUser
|
|||||||
from django.contrib.postgres.fields import ArrayField
|
from django.contrib.postgres.fields import ArrayField
|
||||||
from django.core import mail, validators
|
from django.core import mail, validators
|
||||||
from django.core.exceptions import PermissionDenied, ValidationError
|
from django.core.exceptions import PermissionDenied, ValidationError
|
||||||
from django.db import models, transaction
|
from django.db import models
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.text import capfirst, slugify
|
from django.utils.text import capfirst, slugify
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
@@ -759,7 +756,7 @@ class Application(BaseModel):
|
|||||||
verbose_name=_("Application name"),
|
verbose_name=_("Application name"),
|
||||||
help_text=_("Descriptive name for this application."),
|
help_text=_("Descriptive name for this application."),
|
||||||
)
|
)
|
||||||
is_active = models.BooleanField(default=True)
|
active = models.BooleanField(default=True)
|
||||||
client_id = models.CharField(
|
client_id = models.CharField(
|
||||||
max_length=100, unique=True, default=utils.generate_client_id
|
max_length=100, unique=True, default=utils.generate_client_id
|
||||||
)
|
)
|
||||||
@@ -832,186 +829,3 @@ class ApplicationDomain(BaseModel):
|
|||||||
|
|
||||||
self.domain = self.domain.lower().strip()
|
self.domain = self.domain.lower().strip()
|
||||||
super().save(*args, **kwargs)
|
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"])
|
|
||||||
|
|||||||
@@ -167,7 +167,6 @@ class NotificationService:
|
|||||||
owner_access.user.timezone
|
owner_access.user.timezone
|
||||||
).strftime("%H:%M"),
|
).strftime("%H:%M"),
|
||||||
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
|
"download_link": f"{get_recording_download_base_url()}/{recording.id}",
|
||||||
"context_language": owner_access.user.language,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
headers = {
|
headers = {
|
||||||
|
|||||||
@@ -9,8 +9,6 @@ from typing import Any, Dict, Optional, Protocol
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils.module_loading import import_string
|
from django.utils.module_loading import import_string
|
||||||
|
|
||||||
from core.enums import FILE_EXT_REGEX, UUID_REGEX
|
|
||||||
|
|
||||||
from .exceptions import (
|
from .exceptions import (
|
||||||
InvalidBucketError,
|
InvalidBucketError,
|
||||||
InvalidFilepathError,
|
InvalidFilepathError,
|
||||||
@@ -88,7 +86,7 @@ class MinioParser:
|
|||||||
|
|
||||||
# pylint: disable=line-too-long
|
# pylint: disable=line-too-long
|
||||||
self._filepath_regex = re.compile(
|
self._filepath_regex = re.compile(
|
||||||
rf"(?P<url_encoded_folder_path>(?:[^%]+%2F)+)?{settings.RECORDING_OUTPUT_FOLDER}%2F(?P<recording_id>{UUID_REGEX})\.(?P<extension>{FILE_EXT_REGEX})"
|
r"(?P<url_encoded_folder_path>(?:[^%]+%2F)+)?(?P<recording_id>[0-9a-fA-F\-]{36})\.(?P<extension>[a-zA-Z0-9]+)"
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class RecordingEventsError(Exception):
|
|||||||
|
|
||||||
|
|
||||||
class RecordingEventsService:
|
class RecordingEventsService:
|
||||||
"""Handles recording-related LiveKit webhook events."""
|
"""Handles recording-related Livekit webhook events."""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def handle_update(recording, egress_status):
|
def handle_update(recording, egress_status):
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ class LobbyService:
|
|||||||
) -> Tuple[LobbyParticipant, Optional[Dict]]:
|
) -> Tuple[LobbyParticipant, Optional[Dict]]:
|
||||||
"""Request entry to a room for a participant.
|
"""Request entry to a room for a participant.
|
||||||
|
|
||||||
The usual status transitions are:
|
This usual status transitions is:
|
||||||
UNKNOWN -> WAITING -> (ACCEPTED | DENIED)
|
UNKNOWN -> WAITING -> (ACCEPTED | DENIED)
|
||||||
|
|
||||||
Flow:
|
Flow:
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
# 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",)
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
"""
|
|
||||||
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()
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
{% get_current_language as LANGUAGE %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="{{ LANGUAGE }}">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{% trans "Error" %}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>{{ title|default:_("Error") }}</h1>
|
||||||
|
<p>{{ message|default:_("Something went wrong.") }}</p>
|
||||||
|
<button onclick="window.close()">{% trans "Close" %}</button>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
{% get_current_language as LANGUAGE %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="{{ LANGUAGE }}">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>{% trans "Authentication Success" %}</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script>
|
||||||
|
window.close();
|
||||||
|
</script>
|
||||||
|
<p>{% trans "Session stored successfully. This window will close automatically." %}</p>
|
||||||
|
<p>{% trans "If it doesn't close" %}, <a href="javascript:window.close()">{% trans "click here" %}</a>.</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Custom template tags for the core application of Meet."""
|
"""Custom template tags for the core application of People."""
|
||||||
|
|
||||||
import base64
|
import base64
|
||||||
|
|
||||||
|
|||||||
@@ -1,385 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
"""
|
|
||||||
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 == []
|
|
||||||
@@ -1,216 +0,0 @@
|
|||||||
"""
|
|
||||||
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."]
|
|
||||||
}
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
"""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)
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
"""
|
|
||||||
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
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
"""
|
|
||||||
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"
|
|
||||||
@@ -1,272 +0,0 @@
|
|||||||
"""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": {
|
"s3": {
|
||||||
"bucket": {"name": "test-bucket"},
|
"bucket": {"name": "test-bucket"},
|
||||||
"object": {
|
"object": {
|
||||||
"key": "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
"key": "recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||||
"contentType": "audio/ogg",
|
"contentType": "audio/ogg",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -51,7 +51,7 @@ def test_parse_valid_event(minio_parser, valid_minio_event):
|
|||||||
"""Test parsing a valid Minio event."""
|
"""Test parsing a valid Minio event."""
|
||||||
event = minio_parser.parse(valid_minio_event)
|
event = minio_parser.parse(valid_minio_event)
|
||||||
assert isinstance(event, StorageEvent)
|
assert isinstance(event, StorageEvent)
|
||||||
assert event.filepath == "recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg"
|
assert event.filepath == "recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg"
|
||||||
assert event.filetype == "audio/ogg"
|
assert event.filetype == "audio/ogg"
|
||||||
assert event.bucket_name == "test-bucket"
|
assert event.bucket_name == "test-bucket"
|
||||||
assert event.metadata is None
|
assert event.metadata is None
|
||||||
@@ -130,13 +130,11 @@ def test_validate_invalid_filetype(minio_parser):
|
|||||||
"invalid_filepath",
|
"invalid_filepath",
|
||||||
[
|
[
|
||||||
"invalid_filepath", # totally invalid string
|
"invalid_filepath", # totally invalid string
|
||||||
"recordings/46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
"recording/46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||||
"recordings/46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing extension
|
"recording/46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing extension
|
||||||
"46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing url_encoded_folder_path and extension
|
"46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing url_encoded_folder_path and extension
|
||||||
"", # empty string
|
"", # empty string
|
||||||
"46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg", # no folder at all
|
"recording%2F46d1a1212426484d8fb309b5d886f7a8.ogg",
|
||||||
"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):
|
def test_validate_invalid_filepath(invalid_filepath, minio_parser):
|
||||||
@@ -154,7 +152,7 @@ def test_validate_invalid_filepath(invalid_filepath, minio_parser):
|
|||||||
def test_validate_valid_event(minio_parser):
|
def test_validate_valid_event(minio_parser):
|
||||||
"""Test validation with valid event data."""
|
"""Test validation with valid event data."""
|
||||||
event = StorageEvent(
|
event = StorageEvent(
|
||||||
filepath="recordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||||
filetype="audio/ogg",
|
filetype="audio/ogg",
|
||||||
bucket_name="test-bucket",
|
bucket_name="test-bucket",
|
||||||
metadata=None,
|
metadata=None,
|
||||||
@@ -172,7 +170,7 @@ def test_get_recording_id_success(minio_parser, valid_minio_event):
|
|||||||
def test_validate_filepath_with_folder(minio_parser):
|
def test_validate_filepath_with_folder(minio_parser):
|
||||||
"""Test validation of filepath with folder structure."""
|
"""Test validation of filepath with folder structure."""
|
||||||
event = StorageEvent(
|
event = StorageEvent(
|
||||||
filepath="parent_folder%2Frecordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
filepath="parent_folder%2Ffolder%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||||
filetype="audio/ogg",
|
filetype="audio/ogg",
|
||||||
bucket_name="test-bucket",
|
bucket_name="test-bucket",
|
||||||
metadata=None,
|
metadata=None,
|
||||||
@@ -221,7 +219,7 @@ def test_validate_custom_filetypes():
|
|||||||
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes={"audio/mp3"})
|
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes={"audio/mp3"})
|
||||||
|
|
||||||
event = StorageEvent(
|
event = StorageEvent(
|
||||||
filepath="parent_folder%2Frecordings%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
filepath="parent_folder%2Ffolder%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||||
filetype="audio/mp3",
|
filetype="audio/mp3",
|
||||||
bucket_name="test-bucket",
|
bucket_name="test-bucket",
|
||||||
metadata=None,
|
metadata=None,
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ from ...factories import RecordingFactory
|
|||||||
from ...models import Recording, RecordingStatusChoices
|
from ...models import Recording, RecordingStatusChoices
|
||||||
from ...recording.event.exceptions import (
|
from ...recording.event.exceptions import (
|
||||||
InvalidBucketError,
|
InvalidBucketError,
|
||||||
InvalidFilepathError,
|
|
||||||
InvalidFileTypeError,
|
InvalidFileTypeError,
|
||||||
ParsingEventDataError,
|
ParsingEventDataError,
|
||||||
)
|
)
|
||||||
@@ -133,28 +132,7 @@ def test_save_recording_filetype_error(recording_settings, mock_get_parser):
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {"message": "Notification ignored."}
|
assert response.json() == {"message": "Ignore this file type, unsupported '.json'"}
|
||||||
|
|
||||||
|
|
||||||
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):
|
def test_save_recording_unknown_recording(recording_settings, mock_get_parser, client):
|
||||||
|
|||||||
@@ -120,39 +120,3 @@ def test_api_rooms_list_authenticated_distinct():
|
|||||||
content = response.json()
|
content = response.json()
|
||||||
assert len(content["results"]) == 1
|
assert len(content["results"]) == 1
|
||||||
assert content["results"][0]["id"] == str(room.id)
|
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,109 +631,3 @@ def test_list_waiting_participants_empty(settings):
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {"participants": []}
|
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
|
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import random
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from django.core.exceptions import SuspiciousOperation
|
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -133,7 +132,11 @@ def test_update_participant_success(mock_livekit_client):
|
|||||||
1,
|
1,
|
||||||
2,
|
2,
|
||||||
], # [TrackSource.CAMERA, TrackSource.MICROPHONE]
|
], # [TrackSource.CAMERA, TrackSource.MICROPHONE]
|
||||||
|
"hidden": False,
|
||||||
|
"recorder": False,
|
||||||
"can_update_metadata": True,
|
"can_update_metadata": True,
|
||||||
|
"agent": False,
|
||||||
|
"can_subscribe_metrics": False,
|
||||||
},
|
},
|
||||||
"name": "John Doe",
|
"name": "John Doe",
|
||||||
}
|
}
|
||||||
@@ -148,151 +151,6 @@ def test_update_participant_success(mock_livekit_client):
|
|||||||
mock_livekit_client.aclose.assert_called_once()
|
mock_livekit_client.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"permission_payload",
|
|
||||||
[
|
|
||||||
{}, # empty dict is valid
|
|
||||||
{"can_subscribe": True},
|
|
||||||
{"can_publish": True},
|
|
||||||
{"can_publish_data": True},
|
|
||||||
{"can_publish_sources": [1, 2]},
|
|
||||||
{"can_update_metadata": True},
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_update_participant_permission_fields_are_optional(
|
|
||||||
mock_livekit_client, permission_payload
|
|
||||||
):
|
|
||||||
"""Test that each required permission field can be passed individually."""
|
|
||||||
client = APIClient()
|
|
||||||
room = RoomFactory()
|
|
||||||
user = UserFactory()
|
|
||||||
UserResourceAccessFactory(
|
|
||||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
|
||||||
)
|
|
||||||
client.force_authenticate(user=user)
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"participant_identity": str(uuid4()),
|
|
||||||
"permission": permission_payload,
|
|
||||||
}
|
|
||||||
|
|
||||||
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
|
||||||
response = client.post(url, payload, format="json")
|
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_200_OK
|
|
||||||
assert response.data == {"status": "success"}
|
|
||||||
|
|
||||||
mock_livekit_client.room.update_participant.assert_called_once()
|
|
||||||
mock_livekit_client.aclose.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
"value,permission_key",
|
|
||||||
[
|
|
||||||
(False, "hidden"),
|
|
||||||
(True, "hidden"),
|
|
||||||
(False, "recorder"),
|
|
||||||
(True, "recorder"),
|
|
||||||
(False, "agent"),
|
|
||||||
(True, "agent"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@mock.patch("core.api.serializers.SuspiciousOperation", side_effect=SuspiciousOperation)
|
|
||||||
def test_update_participant_suspicious_permission(
|
|
||||||
mock_suspicious, value, permission_key
|
|
||||||
):
|
|
||||||
"""Test update participant raises 400 when a restricted permission is set."""
|
|
||||||
client = APIClient()
|
|
||||||
room = RoomFactory()
|
|
||||||
user = UserFactory()
|
|
||||||
UserResourceAccessFactory(
|
|
||||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
|
||||||
)
|
|
||||||
client.force_authenticate(user=user)
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"participant_identity": str(uuid4()),
|
|
||||||
"permission": {
|
|
||||||
"can_subscribe": True,
|
|
||||||
"can_publish": True,
|
|
||||||
"can_publish_data": True,
|
|
||||||
"can_update_metadata": False,
|
|
||||||
permission_key: value,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
|
||||||
response = client.post(url, payload, format="json")
|
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
|
||||||
mock_suspicious.assert_called_once_with(
|
|
||||||
f"Setting the following participant permissions is not allowed: {permission_key}."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.api.serializers.SuspiciousOperation", side_effect=SuspiciousOperation)
|
|
||||||
def test_update_participant_suspicious_permission_multiple(mock_suspicious):
|
|
||||||
"""Test update participant raises 400 when multiple suspicious permissions are set."""
|
|
||||||
client = APIClient()
|
|
||||||
room = RoomFactory()
|
|
||||||
user = UserFactory()
|
|
||||||
UserResourceAccessFactory(
|
|
||||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
|
||||||
)
|
|
||||||
client.force_authenticate(user=user)
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"participant_identity": str(uuid4()),
|
|
||||||
"permission": {
|
|
||||||
"can_subscribe": True,
|
|
||||||
"can_publish": True,
|
|
||||||
"can_publish_data": True,
|
|
||||||
"hidden": True,
|
|
||||||
"recorder": False,
|
|
||||||
"can_update_metadata": False,
|
|
||||||
"agent": True,
|
|
||||||
"can_subscribe_metrics": False,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
|
||||||
response = client.post(url, payload, format="json")
|
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
|
||||||
mock_suspicious.assert_called_once_with(
|
|
||||||
"Setting the following participant permissions is not allowed: hidden, recorder, agent."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("value", (False, True))
|
|
||||||
def test_update_participant_unimplemented_can_subscribe_metrics(value):
|
|
||||||
"""Test update participant raises 400 when can_subscribe_metrics is set."""
|
|
||||||
client = APIClient()
|
|
||||||
room = RoomFactory()
|
|
||||||
user = UserFactory()
|
|
||||||
UserResourceAccessFactory(
|
|
||||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
|
||||||
)
|
|
||||||
client.force_authenticate(user=user)
|
|
||||||
|
|
||||||
payload = {
|
|
||||||
"participant_identity": str(uuid4()),
|
|
||||||
"permission": {
|
|
||||||
"can_subscribe": True,
|
|
||||||
"can_publish": True,
|
|
||||||
"can_publish_data": True,
|
|
||||||
"can_update_metadata": False,
|
|
||||||
"can_subscribe_metrics": value,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
|
||||||
response = client.post(url, payload, format="json")
|
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
|
||||||
assert "can_subscribe_metrics" in str(response.data)
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_participant_forbidden_without_access():
|
def test_update_participant_forbidden_without_access():
|
||||||
"""Test update participant returns 403 when user lacks room privileges."""
|
"""Test update participant returns 403 when user lacks room privileges."""
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
@@ -368,17 +226,7 @@ def test_update_participant_invalid_permission():
|
|||||||
response = client.post(url, payload, format="json")
|
response = client.post(url, payload, format="json")
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
assert response.json() == {
|
assert "Invalid permission" in str(response.data)
|
||||||
"permission": [
|
|
||||||
{
|
|
||||||
"type": "extra_forbidden",
|
|
||||||
"loc": ["invalid-attributes"],
|
|
||||||
"msg": "Extra inputs are not permitted",
|
|
||||||
"input": "True",
|
|
||||||
"url": "https://errors.pydantic.dev/2.12/v/extra_forbidden",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_participant_wrong_metadata_attributes():
|
def test_update_participant_wrong_metadata_attributes():
|
||||||
|
|||||||
@@ -199,308 +199,3 @@ def test_start_recording_success(
|
|||||||
access = recording.accesses.first()
|
access = recording.accesses.first()
|
||||||
assert access.user == user
|
assert access.user == user
|
||||||
assert access.role == "owner"
|
assert access.role == "owner"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("value", ["fr", "en", "nl", "de"])
|
|
||||||
def test_start_recording_options_language_valid(
|
|
||||||
settings, mock_worker_service_factory, mock_worker_manager, value
|
|
||||||
):
|
|
||||||
"""Should accept a valid ISO 639-1 language code."""
|
|
||||||
settings.RECORDING_ENABLE = True
|
|
||||||
room = RoomFactory()
|
|
||||||
user = UserFactory()
|
|
||||||
room.accesses.create(user=user, role="owner")
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
|
||||||
{"mode": "screen_recording", "options": {"language": value}},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
|
|
||||||
recording = Recording.objects.get(room=room)
|
|
||||||
assert recording.options == {"language": value}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("value", ["invalid-value", "francais", "123"])
|
|
||||||
def test_start_recording_options_language_not_validated(
|
|
||||||
settings, mock_worker_service_factory, mock_worker_manager, value
|
|
||||||
):
|
|
||||||
"""Invalid language codes are currently accepted — no format validation yet.
|
|
||||||
|
|
||||||
TODO: tighten this once language validation is introduced.
|
|
||||||
"""
|
|
||||||
settings.RECORDING_ENABLE = True
|
|
||||||
room = RoomFactory()
|
|
||||||
user = UserFactory()
|
|
||||||
room.accesses.create(user=user, role="owner")
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
|
||||||
{"mode": "screen_recording", "options": {"language": value}},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
|
|
||||||
|
|
||||||
def test_start_recording_options_language_null(
|
|
||||||
settings, mock_worker_service_factory, mock_worker_manager
|
|
||||||
):
|
|
||||||
"""Should accept null language (triggers auto-detection)."""
|
|
||||||
settings.RECORDING_ENABLE = True
|
|
||||||
room = RoomFactory()
|
|
||||||
user = UserFactory()
|
|
||||||
room.accesses.create(user=user, role="owner")
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
|
||||||
{"mode": "screen_recording", "options": {"language": None}},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
recording = Recording.objects.get(room=room)
|
|
||||||
assert recording.options == {}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("value", [True, 1, "y", "on", "true", "yes", "t"])
|
|
||||||
def test_start_recording_options_transcribe_valid_true(
|
|
||||||
settings, mock_worker_service_factory, mock_worker_manager, value
|
|
||||||
):
|
|
||||||
"""Should accept transcribe with any valid pydantic true values."""
|
|
||||||
settings.RECORDING_ENABLE = True
|
|
||||||
room = RoomFactory()
|
|
||||||
user = UserFactory()
|
|
||||||
room.accesses.create(user=user, role="owner")
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
|
||||||
{"mode": "screen_recording", "options": {"transcribe": value}},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
recording = Recording.objects.get(room=room)
|
|
||||||
assert recording.options == {"transcribe": True}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("value", [False, 0, "n", "off", "false", "no", "f"])
|
|
||||||
def test_start_recording_options_transcribe_valid_false(
|
|
||||||
settings, mock_worker_service_factory, mock_worker_manager, value
|
|
||||||
):
|
|
||||||
"""Should accept transcribe with any valid pydantic false values."""
|
|
||||||
settings.RECORDING_ENABLE = True
|
|
||||||
room = RoomFactory()
|
|
||||||
user = UserFactory()
|
|
||||||
room.accesses.create(user=user, role="owner")
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
|
||||||
{"mode": "screen_recording", "options": {"transcribe": value}},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
recording = Recording.objects.get(room=room)
|
|
||||||
assert recording.options == {"transcribe": False}
|
|
||||||
|
|
||||||
|
|
||||||
def test_start_recording_options_transcribe_null(
|
|
||||||
settings, mock_worker_service_factory, mock_worker_manager
|
|
||||||
):
|
|
||||||
"""Should accept transcribe=null (falls back to application default)."""
|
|
||||||
settings.RECORDING_ENABLE = True
|
|
||||||
room = RoomFactory()
|
|
||||||
user = UserFactory()
|
|
||||||
room.accesses.create(user=user, role="owner")
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
|
||||||
{"mode": "screen_recording", "options": {"transcribe": None}},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
recording = Recording.objects.get(room=room)
|
|
||||||
assert recording.options == {}
|
|
||||||
|
|
||||||
|
|
||||||
def test_start_recording_options_null(
|
|
||||||
settings, mock_worker_service_factory, mock_worker_manager
|
|
||||||
):
|
|
||||||
"""Should accept options=null."""
|
|
||||||
settings.RECORDING_ENABLE = True
|
|
||||||
room = RoomFactory()
|
|
||||||
user = UserFactory()
|
|
||||||
room.accesses.create(user=user, role="owner")
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
|
||||||
{"mode": "screen_recording", "options": None},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
recording = Recording.objects.get(room=room)
|
|
||||||
assert recording.options == {}
|
|
||||||
|
|
||||||
|
|
||||||
def test_start_recording_options_omitted(
|
|
||||||
settings, mock_worker_service_factory, mock_worker_manager
|
|
||||||
):
|
|
||||||
"""Should accept a request with no options field at all."""
|
|
||||||
settings.RECORDING_ENABLE = True
|
|
||||||
room = RoomFactory()
|
|
||||||
user = UserFactory()
|
|
||||||
room.accesses.create(user=user, role="owner")
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
|
||||||
{"mode": "screen_recording"},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
recording = Recording.objects.get(room=room)
|
|
||||||
assert recording.options == {}
|
|
||||||
|
|
||||||
|
|
||||||
def test_start_recording_options_unknown_field_rejected(settings):
|
|
||||||
"""Should reject unknown fields in options (extra='forbid')."""
|
|
||||||
settings.RECORDING_ENABLE = True
|
|
||||||
room = RoomFactory()
|
|
||||||
user = UserFactory()
|
|
||||||
room.accesses.create(user=user, role="owner")
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
|
||||||
{"mode": "screen_recording", "options": {"unknown_field": "value"}},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("value", ["foo", 12])
|
|
||||||
def test_start_recording_options_invalid_transcribe_type(settings, value):
|
|
||||||
"""Should reject non-boolean transcribe values."""
|
|
||||||
settings.RECORDING_ENABLE = True
|
|
||||||
room = RoomFactory()
|
|
||||||
user = UserFactory()
|
|
||||||
room.accesses.create(user=user, role="owner")
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
|
||||||
{"mode": "screen_recording", "options": {"transcribe": value}},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("value", ["screen_recording", "transcript"])
|
|
||||||
def test_start_recording_options_original_mode_valid(
|
|
||||||
settings, mock_worker_service_factory, mock_worker_manager, value
|
|
||||||
):
|
|
||||||
"""Should accept valid recording mode choices for original_mode."""
|
|
||||||
settings.RECORDING_ENABLE = True
|
|
||||||
room = RoomFactory()
|
|
||||||
user = UserFactory()
|
|
||||||
room.accesses.create(user=user, role="owner")
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
|
||||||
{"mode": "screen_recording", "options": {"original_mode": value}},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
recording = Recording.objects.get(room=room)
|
|
||||||
assert recording.options == {"original_mode": value}
|
|
||||||
|
|
||||||
|
|
||||||
def test_start_recording_options_original_mode_null(
|
|
||||||
settings, mock_worker_service_factory, mock_worker_manager
|
|
||||||
):
|
|
||||||
"""Should accept original_mode=null."""
|
|
||||||
settings.RECORDING_ENABLE = True
|
|
||||||
room = RoomFactory()
|
|
||||||
user = UserFactory()
|
|
||||||
room.accesses.create(user=user, role="owner")
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
|
||||||
{"mode": "screen_recording", "options": {"original_mode": None}},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
recording = Recording.objects.get(room=room)
|
|
||||||
assert recording.options == {}
|
|
||||||
|
|
||||||
|
|
||||||
def test_start_recording_options_original_mode_omitted(
|
|
||||||
settings, mock_worker_service_factory, mock_worker_manager
|
|
||||||
):
|
|
||||||
"""Should accept a request with original_mode omitted."""
|
|
||||||
settings.RECORDING_ENABLE = True
|
|
||||||
room = RoomFactory()
|
|
||||||
user = UserFactory()
|
|
||||||
room.accesses.create(user=user, role="owner")
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
|
||||||
{"mode": "screen_recording", "options": {}},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
recording = Recording.objects.get(room=room)
|
|
||||||
assert recording.options == {}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("value", ["invalid_mode", "foo", 123, "SCREEN_RECORDING"])
|
|
||||||
def test_start_recording_options_original_mode_invalid(settings, value):
|
|
||||||
"""Should reject invalid recording mode values for original_mode."""
|
|
||||||
settings.RECORDING_ENABLE = True
|
|
||||||
room = RoomFactory()
|
|
||||||
user = UserFactory()
|
|
||||||
room.accesses.create(user=user, role="owner")
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
|
||||||
{"mode": "screen_recording", "options": {"original_mode": value}},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
|
|||||||
@@ -904,7 +904,7 @@ def test_api_rooms_token_unknown_application(settings):
|
|||||||
|
|
||||||
def test_api_rooms_token_inactive_application(settings):
|
def test_api_rooms_token_inactive_application(settings):
|
||||||
"""Token for inactive application should be rejected."""
|
"""Token for inactive application should be rejected."""
|
||||||
application = ApplicationFactory(is_active=False)
|
application = ApplicationFactory(active=False)
|
||||||
|
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
payload = {
|
payload = {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ def test_api_applications_generate_token_success(settings):
|
|||||||
"""Valid credentials should return a JWT token."""
|
"""Valid credentials should return a JWT token."""
|
||||||
UserFactory(email="User.Family@example.com")
|
UserFactory(email="User.Family@example.com")
|
||||||
application = ApplicationFactory(
|
application = ApplicationFactory(
|
||||||
is_active=True,
|
active=True,
|
||||||
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
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():
|
def test_api_applications_generate_token_invalid_client_secret():
|
||||||
"""Invalid client_secret should return 401."""
|
"""Invalid client_secret should return 401."""
|
||||||
user = UserFactory(email="user@example.com")
|
user = UserFactory(email="user@example.com")
|
||||||
application = ApplicationFactory(is_active=True)
|
application = ApplicationFactory(active=True)
|
||||||
|
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -100,7 +100,7 @@ def test_api_applications_generate_token_invalid_client_secret():
|
|||||||
def test_api_applications_generate_token_inactive_application():
|
def test_api_applications_generate_token_inactive_application():
|
||||||
"""Inactive application should return 401."""
|
"""Inactive application should return 401."""
|
||||||
user = UserFactory(email="user@example.com")
|
user = UserFactory(email="user@example.com")
|
||||||
application = ApplicationFactory(is_active=False)
|
application = ApplicationFactory(active=False)
|
||||||
|
|
||||||
plain_secret = "test-secret-123"
|
plain_secret = "test-secret-123"
|
||||||
application.client_secret = plain_secret
|
application.client_secret = plain_secret
|
||||||
@@ -122,31 +122,9 @@ def test_api_applications_generate_token_inactive_application():
|
|||||||
assert "Application is inactive" in str(response.data)
|
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():
|
def test_api_applications_generate_token_invalid_email_format():
|
||||||
"""Invalid email format should return 400."""
|
"""Invalid email format should return 400."""
|
||||||
application = ApplicationFactory(is_active=True)
|
application = ApplicationFactory(active=True)
|
||||||
|
|
||||||
plain_secret = "test-secret-123"
|
plain_secret = "test-secret-123"
|
||||||
application.client_secret = plain_secret
|
application.client_secret = plain_secret
|
||||||
@@ -171,7 +149,7 @@ def test_api_applications_generate_token_invalid_email_format():
|
|||||||
def test_api_applications_generate_token_domain_not_authorized():
|
def test_api_applications_generate_token_domain_not_authorized():
|
||||||
"""Application without domain authorization should return 403."""
|
"""Application without domain authorization should return 403."""
|
||||||
user = UserFactory(email="user@denied.com")
|
user = UserFactory(email="user@denied.com")
|
||||||
application = ApplicationFactory(is_active=True)
|
application = ApplicationFactory(active=True)
|
||||||
ApplicationDomainFactory(application=application, domain="allowed.com")
|
ApplicationDomainFactory(application=application, domain="allowed.com")
|
||||||
|
|
||||||
plain_secret = "test-secret-123"
|
plain_secret = "test-secret-123"
|
||||||
@@ -198,7 +176,7 @@ def test_api_applications_generate_token_domain_authorized():
|
|||||||
"""Application with domain authorization should succeed."""
|
"""Application with domain authorization should succeed."""
|
||||||
user = UserFactory(email="user@allowed.com")
|
user = UserFactory(email="user@allowed.com")
|
||||||
application = ApplicationFactory(
|
application = ApplicationFactory(
|
||||||
is_active=True,
|
active=True,
|
||||||
scopes=[ApplicationScope.ROOMS_LIST],
|
scopes=[ApplicationScope.ROOMS_LIST],
|
||||||
)
|
)
|
||||||
ApplicationDomainFactory(application=application, domain="allowed.com")
|
ApplicationDomainFactory(application=application, domain="allowed.com")
|
||||||
@@ -225,7 +203,7 @@ def test_api_applications_generate_token_domain_authorized():
|
|||||||
|
|
||||||
def test_api_applications_generate_token_user_not_found():
|
def test_api_applications_generate_token_user_not_found():
|
||||||
"""Non-existent user should return 404."""
|
"""Non-existent user should return 404."""
|
||||||
application = ApplicationFactory(is_active=True)
|
application = ApplicationFactory(active=True)
|
||||||
|
|
||||||
plain_secret = "test-secret-123"
|
plain_secret = "test-secret-123"
|
||||||
application.client_secret = plain_secret
|
application.client_secret = plain_secret
|
||||||
@@ -253,7 +231,7 @@ def test_api_applications_token_payload_structure(settings):
|
|||||||
user = UserFactory(email="user@example.com")
|
user = UserFactory(email="user@example.com")
|
||||||
|
|
||||||
application = ApplicationFactory(
|
application = ApplicationFactory(
|
||||||
is_active=True,
|
active=True,
|
||||||
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -306,7 +284,7 @@ def test_api_applications_token_new_user(settings):
|
|||||||
assert len(User.objects.all()) == 0
|
assert len(User.objects.all()) == 0
|
||||||
|
|
||||||
application = ApplicationFactory(
|
application = ApplicationFactory(
|
||||||
is_active=True,
|
active=True,
|
||||||
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -364,7 +342,7 @@ def test_api_applications_token_existing_user(settings):
|
|||||||
assert len(User.objects.all()) == 1
|
assert len(User.objects.all()) == 1
|
||||||
|
|
||||||
application = ApplicationFactory(
|
application = ApplicationFactory(
|
||||||
is_active=True,
|
active=True,
|
||||||
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ def test_models_application_name_maxlength():
|
|||||||
def test_models_application_active_default():
|
def test_models_application_active_default():
|
||||||
"""An application should be active by default."""
|
"""An application should be active by default."""
|
||||||
application = Application.objects.create(name="Test App")
|
application = Application.objects.create(name="Test App")
|
||||||
assert application.is_active is True
|
assert application.active is True
|
||||||
|
|
||||||
|
|
||||||
def test_models_application_scopes_default():
|
def test_models_application_scopes_default():
|
||||||
|
|||||||
@@ -1,166 +0,0 @@
|
|||||||
"""
|
|
||||||
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"
|
|
||||||
@@ -6,6 +6,8 @@ from django.urls import include, path
|
|||||||
from lasuite.oidc_login.urls import urlpatterns as oidc_urls
|
from lasuite.oidc_login.urls import urlpatterns as oidc_urls
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
|
from core.addons import views as addons_views
|
||||||
|
from core.addons import viewsets as addons_viewsets
|
||||||
from core.api import get_frontend_configuration, viewsets
|
from core.api import get_frontend_configuration, viewsets
|
||||||
from core.external_api import viewsets as external_viewsets
|
from core.external_api import viewsets as external_viewsets
|
||||||
|
|
||||||
@@ -14,7 +16,6 @@ router = DefaultRouter()
|
|||||||
router.register("users", viewsets.UserViewSet, basename="users")
|
router.register("users", viewsets.UserViewSet, basename="users")
|
||||||
router.register("rooms", viewsets.RoomViewSet, basename="rooms")
|
router.register("rooms", viewsets.RoomViewSet, basename="rooms")
|
||||||
router.register("recordings", viewsets.RecordingViewSet, basename="recordings")
|
router.register("recordings", viewsets.RecordingViewSet, basename="recordings")
|
||||||
router.register("files", viewsets.FileViewSet, basename="files")
|
|
||||||
router.register(
|
router.register(
|
||||||
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
|
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
|
||||||
)
|
)
|
||||||
@@ -27,12 +28,24 @@ external_router.register(
|
|||||||
basename="external_application",
|
basename="external_application",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# - Addons API
|
||||||
|
addons_router = DefaultRouter()
|
||||||
|
addons_router.register(
|
||||||
|
"addons/sessions",
|
||||||
|
addons_viewsets.AuthSessionViewSet,
|
||||||
|
basename="addons_auth_sessions",
|
||||||
|
)
|
||||||
|
|
||||||
external_router.register(
|
external_router.register(
|
||||||
"rooms",
|
"rooms",
|
||||||
external_viewsets.RoomViewSet,
|
external_viewsets.RoomViewSet,
|
||||||
basename="external_room",
|
basename="external_room",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
addons_urls = addons_router.urls if settings.ADDONS_ENABLED else []
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path(
|
path(
|
||||||
f"api/{settings.API_VERSION}/",
|
f"api/{settings.API_VERSION}/",
|
||||||
@@ -40,12 +53,26 @@ urlpatterns = [
|
|||||||
[
|
[
|
||||||
*router.urls,
|
*router.urls,
|
||||||
*oidc_urls,
|
*oidc_urls,
|
||||||
|
*addons_urls, # should be in external api
|
||||||
path("config/", get_frontend_configuration, name="config"),
|
path("config/", get_frontend_configuration, name="config"),
|
||||||
]
|
]
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if settings.ADDONS_ENABLED:
|
||||||
|
urlpatterns.append(
|
||||||
|
path(
|
||||||
|
"addons/",
|
||||||
|
include(
|
||||||
|
[
|
||||||
|
path("transit/", addons_views.transit_page, name="transit_page"),
|
||||||
|
path("redirect/", addons_views.redirect_page, name="redirect_page"),
|
||||||
|
]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
if settings.EXTERNAL_API_ENABLED:
|
if settings.EXTERNAL_API_ENABLED:
|
||||||
urlpatterns.append(
|
urlpatterns.append(
|
||||||
path(
|
path(
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ Utils functions used in the core app
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import logging
|
|
||||||
import mimetypes
|
|
||||||
import random
|
import random
|
||||||
import secrets
|
import secrets
|
||||||
import string
|
import string
|
||||||
@@ -19,9 +17,7 @@ from django.conf import settings
|
|||||||
from django.core.files.storage import default_storage
|
from django.core.files.storage import default_storage
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
import boto3
|
|
||||||
import botocore
|
import botocore
|
||||||
import magic
|
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
from livekit.api import ( # pylint: disable=E0611
|
from livekit.api import ( # pylint: disable=E0611
|
||||||
AccessToken,
|
AccessToken,
|
||||||
@@ -33,8 +29,6 @@ from livekit.api import ( # pylint: disable=E0611
|
|||||||
VideoGrants,
|
VideoGrants,
|
||||||
)
|
)
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
def generate_color(identity: str) -> str:
|
def generate_color(identity: str) -> str:
|
||||||
"""Generates a consistent HSL color based on a given identity string.
|
"""Generates a consistent HSL color based on a given identity string.
|
||||||
@@ -350,108 +344,3 @@ def generate_room_slug():
|
|||||||
for size in sizes
|
for size in sizes
|
||||||
]
|
]
|
||||||
return "-".join(parts)
|
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 ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-03-12 13:46+0000\n"
|
"POT-Creation-Date: 2026-01-26 15:40+0000\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -17,6 +17,30 @@ msgstr ""
|
|||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
|
||||||
|
#: core/addons/views.py:24
|
||||||
|
msgid "Session ID is required."
|
||||||
|
msgstr "Sitzungs-ID ist erforderlich."
|
||||||
|
|
||||||
|
#: core/addons/views.py:29
|
||||||
|
msgid "Session not found or expired."
|
||||||
|
msgstr "Sitzung nicht gefunden oder abgelaufen."
|
||||||
|
|
||||||
|
#: core/addons/views.py:32
|
||||||
|
msgid "Invalid session state."
|
||||||
|
msgstr "Ungültiger Sitzungsstatus."
|
||||||
|
|
||||||
|
#: core/addons/views.py:45
|
||||||
|
msgid "Authentication required."
|
||||||
|
msgstr "Authentifizierung erforderlich."
|
||||||
|
|
||||||
|
#: core/addons/views.py:50
|
||||||
|
msgid "No active session found."
|
||||||
|
msgstr "Keine aktive Sitzung gefunden."
|
||||||
|
|
||||||
|
#: core/addons/views.py:55
|
||||||
|
msgid "Invalid or expired session."
|
||||||
|
msgstr "Ungültige oder abgelaufene Sitzung."
|
||||||
|
|
||||||
#: core/admin.py:29
|
#: core/admin.py:29
|
||||||
msgid "Personal info"
|
msgid "Personal info"
|
||||||
msgstr "Persönliche Informationen"
|
msgstr "Persönliche Informationen"
|
||||||
@@ -29,168 +53,141 @@ msgstr "Berechtigungen"
|
|||||||
msgid "Important dates"
|
msgid "Important dates"
|
||||||
msgstr "Wichtige Daten"
|
msgstr "Wichtige Daten"
|
||||||
|
|
||||||
#: core/admin.py:132 core/admin.py:275
|
#: core/admin.py:128 core/admin.py:228
|
||||||
msgid "No owner"
|
msgid "No owner"
|
||||||
msgstr "Kein Eigentümer"
|
msgstr "Kein Eigentümer"
|
||||||
|
|
||||||
#: core/admin.py:135 core/admin.py:278
|
#: core/admin.py:131 core/admin.py:231
|
||||||
msgid "Multiple owners"
|
msgid "Multiple owners"
|
||||||
msgstr "Mehrere Eigentümer"
|
msgstr "Mehrere Eigentümer"
|
||||||
|
|
||||||
#: core/admin.py:148
|
#: core/admin.py:143
|
||||||
msgid "Resend notification to external service"
|
msgid "Resend notification to external service"
|
||||||
msgstr "Benachrichtigung erneut an externen Dienst senden"
|
msgstr "Benachrichtigung erneut an externen Dienst senden"
|
||||||
|
|
||||||
#: core/admin.py:171
|
#: core/admin.py:166
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s"
|
msgid "Failed to notify for recording %(id)s"
|
||||||
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen"
|
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen"
|
||||||
|
|
||||||
#: core/admin.py:179
|
#: core/admin.py:174
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||||
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen: %(error)s"
|
msgstr "Benachrichtigung für Aufnahme %(id)s fehlgeschlagen: %(error)s"
|
||||||
|
|
||||||
#: core/admin.py:187
|
#: core/admin.py:182
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||||
msgstr "Benachrichtigungen für %(count)s Aufnahme(n) erfolgreich gesendet."
|
msgstr "Benachrichtigungen für %(count)s Aufnahme(n) erfolgreich gesendet."
|
||||||
|
|
||||||
#: core/admin.py:195
|
#: core/admin.py:190
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Skipped %(count)s expired recording(s)."
|
msgid "Skipped %(count)s expired recording(s)."
|
||||||
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
|
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
|
||||||
|
|
||||||
#: core/admin.py:200
|
#: core/admin.py:294
|
||||||
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"
|
msgid "No scopes"
|
||||||
msgstr "Keine Scopes"
|
msgstr "Keine Scopes"
|
||||||
|
|
||||||
#: core/admin.py:344
|
#: core/admin.py:296
|
||||||
msgid "Scopes"
|
msgid "Scopes"
|
||||||
msgstr "Scopes"
|
msgstr "Scopes"
|
||||||
|
|
||||||
#: core/api/filters.py:25
|
#: core/api/serializers.py:68
|
||||||
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."
|
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
|
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
|
||||||
"hinzuzufügen."
|
"hinzuzufügen."
|
||||||
|
|
||||||
#: core/api/serializers.py:516
|
#: core/models.py:34
|
||||||
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"
|
msgid "Member"
|
||||||
msgstr "Mitglied"
|
msgstr "Mitglied"
|
||||||
|
|
||||||
#: core/models.py:38
|
#: core/models.py:35
|
||||||
msgid "Administrator"
|
msgid "Administrator"
|
||||||
msgstr "Administrator"
|
msgstr "Administrator"
|
||||||
|
|
||||||
#: core/models.py:39
|
#: core/models.py:36
|
||||||
msgid "Owner"
|
msgid "Owner"
|
||||||
msgstr "Eigentümer"
|
msgstr "Eigentümer"
|
||||||
|
|
||||||
#: core/models.py:55
|
#: core/models.py:52
|
||||||
msgid "Initiated"
|
msgid "Initiated"
|
||||||
msgstr "Gestartet"
|
msgstr "Gestartet"
|
||||||
|
|
||||||
#: core/models.py:56
|
#: core/models.py:53
|
||||||
msgid "Active"
|
msgid "Active"
|
||||||
msgstr "Aktiv"
|
msgstr "Aktiv"
|
||||||
|
|
||||||
#: core/models.py:57
|
#: core/models.py:54
|
||||||
msgid "Stopped"
|
msgid "Stopped"
|
||||||
msgstr "Beendet"
|
msgstr "Beendet"
|
||||||
|
|
||||||
#: core/models.py:58
|
#: core/models.py:55
|
||||||
msgid "Saved"
|
msgid "Saved"
|
||||||
msgstr "Gespeichert"
|
msgstr "Gespeichert"
|
||||||
|
|
||||||
#: core/models.py:59
|
#: core/models.py:56
|
||||||
msgid "Aborted"
|
msgid "Aborted"
|
||||||
msgstr "Abgebrochen"
|
msgstr "Abgebrochen"
|
||||||
|
|
||||||
#: core/models.py:60
|
#: core/models.py:57
|
||||||
msgid "Failed to Start"
|
msgid "Failed to Start"
|
||||||
msgstr "Start fehlgeschlagen"
|
msgstr "Start fehlgeschlagen"
|
||||||
|
|
||||||
#: core/models.py:61
|
#: core/models.py:58
|
||||||
msgid "Failed to Stop"
|
msgid "Failed to Stop"
|
||||||
msgstr "Stopp fehlgeschlagen"
|
msgstr "Stopp fehlgeschlagen"
|
||||||
|
|
||||||
#: core/models.py:62
|
#: core/models.py:59
|
||||||
msgid "Notification succeeded"
|
msgid "Notification succeeded"
|
||||||
msgstr "Benachrichtigung erfolgreich"
|
msgstr "Benachrichtigung erfolgreich"
|
||||||
|
|
||||||
#: core/models.py:89
|
#: core/models.py:86
|
||||||
msgid "SCREEN_RECORDING"
|
msgid "SCREEN_RECORDING"
|
||||||
msgstr "BILDSCHIRMAUFZEICHNUNG"
|
msgstr "BILDSCHIRMAUFZEICHNUNG"
|
||||||
|
|
||||||
#: core/models.py:90
|
#: core/models.py:87
|
||||||
msgid "TRANSCRIPT"
|
msgid "TRANSCRIPT"
|
||||||
msgstr "TRANSKRIPT"
|
msgstr "TRANSKRIPT"
|
||||||
|
|
||||||
#: core/models.py:96
|
#: core/models.py:93
|
||||||
msgid "Public Access"
|
msgid "Public Access"
|
||||||
msgstr "Öffentlicher Zugriff"
|
msgstr "Öffentlicher Zugriff"
|
||||||
|
|
||||||
#: core/models.py:97
|
#: core/models.py:94
|
||||||
msgid "Trusted Access"
|
msgid "Trusted Access"
|
||||||
msgstr "Vertrauenswürdiger Zugriff"
|
msgstr "Vertrauenswürdiger Zugriff"
|
||||||
|
|
||||||
#: core/models.py:98
|
#: core/models.py:95
|
||||||
msgid "Restricted Access"
|
msgid "Restricted Access"
|
||||||
msgstr "Eingeschränkter Zugriff"
|
msgstr "Eingeschränkter Zugriff"
|
||||||
|
|
||||||
#: core/models.py:110
|
#: core/models.py:107
|
||||||
msgid "id"
|
msgid "id"
|
||||||
msgstr "ID"
|
msgstr "ID"
|
||||||
|
|
||||||
#: core/models.py:111
|
#: core/models.py:108
|
||||||
msgid "primary key for the record as UUID"
|
msgid "primary key for the record as UUID"
|
||||||
msgstr "Primärschlüssel des Eintrags als UUID"
|
msgstr "Primärschlüssel des Eintrags als UUID"
|
||||||
|
|
||||||
#: core/models.py:117
|
#: core/models.py:114
|
||||||
msgid "created on"
|
msgid "created on"
|
||||||
msgstr "erstellt am"
|
msgstr "erstellt am"
|
||||||
|
|
||||||
#: core/models.py:118
|
#: core/models.py:115
|
||||||
msgid "date and time at which a record was created"
|
msgid "date and time at which a record was created"
|
||||||
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
|
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
|
||||||
|
|
||||||
#: core/models.py:123
|
#: core/models.py:120
|
||||||
msgid "updated on"
|
msgid "updated on"
|
||||||
msgstr "aktualisiert am"
|
msgstr "aktualisiert am"
|
||||||
|
|
||||||
#: core/models.py:124
|
#: core/models.py:121
|
||||||
msgid "date and time at which a record was last updated"
|
msgid "date and time at which a record was last updated"
|
||||||
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
|
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
|
||||||
|
|
||||||
#: core/models.py:144
|
#: core/models.py:141
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||||
"_ characters."
|
"_ characters."
|
||||||
@@ -198,11 +195,11 @@ msgstr ""
|
|||||||
"Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen "
|
"Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen "
|
||||||
"und die Zeichen @/./+/-/_ enthalten."
|
"und die Zeichen @/./+/-/_ enthalten."
|
||||||
|
|
||||||
#: core/models.py:150
|
#: core/models.py:147
|
||||||
msgid "sub"
|
msgid "sub"
|
||||||
msgstr "Sub"
|
msgstr "Sub"
|
||||||
|
|
||||||
#: core/models.py:152
|
#: core/models.py:149
|
||||||
msgid ""
|
msgid ""
|
||||||
"Optional for pending users; required upon account activation. 255 characters "
|
"Optional for pending users; required upon account activation. 255 characters "
|
||||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||||
@@ -210,55 +207,55 @@ msgstr ""
|
|||||||
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
|
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
|
||||||
"Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt."
|
"Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt."
|
||||||
|
|
||||||
#: core/models.py:161
|
#: core/models.py:158
|
||||||
msgid "identity email address"
|
msgid "identity email address"
|
||||||
msgstr "Identitäts-E-Mail-Adresse"
|
msgstr "Identitäts-E-Mail-Adresse"
|
||||||
|
|
||||||
#: core/models.py:166
|
#: core/models.py:163
|
||||||
msgid "admin email address"
|
msgid "admin email address"
|
||||||
msgstr "Administrator-E-Mail-Adresse"
|
msgstr "Administrator-E-Mail-Adresse"
|
||||||
|
|
||||||
#: core/models.py:168
|
#: core/models.py:165
|
||||||
msgid "full name"
|
msgid "full name"
|
||||||
msgstr "Vollständiger Name"
|
msgstr "Vollständiger Name"
|
||||||
|
|
||||||
#: core/models.py:170
|
#: core/models.py:167
|
||||||
msgid "short name"
|
msgid "short name"
|
||||||
msgstr "Kurzname"
|
msgstr "Kurzname"
|
||||||
|
|
||||||
#: core/models.py:176
|
#: core/models.py:173
|
||||||
msgid "language"
|
msgid "language"
|
||||||
msgstr "Sprache"
|
msgstr "Sprache"
|
||||||
|
|
||||||
#: core/models.py:177
|
#: core/models.py:174
|
||||||
msgid "The language in which the user wants to see the interface."
|
msgid "The language in which the user wants to see the interface."
|
||||||
msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
|
msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
|
||||||
|
|
||||||
#: core/models.py:183
|
#: core/models.py:180
|
||||||
msgid "The timezone in which the user wants to see times."
|
msgid "The timezone in which the user wants to see times."
|
||||||
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
|
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
|
||||||
|
|
||||||
#: core/models.py:186
|
#: core/models.py:183
|
||||||
msgid "device"
|
msgid "device"
|
||||||
msgstr "Gerät"
|
msgstr "Gerät"
|
||||||
|
|
||||||
#: core/models.py:188
|
#: core/models.py:185
|
||||||
msgid "Whether the user is a device or a real user."
|
msgid "Whether the user is a device or a real user."
|
||||||
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
|
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
|
||||||
|
|
||||||
#: core/models.py:191
|
#: core/models.py:188
|
||||||
msgid "staff status"
|
msgid "staff status"
|
||||||
msgstr "Mitarbeiterstatus"
|
msgstr "Mitarbeiterstatus"
|
||||||
|
|
||||||
#: core/models.py:193
|
#: core/models.py:190
|
||||||
msgid "Whether the user can log into this admin site."
|
msgid "Whether the user can log into this admin site."
|
||||||
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
|
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
|
||||||
|
|
||||||
#: core/models.py:196
|
#: core/models.py:193
|
||||||
msgid "active"
|
msgid "active"
|
||||||
msgstr "aktiv"
|
msgstr "aktiv"
|
||||||
|
|
||||||
#: core/models.py:199
|
#: core/models.py:196
|
||||||
msgid ""
|
msgid ""
|
||||||
"Whether this user should be treated as active. Unselect this instead of "
|
"Whether this user should be treated as active. Unselect this instead of "
|
||||||
"deleting accounts."
|
"deleting accounts."
|
||||||
@@ -266,66 +263,66 @@ msgstr ""
|
|||||||
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
|
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
|
||||||
"anstelle des Löschens des Kontos."
|
"anstelle des Löschens des Kontos."
|
||||||
|
|
||||||
#: core/models.py:212
|
#: core/models.py:209
|
||||||
msgid "user"
|
msgid "user"
|
||||||
msgstr "Benutzer"
|
msgstr "Benutzer"
|
||||||
|
|
||||||
#: core/models.py:213
|
#: core/models.py:210
|
||||||
msgid "users"
|
msgid "users"
|
||||||
msgstr "Benutzer"
|
msgstr "Benutzer"
|
||||||
|
|
||||||
#: core/models.py:272
|
#: core/models.py:269
|
||||||
msgid "Resource"
|
msgid "Resource"
|
||||||
msgstr "Ressource"
|
msgstr "Ressource"
|
||||||
|
|
||||||
#: core/models.py:273
|
#: core/models.py:270
|
||||||
msgid "Resources"
|
msgid "Resources"
|
||||||
msgstr "Ressourcen"
|
msgstr "Ressourcen"
|
||||||
|
|
||||||
#: core/models.py:331
|
#: core/models.py:324
|
||||||
msgid "Resource access"
|
msgid "Resource access"
|
||||||
msgstr "Ressourcenzugriff"
|
msgstr "Ressourcenzugriff"
|
||||||
|
|
||||||
#: core/models.py:332
|
#: core/models.py:325
|
||||||
msgid "Resource accesses"
|
msgid "Resource accesses"
|
||||||
msgstr "Ressourcenzugriffe"
|
msgstr "Ressourcenzugriffe"
|
||||||
|
|
||||||
#: core/models.py:338
|
#: core/models.py:331
|
||||||
msgid "Resource access with this User and Resource already exists."
|
msgid "Resource access with this User and Resource already exists."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
|
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
|
||||||
"bereits."
|
"bereits."
|
||||||
|
|
||||||
#: core/models.py:394
|
#: core/models.py:387
|
||||||
msgid "Visio room configuration"
|
msgid "Visio room configuration"
|
||||||
msgstr "Visio-Raumkonfiguration"
|
msgstr "Visio-Raumkonfiguration"
|
||||||
|
|
||||||
#: core/models.py:395
|
#: core/models.py:388
|
||||||
msgid "Values for Visio parameters to configure the room."
|
msgid "Values for Visio parameters to configure the room."
|
||||||
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
|
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
|
||||||
|
|
||||||
#: core/models.py:402
|
#: core/models.py:395
|
||||||
msgid "Room PIN code"
|
msgid "Room PIN code"
|
||||||
msgstr "PIN-Code für den Raum"
|
msgstr "PIN-Code für den Raum"
|
||||||
|
|
||||||
#: core/models.py:403
|
#: core/models.py:396
|
||||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
|
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
|
||||||
|
|
||||||
#: core/models.py:409 core/models.py:563
|
#: core/models.py:402 core/models.py:556
|
||||||
msgid "Room"
|
msgid "Room"
|
||||||
msgstr "Raum"
|
msgstr "Raum"
|
||||||
|
|
||||||
#: core/models.py:410
|
#: core/models.py:403
|
||||||
msgid "Rooms"
|
msgid "Rooms"
|
||||||
msgstr "Räume"
|
msgstr "Räume"
|
||||||
|
|
||||||
#: core/models.py:574
|
#: core/models.py:567
|
||||||
msgid "Worker ID"
|
msgid "Worker ID"
|
||||||
msgstr "Worker-ID"
|
msgstr "Worker-ID"
|
||||||
|
|
||||||
#: core/models.py:576
|
#: core/models.py:569
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||||
"the worker stops, allowing for easy tracking."
|
"the worker stops, allowing for easy tracking."
|
||||||
@@ -334,148 +331,107 @@ msgstr ""
|
|||||||
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
|
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
|
||||||
"ermöglicht."
|
"ermöglicht."
|
||||||
|
|
||||||
#: core/models.py:584
|
#: core/models.py:577
|
||||||
msgid "Recording mode"
|
msgid "Recording mode"
|
||||||
msgstr "Aufzeichnungsmodus"
|
msgstr "Aufzeichnungsmodus"
|
||||||
|
|
||||||
#: core/models.py:585
|
#: core/models.py:578
|
||||||
msgid "Defines the mode of recording being called."
|
msgid "Defines the mode of recording being called."
|
||||||
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
|
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
|
||||||
|
|
||||||
#: core/models.py:590 core/models.py:591
|
#: core/models.py:583 core/models.py:584
|
||||||
msgid "Recording options"
|
msgid "Recording options"
|
||||||
msgstr "Aufnahmeoptionen"
|
msgstr "Aufnahmeoptionen"
|
||||||
|
|
||||||
#: core/models.py:597
|
#: core/models.py:590
|
||||||
msgid "Recording"
|
msgid "Recording"
|
||||||
msgstr "Aufzeichnung"
|
msgstr "Aufzeichnung"
|
||||||
|
|
||||||
#: core/models.py:598
|
#: core/models.py:591
|
||||||
msgid "Recordings"
|
msgid "Recordings"
|
||||||
msgstr "Aufzeichnungen"
|
msgstr "Aufzeichnungen"
|
||||||
|
|
||||||
#: core/models.py:706
|
#: core/models.py:699
|
||||||
msgid "Recording/user relation"
|
msgid "Recording/user relation"
|
||||||
msgstr "Beziehung Aufzeichnung/Benutzer"
|
msgstr "Beziehung Aufzeichnung/Benutzer"
|
||||||
|
|
||||||
#: core/models.py:707
|
#: core/models.py:700
|
||||||
msgid "Recording/user relations"
|
msgid "Recording/user relations"
|
||||||
msgstr "Beziehungen Aufzeichnung/Benutzer"
|
msgstr "Beziehungen Aufzeichnung/Benutzer"
|
||||||
|
|
||||||
#: core/models.py:713
|
#: core/models.py:706
|
||||||
msgid "This user is already in this recording."
|
msgid "This user is already in this recording."
|
||||||
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
|
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
|
||||||
|
|
||||||
#: core/models.py:719
|
#: core/models.py:712
|
||||||
msgid "This team is already in this recording."
|
msgid "This team is already in this recording."
|
||||||
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
|
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
|
||||||
|
|
||||||
#: core/models.py:725
|
#: core/models.py:718
|
||||||
msgid "Either user or team must be set, not both."
|
msgid "Either user or team must be set, not both."
|
||||||
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
|
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
|
||||||
|
|
||||||
#: core/models.py:742
|
#: core/models.py:735
|
||||||
msgid "Create rooms"
|
msgid "Create rooms"
|
||||||
msgstr "Räume erstellen"
|
msgstr "Räume erstellen"
|
||||||
|
|
||||||
#: core/models.py:743
|
#: core/models.py:736
|
||||||
msgid "List rooms"
|
msgid "List rooms"
|
||||||
msgstr "Räume auflisten"
|
msgstr "Räume auflisten"
|
||||||
|
|
||||||
#: core/models.py:744
|
#: core/models.py:737
|
||||||
msgid "Retrieve room details"
|
msgid "Retrieve room details"
|
||||||
msgstr "Raumdetails abrufen"
|
msgstr "Raumdetails abrufen"
|
||||||
|
|
||||||
#: core/models.py:745
|
#: core/models.py:738
|
||||||
msgid "Update rooms"
|
msgid "Update rooms"
|
||||||
msgstr "Räume aktualisieren"
|
msgstr "Räume aktualisieren"
|
||||||
|
|
||||||
#: core/models.py:746
|
#: core/models.py:739
|
||||||
msgid "Delete rooms"
|
msgid "Delete rooms"
|
||||||
msgstr "Räume löschen"
|
msgstr "Räume löschen"
|
||||||
|
|
||||||
#: core/models.py:759
|
#: core/models.py:752
|
||||||
msgid "Application name"
|
msgid "Application name"
|
||||||
msgstr "Anwendungsname"
|
msgstr "Anwendungsname"
|
||||||
|
|
||||||
#: core/models.py:760
|
#: core/models.py:753
|
||||||
msgid "Descriptive name for this application."
|
msgid "Descriptive name for this application."
|
||||||
msgstr "Beschreibender Name für diese Anwendung."
|
msgstr "Beschreibender Name für diese Anwendung."
|
||||||
|
|
||||||
#: core/models.py:770
|
#: core/models.py:763
|
||||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
|
"Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
|
||||||
|
|
||||||
#: core/models.py:781
|
#: core/models.py:774
|
||||||
msgid "Application"
|
msgid "Application"
|
||||||
msgstr "Anwendung"
|
msgstr "Anwendung"
|
||||||
|
|
||||||
#: core/models.py:782
|
#: core/models.py:775
|
||||||
msgid "Applications"
|
msgid "Applications"
|
||||||
msgstr "Anwendungen"
|
msgstr "Anwendungen"
|
||||||
|
|
||||||
#: core/models.py:805
|
#: core/models.py:798
|
||||||
msgid "Enter a valid domain"
|
msgid "Enter a valid domain"
|
||||||
msgstr "Geben Sie eine gültige Domain ein"
|
msgstr "Geben Sie eine gültige Domain ein"
|
||||||
|
|
||||||
#: core/models.py:808
|
#: core/models.py:801
|
||||||
msgid "Domain"
|
msgid "Domain"
|
||||||
msgstr "Domain"
|
msgstr "Domain"
|
||||||
|
|
||||||
#: core/models.py:809
|
#: core/models.py:802
|
||||||
msgid "Email domain this application can act on behalf of."
|
msgid "Email domain this application can act on behalf of."
|
||||||
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
|
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
|
||||||
|
|
||||||
#: core/models.py:821
|
#: core/models.py:814
|
||||||
msgid "Application domain"
|
msgid "Application domain"
|
||||||
msgstr "Anwendungsdomain"
|
msgstr "Anwendungsdomain"
|
||||||
|
|
||||||
#: core/models.py:822
|
#: core/models.py:815
|
||||||
msgid "Application domains"
|
msgid "Application domains"
|
||||||
msgstr "Anwendungsdomains"
|
msgstr "Anwendungsdomains"
|
||||||
|
|
||||||
#: 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
|
#: core/recording/event/notification.py:116
|
||||||
msgid "Your recording is ready"
|
msgid "Your recording is ready"
|
||||||
msgstr "Ihre Aufzeichnung ist bereit"
|
msgstr "Ihre Aufzeichnung ist bereit"
|
||||||
@@ -485,6 +441,30 @@ msgstr "Ihre Aufzeichnung ist bereit"
|
|||||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||||
msgstr "Videoanruf läuft: {sender.email} wartet auf Ihre Teilnahme"
|
msgstr "Videoanruf läuft: {sender.email} wartet auf Ihre Teilnahme"
|
||||||
|
|
||||||
|
#: core/templates/addons/error.html:7 core/templates/addons/error.html:11
|
||||||
|
msgid "Error"
|
||||||
|
msgstr "Fehler"
|
||||||
|
|
||||||
|
#: core/templates/addons/error.html:12
|
||||||
|
msgid "Something went wrong."
|
||||||
|
msgstr "Etwas ist schiefgelaufen."
|
||||||
|
|
||||||
|
#: core/templates/addons/error.html:13
|
||||||
|
msgid "Close"
|
||||||
|
msgstr "Schließen"
|
||||||
|
|
||||||
|
#: core/templates/addons/redirect_success.html:7
|
||||||
|
msgid "Authentication Success"
|
||||||
|
msgstr "Authentifizierung erfolgreich"
|
||||||
|
|
||||||
|
#: core/templates/addons/redirect_success.html:13
|
||||||
|
msgid "Session stored successfully. This window will close automatically."
|
||||||
|
msgstr "Sitzung erfolgreich gespeichert. Dieses Fenster wird automatisch geschlossen."
|
||||||
|
|
||||||
|
#: core/templates/addons/redirect_success.html:14
|
||||||
|
msgid "If it doesn't close"
|
||||||
|
msgstr "Falls es sich nicht schließt"
|
||||||
|
|
||||||
#: core/templates/mail/html/invitation.html:159
|
#: core/templates/mail/html/invitation.html:159
|
||||||
#: core/templates/mail/html/screen_recording.html:159
|
#: core/templates/mail/html/screen_recording.html:159
|
||||||
#: core/templates/mail/text/invitation.txt:3
|
#: core/templates/mail/text/invitation.txt:3
|
||||||
@@ -604,18 +584,18 @@ msgstr ""
|
|||||||
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
|
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
|
||||||
"an unser Support-Team unter %(support_email)s. "
|
"an unser Support-Team unter %(support_email)s. "
|
||||||
|
|
||||||
#: meet/settings.py:223
|
#: meet/settings.py:169
|
||||||
msgid "English"
|
msgid "English"
|
||||||
msgstr "Englisch"
|
msgstr "Englisch"
|
||||||
|
|
||||||
#: meet/settings.py:224
|
#: meet/settings.py:170
|
||||||
msgid "French"
|
msgid "French"
|
||||||
msgstr "Französisch"
|
msgstr "Französisch"
|
||||||
|
|
||||||
#: meet/settings.py:225
|
#: meet/settings.py:171
|
||||||
msgid "Dutch"
|
msgid "Dutch"
|
||||||
msgstr "Niederländisch"
|
msgstr "Niederländisch"
|
||||||
|
|
||||||
#: meet/settings.py:226
|
#: meet/settings.py:172
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr "Deutsch"
|
msgstr "Deutsch"
|
||||||
|
|||||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-03-12 13:46+0000\n"
|
"POT-Creation-Date: 2026-01-26 15:40+0000\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -17,6 +17,30 @@ msgstr ""
|
|||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
|
||||||
|
#: core/addons/views.py:24
|
||||||
|
msgid "Session ID is required."
|
||||||
|
msgstr "Session ID is required."
|
||||||
|
|
||||||
|
#: core/addons/views.py:29
|
||||||
|
msgid "Session not found or expired."
|
||||||
|
msgstr "Session not found or expired."
|
||||||
|
|
||||||
|
#: core/addons/views.py:32
|
||||||
|
msgid "Invalid session state."
|
||||||
|
msgstr "Invalid session state."
|
||||||
|
|
||||||
|
#: core/addons/views.py:45
|
||||||
|
msgid "Authentication required."
|
||||||
|
msgstr "Authentication required."
|
||||||
|
|
||||||
|
#: core/addons/views.py:50
|
||||||
|
msgid "No active session found."
|
||||||
|
msgstr "No active session found."
|
||||||
|
|
||||||
|
#: core/addons/views.py:55
|
||||||
|
msgid "Invalid or expired session."
|
||||||
|
msgstr "Invalid or expired session."
|
||||||
|
|
||||||
#: core/admin.py:29
|
#: core/admin.py:29
|
||||||
msgid "Personal info"
|
msgid "Personal info"
|
||||||
msgstr "Personal info"
|
msgstr "Personal info"
|
||||||
@@ -29,166 +53,139 @@ msgstr "Permissions"
|
|||||||
msgid "Important dates"
|
msgid "Important dates"
|
||||||
msgstr "Important dates"
|
msgstr "Important dates"
|
||||||
|
|
||||||
#: core/admin.py:132 core/admin.py:275
|
#: core/admin.py:128 core/admin.py:228
|
||||||
msgid "No owner"
|
msgid "No owner"
|
||||||
msgstr "No owner"
|
msgstr "No owner"
|
||||||
|
|
||||||
#: core/admin.py:135 core/admin.py:278
|
#: core/admin.py:131 core/admin.py:231
|
||||||
msgid "Multiple owners"
|
msgid "Multiple owners"
|
||||||
msgstr "Multiple owners"
|
msgstr "Multiple owners"
|
||||||
|
|
||||||
#: core/admin.py:148
|
#: core/admin.py:143
|
||||||
msgid "Resend notification to external service"
|
msgid "Resend notification to external service"
|
||||||
msgstr "Resend notification to external service"
|
msgstr "Resend notification to external service"
|
||||||
|
|
||||||
#: core/admin.py:171
|
#: core/admin.py:166
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s"
|
msgid "Failed to notify for recording %(id)s"
|
||||||
msgstr "Failed to notify for recording %(id)s"
|
msgstr "Failed to notify for recording %(id)s"
|
||||||
|
|
||||||
#: core/admin.py:179
|
#: core/admin.py:174
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||||
msgstr "Failed to notify for recording %(id)s: %(error)s"
|
msgstr "Failed to notify for recording %(id)s: %(error)s"
|
||||||
|
|
||||||
#: core/admin.py:187
|
#: core/admin.py:182
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||||
msgstr "Successfully sent notifications for %(count)s recording(s)."
|
msgstr "Successfully sent notifications for %(count)s recording(s)."
|
||||||
|
|
||||||
#: core/admin.py:195
|
#: core/admin.py:190
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Skipped %(count)s expired recording(s)."
|
msgid "Skipped %(count)s expired recording(s)."
|
||||||
msgstr "Skipped %(count)s expired recording(s)."
|
msgstr "Skipped %(count)s expired recording(s)."
|
||||||
|
|
||||||
#: core/admin.py:200
|
#: core/admin.py:294
|
||||||
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"
|
msgid "No scopes"
|
||||||
msgstr "No scopes"
|
msgstr "No scopes"
|
||||||
|
|
||||||
#: core/admin.py:344
|
#: core/admin.py:296
|
||||||
msgid "Scopes"
|
msgid "Scopes"
|
||||||
msgstr "Scopes"
|
msgstr "Scopes"
|
||||||
|
|
||||||
#: core/api/filters.py:25
|
#: core/api/serializers.py:68
|
||||||
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."
|
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||||
msgstr "You must be administrator or owner of a room to add accesses to it."
|
msgstr "You must be administrator or owner of a room to add accesses to it."
|
||||||
|
|
||||||
#: core/api/serializers.py:516
|
#: core/models.py:34
|
||||||
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"
|
msgid "Member"
|
||||||
msgstr "Member"
|
msgstr "Member"
|
||||||
|
|
||||||
#: core/models.py:38
|
#: core/models.py:35
|
||||||
msgid "Administrator"
|
msgid "Administrator"
|
||||||
msgstr "Administrator"
|
msgstr "Administrator"
|
||||||
|
|
||||||
#: core/models.py:39
|
#: core/models.py:36
|
||||||
msgid "Owner"
|
msgid "Owner"
|
||||||
msgstr "Owner"
|
msgstr "Owner"
|
||||||
|
|
||||||
#: core/models.py:55
|
#: core/models.py:52
|
||||||
msgid "Initiated"
|
msgid "Initiated"
|
||||||
msgstr "Initiated"
|
msgstr "Initiated"
|
||||||
|
|
||||||
#: core/models.py:56
|
#: core/models.py:53
|
||||||
msgid "Active"
|
msgid "Active"
|
||||||
msgstr "Active"
|
msgstr "Active"
|
||||||
|
|
||||||
#: core/models.py:57
|
#: core/models.py:54
|
||||||
msgid "Stopped"
|
msgid "Stopped"
|
||||||
msgstr "Stopped"
|
msgstr "Stopped"
|
||||||
|
|
||||||
#: core/models.py:58
|
#: core/models.py:55
|
||||||
msgid "Saved"
|
msgid "Saved"
|
||||||
msgstr "Saved"
|
msgstr "Saved"
|
||||||
|
|
||||||
#: core/models.py:59
|
#: core/models.py:56
|
||||||
msgid "Aborted"
|
msgid "Aborted"
|
||||||
msgstr "Aborted"
|
msgstr "Aborted"
|
||||||
|
|
||||||
#: core/models.py:60
|
#: core/models.py:57
|
||||||
msgid "Failed to Start"
|
msgid "Failed to Start"
|
||||||
msgstr "Failed to Start"
|
msgstr "Failed to Start"
|
||||||
|
|
||||||
#: core/models.py:61
|
#: core/models.py:58
|
||||||
msgid "Failed to Stop"
|
msgid "Failed to Stop"
|
||||||
msgstr "Failed to Stop"
|
msgstr "Failed to Stop"
|
||||||
|
|
||||||
#: core/models.py:62
|
#: core/models.py:59
|
||||||
msgid "Notification succeeded"
|
msgid "Notification succeeded"
|
||||||
msgstr "Notification succeeded"
|
msgstr "Notification succeeded"
|
||||||
|
|
||||||
#: core/models.py:89
|
#: core/models.py:86
|
||||||
msgid "SCREEN_RECORDING"
|
msgid "SCREEN_RECORDING"
|
||||||
msgstr "SCREEN_RECORDING"
|
msgstr "SCREEN_RECORDING"
|
||||||
|
|
||||||
#: core/models.py:90
|
#: core/models.py:87
|
||||||
msgid "TRANSCRIPT"
|
msgid "TRANSCRIPT"
|
||||||
msgstr "TRANSCRIPT"
|
msgstr "TRANSCRIPT"
|
||||||
|
|
||||||
#: core/models.py:96
|
#: core/models.py:93
|
||||||
msgid "Public Access"
|
msgid "Public Access"
|
||||||
msgstr "Public Access"
|
msgstr "Public Access"
|
||||||
|
|
||||||
#: core/models.py:97
|
#: core/models.py:94
|
||||||
msgid "Trusted Access"
|
msgid "Trusted Access"
|
||||||
msgstr "Trusted Access"
|
msgstr "Trusted Access"
|
||||||
|
|
||||||
#: core/models.py:98
|
#: core/models.py:95
|
||||||
msgid "Restricted Access"
|
msgid "Restricted Access"
|
||||||
msgstr "Restricted Access"
|
msgstr "Restricted Access"
|
||||||
|
|
||||||
#: core/models.py:110
|
#: core/models.py:107
|
||||||
msgid "id"
|
msgid "id"
|
||||||
msgstr "id"
|
msgstr "id"
|
||||||
|
|
||||||
#: core/models.py:111
|
#: core/models.py:108
|
||||||
msgid "primary key for the record as UUID"
|
msgid "primary key for the record as UUID"
|
||||||
msgstr "primary key for the record as UUID"
|
msgstr "primary key for the record as UUID"
|
||||||
|
|
||||||
#: core/models.py:117
|
#: core/models.py:114
|
||||||
msgid "created on"
|
msgid "created on"
|
||||||
msgstr "created on"
|
msgstr "created on"
|
||||||
|
|
||||||
#: core/models.py:118
|
#: core/models.py:115
|
||||||
msgid "date and time at which a record was created"
|
msgid "date and time at which a record was created"
|
||||||
msgstr "date and time at which a record was created"
|
msgstr "date and time at which a record was created"
|
||||||
|
|
||||||
#: core/models.py:123
|
#: core/models.py:120
|
||||||
msgid "updated on"
|
msgid "updated on"
|
||||||
msgstr "updated on"
|
msgstr "updated on"
|
||||||
|
|
||||||
#: core/models.py:124
|
#: core/models.py:121
|
||||||
msgid "date and time at which a record was last updated"
|
msgid "date and time at which a record was last updated"
|
||||||
msgstr "date and time at which a record was last updated"
|
msgstr "date and time at which a record was last updated"
|
||||||
|
|
||||||
#: core/models.py:144
|
#: core/models.py:141
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||||
"_ characters."
|
"_ characters."
|
||||||
@@ -196,11 +193,11 @@ msgstr ""
|
|||||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||||
"_ characters."
|
"_ characters."
|
||||||
|
|
||||||
#: core/models.py:150
|
#: core/models.py:147
|
||||||
msgid "sub"
|
msgid "sub"
|
||||||
msgstr "sub"
|
msgstr "sub"
|
||||||
|
|
||||||
#: core/models.py:152
|
#: core/models.py:149
|
||||||
msgid ""
|
msgid ""
|
||||||
"Optional for pending users; required upon account activation. 255 characters "
|
"Optional for pending users; required upon account activation. 255 characters "
|
||||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||||
@@ -208,55 +205,55 @@ msgstr ""
|
|||||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||||
"characters only."
|
"characters only."
|
||||||
|
|
||||||
#: core/models.py:161
|
#: core/models.py:158
|
||||||
msgid "identity email address"
|
msgid "identity email address"
|
||||||
msgstr "identity email address"
|
msgstr "identity email address"
|
||||||
|
|
||||||
#: core/models.py:166
|
#: core/models.py:163
|
||||||
msgid "admin email address"
|
msgid "admin email address"
|
||||||
msgstr "admin email address"
|
msgstr "admin email address"
|
||||||
|
|
||||||
#: core/models.py:168
|
#: core/models.py:165
|
||||||
msgid "full name"
|
msgid "full name"
|
||||||
msgstr "full name"
|
msgstr "full name"
|
||||||
|
|
||||||
#: core/models.py:170
|
#: core/models.py:167
|
||||||
msgid "short name"
|
msgid "short name"
|
||||||
msgstr "short name"
|
msgstr "short name"
|
||||||
|
|
||||||
#: core/models.py:176
|
#: core/models.py:173
|
||||||
msgid "language"
|
msgid "language"
|
||||||
msgstr "language"
|
msgstr "language"
|
||||||
|
|
||||||
#: core/models.py:177
|
#: core/models.py:174
|
||||||
msgid "The language in which the user wants to see the interface."
|
msgid "The language in which the user wants to see the interface."
|
||||||
msgstr "The language in which the user wants to see the interface."
|
msgstr "The language in which the user wants to see the interface."
|
||||||
|
|
||||||
#: core/models.py:183
|
#: core/models.py:180
|
||||||
msgid "The timezone in which the user wants to see times."
|
msgid "The timezone in which the user wants to see times."
|
||||||
msgstr "The timezone in which the user wants to see times."
|
msgstr "The timezone in which the user wants to see times."
|
||||||
|
|
||||||
#: core/models.py:186
|
#: core/models.py:183
|
||||||
msgid "device"
|
msgid "device"
|
||||||
msgstr "device"
|
msgstr "device"
|
||||||
|
|
||||||
#: core/models.py:188
|
#: core/models.py:185
|
||||||
msgid "Whether the user is a device or a real user."
|
msgid "Whether the user is a device or a real user."
|
||||||
msgstr "Whether the user is a device or a real user."
|
msgstr "Whether the user is a device or a real user."
|
||||||
|
|
||||||
#: core/models.py:191
|
#: core/models.py:188
|
||||||
msgid "staff status"
|
msgid "staff status"
|
||||||
msgstr "staff status"
|
msgstr "staff status"
|
||||||
|
|
||||||
#: core/models.py:193
|
#: core/models.py:190
|
||||||
msgid "Whether the user can log into this admin site."
|
msgid "Whether the user can log into this admin site."
|
||||||
msgstr "Whether the user can log into this admin site."
|
msgstr "Whether the user can log into this admin site."
|
||||||
|
|
||||||
#: core/models.py:196
|
#: core/models.py:193
|
||||||
msgid "active"
|
msgid "active"
|
||||||
msgstr "active"
|
msgstr "active"
|
||||||
|
|
||||||
#: core/models.py:199
|
#: core/models.py:196
|
||||||
msgid ""
|
msgid ""
|
||||||
"Whether this user should be treated as active. Unselect this instead of "
|
"Whether this user should be treated as active. Unselect this instead of "
|
||||||
"deleting accounts."
|
"deleting accounts."
|
||||||
@@ -264,63 +261,63 @@ msgstr ""
|
|||||||
"Whether this user should be treated as active. Unselect this instead of "
|
"Whether this user should be treated as active. Unselect this instead of "
|
||||||
"deleting accounts."
|
"deleting accounts."
|
||||||
|
|
||||||
#: core/models.py:212
|
#: core/models.py:209
|
||||||
msgid "user"
|
msgid "user"
|
||||||
msgstr "user"
|
msgstr "user"
|
||||||
|
|
||||||
#: core/models.py:213
|
#: core/models.py:210
|
||||||
msgid "users"
|
msgid "users"
|
||||||
msgstr "users"
|
msgstr "users"
|
||||||
|
|
||||||
#: core/models.py:272
|
#: core/models.py:269
|
||||||
msgid "Resource"
|
msgid "Resource"
|
||||||
msgstr "Resource"
|
msgstr "Resource"
|
||||||
|
|
||||||
#: core/models.py:273
|
#: core/models.py:270
|
||||||
msgid "Resources"
|
msgid "Resources"
|
||||||
msgstr "Resources"
|
msgstr "Resources"
|
||||||
|
|
||||||
#: core/models.py:331
|
#: core/models.py:324
|
||||||
msgid "Resource access"
|
msgid "Resource access"
|
||||||
msgstr "Resource access"
|
msgstr "Resource access"
|
||||||
|
|
||||||
#: core/models.py:332
|
#: core/models.py:325
|
||||||
msgid "Resource accesses"
|
msgid "Resource accesses"
|
||||||
msgstr "Resource accesses"
|
msgstr "Resource accesses"
|
||||||
|
|
||||||
#: core/models.py:338
|
#: core/models.py:331
|
||||||
msgid "Resource access with this User and Resource already exists."
|
msgid "Resource access with this User and Resource already exists."
|
||||||
msgstr "Resource access with this User and Resource already exists."
|
msgstr "Resource access with this User and Resource already exists."
|
||||||
|
|
||||||
#: core/models.py:394
|
#: core/models.py:387
|
||||||
msgid "Visio room configuration"
|
msgid "Visio room configuration"
|
||||||
msgstr "Visio room configuration"
|
msgstr "Visio room configuration"
|
||||||
|
|
||||||
#: core/models.py:395
|
#: core/models.py:388
|
||||||
msgid "Values for Visio parameters to configure the room."
|
msgid "Values for Visio parameters to configure the room."
|
||||||
msgstr "Values for Visio parameters to configure the room."
|
msgstr "Values for Visio parameters to configure the room."
|
||||||
|
|
||||||
#: core/models.py:402
|
#: core/models.py:395
|
||||||
msgid "Room PIN code"
|
msgid "Room PIN code"
|
||||||
msgstr "Room PIN code"
|
msgstr "Room PIN code"
|
||||||
|
|
||||||
#: core/models.py:403
|
#: core/models.py:396
|
||||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||||
msgstr "Unique n-digit code that identifies this room in telephony mode."
|
msgstr "Unique n-digit code that identifies this room in telephony mode."
|
||||||
|
|
||||||
#: core/models.py:409 core/models.py:563
|
#: core/models.py:402 core/models.py:556
|
||||||
msgid "Room"
|
msgid "Room"
|
||||||
msgstr "Room"
|
msgstr "Room"
|
||||||
|
|
||||||
#: core/models.py:410
|
#: core/models.py:403
|
||||||
msgid "Rooms"
|
msgid "Rooms"
|
||||||
msgstr "Rooms"
|
msgstr "Rooms"
|
||||||
|
|
||||||
#: core/models.py:574
|
#: core/models.py:567
|
||||||
msgid "Worker ID"
|
msgid "Worker ID"
|
||||||
msgstr "Worker ID"
|
msgstr "Worker ID"
|
||||||
|
|
||||||
#: core/models.py:576
|
#: core/models.py:569
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||||
"the worker stops, allowing for easy tracking."
|
"the worker stops, allowing for easy tracking."
|
||||||
@@ -328,150 +325,110 @@ msgstr ""
|
|||||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||||
"the worker stops, allowing for easy tracking."
|
"the worker stops, allowing for easy tracking."
|
||||||
|
|
||||||
#: core/models.py:584
|
#: core/models.py:577
|
||||||
msgid "Recording mode"
|
msgid "Recording mode"
|
||||||
msgstr "Recording mode"
|
msgstr "Recording mode"
|
||||||
|
|
||||||
#: core/models.py:585
|
#: core/models.py:578
|
||||||
msgid "Defines the mode of recording being called."
|
msgid "Defines the mode of recording being called."
|
||||||
msgstr "Defines the mode of recording being called."
|
msgstr "Defines the mode of recording being called."
|
||||||
|
|
||||||
#: core/models.py:590 core/models.py:591
|
#: core/models.py:583 core/models.py:584
|
||||||
msgid "Recording options"
|
msgid "Recording options"
|
||||||
msgstr "Recording options"
|
msgstr "Recording options"
|
||||||
|
|
||||||
#: core/models.py:597
|
#: core/models.py:590
|
||||||
msgid "Recording"
|
msgid "Recording"
|
||||||
msgstr "Recording"
|
msgstr "Recording"
|
||||||
|
|
||||||
#: core/models.py:598
|
#: core/models.py:591
|
||||||
msgid "Recordings"
|
msgid "Recordings"
|
||||||
msgstr "Recordings"
|
msgstr "Recordings"
|
||||||
|
|
||||||
#: core/models.py:706
|
#: core/models.py:699
|
||||||
msgid "Recording/user relation"
|
msgid "Recording/user relation"
|
||||||
msgstr "Recording/user relation"
|
msgstr "Recording/user relation"
|
||||||
|
|
||||||
#: core/models.py:707
|
#: core/models.py:700
|
||||||
msgid "Recording/user relations"
|
msgid "Recording/user relations"
|
||||||
msgstr "Recording/user relations"
|
msgstr "Recording/user relations"
|
||||||
|
|
||||||
#: core/models.py:713
|
#: core/models.py:706
|
||||||
msgid "This user is already in this recording."
|
msgid "This user is already in this recording."
|
||||||
msgstr "This user is already in this recording."
|
msgstr "This user is already in this recording."
|
||||||
|
|
||||||
#: core/models.py:719
|
#: core/models.py:712
|
||||||
msgid "This team is already in this recording."
|
msgid "This team is already in this recording."
|
||||||
msgstr "This team is already in this recording."
|
msgstr "This team is already in this recording."
|
||||||
|
|
||||||
#: core/models.py:725
|
#: core/models.py:718
|
||||||
msgid "Either user or team must be set, not both."
|
msgid "Either user or team must be set, not both."
|
||||||
msgstr "Either user or team must be set, not both."
|
msgstr "Either user or team must be set, not both."
|
||||||
|
|
||||||
#: core/models.py:742
|
#: core/models.py:735
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
#| msgid "created on"
|
#| msgid "created on"
|
||||||
msgid "Create rooms"
|
msgid "Create rooms"
|
||||||
msgstr "Create rooms"
|
msgstr "Create rooms"
|
||||||
|
|
||||||
#: core/models.py:743
|
#: core/models.py:736
|
||||||
msgid "List rooms"
|
msgid "List rooms"
|
||||||
msgstr "List rooms"
|
msgstr "List rooms"
|
||||||
|
|
||||||
#: core/models.py:744
|
#: core/models.py:737
|
||||||
msgid "Retrieve room details"
|
msgid "Retrieve room details"
|
||||||
msgstr "Retrieve room details"
|
msgstr "Retrieve room details"
|
||||||
|
|
||||||
#: core/models.py:745
|
#: core/models.py:738
|
||||||
#, fuzzy
|
#, fuzzy
|
||||||
#| msgid "updated on"
|
#| msgid "updated on"
|
||||||
msgid "Update rooms"
|
msgid "Update rooms"
|
||||||
msgstr "Update rooms"
|
msgstr "Update rooms"
|
||||||
|
|
||||||
#: core/models.py:746
|
#: core/models.py:739
|
||||||
msgid "Delete rooms"
|
msgid "Delete rooms"
|
||||||
msgstr "Delete rooms"
|
msgstr "Delete rooms"
|
||||||
|
|
||||||
#: core/models.py:759
|
#: core/models.py:752
|
||||||
msgid "Application name"
|
msgid "Application name"
|
||||||
msgstr "Application name"
|
msgstr "Application name"
|
||||||
|
|
||||||
#: core/models.py:760
|
#: core/models.py:753
|
||||||
msgid "Descriptive name for this application."
|
msgid "Descriptive name for this application."
|
||||||
msgstr "Descriptive name for this application."
|
msgstr "Descriptive name for this application."
|
||||||
|
|
||||||
#: core/models.py:770
|
#: core/models.py:763
|
||||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||||
msgstr "Hashed on Save. Copy it now if this is a new secret."
|
msgstr "Hashed on Save. Copy it now if this is a new secret."
|
||||||
|
|
||||||
#: core/models.py:781
|
#: core/models.py:774
|
||||||
msgid "Application"
|
msgid "Application"
|
||||||
msgstr "Application"
|
msgstr "Application"
|
||||||
|
|
||||||
#: core/models.py:782
|
#: core/models.py:775
|
||||||
msgid "Applications"
|
msgid "Applications"
|
||||||
msgstr "Applications"
|
msgstr "Applications"
|
||||||
|
|
||||||
#: core/models.py:805
|
#: core/models.py:798
|
||||||
msgid "Enter a valid domain"
|
msgid "Enter a valid domain"
|
||||||
msgstr "Enter a valid domain"
|
msgstr "Enter a valid domain"
|
||||||
|
|
||||||
#: core/models.py:808
|
#: core/models.py:801
|
||||||
msgid "Domain"
|
msgid "Domain"
|
||||||
msgstr "Domain"
|
msgstr "Domain"
|
||||||
|
|
||||||
#: core/models.py:809
|
#: core/models.py:802
|
||||||
msgid "Email domain this application can act on behalf of."
|
msgid "Email domain this application can act on behalf of."
|
||||||
msgstr "Email domain this application can act on behalf of."
|
msgstr "Email domain this application can act on behalf of."
|
||||||
|
|
||||||
#: core/models.py:821
|
#: core/models.py:814
|
||||||
msgid "Application domain"
|
msgid "Application domain"
|
||||||
msgstr "Application domain"
|
msgstr "Application domain"
|
||||||
|
|
||||||
#: core/models.py:822
|
#: core/models.py:815
|
||||||
msgid "Application domains"
|
msgid "Application domains"
|
||||||
msgstr "Application domains"
|
msgstr "Application domains"
|
||||||
|
|
||||||
#: 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
|
#: core/recording/event/notification.py:116
|
||||||
msgid "Your recording is ready"
|
msgid "Your recording is ready"
|
||||||
msgstr "Your recording is ready"
|
msgstr "Your recording is ready"
|
||||||
@@ -481,6 +438,30 @@ msgstr "Your recording is ready"
|
|||||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||||
msgstr "Video call in progress: {sender.email} is waiting for you to connect"
|
msgstr "Video call in progress: {sender.email} is waiting for you to connect"
|
||||||
|
|
||||||
|
#: core/templates/addons/error.html:7 core/templates/addons/error.html:11
|
||||||
|
msgid "Error"
|
||||||
|
msgstr "Error"
|
||||||
|
|
||||||
|
#: core/templates/addons/error.html:12
|
||||||
|
msgid "Something went wrong."
|
||||||
|
msgstr "Something went wrong."
|
||||||
|
|
||||||
|
#: core/templates/addons/error.html:13
|
||||||
|
msgid "Close"
|
||||||
|
msgstr "Close"
|
||||||
|
|
||||||
|
#: core/templates/addons/redirect_success.html:7
|
||||||
|
msgid "Authentication Success"
|
||||||
|
msgstr "Authentication Success"
|
||||||
|
|
||||||
|
#: core/templates/addons/redirect_success.html:13
|
||||||
|
msgid "Session stored successfully. This window will close automatically."
|
||||||
|
msgstr "Session stored successfully. This window will close automatically."
|
||||||
|
|
||||||
|
#: core/templates/addons/redirect_success.html:14
|
||||||
|
msgid "If it doesn't close"
|
||||||
|
msgstr "If it doesn't close"
|
||||||
|
|
||||||
#: core/templates/mail/html/invitation.html:159
|
#: core/templates/mail/html/invitation.html:159
|
||||||
#: core/templates/mail/html/screen_recording.html:159
|
#: core/templates/mail/html/screen_recording.html:159
|
||||||
#: core/templates/mail/text/invitation.txt:3
|
#: core/templates/mail/text/invitation.txt:3
|
||||||
@@ -600,18 +581,18 @@ msgstr ""
|
|||||||
" If you have any questions or need assistance, please contact our support "
|
" If you have any questions or need assistance, please contact our support "
|
||||||
"team at %(support_email)s. "
|
"team at %(support_email)s. "
|
||||||
|
|
||||||
#: meet/settings.py:223
|
#: meet/settings.py:169
|
||||||
msgid "English"
|
msgid "English"
|
||||||
msgstr "English"
|
msgstr "English"
|
||||||
|
|
||||||
#: meet/settings.py:224
|
#: meet/settings.py:170
|
||||||
msgid "French"
|
msgid "French"
|
||||||
msgstr "French"
|
msgstr "French"
|
||||||
|
|
||||||
#: meet/settings.py:225
|
#: meet/settings.py:171
|
||||||
msgid "Dutch"
|
msgid "Dutch"
|
||||||
msgstr "Dutch"
|
msgstr "Dutch"
|
||||||
|
|
||||||
#: meet/settings.py:226
|
#: meet/settings.py:172
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr "German"
|
msgstr "German"
|
||||||
|
|||||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-03-12 13:46+0000\n"
|
"POT-Creation-Date: 2026-01-26 15:40+0000\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
|
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -17,6 +17,30 @@ msgstr ""
|
|||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
|
||||||
|
#: core/addons/views.py:24
|
||||||
|
msgid "Session ID is required."
|
||||||
|
msgstr "L'identifiant de session est requis."
|
||||||
|
|
||||||
|
#: core/addons/views.py:29
|
||||||
|
msgid "Session not found or expired."
|
||||||
|
msgstr "Session introuvable ou expirée."
|
||||||
|
|
||||||
|
#: core/addons/views.py:32
|
||||||
|
msgid "Invalid session state."
|
||||||
|
msgstr "État de session invalide."
|
||||||
|
|
||||||
|
#: core/addons/views.py:45
|
||||||
|
msgid "Authentication required."
|
||||||
|
msgstr "Authentification requise."
|
||||||
|
|
||||||
|
#: core/addons/views.py:50
|
||||||
|
msgid "No active session found."
|
||||||
|
msgstr "Aucune session active trouvée."
|
||||||
|
|
||||||
|
#: core/addons/views.py:55
|
||||||
|
msgid "Invalid or expired session."
|
||||||
|
msgstr "Session invalide ou expirée."
|
||||||
|
|
||||||
#: core/admin.py:29
|
#: core/admin.py:29
|
||||||
msgid "Personal info"
|
msgid "Personal info"
|
||||||
msgstr "Informations personnelles"
|
msgstr "Informations personnelles"
|
||||||
@@ -29,171 +53,143 @@ msgstr "Permissions"
|
|||||||
msgid "Important dates"
|
msgid "Important dates"
|
||||||
msgstr "Dates importantes"
|
msgstr "Dates importantes"
|
||||||
|
|
||||||
#: core/admin.py:132 core/admin.py:275
|
#: core/admin.py:128 core/admin.py:228
|
||||||
msgid "No owner"
|
msgid "No owner"
|
||||||
msgstr "Pas de propriétaire"
|
msgstr "Pas de propriétaire"
|
||||||
|
|
||||||
#: core/admin.py:135 core/admin.py:278
|
#: core/admin.py:131 core/admin.py:231
|
||||||
msgid "Multiple owners"
|
msgid "Multiple owners"
|
||||||
msgstr "Plusieurs propriétaires"
|
msgstr "Plusieurs propriétaires"
|
||||||
|
|
||||||
#: core/admin.py:148
|
#: core/admin.py:143
|
||||||
msgid "Resend notification to external service"
|
msgid "Resend notification to external service"
|
||||||
msgstr "Renvoyer la notification au service externe"
|
msgstr "Renvoyer la notification au service externe"
|
||||||
|
|
||||||
#: core/admin.py:171
|
#: core/admin.py:166
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s"
|
msgid "Failed to notify for recording %(id)s"
|
||||||
msgstr "Échec de la notification pour l’enregistrement %(id)s"
|
msgstr "Échec de la notification pour l’enregistrement %(id)s"
|
||||||
|
|
||||||
#: core/admin.py:179
|
#: core/admin.py:174
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||||
msgstr "Échec de la notification pour l’enregistrement %(id)s : %(error)s"
|
msgstr "Échec de la notification pour l’enregistrement %(id)s : %(error)s"
|
||||||
|
|
||||||
#: core/admin.py:187
|
#: core/admin.py:182
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||||
msgstr "Notifications envoyées avec succès pour %(count)s enregistrement(s)."
|
msgstr "Notifications envoyées avec succès pour %(count)s enregistrement(s)."
|
||||||
|
|
||||||
#: core/admin.py:195
|
#: core/admin.py:190
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Skipped %(count)s expired recording(s)."
|
msgid "Skipped %(count)s expired recording(s)."
|
||||||
msgstr "%(count)s enregistrement(s) expiré(s) ignoré(s)."
|
msgstr "%(count)s enregistrement(s) expiré(s) ignoré(s)."
|
||||||
|
|
||||||
#: core/admin.py:200
|
#: core/admin.py:294
|
||||||
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"
|
msgid "No scopes"
|
||||||
msgstr "Aucun scopes"
|
msgstr "Aucun scopes"
|
||||||
|
|
||||||
#: core/admin.py:344
|
#: core/admin.py:296
|
||||||
msgid "Scopes"
|
msgid "Scopes"
|
||||||
msgstr "Scopes"
|
msgstr "Scopes"
|
||||||
|
|
||||||
#: core/api/filters.py:25
|
#: core/api/serializers.py:68
|
||||||
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."
|
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
|
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
|
||||||
"des accès."
|
"des accès."
|
||||||
|
|
||||||
#: core/api/serializers.py:516
|
#: core/models.py:34
|
||||||
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"
|
msgid "Member"
|
||||||
msgstr "Membre"
|
msgstr "Membre"
|
||||||
|
|
||||||
#: core/models.py:38
|
#: core/models.py:35
|
||||||
msgid "Administrator"
|
msgid "Administrator"
|
||||||
msgstr "Administrateur"
|
msgstr "Administrateur"
|
||||||
|
|
||||||
#: core/models.py:39
|
#: core/models.py:36
|
||||||
msgid "Owner"
|
msgid "Owner"
|
||||||
msgstr "Propriétaire"
|
msgstr "Propriétaire"
|
||||||
|
|
||||||
#: core/models.py:55
|
#: core/models.py:52
|
||||||
msgid "Initiated"
|
msgid "Initiated"
|
||||||
msgstr "Initié"
|
msgstr "Initié"
|
||||||
|
|
||||||
#: core/models.py:56
|
#: core/models.py:53
|
||||||
msgid "Active"
|
msgid "Active"
|
||||||
msgstr "Actif"
|
msgstr "Actif"
|
||||||
|
|
||||||
#: core/models.py:57
|
#: core/models.py:54
|
||||||
msgid "Stopped"
|
msgid "Stopped"
|
||||||
msgstr "Arrêté"
|
msgstr "Arrêté"
|
||||||
|
|
||||||
#: core/models.py:58
|
#: core/models.py:55
|
||||||
msgid "Saved"
|
msgid "Saved"
|
||||||
msgstr "Enregistré"
|
msgstr "Enregistré"
|
||||||
|
|
||||||
#: core/models.py:59
|
#: core/models.py:56
|
||||||
msgid "Aborted"
|
msgid "Aborted"
|
||||||
msgstr "Abandonné"
|
msgstr "Abandonné"
|
||||||
|
|
||||||
#: core/models.py:60
|
#: core/models.py:57
|
||||||
msgid "Failed to Start"
|
msgid "Failed to Start"
|
||||||
msgstr "Échec au démarrage"
|
msgstr "Échec au démarrage"
|
||||||
|
|
||||||
#: core/models.py:61
|
#: core/models.py:58
|
||||||
msgid "Failed to Stop"
|
msgid "Failed to Stop"
|
||||||
msgstr "Échec à l'arrêt"
|
msgstr "Échec à l'arrêt"
|
||||||
|
|
||||||
#: core/models.py:62
|
#: core/models.py:59
|
||||||
msgid "Notification succeeded"
|
msgid "Notification succeeded"
|
||||||
msgstr "Notification réussie"
|
msgstr "Notification réussie"
|
||||||
|
|
||||||
#: core/models.py:89
|
#: core/models.py:86
|
||||||
msgid "SCREEN_RECORDING"
|
msgid "SCREEN_RECORDING"
|
||||||
msgstr "ENREGISTREMENT_ÉCRAN"
|
msgstr "ENREGISTREMENT_ÉCRAN"
|
||||||
|
|
||||||
#: core/models.py:90
|
#: core/models.py:87
|
||||||
msgid "TRANSCRIPT"
|
msgid "TRANSCRIPT"
|
||||||
msgstr "TRANSCRIPTION"
|
msgstr "TRANSCRIPTION"
|
||||||
|
|
||||||
#: core/models.py:96
|
#: core/models.py:93
|
||||||
msgid "Public Access"
|
msgid "Public Access"
|
||||||
msgstr "Accès public"
|
msgstr "Accès public"
|
||||||
|
|
||||||
#: core/models.py:97
|
#: core/models.py:94
|
||||||
msgid "Trusted Access"
|
msgid "Trusted Access"
|
||||||
msgstr "Accès de confiance"
|
msgstr "Accès de confiance"
|
||||||
|
|
||||||
#: core/models.py:98
|
#: core/models.py:95
|
||||||
msgid "Restricted Access"
|
msgid "Restricted Access"
|
||||||
msgstr "Accès restreint"
|
msgstr "Accès restreint"
|
||||||
|
|
||||||
#: core/models.py:110
|
#: core/models.py:107
|
||||||
msgid "id"
|
msgid "id"
|
||||||
msgstr "id"
|
msgstr "id"
|
||||||
|
|
||||||
#: core/models.py:111
|
#: core/models.py:108
|
||||||
msgid "primary key for the record as UUID"
|
msgid "primary key for the record as UUID"
|
||||||
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
|
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
|
||||||
|
|
||||||
#: core/models.py:117
|
#: core/models.py:114
|
||||||
msgid "created on"
|
msgid "created on"
|
||||||
msgstr "créé le"
|
msgstr "créé le"
|
||||||
|
|
||||||
#: core/models.py:118
|
#: core/models.py:115
|
||||||
msgid "date and time at which a record was created"
|
msgid "date and time at which a record was created"
|
||||||
msgstr "date et heure auxquelles un enregistrement a été créé"
|
msgstr "date et heure auxquelles un enregistrement a été créé"
|
||||||
|
|
||||||
#: core/models.py:123
|
#: core/models.py:120
|
||||||
msgid "updated on"
|
msgid "updated on"
|
||||||
msgstr "mis à jour le"
|
msgstr "mis à jour le"
|
||||||
|
|
||||||
#: core/models.py:124
|
#: core/models.py:121
|
||||||
msgid "date and time at which a record was last updated"
|
msgid "date and time at which a record was last updated"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
|
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
|
||||||
"fois"
|
"fois"
|
||||||
|
|
||||||
#: core/models.py:144
|
#: core/models.py:141
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||||
"_ characters."
|
"_ characters."
|
||||||
@@ -201,11 +197,11 @@ msgstr ""
|
|||||||
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des "
|
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des "
|
||||||
"chiffres et les caractères @/./+/-/_."
|
"chiffres et les caractères @/./+/-/_."
|
||||||
|
|
||||||
#: core/models.py:150
|
#: core/models.py:147
|
||||||
msgid "sub"
|
msgid "sub"
|
||||||
msgstr "sub"
|
msgstr "sub"
|
||||||
|
|
||||||
#: core/models.py:152
|
#: core/models.py:149
|
||||||
msgid ""
|
msgid ""
|
||||||
"Optional for pending users; required upon account activation. 255 characters "
|
"Optional for pending users; required upon account activation. 255 characters "
|
||||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||||
@@ -213,55 +209,55 @@ msgstr ""
|
|||||||
"Optionnel pour les utilisateurs en attente ; requis lors de l'activation du "
|
"Optionnel pour les utilisateurs en attente ; requis lors de l'activation du "
|
||||||
"compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement."
|
"compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement."
|
||||||
|
|
||||||
#: core/models.py:161
|
#: core/models.py:158
|
||||||
msgid "identity email address"
|
msgid "identity email address"
|
||||||
msgstr "adresse e-mail d'identité"
|
msgstr "adresse e-mail d'identité"
|
||||||
|
|
||||||
#: core/models.py:166
|
#: core/models.py:163
|
||||||
msgid "admin email address"
|
msgid "admin email address"
|
||||||
msgstr "adresse e-mail d'administrateur"
|
msgstr "adresse e-mail d'administrateur"
|
||||||
|
|
||||||
#: core/models.py:168
|
#: core/models.py:165
|
||||||
msgid "full name"
|
msgid "full name"
|
||||||
msgstr "nom complet"
|
msgstr "nom complet"
|
||||||
|
|
||||||
#: core/models.py:170
|
#: core/models.py:167
|
||||||
msgid "short name"
|
msgid "short name"
|
||||||
msgstr "nom court"
|
msgstr "nom court"
|
||||||
|
|
||||||
#: core/models.py:176
|
#: core/models.py:173
|
||||||
msgid "language"
|
msgid "language"
|
||||||
msgstr "langue"
|
msgstr "langue"
|
||||||
|
|
||||||
#: core/models.py:177
|
#: core/models.py:174
|
||||||
msgid "The language in which the user wants to see the interface."
|
msgid "The language in which the user wants to see the interface."
|
||||||
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
|
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
|
||||||
|
|
||||||
#: core/models.py:183
|
#: core/models.py:180
|
||||||
msgid "The timezone in which the user wants to see times."
|
msgid "The timezone in which the user wants to see times."
|
||||||
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
|
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
|
||||||
|
|
||||||
#: core/models.py:186
|
#: core/models.py:183
|
||||||
msgid "device"
|
msgid "device"
|
||||||
msgstr "appareil"
|
msgstr "appareil"
|
||||||
|
|
||||||
#: core/models.py:188
|
#: core/models.py:185
|
||||||
msgid "Whether the user is a device or a real user."
|
msgid "Whether the user is a device or a real user."
|
||||||
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
|
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
|
||||||
|
|
||||||
#: core/models.py:191
|
#: core/models.py:188
|
||||||
msgid "staff status"
|
msgid "staff status"
|
||||||
msgstr "statut du personnel"
|
msgstr "statut du personnel"
|
||||||
|
|
||||||
#: core/models.py:193
|
#: core/models.py:190
|
||||||
msgid "Whether the user can log into this admin site."
|
msgid "Whether the user can log into this admin site."
|
||||||
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
|
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
|
||||||
|
|
||||||
#: core/models.py:196
|
#: core/models.py:193
|
||||||
msgid "active"
|
msgid "active"
|
||||||
msgstr "actif"
|
msgstr "actif"
|
||||||
|
|
||||||
#: core/models.py:199
|
#: core/models.py:196
|
||||||
msgid ""
|
msgid ""
|
||||||
"Whether this user should be treated as active. Unselect this instead of "
|
"Whether this user should be treated as active. Unselect this instead of "
|
||||||
"deleting accounts."
|
"deleting accounts."
|
||||||
@@ -269,65 +265,65 @@ msgstr ""
|
|||||||
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
|
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
|
||||||
"au lieu de supprimer des comptes."
|
"au lieu de supprimer des comptes."
|
||||||
|
|
||||||
#: core/models.py:212
|
#: core/models.py:209
|
||||||
msgid "user"
|
msgid "user"
|
||||||
msgstr "utilisateur"
|
msgstr "utilisateur"
|
||||||
|
|
||||||
#: core/models.py:213
|
#: core/models.py:210
|
||||||
msgid "users"
|
msgid "users"
|
||||||
msgstr "utilisateurs"
|
msgstr "utilisateurs"
|
||||||
|
|
||||||
#: core/models.py:272
|
#: core/models.py:269
|
||||||
msgid "Resource"
|
msgid "Resource"
|
||||||
msgstr "Ressource"
|
msgstr "Ressource"
|
||||||
|
|
||||||
#: core/models.py:273
|
#: core/models.py:270
|
||||||
msgid "Resources"
|
msgid "Resources"
|
||||||
msgstr "Ressources"
|
msgstr "Ressources"
|
||||||
|
|
||||||
#: core/models.py:331
|
#: core/models.py:324
|
||||||
msgid "Resource access"
|
msgid "Resource access"
|
||||||
msgstr "Accès aux ressources"
|
msgstr "Accès aux ressources"
|
||||||
|
|
||||||
#: core/models.py:332
|
#: core/models.py:325
|
||||||
msgid "Resource accesses"
|
msgid "Resource accesses"
|
||||||
msgstr "Accès aux ressources"
|
msgstr "Accès aux ressources"
|
||||||
|
|
||||||
#: core/models.py:338
|
#: core/models.py:331
|
||||||
msgid "Resource access with this User and Resource already exists."
|
msgid "Resource access with this User and Resource already exists."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
|
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
|
||||||
|
|
||||||
#: core/models.py:394
|
#: core/models.py:387
|
||||||
msgid "Visio room configuration"
|
msgid "Visio room configuration"
|
||||||
msgstr "Configuration de la salle de visioconférence"
|
msgstr "Configuration de la salle de visioconférence"
|
||||||
|
|
||||||
#: core/models.py:395
|
#: core/models.py:388
|
||||||
msgid "Values for Visio parameters to configure the room."
|
msgid "Values for Visio parameters to configure the room."
|
||||||
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
|
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
|
||||||
|
|
||||||
#: core/models.py:402
|
#: core/models.py:395
|
||||||
msgid "Room PIN code"
|
msgid "Room PIN code"
|
||||||
msgstr "Code PIN de la salle"
|
msgstr "Code PIN de la salle"
|
||||||
|
|
||||||
#: core/models.py:403
|
#: core/models.py:396
|
||||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Code unique à n chiffres qui identifie cette salle en mode téléphonique."
|
"Code unique à n chiffres qui identifie cette salle en mode téléphonique."
|
||||||
|
|
||||||
#: core/models.py:409 core/models.py:563
|
#: core/models.py:402 core/models.py:556
|
||||||
msgid "Room"
|
msgid "Room"
|
||||||
msgstr "Salle"
|
msgstr "Salle"
|
||||||
|
|
||||||
#: core/models.py:410
|
#: core/models.py:403
|
||||||
msgid "Rooms"
|
msgid "Rooms"
|
||||||
msgstr "Salles"
|
msgstr "Salles"
|
||||||
|
|
||||||
#: core/models.py:574
|
#: core/models.py:567
|
||||||
msgid "Worker ID"
|
msgid "Worker ID"
|
||||||
msgstr "ID du Worker"
|
msgstr "ID du Worker"
|
||||||
|
|
||||||
#: core/models.py:576
|
#: core/models.py:569
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||||
"the worker stops, allowing for easy tracking."
|
"the worker stops, allowing for easy tracking."
|
||||||
@@ -335,149 +331,108 @@ msgstr ""
|
|||||||
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
|
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
|
||||||
"conservé même lorsque le Worker s'arrête, permettant un suivi facile."
|
"conservé même lorsque le Worker s'arrête, permettant un suivi facile."
|
||||||
|
|
||||||
#: core/models.py:584
|
#: core/models.py:577
|
||||||
msgid "Recording mode"
|
msgid "Recording mode"
|
||||||
msgstr "Mode d'enregistrement"
|
msgstr "Mode d'enregistrement"
|
||||||
|
|
||||||
#: core/models.py:585
|
#: core/models.py:578
|
||||||
msgid "Defines the mode of recording being called."
|
msgid "Defines the mode of recording being called."
|
||||||
msgstr "Définit le mode d'enregistrement appelé."
|
msgstr "Définit le mode d'enregistrement appelé."
|
||||||
|
|
||||||
#: core/models.py:590 core/models.py:591
|
#: core/models.py:583 core/models.py:584
|
||||||
msgid "Recording options"
|
msgid "Recording options"
|
||||||
msgstr "Options d'enregistrement"
|
msgstr "Options d'enregistrement"
|
||||||
|
|
||||||
#: core/models.py:597
|
#: core/models.py:590
|
||||||
msgid "Recording"
|
msgid "Recording"
|
||||||
msgstr "Enregistrement"
|
msgstr "Enregistrement"
|
||||||
|
|
||||||
#: core/models.py:598
|
#: core/models.py:591
|
||||||
msgid "Recordings"
|
msgid "Recordings"
|
||||||
msgstr "Enregistrements"
|
msgstr "Enregistrements"
|
||||||
|
|
||||||
#: core/models.py:706
|
#: core/models.py:699
|
||||||
msgid "Recording/user relation"
|
msgid "Recording/user relation"
|
||||||
msgstr "Relation enregistrement/utilisateur"
|
msgstr "Relation enregistrement/utilisateur"
|
||||||
|
|
||||||
#: core/models.py:707
|
#: core/models.py:700
|
||||||
msgid "Recording/user relations"
|
msgid "Recording/user relations"
|
||||||
msgstr "Relations enregistrement/utilisateur"
|
msgstr "Relations enregistrement/utilisateur"
|
||||||
|
|
||||||
#: core/models.py:713
|
#: core/models.py:706
|
||||||
msgid "This user is already in this recording."
|
msgid "This user is already in this recording."
|
||||||
msgstr "Cet utilisateur est déjà dans cet enregistrement."
|
msgstr "Cet utilisateur est déjà dans cet enregistrement."
|
||||||
|
|
||||||
#: core/models.py:719
|
#: core/models.py:712
|
||||||
msgid "This team is already in this recording."
|
msgid "This team is already in this recording."
|
||||||
msgstr "Cette équipe est déjà dans cet enregistrement."
|
msgstr "Cette équipe est déjà dans cet enregistrement."
|
||||||
|
|
||||||
#: core/models.py:725
|
#: core/models.py:718
|
||||||
msgid "Either user or team must be set, not both."
|
msgid "Either user or team must be set, not both."
|
||||||
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
|
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
|
||||||
|
|
||||||
#: core/models.py:742
|
#: core/models.py:735
|
||||||
msgid "Create rooms"
|
msgid "Create rooms"
|
||||||
msgstr "Créer des salles"
|
msgstr "Créer des salles"
|
||||||
|
|
||||||
#: core/models.py:743
|
#: core/models.py:736
|
||||||
msgid "List rooms"
|
msgid "List rooms"
|
||||||
msgstr "Lister les salles"
|
msgstr "Lister les salles"
|
||||||
|
|
||||||
#: core/models.py:744
|
#: core/models.py:737
|
||||||
msgid "Retrieve room details"
|
msgid "Retrieve room details"
|
||||||
msgstr "Afficher les détails d’une salle"
|
msgstr "Afficher les détails d’une salle"
|
||||||
|
|
||||||
#: core/models.py:745
|
#: core/models.py:738
|
||||||
msgid "Update rooms"
|
msgid "Update rooms"
|
||||||
msgstr "Mettre à jour les salles"
|
msgstr "Mettre à jour les salles"
|
||||||
|
|
||||||
#: core/models.py:746
|
#: core/models.py:739
|
||||||
msgid "Delete rooms"
|
msgid "Delete rooms"
|
||||||
msgstr "Supprimer les salles"
|
msgstr "Supprimer les salles"
|
||||||
|
|
||||||
#: core/models.py:759
|
#: core/models.py:752
|
||||||
msgid "Application name"
|
msgid "Application name"
|
||||||
msgstr "Nom de l’application"
|
msgstr "Nom de l’application"
|
||||||
|
|
||||||
#: core/models.py:760
|
#: core/models.py:753
|
||||||
msgid "Descriptive name for this application."
|
msgid "Descriptive name for this application."
|
||||||
msgstr "Nom descriptif de cette application."
|
msgstr "Nom descriptif de cette application."
|
||||||
|
|
||||||
#: core/models.py:770
|
#: core/models.py:763
|
||||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Haché lors de l’enregistrement. Copiez-le maintenant s’il s’agit d’un "
|
"Haché lors de l’enregistrement. Copiez-le maintenant s’il s’agit d’un "
|
||||||
"nouveau secret."
|
"nouveau secret."
|
||||||
|
|
||||||
#: core/models.py:781
|
#: core/models.py:774
|
||||||
msgid "Application"
|
msgid "Application"
|
||||||
msgstr "Application"
|
msgstr "Application"
|
||||||
|
|
||||||
#: core/models.py:782
|
#: core/models.py:775
|
||||||
msgid "Applications"
|
msgid "Applications"
|
||||||
msgstr "Applications"
|
msgstr "Applications"
|
||||||
|
|
||||||
#: core/models.py:805
|
#: core/models.py:798
|
||||||
msgid "Enter a valid domain"
|
msgid "Enter a valid domain"
|
||||||
msgstr "Saisissez un domaine valide"
|
msgstr "Saisissez un domaine valide"
|
||||||
|
|
||||||
#: core/models.py:808
|
#: core/models.py:801
|
||||||
msgid "Domain"
|
msgid "Domain"
|
||||||
msgstr "Domaine"
|
msgstr "Domaine"
|
||||||
|
|
||||||
#: core/models.py:809
|
#: core/models.py:802
|
||||||
msgid "Email domain this application can act on behalf of."
|
msgid "Email domain this application can act on behalf of."
|
||||||
msgstr "Domaine de messagerie au nom duquel cette application peut agir."
|
msgstr "Domaine de messagerie au nom duquel cette application peut agir."
|
||||||
|
|
||||||
#: core/models.py:821
|
#: core/models.py:814
|
||||||
msgid "Application domain"
|
msgid "Application domain"
|
||||||
msgstr "Domaine d’application"
|
msgstr "Domaine d’application"
|
||||||
|
|
||||||
#: core/models.py:822
|
#: core/models.py:815
|
||||||
msgid "Application domains"
|
msgid "Application domains"
|
||||||
msgstr "Domaines d’application"
|
msgstr "Domaines d’application"
|
||||||
|
|
||||||
#: 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
|
#: core/recording/event/notification.py:116
|
||||||
msgid "Your recording is ready"
|
msgid "Your recording is ready"
|
||||||
msgstr "Votre enregistrement est prêt"
|
msgstr "Votre enregistrement est prêt"
|
||||||
@@ -487,6 +442,30 @@ msgstr "Votre enregistrement est prêt"
|
|||||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||||
msgstr "Appel vidéo en cours : {sender.email} attend que vous vous connectiez"
|
msgstr "Appel vidéo en cours : {sender.email} attend que vous vous connectiez"
|
||||||
|
|
||||||
|
#: core/templates/addons/error.html:7 core/templates/addons/error.html:11
|
||||||
|
msgid "Error"
|
||||||
|
msgstr "Erreur"
|
||||||
|
|
||||||
|
#: core/templates/addons/error.html:12
|
||||||
|
msgid "Something went wrong."
|
||||||
|
msgstr "Une erreur s'est produite."
|
||||||
|
|
||||||
|
#: core/templates/addons/error.html:13
|
||||||
|
msgid "Close"
|
||||||
|
msgstr "Fermer"
|
||||||
|
|
||||||
|
#: core/templates/addons/redirect_success.html:7
|
||||||
|
msgid "Authentication Success"
|
||||||
|
msgstr "Authentification réussie"
|
||||||
|
|
||||||
|
#: core/templates/addons/redirect_success.html:13
|
||||||
|
msgid "Session stored successfully. This window will close automatically."
|
||||||
|
msgstr "Session enregistrée avec succès. Cette fenêtre se fermera automatiquement."
|
||||||
|
|
||||||
|
#: core/templates/addons/redirect_success.html:14
|
||||||
|
msgid "If it doesn't close"
|
||||||
|
msgstr "Si elle ne se ferme pas"
|
||||||
|
|
||||||
#: core/templates/mail/html/invitation.html:159
|
#: core/templates/mail/html/invitation.html:159
|
||||||
#: core/templates/mail/html/screen_recording.html:159
|
#: core/templates/mail/html/screen_recording.html:159
|
||||||
#: core/templates/mail/text/invitation.txt:3
|
#: core/templates/mail/text/invitation.txt:3
|
||||||
@@ -606,18 +585,18 @@ msgstr ""
|
|||||||
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
|
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
|
||||||
"équipe d'assistance à %(support_email)s. "
|
"équipe d'assistance à %(support_email)s. "
|
||||||
|
|
||||||
#: meet/settings.py:223
|
#: meet/settings.py:169
|
||||||
msgid "English"
|
msgid "English"
|
||||||
msgstr "Anglais"
|
msgstr "Anglais"
|
||||||
|
|
||||||
#: meet/settings.py:224
|
#: meet/settings.py:170
|
||||||
msgid "French"
|
msgid "French"
|
||||||
msgstr "Français"
|
msgstr "Français"
|
||||||
|
|
||||||
#: meet/settings.py:225
|
#: meet/settings.py:171
|
||||||
msgid "Dutch"
|
msgid "Dutch"
|
||||||
msgstr "Néerlandais"
|
msgstr "Néerlandais"
|
||||||
|
|
||||||
#: meet/settings.py:226
|
#: meet/settings.py:172
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr "Allemand"
|
msgstr "Allemand"
|
||||||
|
|||||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-03-12 13:46+0000\n"
|
"POT-Creation-Date: 2026-01-26 15:40+0000\n"
|
||||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -17,6 +17,30 @@ msgstr ""
|
|||||||
"Content-Type: text/plain; charset=UTF-8\n"
|
"Content-Type: text/plain; charset=UTF-8\n"
|
||||||
"Content-Transfer-Encoding: 8bit\n"
|
"Content-Transfer-Encoding: 8bit\n"
|
||||||
|
|
||||||
|
#: core/addons/views.py:24
|
||||||
|
msgid "Session ID is required."
|
||||||
|
msgstr "Sessie-ID is vereist."
|
||||||
|
|
||||||
|
#: core/addons/views.py:29
|
||||||
|
msgid "Session not found or expired."
|
||||||
|
msgstr "Sessie niet gevonden of verlopen."
|
||||||
|
|
||||||
|
#: core/addons/views.py:32
|
||||||
|
msgid "Invalid session state."
|
||||||
|
msgstr "Ongeldige sessiestatus."
|
||||||
|
|
||||||
|
#: core/addons/views.py:45
|
||||||
|
msgid "Authentication required."
|
||||||
|
msgstr "Authenticatie vereist."
|
||||||
|
|
||||||
|
#: core/addons/views.py:50
|
||||||
|
msgid "No active session found."
|
||||||
|
msgstr "Geen actieve sessie gevonden."
|
||||||
|
|
||||||
|
#: core/addons/views.py:55
|
||||||
|
msgid "Invalid or expired session."
|
||||||
|
msgstr "Ongeldige of verlopen sessie."
|
||||||
|
|
||||||
#: core/admin.py:29
|
#: core/admin.py:29
|
||||||
msgid "Personal info"
|
msgid "Personal info"
|
||||||
msgstr "Persoonlijke informatie"
|
msgstr "Persoonlijke informatie"
|
||||||
@@ -29,167 +53,140 @@ msgstr "Rechten"
|
|||||||
msgid "Important dates"
|
msgid "Important dates"
|
||||||
msgstr "Belangrijke datums"
|
msgstr "Belangrijke datums"
|
||||||
|
|
||||||
#: core/admin.py:132 core/admin.py:275
|
#: core/admin.py:128 core/admin.py:228
|
||||||
msgid "No owner"
|
msgid "No owner"
|
||||||
msgstr "Geen eigenaar"
|
msgstr "Geen eigenaar"
|
||||||
|
|
||||||
#: core/admin.py:135 core/admin.py:278
|
#: core/admin.py:131 core/admin.py:231
|
||||||
msgid "Multiple owners"
|
msgid "Multiple owners"
|
||||||
msgstr "Meerdere eigenaren"
|
msgstr "Meerdere eigenaren"
|
||||||
|
|
||||||
#: core/admin.py:148
|
#: core/admin.py:143
|
||||||
msgid "Resend notification to external service"
|
msgid "Resend notification to external service"
|
||||||
msgstr "Melding opnieuw verzenden naar externe dienst"
|
msgstr "Melding opnieuw verzenden naar externe dienst"
|
||||||
|
|
||||||
#: core/admin.py:171
|
#: core/admin.py:166
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s"
|
msgid "Failed to notify for recording %(id)s"
|
||||||
msgstr "Melding voor opname %(id)s mislukt"
|
msgstr "Melding voor opname %(id)s mislukt"
|
||||||
|
|
||||||
#: core/admin.py:179
|
#: core/admin.py:174
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Failed to notify for recording %(id)s: %(error)s"
|
msgid "Failed to notify for recording %(id)s: %(error)s"
|
||||||
msgstr "Melding voor opname %(id)s mislukt: %(error)s"
|
msgstr "Melding voor opname %(id)s mislukt: %(error)s"
|
||||||
|
|
||||||
#: core/admin.py:187
|
#: core/admin.py:182
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Successfully sent notifications for %(count)s recording(s)."
|
msgid "Successfully sent notifications for %(count)s recording(s)."
|
||||||
msgstr "Meldingen succesvol verzonden voor %(count)s opname(n)."
|
msgstr "Meldingen succesvol verzonden voor %(count)s opname(n)."
|
||||||
|
|
||||||
#: core/admin.py:195
|
#: core/admin.py:190
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid "Skipped %(count)s expired recording(s)."
|
msgid "Skipped %(count)s expired recording(s)."
|
||||||
msgstr "%(count)s verlopen opname(n) overgeslagen."
|
msgstr "%(count)s verlopen opname(n) overgeslagen."
|
||||||
|
|
||||||
#: core/admin.py:200
|
#: core/admin.py:294
|
||||||
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"
|
msgid "No scopes"
|
||||||
msgstr "Geen scopes"
|
msgstr "Geen scopes"
|
||||||
|
|
||||||
#: core/admin.py:344
|
#: core/admin.py:296
|
||||||
msgid "Scopes"
|
msgid "Scopes"
|
||||||
msgstr "Scopes"
|
msgstr "Scopes"
|
||||||
|
|
||||||
#: core/api/filters.py:25
|
#: core/api/serializers.py:68
|
||||||
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."
|
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
|
"Je moet beheerder of eigenaar van een ruimte zijn om toegang toe te voegen."
|
||||||
|
|
||||||
#: core/api/serializers.py:516
|
#: core/models.py:34
|
||||||
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"
|
msgid "Member"
|
||||||
msgstr "Lid"
|
msgstr "Lid"
|
||||||
|
|
||||||
#: core/models.py:38
|
#: core/models.py:35
|
||||||
msgid "Administrator"
|
msgid "Administrator"
|
||||||
msgstr "Beheerder"
|
msgstr "Beheerder"
|
||||||
|
|
||||||
#: core/models.py:39
|
#: core/models.py:36
|
||||||
msgid "Owner"
|
msgid "Owner"
|
||||||
msgstr "Eigenaar"
|
msgstr "Eigenaar"
|
||||||
|
|
||||||
#: core/models.py:55
|
#: core/models.py:52
|
||||||
msgid "Initiated"
|
msgid "Initiated"
|
||||||
msgstr "Gestart"
|
msgstr "Gestart"
|
||||||
|
|
||||||
#: core/models.py:56
|
#: core/models.py:53
|
||||||
msgid "Active"
|
msgid "Active"
|
||||||
msgstr "Actief"
|
msgstr "Actief"
|
||||||
|
|
||||||
#: core/models.py:57
|
#: core/models.py:54
|
||||||
msgid "Stopped"
|
msgid "Stopped"
|
||||||
msgstr "Gestopt"
|
msgstr "Gestopt"
|
||||||
|
|
||||||
#: core/models.py:58
|
#: core/models.py:55
|
||||||
msgid "Saved"
|
msgid "Saved"
|
||||||
msgstr "Opgeslagen"
|
msgstr "Opgeslagen"
|
||||||
|
|
||||||
#: core/models.py:59
|
#: core/models.py:56
|
||||||
msgid "Aborted"
|
msgid "Aborted"
|
||||||
msgstr "Afgebroken"
|
msgstr "Afgebroken"
|
||||||
|
|
||||||
#: core/models.py:60
|
#: core/models.py:57
|
||||||
msgid "Failed to Start"
|
msgid "Failed to Start"
|
||||||
msgstr "Starten mislukt"
|
msgstr "Starten mislukt"
|
||||||
|
|
||||||
#: core/models.py:61
|
#: core/models.py:58
|
||||||
msgid "Failed to Stop"
|
msgid "Failed to Stop"
|
||||||
msgstr "Stoppen mislukt"
|
msgstr "Stoppen mislukt"
|
||||||
|
|
||||||
#: core/models.py:62
|
#: core/models.py:59
|
||||||
msgid "Notification succeeded"
|
msgid "Notification succeeded"
|
||||||
msgstr "Notificatie geslaagd"
|
msgstr "Notificatie geslaagd"
|
||||||
|
|
||||||
#: core/models.py:89
|
#: core/models.py:86
|
||||||
msgid "SCREEN_RECORDING"
|
msgid "SCREEN_RECORDING"
|
||||||
msgstr "SCHERM_OPNAME"
|
msgstr "SCHERM_OPNAME"
|
||||||
|
|
||||||
#: core/models.py:90
|
#: core/models.py:87
|
||||||
msgid "TRANSCRIPT"
|
msgid "TRANSCRIPT"
|
||||||
msgstr "TRANSCRIPT"
|
msgstr "TRANSCRIPT"
|
||||||
|
|
||||||
#: core/models.py:96
|
#: core/models.py:93
|
||||||
msgid "Public Access"
|
msgid "Public Access"
|
||||||
msgstr "Openbare toegang"
|
msgstr "Openbare toegang"
|
||||||
|
|
||||||
#: core/models.py:97
|
#: core/models.py:94
|
||||||
msgid "Trusted Access"
|
msgid "Trusted Access"
|
||||||
msgstr "Vertrouwde toegang"
|
msgstr "Vertrouwde toegang"
|
||||||
|
|
||||||
#: core/models.py:98
|
#: core/models.py:95
|
||||||
msgid "Restricted Access"
|
msgid "Restricted Access"
|
||||||
msgstr "Beperkte toegang"
|
msgstr "Beperkte toegang"
|
||||||
|
|
||||||
#: core/models.py:110
|
#: core/models.py:107
|
||||||
msgid "id"
|
msgid "id"
|
||||||
msgstr "id"
|
msgstr "id"
|
||||||
|
|
||||||
#: core/models.py:111
|
#: core/models.py:108
|
||||||
msgid "primary key for the record as UUID"
|
msgid "primary key for the record as UUID"
|
||||||
msgstr "primaire sleutel voor het record als UUID"
|
msgstr "primaire sleutel voor het record als UUID"
|
||||||
|
|
||||||
#: core/models.py:117
|
#: core/models.py:114
|
||||||
msgid "created on"
|
msgid "created on"
|
||||||
msgstr "aangemaakt op"
|
msgstr "aangemaakt op"
|
||||||
|
|
||||||
#: core/models.py:118
|
#: core/models.py:115
|
||||||
msgid "date and time at which a record was created"
|
msgid "date and time at which a record was created"
|
||||||
msgstr "datum en tijd waarop een record werd aangemaakt"
|
msgstr "datum en tijd waarop een record werd aangemaakt"
|
||||||
|
|
||||||
#: core/models.py:123
|
#: core/models.py:120
|
||||||
msgid "updated on"
|
msgid "updated on"
|
||||||
msgstr "bijgewerkt op"
|
msgstr "bijgewerkt op"
|
||||||
|
|
||||||
#: core/models.py:124
|
#: core/models.py:121
|
||||||
msgid "date and time at which a record was last updated"
|
msgid "date and time at which a record was last updated"
|
||||||
msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt"
|
msgstr "datum en tijd waarop een record voor het laatst werd bijgewerkt"
|
||||||
|
|
||||||
#: core/models.py:144
|
#: core/models.py:141
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||||
"_ characters."
|
"_ characters."
|
||||||
@@ -197,11 +194,11 @@ msgstr ""
|
|||||||
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
|
"Voer een geldige sub in. Deze waarde mag alleen letters, cijfers en @/./+/-/"
|
||||||
"_ tekens bevatten."
|
"_ tekens bevatten."
|
||||||
|
|
||||||
#: core/models.py:150
|
#: core/models.py:147
|
||||||
msgid "sub"
|
msgid "sub"
|
||||||
msgstr "sub"
|
msgstr "sub"
|
||||||
|
|
||||||
#: core/models.py:152
|
#: core/models.py:149
|
||||||
msgid ""
|
msgid ""
|
||||||
"Optional for pending users; required upon account activation. 255 characters "
|
"Optional for pending users; required upon account activation. 255 characters "
|
||||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||||
@@ -209,55 +206,55 @@ msgstr ""
|
|||||||
"Optioneel voor gebruikers in afwachting; vereist bij accountactivering. "
|
"Optioneel voor gebruikers in afwachting; vereist bij accountactivering. "
|
||||||
"Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan."
|
"Maximum 255 tekens. Alleen letters, cijfers en @/./+/-/_ toegestaan."
|
||||||
|
|
||||||
#: core/models.py:161
|
#: core/models.py:158
|
||||||
msgid "identity email address"
|
msgid "identity email address"
|
||||||
msgstr "identiteit e-mailadres"
|
msgstr "identiteit e-mailadres"
|
||||||
|
|
||||||
#: core/models.py:166
|
#: core/models.py:163
|
||||||
msgid "admin email address"
|
msgid "admin email address"
|
||||||
msgstr "beheerder e-mailadres"
|
msgstr "beheerder e-mailadres"
|
||||||
|
|
||||||
#: core/models.py:168
|
#: core/models.py:165
|
||||||
msgid "full name"
|
msgid "full name"
|
||||||
msgstr "volledige naam"
|
msgstr "volledige naam"
|
||||||
|
|
||||||
#: core/models.py:170
|
#: core/models.py:167
|
||||||
msgid "short name"
|
msgid "short name"
|
||||||
msgstr "korte naam"
|
msgstr "korte naam"
|
||||||
|
|
||||||
#: core/models.py:176
|
#: core/models.py:173
|
||||||
msgid "language"
|
msgid "language"
|
||||||
msgstr "taal"
|
msgstr "taal"
|
||||||
|
|
||||||
#: core/models.py:177
|
#: core/models.py:174
|
||||||
msgid "The language in which the user wants to see the interface."
|
msgid "The language in which the user wants to see the interface."
|
||||||
msgstr "De taal waarin de gebruiker de interface wil zien."
|
msgstr "De taal waarin de gebruiker de interface wil zien."
|
||||||
|
|
||||||
#: core/models.py:183
|
#: core/models.py:180
|
||||||
msgid "The timezone in which the user wants to see times."
|
msgid "The timezone in which the user wants to see times."
|
||||||
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
|
msgstr "De tijdzone waarin de gebruiker tijden wil zien."
|
||||||
|
|
||||||
#: core/models.py:186
|
#: core/models.py:183
|
||||||
msgid "device"
|
msgid "device"
|
||||||
msgstr "apparaat"
|
msgstr "apparaat"
|
||||||
|
|
||||||
#: core/models.py:188
|
#: core/models.py:185
|
||||||
msgid "Whether the user is a device or a real user."
|
msgid "Whether the user is a device or a real user."
|
||||||
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
|
msgstr "Of de gebruiker een apparaat is of een echte gebruiker."
|
||||||
|
|
||||||
#: core/models.py:191
|
#: core/models.py:188
|
||||||
msgid "staff status"
|
msgid "staff status"
|
||||||
msgstr "personeelsstatus"
|
msgstr "personeelsstatus"
|
||||||
|
|
||||||
#: core/models.py:193
|
#: core/models.py:190
|
||||||
msgid "Whether the user can log into this admin site."
|
msgid "Whether the user can log into this admin site."
|
||||||
msgstr "Of de gebruiker kan inloggen op deze beheersite."
|
msgstr "Of de gebruiker kan inloggen op deze beheersite."
|
||||||
|
|
||||||
#: core/models.py:196
|
#: core/models.py:193
|
||||||
msgid "active"
|
msgid "active"
|
||||||
msgstr "actief"
|
msgstr "actief"
|
||||||
|
|
||||||
#: core/models.py:199
|
#: core/models.py:196
|
||||||
msgid ""
|
msgid ""
|
||||||
"Whether this user should be treated as active. Unselect this instead of "
|
"Whether this user should be treated as active. Unselect this instead of "
|
||||||
"deleting accounts."
|
"deleting accounts."
|
||||||
@@ -265,64 +262,64 @@ msgstr ""
|
|||||||
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
|
"Of deze gebruiker als actief moet worden behandeld. Deselecteer dit in "
|
||||||
"plaats van accounts te verwijderen."
|
"plaats van accounts te verwijderen."
|
||||||
|
|
||||||
#: core/models.py:212
|
#: core/models.py:209
|
||||||
msgid "user"
|
msgid "user"
|
||||||
msgstr "gebruiker"
|
msgstr "gebruiker"
|
||||||
|
|
||||||
#: core/models.py:213
|
#: core/models.py:210
|
||||||
msgid "users"
|
msgid "users"
|
||||||
msgstr "gebruikers"
|
msgstr "gebruikers"
|
||||||
|
|
||||||
#: core/models.py:272
|
#: core/models.py:269
|
||||||
msgid "Resource"
|
msgid "Resource"
|
||||||
msgstr "Bron"
|
msgstr "Bron"
|
||||||
|
|
||||||
#: core/models.py:273
|
#: core/models.py:270
|
||||||
msgid "Resources"
|
msgid "Resources"
|
||||||
msgstr "Bronnen"
|
msgstr "Bronnen"
|
||||||
|
|
||||||
#: core/models.py:331
|
#: core/models.py:324
|
||||||
msgid "Resource access"
|
msgid "Resource access"
|
||||||
msgstr "Brontoegang"
|
msgstr "Brontoegang"
|
||||||
|
|
||||||
#: core/models.py:332
|
#: core/models.py:325
|
||||||
msgid "Resource accesses"
|
msgid "Resource accesses"
|
||||||
msgstr "Brontoegangsrechten"
|
msgstr "Brontoegangsrechten"
|
||||||
|
|
||||||
#: core/models.py:338
|
#: core/models.py:331
|
||||||
msgid "Resource access with this User and Resource already exists."
|
msgid "Resource access with this User and Resource already exists."
|
||||||
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
|
msgstr "Brontoegang met deze gebruiker en bron bestaat al."
|
||||||
|
|
||||||
#: core/models.py:394
|
#: core/models.py:387
|
||||||
msgid "Visio room configuration"
|
msgid "Visio room configuration"
|
||||||
msgstr "Visio-ruimteconfiguratie"
|
msgstr "Visio-ruimteconfiguratie"
|
||||||
|
|
||||||
#: core/models.py:395
|
#: core/models.py:388
|
||||||
msgid "Values for Visio parameters to configure the room."
|
msgid "Values for Visio parameters to configure the room."
|
||||||
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
|
msgstr "Waarden voor Visio-parameters om de ruimte te configureren."
|
||||||
|
|
||||||
#: core/models.py:402
|
#: core/models.py:395
|
||||||
msgid "Room PIN code"
|
msgid "Room PIN code"
|
||||||
msgstr "Pincode van de kamer"
|
msgstr "Pincode van de kamer"
|
||||||
|
|
||||||
#: core/models.py:403
|
#: core/models.py:396
|
||||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
|
"Unieke n-cijferige code die deze kamer identificeert in telefonie-modus."
|
||||||
|
|
||||||
#: core/models.py:409 core/models.py:563
|
#: core/models.py:402 core/models.py:556
|
||||||
msgid "Room"
|
msgid "Room"
|
||||||
msgstr "Ruimte"
|
msgstr "Ruimte"
|
||||||
|
|
||||||
#: core/models.py:410
|
#: core/models.py:403
|
||||||
msgid "Rooms"
|
msgid "Rooms"
|
||||||
msgstr "Ruimtes"
|
msgstr "Ruimtes"
|
||||||
|
|
||||||
#: core/models.py:574
|
#: core/models.py:567
|
||||||
msgid "Worker ID"
|
msgid "Worker ID"
|
||||||
msgstr "Worker ID"
|
msgstr "Worker ID"
|
||||||
|
|
||||||
#: core/models.py:576
|
#: core/models.py:569
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||||
"the worker stops, allowing for easy tracking."
|
"the worker stops, allowing for easy tracking."
|
||||||
@@ -330,147 +327,107 @@ msgstr ""
|
|||||||
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
|
"Voer een identificatie in voor de worker-opname. Deze ID blijft behouden, "
|
||||||
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
|
"zelfs wanneer de worker stopt, waardoor eenvoudige tracking mogelijk is."
|
||||||
|
|
||||||
#: core/models.py:584
|
#: core/models.py:577
|
||||||
msgid "Recording mode"
|
msgid "Recording mode"
|
||||||
msgstr "Opnamemodus"
|
msgstr "Opnamemodus"
|
||||||
|
|
||||||
#: core/models.py:585
|
#: core/models.py:578
|
||||||
msgid "Defines the mode of recording being called."
|
msgid "Defines the mode of recording being called."
|
||||||
msgstr "Definieert de modus van opname die wordt aangeroepen."
|
msgstr "Definieert de modus van opname die wordt aangeroepen."
|
||||||
|
|
||||||
#: core/models.py:590 core/models.py:591
|
#: core/models.py:583 core/models.py:584
|
||||||
msgid "Recording options"
|
msgid "Recording options"
|
||||||
msgstr "Opnameopties"
|
msgstr "Opnameopties"
|
||||||
|
|
||||||
#: core/models.py:597
|
#: core/models.py:590
|
||||||
msgid "Recording"
|
msgid "Recording"
|
||||||
msgstr "Opname"
|
msgstr "Opname"
|
||||||
|
|
||||||
#: core/models.py:598
|
#: core/models.py:591
|
||||||
msgid "Recordings"
|
msgid "Recordings"
|
||||||
msgstr "Opnames"
|
msgstr "Opnames"
|
||||||
|
|
||||||
#: core/models.py:706
|
#: core/models.py:699
|
||||||
msgid "Recording/user relation"
|
msgid "Recording/user relation"
|
||||||
msgstr "Opname/gebruiker-relatie"
|
msgstr "Opname/gebruiker-relatie"
|
||||||
|
|
||||||
#: core/models.py:707
|
#: core/models.py:700
|
||||||
msgid "Recording/user relations"
|
msgid "Recording/user relations"
|
||||||
msgstr "Opname/gebruiker-relaties"
|
msgstr "Opname/gebruiker-relaties"
|
||||||
|
|
||||||
#: core/models.py:713
|
#: core/models.py:706
|
||||||
msgid "This user is already in this recording."
|
msgid "This user is already in this recording."
|
||||||
msgstr "Deze gebruiker is al in deze opname."
|
msgstr "Deze gebruiker is al in deze opname."
|
||||||
|
|
||||||
#: core/models.py:719
|
#: core/models.py:712
|
||||||
msgid "This team is already in this recording."
|
msgid "This team is already in this recording."
|
||||||
msgstr "Dit team is al in deze opname."
|
msgstr "Dit team is al in deze opname."
|
||||||
|
|
||||||
#: core/models.py:725
|
#: core/models.py:718
|
||||||
msgid "Either user or team must be set, not both."
|
msgid "Either user or team must be set, not both."
|
||||||
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
|
msgstr "Ofwel gebruiker of team moet worden ingesteld, niet beide."
|
||||||
|
|
||||||
#: core/models.py:742
|
#: core/models.py:735
|
||||||
msgid "Create rooms"
|
msgid "Create rooms"
|
||||||
msgstr "Ruimtes aanmaken"
|
msgstr "Ruimtes aanmaken"
|
||||||
|
|
||||||
#: core/models.py:743
|
#: core/models.py:736
|
||||||
msgid "List rooms"
|
msgid "List rooms"
|
||||||
msgstr "Ruimtes weergeven"
|
msgstr "Ruimtes weergeven"
|
||||||
|
|
||||||
#: core/models.py:744
|
#: core/models.py:737
|
||||||
msgid "Retrieve room details"
|
msgid "Retrieve room details"
|
||||||
msgstr "Details van een ruimte ophalen"
|
msgstr "Details van een ruimte ophalen"
|
||||||
|
|
||||||
#: core/models.py:745
|
#: core/models.py:738
|
||||||
msgid "Update rooms"
|
msgid "Update rooms"
|
||||||
msgstr "Ruimtes bijwerken"
|
msgstr "Ruimtes bijwerken"
|
||||||
|
|
||||||
#: core/models.py:746
|
#: core/models.py:739
|
||||||
msgid "Delete rooms"
|
msgid "Delete rooms"
|
||||||
msgstr "Ruimtes verwijderen"
|
msgstr "Ruimtes verwijderen"
|
||||||
|
|
||||||
#: core/models.py:759
|
#: core/models.py:752
|
||||||
msgid "Application name"
|
msgid "Application name"
|
||||||
msgstr "Naam van de applicatie"
|
msgstr "Naam van de applicatie"
|
||||||
|
|
||||||
#: core/models.py:760
|
#: core/models.py:753
|
||||||
msgid "Descriptive name for this application."
|
msgid "Descriptive name for this application."
|
||||||
msgstr "Beschrijvende naam voor deze applicatie."
|
msgstr "Beschrijvende naam voor deze applicatie."
|
||||||
|
|
||||||
#: core/models.py:770
|
#: core/models.py:763
|
||||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
|
"Wordt gehasht bij het opslaan. Kopieer het nu als dit een nieuw geheim is."
|
||||||
|
|
||||||
#: core/models.py:781
|
#: core/models.py:774
|
||||||
msgid "Application"
|
msgid "Application"
|
||||||
msgstr "Applicatie"
|
msgstr "Applicatie"
|
||||||
|
|
||||||
#: core/models.py:782
|
#: core/models.py:775
|
||||||
msgid "Applications"
|
msgid "Applications"
|
||||||
msgstr "Applicaties"
|
msgstr "Applicaties"
|
||||||
|
|
||||||
#: core/models.py:805
|
#: core/models.py:798
|
||||||
msgid "Enter a valid domain"
|
msgid "Enter a valid domain"
|
||||||
msgstr "Voer een geldig domein in"
|
msgstr "Voer een geldig domein in"
|
||||||
|
|
||||||
#: core/models.py:808
|
#: core/models.py:801
|
||||||
msgid "Domain"
|
msgid "Domain"
|
||||||
msgstr "Domein"
|
msgstr "Domein"
|
||||||
|
|
||||||
#: core/models.py:809
|
#: core/models.py:802
|
||||||
msgid "Email domain this application can act on behalf of."
|
msgid "Email domain this application can act on behalf of."
|
||||||
msgstr "E-maildomein namens welke deze applicatie kan handelen."
|
msgstr "E-maildomein namens welke deze applicatie kan handelen."
|
||||||
|
|
||||||
#: core/models.py:821
|
#: core/models.py:814
|
||||||
msgid "Application domain"
|
msgid "Application domain"
|
||||||
msgstr "Applicatiedomein"
|
msgstr "Applicatiedomein"
|
||||||
|
|
||||||
#: core/models.py:822
|
#: core/models.py:815
|
||||||
msgid "Application domains"
|
msgid "Application domains"
|
||||||
msgstr "Applicatiedomeinen"
|
msgstr "Applicatiedomeinen"
|
||||||
|
|
||||||
#: 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
|
#: core/recording/event/notification.py:116
|
||||||
msgid "Your recording is ready"
|
msgid "Your recording is ready"
|
||||||
msgstr "Je opname is klaar"
|
msgstr "Je opname is klaar"
|
||||||
@@ -480,6 +437,30 @@ msgstr "Je opname is klaar"
|
|||||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||||
msgstr "Video-oproep bezig: {sender.email} wacht op je verbinding"
|
msgstr "Video-oproep bezig: {sender.email} wacht op je verbinding"
|
||||||
|
|
||||||
|
#: core/templates/addons/error.html:7 core/templates/addons/error.html:11
|
||||||
|
msgid "Error"
|
||||||
|
msgstr "Fout"
|
||||||
|
|
||||||
|
#: core/templates/addons/error.html:12
|
||||||
|
msgid "Something went wrong."
|
||||||
|
msgstr "Er is iets misgegaan."
|
||||||
|
|
||||||
|
#: core/templates/addons/error.html:13
|
||||||
|
msgid "Close"
|
||||||
|
msgstr "Sluiten"
|
||||||
|
|
||||||
|
#: core/templates/addons/redirect_success.html:7
|
||||||
|
msgid "Authentication Success"
|
||||||
|
msgstr "Authenticatie geslaagd"
|
||||||
|
|
||||||
|
#: core/templates/addons/redirect_success.html:13
|
||||||
|
msgid "Session stored successfully. This window will close automatically."
|
||||||
|
msgstr "Sessie succesvol opgeslagen. Dit venster wordt automatisch gesloten."
|
||||||
|
|
||||||
|
#: core/templates/addons/redirect_success.html:14
|
||||||
|
msgid "If it doesn't close"
|
||||||
|
msgstr "Als het niet sluit"
|
||||||
|
|
||||||
#: core/templates/mail/html/invitation.html:159
|
#: core/templates/mail/html/invitation.html:159
|
||||||
#: core/templates/mail/html/screen_recording.html:159
|
#: core/templates/mail/html/screen_recording.html:159
|
||||||
#: core/templates/mail/text/invitation.txt:3
|
#: core/templates/mail/text/invitation.txt:3
|
||||||
@@ -599,18 +580,18 @@ msgstr ""
|
|||||||
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
|
" Als je vragen hebt of hulp nodig hebt, neem dan contact op met ons support "
|
||||||
"team via %(support_email)s. "
|
"team via %(support_email)s. "
|
||||||
|
|
||||||
#: meet/settings.py:223
|
#: meet/settings.py:169
|
||||||
msgid "English"
|
msgid "English"
|
||||||
msgstr "Engels"
|
msgstr "Engels"
|
||||||
|
|
||||||
#: meet/settings.py:224
|
#: meet/settings.py:170
|
||||||
msgid "French"
|
msgid "French"
|
||||||
msgstr "Frans"
|
msgstr "Frans"
|
||||||
|
|
||||||
#: meet/settings.py:225
|
#: meet/settings.py:171
|
||||||
msgid "Dutch"
|
msgid "Dutch"
|
||||||
msgstr "Nederlands"
|
msgstr "Nederlands"
|
||||||
|
|
||||||
#: meet/settings.py:226
|
#: meet/settings.py:172
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr "Duits"
|
msgstr "Duits"
|
||||||
|
|||||||
@@ -28,10 +28,6 @@ from sentry_sdk.integrations.logging import ignore_logger
|
|||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
BASE_DIR = path.dirname(path.dirname(path.abspath(__file__)))
|
BASE_DIR = path.dirname(path.dirname(path.abspath(__file__)))
|
||||||
|
|
||||||
KB = 1024
|
|
||||||
MB = 1024 * KB
|
|
||||||
GB = 1024 * MB
|
|
||||||
|
|
||||||
|
|
||||||
def get_release():
|
def get_release():
|
||||||
"""
|
"""
|
||||||
@@ -125,9 +121,6 @@ class Base(Configuration):
|
|||||||
STATIC_ROOT = path.join(DATA_DIR, "static")
|
STATIC_ROOT = path.join(DATA_DIR, "static")
|
||||||
MEDIA_URL = "/media/"
|
MEDIA_URL = "/media/"
|
||||||
MEDIA_ROOT = path.join(DATA_DIR, "media")
|
MEDIA_ROOT = path.join(DATA_DIR, "media")
|
||||||
MEDIA_BASE_URL = values.Value(
|
|
||||||
"", environ_name="MEDIA_BASE_URL", environ_prefix=None
|
|
||||||
)
|
|
||||||
|
|
||||||
SITE_ID = 1
|
SITE_ID = 1
|
||||||
|
|
||||||
@@ -161,48 +154,6 @@ class Base(Configuration):
|
|||||||
environ_name="AWS_STORAGE_BUCKET_NAME",
|
environ_name="AWS_STORAGE_BUCKET_NAME",
|
||||||
environ_prefix=None,
|
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
|
# Internationalization
|
||||||
# https://docs.djangoproject.com/en/3.1/topics/i18n/
|
# https://docs.djangoproject.com/en/3.1/topics/i18n/
|
||||||
@@ -443,13 +394,8 @@ class Base(Configuration):
|
|||||||
THUMBNAIL_ALIASES = {}
|
THUMBNAIL_ALIASES = {}
|
||||||
|
|
||||||
# Celery
|
# Celery
|
||||||
# Defaults to False to avoid breaking change, async task will be run
|
CELERY_BROKER_URL = values.Value("redis://redis:6379/0")
|
||||||
# synchronously
|
CELERY_BROKER_TRANSPORT_OPTIONS = values.DictValue({})
|
||||||
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
|
||||||
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
|
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
|
||||||
@@ -650,12 +596,6 @@ class Base(Configuration):
|
|||||||
ALLOW_UNREGISTERED_ROOMS = values.BooleanValue(
|
ALLOW_UNREGISTERED_ROOMS = values.BooleanValue(
|
||||||
True, environ_name="ALLOW_UNREGISTERED_ROOMS", environ_prefix=None
|
True, environ_name="ALLOW_UNREGISTERED_ROOMS", environ_prefix=None
|
||||||
)
|
)
|
||||||
# if provided, treat as suspicious (possible privilege escalation attempt).
|
|
||||||
PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS = values.ListValue(
|
|
||||||
["hidden", "recorder", "agent"],
|
|
||||||
environ_name="PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS",
|
|
||||||
environ_prefix=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Recording settings
|
# Recording settings
|
||||||
RECORDING_ENABLE = values.BooleanValue(
|
RECORDING_ENABLE = values.BooleanValue(
|
||||||
@@ -857,6 +797,93 @@ class Base(Configuration):
|
|||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Addons
|
||||||
|
ADDONS_ENABLED = values.BooleanValue(
|
||||||
|
False,
|
||||||
|
environ_name="ADDONS_ENABLED",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
ADDONS_SESSION_ID_LENGTH = values.PositiveIntegerValue(
|
||||||
|
32,
|
||||||
|
environ_name="ADDONS_SESSION_ID_LENGTH",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
# Cache key for the session itself (session_id → session data)
|
||||||
|
ADDONS_SESSION_KEY_PREFIX = values.Value(
|
||||||
|
"addons_session_id",
|
||||||
|
environ_name="ADDONS_SESSION_KEY_PREFIX",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
# Cache key for the token → session binding (result_token → session_id)
|
||||||
|
ADDONS_SESSION_TOKEN_PREFIX = values.Value(
|
||||||
|
"addons_token_id",
|
||||||
|
environ_name="ADDONS_SESSION_TOKEN_PREFIX",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
# Used as the Django session key in transit page
|
||||||
|
ADDONS_SESSION_KEY_AUTH = values.Value(
|
||||||
|
"addons_session_id",
|
||||||
|
environ_name="ADDONS_SESSION_KEY_AUTH",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
ADDONS_SESSION_TIMEOUT = values.PositiveIntegerValue(
|
||||||
|
600, environ_name="ADDONS_SESSION_TIMEOUT", environ_prefix=None
|
||||||
|
)
|
||||||
|
ADDONS_RESULT_TOKEN_COOKIE_NAME = values.Value(
|
||||||
|
"wip",
|
||||||
|
environ_name="ADDONS_RESULT_TOKEN_COOKIE_NAME",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
ADDONS_RESULT_TOKEN_COOKIE_SECURE = values.BooleanValue(
|
||||||
|
True,
|
||||||
|
environ_name="ADDONS_RESULT_TOKEN_COOKIE_SECURE",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
ADDONS_RESULT_TOKEN_COOKIE_HTTP_ONLY = values.BooleanValue(
|
||||||
|
True,
|
||||||
|
environ_name="ADDONS_RESULT_TOKEN_COOKIE_HTTP_ONLY",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
ADDONS_RESULT_TOKEN_COOKIE_SAMESITE = values.Value(
|
||||||
|
"strict",
|
||||||
|
environ_name="ADDONS_RESULT_TOKEN_COOKIE_SAMESITE",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
ADDONS_JWT_SECRET_KEY = SecretFileValue(
|
||||||
|
None, environ_name="ADDONS_JWT_SECRET_KEY", environ_prefix=None
|
||||||
|
)
|
||||||
|
ADDONS_JWT_ALG = values.Value(
|
||||||
|
"HS256",
|
||||||
|
environ_name="ADDONS_JWT_ALG",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
ADDONS_SCOPES = values.Value(
|
||||||
|
"rooms:create rooms:list",
|
||||||
|
environ_name="ADDONS_SCOPES",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
ADDONS_JWT_ISSUER = values.Value(
|
||||||
|
"lasuite-meet",
|
||||||
|
environ_name="ADDONS_JWT_ISSUER",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
ADDONS_JWT_AUDIENCE = values.Value(
|
||||||
|
None,
|
||||||
|
environ_name="ADDONS_JWT_AUDIENCE",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
ADDONS_JWT_EXPIRATION_SECONDS = values.PositiveIntegerValue(
|
||||||
|
3600,
|
||||||
|
environ_name="ADDONS_JWT_EXPIRATION_SECONDS",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
ADDONS_JWT_TOKEN_TYPE = values.Value(
|
||||||
|
"Bearer",
|
||||||
|
environ_name="ADDONS_JWT_TOKEN_TYPE",
|
||||||
|
environ_prefix=None,
|
||||||
|
)
|
||||||
|
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
@property
|
@property
|
||||||
def ENVIRONMENT(self):
|
def ENVIRONMENT(self):
|
||||||
@@ -980,8 +1007,7 @@ class Test(Base):
|
|||||||
APPLICATION_JWT_SECRET_KEY = "devKey" # noqa:S105
|
APPLICATION_JWT_SECRET_KEY = "devKey" # noqa:S105
|
||||||
APPLICATION_JWT_AUDIENCE = "Test inc."
|
APPLICATION_JWT_AUDIENCE = "Test inc."
|
||||||
|
|
||||||
CELERY_TASK_ALWAYS_EAGER = True
|
CELERY_TASK_ALWAYS_EAGER = values.BooleanValue(True)
|
||||||
FILE_UPLOAD_ENABLED = True
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
# pylint: disable=invalid-name
|
# pylint: disable=invalid-name
|
||||||
|
|||||||
+15
-19
@@ -2,12 +2,12 @@
|
|||||||
# Meet package
|
# Meet package
|
||||||
#
|
#
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["uv_build>=0.10.9,<0.11.0"]
|
requires = ["setuptools"]
|
||||||
build-backend = "uv_build"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "meet"
|
name = "meet"
|
||||||
version = "1.10.0"
|
version = "1.8.0"
|
||||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Development Status :: 5 - Production/Stable",
|
"Development Status :: 5 - Production/Stable",
|
||||||
@@ -17,12 +17,13 @@ classifiers = [
|
|||||||
"License :: OSI Approved :: MIT License",
|
"License :: OSI Approved :: MIT License",
|
||||||
"Natural Language :: English",
|
"Natural Language :: English",
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
"Programming Language :: Python :: 3.13",
|
"Programming Language :: Python :: 3.10",
|
||||||
]
|
]
|
||||||
description = "A simple video and phone conferencing tool, powered by LiveKit"
|
description = "A simple video and phone conferencing tool, powered by LiveKit"
|
||||||
keywords = ["Django", "Contacts", "Templates", "RBAC"]
|
keywords = ["Django", "Contacts", "Templates", "RBAC"]
|
||||||
license = "MIT"
|
license = { file = "LICENSE" }
|
||||||
requires-python = ">=3.13"
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"boto3==1.42.49",
|
"boto3==1.42.49",
|
||||||
"Brotli==1.2.0",
|
"Brotli==1.2.0",
|
||||||
@@ -32,15 +33,13 @@ dependencies = [
|
|||||||
"django-configurations==2.5.1",
|
"django-configurations==2.5.1",
|
||||||
"django-cors-headers==4.9.0",
|
"django-cors-headers==4.9.0",
|
||||||
"django-countries==8.2.0",
|
"django-countries==8.2.0",
|
||||||
"django-filter==25.2",
|
|
||||||
"django-lasuite[all]==0.0.24",
|
"django-lasuite[all]==0.0.24",
|
||||||
"django-parler==2.3",
|
"django-parler==2.3",
|
||||||
"redis==5.2.1",
|
"redis==5.2.1",
|
||||||
"django-redis==6.0.0",
|
"django-redis==6.0.0",
|
||||||
"django-storages[s3]==1.14.6",
|
"django-storages[s3]==1.14.6",
|
||||||
"django-timezone-field>=5.1",
|
"django-timezone-field>=5.1",
|
||||||
"django-pydantic-field==0.5.4",
|
"django==5.2.11",
|
||||||
"django==5.2.12",
|
|
||||||
"djangorestframework==3.16.1",
|
"djangorestframework==3.16.1",
|
||||||
"drf_spectacular==0.29.0",
|
"drf_spectacular==0.29.0",
|
||||||
"dockerflow==2026.1.26",
|
"dockerflow==2026.1.26",
|
||||||
@@ -51,10 +50,8 @@ dependencies = [
|
|||||||
"markdown==3.10.2",
|
"markdown==3.10.2",
|
||||||
"nested-multipart-parser==1.6.0",
|
"nested-multipart-parser==1.6.0",
|
||||||
"psycopg[binary]==3.3.2",
|
"psycopg[binary]==3.3.2",
|
||||||
"pydantic==2.12.4",
|
|
||||||
"PyJWT==2.11.0",
|
"PyJWT==2.11.0",
|
||||||
"python-frontmatter==1.1.0",
|
"python-frontmatter==1.1.0",
|
||||||
"python-magic==0.4.27",
|
|
||||||
"requests==2.32.5",
|
"requests==2.32.5",
|
||||||
"sentry-sdk==2.53.0",
|
"sentry-sdk==2.53.0",
|
||||||
"whitenoise==6.11.0",
|
"whitenoise==6.11.0",
|
||||||
@@ -69,7 +66,7 @@ dependencies = [
|
|||||||
"Homepage" = "https://github.com/suitenumerique/meet"
|
"Homepage" = "https://github.com/suitenumerique/meet"
|
||||||
"Repository" = "https://github.com/suitenumerique/meet"
|
"Repository" = "https://github.com/suitenumerique/meet"
|
||||||
|
|
||||||
[dependency-groups]
|
[project.optional-dependencies]
|
||||||
dev = [
|
dev = [
|
||||||
"django-extensions==4.1",
|
"django-extensions==4.1",
|
||||||
"drf-spectacular-sidecar==2026.1.1",
|
"drf-spectacular-sidecar==2026.1.1",
|
||||||
@@ -89,13 +86,12 @@ dev = [
|
|||||||
"types-requests==2.32.4.20260107",
|
"types-requests==2.32.4.20260107",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv.build-backend]
|
[tool.setuptools]
|
||||||
module-root = ""
|
packages = { find = { where = ["."], exclude = ["tests"] } }
|
||||||
source-exclude = [
|
zip-safe = true
|
||||||
"**/tests/**",
|
|
||||||
"**/test_*.py",
|
[tool.distutils.bdist_wheel]
|
||||||
"**/tests.py",
|
universal = true
|
||||||
]
|
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
exclude = [
|
exclude = [
|
||||||
|
|||||||
Generated
-2365
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,41 @@ server {
|
|||||||
add_header Content-Disposition "attachment; filename=windows-app-web-link";
|
add_header Content-Disposition "attachment; filename=windows-app-web-link";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Manifest — fetched, never iframed
|
||||||
|
location = /outlook-addin/manifest.xml {
|
||||||
|
alias /usr/share/nginx/html/outlook-addin/manifest.xml;
|
||||||
|
|
||||||
|
add_header Access-Control-Allow-Origin "*";
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
add_header X-Frame-Options "DENY";
|
||||||
|
add_header Content-Security-Policy "frame-ancestors 'none'";
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ ^/outlook-addin(/.*)?$ {
|
||||||
|
alias /usr/share/nginx/html/outlook-addin$1;
|
||||||
|
add_header Access-Control-Allow-Origin "*";
|
||||||
|
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||||
|
|
||||||
|
set $ms_domains "https://*.live.com https://*.office.com https://*.microsoft.com https://*.office365.com https://*.sharepoint.com";
|
||||||
|
|
||||||
|
set $nonce $request_id;
|
||||||
|
|
||||||
|
set $csp "upgrade-insecure-requests; ";
|
||||||
|
set $csp "${csp}frame-ancestors ${ms_domains}; ";
|
||||||
|
set $csp "${csp}script-src 'nonce-${nonce}' 'strict-dynamic' ${ms_domains} 'self'; ";
|
||||||
|
set $csp "${csp}connect-src 'self' 'strict-dynamic' ${ms_domains}; ";
|
||||||
|
set $csp "${csp}frame-src 'none'; ";
|
||||||
|
set $csp "${csp}object-src 'none'; ";
|
||||||
|
set $csp "${csp}base-uri 'none'; ";
|
||||||
|
|
||||||
|
add_header Content-Security-Policy $csp;
|
||||||
|
|
||||||
|
sub_filter 'NONCE_PLACEHOLDER' $nonce;
|
||||||
|
sub_filter_once off;
|
||||||
|
sub_filter_types text/html;
|
||||||
|
}
|
||||||
|
|
||||||
# Serve static files with caching
|
# Serve static files with caching
|
||||||
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
location ~* ^/assets/.*\.(css|js|json|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
expires 30d;
|
expires 30d;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user