mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-29 14:09:17 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f1b0d151fd |
@@ -1,9 +0,0 @@
|
||||
[codespell]
|
||||
# Files that are not English, or generated
|
||||
skip = ./.git,*.pdf,*.po,*.pot,*.json,*.lock,package-lock.json,
|
||||
./LICENSES,
|
||||
./src/summary/summary/core/locales,
|
||||
./src/summary/summary/core/prompt.py
|
||||
# Valid words in French (connexion) or in the code (statics)
|
||||
ignore-words-list = connexion,statics
|
||||
check-filenames = true
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
name: 🐛 Bug Report
|
||||
about: If something is not working as expected 🤔.
|
||||
|
||||
---
|
||||
|
||||
## Bug Report
|
||||
|
||||
**Problematic behavior**
|
||||
A clear and concise description of the behavior.
|
||||
|
||||
**Expected behavior/code**
|
||||
A clear and concise description of what you expected to happen (or code).
|
||||
|
||||
**Steps to Reproduce**
|
||||
1. Do this...
|
||||
2. Then this...
|
||||
3. And then the bug happens!
|
||||
|
||||
**Environment**
|
||||
- Meet version:
|
||||
- Platform:
|
||||
|
||||
**Possible Solution**
|
||||
<!--- Only if you have suggestions on a fix for the bug -->
|
||||
|
||||
**Additional context/Screenshots**
|
||||
Add any other context about the problem here. If applicable, add screenshots to help explain.
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
name: ✨ Feature Request
|
||||
about: I have a suggestion (and may want to build it 💪)!
|
||||
|
||||
---
|
||||
|
||||
## Feature Request
|
||||
|
||||
**Is your feature request related to a problem or unsupported use case? Please describe.**
|
||||
A clear and concise description of what the problem is. For example: I need to do some task and I have an issue...
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen. Add any considered drawbacks.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Discovery, Documentation, Adoption, Migration Strategy**
|
||||
If you can, explain how users will be able to use this and possibly write out a version the docs (if applicable).
|
||||
Maybe a screenshot or design?
|
||||
|
||||
**Do you want to work on it through a Pull Request?**
|
||||
<!-- Make sure to coordinate with us before you spend too much time working on an implementation! -->
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: 🤗 Support Question
|
||||
about: If you have a question 💬, or something was not clear from the docs!
|
||||
|
||||
---
|
||||
|
||||
<!-- ^ Click "Preview" for a nicer view! ^
|
||||
We primarily use GitHub as an issue tracker. If however you're encountering an issue not covered in the docs, we may be able to help! -->
|
||||
|
||||
---
|
||||
|
||||
Please make sure you have read our [main Readme](https://github.com/numerique-gouv/meet).
|
||||
|
||||
Also make sure it was not already answered in [an open or close issue](https://github.com/numerique-gouv/meet/issues).
|
||||
|
||||
If your question was not covered, and you feel like it should be, fire away! We'd love to improve our docs! 👌
|
||||
|
||||
**Topic**
|
||||
What's the general area of your question: for example, docker setup, database schema, search functionality,...
|
||||
|
||||
**Question**
|
||||
Try to be as specific as possible so we can help you as best we can. Please be patient 🙏
|
||||
@@ -0,0 +1,11 @@
|
||||
## Purpose
|
||||
|
||||
Description...
|
||||
|
||||
|
||||
## Proposal
|
||||
|
||||
Description...
|
||||
|
||||
- [] item 1...
|
||||
- [] item 2...
|
||||
@@ -1,19 +0,0 @@
|
||||
name: Changelog Workflow
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changelog:
|
||||
uses: suitenumerique/ci/.github/workflows/_changelog.yml@main
|
||||
+178
-24
@@ -11,30 +11,180 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
lint-git:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request' # Makes sense only for pull requests
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: show
|
||||
run: git log
|
||||
- name: Enforce absence of print statements in code
|
||||
if: always()
|
||||
run: |
|
||||
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude).github/workflows/**' | grep "print("
|
||||
- name: Check absence of fixup commits
|
||||
if: always()
|
||||
run: |
|
||||
! git log | grep 'fixup!'
|
||||
- name: Install uv
|
||||
if: always()
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
- name: Lint commit messages added to main
|
||||
if: always()
|
||||
run: uvx --no-build --from gitlint-core==0.19.1 gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
|
||||
|
||||
lint-python:
|
||||
name: lint ${{ matrix.service }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- service: backend
|
||||
working_directory: src/backend
|
||||
pylint_targets: meet demo core
|
||||
- service: agents
|
||||
working_directory: src/agents
|
||||
pylint_targets: ""
|
||||
- service: summary
|
||||
working_directory: src/summary
|
||||
pylint_targets: ""
|
||||
uses: suitenumerique/ci/.github/workflows/_python-lint.yml@main
|
||||
with:
|
||||
working_directory: ${{ matrix.working_directory }}
|
||||
python_version: "3.13"
|
||||
pylint_targets: ${{ matrix.pylint_targets }}
|
||||
check-changelog:
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
contains(github.event.pull_request.labels.*.name, 'noChangeLog') == false &&
|
||||
github.event_name == 'pull_request'
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
fetch-depth: 50
|
||||
- name: Check that the CHANGELOG has been modified in the current branch
|
||||
run: git diff --name-only ${{ github.event.pull_request.base.sha }} ${{ github.event.after }} | grep 'CHANGELOG.md'
|
||||
|
||||
lint-changelog:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
- name: Check CHANGELOG max line length
|
||||
run: |
|
||||
max_line_length=$(cat CHANGELOG.md | grep -Ev "^\[.*\]: https://github.com" | wc -L)
|
||||
if [ $max_line_length -ge 80 ]; then
|
||||
echo "ERROR: CHANGELOG has lines longer than 80 characters."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
build-mails:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/mail
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
|
||||
- name: Install Node.js
|
||||
uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
|
||||
id: mail-templates
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
|
||||
- name: Install yarn
|
||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||
run: npm install -g --ignore-scripts yarn@1.22.22
|
||||
|
||||
- name: Install node dependencies
|
||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||
run: yarn install --frozen-lockfile --ignore-scripts
|
||||
|
||||
- name: Build mails
|
||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||
run: yarn build
|
||||
|
||||
- name: Cache mail templates
|
||||
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
|
||||
lint-back:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/backend
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
- name: Install the project
|
||||
run: uv sync --locked --all-extras
|
||||
|
||||
- name: Check code formatting with ruff
|
||||
run: uv run --no-sync --no-build ruff format . --diff
|
||||
- name: Lint code with ruff
|
||||
run: uv run --no-sync --no-build ruff check .
|
||||
- name: Lint code with pylint
|
||||
run: uv run --no-sync --no-build pylint meet demo core
|
||||
|
||||
lint-agents:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/agents
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
- name: Install the project
|
||||
run: uv sync --locked --all-extras --no-build
|
||||
- name: Check code formatting with ruff
|
||||
run: uv run --no-sync --no-build ruff format . --diff
|
||||
- name: Lint code with ruff
|
||||
run: uv run --no-sync --no-build ruff check .
|
||||
|
||||
lint-summary:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/summary
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
|
||||
- name: Install the project
|
||||
run: uv sync --locked --all-extras
|
||||
- name: Check code formatting with ruff
|
||||
run: uv run --no-sync --no-build ruff format . --diff
|
||||
- name: Lint code with ruff
|
||||
run: uv run --no-sync --no-build ruff check .
|
||||
|
||||
test-back:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-mails
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
@@ -93,18 +243,22 @@ jobs:
|
||||
sudo mkdir -p /data/media && \
|
||||
sudo mkdir -p /data/static
|
||||
|
||||
- name: Build or restore the mail templates
|
||||
uses: suitenumerique/ci/actions/mail-templates@main
|
||||
- name: Restore the mail templates
|
||||
uses: actions/cache@caa296126883cff596d87d8935842f9db880ef25 # v5.1.0
|
||||
id: mail-templates
|
||||
with:
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
|
||||
- name: Start MinIO
|
||||
run: |
|
||||
docker pull quay.io/minio/minio
|
||||
docker pull minio/minio
|
||||
docker run -d --name minio \
|
||||
-p 9000:9000 \
|
||||
-e "MINIO_ACCESS_KEY=meet" \
|
||||
-e "MINIO_SECRET_KEY=password" \
|
||||
-v /data/media:/data \
|
||||
quay.io/minio/minio server --console-address :9001 /data
|
||||
minio/minio server --console-address :9001 /data
|
||||
|
||||
# Tool to wait for a service to be ready
|
||||
- name: Install Dockerize
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
name: Project quality Workflow
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
uses: suitenumerique/ci/.github/workflows/_project-quality.yml@main
|
||||
with:
|
||||
print_check_paths: src/backend src/summary src/agents
|
||||
codespell_ignore_words: ""
|
||||
@@ -0,0 +1,33 @@
|
||||
name: Download Crowdin translations
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
types: [file-fully-translated]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
crowdin:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
|
||||
- name: Download Crowdin files
|
||||
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
|
||||
with:
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
localization_branch_name: l10n_crowdin_translations
|
||||
create_pull_request: true
|
||||
pull_request_title: "New Crowdin translations"
|
||||
pull_request_body: "New Crowdin pull request with translations"
|
||||
pull_request_base_branch_name: "main"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
CROWDIN_BASE_PATH: ${{ github.workspace }}
|
||||
@@ -1,5 +1,5 @@
|
||||
name: Docker images
|
||||
run-name: Docker images
|
||||
name: Docker Hub Workflow
|
||||
run-name: Docker Hub Workflow
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -15,61 +15,265 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
DOCKER_USER: 1001:127
|
||||
DOCKER_CONTAINER_REGISTRY_HOSTNAME: docker.io
|
||||
DOCKER_CONTAINER_REGISTRY_NAMESPACE: lasuite
|
||||
IS_MULTI_PLATFORM_BUILD: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
BUILD_PLATFORMS: ${{ startsWith(github.ref, 'refs/tags/v') && 'linux/amd64,linux/arm64' || 'linux/amd64' }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
name: ${{ matrix.service }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- service: backend
|
||||
image_name: lasuite/meet-backend
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
target: backend-production
|
||||
- service: frontend
|
||||
image_name: lasuite/meet-frontend
|
||||
context: .
|
||||
file: ./src/frontend/Dockerfile
|
||||
target: frontend-production
|
||||
- service: frontend-dinum
|
||||
image_name: lasuite/meet-frontend-dinum
|
||||
context: .
|
||||
file: ./docker/dinum-frontend/Dockerfile
|
||||
target: frontend-production
|
||||
- service: summary
|
||||
image_name: lasuite/meet-summary
|
||||
context: ./src/summary
|
||||
file: ./src/summary/Dockerfile
|
||||
target: production
|
||||
- service: agents
|
||||
image_name: lasuite/meet-agents
|
||||
context: ./src/agents
|
||||
file: ./src/agents/Dockerfile
|
||||
target: production
|
||||
uses: suitenumerique/ci/.github/workflows/_docker-publish.yml@main
|
||||
with:
|
||||
image_name: ${{ matrix.image_name }}
|
||||
context: ${{ matrix.context }}
|
||||
file: ${{ matrix.file }}
|
||||
target: ${{ matrix.target }}
|
||||
docker_user: "1001:127"
|
||||
is_multi_platform: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
should_push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
|
||||
trivy_scan: true
|
||||
secrets:
|
||||
DOCKER_HUB_USER: ${{ secrets.DOCKER_HUB_USER }}
|
||||
DOCKER_HUB_PASSWORD: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
build-and-push-backend:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
-
|
||||
name: Set up QEMU
|
||||
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend'
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
|
||||
with:
|
||||
docker-build-args: '--target backend-production -f Dockerfile'
|
||||
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
target: backend-production
|
||||
platforms: ${{ env.BUILD_PLATFORMS }}
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
build-and-push-frontend-generic:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
-
|
||||
name: Set up QEMU
|
||||
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend'
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
|
||||
with:
|
||||
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
|
||||
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
file: ./src/frontend/Dockerfile
|
||||
target: frontend-production
|
||||
platforms: ${{ env.BUILD_PLATFORMS }}
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
build-and-push-frontend-dinum:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
-
|
||||
name: Set up QEMU
|
||||
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum'
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
|
||||
with:
|
||||
docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
|
||||
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/dinum-frontend/Dockerfile
|
||||
target: frontend-production
|
||||
platforms: ${{ env.BUILD_PLATFORMS }}
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
build-and-push-summary:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
-
|
||||
name: Set up QEMU
|
||||
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary'
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
|
||||
continue-on-error: true
|
||||
with:
|
||||
docker-build-args: '-f src/summary/Dockerfile --target production'
|
||||
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
|
||||
docker-context: './src/summary'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: ./src/summary
|
||||
file: ./src/summary/Dockerfile
|
||||
target: production
|
||||
platforms: ${{ env.BUILD_PLATFORMS }}
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
build-and-push-agents:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
-
|
||||
name: Set up QEMU
|
||||
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
-
|
||||
name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: lasuite/meet-agents
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@d6e94cfb488f03a0b3e8b8739aad94e74d24d8da # main
|
||||
continue-on-error: true
|
||||
with:
|
||||
docker-build-args: '-f src/agents/Dockerfile --target production'
|
||||
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
|
||||
docker-context: './src/agents'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: ./src/agents
|
||||
file: ./src/agents/Dockerfile
|
||||
target: production
|
||||
platforms: ${{ env.BUILD_PLATFORMS }}
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
notify-argocd:
|
||||
permissions:
|
||||
contents: read
|
||||
needs:
|
||||
- build-and-push
|
||||
- build-and-push-frontend-generic
|
||||
- build-and-push-frontend-dinum
|
||||
- build-and-push-backend
|
||||
- build-and-push-summary
|
||||
- build-and-push-agents
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
- uses: suitenumerique/ci/actions/argocd-webhook-notification@main
|
||||
- uses: numerique-gouv/action-argocd-webhook-notification@cac2ee67896eb13e84e804f60c4271370424eaa8 # main
|
||||
id: notify
|
||||
with:
|
||||
deployment_repo_path: "${{ secrets.DEPLOYMENT_REPO_URL }}"
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
name: Release Helm chart
|
||||
name: Release Chart
|
||||
run-name: Release Chart
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- src/helm/meet/**
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
release:
|
||||
permissions:
|
||||
contents: write
|
||||
uses: suitenumerique/ci/.github/workflows/_release-helm-chart.yml@main
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Cleanup
|
||||
run: rm -rf ./src/helm/extra
|
||||
|
||||
- name: Install Helm
|
||||
uses: azure/setup-helm@1a275c3b69536ee54be43f2070a358922e12c8d4 # v4.3.1
|
||||
env:
|
||||
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||
|
||||
- name: Publish Helm charts
|
||||
uses: numerique-gouv/helm-gh-pages@2cf477ae49d7c70037ceb1685803f4f7bad9b981 # add-overwrite-option
|
||||
with:
|
||||
charts_dir: ./src/helm
|
||||
linting: on
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
name: Security analysis
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
branches:
|
||||
- "**"
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
zizmor:
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
security-events: write
|
||||
uses: suitenumerique/ci/.github/workflows/_zizmor.yml@main
|
||||
with:
|
||||
config: .github/zizmor.yml
|
||||
@@ -1,5 +0,0 @@
|
||||
rules:
|
||||
unpinned-uses:
|
||||
config:
|
||||
policies:
|
||||
"suitenumerique/*": ref-pin
|
||||
@@ -86,6 +86,3 @@ docker/livekit/rootCA.pem
|
||||
|
||||
# Frontend rollup-plugin-visualizer
|
||||
/src/frontend/rollup-plugin-visualizer/*
|
||||
|
||||
# NixOS
|
||||
.devenv
|
||||
|
||||
+5
-81
@@ -8,87 +8,12 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.32.1] - 2026-09-25
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔒️(backend) fix CVE-2026-73228 and CVE-2026-73229 in drf
|
||||
- 🔒️(agent) fix CRITICAL CVE-2026-63072 / CVE-2026-63073 in libssl3t64
|
||||
|
||||
## [1.32.0] - 2026-09-25
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) make the LiveKit default video codec configurable
|
||||
- 🔧(dev) add support for Bureautix workstations
|
||||
- ✨(frontend) add screen share zoom controls #1498
|
||||
|
||||
### Changed
|
||||
|
||||
- 🔥(backend) remove unused API viewset and permission helpers
|
||||
- 🔊(backend) pin the dockerflow logger level to WARNING
|
||||
- 🚑️(summary) serve health endpoints with the dockerflow router
|
||||
- ♻️(backend) serve the dockerflow views early in the middleware stack
|
||||
- 📈(frontend) include LiveKit SIDs in the connection analytics event
|
||||
- 🔇(backend) silence expected 401 warnings on /me
|
||||
- 🔇(backend) silence noisy request summary info logs
|
||||
- ⚡️(frontend) defer loading the Crisp script until idle
|
||||
- ⬆️(frontend) upgrade react-i18next from 17.0.10 to 17.0.12
|
||||
- ⬆️(frontend) upgrade posthog-js from 1.414.0 to 1.418.10
|
||||
- ⬆️(addons) upgrade i18next from 26.3.6 to 26.4.0
|
||||
- ⬆️(frontend) upgrade humanize-duration from 3.33.2 to 3.34.1
|
||||
- ⬆️(addons) upgrade i18next from 26.4.0 to 26.4.2
|
||||
- 🔖(helm) release chart 0.0.28
|
||||
- ♻️(backend) decouple recording event handling from LiveKit egress statuses
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(helm) probe liveness on __lbheartbeat__ and readiness on __heartbeat__
|
||||
- 🐛(helm) render periodSeconds and failureThreshold on probes
|
||||
- 🐛(backend) report the app release to Sentry instead of "NA"
|
||||
- 🐛(frontend) play the waiting room notification sound on every arrival
|
||||
- 🐛(frontend) apply saved reception resolution when joining a meeting #1714
|
||||
- 🐛(backend) acknowledge unknown LiveKit webhook events instead of 422
|
||||
- 🔒️(backend) enforce display name setting on rename API
|
||||
- 🔒️(backend) reject inactive users in resource server backend
|
||||
- 🐛(frontend) fix file permissions in the Docker image
|
||||
- 🚸(frontend) inform user that recording waits until a track is published
|
||||
- 🔒(backend) upgrade base image to python:3.13.5-alpine3.24
|
||||
- 🐛(backend) handle failed and aborted egresses
|
||||
- 🩹(frontend) notify participants when a recording fails or is aborted
|
||||
- 🔒️(frontend) fix HIGH CVE-2026-93990 in libexpat
|
||||
|
||||
## [1.31.0] - 2026-09-08
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(frontend) add 1080p sending resolution option #1660
|
||||
- ✨(backend) add Traefik support via configurable media-auth url header #1649
|
||||
- ✨(backend) update a room's attributes from the external API
|
||||
- 🔊(backend) log request duration in Gunicorn workers
|
||||
- 📈(frontend) track missing lobby participant on accept/reject
|
||||
- ✨(backend) sort waiting participants by their arrival time
|
||||
|
||||
### Changed
|
||||
|
||||
- ⬆️(dev) pin LiveKit server to v1.13.6
|
||||
- 🔒(frontend) upgrade base image to 1.30.4-alpine3.24
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(backend) allow any printable ASCII characters in user sub field #1673
|
||||
- 🐛(frontend) keep the sending resolution picked while the camera is off #1667
|
||||
- 🐛(frontend) restore automatic lower-hand on speaking
|
||||
- 🐛(frontend) center Avatar initials with a font-aware cap-height ratio
|
||||
- 🐛(frontend) keep feedback buttons on one line for fr/es/en
|
||||
- ⚡️(frontend) increase lobby polling interval on both sides
|
||||
- ⚡️(frontend) add trailing slash on the /me endpoint call
|
||||
- ⚡️(backend) refactor lobby storage to bound key lookups per room
|
||||
- ⚡️(backend) refactor presence cache to bound key lookups per room
|
||||
- 💄(frontend) position the login hint dynamically next to the button
|
||||
|
||||
## [1.30.0] - 2026-09-01
|
||||
|
||||
### Changed
|
||||
|
||||
- ♿️(frontend) close side panel with Escape key #1507
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(agent) support Voxtral realtime as inference engine
|
||||
@@ -104,7 +29,6 @@ and this project adheres to
|
||||
- ⬆️(frontend) upgrade @fontsource/opendyslexic from 5.2.5 to 5.3.0
|
||||
- ⬆️(addons) upgrade core-js from 3.49.0 to 3.50.0
|
||||
- ♻️(backend) factorize s3 client creation in utils
|
||||
- ♿️(frontend) close side panel with Escape key #1507
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -374,7 +298,7 @@ and this project adheres to
|
||||
|
||||
### Fixed
|
||||
|
||||
- ♿️(frontend) improve accessibility of the Effects panel #1401
|
||||
- ♿️(frontend) improve accessibilty of the Effects panel #1401
|
||||
|
||||
## [1.20.0] - 2026-06-12
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# Django Meet
|
||||
|
||||
# ---- base image to inherit from ----
|
||||
FROM python:3.13.15-alpine3.24 AS base
|
||||
FROM python:3.13.5-alpine3.21 AS base
|
||||
|
||||
# Upgrade pip to its latest release to speed up dependencies installation
|
||||
RUN python -m pip install --upgrade pip
|
||||
|
||||
@@ -292,7 +292,7 @@ shell: ## connect to database shell
|
||||
# -- Database
|
||||
|
||||
dbshell: ## connect to database shell
|
||||
@$(COMPOSE_EXEC_APP) python manage.py dbshell
|
||||
docker compose exec app-dev python manage.py dbshell
|
||||
.PHONY: dbshell
|
||||
|
||||
resetdb: FLUSH_ARGS ?=
|
||||
|
||||
+10
-9
@@ -5,7 +5,7 @@ set -eo pipefail
|
||||
REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
|
||||
UNSET_USER=0
|
||||
|
||||
export COMPOSE_FILE="${COMPOSE_FILE:-${REPO_DIR}/compose.yml}"
|
||||
COMPOSE_FILE="${REPO_DIR}/compose.yml"
|
||||
COMPOSE_PROJECT="meet"
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ function _docker_compose() {
|
||||
echo "🐳(compose) project: '${COMPOSE_PROJECT}' file: '${COMPOSE_FILE}'"
|
||||
docker compose \
|
||||
-p "${COMPOSE_PROJECT}" \
|
||||
-f "${COMPOSE_FILE}" \
|
||||
--project-directory "${REPO_DIR}" \
|
||||
"$@"
|
||||
}
|
||||
@@ -55,12 +56,12 @@ function _docker_compose() {
|
||||
function _dc_run() {
|
||||
_set_user
|
||||
|
||||
user_args=()
|
||||
if [ -n "$USER_ID" ]; then
|
||||
user_args=("--user=$USER_ID")
|
||||
user_args="--user=$USER_ID"
|
||||
if [ -z $USER_ID ]; then
|
||||
user_args=""
|
||||
fi
|
||||
|
||||
_docker_compose run --rm "${user_args[@]}" "$@"
|
||||
_docker_compose run --rm $user_args "$@"
|
||||
}
|
||||
|
||||
# _dc_exec: wrap docker compose exec command
|
||||
@@ -74,12 +75,12 @@ function _dc_exec() {
|
||||
|
||||
echo "🐳(compose) exec command: '\$@'"
|
||||
|
||||
user_args=()
|
||||
if [ -n "$USER_ID" ]; then
|
||||
user_args=("--user=$USER_ID")
|
||||
user_args="--user=$USER_ID"
|
||||
if [ -z $USER_ID ]; then
|
||||
user_args=""
|
||||
fi
|
||||
|
||||
_docker_compose exec "${user_args[@]}" "$@"
|
||||
_docker_compose exec $user_args "$@"
|
||||
}
|
||||
|
||||
# _django_manage: wrap django's manage.py command with docker compose
|
||||
|
||||
@@ -40,7 +40,7 @@ if [ -n "$CUSTOM_LOGO_URL" ]; then
|
||||
[[ "$IS_SVG" == false ]] && echo "[custom-logo] ERROR: not a valid SVG file" >&2 && exit 1
|
||||
|
||||
mv -f "$TMP_FILE" "$LOGO_FILE"
|
||||
echo "[custom-logo] INFO: Custom logo downloaded successfully"
|
||||
echo "[custom-logo] INFO: Custom logo downloaded successfuly"
|
||||
fi
|
||||
|
||||
mv src/backend/* ./
|
||||
|
||||
@@ -7,7 +7,7 @@ gunicorn -b 0.0.0.0:8000 meet.wsgi:application --log-file - &
|
||||
bin/run &
|
||||
|
||||
# if the current shell is killed, also terminate all its children
|
||||
trap 'pkill -TERM -P $$' SIGTERM
|
||||
trap "pkill SIGTERM -P $$" SIGTERM
|
||||
|
||||
# wait for a single child to finish,
|
||||
wait -n
|
||||
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# shellcheck source=bin/_config.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
_docker_compose "$@"
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -o errexit
|
||||
|
||||
CURRENT_DIR=$(pwd)
|
||||
NAMESPACE=${1:-meet}
|
||||
SECRET_NAME=${2:-bitwarden-cli-meet}
|
||||
TEMP_SECRET_FILE=$(mktemp)
|
||||
@@ -29,10 +30,10 @@ check_secret_exists() {
|
||||
# Collect user input securely
|
||||
get_user_input() {
|
||||
echo "Please provide the following information:"
|
||||
read -r -p "Enter your Vaultwarden email login: " LOGIN
|
||||
read -r -s -p "Enter your Vaultwarden password: " PASSWORD
|
||||
read -p "Enter your Vaultwarden email login: " LOGIN
|
||||
read -s -p "Enter your Vaultwarden password: " PASSWORD
|
||||
echo
|
||||
read -r -p "Enter your Vaultwarden server url: " URL
|
||||
read -p "Enter your Vaultwarden server url: " URL
|
||||
}
|
||||
|
||||
# Create and apply the secret
|
||||
@@ -76,7 +77,7 @@ main() {
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo -e "${TEMP_SECRET_FILE}"
|
||||
echo -e ${TEMP_SECRET_FILE}
|
||||
|
||||
get_user_input
|
||||
echo -e "\nCreating Vaultwarden secret…"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
mkdir -p "$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/"
|
||||
PRE_COMMIT_FILE="$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/pre-commit"
|
||||
|
||||
cat <<'EOF' >"$PRE_COMMIT_FILE"
|
||||
cat <<'EOF' >$PRE_COMMIT_FILE
|
||||
#!/bin/bash
|
||||
|
||||
# directories containing potential secrets
|
||||
@@ -27,4 +27,4 @@ for d in $DIRS; do
|
||||
done
|
||||
EOF
|
||||
|
||||
chmod +x "$PRE_COMMIT_FILE"
|
||||
chmod +x $PRE_COMMIT_FILE
|
||||
|
||||
@@ -68,7 +68,7 @@ fi
|
||||
|
||||
# Ask user for release version number
|
||||
echo ""
|
||||
read -r -p "Enter release version number (e.g., 1.2.3): " VERSION
|
||||
read -p "Enter release version number (e.g., 1.2.3): " VERSION
|
||||
|
||||
# Validate version format (basic semver check)
|
||||
if ! [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
git submodule update --init --recursive
|
||||
# shellcheck disable=SC2016
|
||||
git submodule foreach 'git fetch origin; git checkout $(git rev-parse --abbrev-ref HEAD); git reset --hard origin/$(git rev-parse --abbrev-ref HEAD); git submodule update --recursive; git clean -dfx'
|
||||
|
||||
@@ -8,6 +8,6 @@ environments=$(awk '/environments:/ {flag=1; next} flag && NF {print} !NF {flag=
|
||||
|
||||
for env in $environments; do
|
||||
echo "################### $env lint ###################"
|
||||
helmfile -e "$env" -f src/helm/helmfile.yaml lint || exit 1
|
||||
helmfile -e $env -f src/helm/helmfile.yaml lint || exit 1
|
||||
echo -e "\n"
|
||||
done
|
||||
|
||||
+3
-5
@@ -17,7 +17,7 @@ services:
|
||||
|
||||
minio:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: quay.io/minio/minio
|
||||
image: minio/minio
|
||||
environment:
|
||||
- MINIO_ROOT_USER=meet
|
||||
- MINIO_ROOT_PASSWORD=password
|
||||
@@ -35,7 +35,7 @@ services:
|
||||
- ./data/media:/data
|
||||
|
||||
createbuckets:
|
||||
image: quay.io/minio/mc
|
||||
image: minio/mc
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
@@ -207,14 +207,12 @@ services:
|
||||
- kc_postgresql
|
||||
|
||||
livekit:
|
||||
image: livekit/livekit-server:v1.13.6
|
||||
image: livekit/livekit-server
|
||||
entrypoint: /livekit-server --dev --bind 0.0.0.0 --config ./config.yaml
|
||||
ports:
|
||||
- "7880:7880"
|
||||
- "7881:7881"
|
||||
- "7882:7882/udp"
|
||||
- "3478:3478/udp"
|
||||
- "30000-30100:30000-30100/udp"
|
||||
volumes:
|
||||
- ./docker/livekit/config/livekit-server.yaml:/config.yaml
|
||||
depends_on:
|
||||
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
{
|
||||
"nodes": {
|
||||
"devenv": {
|
||||
"locked": {
|
||||
"dir": "src/modules",
|
||||
"lastModified": 1778705847,
|
||||
"narHash": "sha256-EQnZCy7r4VMO6KDoytxHBa0mFbM1D9g1kaDfs/s0YZA=",
|
||||
"ref": "refs/tags/v2.1.2",
|
||||
"rev": "ea3d94ac9d6bf6a1313773170122ca4e2ef5a0be",
|
||||
"revCount": 6569,
|
||||
"type": "git",
|
||||
"url": "https://github.com/cachix/devenv"
|
||||
},
|
||||
"original": {
|
||||
"dir": "src/modules",
|
||||
"ref": "refs/tags/v2.1.2",
|
||||
"type": "git",
|
||||
"url": "https://github.com/cachix/devenv"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1789542786,
|
||||
"narHash": "sha256-ajQuqcxnj6RYnwCjA/9FTCnrdR2+BDzstE8UembXf60=",
|
||||
"ref": "nixos-26.05",
|
||||
"rev": "4c7870105e7f1fdf9c48688c8d7efc21abf0688a",
|
||||
"shallow": true,
|
||||
"type": "git",
|
||||
"url": "https://github.com/NixOS/nixpkgs"
|
||||
},
|
||||
"original": {
|
||||
"ref": "nixos-26.05",
|
||||
"shallow": true,
|
||||
"type": "git",
|
||||
"url": "https://github.com/NixOS/nixpkgs"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"devenv": "devenv",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
-265
@@ -1,265 +0,0 @@
|
||||
# =============================================================================
|
||||
# devenv.nix — La Suite Meet ("Visio") developer environment
|
||||
# =============================================================================
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
|
||||
let
|
||||
python = pkgs.python313;
|
||||
nodejs = pkgs.nodejs_22;
|
||||
|
||||
backendDir = "src/backend";
|
||||
agentsDir = "src/agents";
|
||||
summaryDir = "src/summary";
|
||||
frontendDir = "src/frontend";
|
||||
|
||||
readDotEnv =
|
||||
file:
|
||||
let
|
||||
lines = lib.splitString "\n" (builtins.readFile file);
|
||||
unquote =
|
||||
v:
|
||||
let
|
||||
len = builtins.stringLength v;
|
||||
in
|
||||
if len >= 2 && lib.hasPrefix "\"" v && lib.hasSuffix "\"" v then
|
||||
builtins.substring 1 (len - 2) v
|
||||
else if len >= 2 && lib.hasPrefix "'" v && lib.hasSuffix "'" v then
|
||||
builtins.substring 1 (len - 2) v
|
||||
else
|
||||
v;
|
||||
parseLine =
|
||||
line:
|
||||
let
|
||||
m = builtins.match "[ \t]*([A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(.*)" line;
|
||||
in
|
||||
if m == null then null else { name = builtins.elemAt m 0; value = unquote (builtins.elemAt m 1); };
|
||||
in
|
||||
builtins.listToAttrs (builtins.filter (x: x != null) (map parseLine lines));
|
||||
|
||||
# Reuse existing .env
|
||||
dotEnv =
|
||||
(readDotEnv ./env.d/development/common.dist)
|
||||
// (readDotEnv ./env.d/development/postgresql.dist);
|
||||
|
||||
sharedEnv = builtins.removeAttrs dotEnv [ "PYTHONPATH" ]; # only makes sense inside the backend container.
|
||||
in
|
||||
{
|
||||
options.meet = {
|
||||
agents.enable = lib.mkEnableOption "tooling for the LiveKit agents in src/agents";
|
||||
summary.enable = lib.mkEnableOption "tooling for the summary service in src/summary";
|
||||
k8s.enable = lib.mkEnableOption "Kubernetes dev utilities";
|
||||
};
|
||||
|
||||
config = {
|
||||
# Profile can be activated with devenv --profile <profile> shell
|
||||
profiles = {
|
||||
agents.module = {
|
||||
meet.agents.enable = true;
|
||||
};
|
||||
summary.module = {
|
||||
meet.summary.enable = true;
|
||||
};
|
||||
k8s.module = {
|
||||
meet.k8s.enable = true;
|
||||
};
|
||||
};
|
||||
languages.python = {
|
||||
enable = true;
|
||||
package = python;
|
||||
directory = backendDir;
|
||||
manylinux.enable = pkgs.stdenv.hostPlatform.isLinux;
|
||||
|
||||
libraries = [
|
||||
"${config.devenv.dotfile}/profile"
|
||||
pkgs.file
|
||||
pkgs.zlib
|
||||
pkgs.libffi
|
||||
pkgs.openssl
|
||||
];
|
||||
|
||||
uv.enable = true;
|
||||
uv.sync.enable = false;
|
||||
venv.enable = false;
|
||||
lsp.enable = true;
|
||||
};
|
||||
|
||||
languages.javascript = {
|
||||
enable = true;
|
||||
package = nodejs;
|
||||
directory = frontendDir;
|
||||
|
||||
npm.enable = true;
|
||||
yarn.enable = true;
|
||||
corepack.enable = false;
|
||||
};
|
||||
|
||||
languages.typescript.enable = false;
|
||||
languages.nix.enable = true;
|
||||
|
||||
packages =
|
||||
with pkgs;
|
||||
[
|
||||
gnumake
|
||||
file
|
||||
shared-mime-info
|
||||
gettext
|
||||
postgresql_16
|
||||
git
|
||||
curl
|
||||
jq
|
||||
podman
|
||||
podman-compose
|
||||
docker-client
|
||||
]
|
||||
|
||||
# -- LiveKit agents
|
||||
++ lib.optionals config.meet.agents.enable [
|
||||
glib
|
||||
portaudio
|
||||
livekit-cli
|
||||
]
|
||||
|
||||
# -- summary service
|
||||
++ lib.optionals config.meet.summary.enable [
|
||||
redis
|
||||
]
|
||||
|
||||
# -- Kubernetes tools
|
||||
++ lib.optionals config.meet.k8s.enable [
|
||||
kubectl
|
||||
kubernetes-helm
|
||||
helmfile
|
||||
tilt
|
||||
kind
|
||||
mkcert
|
||||
];
|
||||
|
||||
env = sharedEnv // {
|
||||
UV_LINK_MODE = "copy";
|
||||
|
||||
PYTHONDONTWRITEBYTECODE = "1";
|
||||
PYTHONUNBUFFERED = "1";
|
||||
|
||||
UV_PROJECT_ENVIRONMENT = lib.mkForce ".venv";
|
||||
|
||||
COMPOSE_PROJECT_NAME = "meet";
|
||||
|
||||
DJANGO_DATA_DIR = "${config.devenv.root}/data";
|
||||
|
||||
# Database / Pgsql
|
||||
DB_HOST = "127.0.0.1";
|
||||
DB_PORT = "15432";
|
||||
PGHOST = "127.0.0.1";
|
||||
PGPORT = "15432";
|
||||
PGDATABASE = sharedEnv.DB_NAME;
|
||||
PGUSER = sharedEnv.DB_USER;
|
||||
PGPASSWORD = sharedEnv.DB_PASSWORD;
|
||||
|
||||
REDIS_URL = "redis://127.0.0.1:6379/1";
|
||||
CELERY_BROKER_URL = "redis://127.0.0.1:6379/0";
|
||||
|
||||
# S3 / MinIO
|
||||
AWS_S3_ENDPOINT_URL = "http://127.0.0.1:9000";
|
||||
|
||||
# OIDC
|
||||
OIDC_OP_JWKS_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/certs";
|
||||
OIDC_OP_TOKEN_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/token";
|
||||
OIDC_OP_USER_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/userinfo";
|
||||
OIDC_OP_INTROSPECTION_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/token/introspect";
|
||||
|
||||
# summary service
|
||||
SUMMARY_SERVICE_ENDPOINT = "http://127.0.0.1:8001/api/v2/async-jobs/transcribe/";
|
||||
SUMMARY_SERVICE_VERSION = "2";
|
||||
|
||||
# Mail
|
||||
DJANGO_EMAIL_HOST = "127.0.0.1";
|
||||
};
|
||||
|
||||
scripts = {
|
||||
|
||||
meet-venv = {
|
||||
description = "Create/refresh meet uv virtualenvs for backend, agents and summary";
|
||||
exec = ''
|
||||
set -euo pipefail
|
||||
cd "$DEVENV_ROOT"
|
||||
|
||||
echo "==> ${backendDir} (uv sync --locked, dependency-groups)"
|
||||
( cd "${backendDir}" && uv sync --locked --all-groups )
|
||||
|
||||
echo "==> ${agentsDir} (uv sync --locked --all-extras)"
|
||||
( cd "${agentsDir}" && uv sync --locked --all-extras )
|
||||
|
||||
echo "==> ${summaryDir} (uv sync --locked --all-extras)"
|
||||
( cd "${summaryDir}" && uv sync --locked --all-extras )
|
||||
|
||||
echo
|
||||
echo "Synced the following virtualenvs successfully:"
|
||||
echo " ${backendDir}/.venv"
|
||||
echo " ${agentsDir}/.venv"
|
||||
echo " ${summaryDir}/.venv"
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
enterShell = ''
|
||||
# Make podman socket accessible in order to launch regular docker commands.
|
||||
# Set MEET_PODMAN_SOCKET=0 to keep the DOCKER_HOST of the calling environment.
|
||||
case "''${MEET_PODMAN_SOCKET:-1}" in
|
||||
0|false|no|off) ;;
|
||||
*)
|
||||
_rundir="''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
|
||||
export DOCKER_HOST="unix://$_rundir/podman/podman.sock"
|
||||
unset _rundir
|
||||
;;
|
||||
esac
|
||||
|
||||
# Compose files to merge
|
||||
_compose_dir="${config.devenv.root}/docker/compose.d"
|
||||
_compose_files="${config.devenv.root}/compose.yml"
|
||||
|
||||
export DOCKER_USER="$(id -u):$(id -g)"
|
||||
|
||||
case "''${DOCKER_HOST:-}" in
|
||||
*podman*)
|
||||
_compose_files="$_compose_files:$_compose_dir/compose.podman.yml"
|
||||
|
||||
# Build images with Podman/Buildah rather than BuildKit. `docker
|
||||
# compose build` otherwise has buildx boot a moby/buildkit container,
|
||||
# and that container lands in its own network namespace with neither
|
||||
# the proxy in its environment nor any route to it.
|
||||
# Buildah has neither problem: base images are resolved by the Podman systemd
|
||||
# service, which inherits the proxy from its systemd socket activated unit, and
|
||||
# RUN steps execute in the *host* network namespace
|
||||
|
||||
export DOCKER_BUILDKIT=0
|
||||
export COMPOSE_BAKE=false
|
||||
;;
|
||||
esac
|
||||
|
||||
|
||||
# Apply Bureautix override
|
||||
if [ -n "''${http_proxy:-}" ]; then
|
||||
_compose_files="$_compose_files:$_compose_dir/compose.bureautix.yml"
|
||||
fi
|
||||
|
||||
export COMPOSE_FILE="$_compose_files"
|
||||
unset _compose_dir _compose_files
|
||||
|
||||
# Make binaries accessible
|
||||
for _d in \
|
||||
"$DEVENV_ROOT/${backendDir}/.venv/bin" \
|
||||
"$DEVENV_ROOT/${agentsDir}/.venv/bin" \
|
||||
"$DEVENV_ROOT/${summaryDir}/.venv/bin" \
|
||||
"$DEVENV_ROOT/${frontendDir}/node_modules/.bin"
|
||||
do
|
||||
[ -d "$_d" ] && export PATH="$_d:$PATH"
|
||||
done
|
||||
unset _d
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
inputs:
|
||||
nixpkgs:
|
||||
url: git+https://github.com/NixOS/nixpkgs?ref=nixos-26.05&shallow=1
|
||||
devenv:
|
||||
url: git+https://github.com/cachix/devenv?ref=refs/tags/v2.1.2&dir=src/modules
|
||||
@@ -1,48 +0,0 @@
|
||||
# Bureautix proxy overrides
|
||||
#
|
||||
# Builds submitted through the Docker API of the Podman service get none of
|
||||
# the proxy variables in their RUN steps. We need to pass the http_proxy explicitly
|
||||
# otherwise all connections fail during the build.
|
||||
|
||||
x-proxy-vars: &proxy-vars
|
||||
http_proxy: ${http_proxy:-}
|
||||
https_proxy: ${https_proxy:-}
|
||||
no_proxy: ${no_proxy:-}
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
args:
|
||||
<<: *proxy-vars
|
||||
app-dev:
|
||||
build:
|
||||
args:
|
||||
<<: *proxy-vars
|
||||
frontend:
|
||||
build:
|
||||
args:
|
||||
<<: *proxy-vars
|
||||
metadata-collector-dev:
|
||||
build:
|
||||
args:
|
||||
<<: *proxy-vars
|
||||
multi-user-transcriber-dev:
|
||||
build:
|
||||
args:
|
||||
<<: *proxy-vars
|
||||
app-summary-dev:
|
||||
build:
|
||||
args:
|
||||
<<: *proxy-vars
|
||||
celery-summary-transcribe:
|
||||
build:
|
||||
args:
|
||||
<<: *proxy-vars
|
||||
celery-summary-summarize:
|
||||
build:
|
||||
args:
|
||||
<<: *proxy-vars
|
||||
# The local proxy listens on 8080 and collides with Keycloak's published admin port.
|
||||
keycloak:
|
||||
ports: !override
|
||||
- "8081:8080"
|
||||
@@ -1,33 +0,0 @@
|
||||
# Rootless Podman override for compose.yml.
|
||||
#
|
||||
# Rootless Podman maps container UID 0 to the host user and every other
|
||||
# container UID to a subuid that owns nothing in the worktree. Meet compose.ymlruns
|
||||
# its containers as DOCKER_USER=$(id -u):$(id -g), which would land on such a
|
||||
# subuid and make every bind mount effectively read-only.
|
||||
#
|
||||
# `userns_mode: keep-id` maps the host user to the same UID and GID inside the
|
||||
# container instead, so DOCKER_USER keeps its Docker value and files written
|
||||
# through a bind mount are owned by the host user on both sides.
|
||||
#
|
||||
# Only the services that mount the worktree and run as DOCKER_USER are listed.
|
||||
|
||||
x-keep-id: &keep-id
|
||||
userns_mode: keep-id
|
||||
|
||||
services:
|
||||
app-dev:
|
||||
<<: *keep-id
|
||||
celery-dev:
|
||||
<<: *keep-id
|
||||
minio:
|
||||
<<: *keep-id
|
||||
node:
|
||||
<<: *keep-id
|
||||
crowdin:
|
||||
<<: *keep-id
|
||||
metadata-collector-dev:
|
||||
<<: *keep-id
|
||||
multi-user-transcriber-dev:
|
||||
<<: *keep-id
|
||||
app-summary-dev:
|
||||
<<: *keep-id
|
||||
@@ -54,12 +54,18 @@ RUN npx webpack --mode production
|
||||
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:1.30.4-alpine3.24 AS frontend-production
|
||||
FROM nginxinc/nginx-unprivileged:1.30.3-alpine3.23 AS frontend-production
|
||||
|
||||
USER root
|
||||
RUN apk upgrade --no-cache libexpat && \
|
||||
apk del curl
|
||||
USER nginx
|
||||
|
||||
# Security patches for known CVEs
|
||||
RUN apk update && apk upgrade \
|
||||
libcrypto3>=3.5.7-r0 \
|
||||
libssl3>=3.5.7-r0 \
|
||||
musl \
|
||||
musl-utils \
|
||||
zlib>=1.3.2-r0 \
|
||||
&& apk del curl
|
||||
|
||||
USER nginx
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
:root {
|
||||
--fonts-sans: 'Marianne', ui-sans-serif, system-ui, sans-serif;
|
||||
--avatar-cap-height: 0.7;
|
||||
}
|
||||
|
||||
.Header-beforeLogo {
|
||||
|
||||
@@ -14,4 +14,3 @@ accesslog = "-"
|
||||
# Using '-' for the error log file makes gunicorn log errors to stderr
|
||||
errorlog = "-"
|
||||
loglevel = "info"
|
||||
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(M)s'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM livekit/livekit-server:v1.13.6
|
||||
FROM livekit/livekit-server:v1.9.4
|
||||
|
||||
# We inject the nip.io certificate manually because the livekit chart doesn't support volume mounting
|
||||
COPY rootCA.pem /etc/ssl/certs/
|
||||
|
||||
@@ -8,23 +8,3 @@ webhook:
|
||||
api_key: devkey
|
||||
urls:
|
||||
- http://app-dev:8000/api/v1.0/rooms/webhooks-livekit/
|
||||
|
||||
turn:
|
||||
enabled: true
|
||||
domain: turn.127.0.0.1.nip.io
|
||||
udp_port: 3478
|
||||
tls_port: 0
|
||||
external_tls: false
|
||||
relay_range_start: 30000
|
||||
relay_range_end: 30100
|
||||
allow_restricted_peer_cidrs:
|
||||
- 192.168.0.0/16
|
||||
- 172.16.0.0/12
|
||||
|
||||
rtc:
|
||||
node_ip: 127.0.0.1
|
||||
advertise_internal_ip: true
|
||||
udp_port: 7882
|
||||
tcp_port: 7881
|
||||
use_external_ip: false
|
||||
|
||||
|
||||
@@ -317,7 +317,7 @@ These are the environmental options available on meet backend.
|
||||
| DJANGO_ALLOWED_HOSTS | Hosts that are allowed | [] |
|
||||
| DJANGO_SECRET_KEY | Secret key used for Django security | |
|
||||
| DJANGO_SILENCED_SYSTEM_CHECKS | Silence Django system checks | [] |
|
||||
| DJANGO_ALLOW_UNSECURE_USER_LISTING | Allow insecure user listing | false |
|
||||
| DJANGO_ALLOW_UNSECURE_USER_LISTING | Allow unsecure user listing | false |
|
||||
| DB_ENGINE | Database engine used | django.db.backends.postgresql_psycopg2 |
|
||||
| DB_NAME | Name of the database | meet |
|
||||
| DB_USER | User used to connect to database | dinum |
|
||||
|
||||
+3
-78
@@ -16,11 +16,11 @@ info:
|
||||
* `rooms:list` – List rooms accessible to the delegated user.
|
||||
* `rooms:retrieve` – Retrieve details of a specific room.
|
||||
* `rooms:create` – Create new rooms.
|
||||
* `rooms:update` – Update the access level and configuration of existing rooms.
|
||||
* `rooms:update` – **Coming soon** Update existing rooms, e.g., add attendees to a room.
|
||||
* `rooms:delete` – **Coming soon** Delete rooms generated by the application.
|
||||
|
||||
|
||||
#### Upcoming Features
|
||||
|
||||
|
||||
* **Add attendees to a room:** You will be able to update a room to include a list of attendees, allowing them to bypass the lobby system automatically.
|
||||
* **Delete application-generated rooms:** Rooms created via the application can be deleted when no longer needed.
|
||||
|
||||
@@ -310,67 +310,6 @@ paths:
|
||||
'404':
|
||||
$ref: '#/components/responses/RoomNotFoundError'
|
||||
|
||||
patch:
|
||||
tags:
|
||||
- Rooms
|
||||
summary: Update a room
|
||||
description: |
|
||||
Partially updates a room. Only the delegated user's rooms where they are
|
||||
administrator or owner can be updated; any other role gets a `403`.
|
||||
|
||||
**Updatable fields:** `access_level` and `configuration`. Every other field
|
||||
(`id`, `name`, `slug`, `pin_code`) is read-only and silently ignored when sent.
|
||||
|
||||
`configuration` is replaced as a whole, it is not merged with the stored one.
|
||||
Send the complete object you want the room to end up with.
|
||||
|
||||
Full replacement (`PUT`) is not supported. Use `PATCH` instead.
|
||||
operationId: updateRoom
|
||||
security:
|
||||
- BearerAuth: [rooms:update]
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: Room UUID
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RoomUpdate'
|
||||
examples:
|
||||
accessLevelOnly:
|
||||
summary: Change the access level
|
||||
value:
|
||||
access_level: "restricted"
|
||||
configurationOnly:
|
||||
summary: Replace the room configuration
|
||||
value:
|
||||
configuration:
|
||||
everyone_can_mute: true
|
||||
responses:
|
||||
'200':
|
||||
description: Room updated successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Room'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequestError'
|
||||
'401':
|
||||
$ref: '#/components/responses/UnauthorizedError'
|
||||
'403':
|
||||
$ref: '#/components/responses/ForbiddenError'
|
||||
'404':
|
||||
$ref: '#/components/responses/RoomNotFoundError'
|
||||
'405':
|
||||
description: |
|
||||
Method not allowed, `PUT` is not supported on this endpoint.
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
BearerAuth:
|
||||
@@ -447,17 +386,6 @@ components:
|
||||
configuration:
|
||||
$ref: '#/components/schemas/RoomConfiguration'
|
||||
|
||||
RoomUpdate:
|
||||
type: object
|
||||
description: |
|
||||
Fields that can be updated on an existing room. Both are optional, omitted
|
||||
fields keep their current value.
|
||||
properties:
|
||||
access_level:
|
||||
$ref: '#/components/schemas/RoomAccessLevel'
|
||||
configuration:
|
||||
$ref: '#/components/schemas/RoomConfiguration'
|
||||
|
||||
RoomConfiguration:
|
||||
type: object
|
||||
description: |
|
||||
@@ -499,9 +427,6 @@ components:
|
||||
- `public`: Anyone with the room link can join directly, no authentication required.
|
||||
- `trusted`: Authenticated users join directly. Unauthenticated users wait in the lobby for approval.
|
||||
- `restricted`: Only participants explicitly trusted by the owner bypass the lobby. Everyone else waits for approval regardless of authentication.
|
||||
|
||||
`public` is rejected with a `400` unless the deployment explicitly enables it
|
||||
for this API. This applies both when creating a room and when updating one.
|
||||
example: "trusted"
|
||||
|
||||
Room:
|
||||
|
||||
@@ -20,7 +20,7 @@ info:
|
||||
* `lasuite_visio:rooms:list` – List rooms accessible to the delegated user.
|
||||
* `lasuite_visio:rooms:retrieve` – Retrieve details of a specific room.
|
||||
* `lasuite_visio:rooms:create` – Create new rooms.
|
||||
* `lasuite_visio:rooms:update` – Update the access level and configuration of existing rooms.
|
||||
* `lasuite_visio:rooms:update` – **Coming soon** Update existing rooms, e.g., add attendees to a room.
|
||||
* `lasuite_visio:rooms:delete` – **Coming soon** Delete rooms generated by the application.
|
||||
|
||||
#### Upcoming Features
|
||||
@@ -206,67 +206,6 @@ paths:
|
||||
'404':
|
||||
$ref: '#/components/responses/RoomNotFoundError'
|
||||
|
||||
patch:
|
||||
tags:
|
||||
- Rooms
|
||||
summary: Update a room
|
||||
description: |
|
||||
Partially updates a room. Only rooms where the user is administrator or
|
||||
owner can be updated; any other role gets a `403`.
|
||||
|
||||
**Updatable fields:** `access_level` and `configuration`. Every other field
|
||||
(`id`, `name`, `slug`, `pin_code`) is read-only and silently ignored when sent.
|
||||
|
||||
`configuration` is replaced as a whole, it is not merged with the stored one.
|
||||
Send the complete object you want the room to end up with.
|
||||
|
||||
Full replacement (`PUT`) is not supported. Use `PATCH` instead.
|
||||
operationId: updateRoom
|
||||
security:
|
||||
- BearerAuth: [rooms:update]
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: Room UUID
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RoomUpdate'
|
||||
examples:
|
||||
accessLevelOnly:
|
||||
summary: Change the access level
|
||||
value:
|
||||
access_level: "restricted"
|
||||
configurationOnly:
|
||||
summary: Replace the room configuration
|
||||
value:
|
||||
configuration:
|
||||
everyone_can_mute: true
|
||||
responses:
|
||||
'200':
|
||||
description: Room updated successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Room'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequestError'
|
||||
'401':
|
||||
$ref: '#/components/responses/UnauthorizedError'
|
||||
'403':
|
||||
$ref: '#/components/responses/ForbiddenError'
|
||||
'404':
|
||||
$ref: '#/components/responses/RoomNotFoundError'
|
||||
'405':
|
||||
description: |
|
||||
Method not allowed, `PUT` is not supported on this endpoint.
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
BearerAuth:
|
||||
@@ -288,17 +227,6 @@ components:
|
||||
configuration:
|
||||
$ref: '#/components/schemas/RoomConfiguration'
|
||||
|
||||
RoomUpdate:
|
||||
type: object
|
||||
description: |
|
||||
Fields that can be updated on an existing room. Both are optional, omitted
|
||||
fields keep their current value.
|
||||
properties:
|
||||
access_level:
|
||||
$ref: '#/components/schemas/RoomAccessLevel'
|
||||
configuration:
|
||||
$ref: '#/components/schemas/RoomConfiguration'
|
||||
|
||||
RoomConfiguration:
|
||||
type: object
|
||||
description: |
|
||||
@@ -340,9 +268,6 @@ components:
|
||||
- `public`: Anyone with the room link can join directly, no authentication required.
|
||||
- `trusted`: Authenticated users join directly. Unauthenticated users wait in the lobby for approval.
|
||||
- `restricted`: Only participants explicitly trusted by the owner bypass the lobby. Everyone else waits for approval regardless of authentication.
|
||||
|
||||
`public` is rejected with a `400` unless the deployment explicitly enables it
|
||||
for this API. This applies both when creating a room and when updating one.
|
||||
example: "trusted"
|
||||
|
||||
Room:
|
||||
|
||||
@@ -34,7 +34,6 @@ Let's say you want to change the font of our application to a custom font. You c
|
||||
|
||||
:root {
|
||||
--fonts-sans: 'Roboto', ui-sans-serif, system-ui, sans-serif;
|
||||
--avatar-cap-height: 0.7;
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"extends": ["github>suitenumerique/ci//renovate/default"],
|
||||
"extends": ["github>numerique-gouv/renovate-configuration"],
|
||||
"dependencyDashboard": true,
|
||||
"labels": ["dependencies", "noChangeLog"],
|
||||
"packageRules": [
|
||||
|
||||
Generated
+4
-4
@@ -10,7 +10,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "3.50.0",
|
||||
"i18next": "26.4.2",
|
||||
"i18next": "26.3.6",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"regenerator-runtime": "0.14.1"
|
||||
},
|
||||
@@ -9367,9 +9367,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.4.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz",
|
||||
"integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==",
|
||||
"version": "26.3.6",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
|
||||
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"core-js": "3.50.0",
|
||||
"i18next": "26.4.2",
|
||||
"i18next": "26.3.6",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"regenerator-runtime": "0.14.1"
|
||||
},
|
||||
|
||||
@@ -21,7 +21,7 @@ const { initI18n, translateUI } = require("../common/i18n");
|
||||
document.querySelector("#close-msg").style.display = "block";
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(`Error occurred: ${e}`);
|
||||
console.error(`Error occured: ${e}`);
|
||||
})
|
||||
.finally(() => {
|
||||
// NOTE: doesn't work with the desktop client — the browser considers
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
FROM python:3.14.6-slim AS base
|
||||
|
||||
# Install system dependencies required by LiveKit, fetching packages over HTTPS only for Bureautix proxy
|
||||
RUN sed -i "s|^URIs: http://|URIs: https://|" /etc/apt/sources.list.d/debian.sources \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends \
|
||||
# Install system dependencies required by LiveKit
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libglib2.0-0 \
|
||||
libgobject-2.0-0 \
|
||||
libssl3t64 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.32.1"
|
||||
version = "1.29.0"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.6.7",
|
||||
|
||||
Generated
+1
-1
@@ -9,7 +9,7 @@ resolution-markers = [
|
||||
|
||||
[[package]]
|
||||
name = "agents"
|
||||
version = "1.32.1"
|
||||
version = "1.29.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
|
||||
@@ -8,7 +8,6 @@ class AnalyticsEvent(StrEnum):
|
||||
|
||||
# Rooms
|
||||
ROOM_CREATED = "room_created"
|
||||
ROOM_UPDATED = "room_updated"
|
||||
|
||||
# Roomkit (meeting-room SIP devices)
|
||||
ROOMKIT_JOINED = "roomkit_joined"
|
||||
|
||||
@@ -71,7 +71,6 @@ def get_frontend_configuration(request):
|
||||
"force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL,
|
||||
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
|
||||
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
||||
"default_video_codec": settings.LIVEKIT_DEFAULT_VIDEO_CODEC,
|
||||
},
|
||||
"authenticated_users_can_edit_display_name": (
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
|
||||
|
||||
@@ -12,6 +12,10 @@ from ..services.participants_management import (
|
||||
ParticipantsManagementException,
|
||||
)
|
||||
|
||||
ACTION_FOR_METHOD_TO_PERMISSION = {
|
||||
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
|
||||
}
|
||||
|
||||
|
||||
class IsAuthenticated(permissions.BasePermission):
|
||||
"""
|
||||
@@ -23,6 +27,15 @@ class IsAuthenticated(permissions.BasePermission):
|
||||
return bool(request.auth) or request.user.is_authenticated
|
||||
|
||||
|
||||
class IsAuthenticatedOrSafe(IsAuthenticated):
|
||||
"""Allows access to authenticated users (or anonymous users but only on safe methods)."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
return super().has_permission(request, view)
|
||||
|
||||
|
||||
class IsSelf(IsAuthenticated):
|
||||
"""
|
||||
Allows access only to authenticated users. Alternative method checking the presence
|
||||
|
||||
@@ -75,7 +75,11 @@ from core.services.participants_management import (
|
||||
ParticipantsManagementException,
|
||||
)
|
||||
from core.services.room_creation import RoomCreation
|
||||
from core.services.room_management import RoomManagement
|
||||
from core.services.room_management import (
|
||||
RoomManagement,
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
from core.services.room_roles import (
|
||||
RoomRoleError,
|
||||
RoomRoleService,
|
||||
@@ -95,6 +99,60 @@ from .feature_flag import FeatureFlag
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class NestedGenericViewSet(viewsets.GenericViewSet):
|
||||
"""
|
||||
A generic Viewset aims to be used in a nested route context.
|
||||
e.g: `/api/v1.0/resource_1/<resource_1_pk>/resource_2/<resource_2_pk>/`
|
||||
|
||||
It allows to define all url kwargs and lookup fields to perform the lookup.
|
||||
"""
|
||||
|
||||
lookup_fields: list[str] = ["pk"]
|
||||
lookup_url_kwargs: list[str] = []
|
||||
|
||||
def __getattribute__(self, file):
|
||||
"""
|
||||
This method is overridden to allow to get the last lookup field or lookup url kwarg
|
||||
when accessing the `lookup_field` or `lookup_url_kwarg` attribute. This is useful
|
||||
to keep compatibility with all methods used by the parent class `GenericViewSet`.
|
||||
"""
|
||||
if file in ["lookup_field", "lookup_url_kwarg"]:
|
||||
return getattr(self, file + "s", [None])[-1]
|
||||
|
||||
return super().__getattribute__(file)
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Get the list of files for this view.
|
||||
|
||||
`lookup_fields` attribute is enumerated here to perform the nested lookup.
|
||||
"""
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# The last lookup field is removed to perform the nested lookup as it corresponds
|
||||
# to the object pk, it is used within get_object method.
|
||||
lookup_url_kwargs = (
|
||||
self.lookup_url_kwargs[:-1]
|
||||
if self.lookup_url_kwargs
|
||||
else self.lookup_fields[:-1]
|
||||
)
|
||||
|
||||
filter_kwargs = {}
|
||||
for index, lookup_url_kwarg in enumerate(lookup_url_kwargs):
|
||||
if lookup_url_kwarg not in self.kwargs:
|
||||
raise KeyError(
|
||||
f"Expected view {self.__class__.__name__} to be called with a URL "
|
||||
f'keyword argument named "{lookup_url_kwarg}". Fix your URL conf, or '
|
||||
"set the `.lookup_fields` attribute on the view correctly."
|
||||
)
|
||||
|
||||
filter_kwargs.update(
|
||||
{self.lookup_fields[index]: self.kwargs[lookup_url_kwarg]}
|
||||
)
|
||||
|
||||
return queryset.filter(**filter_kwargs)
|
||||
|
||||
|
||||
class SerializerPerActionMixin:
|
||||
"""
|
||||
A mixin to allow to define serializer classes for each action.
|
||||
@@ -298,7 +356,26 @@ class RoomViewSet(
|
||||
):
|
||||
return
|
||||
|
||||
RoomManagement.sync_room_metadata(room)
|
||||
metadata = {
|
||||
"configuration": room.configuration,
|
||||
"access_level": room.access_level,
|
||||
}
|
||||
|
||||
try:
|
||||
RoomManagement().update_metadata(
|
||||
room_name=str(room.id),
|
||||
metadata=metadata,
|
||||
)
|
||||
except RoomNotFoundException:
|
||||
logger.info(
|
||||
"LiveKit room %s does not exist yet, skipping metadata sync",
|
||||
room.id,
|
||||
)
|
||||
except RoomManagementException:
|
||||
logger.warning(
|
||||
"Failed to sync metadata to LiveKit for room %s",
|
||||
room.id,
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
@@ -855,15 +932,6 @@ class RoomViewSet(
|
||||
"""Rename the current participant in the room."""
|
||||
room = self.get_object()
|
||||
|
||||
if (
|
||||
not settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
|
||||
and request.user.is_authenticated
|
||||
):
|
||||
return drf_response.Response(
|
||||
{"error": "Authenticated participants cannot edit their display name"},
|
||||
status=drf_status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
serializer = serializers.RenameParticipantSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
@@ -1008,10 +1076,9 @@ class RecordingViewSet(
|
||||
|
||||
def _auth_get_original_url(self, request):
|
||||
"""
|
||||
Extracts and parses the original URL from the configured header.
|
||||
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
|
||||
Raises PermissionDenied if the header is missing.
|
||||
The original url is passed by the reverse proxy in the header named by the
|
||||
MEDIA_AUTH_ORIGINAL_URL_HEADER setting, which defaults to "HTTP_X_ORIGINAL_URL".
|
||||
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.
|
||||
@@ -1021,13 +1088,9 @@ class RecordingViewSet(
|
||||
reasons.
|
||||
"""
|
||||
# Extract the original URL from the request header
|
||||
original_url = request.META.get(settings.MEDIA_AUTH_ORIGINAL_URL_HEADER)
|
||||
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
|
||||
if not original_url:
|
||||
logger.warning(
|
||||
"Missing %s header in subrequest. Set MEDIA_AUTH_ORIGINAL_URL_HEADER "
|
||||
"to the header your reverse proxy sends.",
|
||||
settings.MEDIA_AUTH_ORIGINAL_URL_HEADER,
|
||||
)
|
||||
logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest")
|
||||
raise drf_exceptions.PermissionDenied()
|
||||
|
||||
logger.debug("Original url: '%s'", original_url)
|
||||
@@ -1352,8 +1415,7 @@ class FileViewSet(
|
||||
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 the reverse proxy in the header named by the
|
||||
MEDIA_AUTH_ORIGINAL_URL_HEADER setting, which defaults to "HTTP_X_ORIGINAL_URL".
|
||||
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.
|
||||
@@ -1372,13 +1434,9 @@ class FileViewSet(
|
||||
- PermissionDenied if authorization fails.
|
||||
"""
|
||||
# Extract the original URL from the request header
|
||||
original_url = request.META.get(settings.MEDIA_AUTH_ORIGINAL_URL_HEADER)
|
||||
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
|
||||
if not original_url:
|
||||
logger.warning(
|
||||
"Missing %s header in subrequest. Set MEDIA_AUTH_ORIGINAL_URL_HEADER "
|
||||
"to the header your reverse proxy sends.",
|
||||
settings.MEDIA_AUTH_ORIGINAL_URL_HEADER,
|
||||
)
|
||||
logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest")
|
||||
raise drf_exceptions.PermissionDenied()
|
||||
|
||||
parsed_url = urlparse(original_url)
|
||||
|
||||
@@ -3,11 +3,7 @@
|
||||
import contextlib
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import (
|
||||
ImproperlyConfigured,
|
||||
SuspiciousOperation,
|
||||
ValidationError,
|
||||
)
|
||||
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from lasuite.oidc_login.backends import (
|
||||
@@ -21,7 +17,6 @@ from core.services.marketing import (
|
||||
ContactData,
|
||||
get_marketing_service,
|
||||
)
|
||||
from core.validators import sub_validator
|
||||
|
||||
|
||||
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
@@ -89,19 +84,6 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
|
||||
def get_existing_user(self, sub, email):
|
||||
"""Fetch existing user by sub or email."""
|
||||
|
||||
sub = str(sub)
|
||||
|
||||
try:
|
||||
sub_validator(sub)
|
||||
except ValidationError as err:
|
||||
raise SuspiciousOperation(
|
||||
"User info contained an invalid sub claim"
|
||||
) from err
|
||||
|
||||
if len(sub) > 255:
|
||||
raise SuspiciousOperation("User info contained an invalid sub claim")
|
||||
|
||||
try:
|
||||
return User.objects.get(sub=sub)
|
||||
except User.DoesNotExist:
|
||||
|
||||
@@ -286,10 +286,6 @@ class ResourceServerBackend(LaSuiteBackend):
|
||||
if user is None and settings.OIDC_CREATE_USER:
|
||||
user = self.create_user(sub)
|
||||
|
||||
if user is not None and not user.is_active:
|
||||
logger.warning("Inactive user attempted authentication: %s", user.pk)
|
||||
raise SuspiciousOperation("User account is disabled.")
|
||||
|
||||
return user
|
||||
|
||||
def create_user(self, sub):
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""External API endpoints"""
|
||||
|
||||
import copy
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
@@ -26,7 +25,6 @@ from rest_framework import (
|
||||
from core import analytics, api, models
|
||||
from core.api.feature_flag import FeatureFlag
|
||||
from core.services.jwt_token import JwtTokenService
|
||||
from core.services.room_management import RoomManagement
|
||||
|
||||
from ..services.provisional_user_service import (
|
||||
ProvisionalUserCreationDisabledError,
|
||||
@@ -144,7 +142,6 @@ class RoomViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""Application-delegated API for room management.
|
||||
@@ -157,12 +154,8 @@ class RoomViewSet(
|
||||
- list: List rooms the user has access to (requires 'rooms:list' scope)
|
||||
- retrieve: Get room details (requires 'rooms:retrieve' scope)
|
||||
- create: Create a new room owned by the user (requires 'rooms:create' scope)
|
||||
- partial_update: Update a room's access level and configuration, for
|
||||
administrators and owners only (requires 'rooms:update' scope)
|
||||
"""
|
||||
|
||||
http_method_names = ["get", "post", "patch", "head", "options"]
|
||||
|
||||
authentication_classes = [
|
||||
authentication.ApplicationJWTAuthentication,
|
||||
authentication.AddonsJWTAuthentication,
|
||||
@@ -196,39 +189,7 @@ class RoomViewSet(
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return drf_response.Response(serializer.data)
|
||||
|
||||
def _track_room_event(self, room, event, **extra_properties):
|
||||
"""Log a room operation for auditing and forward it to analytics."""
|
||||
|
||||
auth_method = type(self.request.successful_authenticator).__name__
|
||||
client_id = (self.request.auth or {}).get("client_id", "unknown")
|
||||
|
||||
# Log for auditing
|
||||
details = "".join(f", {key}={value}" for key, value in extra_properties.items())
|
||||
logger.info(
|
||||
"Room %s via application: room_id=%s, user_id=%s, client_id=%s, auth_method=%s%s",
|
||||
event.removeprefix("room_"),
|
||||
room.id,
|
||||
self.request.user.id,
|
||||
client_id,
|
||||
auth_method,
|
||||
details,
|
||||
)
|
||||
|
||||
analytics.capture(
|
||||
self.request.user,
|
||||
event,
|
||||
{
|
||||
"room_id": str(room.pk),
|
||||
"access_level": room.access_level,
|
||||
"client_id": client_id,
|
||||
"external_api": True,
|
||||
"auth_method": auth_method,
|
||||
**extra_properties,
|
||||
"$set": {"email": self.request.user.email},
|
||||
},
|
||||
)
|
||||
|
||||
def perform_create(self, serializer: serializers.RoomSerializer):
|
||||
def perform_create(self, serializer):
|
||||
"""Set the current user as owner of the newly created room."""
|
||||
room = serializer.save()
|
||||
models.ResourceAccess.objects.create(
|
||||
@@ -237,31 +198,27 @@ class RoomViewSet(
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
self._track_room_event(room, analytics.AnalyticsEvent.ROOM_CREATED)
|
||||
auth_method = type(self.request.successful_authenticator).__name__
|
||||
client_id = (self.request.auth or {}).get("client_id", "unknown")
|
||||
|
||||
def perform_update(self, serializer: serializers.RoomSerializer):
|
||||
"""Persist the room update, sync it to LiveKit, then log and track it."""
|
||||
|
||||
previous_values = {
|
||||
"access_level": serializer.instance.access_level,
|
||||
"configuration": copy.deepcopy(serializer.instance.configuration),
|
||||
}
|
||||
|
||||
room = serializer.save()
|
||||
|
||||
# Report the fields that actually changed, not the ones that were submitted.
|
||||
updated_fields = sorted(
|
||||
field
|
||||
for field, previous_value in previous_values.items()
|
||||
if getattr(room, field) != previous_value
|
||||
# Log for auditing
|
||||
logger.info(
|
||||
"Room created via application: room_id=%s, user_id=%s, client_id=%s, auth_method=%s",
|
||||
room.id,
|
||||
self.request.user.id,
|
||||
client_id,
|
||||
auth_method,
|
||||
)
|
||||
|
||||
if updated_fields:
|
||||
RoomManagement.sync_room_metadata(room)
|
||||
|
||||
self._track_room_event(
|
||||
room,
|
||||
analytics.AnalyticsEvent.ROOM_UPDATED,
|
||||
updated_fields=updated_fields,
|
||||
previous_access_level=previous_values["access_level"],
|
||||
analytics.capture(
|
||||
self.request.user,
|
||||
analytics.AnalyticsEvent.ROOM_CREATED,
|
||||
{
|
||||
"room_id": str(room.pk),
|
||||
"access_level": room.access_level,
|
||||
"client_id": client_id,
|
||||
"external_api": True,
|
||||
"auth_method": auth_method,
|
||||
"$set": {"email": self.request.user.email},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -48,6 +48,8 @@ class ResourceFactory(factory.django.DjangoModelFactory):
|
||||
else:
|
||||
UserResourceAccessFactory(resource=self, user=item[0], role=item[1])
|
||||
|
||||
self.save()
|
||||
|
||||
|
||||
class UserResourceAccessFactory(factory.django.DjangoModelFactory):
|
||||
"""Create fake resource user accesses for testing."""
|
||||
@@ -95,6 +97,8 @@ class RecordingFactory(factory.django.DjangoModelFactory):
|
||||
recording=self, user=item[0], role=item[1]
|
||||
)
|
||||
|
||||
self.save()
|
||||
|
||||
|
||||
class UserRecordingAccessFactory(factory.django.DjangoModelFactory):
|
||||
"""Create fake recording user accesses for testing."""
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
"""Logging filters for the core application."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class SilenceExpected401(logging.Filter):
|
||||
"""Drop the expected 401 from anonymous hits on the /me endpoint.
|
||||
|
||||
The frontend probes `/users/me/` to check authentication; a 401 for
|
||||
anonymous users is normal, not a warning worth logging.
|
||||
"""
|
||||
|
||||
def filter(self, record):
|
||||
"""Return False for a 401 on a silenced path, True otherwise."""
|
||||
if getattr(record, "status_code", None) != 401:
|
||||
return True
|
||||
|
||||
request = getattr(record, "request", None)
|
||||
path = getattr(request, "path", None)
|
||||
if not path:
|
||||
return True
|
||||
|
||||
return path not in settings.LOGGING_SILENCED_401_PATHS
|
||||
@@ -8,8 +8,6 @@ import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import core.validators
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
@@ -43,7 +41,7 @@ class Migration(migrations.Migration):
|
||||
('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')),
|
||||
('sub', models.CharField(blank=True, help_text='Optional for pending users; required upon account activation. 255 characters or fewer. Printable ASCII characters only.', max_length=255, null=True, unique=True, validators=[core.validators.sub_validator], 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')),
|
||||
('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')),
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 5.2.16 on 2026-09-23 16:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0022_user_default_room_access_level_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='recording',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('initiated', 'Initiated'), ('active', 'Active'), ('stopped', 'Stopped'), ('saved', 'Saved'), ('aborted', 'Aborted'), ('failed', 'Failed'), ('failed_to_start', 'Failed to Start'), ('failed_to_stop', 'Failed to Stop'), ('notification_succeeded', 'Notification succeeded'), ('external_process_successful', 'External process successful'), ('external_process_failed', 'External process failed')], default='initiated', max_length=50),
|
||||
),
|
||||
]
|
||||
@@ -27,7 +27,6 @@ from timezone_field import TimeZoneField
|
||||
|
||||
from . import fields, utils
|
||||
from .recording.enums import FileExtension
|
||||
from .validators import sub_validator
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
@@ -58,7 +57,6 @@ class RecordingStatusChoices(models.TextChoices):
|
||||
STOPPED = "stopped", _("Stopped")
|
||||
SAVED = "saved", _("Saved")
|
||||
ABORTED = "aborted", _("Aborted")
|
||||
FAILED = "failed", _("Failed")
|
||||
FAILED_TO_START = "failed_to_start", _("Failed to Start")
|
||||
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
|
||||
NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded")
|
||||
@@ -80,13 +78,17 @@ class RecordingStatusChoices(models.TextChoices):
|
||||
cls.STOPPED,
|
||||
cls.SAVED,
|
||||
cls.ABORTED,
|
||||
cls.FAILED,
|
||||
cls.EXTERNAL_PROCESS_SUCCESSFUL,
|
||||
cls.EXTERNAL_PROCESS_FAILED,
|
||||
cls.FAILED_TO_START,
|
||||
cls.FAILED_TO_STOP,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def is_unsuccessful(cls, status):
|
||||
"""Determine if the recording status represents an unsuccessful state."""
|
||||
return status in {cls.ABORTED, cls.FAILED_TO_START, cls.FAILED_TO_STOP}
|
||||
|
||||
|
||||
class RecordingModeChoices(models.TextChoices):
|
||||
"""Recording mode choices."""
|
||||
@@ -143,11 +145,19 @@ class BaseModel(models.Model):
|
||||
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
"""User model to work with OIDC only authentication."""
|
||||
|
||||
sub_validator = validators.RegexValidator(
|
||||
regex=r"^[\w.@+-]+\Z",
|
||||
message=_(
|
||||
"Enter a valid sub. This value may contain only letters, "
|
||||
"numbers, and @/./+/-/_ characters."
|
||||
),
|
||||
)
|
||||
|
||||
sub = models.CharField(
|
||||
_("sub"),
|
||||
help_text=_(
|
||||
"Optional for pending users; required upon account activation. "
|
||||
"255 characters or fewer. Printable ASCII characters only."
|
||||
"255 characters or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
),
|
||||
max_length=255,
|
||||
unique=True,
|
||||
@@ -579,7 +589,6 @@ class Recording(BaseModel):
|
||||
4. NOTIFICATION_SUCCEEDED: External service has been notified of this recording
|
||||
|
||||
Error States:
|
||||
- FAILED: Egress failed mid-recording
|
||||
- FAILED_TO_START: Worker failed to initialize recording
|
||||
- FAILED_TO_STOP: Worker failed during stop operation
|
||||
- ABORTED: Recording was terminated before completion
|
||||
|
||||
@@ -8,50 +8,3 @@ class FileExtension(Enum):
|
||||
|
||||
OGG = "ogg"
|
||||
MP4 = "mp4"
|
||||
|
||||
|
||||
class RecordingWorkerEvent(Enum):
|
||||
"""Lifecycle events a recording worker reports about a recording.
|
||||
|
||||
It is intended to be free of SFU-specific vocabulary.
|
||||
"""
|
||||
|
||||
# The worker accepted the request but is not recording yet.
|
||||
STARTING = "starting"
|
||||
# The worker is recording.
|
||||
STARTED = "started"
|
||||
# The worker stopped recording and is flushing the media file.
|
||||
SAVING = "saving"
|
||||
|
||||
# The recording ended, its media file is available.
|
||||
COMPLETED = "completed"
|
||||
# The recording ended on its configured limit, its media file is available.
|
||||
LIMIT_REACHED = "limit reached"
|
||||
# The worker stopped before it ever started recording, there is no media file.
|
||||
ABORTED = "aborted"
|
||||
# The worker hit a runtime error once recording had started; its media file
|
||||
# may be available.
|
||||
FAILED = "failed"
|
||||
|
||||
@classmethod
|
||||
def is_terminal(cls, event):
|
||||
"""Determine if the event ends the recording's lifecycle (successful or not)."""
|
||||
|
||||
return event in TERMINAL_EVENTS
|
||||
|
||||
|
||||
SUCCESSFUL_EVENTS = frozenset(
|
||||
{
|
||||
RecordingWorkerEvent.COMPLETED,
|
||||
RecordingWorkerEvent.LIMIT_REACHED,
|
||||
}
|
||||
)
|
||||
|
||||
UNSUCCESSFUL_EVENTS = frozenset(
|
||||
{
|
||||
RecordingWorkerEvent.ABORTED,
|
||||
RecordingWorkerEvent.FAILED,
|
||||
}
|
||||
)
|
||||
|
||||
TERMINAL_EVENTS = SUCCESSFUL_EVENTS | UNSUCCESSFUL_EVENTS
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""Recording-related Events Service"""
|
||||
"""Recording-related LiveKit Events Service"""
|
||||
|
||||
# pylint: disable=no-member
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
from livekit import api
|
||||
|
||||
from core import models, utils
|
||||
from core.models import Recording
|
||||
from core.recording.enums import (
|
||||
UNSUCCESSFUL_EVENTS,
|
||||
RecordingWorkerEvent,
|
||||
)
|
||||
from core.recording.event.notification import notification_service
|
||||
from core.services.room_management import (
|
||||
RoomManagement,
|
||||
@@ -26,113 +26,25 @@ class RecordingNotSavableError(Exception):
|
||||
"""Recording cannot be saved because it is either in an error state or has already been saved"""
|
||||
|
||||
|
||||
# Notification sent to the room's participants, per event and recording mode.
|
||||
NOTIFICATION_PREFIXES = {
|
||||
models.RecordingModeChoices.SCREEN_RECORDING: "screenRecording",
|
||||
models.RecordingModeChoices.TRANSCRIPT: "transcription",
|
||||
}
|
||||
NOTIFICATION_SUFFIXES = {
|
||||
RecordingWorkerEvent.LIMIT_REACHED: "LimitReached",
|
||||
RecordingWorkerEvent.FAILED: "Failed",
|
||||
RecordingWorkerEvent.ABORTED: "Aborted",
|
||||
}
|
||||
|
||||
|
||||
def get_notification_type(recording_mode, event):
|
||||
"""Generate corresponding notification type string."""
|
||||
try:
|
||||
return f"{NOTIFICATION_PREFIXES[recording_mode]}{NOTIFICATION_SUFFIXES[event]}"
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
|
||||
# Recording status in the room's metadata, per event.
|
||||
ROOM_METADATA_RECORDING_STATUSES = {
|
||||
RecordingWorkerEvent.STARTED: "started",
|
||||
RecordingWorkerEvent.SAVING: "saving",
|
||||
}
|
||||
|
||||
|
||||
class RecordingEventsService:
|
||||
"""Handles recording-related worker events.
|
||||
|
||||
Two entry points: `handle_update` for the events a running recording
|
||||
reports, and `handle_terminal_event` for the one ending it.
|
||||
"""
|
||||
"""Handles recording-related LiveKit webhook events."""
|
||||
|
||||
@staticmethod
|
||||
def log_worker_error(recording, event, error=None, error_code=None):
|
||||
"""Log FAILED at error level and expected ABORTED outcomes at info level."""
|
||||
|
||||
if event == RecordingWorkerEvent.FAILED:
|
||||
log = logger.error
|
||||
elif event == RecordingWorkerEvent.ABORTED:
|
||||
log = logger.info
|
||||
else:
|
||||
return
|
||||
|
||||
log(
|
||||
"Recording worker reported %s for recording %s (room=%s, mode=%s): %s (error_code=%s)",
|
||||
event.value,
|
||||
recording.id,
|
||||
recording.room.id,
|
||||
recording.mode,
|
||||
error or "no error reported",
|
||||
error_code or "no error_code reported",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _notify_participants(recording: Recording, event: RecordingWorkerEvent):
|
||||
"""Notify the room's participants that a recording ended on the given event."""
|
||||
recording_mode = recording.options.get("original_mode", None) or recording.mode
|
||||
|
||||
notification_type = get_notification_type(recording_mode, event)
|
||||
if not notification_type:
|
||||
logger.warning(
|
||||
"Could not find notification type for: "
|
||||
"room=%s, recording_id=%s, mode=%s, event=%s",
|
||||
recording.room.id,
|
||||
recording.id,
|
||||
recording_mode,
|
||||
event.value,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
utils.notify_participants(
|
||||
room_name=str(recording.room.id),
|
||||
notification_data={"type": notification_type},
|
||||
)
|
||||
except utils.NotificationError as e:
|
||||
raise RecordingEventsError(
|
||||
f"Failed to notify participants in room '{recording.room.id}' about "
|
||||
f"recording {event.value} (recording_id={recording.id})"
|
||||
) from e
|
||||
|
||||
@staticmethod
|
||||
def _log_notification_failure(recording, event: RecordingWorkerEvent):
|
||||
"""Log a participant notification error on an unsuccessful recording."""
|
||||
|
||||
logger.exception(
|
||||
"Failed to notify participants that recording %s %s (room=%s)",
|
||||
recording.id,
|
||||
event.value,
|
||||
recording.room.id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def handle_update(recording: Recording, event: RecordingWorkerEvent):
|
||||
"""Handle non-terminal worker events and sync recording state to room metadata.
|
||||
|
||||
Terminal events are dispatched through `handle_terminal_event` instead.
|
||||
"""
|
||||
def handle_update(recording: Recording, egress_status):
|
||||
"""Handle egress status updates and sync recording state to room metadata."""
|
||||
|
||||
room_name = str(recording.room.id)
|
||||
|
||||
recording_status = ROOM_METADATA_RECORDING_STATUSES.get(event)
|
||||
status_mapping = {
|
||||
api.EgressStatus.EGRESS_ACTIVE: "started",
|
||||
api.EgressStatus.EGRESS_ENDING: "saving",
|
||||
api.EgressStatus.EGRESS_ABORTED: "aborted",
|
||||
}
|
||||
|
||||
recording_status = status_mapping.get(egress_status)
|
||||
if recording_status:
|
||||
try:
|
||||
RoomManagement.update_metadata(
|
||||
RoomManagement().update_metadata(
|
||||
room_name, {"recording_status": recording_status}
|
||||
)
|
||||
except RoomNotFoundException:
|
||||
@@ -143,113 +55,42 @@ class RecordingEventsService:
|
||||
except RoomManagementException as e:
|
||||
logger.exception("Failed to update room's metadata: %s", e)
|
||||
|
||||
def handle_terminal_event(self, recording: Recording, event: RecordingWorkerEvent):
|
||||
"""Run the appropriate handlers for a terminal event, given the recording's state."""
|
||||
|
||||
if not RecordingWorkerEvent.is_terminal(event):
|
||||
logger.warning(
|
||||
"Ignoring non-terminal event %s dispatched as a terminal event "
|
||||
"for recording %s.",
|
||||
event.value,
|
||||
recording.id,
|
||||
)
|
||||
return
|
||||
|
||||
if event in UNSUCCESSFUL_EVENTS:
|
||||
self._flag_unsuccessful_recording(recording, event)
|
||||
else:
|
||||
self._save_successful_recording(recording, event)
|
||||
|
||||
def _flag_unsuccessful_recording(
|
||||
self, recording: Recording, event: RecordingWorkerEvent
|
||||
):
|
||||
"""Persist the outcome of a recording the worker announced as unsuccessful."""
|
||||
|
||||
# Aborted
|
||||
if event == RecordingWorkerEvent.ABORTED:
|
||||
if recording.status == models.RecordingStatusChoices.ACTIVE:
|
||||
self._apply_outcome(recording, event, self._handle_aborted)
|
||||
return
|
||||
|
||||
# Failed
|
||||
if event == RecordingWorkerEvent.FAILED:
|
||||
if recording.is_savable():
|
||||
self._apply_outcome(recording, event, self._handle_failed)
|
||||
return
|
||||
|
||||
logger.error(
|
||||
"Unsuccessful event %s has no handler; recording %s keeps status '%s'.",
|
||||
event.value,
|
||||
recording.id,
|
||||
recording.status,
|
||||
)
|
||||
|
||||
def _save_successful_recording(
|
||||
self, recording: Recording, event: RecordingWorkerEvent
|
||||
):
|
||||
"""Save a recording whose media file the worker made available."""
|
||||
|
||||
# Limit reached
|
||||
if (
|
||||
event == RecordingWorkerEvent.LIMIT_REACHED
|
||||
and recording.status == models.RecordingStatusChoices.ACTIVE
|
||||
):
|
||||
self._apply_outcome(recording, event, self._handle_limit_reached)
|
||||
|
||||
try:
|
||||
self._handle_successful(recording)
|
||||
except RecordingNotSavableError:
|
||||
logger.warning(
|
||||
"Recording %s is not savable on a completed recording "
|
||||
"(already saved or in an error state); ignoring.",
|
||||
recording.id,
|
||||
)
|
||||
|
||||
def _apply_outcome(
|
||||
self, recording: Recording, event: RecordingWorkerEvent, handler
|
||||
):
|
||||
"""Keep notification failure non-fatal."""
|
||||
|
||||
try:
|
||||
handler(recording)
|
||||
except RecordingEventsError:
|
||||
self._log_notification_failure(recording, event)
|
||||
|
||||
@classmethod
|
||||
def _handle_limit_reached(cls, recording: Recording):
|
||||
@staticmethod
|
||||
def handle_limit_reached(recording: Recording):
|
||||
"""Stop recording and notify participants when limit is reached."""
|
||||
|
||||
recording.status = models.RecordingStatusChoices.STOPPED
|
||||
recording.save()
|
||||
|
||||
cls._notify_participants(recording, RecordingWorkerEvent.LIMIT_REACHED)
|
||||
notification_mapping = {
|
||||
models.RecordingModeChoices.SCREEN_RECORDING: "screenRecordingLimitReached",
|
||||
models.RecordingModeChoices.TRANSCRIPT: "transcriptionLimitReached",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _handle_failed(cls, recording: Recording):
|
||||
"""Set recording status to failed, matching the worker event, and notify participants.
|
||||
notification_type = notification_mapping.get(recording.mode)
|
||||
if not notification_type:
|
||||
return
|
||||
|
||||
FAILED: used when an actual runtime/pipeline error occurs after the
|
||||
recording has started
|
||||
"""
|
||||
recording.status = models.RecordingStatusChoices.FAILED
|
||||
recording.save()
|
||||
|
||||
cls._notify_participants(recording, RecordingWorkerEvent.FAILED)
|
||||
|
||||
@classmethod
|
||||
def _handle_aborted(cls, recording: Recording):
|
||||
"""Set recording status to aborted, matching the worker event, and notify participants.
|
||||
|
||||
ABORTED: used when the worker stops before it ever became
|
||||
active/recording
|
||||
"""
|
||||
recording.status = models.RecordingStatusChoices.ABORTED
|
||||
recording.save()
|
||||
|
||||
cls._notify_participants(recording, RecordingWorkerEvent.ABORTED)
|
||||
try:
|
||||
utils.notify_participants(
|
||||
room_name=str(recording.room.id),
|
||||
notification_data={"type": notification_type},
|
||||
)
|
||||
except utils.NotificationError as e:
|
||||
logger.exception(
|
||||
"Failed to notify participants about recording limit reached: "
|
||||
"room=%s, recording_id=%s, mode=%s",
|
||||
recording.room.id,
|
||||
recording.id,
|
||||
recording.mode,
|
||||
)
|
||||
raise RecordingEventsError(
|
||||
f"Failed to notify participants in room '{recording.room.id}' about "
|
||||
f"recording limit reached (recording_id={recording.id})"
|
||||
) from e
|
||||
|
||||
@staticmethod
|
||||
def _handle_successful(recording: Recording):
|
||||
def handle_complete(recording: Recording):
|
||||
"""Notify external services and save recording."""
|
||||
|
||||
if not recording.is_savable():
|
||||
|
||||
@@ -68,7 +68,7 @@ class WorkerServiceMediator:
|
||||
mode = recording.options.get("original_mode", None) or recording.mode
|
||||
|
||||
try:
|
||||
RoomManagement.update_metadata(
|
||||
RoomManagement().update_metadata(
|
||||
room_name, {"recording_mode": mode, "recording_status": "starting"}
|
||||
)
|
||||
except RoomNotFoundException:
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
# pylint: disable=no-member
|
||||
|
||||
import logging
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit import api as livekit_api
|
||||
|
||||
@@ -12,8 +10,6 @@ from ..enums import FileExtension
|
||||
from .exceptions import WorkerConnectionError, WorkerResponseError
|
||||
from .factories import WorkerServiceConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseEgressService:
|
||||
"""Base egress defining common methods to manage and interact with LiveKit egress processes."""
|
||||
@@ -24,7 +20,7 @@ class BaseEgressService:
|
||||
|
||||
def _get_filepath(self, filename: str, extension: str) -> str:
|
||||
"""Construct the file path for a given filename and extension.
|
||||
Insecure method, doesn't handle paths robustly and securely.
|
||||
Unsecure method, doesn't handle paths robustly and securely.
|
||||
"""
|
||||
return f"{self._config.output_folder}/{filename}.{extension}"
|
||||
|
||||
@@ -53,22 +49,6 @@ class BaseEgressService:
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
@staticmethod
|
||||
def _log_egress_error(response, event: str):
|
||||
"""Log the reason LiveKit reported an unsuccessful egress on stop.
|
||||
|
||||
Mirrors the logging done in the 'egress_ended' webhook. The
|
||||
StopEgress response carries the same error fields.
|
||||
"""
|
||||
logger.error(
|
||||
"Egress %s on stop (egress_id=%s, status=%s): %s (error_code=%s)",
|
||||
event,
|
||||
response.egress_id,
|
||||
livekit_api.EgressStatus.Name(response.status),
|
||||
response.error or "no error reported",
|
||||
response.error_code or "no error_code reported",
|
||||
)
|
||||
|
||||
def stop(self, worker_id: str) -> str:
|
||||
"""Stop an ongoing egress worker.
|
||||
The StopEgressRequest is shared among all types of egress,
|
||||
@@ -86,26 +66,14 @@ class BaseEgressService:
|
||||
"LiveKit response is missing the recording status."
|
||||
)
|
||||
|
||||
# To avoid exposing EgressStatus values and coupling with LiveKit outside of this class,
|
||||
# the response status is mapped to simpler "ABORTED", "STOPPED" or "FAILED_TO_STOP" strings.
|
||||
if response.status == livekit_api.EgressStatus.EGRESS_ABORTED:
|
||||
return "ABORTED"
|
||||
|
||||
if response.status == livekit_api.EgressStatus.EGRESS_ENDING:
|
||||
return "STOPPED"
|
||||
|
||||
if response.status == livekit_api.EgressStatus.EGRESS_LIMIT_REACHED:
|
||||
return "STOPPED"
|
||||
|
||||
# Cases below should be very infrequent as status changes should be
|
||||
# received and processed by `handle_ended`, thus `stop` would not
|
||||
# be called (unless failure and stop are very close in time).
|
||||
# We therefore accept not to notify the user in this code branch.
|
||||
# This could be fixed in a future refactoring.
|
||||
if response.status == livekit_api.EgressStatus.EGRESS_ABORTED:
|
||||
self._log_egress_error(response, "aborted")
|
||||
return "ABORTED"
|
||||
|
||||
if response.status == livekit_api.EgressStatus.EGRESS_FAILED:
|
||||
self._log_egress_error(response, "failed")
|
||||
return "FAILED"
|
||||
|
||||
self._log_egress_error(response, "failed to stop")
|
||||
return "FAILED_TO_STOP"
|
||||
|
||||
def start(self, room_name, recording_id):
|
||||
|
||||
@@ -12,12 +12,15 @@ from django.conf import settings
|
||||
from livekit import api
|
||||
|
||||
from core import models
|
||||
from core.recording.enums import RecordingWorkerEvent
|
||||
from core.recording.services.metadata_collector import (
|
||||
MetadataCollectorException,
|
||||
MetadataCollectorService,
|
||||
)
|
||||
from core.recording.services.recording_events import RecordingEventsService
|
||||
from core.recording.services.recording_events import (
|
||||
RecordingEventsError,
|
||||
RecordingEventsService,
|
||||
RecordingNotSavableError,
|
||||
)
|
||||
|
||||
from .lobby import LobbyService
|
||||
from .presence import PresenceCache
|
||||
@@ -49,6 +52,12 @@ class InvalidPayloadError(LiveKitWebhookError):
|
||||
status_code = 400
|
||||
|
||||
|
||||
class UnsupportedEventTypeError(LiveKitWebhookError):
|
||||
"""Unsupported event type."""
|
||||
|
||||
status_code = 422
|
||||
|
||||
|
||||
class ActionFailedError(LiveKitWebhookError):
|
||||
"""Webhook action fails to process or complete."""
|
||||
|
||||
@@ -65,7 +74,6 @@ class LiveKitWebhookEventType(Enum):
|
||||
# Participant events
|
||||
PARTICIPANT_JOINED = "participant_joined"
|
||||
PARTICIPANT_LEFT = "participant_left"
|
||||
PARTICIPANT_CONNECTION_ABORTED = "participant_connection_aborted"
|
||||
|
||||
# Track events
|
||||
TRACK_PUBLISHED = "track_published"
|
||||
@@ -81,30 +89,6 @@ class LiveKitWebhookEventType(Enum):
|
||||
INGRESS_ENDED = "ingress_ended"
|
||||
|
||||
|
||||
# LiveKit egress statuses mapped to recording worker event statuses
|
||||
EGRESS_STATUS_TO_RECORDING_EVENT = {
|
||||
api.EgressStatus.EGRESS_STARTING: RecordingWorkerEvent.STARTING,
|
||||
api.EgressStatus.EGRESS_ACTIVE: RecordingWorkerEvent.STARTED,
|
||||
api.EgressStatus.EGRESS_ENDING: RecordingWorkerEvent.SAVING,
|
||||
api.EgressStatus.EGRESS_COMPLETE: RecordingWorkerEvent.COMPLETED,
|
||||
api.EgressStatus.EGRESS_LIMIT_REACHED: RecordingWorkerEvent.LIMIT_REACHED,
|
||||
api.EgressStatus.EGRESS_ABORTED: RecordingWorkerEvent.ABORTED,
|
||||
api.EgressStatus.EGRESS_FAILED: RecordingWorkerEvent.FAILED,
|
||||
}
|
||||
|
||||
|
||||
def to_recording_event(egress_status):
|
||||
"""Translate a LiveKit egress status into a recording worker event."""
|
||||
|
||||
event = EGRESS_STATUS_TO_RECORDING_EVENT.get(egress_status)
|
||||
if event is None:
|
||||
logger.warning(
|
||||
"Unmapped LiveKit egress status '%s', ignoring the event.",
|
||||
egress_status,
|
||||
)
|
||||
return event
|
||||
|
||||
|
||||
class LiveKitEventsService:
|
||||
"""Service for processing and handling LiveKit webhook events and notifications."""
|
||||
|
||||
@@ -169,13 +153,10 @@ class LiveKitEventsService:
|
||||
|
||||
try:
|
||||
webhook_type = LiveKitWebhookEventType(data.event)
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Ignoring unknown LiveKit webhook event type '%s' for room '%s'",
|
||||
data.event,
|
||||
room_name,
|
||||
)
|
||||
return
|
||||
except ValueError as e:
|
||||
raise UnsupportedEventTypeError(
|
||||
f"Unknown webhook type: {data.event}"
|
||||
) from e
|
||||
|
||||
# Handle according to received webhook type
|
||||
handler = self._webhook_handlers.get(webhook_type.value)
|
||||
@@ -194,20 +175,12 @@ class LiveKitEventsService:
|
||||
f"Recording with worker ID {egress_id} does not exist"
|
||||
) from err
|
||||
|
||||
event = to_recording_event(data.egress_info.status)
|
||||
if event is None:
|
||||
return
|
||||
|
||||
self.recording_events.handle_update(recording, event)
|
||||
egress_status = data.egress_info.status
|
||||
self.recording_events.handle_update(recording, egress_status)
|
||||
|
||||
def _handle_egress_ended(self, data):
|
||||
"""Handle 'egress_ended' event.
|
||||
"""Handle 'egress_ended' event."""
|
||||
|
||||
Egress ended is sent with one of these statuses:
|
||||
EGRESS_COMPLETE, EGRESS_FAILED, EGRESS_ABORTED, EGRESS_LIMIT_REACHED
|
||||
"""
|
||||
|
||||
# Fetch recording
|
||||
try:
|
||||
recording = models.Recording.objects.select_related("room").get(
|
||||
worker_id=data.egress_info.egress_id
|
||||
@@ -217,20 +190,9 @@ class LiveKitEventsService:
|
||||
f"Recording with worker ID {data.egress_info.egress_id} does not exist"
|
||||
) from err
|
||||
|
||||
event = to_recording_event(data.egress_info.status)
|
||||
|
||||
# Log if/why the recording failed
|
||||
self.recording_events.log_worker_error(
|
||||
recording,
|
||||
event,
|
||||
error=data.egress_info.error,
|
||||
error_code=data.egress_info.error_code,
|
||||
)
|
||||
|
||||
# Update room
|
||||
try:
|
||||
room_name = str(recording.room.id)
|
||||
RoomManagement.update_metadata(
|
||||
RoomManagement().update_metadata(
|
||||
room_name, remove_keys=["recording_mode", "recording_status"]
|
||||
)
|
||||
except RoomNotFoundException:
|
||||
@@ -241,17 +203,38 @@ class LiveKitEventsService:
|
||||
except RoomManagementException as e:
|
||||
logger.exception("Failed to update room's metadata: %s", e)
|
||||
|
||||
# Stop metadata collector
|
||||
if recording.options.get("metadata_collector_dispatch_id", None) is not None:
|
||||
try:
|
||||
MetadataCollectorService().stop(recording)
|
||||
except MetadataCollectorException:
|
||||
logger.warning("Failed to stop the MetadataCollectorService")
|
||||
|
||||
if event is None:
|
||||
return
|
||||
if (
|
||||
data.egress_info.status == api.EgressStatus.EGRESS_LIMIT_REACHED
|
||||
and recording.status == models.RecordingStatusChoices.ACTIVE
|
||||
):
|
||||
try:
|
||||
self.recording_events.handle_limit_reached(recording)
|
||||
except RecordingEventsError as e:
|
||||
raise ActionFailedError(
|
||||
f"Failed to process limit reached event for recording {recording}"
|
||||
) from e
|
||||
|
||||
self.recording_events.handle_terminal_event(recording, event)
|
||||
# Finalize the recording, the egress has uploaded the file to the storage
|
||||
if data.egress_info.status in [
|
||||
api.EgressStatus.EGRESS_COMPLETE,
|
||||
api.EgressStatus.EGRESS_LIMIT_REACHED,
|
||||
]:
|
||||
try:
|
||||
self.recording_events.handle_complete(recording)
|
||||
except RecordingNotSavableError:
|
||||
logger.warning(
|
||||
"Recording %s is not savable on egress complete "
|
||||
"(already saved or in an error state); ignoring.",
|
||||
recording.id,
|
||||
)
|
||||
|
||||
# Silently ignoring EGRESS_ABORTED, EGRESS_FAILED
|
||||
|
||||
@staticmethod
|
||||
def _is_connection_test_room(room_name: str) -> bool:
|
||||
|
||||
@@ -4,12 +4,11 @@ import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Dict, FrozenSet, Optional, Sequence, Tuple
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from uuid import UUID
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.utils import timezone
|
||||
|
||||
from core import models, utils
|
||||
|
||||
@@ -47,7 +46,6 @@ class LobbyParticipant:
|
||||
username: str
|
||||
color: str
|
||||
id: str
|
||||
entered_at: str
|
||||
|
||||
def to_dict(self) -> Dict[str, str]:
|
||||
"""Serialize the participant object to a dict representation."""
|
||||
@@ -56,7 +54,6 @@ class LobbyParticipant:
|
||||
"username": self.username,
|
||||
"id": self.id,
|
||||
"color": self.color,
|
||||
"entered_at": self.entered_at,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -71,7 +68,6 @@ class LobbyParticipant:
|
||||
username=data["username"],
|
||||
id=data["id"],
|
||||
color=data["color"],
|
||||
entered_at=data["entered_at"],
|
||||
)
|
||||
except (KeyError, ValueError) as e:
|
||||
logger.exception("Error creating Participant from dict:")
|
||||
@@ -90,47 +86,6 @@ class LobbyService:
|
||||
"""Generate cache key for participant(s) data."""
|
||||
return f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
|
||||
|
||||
@staticmethod
|
||||
def _get_index_key(room_id: UUID) -> str:
|
||||
"""Raw Redis key of the per-room participant index (a native SET)."""
|
||||
return cache.client.make_key(f"{settings.LOBBY_KEY_PREFIX}-index_{room_id!s}")
|
||||
|
||||
@staticmethod
|
||||
def _redis(write: bool = True):
|
||||
"""Raw redis-py client.
|
||||
|
||||
SADD/SREM/SMEMBERS are not exposed by the Django cache API; this is
|
||||
the documented django-redis escape hatch.
|
||||
"""
|
||||
return cache.client.get_client(write=write)
|
||||
|
||||
def _index_add(self, room_id: UUID, participant_id: str) -> None:
|
||||
"""Record a participant id in the room index."""
|
||||
index_key = self._get_index_key(room_id)
|
||||
pipe = self._redis().pipeline(transaction=False)
|
||||
pipe.sadd(index_key, participant_id)
|
||||
pipe.expire(index_key, settings.LOBBY_ACCEPTED_TIMEOUT)
|
||||
pipe.execute()
|
||||
|
||||
def _index_members(self, room_id: UUID) -> FrozenSet[str]:
|
||||
"""All participant ids currently indexed for the room."""
|
||||
members = self._redis(write=False).smembers(self._get_index_key(room_id))
|
||||
return frozenset(
|
||||
member.decode() if isinstance(member, bytes) else member
|
||||
for member in members
|
||||
)
|
||||
|
||||
def _index_touch(self, room_id: UUID) -> None:
|
||||
"""Re-arm the room index backstop TTL."""
|
||||
self._redis().expire(
|
||||
self._get_index_key(room_id), settings.LOBBY_ACCEPTED_TIMEOUT
|
||||
)
|
||||
|
||||
def _index_remove(self, room_id: UUID, *participant_ids: str) -> None:
|
||||
"""Drop participant ids from the room index."""
|
||||
if participant_ids:
|
||||
self._redis().srem(self._get_index_key(room_id), *participant_ids)
|
||||
|
||||
@staticmethod
|
||||
def _get_or_create_participant_id(request) -> str:
|
||||
"""Extract unique participant identifier from the request."""
|
||||
@@ -207,7 +162,6 @@ class LobbyService:
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color=utils.generate_color(participant_id),
|
||||
entered_at=timezone.now().isoformat(),
|
||||
)
|
||||
else:
|
||||
participant.status = LobbyParticipantStatus.ACCEPTED
|
||||
@@ -255,12 +209,15 @@ class LobbyService:
|
||||
cache.touch(
|
||||
self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT
|
||||
)
|
||||
self._index_touch(room_id)
|
||||
|
||||
def enter(
|
||||
self, room_id: UUID, participant_id: str, username: str
|
||||
) -> LobbyParticipant:
|
||||
"""Add participant to waiting lobby."""
|
||||
"""Add participant to waiting lobby.
|
||||
|
||||
Create a new participant entry in waiting status and notify room
|
||||
participants of the new entry request.
|
||||
"""
|
||||
|
||||
color = utils.generate_color(participant_id)
|
||||
|
||||
@@ -269,7 +226,6 @@ class LobbyService:
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color=color,
|
||||
entered_at=timezone.now().isoformat(),
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -289,7 +245,6 @@ class LobbyService:
|
||||
participant.to_dict(),
|
||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||
)
|
||||
self._index_add(room_id, participant_id)
|
||||
|
||||
return participant
|
||||
|
||||
@@ -311,42 +266,28 @@ class LobbyService:
|
||||
cache.delete(cache_key)
|
||||
return None
|
||||
|
||||
def list_waiting_participants(self, room_id: UUID) -> Sequence[dict]:
|
||||
def list_waiting_participants(self, room_id: UUID) -> List[dict]:
|
||||
"""List all waiting participants for a room."""
|
||||
|
||||
member_ids = self._index_members(room_id)
|
||||
pattern = self._get_cache_key(room_id, "*")
|
||||
keys = list(cache.iter_keys(pattern, itersize=utils.CACHE_SCAN_ITERSIZE))
|
||||
|
||||
if not member_ids:
|
||||
return ()
|
||||
if not keys:
|
||||
return []
|
||||
|
||||
keys_by_id = {
|
||||
participant_id: self._get_cache_key(room_id, participant_id)
|
||||
for participant_id in member_ids
|
||||
}
|
||||
data = cache.get_many(list(keys_by_id.values()))
|
||||
data = cache.get_many(keys)
|
||||
|
||||
dead_ids = []
|
||||
waiting_participants = []
|
||||
|
||||
for participant_id, cache_key in keys_by_id.items():
|
||||
raw_participant = data.get(cache_key)
|
||||
if raw_participant is None:
|
||||
dead_ids.append(participant_id)
|
||||
continue
|
||||
for cache_key, raw_participant in data.items():
|
||||
try:
|
||||
participant = LobbyParticipant.from_dict(raw_participant)
|
||||
except LobbyParticipantParsingError:
|
||||
cache.delete(cache_key)
|
||||
dead_ids.append(participant_id)
|
||||
continue
|
||||
if participant.status == LobbyParticipantStatus.WAITING:
|
||||
waiting_participants.append(participant.to_dict())
|
||||
|
||||
self._index_remove(room_id, *dead_ids)
|
||||
|
||||
waiting_participants.sort(key=lambda p: p["entered_at"], reverse=True)
|
||||
|
||||
return tuple(waiting_participants)
|
||||
return waiting_participants
|
||||
|
||||
def handle_participant_entry(
|
||||
self,
|
||||
@@ -400,24 +341,16 @@ class LobbyService:
|
||||
|
||||
participant.status = status
|
||||
cache.set(cache_key, participant.to_dict(), timeout=timeout)
|
||||
self._index_touch(room_id)
|
||||
|
||||
def clear_room_cache(self, room_id: UUID) -> None:
|
||||
"""Clear all participant entries from the cache for a specific room."""
|
||||
|
||||
member_ids = self._index_members(room_id)
|
||||
if member_ids:
|
||||
cache.delete_many(
|
||||
[
|
||||
self._get_cache_key(room_id, participant_id)
|
||||
for participant_id in member_ids
|
||||
]
|
||||
)
|
||||
self._redis().delete(self._get_index_key(room_id))
|
||||
cache.delete_pattern(
|
||||
self._get_cache_key(room_id, "*"), itersize=utils.CACHE_SCAN_ITERSIZE
|
||||
)
|
||||
|
||||
def clear_participant_cache(self, room_id: UUID, participant_id: str) -> None:
|
||||
"""Clear a given participant entry from the cache for a specific room."""
|
||||
|
||||
cache_key = self._get_cache_key(room_id, participant_id)
|
||||
cache.delete(cache_key)
|
||||
self._index_remove(room_id, participant_id)
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
"""Presence cache."""
|
||||
"""Presence cache.
|
||||
|
||||
Redis-backed memo of "this identity is currently connected to this room".
|
||||
|
||||
This module is intentionally a *pure cache store* with no dependency on other
|
||||
services, so that `participants_management` (which talks to LiveKit) can
|
||||
import it without creating an import cycle. The composition of "check cache,
|
||||
fall back to LiveKit" lives in
|
||||
`ParticipantsManagement.check_if_in_meeting_cached`.
|
||||
|
||||
Only positive answers are stored: a sticky negative would lock out someone
|
||||
who joins right after a miss for the whole TTL. The TTL is a safety net in
|
||||
case an invalidation webhook is lost.
|
||||
"""
|
||||
|
||||
from typing import FrozenSet
|
||||
from uuid import UUID
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
from core.utils import CACHE_SCAN_ITERSIZE
|
||||
|
||||
|
||||
class PresenceCache:
|
||||
"""Store and invalidate (room, identity) presence entries."""
|
||||
@@ -15,65 +29,24 @@ class PresenceCache:
|
||||
"""Cache key for a (room, identity) presence entry."""
|
||||
return f"{settings.PRESENCE_KEY_PREFIX}_{room_id!s}_{identity}"
|
||||
|
||||
@staticmethod
|
||||
def _get_index_key(room_id: UUID | str) -> str:
|
||||
"""Raw Redis key of the per-room identity index (a native SET).
|
||||
|
||||
Built through django-redis' make_key so it lives under the same
|
||||
KEY_PREFIX/version namespace as the presence entries.
|
||||
"""
|
||||
return cache.client.make_key(
|
||||
f"{settings.PRESENCE_KEY_PREFIX}-index_{room_id!s}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _redis(write: bool = True):
|
||||
"""Raw redis-py client.
|
||||
|
||||
SADD/SREM/SMEMBERS are not exposed by the Django cache API; this is
|
||||
the documented django-redis escape hatch.
|
||||
"""
|
||||
return cache.client.get_client(write=write)
|
||||
|
||||
def _index_members(self, room_id: UUID | str) -> FrozenSet[str]:
|
||||
"""All identities currently indexed for the room."""
|
||||
members = self._redis(write=False).smembers(self._get_index_key(room_id))
|
||||
return frozenset(
|
||||
member.decode() if isinstance(member, bytes) else member
|
||||
for member in members
|
||||
)
|
||||
|
||||
def is_marked_present(self, room_id: UUID | str, identity: str) -> bool:
|
||||
"""Return True if a positive presence entry exists in cache."""
|
||||
return bool(cache.get(self._get_cache_key(room_id, identity)))
|
||||
|
||||
def mark_present(self, room_id: UUID | str, identity: str) -> None:
|
||||
"""Record that `identity` is in `room_id` and index it for the room."""
|
||||
"""Record that `identity` is in `room_id`."""
|
||||
cache.set(
|
||||
self._get_cache_key(room_id, identity),
|
||||
True,
|
||||
timeout=settings.PRESENCE_CACHE_TIMEOUT,
|
||||
)
|
||||
index_key = self._get_index_key(room_id)
|
||||
pipe = self._redis().pipeline(transaction=False)
|
||||
pipe.sadd(index_key, identity)
|
||||
pipe.expire(index_key, settings.PRESENCE_CACHE_TIMEOUT)
|
||||
pipe.execute()
|
||||
|
||||
def clear(self, room_id: UUID | str, identity: str) -> None:
|
||||
"""Forget presence for one participant (e.g. on participant_left)."""
|
||||
cache.delete(self._get_cache_key(room_id, identity))
|
||||
self._redis().srem(self._get_index_key(room_id), identity)
|
||||
|
||||
def clear_room(self, room_id: UUID | str) -> None:
|
||||
"""Forget presence for every participant of a room (on room_finished).
|
||||
|
||||
Deletes the indexed entries and the index itself with targeted
|
||||
commands instead of a full-keyspace pattern scan.
|
||||
"""
|
||||
identities = self._index_members(room_id)
|
||||
if identities:
|
||||
cache.delete_many(
|
||||
[self._get_cache_key(room_id, identity) for identity in identities]
|
||||
)
|
||||
self._redis().delete(self._get_index_key(room_id))
|
||||
"""Forget presence for every participant of a room (on room_finished)."""
|
||||
cache.delete_pattern(
|
||||
self._get_cache_key(room_id, "*"), itersize=CACHE_SCAN_ITERSIZE
|
||||
)
|
||||
|
||||
@@ -30,10 +30,9 @@ class RoomNotFoundException(RoomManagementException):
|
||||
class RoomManagement:
|
||||
"""Service for managing LiveKit rooms."""
|
||||
|
||||
@classmethod
|
||||
@async_to_sync
|
||||
async def update_metadata(
|
||||
cls,
|
||||
self,
|
||||
room_name: str,
|
||||
metadata: Optional[Dict] = None,
|
||||
remove_keys: Optional[list[str]] = None,
|
||||
@@ -76,6 +75,10 @@ class RoomManagement:
|
||||
|
||||
except TwirpError as e:
|
||||
if e.code == "not_found":
|
||||
logger.warning(
|
||||
"Room %s not found in LiveKit, skipping metadata update",
|
||||
room_name,
|
||||
)
|
||||
raise RoomNotFoundException("Room does not exist") from e
|
||||
|
||||
logger.exception(
|
||||
@@ -87,9 +90,8 @@ class RoomManagement:
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
@classmethod
|
||||
@async_to_sync
|
||||
async def delete_room(cls, room_name: str):
|
||||
async def delete_room(self, room_name: str):
|
||||
"""Delete a LiveKit room and disconnect all participants.
|
||||
|
||||
Raises:
|
||||
@@ -114,32 +116,3 @@ class RoomManagement:
|
||||
raise RoomManagementException("Could not delete room") from e
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
@classmethod
|
||||
def sync_room_metadata(cls, room):
|
||||
"""Push a room's configuration and access level to its LiveKit room metadata.
|
||||
|
||||
Failures are swallowed: a room that is not live yet, or a LiveKit hiccup,
|
||||
should never fail the request that triggered the update.
|
||||
"""
|
||||
|
||||
metadata = {
|
||||
"configuration": room.configuration,
|
||||
"access_level": room.access_level,
|
||||
}
|
||||
|
||||
try:
|
||||
cls.update_metadata(
|
||||
room_name=str(room.id),
|
||||
metadata=metadata,
|
||||
)
|
||||
except RoomNotFoundException:
|
||||
logger.info(
|
||||
"LiveKit room %s does not exist yet, skipping metadata sync",
|
||||
room.id,
|
||||
)
|
||||
except RoomManagementException:
|
||||
logger.warning(
|
||||
"Failed to sync metadata to LiveKit for room %s",
|
||||
room.id,
|
||||
)
|
||||
|
||||
@@ -31,7 +31,7 @@ def delete_connection_test_room(room_name: str):
|
||||
return
|
||||
|
||||
try:
|
||||
RoomManagement.delete_room(room_name)
|
||||
RoomManagement().delete_room(room_name)
|
||||
except RoomNotFoundException:
|
||||
# Room may already be gone after empty/departure timeout.
|
||||
logger.info("Connection test room '%s' already gone.", room_name)
|
||||
|
||||
@@ -40,111 +40,6 @@ def test_authentication_getter_existing_user(monkeypatch):
|
||||
assert user == db_user
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sub",
|
||||
[
|
||||
# NUL (U+0000) passes str.isascii() but PostgreSQL text fields
|
||||
# cannot store or compare it (DataError)
|
||||
"auth0|abc\x00def",
|
||||
# lone surrogates cannot be encoded to UTF-8 for the DB lookup
|
||||
# (UnicodeEncodeError), which runs before any model validation
|
||||
"bad\ud800sub",
|
||||
# plainly invalid subs would otherwise escape as ValidationError
|
||||
# on user creation, which mozilla-django-oidc does not catch
|
||||
"\u00e9milie",
|
||||
"a" * 256,
|
||||
# ASCII control characters are rejected by policy
|
||||
"tab\tsub",
|
||||
"del\x7fsub",
|
||||
],
|
||||
)
|
||||
def test_authentication_getter_invalid_sub_rejected_cleanly(monkeypatch, sub):
|
||||
"""
|
||||
Subs that can never be persisted should be rejected with
|
||||
SuspiciousOperation (turned into a clean authentication failure by
|
||||
mozilla-django-oidc) instead of leaking DataError, UnicodeEncodeError
|
||||
or ValidationError as a server error.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": sub, "email": "john@example.com"}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match="User info contained an invalid sub claim",
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
assert models.User.objects.exists() is False
|
||||
|
||||
|
||||
def test_authentication_getter_numeric_sub(monkeypatch):
|
||||
"""
|
||||
Some providers serialize the sub as a JSON number. It should keep working
|
||||
(CharField coerces it to a string on save) and must not crash the early
|
||||
sub checks in get_existing_user.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": 12345, "email": "john@example.com"}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user.sub == "12345"
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
def test_authentication_getter_new_user_auth0_pipe_sub(monkeypatch):
|
||||
"""
|
||||
A first login with an Auth0-style sub containing a pipe ("provider|user-id")
|
||||
should create the user instead of raising a ValidationError.
|
||||
Regression test for https://github.com/suitenumerique/meet/issues/[XXX].
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "auth0|644c0bc8f1874ef6d339fb34", "email": "john@example.com"}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user.sub == "auth0|644c0bc8f1874ef6d339fb34"
|
||||
assert user.email == "john@example.com"
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_auth0_pipe_sub(monkeypatch):
|
||||
"""
|
||||
A returning user with an Auth0-style pipe sub should be matched by sub,
|
||||
not duplicated or rejected.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(sub="auth0|644c0bc8f1874ef6d339fb34")
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": db_user.sub}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user == db_user
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
def test_authentication_getter_new_user_no_email(monkeypatch):
|
||||
"""
|
||||
If no user matches, a user should be created.
|
||||
|
||||
@@ -27,7 +27,7 @@ def test_api_files_list_anonymous_not_allowed():
|
||||
|
||||
def test_api_files_list_authentificated_user_allowed():
|
||||
"""
|
||||
Authenticated users should be allowed to list files
|
||||
Authentificated users should be allowed to list files
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
|
||||
@@ -7,7 +7,6 @@ from urllib.parse import quote, urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.storage import default_storage
|
||||
from django.test import override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
@@ -144,59 +143,3 @@ def test_api_files_media_auth_own_file_deleted():
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI")
|
||||
def test_api_files_media_auth_custom_original_url_header():
|
||||
"""
|
||||
Authorization should honour the configured original-url header.
|
||||
|
||||
Covers the attachment subrequest path, which resolves the header separately
|
||||
from the recording one. Reverse proxies other than nginx-ingress use
|
||||
different headers: Traefik's ForwardAuth sends X-Forwarded-Uri and cannot
|
||||
emit X-Original-URL at all.
|
||||
"""
|
||||
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}"
|
||||
response = client.get(
|
||||
"/api/v1.0/files/media-auth/", HTTP_X_FORWARDED_URI=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "AWS4-HMAC-SHA256 Credential=" in response["Authorization"]
|
||||
|
||||
|
||||
@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI")
|
||||
def test_api_files_media_auth_default_header_ignored_when_reconfigured():
|
||||
"""
|
||||
Only the configured header should be honoured, never a hardcoded fallback.
|
||||
"""
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
@@ -2,23 +2,18 @@
|
||||
Test RecordingEventsService service.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,protected-access
|
||||
# pylint: disable=redefined-outer-name
|
||||
|
||||
import logging
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from core.factories import RecordingFactory
|
||||
from core.recording.enums import RecordingWorkerEvent
|
||||
from core.recording.services.recording_events import (
|
||||
RecordingEventsError,
|
||||
RecordingEventsService,
|
||||
RecordingNotSavableError,
|
||||
)
|
||||
from core.services.room_management import (
|
||||
RoomManagementException,
|
||||
)
|
||||
from core.utils import NotificationError
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -39,10 +34,10 @@ def service():
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_limit_reached_success(mock_notify, mode, notification_type, service):
|
||||
"""Test _handle_limit_reached stops recording and notifies participants."""
|
||||
"""Test handle_limit_reached stops recording and notifies participants."""
|
||||
|
||||
recording = RecordingFactory(status="active", mode=mode)
|
||||
service._handle_limit_reached(recording)
|
||||
service.handle_limit_reached(recording)
|
||||
|
||||
assert recording.status == "stopped"
|
||||
mock_notify.assert_called_once_with(
|
||||
@@ -53,69 +48,13 @@ def test_handle_limit_reached_success(mock_notify, mode, notification_type, serv
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "notification_type"),
|
||||
(
|
||||
("screen_recording", "screenRecordingFailed"),
|
||||
("transcript", "transcriptionFailed"),
|
||||
("screen_recording", "screenRecordingLimitReached"),
|
||||
("transcript", "transcriptionLimitReached"),
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_failed_success(mock_notify, mode, notification_type, service):
|
||||
"""Test _handle_failed marks recording as failed and notifies participants."""
|
||||
|
||||
recording = RecordingFactory(status="active", mode=mode)
|
||||
service._handle_failed(recording)
|
||||
|
||||
assert recording.status == "failed"
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "notification_type"),
|
||||
(
|
||||
("screen_recording", "screenRecordingAborted"),
|
||||
("transcript", "transcriptionAborted"),
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_aborted_success(mock_notify, mode, notification_type, service):
|
||||
"""Test _handle_aborted marks recording as aborted and notifies participants."""
|
||||
|
||||
recording = RecordingFactory(status="active", mode=mode)
|
||||
service._handle_aborted(recording)
|
||||
|
||||
assert recording.status == "aborted"
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "notification_prefix"),
|
||||
(("screen_recording", "screenRecording"), ("transcript", "transcription")),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("handler", "expected_status", "event", "notification_suffix"),
|
||||
(
|
||||
("_handle_limit_reached", "stopped", "limit reached", "LimitReached"),
|
||||
("_handle_failed", "failed", "failed", "Failed"),
|
||||
("_handle_aborted", "aborted", "aborted", "Aborted"),
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_event_notification_error( # noqa: PLR0913, PLR0917
|
||||
mock_notify,
|
||||
handler,
|
||||
expected_status,
|
||||
event,
|
||||
notification_suffix,
|
||||
mode,
|
||||
notification_prefix,
|
||||
service,
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
"""Test handlers raise RecordingEventsError when notifying participants fails,
|
||||
while still applying the recording status of their event.
|
||||
"""
|
||||
def test_handle_limit_reached_error(mock_notify, mode, notification_type, service):
|
||||
"""Test handle_limit_reached raises RecordingEventsError when notification fails."""
|
||||
|
||||
mock_notify.side_effect = NotificationError("Error notifying")
|
||||
|
||||
@@ -123,15 +62,14 @@ def test_handle_event_notification_error( # noqa: PLR0913, PLR0917
|
||||
|
||||
with pytest.raises(
|
||||
RecordingEventsError,
|
||||
match=rf"Failed to notify participants in room '.+' "
|
||||
rf"about recording {event} \(recording_id=.+\)",
|
||||
match=r"Failed to notify participants in room '.+' "
|
||||
r"about recording limit reached \(recording_id=.+\)",
|
||||
):
|
||||
getattr(service, handler)(recording)
|
||||
service.handle_limit_reached(recording)
|
||||
|
||||
assert recording.status == expected_status
|
||||
assert recording.status == "stopped"
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(recording.room.id),
|
||||
notification_data={"type": f"{notification_prefix}{notification_suffix}"},
|
||||
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||
)
|
||||
|
||||
|
||||
@@ -144,19 +82,19 @@ def test_handle_event_notification_error( # noqa: PLR0913, PLR0917
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
def test_handle_successful_saves_recording( # pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||
def test_handle_complete_saves_recording( # pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||
mock_notify_external_services,
|
||||
notify_return_value,
|
||||
expected_status,
|
||||
status,
|
||||
service,
|
||||
):
|
||||
"""Test _handle_successful notifies external services and saves a savable recording."""
|
||||
"""Test handle_complete notifies external services and saves a savable recording."""
|
||||
|
||||
mock_notify_external_services.return_value = notify_return_value
|
||||
|
||||
recording = RecordingFactory(status=status)
|
||||
service._handle_successful(recording)
|
||||
service.handle_complete(recording)
|
||||
|
||||
mock_notify_external_services.assert_called_once_with(recording)
|
||||
|
||||
@@ -166,293 +104,23 @@ def test_handle_successful_saves_recording( # pylint: disable=too-many-argument
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status",
|
||||
[
|
||||
"initiated",
|
||||
"saved",
|
||||
"notification_succeeded",
|
||||
"aborted",
|
||||
"failed",
|
||||
"failed_to_start",
|
||||
],
|
||||
["initiated", "saved", "notification_succeeded", "aborted", "failed_to_start"],
|
||||
)
|
||||
@mock.patch(
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
def test_handle_successful_non_savable_recording(
|
||||
def test_handle_complete_non_savable_recording(
|
||||
mock_notify_external_services, status, service
|
||||
):
|
||||
"""Test _handle_successful refuses recordings that are already saved or in error."""
|
||||
"""Test handle_complete refuses recordings that are already saved or in error."""
|
||||
|
||||
recording = RecordingFactory(status=status)
|
||||
|
||||
with pytest.raises(RecordingNotSavableError):
|
||||
service._handle_successful(recording)
|
||||
service.handle_complete(recording)
|
||||
|
||||
mock_notify_external_services.assert_not_called()
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == status
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("event", "recording_status"),
|
||||
(
|
||||
(RecordingWorkerEvent.STARTED, "started"),
|
||||
(RecordingWorkerEvent.SAVING, "saving"),
|
||||
),
|
||||
)
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_update_syncs_room_metadata(
|
||||
mock_update_metadata, event, recording_status, service
|
||||
):
|
||||
"""Test handle_update updates the room's metadata."""
|
||||
|
||||
recording = RecordingFactory(status="active")
|
||||
service.handle_update(recording, event)
|
||||
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
str(recording.room.id), {"recording_status": recording_status}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"event",
|
||||
(
|
||||
RecordingWorkerEvent.STARTING,
|
||||
RecordingWorkerEvent.COMPLETED,
|
||||
RecordingWorkerEvent.LIMIT_REACHED,
|
||||
RecordingWorkerEvent.ABORTED,
|
||||
RecordingWorkerEvent.FAILED,
|
||||
),
|
||||
)
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_update_ignores_events_without_a_metadata_status(
|
||||
mock_update_metadata, event, service
|
||||
):
|
||||
"""Test handle_update doesn't update metadata for events it doesn't match."""
|
||||
|
||||
recording = RecordingFactory(status="active")
|
||||
service.handle_update(recording, event)
|
||||
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("event", "initial_status", "expected_status", "notification_type"),
|
||||
(
|
||||
(RecordingWorkerEvent.LIMIT_REACHED, "active", "saved", "LimitReached"),
|
||||
(RecordingWorkerEvent.LIMIT_REACHED, "stopped", "saved", None),
|
||||
(RecordingWorkerEvent.LIMIT_REACHED, "saved", "saved", None),
|
||||
(RecordingWorkerEvent.ABORTED, "active", "aborted", "Aborted"),
|
||||
(RecordingWorkerEvent.ABORTED, "failed_to_stop", "failed_to_stop", None),
|
||||
(RecordingWorkerEvent.FAILED, "active", "failed", "Failed"),
|
||||
(RecordingWorkerEvent.FAILED, "stopped", "failed", "Failed"),
|
||||
(RecordingWorkerEvent.FAILED, "aborted", "aborted", None),
|
||||
(RecordingWorkerEvent.COMPLETED, "active", "saved", None),
|
||||
(RecordingWorkerEvent.COMPLETED, "saved", "saved", None),
|
||||
),
|
||||
)
|
||||
@mock.patch(
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_terminal_event_dispatches_on_event_and_status( # noqa: PLR0913, PLR0917
|
||||
mock_notify,
|
||||
mock_notify_external_services,
|
||||
event,
|
||||
initial_status,
|
||||
expected_status,
|
||||
notification_type,
|
||||
service,
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
"""Test handle_terminal_event chooses the right handler from the event and status."""
|
||||
|
||||
mock_notify_external_services.return_value = False
|
||||
|
||||
recording = RecordingFactory(status=initial_status, mode="screen_recording")
|
||||
service.handle_terminal_event(recording, event)
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == expected_status
|
||||
|
||||
if notification_type is None:
|
||||
mock_notify.assert_not_called()
|
||||
else:
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(recording.room.id),
|
||||
notification_data={"type": f"screenRecording{notification_type}"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"event",
|
||||
(
|
||||
RecordingWorkerEvent.STARTING,
|
||||
RecordingWorkerEvent.STARTED,
|
||||
RecordingWorkerEvent.SAVING,
|
||||
),
|
||||
)
|
||||
@mock.patch(
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_terminal_event_ignores_non_terminal_events(
|
||||
mock_notify, mock_notify_external_services, event, service, caplog
|
||||
):
|
||||
"""Test handle_terminal_event refuses non-terminal events."""
|
||||
|
||||
recording = RecordingFactory(status="active")
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
service.handle_terminal_event(recording, event)
|
||||
|
||||
assert f"Ignoring non-terminal event {event.value}" in caplog.text
|
||||
mock_notify.assert_not_called()
|
||||
mock_notify_external_services.assert_not_called()
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == "active"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("event", "expected_status"),
|
||||
(
|
||||
(RecordingWorkerEvent.LIMIT_REACHED, "saved"),
|
||||
(RecordingWorkerEvent.ABORTED, "aborted"),
|
||||
(RecordingWorkerEvent.FAILED, "failed"),
|
||||
),
|
||||
)
|
||||
@mock.patch(
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_terminal_event_survives_a_notification_failure( # noqa: PLR0913, PLR0917
|
||||
mock_notify,
|
||||
mock_notify_external_services,
|
||||
event,
|
||||
expected_status,
|
||||
service,
|
||||
caplog,
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
"""Test handle_terminal_event logs a notification failure instead of raising.
|
||||
|
||||
The recording status must still be persisted: participants missing their
|
||||
notification should not disturb recording.
|
||||
"""
|
||||
|
||||
mock_notify_external_services.return_value = False
|
||||
mock_notify.side_effect = NotificationError("Error notifying")
|
||||
|
||||
recording = RecordingFactory(status="active")
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
service.handle_terminal_event(recording, event)
|
||||
|
||||
assert f"Failed to notify participants that recording {recording.id}" in caplog.text
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == expected_status
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status",
|
||||
["failed_to_start", "aborted", "failed", "failed_to_stop", "saved", "initiated"],
|
||||
)
|
||||
@mock.patch(
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
def test_handle_terminal_event_ignores_a_non_savable_recording(
|
||||
mock_notify_external_services, status, service, caplog
|
||||
):
|
||||
"""Test handle_terminal_event handles a redelivered event idempotently.
|
||||
|
||||
A terminal event may be redelivered for an already finalized recording;
|
||||
this must not raise, otherwise the webhook would 500 and be retried.
|
||||
"""
|
||||
|
||||
recording = RecordingFactory(status=status)
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
service.handle_terminal_event(recording, RecordingWorkerEvent.COMPLETED)
|
||||
|
||||
assert f"Recording {recording.id} is not savable" in caplog.text
|
||||
mock_notify_external_services.assert_not_called()
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == status
|
||||
|
||||
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_update_survives_a_metadata_failure(
|
||||
mock_update_metadata, service, caplog
|
||||
):
|
||||
"""Test handle_update logs a metadata failure instead of raising."""
|
||||
|
||||
mock_update_metadata.side_effect = RoomManagementException("Error updating")
|
||||
|
||||
recording = RecordingFactory(status="active")
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
service.handle_update(recording, RecordingWorkerEvent.SAVING)
|
||||
|
||||
assert "Failed to update room's metadata" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("event", "expected_level"),
|
||||
(
|
||||
(RecordingWorkerEvent.ABORTED, logging.INFO),
|
||||
(RecordingWorkerEvent.FAILED, logging.ERROR),
|
||||
),
|
||||
)
|
||||
def test_log_worker_error_reports_an_unsuccessful_event(
|
||||
event, expected_level, service, caplog
|
||||
):
|
||||
"""Test log_worker_error records the reason the recording did not succeed."""
|
||||
|
||||
recording = RecordingFactory(status="active", mode="screen_recording")
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
service.log_worker_error(
|
||||
recording, event, error="could not connect to the room", error_code=500
|
||||
)
|
||||
|
||||
assert (
|
||||
f"Recording worker reported {event.value} for recording {recording.id}"
|
||||
in caplog.text
|
||||
)
|
||||
assert "could not connect to the room" in caplog.text
|
||||
assert "error_code=500" in caplog.text
|
||||
worker_logs = [
|
||||
record
|
||||
for record in caplog.records
|
||||
if record.name == "core.recording.services.recording_events"
|
||||
]
|
||||
assert [record.levelno for record in worker_logs] == [expected_level]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"event",
|
||||
(
|
||||
RecordingWorkerEvent.STARTING,
|
||||
RecordingWorkerEvent.STARTED,
|
||||
RecordingWorkerEvent.SAVING,
|
||||
RecordingWorkerEvent.COMPLETED,
|
||||
RecordingWorkerEvent.LIMIT_REACHED,
|
||||
None,
|
||||
),
|
||||
)
|
||||
def test_log_worker_error_stays_quiet_on_anything_else(event, service, caplog):
|
||||
"""Test log_worker_error ignores events other than FAILED and ABORTED."""
|
||||
|
||||
recording = RecordingFactory(status="active")
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
service.log_worker_error(recording, event, error="some error", error_code=500)
|
||||
|
||||
assert "Recording worker reported" not in caplog.text
|
||||
|
||||
@@ -8,7 +8,6 @@ from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.storage import default_storage
|
||||
from django.test import override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
@@ -283,63 +282,3 @@ def test_api_recordings_media_auth_success_administrator(mode):
|
||||
timeout=1,
|
||||
)
|
||||
assert response.content.decode("utf-8") == "my prose"
|
||||
|
||||
|
||||
def test_api_recordings_media_auth_missing_header():
|
||||
"""
|
||||
Test that a subrequest without the configured original-url header is rejected.
|
||||
"""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/recordings/media-auth/")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI")
|
||||
def test_api_recordings_media_auth_custom_original_url_header():
|
||||
"""
|
||||
Test that the header carrying the original URL can be configured.
|
||||
|
||||
Reverse proxies other than nginx-ingress use different headers: Traefik's
|
||||
ForwardAuth sends X-Forwarded-Uri and cannot emit X-Original-URL at all.
|
||||
"""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
original_url = f"http://localhost/media/recordings/{uuid4()!s}.mp4"
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/media-auth/", HTTP_X_FORWARDED_URI=original_url
|
||||
)
|
||||
|
||||
# The header was read and parsed: we get as far as looking the recording up,
|
||||
# rather than being rejected for a missing header.
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
@override_settings(MEDIA_AUTH_ORIGINAL_URL_HEADER="HTTP_X_FORWARDED_URI")
|
||||
def test_api_recordings_media_auth_default_header_ignored_when_reconfigured():
|
||||
"""
|
||||
Test that only the configured header is honoured.
|
||||
|
||||
Guards against the header being read from a hardcoded name in parallel with
|
||||
the setting.
|
||||
"""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
original_url = f"http://localhost/media/recordings/{uuid4()!s}.mp4"
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
@@ -224,7 +224,6 @@ def test_api_recording_retrieve_expired(settings):
|
||||
RecordingStatusChoices.INITIATED,
|
||||
RecordingStatusChoices.ACTIVE,
|
||||
RecordingStatusChoices.SAVED,
|
||||
RecordingStatusChoices.FAILED,
|
||||
RecordingStatusChoices.FAILED_TO_START,
|
||||
RecordingStatusChoices.FAILED_TO_STOP,
|
||||
RecordingStatusChoices.ABORTED,
|
||||
|
||||
@@ -4,7 +4,6 @@ Test worker service classes.
|
||||
|
||||
# pylint: disable=protected-access,redefined-outer-name,unused-argument,no-member
|
||||
|
||||
import logging
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
@@ -155,9 +154,9 @@ def test_base_egress_filepath_construction(service, filename, extension, expecte
|
||||
"response_status,expected_result",
|
||||
[
|
||||
(livekit_api.EgressStatus.EGRESS_ABORTED, "ABORTED"),
|
||||
(livekit_api.EgressStatus.EGRESS_FAILED, "FAILED"),
|
||||
(livekit_api.EgressStatus.EGRESS_COMPLETE, "FAILED_TO_STOP"),
|
||||
(livekit_api.EgressStatus.EGRESS_ENDING, "STOPPED"),
|
||||
(livekit_api.EgressStatus.EGRESS_FAILED, "FAILED_TO_STOP"),
|
||||
],
|
||||
)
|
||||
def test_base_egress_stop_with_status(service, response_status, expected_result):
|
||||
@@ -176,32 +175,6 @@ def test_base_egress_stop_with_status(service, response_status, expected_result)
|
||||
assert result == expected_result
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"response_status,event",
|
||||
[
|
||||
(livekit_api.EgressStatus.EGRESS_ABORTED, "aborted"),
|
||||
(livekit_api.EgressStatus.EGRESS_FAILED, "failed"),
|
||||
(livekit_api.EgressStatus.EGRESS_COMPLETE, "failed to stop"),
|
||||
],
|
||||
)
|
||||
def test_base_egress_stop_logs_livekit_error(service, response_status, event, caplog):
|
||||
"""Should log the reason LiveKit reported for an unsuccessful stop."""
|
||||
mock_response = Mock(
|
||||
status=response_status,
|
||||
egress_id="test_worker_id",
|
||||
error="could not connect to the room",
|
||||
error_code=500,
|
||||
)
|
||||
service._handle_request = Mock(return_value=mock_response)
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
service.stop("test_worker_id")
|
||||
|
||||
assert f"Egress {event} on stop (egress_id=test_worker_id" in caplog.text
|
||||
assert "could not connect to the room" in caplog.text
|
||||
assert "error_code=500" in caplog.text
|
||||
|
||||
|
||||
def test_base_egress_stop_missing_status(service):
|
||||
"""Test stop method when response is missing status"""
|
||||
# Mock _handle_request with missing status
|
||||
|
||||
@@ -9,7 +9,6 @@ from unittest import mock
|
||||
from django.core.cache import cache
|
||||
|
||||
import pytest
|
||||
from freezegun import freeze_time
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ... import utils
|
||||
@@ -25,7 +24,6 @@ pytestmark = pytest.mark.django_db
|
||||
# Tests for request_entry endpoint
|
||||
|
||||
|
||||
@freeze_time("2025-01-01 10:00:00")
|
||||
def test_request_entry_anonymous(settings):
|
||||
"""Anonymous users should be allowed to request entry to a room."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
@@ -61,7 +59,6 @@ def test_request_entry_anonymous(settings):
|
||||
"username": "test_user",
|
||||
"status": "waiting",
|
||||
"color": "mocked-color",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
"livekit": None,
|
||||
}
|
||||
|
||||
@@ -74,7 +71,6 @@ def test_request_entry_anonymous(settings):
|
||||
assert participant_data.get("username") == "test_user"
|
||||
|
||||
|
||||
@freeze_time("2025-01-01 10:00:00")
|
||||
def test_request_entry_authenticated_user(settings):
|
||||
"""Authenticated users should be allowed to request entry."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
@@ -112,7 +108,6 @@ def test_request_entry_authenticated_user(settings):
|
||||
"username": "test_user",
|
||||
"status": "waiting",
|
||||
"color": "mocked-color",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
"livekit": None,
|
||||
}
|
||||
|
||||
@@ -125,7 +120,6 @@ def test_request_entry_authenticated_user(settings):
|
||||
assert participant_data.get("username") == "test_user"
|
||||
|
||||
|
||||
@freeze_time("2025-01-01 10:00:00")
|
||||
def test_request_entry_with_existing_participants(settings):
|
||||
"""Anonymous users should be allowed to request entry to a room with existing participants."""
|
||||
# Create a restricted access room
|
||||
@@ -144,7 +138,6 @@ def test_request_entry_with_existing_participants(settings):
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
},
|
||||
)
|
||||
cache.set(
|
||||
@@ -154,7 +147,6 @@ def test_request_entry_with_existing_participants(settings):
|
||||
"username": "user2",
|
||||
"status": "accepted",
|
||||
"color": "#654321",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -186,7 +178,6 @@ def test_request_entry_with_existing_participants(settings):
|
||||
assert response.json() == {
|
||||
"id": participant_id,
|
||||
"username": "test_user",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
"status": "waiting",
|
||||
"color": "mocked-color",
|
||||
"livekit": None,
|
||||
@@ -201,7 +192,6 @@ def test_request_entry_with_existing_participants(settings):
|
||||
assert participant_data.get("username") == "test_user"
|
||||
|
||||
|
||||
@freeze_time("2025-01-01 10:00:00")
|
||||
def test_request_entry_public_room(settings):
|
||||
"""Entry requests to public rooms should return ACCEPTED status with LiveKit config."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
@@ -240,7 +230,6 @@ def test_request_entry_public_room(settings):
|
||||
assert response.json() == {
|
||||
"id": "123",
|
||||
"username": "test_user",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
"status": "accepted",
|
||||
"color": "mocked-color",
|
||||
"livekit": {"token": "test-token"},
|
||||
@@ -251,7 +240,6 @@ def test_request_entry_public_room(settings):
|
||||
assert not lobby_keys
|
||||
|
||||
|
||||
@freeze_time("2025-01-01 10:00:00")
|
||||
def test_request_entry_authenticated_user_public_room(settings):
|
||||
"""While authenticated, entry request to public rooms should get accepted."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
@@ -294,7 +282,6 @@ def test_request_entry_authenticated_user_public_room(settings):
|
||||
assert response.json() == {
|
||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
"username": "test_user",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
"status": "accepted",
|
||||
"color": "mocked-color",
|
||||
"livekit": {"token": "test-token"},
|
||||
@@ -305,7 +292,6 @@ def test_request_entry_authenticated_user_public_room(settings):
|
||||
assert not lobby_keys
|
||||
|
||||
|
||||
@freeze_time("2025-01-01 10:00:00")
|
||||
def test_request_entry_waiting_participant_public_room(settings):
|
||||
"""While waiting, entry request to public rooms should get accepted."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
@@ -322,7 +308,6 @@ def test_request_entry_waiting_participant_public_room(settings):
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -353,7 +338,6 @@ def test_request_entry_waiting_participant_public_room(settings):
|
||||
"username": "user1",
|
||||
"status": "accepted",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
"livekit": {"token": "test-token"},
|
||||
}
|
||||
|
||||
@@ -459,7 +443,6 @@ def test_allow_participant_to_enter_success(settings, allow_entry, updated_statu
|
||||
"status": "waiting",
|
||||
"username": "foo",
|
||||
"color": "123",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -595,7 +578,6 @@ def test_list_waiting_participants_success(settings):
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
},
|
||||
)
|
||||
cache.set(
|
||||
@@ -605,35 +587,28 @@ def test_list_waiting_participants_success(settings):
|
||||
"username": "user2",
|
||||
"status": "waiting",
|
||||
"color": "#654321",
|
||||
"entered_at": "2025-01-01T10:05:00+00:00",
|
||||
},
|
||||
)
|
||||
lobby_service = LobbyService()
|
||||
lobby_service._index_add(room.id, "2f7f162f-e7d1-421b-90e7-02bfbfbf8def")
|
||||
lobby_service._index_add(room.id, "f4ca3ab8a6c04ad88097b8da33f60f10")
|
||||
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
assert response.json() == {
|
||||
"participants": [
|
||||
{
|
||||
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
|
||||
"username": "user2",
|
||||
"status": "waiting",
|
||||
"color": "#654321",
|
||||
"entered_at": "2025-01-01T10:05:00+00:00",
|
||||
},
|
||||
{
|
||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
},
|
||||
]
|
||||
}
|
||||
participants = response.json().get("participants")
|
||||
assert sorted(participants, key=lambda p: p["id"]) == [
|
||||
{
|
||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
},
|
||||
{
|
||||
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
|
||||
"username": "user2",
|
||||
"status": "waiting",
|
||||
"color": "#654321",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_list_waiting_participants_empty(settings):
|
||||
|
||||
@@ -458,7 +458,7 @@ def test_mute_participant_livekit_token_presence_check_twirp_error_forbidden(
|
||||
room = RoomFactory()
|
||||
|
||||
mock_livekit_client.room.get_participant.side_effect = TwirpError(
|
||||
msg="an error occurred", code="not_found", status=500
|
||||
msg="an error occured", code="not_found", status=500
|
||||
)
|
||||
|
||||
user = AnonymousUser()
|
||||
|
||||
@@ -372,67 +372,6 @@ def test_rename_participant_unexpected_twirp_error(mock_livekit_client, room, to
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name", ["John Doe", "Admin", "Room Owner"])
|
||||
def test_rename_participant_forbidden_when_display_name_edit_disabled(
|
||||
mock_livekit_client, settings, room, token, name
|
||||
):
|
||||
"""
|
||||
Test rename is rejected for authenticated users when the self-hoster
|
||||
disables AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME.
|
||||
"""
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url, {"name": name}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
assert response.data == {
|
||||
"error": "Authenticated participants cannot edit their display name"
|
||||
}
|
||||
mock_livekit_client.room.update_participant.assert_not_called()
|
||||
|
||||
|
||||
def test_rename_participant_allowed_when_display_name_edit_enabled(
|
||||
mock_livekit_client, settings, room, token
|
||||
):
|
||||
"""Test rename still works for authenticated users when the setting is enabled."""
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = True
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url, {"name": "John Doe"}, format="json", HTTP_AUTHORIZATION=f"Bearer {token}"
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
mock_livekit_client.room.update_participant.assert_called_once()
|
||||
|
||||
|
||||
def test_rename_participant_anonymous_allowed_when_display_name_edit_disabled(
|
||||
mock_livekit_client, settings, room, anonymous_token
|
||||
):
|
||||
"""
|
||||
Test the setting only restricts authenticated users: anonymous participants
|
||||
have no account name to fall back on and can still rename themselves.
|
||||
"""
|
||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME = False
|
||||
|
||||
client = APIClient()
|
||||
url = reverse("rooms-rename", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
url,
|
||||
{"name": "Guest User"},
|
||||
format="json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {anonymous_token}",
|
||||
)
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
mock_livekit_client.room.update_participant.assert_called_once()
|
||||
|
||||
|
||||
def test_rename_participant_success_anonymous(
|
||||
mock_livekit_client, room, anonymous_token
|
||||
):
|
||||
|
||||
@@ -117,11 +117,9 @@ def test_start_subtitle_invalid_token():
|
||||
assert response.json() == {"detail": "Invalid LiveKit token: Not enough segments"}
|
||||
|
||||
|
||||
def test_start_subtitle_disabled_by_default(mock_livekit_token, settings):
|
||||
def test_start_subtitle_disabled_by_default(mock_livekit_token):
|
||||
"""Test that subtitle functionality is disabled when feature flag is off."""
|
||||
|
||||
settings.ROOM_SUBTITLE_ENABLED = False
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
|
||||
@@ -381,11 +381,38 @@ def test_api_rooms_update_administrators_of_another():
|
||||
assert other_room.slug == "old-name"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("exception", [RoomNotFoundException, RoomManagementException])
|
||||
@patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_livekit_sync_failure(mock_update_metadata, exception):
|
||||
"""Should not fail the API request when the LiveKit metadata sync fails."""
|
||||
mock_update_metadata.side_effect = exception
|
||||
@patch.object(RoomManagement, "update_metadata", side_effect=RoomNotFoundException)
|
||||
def test_api_rooms_update_livekit_room_not_found(mock_update_metadata):
|
||||
"""Should not fail the API request when the LiveKit room does not exist yet."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
users=[(user, random.choice(["administrator", "owner"]))],
|
||||
configuration={},
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
{"configuration": {"can_publish_sources": ["camera"]}},
|
||||
format="json",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
room.refresh_from_db()
|
||||
assert room.configuration == {"can_publish_sources": ["camera"]}
|
||||
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
room_name=str(room.id),
|
||||
metadata={
|
||||
"access_level": room.access_level,
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@patch.object(RoomManagement, "update_metadata", side_effect=RoomManagementException)
|
||||
def test_api_rooms_update_livekit_sync_failure(mock_update_metadata):
|
||||
"""Should not fail the API request when the LiveKit metadata sync fails."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
users=[(user, random.choice(["administrator", "owner"]))],
|
||||
|
||||
@@ -94,7 +94,7 @@ def test_invalid_payload(client, auth_token, mock_livekit_config):
|
||||
|
||||
|
||||
def test_unknown_event_type(client, mock_livekit_config):
|
||||
"""Should acknowledge (200) an unknown event type rather than reject it."""
|
||||
"""Should return 422 for unknown event type."""
|
||||
event_data = json.dumps({"event": "unknown_event_type"})
|
||||
|
||||
# Generate auth token for this specific payload
|
||||
@@ -112,8 +112,10 @@ def test_unknown_event_type(client, mock_livekit_config):
|
||||
HTTP_AUTHORIZATION=auth_token,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "success"}
|
||||
assert response.status_code == 422
|
||||
assert response.json() == {
|
||||
"status": "error",
|
||||
}
|
||||
|
||||
|
||||
@mock.patch.object(LiveKitEventsService, "_handle_room_finished")
|
||||
|
||||
@@ -3,7 +3,6 @@ Test LiveKitEvents service.
|
||||
"""
|
||||
# pylint: disable=W0621,W0613, W0212, E0611
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
@@ -11,16 +10,14 @@ import pytest
|
||||
from livekit.api import EgressStatus
|
||||
|
||||
from core.factories import RecordingFactory, RoomFactory
|
||||
from core.recording.enums import RecordingWorkerEvent
|
||||
from core.recording.services.recording_events import RecordingEventsService
|
||||
from core.services.livekit_events import (
|
||||
EGRESS_STATUS_TO_RECORDING_EVENT,
|
||||
ActionFailedError,
|
||||
AuthenticationError,
|
||||
InvalidPayloadError,
|
||||
LiveKitEventsService,
|
||||
UnsupportedEventTypeError,
|
||||
api,
|
||||
to_recording_event,
|
||||
)
|
||||
from core.services.lobby import LobbyService
|
||||
from core.services.room_management import RoomManagementException
|
||||
@@ -81,7 +78,7 @@ def test_initialization(
|
||||
def test_handle_egress_ended_success( # pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||
mock_update_metadata, mock_notify, mode, notification_type, service
|
||||
):
|
||||
"""Should successfully stop recording and notify all participant."""
|
||||
"""Should successfully stop recording and notifies all participant."""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
||||
mock_data = mock.MagicMock()
|
||||
@@ -108,6 +105,7 @@ def test_handle_egress_ended_success( # pylint: disable=too-many-arguments, too
|
||||
(
|
||||
(EgressStatus.EGRESS_ACTIVE, "started"),
|
||||
(EgressStatus.EGRESS_ENDING, "saving"),
|
||||
(EgressStatus.EGRESS_ABORTED, "aborted"),
|
||||
),
|
||||
)
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
@@ -128,6 +126,29 @@ def test_handle_egress_updated_success(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"egress_status",
|
||||
(
|
||||
EgressStatus.EGRESS_FAILED,
|
||||
EgressStatus.EGRESS_LIMIT_REACHED,
|
||||
),
|
||||
)
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_updated_non_handled(
|
||||
mock_update_metadata, egress_status, service
|
||||
):
|
||||
"""Should ignore certain egress status and don't trigger metadata updates."""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="initiated")
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = egress_status
|
||||
|
||||
service._handle_egress_updated(mock_data)
|
||||
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "notification_type"),
|
||||
(
|
||||
@@ -160,38 +181,33 @@ def test_handle_egress_ended_metadata_update_fails( # pylint: disable=too-many-
|
||||
assert recording.status == "saved"
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_notification_fails(
|
||||
mock_update_metadata, mock_notify, mock_notify_external_services, service
|
||||
mock_update_metadata, mock_notify, service
|
||||
):
|
||||
"""Should still stop and save the recording when notifying participants fails."""
|
||||
|
||||
mock_notify_external_services.return_value = False
|
||||
mock_notify.side_effect = NotificationError("Error notifying")
|
||||
"""Should raise ActionFailedError when notification fails but still stop recording."""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
|
||||
|
||||
service._handle_egress_ended(mock_data)
|
||||
mock_notify.side_effect = NotificationError("Error notifying")
|
||||
|
||||
with pytest.raises(
|
||||
ActionFailedError,
|
||||
match=r"Failed to process limit reached event for recording .+",
|
||||
):
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == "stopped"
|
||||
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(recording.room.id),
|
||||
notification_data={"type": "screenRecordingLimitReached"},
|
||||
)
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||
)
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == "saved"
|
||||
|
||||
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
@@ -217,25 +233,17 @@ def test_handle_egress_ended_recording_not_found(
|
||||
assert recording.status == "active"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"egress_status",
|
||||
(
|
||||
EgressStatus.EGRESS_FAILED,
|
||||
EgressStatus.EGRESS_ABORTED,
|
||||
EgressStatus.EGRESS_LIMIT_REACHED,
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_recording_should_not_be_saved(
|
||||
mock_update_metadata, mock_notify, egress_status, service
|
||||
def test_handle_egress_ended_recording_not_active(
|
||||
mock_update_metadata, mock_notify, service
|
||||
):
|
||||
"""Don't update status for recordings that must not be saved."""
|
||||
"""Should ignore non-active recordings."""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="failed_to_stop")
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = "worker-1"
|
||||
mock_data.egress_info.status = egress_status
|
||||
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
|
||||
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
@@ -248,24 +256,12 @@ def test_handle_egress_ended_recording_should_not_be_saved(
|
||||
assert recording.status == "failed_to_stop"
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_complete_does_not_notify_participants(
|
||||
mock_update_metadata, mock_notify, mock_notify_external_services, service
|
||||
def test_handle_egress_ended_recording_not_limit_reached(
|
||||
mock_update_metadata, mock_notify, service
|
||||
):
|
||||
"""Shouldn't notify participants on a successful egress.
|
||||
|
||||
EGRESS_COMPLETE is the only ended status that notifies no one: limit
|
||||
reached, aborted and failed egresses each send their own notification.
|
||||
A stopped recording is simply finalized, which is the nominal flow once
|
||||
the egress uploaded the file of a user-initiated stop.
|
||||
"""
|
||||
|
||||
mock_notify_external_services.return_value = False
|
||||
"""Should ignore egress non-limit-reached statuses."""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="stopped")
|
||||
mock_data = mock.MagicMock()
|
||||
@@ -278,10 +274,7 @@ def test_handle_egress_ended_complete_does_not_notify_participants(
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||
)
|
||||
mock_notify_external_services.assert_called_once_with(recording)
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == "saved"
|
||||
assert recording.status == "stopped"
|
||||
|
||||
|
||||
@mock.patch("core.services.livekit_events.MetadataCollectorService")
|
||||
@@ -341,163 +334,96 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
|
||||
mock_collector.stop.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("egress_status", "recording_status", "event", "expected_level"),
|
||||
(
|
||||
(EgressStatus.EGRESS_ABORTED, "active", "aborted", logging.INFO),
|
||||
(EgressStatus.EGRESS_FAILED, "active", "failed", logging.ERROR),
|
||||
# The synchronous stop may already have persisted the terminal status,
|
||||
# and the error details exist only in the webhook payload.
|
||||
(EgressStatus.EGRESS_ABORTED, "aborted", "aborted", logging.INFO),
|
||||
(EgressStatus.EGRESS_FAILED, "failed", "failed", logging.ERROR),
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_logs_livekit_error( # noqa: PLR0913, PLR0917
|
||||
mock_update_metadata,
|
||||
mock_notify,
|
||||
egress_status,
|
||||
recording_status,
|
||||
event,
|
||||
expected_level,
|
||||
service,
|
||||
caplog,
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
"""Should log the reason LiveKit reported an unsuccessful egress."""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status=recording_status)
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = egress_status
|
||||
mock_data.egress_info.error = "could not connect to the room"
|
||||
mock_data.egress_info.error_code = 500
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
assert (
|
||||
f"Recording worker reported {event} for recording {recording.id}" in caplog.text
|
||||
)
|
||||
assert "could not connect to the room" in caplog.text
|
||||
assert "error_code=500" in caplog.text
|
||||
|
||||
worker_logs = [
|
||||
record
|
||||
for record in caplog.records
|
||||
if record.name == "core.recording.services.recording_events"
|
||||
]
|
||||
assert [record.levelno for record in worker_logs] == [expected_level]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"egress_status",
|
||||
(EgressStatus.EGRESS_COMPLETE, EgressStatus.EGRESS_LIMIT_REACHED),
|
||||
)
|
||||
@mock.patch(
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_does_not_log_error_on_successful_egress( # noqa: PLR0913, PLR0917
|
||||
@pytest.mark.parametrize(
|
||||
"egress_status",
|
||||
[EgressStatus.EGRESS_COMPLETE, EgressStatus.EGRESS_LIMIT_REACHED],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"notify_return_value, recording_status",
|
||||
[(True, "notification_succeeded"), (False, "saved")],
|
||||
)
|
||||
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913, PLR0917
|
||||
mock_update_metadata,
|
||||
mock_notify,
|
||||
mock_notify_external_services,
|
||||
notify_return_value,
|
||||
recording_status,
|
||||
egress_status,
|
||||
service,
|
||||
caplog,
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
"""Shouldn't log an egress error when LiveKit reports a successful egress."""
|
||||
"""Should notify external services and save the recording on egress completion
|
||||
(EGRESS_COMPLETE or EGRESS_LIMIT_REACHED).
|
||||
"""
|
||||
mock_notify_external_services.return_value = notify_return_value
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = egress_status
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
service._handle_egress_ended(mock_data)
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
assert "Recording worker reported" not in caplog.text
|
||||
mock_notify_external_services.assert_called_once_with(recording)
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == recording_status
|
||||
|
||||
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@pytest.mark.parametrize(
|
||||
"egress_status",
|
||||
[
|
||||
EgressStatus.EGRESS_STARTING,
|
||||
EgressStatus.EGRESS_ACTIVE,
|
||||
EgressStatus.EGRESS_ENDING,
|
||||
EgressStatus.EGRESS_FAILED,
|
||||
EgressStatus.EGRESS_ABORTED,
|
||||
],
|
||||
)
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_logs_livekit_error_before_cleaning_up(
|
||||
mock_update_metadata, mock_notify, service, caplog
|
||||
def test_handle_egress_ended_does_not_save_on_wrong_status(
|
||||
mock_update_metadata, egress_status, service
|
||||
):
|
||||
"""Should log the failure reason even when the cleanup fails afterwards."""
|
||||
|
||||
mock_update_metadata.side_effect = RuntimeError("LiveKit is unreachable")
|
||||
"""Shouldn't save on invalid status."""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = EgressStatus.EGRESS_FAILED
|
||||
mock_data.egress_info.error = "could not connect to the room"
|
||||
mock_data.egress_info.error_code = 500
|
||||
mock_data.egress_info.status = egress_status
|
||||
|
||||
with caplog.at_level(logging.ERROR), pytest.raises(RuntimeError):
|
||||
service._handle_egress_ended(mock_data)
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
assert (
|
||||
f"Recording worker reported failed for recording {recording.id}" in caplog.text
|
||||
)
|
||||
assert "could not connect to the room" in caplog.text
|
||||
|
||||
|
||||
def test_egress_status_mapping_covers_every_livekit_status():
|
||||
"""Every egress status LiveKit can report must translate to a recording event."""
|
||||
|
||||
unmapped = [
|
||||
name
|
||||
for name in EgressStatus.keys()
|
||||
if getattr(EgressStatus, name) not in EGRESS_STATUS_TO_RECORDING_EVENT
|
||||
]
|
||||
|
||||
assert not unmapped
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == "active"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("egress_status", "expected_event"),
|
||||
(
|
||||
(EgressStatus.EGRESS_STARTING, RecordingWorkerEvent.STARTING),
|
||||
(EgressStatus.EGRESS_ACTIVE, RecordingWorkerEvent.STARTED),
|
||||
(EgressStatus.EGRESS_ENDING, RecordingWorkerEvent.SAVING),
|
||||
(EgressStatus.EGRESS_COMPLETE, RecordingWorkerEvent.COMPLETED),
|
||||
(EgressStatus.EGRESS_LIMIT_REACHED, RecordingWorkerEvent.LIMIT_REACHED),
|
||||
(EgressStatus.EGRESS_ABORTED, RecordingWorkerEvent.ABORTED),
|
||||
(EgressStatus.EGRESS_FAILED, RecordingWorkerEvent.FAILED),
|
||||
),
|
||||
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
|
||||
)
|
||||
def test_to_recording_event_translates_egress_status(egress_status, expected_event):
|
||||
"""Should translate a LiveKit egress status into a recording worker event."""
|
||||
|
||||
assert to_recording_event(egress_status) == expected_event
|
||||
|
||||
|
||||
def test_to_recording_event_returns_none_on_unmapped_status(caplog):
|
||||
"""Should warn and return None when LiveKit reports an unknown status."""
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
event = to_recording_event(999)
|
||||
|
||||
assert event is None
|
||||
assert "Unmapped LiveKit egress status" in caplog.text
|
||||
|
||||
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_updated_ignores_unmapped_status(mock_update_metadata, service):
|
||||
"""Shouldn't touch the room's metadata when the egress status is unknown."""
|
||||
def test_handle_egress_ended_ignores_non_savable_recording(
|
||||
mock_update_metadata, status, service
|
||||
):
|
||||
"""Should handle non-savable recordings idempotently without raising.
|
||||
|
||||
RecordingFactory(worker_id="worker-1", status="active")
|
||||
'egress_ended' may be redelivered (e.g. for an already-saved recording);
|
||||
this must not raise, otherwise the webhook would 500 and LiveKit would retry.
|
||||
"""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status=status)
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = "worker-1"
|
||||
mock_data.egress_info.status = 999
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = EgressStatus.EGRESS_COMPLETE
|
||||
|
||||
service._handle_egress_updated(mock_data)
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_update_metadata.assert_not_called()
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == status
|
||||
|
||||
|
||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||
@@ -739,27 +665,22 @@ def test_receive_missing_auth(service):
|
||||
|
||||
|
||||
@mock.patch.object(api.WebhookReceiver, "receive")
|
||||
def test_receive_unknown_event_is_acknowledged(mock_receive, service, caplog):
|
||||
"""Unknown event types are logged and ignored, not rejected.
|
||||
|
||||
LiveKit adds event types over time and does not retry 4xx responses, so
|
||||
raising here would silently drop the event.
|
||||
"""
|
||||
def test_receive_unsupported_event(mock_receive, service):
|
||||
"""Should raise LiveKitWebhookError for unsupported events."""
|
||||
mock_request = mock.MagicMock()
|
||||
mock_request.headers = {"Authorization": "test_token"}
|
||||
mock_request.body = b"{}"
|
||||
|
||||
# Mock returned data with unsupported event type
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(uuid.uuid4())
|
||||
mock_data.event = "some_future_event"
|
||||
mock_data.event = "unsupported_event"
|
||||
mock_receive.return_value = mock_data
|
||||
|
||||
with caplog.at_level("WARNING", logger="core.services.livekit_events"):
|
||||
service.receive(mock_request) # must not raise
|
||||
|
||||
assert "Ignoring unknown LiveKit webhook event type 'some_future_event'" in (
|
||||
caplog.text
|
||||
)
|
||||
with pytest.raises(
|
||||
UnsupportedEventTypeError, match="Unknown webhook type: unsupported_event"
|
||||
):
|
||||
service.receive(mock_request)
|
||||
|
||||
|
||||
@mock.patch.object(api.WebhookReceiver, "receive")
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Test lobby service.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621,W0613, W0212, R0913, C0302
|
||||
# pylint: disable=W0621,W0613, W0212, R0913
|
||||
# ruff: noqa: PLR0913, PLR0917
|
||||
|
||||
import uuid
|
||||
@@ -14,7 +14,6 @@ from django.core.cache import cache
|
||||
from django.http import HttpResponse
|
||||
|
||||
import pytest
|
||||
from freezegun import freeze_time
|
||||
|
||||
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||
from core.models import RoleChoices, RoomAccessLevel
|
||||
@@ -25,6 +24,7 @@ from core.services.lobby import (
|
||||
LobbyParticipantStatus,
|
||||
LobbyService,
|
||||
)
|
||||
from core.services.presence import CACHE_SCAN_ITERSIZE
|
||||
from core.utils import NotificationError
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -56,7 +56,6 @@ def participant_dict():
|
||||
"username": "test-username",
|
||||
"id": "test-participant-id",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
}
|
||||
|
||||
|
||||
@@ -68,7 +67,6 @@ def participant_data():
|
||||
username="test-username",
|
||||
id="test-participant-id",
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
)
|
||||
|
||||
|
||||
@@ -80,7 +78,6 @@ def test_lobby_participant_to_dict(participant_data):
|
||||
assert result["username"] == "test-username"
|
||||
assert result["id"] == "test-participant-id"
|
||||
assert result["color"] == "#123456"
|
||||
assert result["entered_at"] == "2025-01-01T10:00:00+00:00"
|
||||
|
||||
|
||||
def test_lobby_participant_from_dict_success(participant_dict):
|
||||
@@ -91,20 +88,6 @@ def test_lobby_participant_from_dict_success(participant_dict):
|
||||
assert participant.username == "test-username"
|
||||
assert participant.id == "test-participant-id"
|
||||
assert participant.color == "#123456"
|
||||
assert participant.entered_at == "2025-01-01T10:00:00+00:00"
|
||||
|
||||
|
||||
def test_lobby_participant_from_dict_missing_entered_at():
|
||||
"""`entered_at` is mandatory; data without it is rejected."""
|
||||
data = {
|
||||
"status": "waiting",
|
||||
"username": "test-username",
|
||||
"id": "test-participant-id",
|
||||
"color": "#123456",
|
||||
}
|
||||
|
||||
with pytest.raises(LobbyParticipantParsingError, match="Invalid participant data"):
|
||||
LobbyParticipant.from_dict(data)
|
||||
|
||||
|
||||
def test_lobby_participant_from_dict_default_status():
|
||||
@@ -113,7 +96,6 @@ def test_lobby_participant_from_dict_default_status():
|
||||
"username": "test-username",
|
||||
"id": "test-participant-id",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
}
|
||||
|
||||
participant = LobbyParticipant.from_dict(data_without_status)
|
||||
@@ -139,7 +121,6 @@ def test_lobby_participant_from_dict_invalid_status():
|
||||
"username": "test-username",
|
||||
"id": "test-participant-id",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
}
|
||||
|
||||
with pytest.raises(LobbyParticipantParsingError, match="Invalid participant data"):
|
||||
@@ -284,7 +265,6 @@ def test_request_entry_public_room(
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
)
|
||||
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
@@ -323,7 +303,6 @@ def test_request_entry_trusted_room(
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
)
|
||||
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
@@ -366,7 +345,6 @@ def test_request_entry_new_participant(
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
)
|
||||
mock_enter.return_value = participant_data
|
||||
|
||||
@@ -394,7 +372,6 @@ def test_request_entry_waiting_participant(
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
)
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||
@@ -423,7 +400,6 @@ def test_request_entry_accepted_participant(
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
)
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||
@@ -464,7 +440,6 @@ def test_request_entry_participant_with_role(
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
)
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||
@@ -491,23 +466,18 @@ def test_request_entry_participant_with_role(
|
||||
def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
|
||||
"""Test refreshing waiting status for a participant."""
|
||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||
lobby_service._index_touch = mock.Mock()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
lobby_service.refresh_waiting_status(room.id, participant_id)
|
||||
mock_cache.touch.assert_called_once_with(
|
||||
"mocked_cache_key", settings.LOBBY_WAITING_TIMEOUT
|
||||
)
|
||||
lobby_service._index_touch.assert_called_once_with(room.id)
|
||||
|
||||
|
||||
# pylint: disable=R0917
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
@mock.patch("core.utils.generate_color")
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.lobby.LobbyService._index_add")
|
||||
@freeze_time("2025-01-01 10:00:00")
|
||||
def test_enter_success(
|
||||
mock_index_add,
|
||||
mock_notify,
|
||||
mock_generate_color,
|
||||
mock_cache,
|
||||
@@ -527,7 +497,6 @@ def test_enter_success(
|
||||
assert participant.username == username
|
||||
assert participant.id == participant_id
|
||||
assert participant.color == "#123456"
|
||||
assert participant.entered_at == "2025-01-01T10:00:00+00:00"
|
||||
|
||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
@@ -539,16 +508,13 @@ def test_enter_success(
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(room.pk), notification_data={"type": "participantWaiting"}
|
||||
)
|
||||
mock_index_add.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
# pylint: disable=R0917
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
@mock.patch("core.utils.generate_color")
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.lobby.LobbyService._index_add")
|
||||
def test_enter_with_notification_error(
|
||||
mock_index_add,
|
||||
mock_notify,
|
||||
mock_generate_color,
|
||||
mock_cache,
|
||||
@@ -575,7 +541,6 @@ def test_enter_with_notification_error(
|
||||
participant.to_dict(),
|
||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||
)
|
||||
mock_index_add.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
@@ -614,15 +579,14 @@ def test_get_participant_parsing_error(
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
def test_list_waiting_participants_empty(mock_cache, lobby_service):
|
||||
"""Test listing waiting participants when none exist."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
lobby_service._index_members = mock.Mock(return_value=[])
|
||||
lobby_service._index_remove = mock.Mock()
|
||||
mock_cache.iter_keys.return_value = []
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
result = lobby_service.list_waiting_participants(room.id)
|
||||
|
||||
assert result == ()
|
||||
lobby_service._index_members.assert_called_once_with(room.id)
|
||||
lobby_service._index_remove.assert_not_called()
|
||||
assert result == []
|
||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
||||
mock_cache.get_many.assert_not_called()
|
||||
|
||||
|
||||
@@ -631,8 +595,7 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
|
||||
"""Test listing waiting participants with valid data."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||
lobby_service._index_members = mock.Mock(return_value=["participant1"])
|
||||
lobby_service._index_remove = mock.Mock()
|
||||
mock_cache.iter_keys.return_value = [cache_key]
|
||||
mock_cache.get_many.return_value = {cache_key: participant_dict}
|
||||
|
||||
result = lobby_service.list_waiting_participants(room.id)
|
||||
@@ -640,8 +603,8 @@ def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
|
||||
assert len(result) == 1
|
||||
assert result[0]["status"] == "waiting"
|
||||
assert result[0]["username"] == "test-username"
|
||||
lobby_service._index_members.assert_called_once_with(room.id)
|
||||
lobby_service._index_remove.assert_called_once_with(room.id)
|
||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
||||
mock_cache.get_many.assert_called_once_with([cache_key])
|
||||
|
||||
|
||||
@@ -657,7 +620,6 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
||||
"username": "user1",
|
||||
"id": "participant1",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
}
|
||||
|
||||
participant2 = {
|
||||
@@ -665,13 +627,9 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
||||
"username": "user2",
|
||||
"id": "participant2",
|
||||
"color": "#654321",
|
||||
"entered_at": "2025-01-01T10:05:00+00:00",
|
||||
}
|
||||
|
||||
lobby_service._index_members = mock.Mock(
|
||||
return_value=["participant1", "participant2"]
|
||||
)
|
||||
lobby_service._index_remove = mock.Mock()
|
||||
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
|
||||
mock_cache.get_many.return_value = {
|
||||
cache_key1: participant1,
|
||||
cache_key2: participant2,
|
||||
@@ -681,15 +639,15 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
||||
|
||||
assert len(result) == 2
|
||||
|
||||
# Most recent entry comes first
|
||||
assert [p["id"] for p in result] == ["participant2", "participant1"]
|
||||
assert result[0]["username"] == "user2"
|
||||
assert result[1]["username"] == "user1"
|
||||
# Verify both participants are in the result
|
||||
assert any(p["id"] == "participant1" and p["username"] == "user1" for p in result)
|
||||
assert any(p["id"] == "participant2" and p["username"] == "user2" for p in result)
|
||||
|
||||
# Verify all participants have waiting status
|
||||
assert all(p["status"] == "waiting" for p in result)
|
||||
|
||||
lobby_service._index_members.assert_called_once_with(room.id)
|
||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
||||
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
|
||||
|
||||
|
||||
@@ -698,13 +656,12 @@ def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service):
|
||||
"""Test listing waiting participants with corrupted data."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||
lobby_service._index_members = mock.Mock(return_value=["participant1"])
|
||||
lobby_service._index_remove = mock.Mock()
|
||||
mock_cache.iter_keys.return_value = [cache_key]
|
||||
mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}}
|
||||
|
||||
result = lobby_service.list_waiting_participants(room.id)
|
||||
|
||||
assert result == ()
|
||||
assert result == []
|
||||
mock_cache.delete.assert_called_once_with(cache_key)
|
||||
|
||||
|
||||
@@ -720,15 +677,11 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
|
||||
"username": "user2",
|
||||
"id": "participant2",
|
||||
"color": "#654321",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
}
|
||||
|
||||
corrupted_participant = {"invalid": "data"}
|
||||
|
||||
lobby_service._index_members = mock.Mock(
|
||||
return_value=["participant1", "participant2"]
|
||||
)
|
||||
lobby_service._index_remove = mock.Mock()
|
||||
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
|
||||
mock_cache.get_many.return_value = {
|
||||
cache_key1: corrupted_participant,
|
||||
cache_key2: valid_participant,
|
||||
@@ -746,6 +699,8 @@ def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service
|
||||
mock_cache.delete.assert_called_once_with(cache_key1)
|
||||
|
||||
# Verify both cache keys were queried
|
||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
||||
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
|
||||
|
||||
|
||||
@@ -761,20 +716,15 @@ def test_list_waiting_participants_non_waiting(mock_cache, lobby_service):
|
||||
"username": "user1",
|
||||
"id": "participant1",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
}
|
||||
participant2 = {
|
||||
"status": "accepted",
|
||||
"username": "user2",
|
||||
"id": "participant2",
|
||||
"color": "#654321",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
}
|
||||
|
||||
lobby_service._index_members = mock.Mock(
|
||||
return_value=["participant1", "participant2"]
|
||||
)
|
||||
lobby_service._index_remove = mock.Mock()
|
||||
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
|
||||
mock_cache.get_many.return_value = {
|
||||
cache_key1: participant1,
|
||||
cache_key2: participant2,
|
||||
@@ -866,12 +816,10 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
|
||||
"username": "test-username",
|
||||
"id": participant_id,
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
}
|
||||
|
||||
mock_cache.get.return_value = participant_dict
|
||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||
lobby_service._index_touch = mock.Mock()
|
||||
|
||||
lobby_service._update_participant_status(
|
||||
room.id,
|
||||
@@ -885,12 +833,10 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
|
||||
"username": "test-username",
|
||||
"id": participant_id,
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
}
|
||||
mock_cache.set.assert_called_once_with(
|
||||
"mocked_cache_key", expected_data, timeout=60
|
||||
)
|
||||
lobby_service._index_touch.assert_called_once_with(room.id)
|
||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@@ -911,7 +857,6 @@ def test_clear_room_cache(settings, lobby_service):
|
||||
username="participant1",
|
||||
id="participant1",
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
),
|
||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||
)
|
||||
@@ -922,7 +867,6 @@ def test_clear_room_cache(settings, lobby_service):
|
||||
username="participant2",
|
||||
id="participant2",
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
),
|
||||
timeout=settings.LOBBY_ACCEPTED_TIMEOUT,
|
||||
)
|
||||
@@ -933,18 +877,13 @@ def test_clear_room_cache(settings, lobby_service):
|
||||
username="participant3",
|
||||
id="participant3",
|
||||
color="#123456",
|
||||
entered_at="2025-01-01T10:00:00+00:00",
|
||||
),
|
||||
timeout=settings.LOBBY_DENIED_TIMEOUT,
|
||||
)
|
||||
|
||||
for participant_id in ("participant1", "participant2", "participant3"):
|
||||
lobby_service._index_add(room_id, participant_id)
|
||||
|
||||
lobby_service.clear_room_cache(room_id)
|
||||
|
||||
assert cache.keys(f"test-lobby_{room_id!s}_*") == []
|
||||
assert lobby_service._index_members(room_id) == frozenset()
|
||||
|
||||
|
||||
def test_clear_room_empty(settings, lobby_service):
|
||||
@@ -969,16 +908,12 @@ def test_clear_participant_cache(lobby_service):
|
||||
"username": "test-username",
|
||||
"id": participant_id,
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
}
|
||||
cache.set(cache_key, participant_data, timeout=settings.LOBBY_WAITING_TIMEOUT)
|
||||
lobby_service._index_add(room_id, participant_id)
|
||||
assert cache.get(cache_key) is not None
|
||||
assert participant_id in lobby_service._index_members(room_id)
|
||||
|
||||
lobby_service.clear_participant_cache(room_id, participant_id)
|
||||
assert cache.get(cache_key) is None
|
||||
assert participant_id not in lobby_service._index_members(room_id)
|
||||
|
||||
|
||||
def test_clear_participant_cache_nonexistent(lobby_service):
|
||||
@@ -992,85 +927,3 @@ def test_clear_participant_cache_nonexistent(lobby_service):
|
||||
lobby_service.clear_participant_cache(room_id, participant_id)
|
||||
|
||||
assert cache.get(cache_key) is None
|
||||
|
||||
|
||||
def test_index_add_members_remove_roundtrip(lobby_service):
|
||||
"""The room index records, lists and forgets participant ids."""
|
||||
room_id = uuid.uuid4()
|
||||
|
||||
assert lobby_service._index_members(room_id) == frozenset()
|
||||
|
||||
lobby_service._index_add(room_id, "participant1")
|
||||
lobby_service._index_add(room_id, "participant2")
|
||||
|
||||
assert sorted(lobby_service._index_members(room_id)) == [
|
||||
"participant1",
|
||||
"participant2",
|
||||
]
|
||||
|
||||
# The index carries a backstop TTL so abandoned rooms cannot leak it.
|
||||
ttl = lobby_service._redis().ttl(lobby_service._get_index_key(room_id))
|
||||
assert 0 < ttl <= settings.LOBBY_ACCEPTED_TIMEOUT
|
||||
|
||||
lobby_service._index_remove(room_id, "participant1")
|
||||
assert lobby_service._index_members(room_id) == frozenset(["participant2"])
|
||||
|
||||
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_enter_registers_participant_in_room_index(
|
||||
mock_notify, lobby_service, participant_id, username
|
||||
):
|
||||
"""Entering the lobby must index the participant id for the room."""
|
||||
room_id = uuid.uuid4()
|
||||
|
||||
lobby_service.enter(room_id, participant_id, username)
|
||||
|
||||
assert lobby_service._index_members(room_id) == frozenset([participant_id])
|
||||
|
||||
|
||||
def test_list_waiting_participants_prunes_stale_index_ids(settings, lobby_service):
|
||||
"""Indexed ids whose cache entry expired are pruned and not listed."""
|
||||
settings.LOBBY_KEY_PREFIX = "test-lobby-prune"
|
||||
room_id = uuid.uuid4()
|
||||
|
||||
cache.set(
|
||||
f"test-lobby-prune_{room_id!s}_participant1",
|
||||
{
|
||||
"id": "participant1",
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
||||
},
|
||||
timeout=100,
|
||||
)
|
||||
lobby_service._index_add(room_id, "participant1")
|
||||
# participant2 is indexed but its cache entry has expired.
|
||||
lobby_service._index_add(room_id, "participant2")
|
||||
|
||||
result = lobby_service.list_waiting_participants(room_id)
|
||||
|
||||
assert [participant["id"] for participant in result] == ["participant1"]
|
||||
assert lobby_service._index_members(room_id) == frozenset(["participant1"])
|
||||
|
||||
|
||||
def test_refresh_waiting_status_rearms_room_index_ttl(lobby_service, participant_id):
|
||||
"""A lone waiter's polling must keep the room index alive.
|
||||
|
||||
Regression test: the index backstop TTL is only armed at enter() time,
|
||||
so a participant whose rolling WAITING refreshes outlast it would keep
|
||||
their entry alive while silently vanishing from the moderator list.
|
||||
Refreshing the waiting status must therefore re-arm the index TTL.
|
||||
"""
|
||||
room_id = uuid.uuid4()
|
||||
lobby_service._index_add(room_id, participant_id)
|
||||
|
||||
index_key = lobby_service._get_index_key(room_id)
|
||||
redis_client = lobby_service._redis()
|
||||
redis_client.expire(index_key, 10)
|
||||
assert redis_client.ttl(index_key) <= 10
|
||||
|
||||
lobby_service.refresh_waiting_status(room_id, participant_id)
|
||||
|
||||
assert redis_client.ttl(index_key) > 10
|
||||
assert lobby_service._index_members(room_id) == frozenset([participant_id])
|
||||
|
||||
@@ -90,18 +90,19 @@ def test_presence_clear_and_clear_room():
|
||||
assert presence.is_marked_present(other_room, "a") is True
|
||||
|
||||
|
||||
def test_presence_clear_room_removes_many_entries_and_the_index():
|
||||
"""clear_room removes every entry of the room through the index — never
|
||||
a keyspace scan — and leaves other rooms untouched."""
|
||||
def test_presence_clear_room_scans_in_pages():
|
||||
"""clear_room removes every match, even across several SCAN pages,
|
||||
and only within the room."""
|
||||
room_id, other_room = str(uuid4()), str(uuid4())
|
||||
presence = PresenceCache()
|
||||
for i in range(7):
|
||||
presence.mark_present(room_id, f"user-{i}")
|
||||
presence.mark_present(other_room, "user-0")
|
||||
|
||||
presence.clear_room(room_id)
|
||||
# An itersize smaller than the match count forces delete_pattern to
|
||||
# page through several SCAN cursors rather than finish in one pass.
|
||||
with mock.patch("core.utils.CACHE_SCAN_ITERSIZE", 3):
|
||||
presence.clear_room(room_id)
|
||||
|
||||
assert all(not presence.is_marked_present(room_id, f"user-{i}") for i in range(7))
|
||||
assert presence.is_marked_present(other_room, "user-0") is True
|
||||
assert presence._index_members(room_id) == frozenset([])
|
||||
assert presence._index_members(other_room) == frozenset(["user-0"])
|
||||
|
||||
@@ -5,8 +5,6 @@ from unittest import mock
|
||||
import pytest
|
||||
from livekit.api import TwirpError
|
||||
|
||||
from core.factories import RoomFactory
|
||||
from core.models import RoomAccessLevel
|
||||
from core.services.room_management import (
|
||||
RoomManagement,
|
||||
RoomManagementException,
|
||||
@@ -22,7 +20,7 @@ def test_delete_room_calls_livekit(mock_create_livekit_client):
|
||||
mock_api.aclose = mock.AsyncMock()
|
||||
mock_create_livekit_client.return_value = mock_api
|
||||
|
||||
RoomManagement.delete_room("room-abc")
|
||||
RoomManagement().delete_room("room-abc")
|
||||
|
||||
mock_api.room.delete_room.assert_awaited_once()
|
||||
request = mock_api.room.delete_room.await_args.args[0]
|
||||
@@ -41,7 +39,7 @@ def test_delete_room_raises_not_found(mock_create_livekit_client):
|
||||
mock_create_livekit_client.return_value = mock_api
|
||||
|
||||
with pytest.raises(RoomNotFoundException):
|
||||
RoomManagement.delete_room("missing-room")
|
||||
RoomManagement().delete_room("missing-room")
|
||||
|
||||
mock_api.aclose.assert_awaited_once()
|
||||
|
||||
@@ -57,25 +55,6 @@ def test_delete_room_raises_management_exception(mock_create_livekit_client):
|
||||
mock_create_livekit_client.return_value = mock_api
|
||||
|
||||
with pytest.raises(RoomManagementException):
|
||||
RoomManagement.delete_room("room-abc")
|
||||
RoomManagement().delete_room("room-abc")
|
||||
|
||||
mock_api.aclose.assert_awaited_once()
|
||||
|
||||
|
||||
@mock.patch.object(RoomManagement, "update_metadata")
|
||||
def test_sync_room_metadata_pushes_configuration_and_access_level(mock_update_metadata):
|
||||
"""The room's configuration and access level are forwarded to LiveKit."""
|
||||
room = RoomFactory.build(
|
||||
access_level=RoomAccessLevel.RESTRICTED,
|
||||
configuration={"everyone_can_mute": True},
|
||||
)
|
||||
|
||||
RoomManagement.sync_room_metadata(room)
|
||||
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
room_name=str(room.id),
|
||||
metadata={
|
||||
"configuration": {"everyone_can_mute": True},
|
||||
"access_level": RoomAccessLevel.RESTRICTED,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Test SIP management service.
|
||||
Test SIP mamagement service.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0212
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
"""Tests for the Dockerflow health endpoints backing the Kubernetes probes."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.core import checks
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import pytest
|
||||
from dockerflow.django.views import django_check_registry
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/__lbheartbeat__", "/__heartbeat__"])
|
||||
def test_dockerflow_endpoints_are_anonymous(client, path):
|
||||
"""Both endpoints answer without authentication."""
|
||||
response = client.get(path)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_dockerflow_lbheartbeat_is_a_liveness_signal(client, django_assert_num_queries):
|
||||
"""The load balancer heartbeat answers 200 without touching the database."""
|
||||
with django_assert_num_queries(0):
|
||||
response = client.get("/__lbheartbeat__")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_dockerflow_lbheartbeat_ignores_allowed_hosts(client):
|
||||
"""The probes are served before ALLOWED_HOSTS is enforced."""
|
||||
response = client.get("/__lbheartbeat__", headers={"host": "1.2.3.4:8000"})
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_dockerflow_heartbeat_reports_the_configured_checks(client):
|
||||
"""The heartbeat runs the database, migrations and redis checks."""
|
||||
with override_settings(DEBUG=True):
|
||||
response = client.get("/__heartbeat__")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
payload = response.json()
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["details"] == {}
|
||||
assert payload["checks"]["check_database_connected"] == "ok"
|
||||
assert payload["checks"]["check_migrations_applied"] == "ok"
|
||||
|
||||
|
||||
def test_dockerflow_heartbeat_is_a_readiness_signal(client):
|
||||
"""A check reporting an error takes the heartbeat down with a 500."""
|
||||
|
||||
def failing_check(**kwargs):
|
||||
return [checks.Error("Could not connect to database", id="health.E001")]
|
||||
|
||||
with mock.patch.object(
|
||||
django_check_registry, "get_checks", return_value=[failing_check]
|
||||
):
|
||||
response = client.get("/__heartbeat__")
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.json()["status"] == "error"
|
||||
|
||||
|
||||
def test_dockerflow_heartbeat_tolerates_warnings(client):
|
||||
"""A warning in the check should not affect the probes."""
|
||||
|
||||
def warning_check(**kwargs):
|
||||
return [checks.Warning("Unapplied migration", id="health.W001")]
|
||||
|
||||
with mock.patch.object(
|
||||
django_check_registry, "get_checks", return_value=[warning_check]
|
||||
):
|
||||
response = client.get("/__heartbeat__")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "warning"
|
||||
@@ -1,96 +0,0 @@
|
||||
"""Tests for the external API ResourceServerBackend."""
|
||||
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
|
||||
import pytest
|
||||
import responses
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.external_api.authentication import ResourceServerBackend
|
||||
from core.factories import UserFactory
|
||||
from core.models import User
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def _payload(sub):
|
||||
return {"sub": sub, "active": True, "scope": "lasuite_meet", "client_id": "app"}
|
||||
|
||||
|
||||
def test_resource_server_backend_get_or_create_user_active():
|
||||
"""An existing active user matching the sub should be returned."""
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
result = ResourceServerBackend().get_or_create_user(
|
||||
access_token="token", id_token=None, payload=_payload(user.sub)
|
||||
)
|
||||
|
||||
assert result == user
|
||||
|
||||
|
||||
def test_resource_server_backend_get_or_create_user_inactive():
|
||||
"""An inactive user should be rejected even with a valid token."""
|
||||
|
||||
user = UserFactory(is_active=False)
|
||||
|
||||
with pytest.raises(SuspiciousOperation, match="User account is disabled."):
|
||||
ResourceServerBackend().get_or_create_user(
|
||||
access_token="token", id_token=None, payload=_payload(user.sub)
|
||||
)
|
||||
|
||||
|
||||
def test_resource_server_backend_get_or_create_user_creates(settings):
|
||||
"""An unknown sub should create an active user when OIDC_CREATE_USER is set."""
|
||||
|
||||
settings.OIDC_CREATE_USER = True
|
||||
|
||||
result = ResourceServerBackend().get_or_create_user(
|
||||
access_token="token", id_token=None, payload=_payload("new-sub")
|
||||
)
|
||||
|
||||
assert result.sub == "new-sub"
|
||||
assert result.is_active is True
|
||||
assert User.objects.filter(sub="new-sub").exists()
|
||||
|
||||
|
||||
def test_resource_server_backend_get_or_create_user_no_creation(settings):
|
||||
"""An unknown sub should return None when OIDC_CREATE_USER is unset."""
|
||||
|
||||
settings.OIDC_CREATE_USER = False
|
||||
|
||||
result = ResourceServerBackend().get_or_create_user(
|
||||
access_token="token", id_token=None, payload=_payload("new-sub")
|
||||
)
|
||||
|
||||
assert result is None
|
||||
assert not User.objects.filter(sub="new-sub").exists()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_api_rooms_list_resource_server_inactive_user(settings):
|
||||
"""End to end: a valid introspected token for an inactive user should get 401."""
|
||||
|
||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||
|
||||
user = UserFactory(is_active=False)
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"https://oidc.example.com/introspect",
|
||||
json={
|
||||
"iss": "https://oidc.example.com",
|
||||
"active": True,
|
||||
"sub": user.sub,
|
||||
"scope": "openid lasuite_meet rooms:list",
|
||||
"client_id": "app",
|
||||
},
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION="Bearer rs-token")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "login failed" in str(response.data).lower()
|
||||
@@ -16,17 +16,8 @@ import responses
|
||||
from lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.analytics import AnalyticsEvent
|
||||
from core.factories import ApplicationFactory, RoomFactory, UserFactory
|
||||
from core.models import (
|
||||
Application,
|
||||
ApplicationScope,
|
||||
RoleChoices,
|
||||
Room,
|
||||
RoomAccessLevel,
|
||||
User,
|
||||
)
|
||||
from core.services.room_management import RoomManagement
|
||||
from core.models import ApplicationScope, RoleChoices, Room, RoomAccessLevel, User
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -889,509 +880,6 @@ def test_api_rooms_create_public_access_level_when_default_is_public(settings):
|
||||
assert response.data["access_level"] == RoomAccessLevel.PUBLIC
|
||||
|
||||
|
||||
@mock.patch("core.external_api.viewsets.analytics.capture")
|
||||
def test_api_rooms_create_tracks_analytics(mock_capture):
|
||||
"""Creating a room should emit a ROOM_CREATED analytics event."""
|
||||
|
||||
user = UserFactory()
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
|
||||
application = Application.objects.get()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post(
|
||||
"/external-api/v1.0/rooms/",
|
||||
{"access_level": RoomAccessLevel.RESTRICTED},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
|
||||
mock_capture.assert_called_once()
|
||||
captured_user, event, properties = mock_capture.call_args[0]
|
||||
|
||||
assert captured_user == user
|
||||
assert event == AnalyticsEvent.ROOM_CREATED
|
||||
assert properties == {
|
||||
"room_id": response.data["id"],
|
||||
"access_level": RoomAccessLevel.RESTRICTED,
|
||||
"client_id": str(application.client_id),
|
||||
"external_api": True,
|
||||
"auth_method": "ApplicationJWTAuthentication",
|
||||
"$set": {"email": user.email},
|
||||
}
|
||||
|
||||
|
||||
def test_api_rooms_update_requires_authentication():
|
||||
"""Updating a room without authentication should return 401."""
|
||||
|
||||
room = RoomFactory(users=[(UserFactory(), RoleChoices.OWNER)])
|
||||
|
||||
client = APIClient()
|
||||
response = client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{"access_level": RoomAccessLevel.RESTRICTED},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_rooms_update_requires_scope():
|
||||
"""Updating a room requires the ROOMS_UPDATE scope."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
# Token without ROOMS_UPDATE scope
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{"access_level": RoomAccessLevel.RESTRICTED},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert (
|
||||
"insufficient permissions. required scope: rooms:update"
|
||||
in str(response.data).lower()
|
||||
)
|
||||
|
||||
|
||||
def test_api_rooms_update_no_scope():
|
||||
"""Updating a room without any scope should return 403."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
token = generate_test_token(user, [])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{"access_level": RoomAccessLevel.RESTRICTED},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "insufficient permissions." in str(response.data).lower()
|
||||
|
||||
|
||||
@mock.patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_owner_success(mock_update_metadata, settings):
|
||||
"""An owner should be able to update the access level and the configuration."""
|
||||
|
||||
settings.APPLICATION_BASE_URL = "http://your-application.com"
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
users=[(user, RoleChoices.OWNER)],
|
||||
access_level=RoomAccessLevel.TRUSTED,
|
||||
configuration={},
|
||||
)
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{
|
||||
"access_level": RoomAccessLevel.RESTRICTED,
|
||||
"configuration": {"everyone_can_mute": True},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["id"] == str(room.id)
|
||||
assert response.data["access_level"] == RoomAccessLevel.RESTRICTED
|
||||
assert response.data["configuration"] == {"everyone_can_mute": True}
|
||||
assert response.data["url"] == f"http://your-application.com/{room.slug}"
|
||||
|
||||
room.refresh_from_db()
|
||||
assert room.access_level == RoomAccessLevel.RESTRICTED
|
||||
assert room.configuration == {"everyone_can_mute": True}
|
||||
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
room_name=str(room.id),
|
||||
metadata={
|
||||
"configuration": {"everyone_can_mute": True},
|
||||
"access_level": RoomAccessLevel.RESTRICTED,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@mock.patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_replaces_configuration(mock_update_metadata):
|
||||
"""The configuration is replaced as a whole, it is not merged with the stored one."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
users=[(user, RoleChoices.OWNER)],
|
||||
configuration={"can_publish_sources": ["camera"], "everyone_can_mute": True},
|
||||
)
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{"configuration": {"everyone_can_mute": False}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# The keys missing from the payload are dropped, not kept.
|
||||
assert response.data["configuration"] == {"everyone_can_mute": False}
|
||||
|
||||
room.refresh_from_db()
|
||||
assert room.configuration == {"everyone_can_mute": False}
|
||||
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
room_name=str(room.id),
|
||||
metadata={
|
||||
"configuration": {"everyone_can_mute": False},
|
||||
"access_level": room.access_level,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@mock.patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_administrator_success(mock_update_metadata):
|
||||
"""An administrator should be able to update a room."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
users=[(user, RoleChoices.ADMIN)],
|
||||
access_level=RoomAccessLevel.TRUSTED,
|
||||
)
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{"access_level": RoomAccessLevel.RESTRICTED},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
room.refresh_from_db()
|
||||
assert room.access_level == RoomAccessLevel.RESTRICTED
|
||||
mock_update_metadata.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_put_not_allowed(mock_update_metadata):
|
||||
"""PUT is not exposed: full replacement is not supported, only PATCH is."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
users=[(user, RoleChoices.OWNER)],
|
||||
access_level=RoomAccessLevel.TRUSTED,
|
||||
configuration={},
|
||||
)
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.put(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{
|
||||
"access_level": RoomAccessLevel.RESTRICTED,
|
||||
"configuration": {"everyone_can_mute": True},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 405
|
||||
|
||||
room.refresh_from_db()
|
||||
assert room.access_level == RoomAccessLevel.TRUSTED
|
||||
assert room.configuration == {}
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", [RoleChoices.MEMBER, None])
|
||||
@mock.patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_without_privileges(mock_update_metadata, role):
|
||||
"""Members and users without any role should not be able to update a room."""
|
||||
|
||||
user = UserFactory()
|
||||
users = [(user, role)] if role else []
|
||||
room = RoomFactory(users=users, access_level=RoomAccessLevel.TRUSTED)
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{"access_level": RoomAccessLevel.RESTRICTED},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
room.refresh_from_db()
|
||||
assert room.access_level == RoomAccessLevel.TRUSTED
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_readonly_enforcement(mock_update_metadata):
|
||||
"""Read-only fields provided on update should be ignored, the slug stays immutable."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
users=[(user, RoleChoices.OWNER)],
|
||||
access_level=RoomAccessLevel.TRUSTED,
|
||||
)
|
||||
expected_id, expected_name = str(room.id), room.name
|
||||
expected_slug, expected_pin_code = room.slug, room.pin_code
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": "fake-name",
|
||||
"slug": "fake-slug",
|
||||
"pin_code": "000000",
|
||||
"access_level": RoomAccessLevel.RESTRICTED,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["id"] == expected_id
|
||||
assert response.data["name"] == expected_name
|
||||
assert response.data["slug"] == expected_slug
|
||||
|
||||
room.refresh_from_db()
|
||||
assert str(room.id) == expected_id
|
||||
assert room.name == expected_name
|
||||
assert room.slug == expected_slug
|
||||
assert room.pin_code == expected_pin_code
|
||||
|
||||
# The one writable field in the payload was applied
|
||||
assert room.access_level == RoomAccessLevel.RESTRICTED
|
||||
mock_update_metadata.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_rejects_invalid_configuration(mock_update_metadata):
|
||||
"""Updating a room with unsupported configuration keys should fail."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)], configuration={})
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{"configuration": {"unsupported_flag": True}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "extra inputs are not permitted" in str(response.data).lower()
|
||||
|
||||
room.refresh_from_db()
|
||||
assert room.configuration == {}
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"invalid_configuration",
|
||||
[
|
||||
{"can_publish_sources": ["invalid-source"]},
|
||||
{"everyone_can_mute": "invalid-value"},
|
||||
],
|
||||
)
|
||||
@mock.patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_rejects_invalid_configuration_values(
|
||||
mock_update_metadata, invalid_configuration
|
||||
):
|
||||
"""Updating a room with invalid configuration values should fail."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)], configuration={})
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{"configuration": invalid_configuration},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
room.refresh_from_db()
|
||||
assert room.configuration == {}
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_public_access_disabled_by_default(mock_update_metadata):
|
||||
"""Switching a room to public should be disabled for the external API by default."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
users=[(user, RoleChoices.OWNER)],
|
||||
access_level=RoomAccessLevel.TRUSTED,
|
||||
)
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{"access_level": RoomAccessLevel.PUBLIC},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "public rooms are disabled" in str(response.data).lower()
|
||||
|
||||
room.refresh_from_db()
|
||||
assert room.access_level == RoomAccessLevel.TRUSTED
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_public_access_enabled_with_settings(
|
||||
mock_update_metadata, settings
|
||||
):
|
||||
"""Switching a room to public should be allowed when explicitly enabled."""
|
||||
|
||||
settings.EXTERNAL_API_ALLOW_PUBLIC_ACCESS = True
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
users=[(user, RoleChoices.OWNER)],
|
||||
access_level=RoomAccessLevel.TRUSTED,
|
||||
)
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{"access_level": RoomAccessLevel.PUBLIC},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["access_level"] == RoomAccessLevel.PUBLIC
|
||||
|
||||
room.refresh_from_db()
|
||||
assert room.access_level == RoomAccessLevel.PUBLIC
|
||||
mock_update_metadata.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.external_api.viewsets.analytics.capture")
|
||||
@mock.patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_unchanged_skips_livekit_sync(
|
||||
mock_update_metadata, mock_capture
|
||||
):
|
||||
"""An update that changes nothing should not sync metadata nor report changes."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
users=[(user, RoleChoices.OWNER)],
|
||||
access_level=RoomAccessLevel.TRUSTED,
|
||||
configuration={"everyone_can_mute": True},
|
||||
)
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{
|
||||
"access_level": RoomAccessLevel.TRUSTED,
|
||||
"configuration": {"everyone_can_mute": True},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
# The event is still emitted for auditing, but reports an empty delta.
|
||||
_, _, properties = mock_capture.call_args[0]
|
||||
assert properties["updated_fields"] == []
|
||||
|
||||
|
||||
@mock.patch("core.external_api.viewsets.analytics.capture")
|
||||
@mock.patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_tracks_analytics(mock_update_metadata, mock_capture):
|
||||
"""Updating a room should emit a ROOM_UPDATED analytics event."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
users=[(user, RoleChoices.OWNER)],
|
||||
access_level=RoomAccessLevel.TRUSTED,
|
||||
configuration={},
|
||||
)
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||
application = Application.objects.get()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{
|
||||
"access_level": RoomAccessLevel.RESTRICTED,
|
||||
"configuration": {"everyone_can_mute": True},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
mock_capture.assert_called_once()
|
||||
captured_user, event, properties = mock_capture.call_args[0]
|
||||
|
||||
assert captured_user == user
|
||||
assert event == AnalyticsEvent.ROOM_UPDATED
|
||||
assert properties == {
|
||||
"room_id": str(room.pk),
|
||||
"access_level": RoomAccessLevel.RESTRICTED,
|
||||
"updated_fields": ["access_level", "configuration"],
|
||||
"previous_access_level": RoomAccessLevel.TRUSTED,
|
||||
"client_id": str(application.client_id),
|
||||
"external_api": True,
|
||||
"auth_method": "ApplicationJWTAuthentication",
|
||||
"$set": {"email": user.email},
|
||||
}
|
||||
|
||||
mock_update_metadata.assert_called_once()
|
||||
|
||||
|
||||
def test_api_rooms_response_no_url(settings):
|
||||
"""Response should not include url field when APPLICATION_BASE_URL is None."""
|
||||
settings.APPLICATION_BASE_URL = None
|
||||
@@ -2009,106 +1497,6 @@ def test_resource_server_denies_access_with_insufficient_scopes(settings):
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@responses.activate
|
||||
@mock.patch.object(RoomManagement, "update_metadata")
|
||||
def test_resource_server_updates_room_with_prefixed_scope(
|
||||
mock_update_metadata, settings
|
||||
):
|
||||
"""A resource server token carrying the prefixed update scope should be accepted."""
|
||||
|
||||
user = UserFactory(sub="very-specific-sub")
|
||||
room = RoomFactory(
|
||||
users=[(user, RoleChoices.OWNER)],
|
||||
access_level=RoomAccessLevel.TRUSTED,
|
||||
)
|
||||
|
||||
settings.OIDC_RS_CLIENT_ID = "some_client_id"
|
||||
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
|
||||
settings.OIDC_RS_SCOPES_PREFIX = "lasuite_meet"
|
||||
|
||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||
settings.OIDC_VERIFY_SSL = False
|
||||
settings.OIDC_TIMEOUT = 5
|
||||
settings.OIDC_PROXY = None
|
||||
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
|
||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"https://oidc.example.com/introspect",
|
||||
json={
|
||||
"iss": "https://oidc.example.com",
|
||||
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
|
||||
"sub": "very-specific-sub",
|
||||
"client_id": "some_service_provider",
|
||||
"scope": "openid lasuite_meet lasuite_meet:rooms:update",
|
||||
"active": True,
|
||||
},
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
|
||||
response = client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{"access_level": RoomAccessLevel.RESTRICTED},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
room.refresh_from_db()
|
||||
assert room.access_level == RoomAccessLevel.RESTRICTED
|
||||
mock_update_metadata.assert_called_once()
|
||||
|
||||
|
||||
@responses.activate
|
||||
def test_resource_server_denies_room_update_without_update_scope(settings):
|
||||
"""A resource server token without the update scope should be denied."""
|
||||
|
||||
user = UserFactory(sub="very-specific-sub")
|
||||
room = RoomFactory(
|
||||
users=[(user, RoleChoices.OWNER)],
|
||||
access_level=RoomAccessLevel.TRUSTED,
|
||||
)
|
||||
|
||||
settings.OIDC_RS_CLIENT_ID = "some_client_id"
|
||||
settings.OIDC_RS_CLIENT_SECRET = "some_client_secret"
|
||||
settings.OIDC_RS_SCOPES_PREFIX = "lasuite_meet"
|
||||
|
||||
settings.OIDC_OP_URL = "https://oidc.example.com"
|
||||
settings.OIDC_VERIFY_SSL = False
|
||||
settings.OIDC_TIMEOUT = 5
|
||||
settings.OIDC_PROXY = None
|
||||
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
|
||||
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
|
||||
|
||||
responses.add(
|
||||
responses.POST,
|
||||
"https://oidc.example.com/introspect",
|
||||
json={
|
||||
"iss": "https://oidc.example.com",
|
||||
"aud": "some_client_id", # settings.OIDC_RS_CLIENT_ID
|
||||
"sub": "very-specific-sub",
|
||||
"client_id": "some_service_provider",
|
||||
"scope": "openid lasuite_meet lasuite_meet:rooms:retrieve",
|
||||
"active": True,
|
||||
},
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION="Bearer some_token")
|
||||
response = client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{"access_level": RoomAccessLevel.RESTRICTED},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
room.refresh_from_db()
|
||||
assert room.access_level == RoomAccessLevel.TRUSTED
|
||||
|
||||
|
||||
# ==============================
|
||||
# Addons
|
||||
# ==============================
|
||||
@@ -2160,32 +1548,6 @@ def test_api_rooms_create_with_valid_addons_token():
|
||||
assert room.get_role(user) == RoleChoices.OWNER
|
||||
|
||||
|
||||
@mock.patch.object(RoomManagement, "update_metadata")
|
||||
def test_api_rooms_update_with_valid_addons_token(mock_update_metadata):
|
||||
"""Updating a room with a valid addons token should succeed."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
users=[(user, RoleChoices.OWNER)],
|
||||
access_level=RoomAccessLevel.TRUSTED,
|
||||
)
|
||||
|
||||
token = generate_addons_test_token(user, [ApplicationScope.ROOMS_UPDATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.patch(
|
||||
f"/external-api/v1.0/rooms/{room.id}/",
|
||||
{"access_level": RoomAccessLevel.RESTRICTED},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
room.refresh_from_db()
|
||||
assert room.access_level == RoomAccessLevel.RESTRICTED
|
||||
mock_update_metadata.assert_called_once()
|
||||
|
||||
|
||||
def test_api_rooms_addons_token_inactive_user():
|
||||
"""Addons token for an inactive user should return 401."""
|
||||
user = UserFactory(is_active=False)
|
||||
|
||||
@@ -157,7 +157,6 @@ def test_models_recording_is_savable_normal():
|
||||
@pytest.mark.parametrize(
|
||||
"status",
|
||||
[
|
||||
RecordingStatusChoices.FAILED,
|
||||
RecordingStatusChoices.FAILED_TO_STOP,
|
||||
RecordingStatusChoices.FAILED_TO_START,
|
||||
RecordingStatusChoices.ABORTED,
|
||||
@@ -280,7 +279,6 @@ def test_models_recording_is_saved_false_initiated():
|
||||
@pytest.mark.parametrize(
|
||||
"status",
|
||||
[
|
||||
RecordingStatusChoices.FAILED,
|
||||
RecordingStatusChoices.FAILED_TO_STOP,
|
||||
RecordingStatusChoices.FAILED_TO_START,
|
||||
RecordingStatusChoices.ABORTED,
|
||||
|
||||
@@ -26,64 +26,6 @@ def test_models_users_id_unique():
|
||||
factories.UserFactory(id=user.id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sub,is_valid",
|
||||
[
|
||||
# cases from suitenumerique/docs PR #1295 (same validator)
|
||||
("valid_sub.@+-:=/", True),
|
||||
("invalid süb", False),
|
||||
(12345, True),
|
||||
# Auth0 emits "provider|user-id" subject identifiers
|
||||
("auth0|644c0bc8f1874ef6d339fb34", True),
|
||||
("google-oauth2|103547991597142817347", True),
|
||||
# Keycloak-style UUID
|
||||
("f:550e8400-e29b-41d4-a716-446655440000:jdoe", True),
|
||||
# base64/URN-style identifiers
|
||||
("dGVzdC1zdWItdmFsdWU=", True),
|
||||
("urn:example:user/42", True),
|
||||
# legacy format still accepted
|
||||
("user@example.com", True),
|
||||
# space (U+0020) is printable ASCII and remains allowed
|
||||
("sub with space", True),
|
||||
# non-ASCII values are rejected
|
||||
("émilie", False),
|
||||
# ASCII control characters (U+0000-U+001F, U+007F) are rejected:
|
||||
# NUL passes isascii() but cannot be stored in PostgreSQL text
|
||||
# fields, and the others invite log injection and interop issues
|
||||
("nul\x00sub", False),
|
||||
("\x00", False),
|
||||
("tab\tsub", False),
|
||||
("newline\nsub", False),
|
||||
("del\x7fsub", False),
|
||||
],
|
||||
)
|
||||
def test_models_users_sub_validator(sub, is_valid):
|
||||
"""
|
||||
The "sub" field should accept any ASCII string as required by
|
||||
OpenID Connect Core 1.0 §2 and RFC 7519 §4.1.2, and reject non-ASCII values.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
user.sub = sub
|
||||
if is_valid:
|
||||
user.full_clean()
|
||||
else:
|
||||
with pytest.raises(
|
||||
ValidationError,
|
||||
match="Enter a valid sub. This value should be printable ASCII only.",
|
||||
):
|
||||
user.full_clean()
|
||||
|
||||
|
||||
def test_models_users_sub_max_length():
|
||||
"""The "sub" field should enforce the 255 ASCII characters limit of OIDC Core 1.0 §2."""
|
||||
user = factories.UserFactory(sub="a" * 255)
|
||||
assert user.sub == "a" * 255
|
||||
|
||||
user.sub = "a" * 256
|
||||
with pytest.raises(ValidationError, match="at most 255 characters"):
|
||||
user.full_clean()
|
||||
|
||||
|
||||
def test_models_users_send_mail_main_existing():
|
||||
"""The "email_user' method should send mail to the user's email address."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
"""Unit tests for the get_release settings helper."""
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from meet.settings import get_release
|
||||
|
||||
|
||||
@pytest.fixture(name="base_dir")
|
||||
def fixture_empty_base_dir(tmp_path, monkeypatch):
|
||||
"""Point get_release at an empty directory."""
|
||||
monkeypatch.setattr("meet.settings.BASE_DIR", str(tmp_path))
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_get_release_reads_project_pyproject():
|
||||
"""Should return the semantic version of the backend's pyproject.toml."""
|
||||
assert re.fullmatch(r"\d+\.\d+\.\d+", get_release())
|
||||
|
||||
|
||||
def test_get_release_reads_pyproject_version(base_dir):
|
||||
"""Should return the version declared in the [project] table."""
|
||||
(base_dir / "pyproject.toml").write_text(
|
||||
'[project]\nname = "meet"\nversion = "1.2.3"\n', encoding="utf-8"
|
||||
)
|
||||
assert get_release() == "1.2.3"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("base_dir")
|
||||
def test_get_release_missing_pyproject():
|
||||
"""Should fall back to "NA" without a pyproject.toml."""
|
||||
assert get_release() == "NA"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"content",
|
||||
[
|
||||
'[project]\nname = "meet"\n', # no version
|
||||
"[tool.uv]\npackage = true\n", # no [project] table
|
||||
"[project\nversion = ", # malformed TOML
|
||||
],
|
||||
)
|
||||
def test_get_release_unreadable_version(base_dir, content):
|
||||
"""Should fall back to "NA" without a readable version in pyproject.toml."""
|
||||
(base_dir / "pyproject.toml").write_text(content, encoding="utf-8")
|
||||
assert get_release() == "NA"
|
||||
@@ -1,22 +0,0 @@
|
||||
"""Unit tests for the LIVEKIT_DEFAULT_VIDEO_CODEC setting value."""
|
||||
|
||||
import pytest
|
||||
|
||||
from meet.settings import VideoCodecValue
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw,expected",
|
||||
[("vp9", "vp9"), ("AV1", "av1"), (" h264 ", "h264")],
|
||||
)
|
||||
def test_video_codec_value_normalizes(raw, expected):
|
||||
"""Whitespace is trimmed and the name is lowercased before it is checked."""
|
||||
# environ=False keeps __new__ from resolving the value, so the instance survives.
|
||||
assert VideoCodecValue(environ=False).to_python(raw) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw", ["vp10", "", "h.264"])
|
||||
def test_video_codec_value_rejects_unsupported(raw):
|
||||
"""A name outside the accepted list stops the settings module loading."""
|
||||
with pytest.raises(ValueError, match="Unsupported video codec"):
|
||||
VideoCodecValue(environ=False).to_python(raw)
|
||||
@@ -499,3 +499,6 @@ def build_telephony_config():
|
||||
"default_country": country,
|
||||
"international_phone_number": international,
|
||||
}
|
||||
|
||||
|
||||
CACHE_SCAN_ITERSIZE = 500
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
"""Custom validators for the core app."""
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
def sub_validator(value):
|
||||
"""Validate that the sub is printable ASCII only.
|
||||
|
||||
OpenID Connect Core 1.0 (section 2) allows any ASCII (RFC 20) string of
|
||||
at most 255 characters, so no character whitelist is applied: providers
|
||||
legitimately emit "|" (Auth0), ":" (Keycloak), "=", "/", etc. As a
|
||||
deliberate hardening beyond the spec, ASCII control characters
|
||||
(U+0000-U+001F and U+007F) are rejected: no known provider emits them,
|
||||
NUL cannot be stored in PostgreSQL text fields, and the others invite
|
||||
log-injection and interoperability issues. For str values,
|
||||
``isprintable()`` is false exactly for those control characters, while
|
||||
space (U+0020) remains allowed.
|
||||
"""
|
||||
if not value.isascii() or not value.isprintable():
|
||||
raise ValidationError(
|
||||
_("Enter a valid sub. This value should be printable ASCII only.")
|
||||
)
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-09-02 22:52+0000\n"
|
||||
"POT-Creation-Date: 2026-07-02 10:47+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -51,11 +51,11 @@ msgstr ""
|
||||
msgid "File preview"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:300 core/admin.py:450
|
||||
#: core/admin.py:300 core/admin.py:443
|
||||
msgid "No owner"
|
||||
msgstr "Kein Eigentümer"
|
||||
|
||||
#: core/admin.py:303 core/admin.py:453
|
||||
#: core/admin.py:303 core/admin.py:446
|
||||
msgid "Multiple owners"
|
||||
msgstr "Mehrere Eigentümer"
|
||||
|
||||
@@ -98,11 +98,11 @@ msgstr "%(count)s Aufnahme(n) erfolgreich als ‚Fehler beim Stoppen‘ markiert
|
||||
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
||||
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
|
||||
|
||||
#: core/admin.py:517
|
||||
#: core/admin.py:510
|
||||
msgid "No scopes"
|
||||
msgstr "Keine Scopes"
|
||||
|
||||
#: core/admin.py:519
|
||||
#: core/admin.py:512
|
||||
msgid "Scopes"
|
||||
msgstr "Scopes"
|
||||
|
||||
@@ -110,201 +110,185 @@ msgstr "Scopes"
|
||||
msgid "Creator is me"
|
||||
msgstr "Ersteller bin ich"
|
||||
|
||||
#: core/api/serializers.py:108
|
||||
#: core/api/serializers.py:89
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr ""
|
||||
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
|
||||
"hinzuzufügen."
|
||||
|
||||
#: core/api/serializers.py:560
|
||||
#: core/api/serializers.py:534
|
||||
msgid "This file extension is not allowed."
|
||||
msgstr "Diese Dateiendung ist nicht erlaubt."
|
||||
|
||||
#: core/api/viewsets.py:1268
|
||||
#: core/api/viewsets.py:1222
|
||||
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:38
|
||||
#: core/models.py:37
|
||||
msgid "Member"
|
||||
msgstr "Mitglied"
|
||||
|
||||
#: core/models.py:39
|
||||
#: core/models.py:38
|
||||
msgid "Administrator"
|
||||
msgstr "Administrator"
|
||||
|
||||
#: core/models.py:40
|
||||
#: core/models.py:39
|
||||
msgid "Owner"
|
||||
msgstr "Eigentümer"
|
||||
|
||||
#: core/models.py:56
|
||||
#: core/models.py:55
|
||||
msgid "Initiated"
|
||||
msgstr "Gestartet"
|
||||
|
||||
#: core/models.py:57
|
||||
#: core/models.py:56
|
||||
msgid "Active"
|
||||
msgstr "Aktiv"
|
||||
|
||||
#: core/models.py:58
|
||||
#: core/models.py:57
|
||||
msgid "Stopped"
|
||||
msgstr "Beendet"
|
||||
|
||||
#: core/models.py:59
|
||||
#: core/models.py:58
|
||||
msgid "Saved"
|
||||
msgstr "Gespeichert"
|
||||
|
||||
#: core/models.py:60
|
||||
#: core/models.py:59
|
||||
msgid "Aborted"
|
||||
msgstr "Abgebrochen"
|
||||
|
||||
#: core/models.py:61
|
||||
#: core/models.py:60
|
||||
msgid "Failed to Start"
|
||||
msgstr "Start fehlgeschlagen"
|
||||
|
||||
#: core/models.py:62
|
||||
#: core/models.py:61
|
||||
msgid "Failed to Stop"
|
||||
msgstr "Stopp fehlgeschlagen"
|
||||
|
||||
#: core/models.py:63
|
||||
#: core/models.py:62
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Benachrichtigung erfolgreich"
|
||||
|
||||
#: core/models.py:66
|
||||
#: core/models.py:65
|
||||
msgid "External process successful"
|
||||
msgstr "Externer Prozess erfolgreich"
|
||||
|
||||
#: core/models.py:68
|
||||
#: core/models.py:67
|
||||
msgid "External process failed"
|
||||
msgstr "Externer Prozess fehlgeschlagen"
|
||||
|
||||
#: core/models.py:97
|
||||
#: core/models.py:96
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "BILDSCHIRMAUFZEICHNUNG"
|
||||
|
||||
#: core/models.py:98
|
||||
#: core/models.py:97
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSKRIPT"
|
||||
|
||||
#: core/models.py:104
|
||||
#: core/models.py:103
|
||||
msgid "Public Access"
|
||||
msgstr "Öffentlicher Zugriff"
|
||||
|
||||
#: core/models.py:105
|
||||
#: core/models.py:104
|
||||
msgid "Trusted Access"
|
||||
msgstr "Vertrauenswürdiger Zugriff"
|
||||
|
||||
#: core/models.py:106
|
||||
#: core/models.py:105
|
||||
msgid "Restricted Access"
|
||||
msgstr "Eingeschränkter Zugriff"
|
||||
|
||||
#: core/models.py:118
|
||||
#: core/models.py:117
|
||||
msgid "id"
|
||||
msgstr "ID"
|
||||
|
||||
#: core/models.py:119
|
||||
#: core/models.py:118
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "Primärschlüssel des Eintrags als UUID"
|
||||
|
||||
#: core/models.py:125
|
||||
#: core/models.py:124
|
||||
msgid "created on"
|
||||
msgstr "erstellt am"
|
||||
|
||||
#: core/models.py:126
|
||||
#: core/models.py:125
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
|
||||
|
||||
#: core/models.py:131
|
||||
#: core/models.py:130
|
||||
msgid "updated on"
|
||||
msgstr "aktualisiert am"
|
||||
|
||||
#: core/models.py:132
|
||||
#: core/models.py:131
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
|
||||
|
||||
#: core/models.py:150
|
||||
#: core/models.py:151
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
msgstr ""
|
||||
"Geben Sie einen gültigen Sub ein. Dieser Wert darf nur Buchstaben, Zahlen "
|
||||
"und die Zeichen @/./+/-/_ enthalten."
|
||||
|
||||
#: core/models.py:157
|
||||
msgid "sub"
|
||||
msgstr "Sub"
|
||||
|
||||
#: core/models.py:152
|
||||
#: core/models.py:159
|
||||
msgid ""
|
||||
"Optional for pending users; required upon account activation. 255 characters "
|
||||
"or fewer. Printable ASCII characters only."
|
||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
msgstr ""
|
||||
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
|
||||
"Maximal 255 Zeichen. Nur druckbare ASCII-Zeichen erlaubt."
|
||||
"Maximal 255 Zeichen. Nur Buchstaben, Zahlen und @/./+/-/_ Zeichen erlaubt."
|
||||
|
||||
#: core/validators.py:22
|
||||
msgid "Enter a valid sub. This value should be printable ASCII only."
|
||||
msgstr "Geben Sie einen gültigen sub ein. Dieser Wert darf nur druckbare ASCII-Zeichen enthalten."
|
||||
|
||||
#: core/models.py:161
|
||||
#: core/models.py:168
|
||||
msgid "identity email address"
|
||||
msgstr "Identitäts-E-Mail-Adresse"
|
||||
|
||||
#: core/models.py:166
|
||||
#: core/models.py:173
|
||||
msgid "admin email address"
|
||||
msgstr "Administrator-E-Mail-Adresse"
|
||||
|
||||
#: core/models.py:168
|
||||
#: core/models.py:175
|
||||
msgid "full name"
|
||||
msgstr "Vollständiger Name"
|
||||
|
||||
#: core/models.py:170
|
||||
#: core/models.py:177
|
||||
msgid "short name"
|
||||
msgstr "Kurzname"
|
||||
|
||||
#: core/models.py:176
|
||||
#: core/models.py:183
|
||||
msgid "language"
|
||||
msgstr "Sprache"
|
||||
|
||||
#: core/models.py:177
|
||||
#: core/models.py:184
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
|
||||
|
||||
#: core/models.py:183
|
||||
#: core/models.py:190
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
|
||||
|
||||
#: core/models.py:190
|
||||
msgid "default room access level"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:192
|
||||
msgid ""
|
||||
"Access level applied by default to new rooms created by this user. When "
|
||||
"empty, the instance default is used."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:199
|
||||
#, fuzzy
|
||||
#| msgid "Visio room configuration"
|
||||
msgid "default room configuration"
|
||||
msgstr "Visio-Raumkonfiguration"
|
||||
|
||||
#: core/models.py:201
|
||||
msgid "Configurations applied by default to new rooms created by this user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:205
|
||||
#: core/models.py:193
|
||||
msgid "device"
|
||||
msgstr "Gerät"
|
||||
|
||||
#: core/models.py:207
|
||||
#: core/models.py:195
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
|
||||
|
||||
#: core/models.py:210
|
||||
#: core/models.py:198
|
||||
msgid "staff status"
|
||||
msgstr "Mitarbeiterstatus"
|
||||
|
||||
#: core/models.py:212
|
||||
#: core/models.py:200
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
|
||||
|
||||
#: core/models.py:215
|
||||
#: core/models.py:203
|
||||
msgid "active"
|
||||
msgstr "aktiv"
|
||||
|
||||
#: core/models.py:218
|
||||
#: core/models.py:206
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
@@ -312,66 +296,66 @@ msgstr ""
|
||||
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
|
||||
"anstelle des Löschens des Kontos."
|
||||
|
||||
#: core/models.py:231
|
||||
#: core/models.py:219
|
||||
msgid "user"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#: core/models.py:232
|
||||
#: core/models.py:220
|
||||
msgid "users"
|
||||
msgstr "Benutzer"
|
||||
|
||||
#: core/models.py:298
|
||||
#: core/models.py:286
|
||||
msgid "Resource"
|
||||
msgstr "Ressource"
|
||||
|
||||
#: core/models.py:299
|
||||
#: core/models.py:287
|
||||
msgid "Resources"
|
||||
msgstr "Ressourcen"
|
||||
|
||||
#: core/models.py:357
|
||||
#: core/models.py:345
|
||||
msgid "Resource access"
|
||||
msgstr "Ressourcenzugriff"
|
||||
|
||||
#: core/models.py:358
|
||||
#: core/models.py:346
|
||||
msgid "Resource accesses"
|
||||
msgstr "Ressourcenzugriffe"
|
||||
|
||||
#: core/models.py:364
|
||||
#: core/models.py:352
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr ""
|
||||
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
|
||||
"bereits."
|
||||
|
||||
#: core/models.py:421
|
||||
#: core/models.py:409
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Visio-Raumkonfiguration"
|
||||
|
||||
#: core/models.py:422
|
||||
#: core/models.py:410
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
|
||||
|
||||
#: core/models.py:429
|
||||
#: core/models.py:417
|
||||
msgid "Room PIN code"
|
||||
msgstr "PIN-Code für den Raum"
|
||||
|
||||
#: core/models.py:430
|
||||
#: core/models.py:418
|
||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||
msgstr ""
|
||||
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
|
||||
|
||||
#: core/models.py:436 core/models.py:597
|
||||
#: core/models.py:424 core/models.py:578
|
||||
msgid "Room"
|
||||
msgstr "Raum"
|
||||
|
||||
#: core/models.py:437
|
||||
#: core/models.py:425
|
||||
msgid "Rooms"
|
||||
msgstr "Räume"
|
||||
|
||||
#: core/models.py:608
|
||||
#: core/models.py:589
|
||||
msgid "Worker ID"
|
||||
msgstr "Worker-ID"
|
||||
|
||||
#: core/models.py:610
|
||||
#: core/models.py:591
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
@@ -380,153 +364,153 @@ msgstr ""
|
||||
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
|
||||
"ermöglicht."
|
||||
|
||||
#: core/models.py:618
|
||||
#: core/models.py:599
|
||||
msgid "Recording mode"
|
||||
msgstr "Aufzeichnungsmodus"
|
||||
|
||||
#: core/models.py:619
|
||||
#: core/models.py:600
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
|
||||
|
||||
#: core/models.py:624 core/models.py:625
|
||||
#: core/models.py:605 core/models.py:606
|
||||
msgid "Recording options"
|
||||
msgstr "Aufnahmeoptionen"
|
||||
|
||||
#: core/models.py:632
|
||||
#: core/models.py:613
|
||||
msgid "External Process ID"
|
||||
msgstr "External Process ID"
|
||||
|
||||
#: core/models.py:633
|
||||
#: core/models.py:614
|
||||
msgid "ID of the external process associated with the recording."
|
||||
msgstr "ID des externen Prozesses, der mit der Aufzeichnung verknüpft ist"
|
||||
|
||||
#: core/models.py:639
|
||||
#: core/models.py:620
|
||||
msgid "Recording"
|
||||
msgstr "Aufzeichnung"
|
||||
|
||||
#: core/models.py:640
|
||||
#: core/models.py:621
|
||||
msgid "Recordings"
|
||||
msgstr "Aufzeichnungen"
|
||||
|
||||
#: core/models.py:750
|
||||
#: core/models.py:731
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Beziehung Aufzeichnung/Benutzer"
|
||||
|
||||
#: core/models.py:751
|
||||
#: core/models.py:732
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Beziehungen Aufzeichnung/Benutzer"
|
||||
|
||||
#: core/models.py:757
|
||||
#: core/models.py:738
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
|
||||
|
||||
#: core/models.py:763
|
||||
#: core/models.py:744
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
|
||||
|
||||
#: core/models.py:769
|
||||
#: core/models.py:750
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
|
||||
|
||||
#: core/models.py:786
|
||||
#: core/models.py:767
|
||||
msgid "Create rooms"
|
||||
msgstr "Räume erstellen"
|
||||
|
||||
#: core/models.py:787
|
||||
#: core/models.py:768
|
||||
msgid "List rooms"
|
||||
msgstr "Räume auflisten"
|
||||
|
||||
#: core/models.py:788
|
||||
#: core/models.py:769
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Raumdetails abrufen"
|
||||
|
||||
#: core/models.py:789
|
||||
#: core/models.py:770
|
||||
msgid "Update rooms"
|
||||
msgstr "Räume aktualisieren"
|
||||
|
||||
#: core/models.py:790
|
||||
#: core/models.py:771
|
||||
msgid "Delete rooms"
|
||||
msgstr "Räume löschen"
|
||||
|
||||
#: core/models.py:803
|
||||
#: core/models.py:784
|
||||
msgid "Application name"
|
||||
msgstr "Anwendungsname"
|
||||
|
||||
#: core/models.py:804
|
||||
#: core/models.py:785
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Beschreibender Name für diese Anwendung."
|
||||
|
||||
#: core/models.py:814
|
||||
#: core/models.py:795
|
||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||
msgstr ""
|
||||
"Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
|
||||
|
||||
#: core/models.py:825
|
||||
#: core/models.py:806
|
||||
msgid "Application"
|
||||
msgstr "Anwendung"
|
||||
|
||||
#: core/models.py:826
|
||||
#: core/models.py:807
|
||||
msgid "Applications"
|
||||
msgstr "Anwendungen"
|
||||
|
||||
#: core/models.py:849
|
||||
#: core/models.py:830
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Geben Sie eine gültige Domain ein"
|
||||
|
||||
#: core/models.py:852
|
||||
#: core/models.py:833
|
||||
msgid "Domain"
|
||||
msgstr "Domain"
|
||||
|
||||
#: core/models.py:853
|
||||
#: core/models.py:834
|
||||
msgid "Email domain this application can act on behalf of."
|
||||
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
|
||||
|
||||
#: core/models.py:865
|
||||
#: core/models.py:846
|
||||
msgid "Application domain"
|
||||
msgstr "Anwendungsdomain"
|
||||
|
||||
#: core/models.py:866
|
||||
#: core/models.py:847
|
||||
msgid "Application domains"
|
||||
msgstr "Anwendungsdomains"
|
||||
|
||||
#: core/models.py:884
|
||||
#: core/models.py:865
|
||||
msgid "Pending"
|
||||
msgstr "Ausstehend"
|
||||
|
||||
#: core/models.py:885
|
||||
#: core/models.py:866
|
||||
msgid "Analyzing"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:892
|
||||
#: core/models.py:873
|
||||
msgid "Ready"
|
||||
msgstr "Bereit"
|
||||
|
||||
#: core/models.py:898
|
||||
#: core/models.py:879
|
||||
msgid "Background image"
|
||||
msgstr "Hintergrundbild"
|
||||
|
||||
#: core/models.py:910
|
||||
#: core/models.py:891
|
||||
msgid "title"
|
||||
msgstr "Titel"
|
||||
|
||||
#: core/models.py:934
|
||||
#: core/models.py:915
|
||||
msgid "Malware detection info when the analysis status is unsafe."
|
||||
msgstr ""
|
||||
"Informationen zur Malware-Erkennung, wenn der Analyse-Status unsicher ist."
|
||||
|
||||
#: core/models.py:939
|
||||
#: core/models.py:920
|
||||
msgid "File"
|
||||
msgstr "Datei"
|
||||
|
||||
#: core/models.py:940
|
||||
#: core/models.py:921
|
||||
msgid "Files"
|
||||
msgstr "Dateien"
|
||||
|
||||
#: core/models.py:1060
|
||||
#: core/models.py:1041
|
||||
msgid "This file is already hard deleted."
|
||||
msgstr "Diese Datei wurde bereits endgültig gelöscht."
|
||||
|
||||
#: core/models.py:1070
|
||||
#: core/models.py:1051
|
||||
#, 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."
|
||||
@@ -534,15 +518,15 @@ msgstr ""
|
||||
"Um eine Datei endgültig zu löschen, muss sie zuvor weich gelöscht worden "
|
||||
"sein."
|
||||
|
||||
#: core/recording/event/notification.py:124
|
||||
#: core/recording/event/notification.py:123
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Ihre Aufzeichnung ist bereit"
|
||||
|
||||
#: core/recording/event/notification.py:195
|
||||
#: core/recording/event/notification.py:194
|
||||
msgid "Transcription"
|
||||
msgstr "Transkription"
|
||||
|
||||
#: core/recording/event/notification.py:206
|
||||
#: core/recording/event/notification.py:204
|
||||
#, python-brace-format
|
||||
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
||||
msgstr ""
|
||||
@@ -553,25 +537,25 @@ msgstr ""
|
||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||
msgstr "Videoanruf läuft: {sender.email} wartet auf Ihre Teilnahme"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:151
|
||||
#: core/templates/mail/html/screen_recording.html:151
|
||||
#: core/templates/mail/text/invitation.txt:4
|
||||
#: core/templates/mail/text/screen_recording.txt:4
|
||||
#: core/templates/mail/html/invitation.html:159
|
||||
#: core/templates/mail/html/screen_recording.html:159
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
#: core/templates/mail/text/screen_recording.txt:3
|
||||
msgid "Logo email"
|
||||
msgstr "Logo-E-Mail"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:181
|
||||
#: core/templates/mail/text/invitation.txt:6
|
||||
#: core/templates/mail/html/invitation.html:189
|
||||
#: core/templates/mail/text/invitation.txt:5
|
||||
msgid "invites you to join an ongoing video call"
|
||||
msgstr "lädt Sie zu einem laufenden Videoanruf ein"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:192
|
||||
#: core/templates/mail/text/invitation.txt:8
|
||||
#: core/templates/mail/html/invitation.html:200
|
||||
#: core/templates/mail/text/invitation.txt:7
|
||||
msgid "JOIN THE CALL"
|
||||
msgstr "AM ANRUF TEILNEHMEN"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:219
|
||||
#: core/templates/mail/text/invitation.txt:14
|
||||
#: core/templates/mail/html/invitation.html:227
|
||||
#: core/templates/mail/text/invitation.txt:13
|
||||
msgid ""
|
||||
"If you can't click the button, copy and paste the URL into your browser to "
|
||||
"join the call."
|
||||
@@ -579,47 +563,41 @@ msgstr ""
|
||||
"Wenn Sie den Button nicht anklicken können, kopieren Sie die URL und fügen "
|
||||
"Sie sie in Ihren Browser ein, um am Anruf teilzunehmen."
|
||||
|
||||
#: core/templates/mail/html/invitation.html:227
|
||||
#: core/templates/mail/text/invitation.txt:16
|
||||
#: core/templates/mail/html/invitation.html:235
|
||||
#: core/templates/mail/text/invitation.txt:15
|
||||
msgid "Tips for a better experience:"
|
||||
msgstr "Tipps für ein besseres Erlebnis:"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:229
|
||||
#: core/templates/mail/text/invitation.txt:18
|
||||
#: core/templates/mail/html/invitation.html:237
|
||||
#: core/templates/mail/text/invitation.txt:17
|
||||
msgid "Use Chrome or Firefox for better call quality"
|
||||
msgstr "Verwenden Sie Chrome oder Firefox für eine bessere Anrufqualität"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:230
|
||||
#: core/templates/mail/text/invitation.txt:19
|
||||
#: core/templates/mail/html/invitation.html:238
|
||||
#: core/templates/mail/text/invitation.txt:18
|
||||
msgid "Test your microphone and camera before joining"
|
||||
msgstr "Testen Sie Ihr Mikrofon und Ihre Kamera vor dem Beitritt"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:231
|
||||
#: core/templates/mail/text/invitation.txt:20
|
||||
#: core/templates/mail/html/invitation.html:239
|
||||
#: core/templates/mail/text/invitation.txt:19
|
||||
msgid "Make sure you have a stable internet connection"
|
||||
msgstr "Stellen Sie sicher, dass Sie eine stabile Internetverbindung haben"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:240
|
||||
#: core/templates/mail/html/screen_recording.html:237
|
||||
#: core/templates/mail/text/invitation.txt:22
|
||||
#: core/templates/mail/text/screen_recording.txt:24
|
||||
#: core/templates/mail/html/invitation.html:248
|
||||
#: core/templates/mail/html/screen_recording.html:245
|
||||
#: core/templates/mail/text/invitation.txt:21
|
||||
#: core/templates/mail/text/screen_recording.txt:23
|
||||
#, python-format
|
||||
msgid " Thank you for using %(brandname)s. "
|
||||
msgstr " Vielen Dank für die Nutzung von %(brandname)s. "
|
||||
|
||||
#: core/templates/mail/html/invitation.html:271
|
||||
#, python-format
|
||||
msgid ""
|
||||
"This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:180
|
||||
#: core/templates/mail/text/screen_recording.txt:7
|
||||
#: core/templates/mail/html/screen_recording.html:188
|
||||
#: core/templates/mail/text/screen_recording.txt:6
|
||||
msgid "Your recording is ready!"
|
||||
msgstr "Ihre Aufzeichnung ist fertig!"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:187
|
||||
#: core/templates/mail/text/screen_recording.txt:9
|
||||
#: core/templates/mail/html/screen_recording.html:195
|
||||
#: core/templates/mail/text/screen_recording.txt:8
|
||||
#, python-format
|
||||
msgid ""
|
||||
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
|
||||
@@ -628,14 +606,14 @@ msgstr ""
|
||||
" Ihre Aufzeichnung von \"%(room_name)s\" am %(recording_date)s um "
|
||||
"%(recording_time)s steht nun zum Herunterladen bereit. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:187
|
||||
#: core/templates/mail/text/screen_recording.txt:9
|
||||
#: core/templates/mail/html/screen_recording.html:195
|
||||
#: core/templates/mail/text/screen_recording.txt:8
|
||||
#, python-format
|
||||
msgid " The recording will expire in %(days)s days. "
|
||||
msgstr " Die Aufzeichnung wird in %(days)s Tagen ablaufen. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:192
|
||||
#: core/templates/mail/text/screen_recording.txt:10
|
||||
#: core/templates/mail/html/screen_recording.html:200
|
||||
#: core/templates/mail/text/screen_recording.txt:9
|
||||
msgid ""
|
||||
" Sharing the recording via link is not yet available. Only organizers can "
|
||||
"download it. "
|
||||
@@ -643,33 +621,33 @@ msgstr ""
|
||||
" Die Freigabe der Aufzeichnung per Link ist noch nicht verfügbar. Nur "
|
||||
"Organisatoren können sie herunterladen. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:198
|
||||
#: core/templates/mail/text/screen_recording.txt:12
|
||||
#: core/templates/mail/html/screen_recording.html:206
|
||||
#: core/templates/mail/text/screen_recording.txt:11
|
||||
msgid "To keep this recording permanently:"
|
||||
msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:200
|
||||
#: core/templates/mail/html/screen_recording.html:208
|
||||
#, python-format
|
||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:201
|
||||
#: core/templates/mail/text/screen_recording.txt:15
|
||||
#: core/templates/mail/html/screen_recording.html:209
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
msgid "Use the \"Download\" button in the interface "
|
||||
msgstr "Verwenden Sie den Button „Herunterladen“ in der Oberfläche "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:202
|
||||
#: core/templates/mail/text/screen_recording.txt:16
|
||||
#: core/templates/mail/html/screen_recording.html:210
|
||||
#: core/templates/mail/text/screen_recording.txt:15
|
||||
msgid "Save the file to your preferred location"
|
||||
msgstr "Speichern Sie die Datei an einem gewünschten Ort"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:213
|
||||
#: core/templates/mail/text/screen_recording.txt:18
|
||||
#: core/templates/mail/html/screen_recording.html:221
|
||||
#: core/templates/mail/text/screen_recording.txt:17
|
||||
msgid "Open"
|
||||
msgstr "Öffnen"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:222
|
||||
#: core/templates/mail/text/screen_recording.txt:20
|
||||
#: core/templates/mail/html/screen_recording.html:230
|
||||
#: core/templates/mail/text/screen_recording.txt:19
|
||||
#, python-format
|
||||
msgid ""
|
||||
" If you have any questions or need assistance, please contact our support "
|
||||
@@ -678,33 +656,28 @@ msgstr ""
|
||||
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
|
||||
"an unser Support-Team unter %(support_email)s. "
|
||||
|
||||
#: core/templates/mail/text/invitation.txt:24
|
||||
#, python-format
|
||||
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgid "Click the \"Open [%(link)s]\" link below "
|
||||
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
|
||||
|
||||
#: meet/settings.py:238
|
||||
#: meet/settings.py:228
|
||||
msgid "English"
|
||||
msgstr "Englisch"
|
||||
|
||||
#: meet/settings.py:239
|
||||
#: meet/settings.py:229
|
||||
msgid "French"
|
||||
msgstr "Französisch"
|
||||
|
||||
#: meet/settings.py:240
|
||||
#: meet/settings.py:230
|
||||
msgid "Dutch"
|
||||
msgstr "Niederländisch"
|
||||
|
||||
#: meet/settings.py:241
|
||||
#: meet/settings.py:231
|
||||
msgid "German"
|
||||
msgstr "Deutsch"
|
||||
|
||||
#: meet/settings.py:242
|
||||
#: meet/settings.py:233
|
||||
msgid "Spanish"
|
||||
msgstr "Spanisch"
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-09-02 22:52+0000\n"
|
||||
"POT-Creation-Date: 2026-07-02 10:47+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -51,11 +51,11 @@ msgstr ""
|
||||
msgid "File preview"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:300 core/admin.py:450
|
||||
#: core/admin.py:300 core/admin.py:443
|
||||
msgid "No owner"
|
||||
msgstr "No owner"
|
||||
|
||||
#: core/admin.py:303 core/admin.py:453
|
||||
#: core/admin.py:303 core/admin.py:446
|
||||
msgid "Multiple owners"
|
||||
msgstr "Multiple owners"
|
||||
|
||||
@@ -98,11 +98,11 @@ msgstr "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
||||
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
||||
msgstr "Skipped %(count)s expired recording(s)."
|
||||
|
||||
#: core/admin.py:517
|
||||
#: core/admin.py:510
|
||||
msgid "No scopes"
|
||||
msgstr "No scopes"
|
||||
|
||||
#: core/admin.py:519
|
||||
#: core/admin.py:512
|
||||
msgid "Scopes"
|
||||
msgstr "Scopes"
|
||||
|
||||
@@ -110,199 +110,183 @@ msgstr "Scopes"
|
||||
msgid "Creator is me"
|
||||
msgstr "Creator is me"
|
||||
|
||||
#: core/api/serializers.py:108
|
||||
#: core/api/serializers.py:89
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr "You must be administrator or owner of a room to add accesses to it."
|
||||
|
||||
#: core/api/serializers.py:560
|
||||
#: core/api/serializers.py:534
|
||||
msgid "This file extension is not allowed."
|
||||
msgstr "This file extension is not allowed."
|
||||
|
||||
#: core/api/viewsets.py:1268
|
||||
#: core/api/viewsets.py:1222
|
||||
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:38
|
||||
#: core/models.py:37
|
||||
msgid "Member"
|
||||
msgstr "Member"
|
||||
|
||||
#: core/models.py:39
|
||||
#: core/models.py:38
|
||||
msgid "Administrator"
|
||||
msgstr "Administrator"
|
||||
|
||||
#: core/models.py:40
|
||||
#: core/models.py:39
|
||||
msgid "Owner"
|
||||
msgstr "Owner"
|
||||
|
||||
#: core/models.py:56
|
||||
#: core/models.py:55
|
||||
msgid "Initiated"
|
||||
msgstr "Initiated"
|
||||
|
||||
#: core/models.py:57
|
||||
#: core/models.py:56
|
||||
msgid "Active"
|
||||
msgstr "Active"
|
||||
|
||||
#: core/models.py:58
|
||||
#: core/models.py:57
|
||||
msgid "Stopped"
|
||||
msgstr "Stopped"
|
||||
|
||||
#: core/models.py:59
|
||||
#: core/models.py:58
|
||||
msgid "Saved"
|
||||
msgstr "Saved"
|
||||
|
||||
#: core/models.py:60
|
||||
#: core/models.py:59
|
||||
msgid "Aborted"
|
||||
msgstr "Aborted"
|
||||
|
||||
#: core/models.py:61
|
||||
#: core/models.py:60
|
||||
msgid "Failed to Start"
|
||||
msgstr "Failed to Start"
|
||||
|
||||
#: core/models.py:62
|
||||
#: core/models.py:61
|
||||
msgid "Failed to Stop"
|
||||
msgstr "Failed to Stop"
|
||||
|
||||
#: core/models.py:63
|
||||
#: core/models.py:62
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Notification succeeded"
|
||||
|
||||
#: core/models.py:66
|
||||
#: core/models.py:65
|
||||
msgid "External process successful"
|
||||
msgstr "External process successful"
|
||||
|
||||
#: core/models.py:68
|
||||
#: core/models.py:67
|
||||
msgid "External process failed"
|
||||
msgstr "External process failed"
|
||||
|
||||
#: core/models.py:97
|
||||
#: core/models.py:96
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "SCREEN_RECORDING"
|
||||
|
||||
#: core/models.py:98
|
||||
#: core/models.py:97
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSCRIPT"
|
||||
|
||||
#: core/models.py:104
|
||||
#: core/models.py:103
|
||||
msgid "Public Access"
|
||||
msgstr "Public Access"
|
||||
|
||||
#: core/models.py:105
|
||||
#: core/models.py:104
|
||||
msgid "Trusted Access"
|
||||
msgstr "Trusted Access"
|
||||
|
||||
#: core/models.py:106
|
||||
#: core/models.py:105
|
||||
msgid "Restricted Access"
|
||||
msgstr "Restricted Access"
|
||||
|
||||
#: core/models.py:118
|
||||
#: core/models.py:117
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: core/models.py:119
|
||||
#: core/models.py:118
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "primary key for the record as UUID"
|
||||
|
||||
#: core/models.py:125
|
||||
#: core/models.py:124
|
||||
msgid "created on"
|
||||
msgstr "created on"
|
||||
|
||||
#: core/models.py:126
|
||||
#: core/models.py:125
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "date and time at which a record was created"
|
||||
|
||||
#: core/models.py:131
|
||||
#: core/models.py:130
|
||||
msgid "updated on"
|
||||
msgstr "updated on"
|
||||
|
||||
#: core/models.py:132
|
||||
#: core/models.py:131
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "date and time at which a record was last updated"
|
||||
|
||||
#: core/models.py:150
|
||||
#: core/models.py:151
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
msgstr ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
|
||||
#: core/models.py:157
|
||||
msgid "sub"
|
||||
msgstr "sub"
|
||||
|
||||
#: core/models.py:152
|
||||
#: core/models.py:159
|
||||
msgid ""
|
||||
"Optional for pending users; required upon account activation. 255 characters "
|
||||
"or fewer. Printable ASCII characters only."
|
||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
msgstr ""
|
||||
"Optional for pending users; required upon account activation. 255 characters "
|
||||
"or fewer. Printable ASCII characters only."
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
|
||||
#: core/validators.py:22
|
||||
msgid "Enter a valid sub. This value should be printable ASCII only."
|
||||
msgstr "Enter a valid sub. This value should be printable ASCII only."
|
||||
|
||||
#: core/models.py:161
|
||||
#: core/models.py:168
|
||||
msgid "identity email address"
|
||||
msgstr "identity email address"
|
||||
|
||||
#: core/models.py:166
|
||||
#: core/models.py:173
|
||||
msgid "admin email address"
|
||||
msgstr "admin email address"
|
||||
|
||||
#: core/models.py:168
|
||||
#: core/models.py:175
|
||||
msgid "full name"
|
||||
msgstr "full name"
|
||||
|
||||
#: core/models.py:170
|
||||
#: core/models.py:177
|
||||
msgid "short name"
|
||||
msgstr "short name"
|
||||
|
||||
#: core/models.py:176
|
||||
#: core/models.py:183
|
||||
msgid "language"
|
||||
msgstr "language"
|
||||
|
||||
#: core/models.py:177
|
||||
#: core/models.py:184
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "The language in which the user wants to see the interface."
|
||||
|
||||
#: core/models.py:183
|
||||
#: core/models.py:190
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "The timezone in which the user wants to see times."
|
||||
|
||||
#: core/models.py:190
|
||||
msgid "default room access level"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:192
|
||||
msgid ""
|
||||
"Access level applied by default to new rooms created by this user. When "
|
||||
"empty, the instance default is used."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:199
|
||||
#, fuzzy
|
||||
#| msgid "Visio room configuration"
|
||||
msgid "default room configuration"
|
||||
msgstr "Visio room configuration"
|
||||
|
||||
#: core/models.py:201
|
||||
msgid "Configurations applied by default to new rooms created by this user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:205
|
||||
#: core/models.py:193
|
||||
msgid "device"
|
||||
msgstr "device"
|
||||
|
||||
#: core/models.py:207
|
||||
#: core/models.py:195
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Whether the user is a device or a real user."
|
||||
|
||||
#: core/models.py:210
|
||||
#: core/models.py:198
|
||||
msgid "staff status"
|
||||
msgstr "staff status"
|
||||
|
||||
#: core/models.py:212
|
||||
#: core/models.py:200
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Whether the user can log into this admin site."
|
||||
|
||||
#: core/models.py:215
|
||||
#: core/models.py:203
|
||||
msgid "active"
|
||||
msgstr "active"
|
||||
|
||||
#: core/models.py:218
|
||||
#: core/models.py:206
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
@@ -310,63 +294,63 @@ msgstr ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
|
||||
#: core/models.py:231
|
||||
#: core/models.py:219
|
||||
msgid "user"
|
||||
msgstr "user"
|
||||
|
||||
#: core/models.py:232
|
||||
#: core/models.py:220
|
||||
msgid "users"
|
||||
msgstr "users"
|
||||
|
||||
#: core/models.py:298
|
||||
#: core/models.py:286
|
||||
msgid "Resource"
|
||||
msgstr "Resource"
|
||||
|
||||
#: core/models.py:299
|
||||
#: core/models.py:287
|
||||
msgid "Resources"
|
||||
msgstr "Resources"
|
||||
|
||||
#: core/models.py:357
|
||||
#: core/models.py:345
|
||||
msgid "Resource access"
|
||||
msgstr "Resource access"
|
||||
|
||||
#: core/models.py:358
|
||||
#: core/models.py:346
|
||||
msgid "Resource accesses"
|
||||
msgstr "Resource accesses"
|
||||
|
||||
#: core/models.py:364
|
||||
#: core/models.py:352
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr "Resource access with this User and Resource already exists."
|
||||
|
||||
#: core/models.py:421
|
||||
#: core/models.py:409
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Visio room configuration"
|
||||
|
||||
#: core/models.py:422
|
||||
#: core/models.py:410
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr "Values for Visio parameters to configure the room."
|
||||
|
||||
#: core/models.py:429
|
||||
#: core/models.py:417
|
||||
msgid "Room PIN code"
|
||||
msgstr "Room PIN code"
|
||||
|
||||
#: core/models.py:430
|
||||
#: core/models.py:418
|
||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||
msgstr "Unique n-digit code that identifies this room in telephony mode."
|
||||
|
||||
#: core/models.py:436 core/models.py:597
|
||||
#: core/models.py:424 core/models.py:578
|
||||
msgid "Room"
|
||||
msgstr "Room"
|
||||
|
||||
#: core/models.py:437
|
||||
#: core/models.py:425
|
||||
msgid "Rooms"
|
||||
msgstr "Rooms"
|
||||
|
||||
#: core/models.py:608
|
||||
#: core/models.py:589
|
||||
msgid "Worker ID"
|
||||
msgstr "Worker ID"
|
||||
|
||||
#: core/models.py:610
|
||||
#: core/models.py:591
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
@@ -374,171 +358,171 @@ msgstr ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
|
||||
#: core/models.py:618
|
||||
#: core/models.py:599
|
||||
msgid "Recording mode"
|
||||
msgstr "Recording mode"
|
||||
|
||||
#: core/models.py:619
|
||||
#: core/models.py:600
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Defines the mode of recording being called."
|
||||
|
||||
#: core/models.py:624 core/models.py:625
|
||||
#: core/models.py:605 core/models.py:606
|
||||
msgid "Recording options"
|
||||
msgstr "Recording options"
|
||||
|
||||
#: core/models.py:632
|
||||
#: core/models.py:613
|
||||
msgid "External Process ID"
|
||||
msgstr "External Process ID"
|
||||
|
||||
#: core/models.py:633
|
||||
#: core/models.py:614
|
||||
msgid "ID of the external process associated with the recording."
|
||||
msgstr "ID of the external process associated with the recording."
|
||||
|
||||
#: core/models.py:639
|
||||
#: core/models.py:620
|
||||
msgid "Recording"
|
||||
msgstr "Recording"
|
||||
|
||||
#: core/models.py:640
|
||||
#: core/models.py:621
|
||||
msgid "Recordings"
|
||||
msgstr "Recordings"
|
||||
|
||||
#: core/models.py:750
|
||||
#: core/models.py:731
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Recording/user relation"
|
||||
|
||||
#: core/models.py:751
|
||||
#: core/models.py:732
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Recording/user relations"
|
||||
|
||||
#: core/models.py:757
|
||||
#: core/models.py:738
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "This user is already in this recording."
|
||||
|
||||
#: core/models.py:763
|
||||
#: core/models.py:744
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "This team is already in this recording."
|
||||
|
||||
#: core/models.py:769
|
||||
#: core/models.py:750
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Either user or team must be set, not both."
|
||||
|
||||
#: core/models.py:786
|
||||
#: core/models.py:767
|
||||
#, fuzzy
|
||||
#| msgid "created on"
|
||||
msgid "Create rooms"
|
||||
msgstr "Create rooms"
|
||||
|
||||
#: core/models.py:787
|
||||
#: core/models.py:768
|
||||
msgid "List rooms"
|
||||
msgstr "List rooms"
|
||||
|
||||
#: core/models.py:788
|
||||
#: core/models.py:769
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Retrieve room details"
|
||||
|
||||
#: core/models.py:789
|
||||
#: core/models.py:770
|
||||
#, fuzzy
|
||||
#| msgid "updated on"
|
||||
msgid "Update rooms"
|
||||
msgstr "Update rooms"
|
||||
|
||||
#: core/models.py:790
|
||||
#: core/models.py:771
|
||||
msgid "Delete rooms"
|
||||
msgstr "Delete rooms"
|
||||
|
||||
#: core/models.py:803
|
||||
#: core/models.py:784
|
||||
msgid "Application name"
|
||||
msgstr "Application name"
|
||||
|
||||
#: core/models.py:804
|
||||
#: core/models.py:785
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Descriptive name for this application."
|
||||
|
||||
#: core/models.py:814
|
||||
#: core/models.py:795
|
||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||
msgstr "Hashed on Save. Copy it now if this is a new secret."
|
||||
|
||||
#: core/models.py:825
|
||||
#: core/models.py:806
|
||||
msgid "Application"
|
||||
msgstr "Application"
|
||||
|
||||
#: core/models.py:826
|
||||
#: core/models.py:807
|
||||
msgid "Applications"
|
||||
msgstr "Applications"
|
||||
|
||||
#: core/models.py:849
|
||||
#: core/models.py:830
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Enter a valid domain"
|
||||
|
||||
#: core/models.py:852
|
||||
#: core/models.py:833
|
||||
msgid "Domain"
|
||||
msgstr "Domain"
|
||||
|
||||
#: core/models.py:853
|
||||
#: core/models.py:834
|
||||
msgid "Email domain this application can act on behalf of."
|
||||
msgstr "Email domain this application can act on behalf of."
|
||||
|
||||
#: core/models.py:865
|
||||
#: core/models.py:846
|
||||
msgid "Application domain"
|
||||
msgstr "Application domain"
|
||||
|
||||
#: core/models.py:866
|
||||
#: core/models.py:847
|
||||
msgid "Application domains"
|
||||
msgstr "Application domains"
|
||||
|
||||
#: core/models.py:884
|
||||
#: core/models.py:865
|
||||
#, fuzzy
|
||||
#| msgid "Recording"
|
||||
msgid "Pending"
|
||||
msgstr "Pending"
|
||||
|
||||
#: core/models.py:885
|
||||
#: core/models.py:866
|
||||
msgid "Analyzing"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:892
|
||||
#: core/models.py:873
|
||||
msgid "Ready"
|
||||
msgstr "Ready"
|
||||
|
||||
#: core/models.py:898
|
||||
#: core/models.py:879
|
||||
msgid "Background image"
|
||||
msgstr "Background image"
|
||||
|
||||
#: core/models.py:910
|
||||
#: core/models.py:891
|
||||
msgid "title"
|
||||
msgstr "title"
|
||||
|
||||
#: core/models.py:934
|
||||
#: core/models.py:915
|
||||
msgid "Malware detection info when the analysis status is unsafe."
|
||||
msgstr "Malware detection info when the analysis status is unsafe."
|
||||
|
||||
#: core/models.py:939
|
||||
#: core/models.py:920
|
||||
msgid "File"
|
||||
msgstr "File"
|
||||
|
||||
#: core/models.py:940
|
||||
#: core/models.py:921
|
||||
msgid "Files"
|
||||
msgstr "Files"
|
||||
|
||||
#: core/models.py:1060
|
||||
#: core/models.py:1041
|
||||
#, 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:1070
|
||||
#: core/models.py:1051
|
||||
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:124
|
||||
#: core/recording/event/notification.py:123
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Your recording is ready"
|
||||
|
||||
#: core/recording/event/notification.py:195
|
||||
#: core/recording/event/notification.py:194
|
||||
msgid "Transcription"
|
||||
msgstr "Transcription"
|
||||
|
||||
#: core/recording/event/notification.py:206
|
||||
#: core/recording/event/notification.py:204
|
||||
#, python-brace-format
|
||||
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
||||
msgstr "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
||||
@@ -548,25 +532,25 @@ msgstr "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
||||
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"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:151
|
||||
#: core/templates/mail/html/screen_recording.html:151
|
||||
#: core/templates/mail/text/invitation.txt:4
|
||||
#: core/templates/mail/text/screen_recording.txt:4
|
||||
#: core/templates/mail/html/invitation.html:159
|
||||
#: core/templates/mail/html/screen_recording.html:159
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
#: core/templates/mail/text/screen_recording.txt:3
|
||||
msgid "Logo email"
|
||||
msgstr "Logo email"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:181
|
||||
#: core/templates/mail/text/invitation.txt:6
|
||||
#: core/templates/mail/html/invitation.html:189
|
||||
#: core/templates/mail/text/invitation.txt:5
|
||||
msgid "invites you to join an ongoing video call"
|
||||
msgstr "invites you to join an ongoing video call"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:192
|
||||
#: core/templates/mail/text/invitation.txt:8
|
||||
#: core/templates/mail/html/invitation.html:200
|
||||
#: core/templates/mail/text/invitation.txt:7
|
||||
msgid "JOIN THE CALL"
|
||||
msgstr "JOIN THE CALL"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:219
|
||||
#: core/templates/mail/text/invitation.txt:14
|
||||
#: core/templates/mail/html/invitation.html:227
|
||||
#: core/templates/mail/text/invitation.txt:13
|
||||
msgid ""
|
||||
"If you can't click the button, copy and paste the URL into your browser to "
|
||||
"join the call."
|
||||
@@ -574,47 +558,41 @@ msgstr ""
|
||||
"If you can't click the button, copy and paste the URL into your browser to "
|
||||
"join the call."
|
||||
|
||||
#: core/templates/mail/html/invitation.html:227
|
||||
#: core/templates/mail/text/invitation.txt:16
|
||||
#: core/templates/mail/html/invitation.html:235
|
||||
#: core/templates/mail/text/invitation.txt:15
|
||||
msgid "Tips for a better experience:"
|
||||
msgstr "Tips for a better experience:"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:229
|
||||
#: core/templates/mail/text/invitation.txt:18
|
||||
#: core/templates/mail/html/invitation.html:237
|
||||
#: core/templates/mail/text/invitation.txt:17
|
||||
msgid "Use Chrome or Firefox for better call quality"
|
||||
msgstr "Use Chrome or Firefox for better call quality"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:230
|
||||
#: core/templates/mail/text/invitation.txt:19
|
||||
#: core/templates/mail/html/invitation.html:238
|
||||
#: core/templates/mail/text/invitation.txt:18
|
||||
msgid "Test your microphone and camera before joining"
|
||||
msgstr "Test your microphone and camera before joining"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:231
|
||||
#: core/templates/mail/text/invitation.txt:20
|
||||
#: core/templates/mail/html/invitation.html:239
|
||||
#: core/templates/mail/text/invitation.txt:19
|
||||
msgid "Make sure you have a stable internet connection"
|
||||
msgstr "Make sure you have a stable internet connection"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:240
|
||||
#: core/templates/mail/html/screen_recording.html:237
|
||||
#: core/templates/mail/text/invitation.txt:22
|
||||
#: core/templates/mail/text/screen_recording.txt:24
|
||||
#: core/templates/mail/html/invitation.html:248
|
||||
#: core/templates/mail/html/screen_recording.html:245
|
||||
#: core/templates/mail/text/invitation.txt:21
|
||||
#: core/templates/mail/text/screen_recording.txt:23
|
||||
#, python-format
|
||||
msgid " Thank you for using %(brandname)s. "
|
||||
msgstr " Thank you for using %(brandname)s. "
|
||||
|
||||
#: core/templates/mail/html/invitation.html:271
|
||||
#, python-format
|
||||
msgid ""
|
||||
"This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:180
|
||||
#: core/templates/mail/text/screen_recording.txt:7
|
||||
#: core/templates/mail/html/screen_recording.html:188
|
||||
#: core/templates/mail/text/screen_recording.txt:6
|
||||
msgid "Your recording is ready!"
|
||||
msgstr "Your recording is ready!"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:187
|
||||
#: core/templates/mail/text/screen_recording.txt:9
|
||||
#: core/templates/mail/html/screen_recording.html:195
|
||||
#: core/templates/mail/text/screen_recording.txt:8
|
||||
#, python-format
|
||||
msgid ""
|
||||
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
|
||||
@@ -623,14 +601,14 @@ msgstr ""
|
||||
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
|
||||
"%(recording_time)s is now ready to download. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:187
|
||||
#: core/templates/mail/text/screen_recording.txt:9
|
||||
#: core/templates/mail/html/screen_recording.html:195
|
||||
#: core/templates/mail/text/screen_recording.txt:8
|
||||
#, python-format
|
||||
msgid " The recording will expire in %(days)s days. "
|
||||
msgstr " The recording will expire in %(days)s days. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:192
|
||||
#: core/templates/mail/text/screen_recording.txt:10
|
||||
#: core/templates/mail/html/screen_recording.html:200
|
||||
#: core/templates/mail/text/screen_recording.txt:9
|
||||
msgid ""
|
||||
" Sharing the recording via link is not yet available. Only organizers can "
|
||||
"download it. "
|
||||
@@ -638,33 +616,33 @@ msgstr ""
|
||||
" Sharing the recording via link is not yet available. Only organizers can "
|
||||
"download it. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:198
|
||||
#: core/templates/mail/text/screen_recording.txt:12
|
||||
#: core/templates/mail/html/screen_recording.html:206
|
||||
#: core/templates/mail/text/screen_recording.txt:11
|
||||
msgid "To keep this recording permanently:"
|
||||
msgstr "To keep this recording permanently:"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:200
|
||||
#: core/templates/mail/html/screen_recording.html:208
|
||||
#, python-format
|
||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:201
|
||||
#: core/templates/mail/text/screen_recording.txt:15
|
||||
#: core/templates/mail/html/screen_recording.html:209
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
msgid "Use the \"Download\" button in the interface "
|
||||
msgstr "Use the \"Download\" button in the interface "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:202
|
||||
#: core/templates/mail/text/screen_recording.txt:16
|
||||
#: core/templates/mail/html/screen_recording.html:210
|
||||
#: core/templates/mail/text/screen_recording.txt:15
|
||||
msgid "Save the file to your preferred location"
|
||||
msgstr "Save the file to your preferred location"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:213
|
||||
#: core/templates/mail/text/screen_recording.txt:18
|
||||
#: core/templates/mail/html/screen_recording.html:221
|
||||
#: core/templates/mail/text/screen_recording.txt:17
|
||||
msgid "Open"
|
||||
msgstr "Open"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:222
|
||||
#: core/templates/mail/text/screen_recording.txt:20
|
||||
#: core/templates/mail/html/screen_recording.html:230
|
||||
#: core/templates/mail/text/screen_recording.txt:19
|
||||
#, python-format
|
||||
msgid ""
|
||||
" If you have any questions or need assistance, please contact our support "
|
||||
@@ -673,33 +651,28 @@ msgstr ""
|
||||
" If you have any questions or need assistance, please contact our support "
|
||||
"team at %(support_email)s. "
|
||||
|
||||
#: core/templates/mail/text/invitation.txt:24
|
||||
#, python-format
|
||||
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgid "Click the \"Open [%(link)s]\" link below "
|
||||
msgstr "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
|
||||
#: meet/settings.py:238
|
||||
#: meet/settings.py:228
|
||||
msgid "English"
|
||||
msgstr "English"
|
||||
|
||||
#: meet/settings.py:239
|
||||
#: meet/settings.py:229
|
||||
msgid "French"
|
||||
msgstr "French"
|
||||
|
||||
#: meet/settings.py:240
|
||||
#: meet/settings.py:230
|
||||
msgid "Dutch"
|
||||
msgstr "Dutch"
|
||||
|
||||
#: meet/settings.py:241
|
||||
#: meet/settings.py:231
|
||||
msgid "German"
|
||||
msgstr "German"
|
||||
|
||||
#: meet/settings.py:242
|
||||
#: meet/settings.py:233
|
||||
msgid "Spanish"
|
||||
msgstr "Spanish"
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-09-02 22:52+0000\n"
|
||||
"POT-Creation-Date: 2026-07-02 10:47+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -41,20 +41,20 @@ msgstr "Eliminar Salas"
|
||||
msgid "Derived info"
|
||||
msgstr ""
|
||||
|
||||
# 'Marcas de tiempo' is a bad translation for spanish
|
||||
#: core/admin.py:237
|
||||
# 'Marcas de tiempo' is a bad translation for spanish
|
||||
msgid "Timestamps"
|
||||
msgstr ""
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:240
|
||||
msgid "File preview"
|
||||
msgstr "Vista previa"
|
||||
|
||||
#: core/admin.py:300 core/admin.py:450
|
||||
#: core/admin.py:300 core/admin.py:443
|
||||
msgid "No owner"
|
||||
msgstr "Sin propietario"
|
||||
|
||||
#: core/admin.py:303 core/admin.py:453
|
||||
#: core/admin.py:303 core/admin.py:446
|
||||
msgid "Multiple owners"
|
||||
msgstr "Varios propietarios"
|
||||
|
||||
@@ -89,19 +89,18 @@ msgstr "Marcar las grabaciones seleccionadas como «Error al detener»"
|
||||
#: core/admin.py:386
|
||||
#, python-format
|
||||
msgid "%(count)s recording(s) successfully marked as 'Failed to Stop'."
|
||||
msgstr ""
|
||||
"%(count)s grabación(es) marcada(s) correctamente como «Error al detener»."
|
||||
msgstr "%(count)s grabación(es) marcada(s) correctamente como «Error al detener»."
|
||||
|
||||
#: core/admin.py:394
|
||||
#, python-format
|
||||
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
||||
msgstr "Se han omitido %(count)s grabación(es) con un estado no elegible."
|
||||
|
||||
#: core/admin.py:517
|
||||
#: core/admin.py:510
|
||||
msgid "No scopes"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:519
|
||||
#: core/admin.py:512
|
||||
msgid "Scopes"
|
||||
msgstr "Ámbitos"
|
||||
|
||||
@@ -109,599 +108,545 @@ msgstr "Ámbitos"
|
||||
msgid "Creator is me"
|
||||
msgstr "Yo soy el creador"
|
||||
|
||||
#: core/api/serializers.py:108
|
||||
#: core/api/serializers.py:89
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr ""
|
||||
"Debes ser administrador o propietario de una reunión para añadirle accesos."
|
||||
msgstr "Debes ser administrador o propietario de una reunión para añadirle accesos."
|
||||
|
||||
#: core/api/serializers.py:560
|
||||
#: core/api/serializers.py:534
|
||||
msgid "This file extension is not allowed."
|
||||
msgstr "Esta extensión de archivo no está permitida."
|
||||
|
||||
#: core/api/viewsets.py:1268
|
||||
#: core/api/viewsets.py:1222
|
||||
msgid "You have reached the maximum number of files for this type."
|
||||
msgstr "Has alcanzado el número máximo de archivos de este tipo."
|
||||
|
||||
#: core/models.py:38
|
||||
#: core/models.py:37
|
||||
msgid "Member"
|
||||
msgstr "Miembro"
|
||||
|
||||
#: core/models.py:39
|
||||
#: core/models.py:38
|
||||
msgid "Administrator"
|
||||
msgstr "Administrador"
|
||||
|
||||
#: core/models.py:40
|
||||
#: core/models.py:39
|
||||
msgid "Owner"
|
||||
msgstr "Propietario"
|
||||
|
||||
#: core/models.py:55
|
||||
# To check here and following lines for gender agreement (Masculine/Feminine)
|
||||
#: core/models.py:56
|
||||
msgid "Initiated"
|
||||
msgstr "Iniciada"
|
||||
|
||||
#: core/models.py:57
|
||||
#: core/models.py:56
|
||||
msgid "Active"
|
||||
msgstr "Activa"
|
||||
|
||||
#: core/models.py:58
|
||||
#: core/models.py:57
|
||||
msgid "Stopped"
|
||||
msgstr "Detenida"
|
||||
|
||||
#: core/models.py:59
|
||||
#: core/models.py:58
|
||||
msgid "Saved"
|
||||
msgstr "Guardada"
|
||||
|
||||
#: core/models.py:60
|
||||
#: core/models.py:59
|
||||
msgid "Aborted"
|
||||
msgstr "Cancelada"
|
||||
|
||||
#: core/models.py:61
|
||||
#: core/models.py:60
|
||||
msgid "Failed to Start"
|
||||
msgstr "Error al iniciar"
|
||||
|
||||
#: core/models.py:62
|
||||
#: core/models.py:61
|
||||
msgid "Failed to Stop"
|
||||
msgstr "Error al detener"
|
||||
|
||||
#: core/models.py:63
|
||||
#: core/models.py:62
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Notificado correctamente"
|
||||
|
||||
#: core/models.py:66
|
||||
#: core/models.py:65
|
||||
msgid "External process successful"
|
||||
msgstr "Proceso externo finalizado correctamente"
|
||||
|
||||
#: core/models.py:68
|
||||
#: core/models.py:67
|
||||
msgid "External process failed"
|
||||
msgstr "Error en el Proceso externo"
|
||||
|
||||
#: core/models.py:97
|
||||
#: core/models.py:96
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "GRABACIÓN_DE_PANTALLA"
|
||||
|
||||
#: core/models.py:98
|
||||
#: core/models.py:97
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSCRIPCIÓN"
|
||||
|
||||
#: core/models.py:104
|
||||
#: core/models.py:103
|
||||
msgid "Public Access"
|
||||
msgstr "Acceso público"
|
||||
|
||||
#: core/models.py:105
|
||||
#: core/models.py:104
|
||||
msgid "Trusted Access"
|
||||
msgstr "Acceso usuarios autorizados"
|
||||
|
||||
#: core/models.py:106
|
||||
#: core/models.py:105
|
||||
msgid "Restricted Access"
|
||||
msgstr "Acceso restringido"
|
||||
|
||||
#: core/models.py:118
|
||||
#: core/models.py:117
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: core/models.py:119
|
||||
#: core/models.py:118
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "clave primaria del registro en forma de UUID"
|
||||
|
||||
#: core/models.py:125
|
||||
#: core/models.py:124
|
||||
msgid "created on"
|
||||
msgstr "creado el"
|
||||
|
||||
#: core/models.py:126
|
||||
#: core/models.py:125
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "fecha y hora en que se creó un registro"
|
||||
|
||||
#: core/models.py:131
|
||||
#: core/models.py:130
|
||||
msgid "updated on"
|
||||
msgstr "actualizado el"
|
||||
|
||||
#: core/models.py:132
|
||||
#: core/models.py:131
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr "fecha y hora de la última actualización de un registro"
|
||||
|
||||
#: core/models.py:150
|
||||
#: core/models.py:151
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
msgstr "Introduce un sub válido. Este valor solo puede contener letras, números y los caracteres @/./+/-/_."
|
||||
|
||||
#: core/models.py:157
|
||||
msgid "sub"
|
||||
msgstr "sub"
|
||||
|
||||
#: core/models.py:152
|
||||
#: core/models.py:159
|
||||
msgid ""
|
||||
"Optional for pending users; required upon account activation. 255 characters "
|
||||
"or fewer. Printable ASCII characters only."
|
||||
msgstr ""
|
||||
"Opcional para los usuarios pendientes; obligatorio al activar la cuenta. "
|
||||
"255 caracteres como máximo. Solo caracteres ASCII imprimibles."
|
||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
msgstr "Opcional para los usuarios pendientes; obligatorio al activar la cuenta. 255 caracteres como máximo. Solo letras, números y los caracteres @/./+/-/_."
|
||||
|
||||
#: core/validators.py:22
|
||||
msgid "Enter a valid sub. This value should be printable ASCII only."
|
||||
msgstr "Introduzca un sub válido. Este valor solo debe contener caracteres ASCII imprimibles."
|
||||
|
||||
#: core/models.py:161
|
||||
#: core/models.py:168
|
||||
msgid "identity email address"
|
||||
msgstr "dirección de correo electrónico"
|
||||
|
||||
#: core/models.py:166
|
||||
#: core/models.py:173
|
||||
msgid "admin email address"
|
||||
msgstr "dirección de correo electrónico de administrador"
|
||||
|
||||
#: core/models.py:168
|
||||
#: core/models.py:175
|
||||
msgid "full name"
|
||||
msgstr "nombre completo"
|
||||
|
||||
#: core/models.py:170
|
||||
#: core/models.py:177
|
||||
msgid "short name"
|
||||
msgstr "nombre corto"
|
||||
|
||||
#: core/models.py:176
|
||||
#: core/models.py:183
|
||||
msgid "language"
|
||||
msgstr "idioma"
|
||||
|
||||
#: core/models.py:177
|
||||
#: core/models.py:184
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "El idioma preferido de interfaz."
|
||||
|
||||
#: core/models.py:183
|
||||
#: core/models.py:190
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "La zona horaria en la que el usuario quiere ver las horas."
|
||||
|
||||
#: core/models.py:190
|
||||
msgid "default room access level"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:192
|
||||
msgid ""
|
||||
"Access level applied by default to new rooms created by this user. When "
|
||||
"empty, the instance default is used."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:199
|
||||
#, fuzzy
|
||||
#| msgid "Visio room configuration"
|
||||
msgid "default room configuration"
|
||||
msgstr "Configuración de la videoconferencia"
|
||||
|
||||
#: core/models.py:201
|
||||
msgid "Configurations applied by default to new rooms created by this user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:205
|
||||
#: core/models.py:193
|
||||
msgid "device"
|
||||
msgstr "dispositivo"
|
||||
|
||||
#: core/models.py:207
|
||||
#: core/models.py:195
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Si el usuario es un dispositivo o un usuario real."
|
||||
|
||||
#: core/models.py:210
|
||||
#: core/models.py:198
|
||||
msgid "staff status"
|
||||
msgstr "estado del personal"
|
||||
|
||||
#: core/models.py:212
|
||||
#: core/models.py:200
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Si el usuario puede acceder a este sitio de administración."
|
||||
|
||||
#: core/models.py:215
|
||||
#: core/models.py:203
|
||||
msgid "active"
|
||||
msgstr "activo"
|
||||
|
||||
#: core/models.py:218
|
||||
#: core/models.py:206
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
msgstr ""
|
||||
"Si este usuario debe considerarse activo. Desmarca esta opción en lugar de "
|
||||
"eliminar cuentas."
|
||||
msgstr "Si este usuario debe considerarse activo. Desmarca esta opción en lugar de eliminar cuentas."
|
||||
|
||||
#: core/models.py:231
|
||||
#: core/models.py:219
|
||||
msgid "user"
|
||||
msgstr "usuario"
|
||||
|
||||
#: core/models.py:232
|
||||
#: core/models.py:220
|
||||
msgid "users"
|
||||
msgstr "usuarios"
|
||||
|
||||
#: core/models.py:298
|
||||
#: core/models.py:286
|
||||
msgid "Resource"
|
||||
msgstr "Recurso"
|
||||
|
||||
#: core/models.py:299
|
||||
#: core/models.py:287
|
||||
msgid "Resources"
|
||||
msgstr "Recursos"
|
||||
|
||||
#: core/models.py:357
|
||||
#: core/models.py:345
|
||||
msgid "Resource access"
|
||||
msgstr "Acceso a recursos"
|
||||
|
||||
#: core/models.py:358
|
||||
#: core/models.py:346
|
||||
msgid "Resource accesses"
|
||||
msgstr "Accesos a recursos"
|
||||
|
||||
#: core/models.py:364
|
||||
#: core/models.py:352
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr "Ya existe un acceso al recurso con este usuario y este recurso."
|
||||
|
||||
#: core/models.py:421
|
||||
#: core/models.py:409
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Configuración de la videoconferencia"
|
||||
|
||||
#: core/models.py:422
|
||||
#: core/models.py:410
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr ""
|
||||
"Valores de los parámetros de videoconferencia para configurar la reunión."
|
||||
msgstr "Valores de los parámetros de videoconferencia para configurar la reunión."
|
||||
|
||||
#: core/models.py:429
|
||||
#: core/models.py:417
|
||||
msgid "Room PIN code"
|
||||
msgstr "Código PIN de la reunión"
|
||||
|
||||
#: core/models.py:430
|
||||
#: core/models.py:418
|
||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||
msgstr ""
|
||||
"Código único de n dígitos que identifica esta reunión en modo telefónico."
|
||||
msgstr "Código único de n dígitos que identifica esta reunión en modo telefónico."
|
||||
|
||||
#: core/models.py:436 core/models.py:597
|
||||
#: core/models.py:424 core/models.py:578
|
||||
msgid "Room"
|
||||
msgstr "Reunión"
|
||||
|
||||
#: core/models.py:437
|
||||
#: core/models.py:425
|
||||
msgid "Rooms"
|
||||
msgstr "Reuniones"
|
||||
|
||||
#: core/models.py:608
|
||||
#: core/models.py:589
|
||||
msgid "Worker ID"
|
||||
msgstr "ID del worker"
|
||||
|
||||
#: core/models.py:610
|
||||
#: core/models.py:591
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
msgstr ""
|
||||
"Introduce un identificador para la grabación del worker. Este identificador "
|
||||
"se conserva incluso cuando el worker se detiene, lo que permite un "
|
||||
"seguimiento sencillo."
|
||||
msgstr "Introduce un identificador para la grabación del worker. Este identificador se conserva incluso cuando el worker se detiene, lo que permite un seguimiento sencillo."
|
||||
|
||||
#: core/models.py:618
|
||||
#: core/models.py:599
|
||||
msgid "Recording mode"
|
||||
msgstr "Modo de grabación"
|
||||
|
||||
#: core/models.py:619
|
||||
#: core/models.py:600
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Define el modo de grabación a utilizar."
|
||||
|
||||
#: core/models.py:624 core/models.py:625
|
||||
#: core/models.py:605 core/models.py:606
|
||||
msgid "Recording options"
|
||||
msgstr "Opciones de grabación"
|
||||
|
||||
#: core/models.py:632
|
||||
#: core/models.py:613
|
||||
msgid "External Process ID"
|
||||
msgstr "ID del proceso externo"
|
||||
|
||||
#: core/models.py:633
|
||||
#: core/models.py:614
|
||||
msgid "ID of the external process associated with the recording."
|
||||
msgstr "ID del proceso externo asociado a la grabación."
|
||||
|
||||
#: core/models.py:639
|
||||
#: core/models.py:620
|
||||
msgid "Recording"
|
||||
msgstr "Grabación"
|
||||
|
||||
#: core/models.py:640
|
||||
#: core/models.py:621
|
||||
msgid "Recordings"
|
||||
msgstr "Grabaciones"
|
||||
|
||||
#: core/models.py:750
|
||||
#: core/models.py:731
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Relación grabación/usuario"
|
||||
|
||||
#: core/models.py:751
|
||||
#: core/models.py:732
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Relaciones grabación/usuario"
|
||||
|
||||
#: core/models.py:757
|
||||
#: core/models.py:738
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "Este usuario ya está en esta grabación."
|
||||
|
||||
#: core/models.py:763
|
||||
#: core/models.py:744
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "Este equipo ya está en esta grabación."
|
||||
|
||||
#: core/models.py:769
|
||||
#: core/models.py:750
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Debe definirse el usuario o el equipo, pero no ambos."
|
||||
|
||||
#: core/models.py:786
|
||||
#: core/models.py:767
|
||||
msgid "Create rooms"
|
||||
msgstr "Crear reunión"
|
||||
|
||||
#: core/models.py:787
|
||||
#: core/models.py:768
|
||||
msgid "List rooms"
|
||||
msgstr "Listar reuniones"
|
||||
|
||||
#: core/models.py:788
|
||||
#: core/models.py:769
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Ver los detalles de una reunión"
|
||||
|
||||
#: core/models.py:789
|
||||
#: core/models.py:770
|
||||
msgid "Update rooms"
|
||||
msgstr "Actualizar las reuniones"
|
||||
|
||||
#: core/models.py:790
|
||||
#: core/models.py:771
|
||||
msgid "Delete rooms"
|
||||
msgstr "Eliminar las reuniones"
|
||||
|
||||
#: core/models.py:803
|
||||
#: core/models.py:784
|
||||
msgid "Application name"
|
||||
msgstr "Nombre de la aplicación"
|
||||
|
||||
#: core/models.py:804
|
||||
#: core/models.py:785
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Nombre descriptivo de esta aplicación."
|
||||
|
||||
#: core/models.py:814
|
||||
#: core/models.py:795
|
||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||
msgstr "Se cifra al guardar. Cópialo ahora si se trata de un secreto nuevo."
|
||||
|
||||
#: core/models.py:825
|
||||
#: core/models.py:806
|
||||
msgid "Application"
|
||||
msgstr "Aplicación"
|
||||
|
||||
#: core/models.py:826
|
||||
#: core/models.py:807
|
||||
msgid "Applications"
|
||||
msgstr "Aplicaciones"
|
||||
|
||||
#: core/models.py:849
|
||||
#: core/models.py:830
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Introduce un dominio válido"
|
||||
|
||||
#: core/models.py:852
|
||||
#: core/models.py:833
|
||||
msgid "Domain"
|
||||
msgstr "Dominio"
|
||||
|
||||
#: core/models.py:853
|
||||
#: core/models.py:834
|
||||
msgid "Email domain this application can act on behalf of."
|
||||
msgstr ""
|
||||
"Dominio de correo electrónico en cuyo nombre puede actuar esta aplicación."
|
||||
msgstr "Dominio de correo electrónico en cuyo nombre puede actuar esta aplicación."
|
||||
|
||||
#: core/models.py:865
|
||||
#: core/models.py:846
|
||||
msgid "Application domain"
|
||||
msgstr "Dominio de aplicación"
|
||||
|
||||
#: core/models.py:866
|
||||
#: core/models.py:847
|
||||
msgid "Application domains"
|
||||
msgstr "Dominios de aplicación"
|
||||
|
||||
#: core/models.py:884
|
||||
#: core/models.py:865
|
||||
msgid "Pending"
|
||||
msgstr "Pendiente"
|
||||
|
||||
#: core/models.py:885
|
||||
#: core/models.py:866
|
||||
msgid "Analyzing"
|
||||
msgstr "Analizando"
|
||||
|
||||
#: core/models.py:892
|
||||
#: core/models.py:873
|
||||
msgid "Ready"
|
||||
msgstr "Listo"
|
||||
|
||||
#: core/models.py:898
|
||||
#: core/models.py:879
|
||||
msgid "Background image"
|
||||
msgstr "Imagen de fondo"
|
||||
|
||||
#: core/models.py:910
|
||||
#: core/models.py:891
|
||||
msgid "title"
|
||||
msgstr "título"
|
||||
|
||||
#: core/models.py:934
|
||||
#: core/models.py:915
|
||||
msgid "Malware detection info when the analysis status is unsafe."
|
||||
msgstr ""
|
||||
"Información sobre la detección de malware cuando el estado del análisis no "
|
||||
"es seguro."
|
||||
msgstr "Información sobre la detección de malware cuando el estado del análisis no es seguro."
|
||||
|
||||
#: core/models.py:939
|
||||
#: core/models.py:920
|
||||
msgid "File"
|
||||
msgstr "Archivo"
|
||||
|
||||
#: core/models.py:940
|
||||
#: core/models.py:921
|
||||
msgid "Files"
|
||||
msgstr "Archivos"
|
||||
|
||||
#: core/models.py:1060
|
||||
#: core/models.py:1041
|
||||
msgid "This file is already hard deleted."
|
||||
msgstr "Este archivo ya se ha eliminado definitivamente."
|
||||
|
||||
#: core/models.py:1070
|
||||
#: core/models.py:1051
|
||||
msgid "To hard delete a file, it must first be soft deleted."
|
||||
msgstr ""
|
||||
"Para eliminar definitivamente un archivo, primero debe haberse marcado como "
|
||||
"eliminado."
|
||||
msgstr "Para eliminar definitivamente un archivo, primero debe haberse marcado como eliminado."
|
||||
|
||||
#: core/recording/event/notification.py:124
|
||||
#: core/recording/event/notification.py:123
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Tu grabación está lista"
|
||||
|
||||
#: core/recording/event/notification.py:195
|
||||
#: core/recording/event/notification.py:194
|
||||
msgid "Transcription"
|
||||
msgstr "Transcripción"
|
||||
|
||||
#: core/recording/event/notification.py:206
|
||||
#: core/recording/event/notification.py:204
|
||||
#, python-brace-format
|
||||
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
||||
msgstr ""
|
||||
"Reunión \"{room}\" del {room_recording_date} a las {room_recording_time}"
|
||||
msgstr "Reunión \"{room}\" del {room_recording_date} a las {room_recording_time}"
|
||||
|
||||
#: core/services/invitation.py:44
|
||||
#, python-brace-format
|
||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||
msgstr "Videollamada en curso: {sender.email} está esperando a que te conectes"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:151
|
||||
#: core/templates/mail/html/screen_recording.html:151
|
||||
#: core/templates/mail/text/invitation.txt:4
|
||||
#: core/templates/mail/text/screen_recording.txt:4
|
||||
#: core/templates/mail/html/invitation.html:159
|
||||
#: core/templates/mail/html/screen_recording.html:159
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
#: core/templates/mail/text/screen_recording.txt:3
|
||||
msgid "Logo email"
|
||||
msgstr "Logotipo del correo"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:181
|
||||
#: core/templates/mail/text/invitation.txt:6
|
||||
#: core/templates/mail/html/invitation.html:189
|
||||
#: core/templates/mail/text/invitation.txt:5
|
||||
msgid "invites you to join an ongoing video call"
|
||||
msgstr "te invita a unirte a una videollamada en curso"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:192
|
||||
#: core/templates/mail/text/invitation.txt:8
|
||||
#: core/templates/mail/html/invitation.html:200
|
||||
#: core/templates/mail/text/invitation.txt:7
|
||||
msgid "JOIN THE CALL"
|
||||
msgstr "UNIRSE A LA LLAMADA"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:219
|
||||
#: core/templates/mail/text/invitation.txt:14
|
||||
#: core/templates/mail/html/invitation.html:227
|
||||
#: core/templates/mail/text/invitation.txt:13
|
||||
msgid ""
|
||||
"If you can't click the button, copy and paste the URL into your browser to "
|
||||
"join the call."
|
||||
msgstr ""
|
||||
"Si no puedes hacer clic en el botón, copia y pega la URL en tu navegador "
|
||||
"para unirte a la llamada."
|
||||
msgstr "Si no puedes hacer clic en el botón, copia y pega la URL en tu navegador para unirte a la llamada."
|
||||
|
||||
#: core/templates/mail/html/invitation.html:227
|
||||
#: core/templates/mail/text/invitation.txt:16
|
||||
#: core/templates/mail/html/invitation.html:235
|
||||
#: core/templates/mail/text/invitation.txt:15
|
||||
msgid "Tips for a better experience:"
|
||||
msgstr "Consejos para una mejor experiencia:"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:229
|
||||
#: core/templates/mail/text/invitation.txt:18
|
||||
#: core/templates/mail/html/invitation.html:237
|
||||
#: core/templates/mail/text/invitation.txt:17
|
||||
msgid "Use Chrome or Firefox for better call quality"
|
||||
msgstr "Usa Chrome o Firefox para una mejor calidad de llamada"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:230
|
||||
#: core/templates/mail/text/invitation.txt:19
|
||||
#: core/templates/mail/html/invitation.html:238
|
||||
#: core/templates/mail/text/invitation.txt:18
|
||||
msgid "Test your microphone and camera before joining"
|
||||
msgstr "Prueba tu micrófono y tu cámara antes de unirte"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:231
|
||||
#: core/templates/mail/text/invitation.txt:20
|
||||
#: core/templates/mail/html/invitation.html:239
|
||||
#: core/templates/mail/text/invitation.txt:19
|
||||
msgid "Make sure you have a stable internet connection"
|
||||
msgstr "Asegúrate de tener una conexión a internet estable"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:240
|
||||
#: core/templates/mail/html/screen_recording.html:237
|
||||
#: core/templates/mail/text/invitation.txt:22
|
||||
#: core/templates/mail/text/screen_recording.txt:24
|
||||
#: core/templates/mail/html/invitation.html:248
|
||||
#: core/templates/mail/html/screen_recording.html:245
|
||||
#: core/templates/mail/text/invitation.txt:21
|
||||
#: core/templates/mail/text/screen_recording.txt:23
|
||||
#, python-format
|
||||
msgid " Thank you for using %(brandname)s. "
|
||||
msgstr " Gracias por usar %(brandname)s. "
|
||||
|
||||
#: core/templates/mail/html/invitation.html:271
|
||||
#, python-format
|
||||
msgid ""
|
||||
"This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:180
|
||||
#: core/templates/mail/text/screen_recording.txt:7
|
||||
#: core/templates/mail/html/screen_recording.html:188
|
||||
#: core/templates/mail/text/screen_recording.txt:6
|
||||
msgid "Your recording is ready!"
|
||||
msgstr "¡Tu grabación está lista!"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:187
|
||||
#: core/templates/mail/text/screen_recording.txt:9
|
||||
#: core/templates/mail/html/screen_recording.html:195
|
||||
#: core/templates/mail/text/screen_recording.txt:8
|
||||
#, python-format
|
||||
msgid ""
|
||||
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
|
||||
"%(recording_time)s is now ready to download. "
|
||||
msgstr ""
|
||||
" Tu grabación de \"%(room_name)s\" del %(recording_date)s a las "
|
||||
"%(recording_time)s ya está lista para descargar. "
|
||||
msgstr " Tu grabación de \"%(room_name)s\" del %(recording_date)s a las %(recording_time)s ya está lista para descargar. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:187
|
||||
#: core/templates/mail/text/screen_recording.txt:9
|
||||
#: core/templates/mail/html/screen_recording.html:195
|
||||
#: core/templates/mail/text/screen_recording.txt:8
|
||||
#, python-format
|
||||
msgid " The recording will expire in %(days)s days. "
|
||||
msgstr " La grabación caducará dentro de %(days)s días. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:192
|
||||
#: core/templates/mail/text/screen_recording.txt:10
|
||||
#: core/templates/mail/html/screen_recording.html:200
|
||||
#: core/templates/mail/text/screen_recording.txt:9
|
||||
msgid ""
|
||||
" Sharing the recording via link is not yet available. Only organizers can "
|
||||
"download it. "
|
||||
msgstr ""
|
||||
" Compartir la grabación mediante un enlace todavía no está disponible. Solo "
|
||||
"los organizadores pueden descargarla. "
|
||||
msgstr " Compartir la grabación mediante un enlace todavía no está disponible. Solo los organizadores pueden descargarla. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:198
|
||||
#: core/templates/mail/text/screen_recording.txt:12
|
||||
#: core/templates/mail/html/screen_recording.html:206
|
||||
#: core/templates/mail/text/screen_recording.txt:11
|
||||
msgid "To keep this recording permanently:"
|
||||
msgstr "Para conservar esta grabación de forma permanente:"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:200
|
||||
#: core/templates/mail/html/screen_recording.html:208
|
||||
#, python-format
|
||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgstr ""
|
||||
"Haz clic en el enlace \"<a href=\"%(link)s\">Abrir</a>\" que aparece abajo "
|
||||
msgstr "Haz clic en el enlace \"<a href=\"%(link)s\">Abrir</a>\" que aparece abajo "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:201
|
||||
#: core/templates/mail/text/screen_recording.txt:15
|
||||
#: core/templates/mail/html/screen_recording.html:209
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
msgid "Use the \"Download\" button in the interface "
|
||||
msgstr "Usa el botón \"Descargar\" de la interfaz "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:202
|
||||
#: core/templates/mail/text/screen_recording.txt:16
|
||||
#: core/templates/mail/html/screen_recording.html:210
|
||||
#: core/templates/mail/text/screen_recording.txt:15
|
||||
msgid "Save the file to your preferred location"
|
||||
msgstr "Guarda el archivo en la ubicación que prefieras"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:213
|
||||
#: core/templates/mail/text/screen_recording.txt:18
|
||||
#: core/templates/mail/html/screen_recording.html:221
|
||||
#: core/templates/mail/text/screen_recording.txt:17
|
||||
msgid "Open"
|
||||
msgstr "Abrir"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:222
|
||||
#: core/templates/mail/text/screen_recording.txt:20
|
||||
#: core/templates/mail/html/screen_recording.html:230
|
||||
#: core/templates/mail/text/screen_recording.txt:19
|
||||
#, python-format
|
||||
msgid ""
|
||||
" If you have any questions or need assistance, please contact our support "
|
||||
"team at %(support_email)s. "
|
||||
msgstr ""
|
||||
" Si tienes alguna pregunta o necesitas ayuda, ponte en contacto con nuestro "
|
||||
"equipo de soporte en %(support_email)s. "
|
||||
msgstr " Si tienes alguna pregunta o necesitas ayuda, ponte en contacto con nuestro equipo de soporte en %(support_email)s. "
|
||||
|
||||
#: core/templates/mail/text/invitation.txt:24
|
||||
#, python-format
|
||||
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
#, python-format
|
||||
msgid "Click the \"Open [%(link)s]\" link below "
|
||||
msgstr "Haz clic en el enlace \"Abrir [%(link)s]\" que aparece abajo "
|
||||
|
||||
#: meet/settings.py:238
|
||||
#: meet/settings.py:228
|
||||
msgid "English"
|
||||
msgstr "Inglés"
|
||||
|
||||
#: meet/settings.py:239
|
||||
#: meet/settings.py:229
|
||||
msgid "French"
|
||||
msgstr "Francés"
|
||||
|
||||
#: meet/settings.py:240
|
||||
#: meet/settings.py:230
|
||||
msgid "Dutch"
|
||||
msgstr "Neerlandés"
|
||||
|
||||
#: meet/settings.py:241
|
||||
#: meet/settings.py:231
|
||||
msgid "German"
|
||||
msgstr "Alemán"
|
||||
|
||||
#: meet/settings.py:242
|
||||
#: meet/settings.py:233
|
||||
msgid "Spanish"
|
||||
msgstr "Español"
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-09-02 22:52+0000\n"
|
||||
"POT-Creation-Date: 2026-07-02 10:47+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: antoine.lebaud@mail.numerique.gouv.fr\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -51,11 +51,11 @@ msgstr ""
|
||||
msgid "File preview"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:300 core/admin.py:450
|
||||
#: core/admin.py:300 core/admin.py:443
|
||||
msgid "No owner"
|
||||
msgstr "Pas de propriétaire"
|
||||
|
||||
#: core/admin.py:303 core/admin.py:453
|
||||
#: core/admin.py:303 core/admin.py:446
|
||||
msgid "Multiple owners"
|
||||
msgstr "Plusieurs propriétaires"
|
||||
|
||||
@@ -99,11 +99,11 @@ msgstr ""
|
||||
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:517
|
||||
#: core/admin.py:510
|
||||
msgid "No scopes"
|
||||
msgstr "Aucun scopes"
|
||||
|
||||
#: core/admin.py:519
|
||||
#: core/admin.py:512
|
||||
msgid "Scopes"
|
||||
msgstr "Scopes"
|
||||
|
||||
@@ -111,203 +111,187 @@ msgstr "Scopes"
|
||||
msgid "Creator is me"
|
||||
msgstr "Je suis le créateur"
|
||||
|
||||
#: core/api/serializers.py:108
|
||||
#: core/api/serializers.py:89
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr ""
|
||||
"Vous devez être administrateur ou propriétaire d'une salle pour y ajouter "
|
||||
"des accès."
|
||||
|
||||
#: core/api/serializers.py:560
|
||||
#: core/api/serializers.py:534
|
||||
msgid "This file extension is not allowed."
|
||||
msgstr "Cette extension n'est pas autorisée"
|
||||
|
||||
#: core/api/viewsets.py:1268
|
||||
#: core/api/viewsets.py:1222
|
||||
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:38
|
||||
#: core/models.py:37
|
||||
msgid "Member"
|
||||
msgstr "Membre"
|
||||
|
||||
#: core/models.py:39
|
||||
#: core/models.py:38
|
||||
msgid "Administrator"
|
||||
msgstr "Administrateur"
|
||||
|
||||
#: core/models.py:40
|
||||
#: core/models.py:39
|
||||
msgid "Owner"
|
||||
msgstr "Propriétaire"
|
||||
|
||||
#: core/models.py:56
|
||||
#: core/models.py:55
|
||||
msgid "Initiated"
|
||||
msgstr "Initié"
|
||||
|
||||
#: core/models.py:57
|
||||
#: core/models.py:56
|
||||
msgid "Active"
|
||||
msgstr "Actif"
|
||||
|
||||
#: core/models.py:58
|
||||
#: core/models.py:57
|
||||
msgid "Stopped"
|
||||
msgstr "Arrêté"
|
||||
|
||||
#: core/models.py:59
|
||||
#: core/models.py:58
|
||||
msgid "Saved"
|
||||
msgstr "Enregistré"
|
||||
|
||||
#: core/models.py:60
|
||||
#: core/models.py:59
|
||||
msgid "Aborted"
|
||||
msgstr "Abandonné"
|
||||
|
||||
#: core/models.py:61
|
||||
#: core/models.py:60
|
||||
msgid "Failed to Start"
|
||||
msgstr "Échec au démarrage"
|
||||
|
||||
#: core/models.py:62
|
||||
#: core/models.py:61
|
||||
msgid "Failed to Stop"
|
||||
msgstr "Échec à l'arrêt"
|
||||
|
||||
#: core/models.py:63
|
||||
#: core/models.py:62
|
||||
msgid "Notification succeeded"
|
||||
msgstr "Notification réussie"
|
||||
|
||||
#: core/models.py:66
|
||||
#: core/models.py:65
|
||||
msgid "External process successful"
|
||||
msgstr "Traitement externe : succès"
|
||||
|
||||
#: core/models.py:68
|
||||
#: core/models.py:67
|
||||
msgid "External process failed"
|
||||
msgstr "Traitement externe : erreur"
|
||||
|
||||
#: core/models.py:97
|
||||
#: core/models.py:96
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr "ENREGISTREMENT_ÉCRAN"
|
||||
|
||||
#: core/models.py:98
|
||||
#: core/models.py:97
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr "TRANSCRIPTION"
|
||||
|
||||
#: core/models.py:104
|
||||
#: core/models.py:103
|
||||
msgid "Public Access"
|
||||
msgstr "Accès public"
|
||||
|
||||
#: core/models.py:105
|
||||
#: core/models.py:104
|
||||
msgid "Trusted Access"
|
||||
msgstr "Accès de confiance"
|
||||
|
||||
#: core/models.py:106
|
||||
#: core/models.py:105
|
||||
msgid "Restricted Access"
|
||||
msgstr "Accès restreint"
|
||||
|
||||
#: core/models.py:118
|
||||
#: core/models.py:117
|
||||
msgid "id"
|
||||
msgstr "id"
|
||||
|
||||
#: core/models.py:119
|
||||
#: core/models.py:118
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr "clé primaire pour l'enregistrement sous forme d'UUID"
|
||||
|
||||
#: core/models.py:125
|
||||
#: core/models.py:124
|
||||
msgid "created on"
|
||||
msgstr "créé le"
|
||||
|
||||
#: core/models.py:126
|
||||
#: core/models.py:125
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr "date et heure auxquelles un enregistrement a été créé"
|
||||
|
||||
#: core/models.py:131
|
||||
#: core/models.py:130
|
||||
msgid "updated on"
|
||||
msgstr "mis à jour le"
|
||||
|
||||
#: core/models.py:132
|
||||
#: core/models.py:131
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
"date et heure auxquelles un enregistrement a été mis à jour pour la dernière "
|
||||
"fois"
|
||||
|
||||
#: core/models.py:150
|
||||
#: core/models.py:151
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
msgstr ""
|
||||
"Entrez un sub valide. Cette valeur ne peut contenir que des lettres, des "
|
||||
"chiffres et les caractères @/./+/-/_."
|
||||
|
||||
#: core/models.py:157
|
||||
msgid "sub"
|
||||
msgstr "sub"
|
||||
|
||||
#: core/models.py:152
|
||||
#: core/models.py:159
|
||||
msgid ""
|
||||
"Optional for pending users; required upon account activation. 255 characters "
|
||||
"or fewer. Printable ASCII characters only."
|
||||
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||
msgstr ""
|
||||
"Facultatif pour les utilisateurs en attente ; obligatoire lors de l’activation "
|
||||
"du compte. 255 caractères maximum. Caractères ASCII imprimables uniquement."
|
||||
"Optionnel pour les utilisateurs en attente ; requis lors de l'activation du "
|
||||
"compte. 255 caractères maximum. Lettres, chiffres et @/./+/-/_ uniquement."
|
||||
|
||||
#: core/validators.py:22
|
||||
msgid "Enter a valid sub. This value should be printable ASCII only."
|
||||
msgstr "Saisissez un sub valide. Cette valeur ne doit contenir que des caractères ASCII imprimables."
|
||||
|
||||
#: core/models.py:161
|
||||
#: core/models.py:168
|
||||
msgid "identity email address"
|
||||
msgstr "adresse e-mail d'identité"
|
||||
|
||||
#: core/models.py:166
|
||||
#: core/models.py:173
|
||||
msgid "admin email address"
|
||||
msgstr "adresse e-mail d'administrateur"
|
||||
|
||||
#: core/models.py:168
|
||||
#: core/models.py:175
|
||||
msgid "full name"
|
||||
msgstr "nom complet"
|
||||
|
||||
#: core/models.py:170
|
||||
#: core/models.py:177
|
||||
msgid "short name"
|
||||
msgstr "nom court"
|
||||
|
||||
#: core/models.py:176
|
||||
#: core/models.py:183
|
||||
msgid "language"
|
||||
msgstr "langue"
|
||||
|
||||
#: core/models.py:177
|
||||
#: core/models.py:184
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr "La langue dans laquelle l'utilisateur souhaite voir l'interface."
|
||||
|
||||
#: core/models.py:183
|
||||
#: core/models.py:190
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr "Le fuseau horaire dans lequel l'utilisateur souhaite voir les heures."
|
||||
|
||||
#: core/models.py:190
|
||||
msgid "default room access level"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:192
|
||||
msgid ""
|
||||
"Access level applied by default to new rooms created by this user. When "
|
||||
"empty, the instance default is used."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:199
|
||||
#, fuzzy
|
||||
#| msgid "Visio room configuration"
|
||||
msgid "default room configuration"
|
||||
msgstr "Configuration de la salle de visioconférence"
|
||||
|
||||
#: core/models.py:201
|
||||
msgid "Configurations applied by default to new rooms created by this user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:205
|
||||
#: core/models.py:193
|
||||
msgid "device"
|
||||
msgstr "appareil"
|
||||
|
||||
#: core/models.py:207
|
||||
#: core/models.py:195
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr "Si l'utilisateur est un appareil ou un utilisateur réel."
|
||||
|
||||
#: core/models.py:210
|
||||
#: core/models.py:198
|
||||
msgid "staff status"
|
||||
msgstr "statut du personnel"
|
||||
|
||||
#: core/models.py:212
|
||||
#: core/models.py:200
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr "Si l'utilisateur peut se connecter à ce site d'administration."
|
||||
|
||||
#: core/models.py:215
|
||||
#: core/models.py:203
|
||||
msgid "active"
|
||||
msgstr "actif"
|
||||
|
||||
#: core/models.py:218
|
||||
#: core/models.py:206
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
@@ -315,65 +299,65 @@ msgstr ""
|
||||
"Si cet utilisateur doit être traité comme actif. Désélectionnez cette option "
|
||||
"au lieu de supprimer des comptes."
|
||||
|
||||
#: core/models.py:231
|
||||
#: core/models.py:219
|
||||
msgid "user"
|
||||
msgstr "utilisateur"
|
||||
|
||||
#: core/models.py:232
|
||||
#: core/models.py:220
|
||||
msgid "users"
|
||||
msgstr "utilisateurs"
|
||||
|
||||
#: core/models.py:298
|
||||
#: core/models.py:286
|
||||
msgid "Resource"
|
||||
msgstr "Ressource"
|
||||
|
||||
#: core/models.py:299
|
||||
#: core/models.py:287
|
||||
msgid "Resources"
|
||||
msgstr "Ressources"
|
||||
|
||||
#: core/models.py:357
|
||||
#: core/models.py:345
|
||||
msgid "Resource access"
|
||||
msgstr "Accès aux ressources"
|
||||
|
||||
#: core/models.py:358
|
||||
#: core/models.py:346
|
||||
msgid "Resource accesses"
|
||||
msgstr "Accès aux ressources"
|
||||
|
||||
#: core/models.py:364
|
||||
#: core/models.py:352
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr ""
|
||||
"L'accès à la ressource avec cet utilisateur et cette ressource existe déjà."
|
||||
|
||||
#: core/models.py:421
|
||||
#: core/models.py:409
|
||||
msgid "Visio room configuration"
|
||||
msgstr "Configuration de la salle de visioconférence"
|
||||
|
||||
#: core/models.py:422
|
||||
#: core/models.py:410
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr "Valeurs des paramètres de visioconférence pour configurer la salle."
|
||||
|
||||
#: core/models.py:429
|
||||
#: core/models.py:417
|
||||
msgid "Room PIN code"
|
||||
msgstr "Code PIN de la salle"
|
||||
|
||||
#: core/models.py:430
|
||||
#: core/models.py:418
|
||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||
msgstr ""
|
||||
"Code unique à n chiffres qui identifie cette salle en mode téléphonique."
|
||||
|
||||
#: core/models.py:436 core/models.py:597
|
||||
#: core/models.py:424 core/models.py:578
|
||||
msgid "Room"
|
||||
msgstr "Salle"
|
||||
|
||||
#: core/models.py:437
|
||||
#: core/models.py:425
|
||||
msgid "Rooms"
|
||||
msgstr "Salles"
|
||||
|
||||
#: core/models.py:608
|
||||
#: core/models.py:589
|
||||
msgid "Worker ID"
|
||||
msgstr "ID du Worker"
|
||||
|
||||
#: core/models.py:610
|
||||
#: core/models.py:591
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
@@ -381,170 +365,170 @@ msgstr ""
|
||||
"Entrez un identifiant pour l'enregistrement du Worker. Cet identifiant est "
|
||||
"conservé même lorsque le Worker s'arrête, permettant un suivi facile."
|
||||
|
||||
#: core/models.py:618
|
||||
#: core/models.py:599
|
||||
msgid "Recording mode"
|
||||
msgstr "Mode d'enregistrement"
|
||||
|
||||
#: core/models.py:619
|
||||
#: core/models.py:600
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr "Définit le mode d'enregistrement appelé."
|
||||
|
||||
#: core/models.py:624 core/models.py:625
|
||||
#: core/models.py:605 core/models.py:606
|
||||
msgid "Recording options"
|
||||
msgstr "Options d'enregistrement"
|
||||
|
||||
#: core/models.py:632
|
||||
#: core/models.py:613
|
||||
msgid "External Process ID"
|
||||
msgstr "ID Traitement externe"
|
||||
|
||||
#: core/models.py:633
|
||||
#: core/models.py:614
|
||||
msgid "ID of the external process associated with the recording."
|
||||
msgstr "ID du traitement externe associé avec l'enregistrement."
|
||||
|
||||
#: core/models.py:639
|
||||
#: core/models.py:620
|
||||
msgid "Recording"
|
||||
msgstr "Enregistrement"
|
||||
|
||||
#: core/models.py:640
|
||||
#: core/models.py:621
|
||||
msgid "Recordings"
|
||||
msgstr "Enregistrements"
|
||||
|
||||
#: core/models.py:750
|
||||
#: core/models.py:731
|
||||
msgid "Recording/user relation"
|
||||
msgstr "Relation enregistrement/utilisateur"
|
||||
|
||||
#: core/models.py:751
|
||||
#: core/models.py:732
|
||||
msgid "Recording/user relations"
|
||||
msgstr "Relations enregistrement/utilisateur"
|
||||
|
||||
#: core/models.py:757
|
||||
#: core/models.py:738
|
||||
msgid "This user is already in this recording."
|
||||
msgstr "Cet utilisateur est déjà dans cet enregistrement."
|
||||
|
||||
#: core/models.py:763
|
||||
#: core/models.py:744
|
||||
msgid "This team is already in this recording."
|
||||
msgstr "Cette équipe est déjà dans cet enregistrement."
|
||||
|
||||
#: core/models.py:769
|
||||
#: core/models.py:750
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr "Soit l'utilisateur, soit l'équipe doit être défini, pas les deux."
|
||||
|
||||
#: core/models.py:786
|
||||
#: core/models.py:767
|
||||
msgid "Create rooms"
|
||||
msgstr "Créer des salles"
|
||||
|
||||
#: core/models.py:787
|
||||
#: core/models.py:768
|
||||
msgid "List rooms"
|
||||
msgstr "Lister les salles"
|
||||
|
||||
#: core/models.py:788
|
||||
#: core/models.py:769
|
||||
msgid "Retrieve room details"
|
||||
msgstr "Afficher les détails d’une salle"
|
||||
|
||||
#: core/models.py:789
|
||||
#: core/models.py:770
|
||||
msgid "Update rooms"
|
||||
msgstr "Mettre à jour les salles"
|
||||
|
||||
#: core/models.py:790
|
||||
#: core/models.py:771
|
||||
msgid "Delete rooms"
|
||||
msgstr "Supprimer les salles"
|
||||
|
||||
#: core/models.py:803
|
||||
#: core/models.py:784
|
||||
msgid "Application name"
|
||||
msgstr "Nom de l’application"
|
||||
|
||||
#: core/models.py:804
|
||||
#: core/models.py:785
|
||||
msgid "Descriptive name for this application."
|
||||
msgstr "Nom descriptif de cette application."
|
||||
|
||||
#: core/models.py:814
|
||||
#: core/models.py:795
|
||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||
msgstr ""
|
||||
"Haché lors de l’enregistrement. Copiez-le maintenant s’il s’agit d’un "
|
||||
"nouveau secret."
|
||||
|
||||
#: core/models.py:825
|
||||
#: core/models.py:806
|
||||
msgid "Application"
|
||||
msgstr "Application"
|
||||
|
||||
#: core/models.py:826
|
||||
#: core/models.py:807
|
||||
msgid "Applications"
|
||||
msgstr "Applications"
|
||||
|
||||
#: core/models.py:849
|
||||
#: core/models.py:830
|
||||
msgid "Enter a valid domain"
|
||||
msgstr "Saisissez un domaine valide"
|
||||
|
||||
#: core/models.py:852
|
||||
#: core/models.py:833
|
||||
msgid "Domain"
|
||||
msgstr "Domaine"
|
||||
|
||||
#: core/models.py:853
|
||||
#: core/models.py:834
|
||||
msgid "Email domain this application can act on behalf of."
|
||||
msgstr "Domaine de messagerie au nom duquel cette application peut agir."
|
||||
|
||||
#: core/models.py:865
|
||||
#: core/models.py:846
|
||||
msgid "Application domain"
|
||||
msgstr "Domaine d’application"
|
||||
|
||||
#: core/models.py:866
|
||||
#: core/models.py:847
|
||||
msgid "Application domains"
|
||||
msgstr "Domaines d’application"
|
||||
|
||||
#: core/models.py:884
|
||||
#: core/models.py:865
|
||||
msgid "Pending"
|
||||
msgstr "En attente"
|
||||
|
||||
#: core/models.py:885
|
||||
#: core/models.py:866
|
||||
msgid "Analyzing"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:892
|
||||
#: core/models.py:873
|
||||
msgid "Ready"
|
||||
msgstr "Prêt"
|
||||
|
||||
#: core/models.py:898
|
||||
#: core/models.py:879
|
||||
msgid "Background image"
|
||||
msgstr "Image de fond"
|
||||
|
||||
#: core/models.py:910
|
||||
#: core/models.py:891
|
||||
msgid "title"
|
||||
msgstr "Titre"
|
||||
|
||||
#: core/models.py:934
|
||||
#: core/models.py:915
|
||||
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:939
|
||||
#: core/models.py:920
|
||||
msgid "File"
|
||||
msgstr "Fichier"
|
||||
|
||||
#: core/models.py:940
|
||||
#: core/models.py:921
|
||||
msgid "Files"
|
||||
msgstr "Fichiers"
|
||||
|
||||
#: core/models.py:1060
|
||||
#: core/models.py:1041
|
||||
#, 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:1070
|
||||
#: core/models.py:1051
|
||||
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:124
|
||||
#: core/recording/event/notification.py:123
|
||||
msgid "Your recording is ready"
|
||||
msgstr "Votre enregistrement est prêt"
|
||||
|
||||
#: core/recording/event/notification.py:195
|
||||
#: core/recording/event/notification.py:194
|
||||
msgid "Transcription"
|
||||
msgstr "Transcription"
|
||||
|
||||
#: core/recording/event/notification.py:206
|
||||
#: core/recording/event/notification.py:204
|
||||
#, python-brace-format
|
||||
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
||||
msgstr "Réunion \"{room}\" du {room_recording_date} à {room_recording_time}"
|
||||
@@ -554,25 +538,25 @@ msgstr "Réunion \"{room}\" du {room_recording_date} à {room_recording_time}"
|
||||
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"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:151
|
||||
#: core/templates/mail/html/screen_recording.html:151
|
||||
#: core/templates/mail/text/invitation.txt:4
|
||||
#: core/templates/mail/text/screen_recording.txt:4
|
||||
#: core/templates/mail/html/invitation.html:159
|
||||
#: core/templates/mail/html/screen_recording.html:159
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
#: core/templates/mail/text/screen_recording.txt:3
|
||||
msgid "Logo email"
|
||||
msgstr "Logo email"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:181
|
||||
#: core/templates/mail/text/invitation.txt:6
|
||||
#: core/templates/mail/html/invitation.html:189
|
||||
#: core/templates/mail/text/invitation.txt:5
|
||||
msgid "invites you to join an ongoing video call"
|
||||
msgstr "vous invite à rejoindre un appel vidéo en cours"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:192
|
||||
#: core/templates/mail/text/invitation.txt:8
|
||||
#: core/templates/mail/html/invitation.html:200
|
||||
#: core/templates/mail/text/invitation.txt:7
|
||||
msgid "JOIN THE CALL"
|
||||
msgstr "REJOINDRE L'APPEL"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:219
|
||||
#: core/templates/mail/text/invitation.txt:14
|
||||
#: core/templates/mail/html/invitation.html:227
|
||||
#: core/templates/mail/text/invitation.txt:13
|
||||
msgid ""
|
||||
"If you can't click the button, copy and paste the URL into your browser to "
|
||||
"join the call."
|
||||
@@ -580,47 +564,41 @@ msgstr ""
|
||||
"Si vous ne pouvez pas cliquer sur le bouton, copiez et collez l'URL dans "
|
||||
"votre navigateur pour rejoindre l'appel."
|
||||
|
||||
#: core/templates/mail/html/invitation.html:227
|
||||
#: core/templates/mail/text/invitation.txt:16
|
||||
#: core/templates/mail/html/invitation.html:235
|
||||
#: core/templates/mail/text/invitation.txt:15
|
||||
msgid "Tips for a better experience:"
|
||||
msgstr "Conseils pour une meilleure expérience :"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:229
|
||||
#: core/templates/mail/text/invitation.txt:18
|
||||
#: core/templates/mail/html/invitation.html:237
|
||||
#: core/templates/mail/text/invitation.txt:17
|
||||
msgid "Use Chrome or Firefox for better call quality"
|
||||
msgstr "Utilisez Chrome ou Firefox pour une meilleure qualité d'appel"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:230
|
||||
#: core/templates/mail/text/invitation.txt:19
|
||||
#: core/templates/mail/html/invitation.html:238
|
||||
#: core/templates/mail/text/invitation.txt:18
|
||||
msgid "Test your microphone and camera before joining"
|
||||
msgstr "Testez votre microphone et votre caméra avant de rejoindre"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:231
|
||||
#: core/templates/mail/text/invitation.txt:20
|
||||
#: core/templates/mail/html/invitation.html:239
|
||||
#: core/templates/mail/text/invitation.txt:19
|
||||
msgid "Make sure you have a stable internet connection"
|
||||
msgstr "Assurez-vous d'avoir une connexion Internet stable"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:240
|
||||
#: core/templates/mail/html/screen_recording.html:237
|
||||
#: core/templates/mail/text/invitation.txt:22
|
||||
#: core/templates/mail/text/screen_recording.txt:24
|
||||
#: core/templates/mail/html/invitation.html:248
|
||||
#: core/templates/mail/html/screen_recording.html:245
|
||||
#: core/templates/mail/text/invitation.txt:21
|
||||
#: core/templates/mail/text/screen_recording.txt:23
|
||||
#, python-format
|
||||
msgid " Thank you for using %(brandname)s. "
|
||||
msgstr " Merci d'utiliser %(brandname)s. "
|
||||
|
||||
#: core/templates/mail/html/invitation.html:271
|
||||
#, python-format
|
||||
msgid ""
|
||||
"This mail has been sent to %(email)s by <a href=\"%(href)s\">%(name)s</a>"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:180
|
||||
#: core/templates/mail/text/screen_recording.txt:7
|
||||
#: core/templates/mail/html/screen_recording.html:188
|
||||
#: core/templates/mail/text/screen_recording.txt:6
|
||||
msgid "Your recording is ready!"
|
||||
msgstr "Votre enregistrement est prêt !"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:187
|
||||
#: core/templates/mail/text/screen_recording.txt:9
|
||||
#: core/templates/mail/html/screen_recording.html:195
|
||||
#: core/templates/mail/text/screen_recording.txt:8
|
||||
#, python-format
|
||||
msgid ""
|
||||
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
|
||||
@@ -629,14 +607,14 @@ msgstr ""
|
||||
" Votre enregistrement de \"%(room_name)s\" du %(recording_date)s à "
|
||||
"%(recording_time)s est maintenant prêt à être téléchargé. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:187
|
||||
#: core/templates/mail/text/screen_recording.txt:9
|
||||
#: core/templates/mail/html/screen_recording.html:195
|
||||
#: core/templates/mail/text/screen_recording.txt:8
|
||||
#, python-format
|
||||
msgid " The recording will expire in %(days)s days. "
|
||||
msgstr " L'enregistrement expirera dans %(days)s jours. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:192
|
||||
#: core/templates/mail/text/screen_recording.txt:10
|
||||
#: core/templates/mail/html/screen_recording.html:200
|
||||
#: core/templates/mail/text/screen_recording.txt:9
|
||||
msgid ""
|
||||
" Sharing the recording via link is not yet available. Only organizers can "
|
||||
"download it. "
|
||||
@@ -644,33 +622,33 @@ msgstr ""
|
||||
"Le partage de l'enregistrement via lien n'est pas encore disponible. Seuls "
|
||||
"les organisateurs peuvent le télécharger."
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:198
|
||||
#: core/templates/mail/text/screen_recording.txt:12
|
||||
#: core/templates/mail/html/screen_recording.html:206
|
||||
#: core/templates/mail/text/screen_recording.txt:11
|
||||
msgid "To keep this recording permanently:"
|
||||
msgstr "Pour conserver cet enregistrement de façon permanente :"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:200
|
||||
#: core/templates/mail/html/screen_recording.html:208
|
||||
#, python-format
|
||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" ci-dessous "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:201
|
||||
#: core/templates/mail/text/screen_recording.txt:15
|
||||
#: core/templates/mail/html/screen_recording.html:209
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
msgid "Use the \"Download\" button in the interface "
|
||||
msgstr "Utilisez le bouton \"Télécharger\" dans l'interface "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:202
|
||||
#: core/templates/mail/text/screen_recording.txt:16
|
||||
#: core/templates/mail/html/screen_recording.html:210
|
||||
#: core/templates/mail/text/screen_recording.txt:15
|
||||
msgid "Save the file to your preferred location"
|
||||
msgstr "Enregistrez le fichier à l'emplacement de votre choix"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:213
|
||||
#: core/templates/mail/text/screen_recording.txt:18
|
||||
#: core/templates/mail/html/screen_recording.html:221
|
||||
#: core/templates/mail/text/screen_recording.txt:17
|
||||
msgid "Open"
|
||||
msgstr "Ouvrir"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:222
|
||||
#: core/templates/mail/text/screen_recording.txt:20
|
||||
#: core/templates/mail/html/screen_recording.html:230
|
||||
#: core/templates/mail/text/screen_recording.txt:19
|
||||
#, python-format
|
||||
msgid ""
|
||||
" If you have any questions or need assistance, please contact our support "
|
||||
@@ -679,33 +657,28 @@ msgstr ""
|
||||
" Si vous avez des questions ou besoin d'assistance, veuillez contacter notre "
|
||||
"équipe d'assistance à %(support_email)s. "
|
||||
|
||||
#: core/templates/mail/text/invitation.txt:24
|
||||
#, python-format
|
||||
msgid "This mail has been sent to %(email)s by %(name)s [%(href)s]"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
#, fuzzy, python-format
|
||||
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||
msgid "Click the \"Open [%(link)s]\" link below "
|
||||
msgstr "Cliquez sur le lien \"<a href=\"%(link)s\">Ouvrir</a>\" ci-dessous "
|
||||
|
||||
#: meet/settings.py:238
|
||||
#: meet/settings.py:228
|
||||
msgid "English"
|
||||
msgstr "Anglais"
|
||||
|
||||
#: meet/settings.py:239
|
||||
#: meet/settings.py:229
|
||||
msgid "French"
|
||||
msgstr "Français"
|
||||
|
||||
#: meet/settings.py:240
|
||||
#: meet/settings.py:230
|
||||
msgid "Dutch"
|
||||
msgstr "Néerlandais"
|
||||
|
||||
#: meet/settings.py:241
|
||||
#: meet/settings.py:231
|
||||
msgid "German"
|
||||
msgstr "Allemand"
|
||||
|
||||
#: meet/settings.py:242
|
||||
#: meet/settings.py:233
|
||||
msgid "Spanish"
|
||||
msgstr "Espagnol"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user