mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-29 22:19:08 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d465291cf5 | |||
| ea771bff0c |
@@ -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
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- "*"
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
|
|
||||||
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 }}
|
|
||||||
|
|
||||||
test-back:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: src/backend
|
|
||||||
|
|
||||||
services:
|
|
||||||
postgres:
|
|
||||||
image: postgres:16
|
|
||||||
env:
|
|
||||||
POSTGRES_DB: meet
|
|
||||||
POSTGRES_USER: dinum
|
|
||||||
POSTGRES_PASSWORD: pass
|
|
||||||
ports:
|
|
||||||
- 5432:5432
|
|
||||||
# needed because the postgres container does not provide a healthcheck
|
|
||||||
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
|
||||||
redis:
|
|
||||||
image: redis:5
|
|
||||||
ports:
|
|
||||||
- 6379:6379
|
|
||||||
# Set health checks to wait until redis has started
|
|
||||||
options: >-
|
|
||||||
--health-cmd "redis-cli ping"
|
|
||||||
--health-interval 10s
|
|
||||||
--health-timeout 5s
|
|
||||||
--health-retries 5
|
|
||||||
|
|
||||||
env:
|
|
||||||
DJANGO_CONFIGURATION: Test
|
|
||||||
DJANGO_SETTINGS_MODULE: meet.settings
|
|
||||||
DJANGO_SECRET_KEY: ThisIsAnExampleKeyForTestPurposeOnly
|
|
||||||
OIDC_OP_JWKS_ENDPOINT: /endpoint-for-test-purpose-only
|
|
||||||
DB_HOST: localhost
|
|
||||||
DB_NAME: meet
|
|
||||||
DB_USER: dinum
|
|
||||||
DB_PASSWORD: pass
|
|
||||||
DB_PORT: 5432
|
|
||||||
REDIS_URL: redis://localhost:6379/1
|
|
||||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
|
||||||
AWS_S3_ENDPOINT_URL: http://localhost:9000
|
|
||||||
AWS_S3_ACCESS_KEY_ID: meet
|
|
||||||
AWS_S3_SECRET_ACCESS_KEY: password
|
|
||||||
OIDC_RS_CLIENT_ID: meet
|
|
||||||
OIDC_RS_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
|
||||||
OIDC_OP_INTROSPECTION_ENDPOINT: https://oidc.example.com/introspect
|
|
||||||
OIDC_OP_URL: https://oidc.example.com
|
|
||||||
MEDIA_BASE_URL: http://localhost:8083
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
|
||||||
|
|
||||||
- name: Create writable /data
|
|
||||||
run: |
|
|
||||||
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: Start MinIO
|
|
||||||
run: |
|
|
||||||
docker pull quay.io/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
|
|
||||||
|
|
||||||
# Tool to wait for a service to be ready
|
|
||||||
- name: Install Dockerize
|
|
||||||
run: |
|
|
||||||
curl --proto "=https" --proto-redir "=https" --tlsv1.2 -sSLf \
|
|
||||||
https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz |
|
|
||||||
sudo tar -C /usr/local/bin -xzv
|
|
||||||
|
|
||||||
- name: Wait for MinIO to be ready
|
|
||||||
run: |
|
|
||||||
dockerize -wait tcp://localhost:9000 -timeout 10s
|
|
||||||
|
|
||||||
- name: Configure MinIO
|
|
||||||
run: |
|
|
||||||
MINIO=$(docker ps | grep minio/minio | sed -E 's/.*\s+([a-zA-Z0-9_-]+)$/\1/')
|
|
||||||
docker exec ${MINIO} sh -c \
|
|
||||||
"mc alias set meet http://localhost:9000 meet password && \
|
|
||||||
mc alias ls && \
|
|
||||||
mc mb meet/meet-media-storage"
|
|
||||||
|
|
||||||
- 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 dependencies
|
|
||||||
run: uv sync --locked --all-extras
|
|
||||||
|
|
||||||
- name: Install gettext (required to compile messages)
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y gettext
|
|
||||||
|
|
||||||
- name: Generate a MO file from strings extracted from the project
|
|
||||||
run: uv run --no-sync --no-build python manage.py compilemessages
|
|
||||||
|
|
||||||
- name: Run tests
|
|
||||||
run: uv run --no-sync --no-build pytest -n 2
|
|
||||||
|
|
||||||
test-summary:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: src/summary
|
|
||||||
|
|
||||||
env:
|
|
||||||
AUTHORIZED_TENANTS: '[{"id": "test-tenant", "api_key": "test-api-token", "webhook_url": "https://example.com/webhook", "webhook_api_key": "test-webhook-api-key"}]'
|
|
||||||
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
|
|
||||||
AWS_S3_ENDPOINT_URL: "minio:9000"
|
|
||||||
AWS_S3_ACCESS_KEY_ID: "meet"
|
|
||||||
AWS_S3_SECRET_ACCESS_KEY: "password"
|
|
||||||
WHISPERX_BASE_URL: "https://configure-your-url.com"
|
|
||||||
WHISPERX_ASR_MODEL: "large-v2"
|
|
||||||
WHISPERX_API_KEY: "test-whisperx-secret"
|
|
||||||
WHISPERX_DEFAULT_LANGUAGE: "fr"
|
|
||||||
LLM_BASE_URL: "https://configure-your-url.com"
|
|
||||||
LLM_API_KEY: "test-llm-secret"
|
|
||||||
LLM_MODEL: "test-llm-model"
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
|
||||||
|
|
||||||
- name: Install ffmpeg
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo apt-get install -y ffmpeg
|
|
||||||
|
|
||||||
- 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: Run summary tests
|
|
||||||
run: uv run --no-sync --no-build pytest
|
|
||||||
|
|
||||||
lint-front:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: cd src/frontend/ && npm ci --ignore-scripts
|
|
||||||
|
|
||||||
- name: Check linting
|
|
||||||
run: cd src/frontend/ && npm run lint
|
|
||||||
|
|
||||||
- name: Check format
|
|
||||||
run: cd src/frontend/ && npm run check
|
|
||||||
|
|
||||||
lint-sdk:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: src/sdk/library
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci --ignore-scripts
|
|
||||||
|
|
||||||
- name: Check linting
|
|
||||||
run: npm run lint
|
|
||||||
|
|
||||||
- name: Check format
|
|
||||||
run: npm run check
|
|
||||||
|
|
||||||
build-sdk:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
needs: lint-sdk
|
|
||||||
defaults:
|
|
||||||
run:
|
|
||||||
working-directory: src/sdk/library
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: npm ci --ignore-scripts
|
|
||||||
|
|
||||||
- name: Build SDK
|
|
||||||
run: npm run build
|
|
||||||
@@ -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@v6
|
||||||
|
|
||||||
|
- name: Download Crowdin files
|
||||||
|
uses: crowdin/github-action@v2
|
||||||
|
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
|
name: Docker Hub Workflow
|
||||||
run-name: Docker images
|
run-name: Docker Hub Workflow
|
||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
@@ -15,61 +15,265 @@ on:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
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:
|
jobs:
|
||||||
build-and-push:
|
build-and-push-backend:
|
||||||
name: ${{ matrix.service }}
|
runs-on: ubuntu-latest
|
||||||
strategy:
|
permissions:
|
||||||
fail-fast: false
|
contents: read
|
||||||
matrix:
|
steps:
|
||||||
include:
|
-
|
||||||
- service: backend
|
name: Checkout repository
|
||||||
image_name: lasuite/meet-backend
|
uses: actions/checkout@v6
|
||||||
context: .
|
-
|
||||||
file: ./Dockerfile
|
name: Set up QEMU
|
||||||
target: backend-production
|
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||||
- service: frontend
|
uses: docker/setup-qemu-action@v3
|
||||||
image_name: lasuite/meet-frontend
|
-
|
||||||
context: .
|
name: Set up Docker Buildx
|
||||||
file: ./src/frontend/Dockerfile
|
uses: docker/setup-buildx-action@v3
|
||||||
target: frontend-production
|
-
|
||||||
- service: frontend-dinum
|
name: Docker meta
|
||||||
image_name: lasuite/meet-frontend-dinum
|
id: meta
|
||||||
context: .
|
uses: docker/metadata-action@v5
|
||||||
file: ./docker/dinum-frontend/Dockerfile
|
with:
|
||||||
target: frontend-production
|
images: '${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend'
|
||||||
- service: summary
|
-
|
||||||
image_name: lasuite/meet-summary
|
name: Login to DockerHub
|
||||||
context: ./src/summary
|
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||||
file: ./src/summary/Dockerfile
|
uses: docker/login-action@v3
|
||||||
target: production
|
with:
|
||||||
- service: agents
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
image_name: lasuite/meet-agents
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
context: ./src/agents
|
-
|
||||||
file: ./src/agents/Dockerfile
|
name: Run trivy scan
|
||||||
target: production
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
uses: suitenumerique/ci/.github/workflows/_docker-publish.yml@main
|
with:
|
||||||
with:
|
docker-build-args: '--target backend-production -f Dockerfile'
|
||||||
image_name: ${{ matrix.image_name }}
|
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-backend:${{ github.sha }}'
|
||||||
context: ${{ matrix.context }}
|
-
|
||||||
file: ${{ matrix.file }}
|
name: Build and push
|
||||||
target: ${{ matrix.target }}
|
uses: docker/build-push-action@v6
|
||||||
docker_user: "1001:127"
|
with:
|
||||||
is_multi_platform: ${{ startsWith(github.ref, 'refs/tags/v') }}
|
context: .
|
||||||
should_push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
|
target: backend-production
|
||||||
trivy_scan: true
|
platforms: ${{ env.BUILD_PLATFORMS }}
|
||||||
secrets:
|
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||||
DOCKER_HUB_USER: ${{ secrets.DOCKER_HUB_USER }}
|
push: ${{ github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/') }}
|
||||||
DOCKER_HUB_PASSWORD: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
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@v6
|
||||||
|
-
|
||||||
|
name: Set up QEMU
|
||||||
|
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
-
|
||||||
|
name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
-
|
||||||
|
name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
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@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
|
-
|
||||||
|
name: Run trivy scan
|
||||||
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
|
with:
|
||||||
|
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
|
||||||
|
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend:${{ github.sha }}'
|
||||||
|
-
|
||||||
|
name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
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@v6
|
||||||
|
-
|
||||||
|
name: Set up QEMU
|
||||||
|
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
-
|
||||||
|
name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
-
|
||||||
|
name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
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@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
|
-
|
||||||
|
name: Run trivy scan
|
||||||
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
|
with:
|
||||||
|
docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
|
||||||
|
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-frontend-dinum:${{ github.sha }}'
|
||||||
|
-
|
||||||
|
name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
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@v6
|
||||||
|
-
|
||||||
|
name: Set up QEMU
|
||||||
|
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
-
|
||||||
|
name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
-
|
||||||
|
name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
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@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
|
-
|
||||||
|
name: Run trivy scan
|
||||||
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
docker-build-args: '-f src/summary/Dockerfile --target production'
|
||||||
|
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-summary:${{ github.sha }}'
|
||||||
|
docker-context: './src/summary'
|
||||||
|
-
|
||||||
|
name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
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@v6
|
||||||
|
-
|
||||||
|
name: Set up QEMU
|
||||||
|
if: env.IS_MULTI_PLATFORM_BUILD == 'true'
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
-
|
||||||
|
name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
-
|
||||||
|
name: Docker meta
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: lasuite/meet-agents
|
||||||
|
-
|
||||||
|
name: Login to DockerHub
|
||||||
|
if: github.event_name != 'pull_request' || startsWith(github.head_ref, 'integration/')
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||||
|
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||||
|
-
|
||||||
|
name: Run trivy scan
|
||||||
|
uses: numerique-gouv/action-trivy-cache@main
|
||||||
|
continue-on-error: true
|
||||||
|
with:
|
||||||
|
docker-build-args: '-f src/agents/Dockerfile --target production'
|
||||||
|
docker-image-name: '${{ env.DOCKER_CONTAINER_REGISTRY_HOSTNAME }}/${{ env.DOCKER_CONTAINER_REGISTRY_NAMESPACE }}/meet-agents:${{ github.sha }}'
|
||||||
|
docker-context: './src/agents'
|
||||||
|
-
|
||||||
|
name: Build and push
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
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:
|
notify-argocd:
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
needs:
|
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
|
runs-on: ubuntu-latest
|
||||||
if: github.event_name != 'pull_request'
|
if: github.event_name != 'pull_request'
|
||||||
steps:
|
steps:
|
||||||
- uses: suitenumerique/ci/actions/argocd-webhook-notification@main
|
- uses: numerique-gouv/action-argocd-webhook-notification@main
|
||||||
id: notify
|
id: notify
|
||||||
with:
|
with:
|
||||||
deployment_repo_path: "${{ secrets.DEPLOYMENT_REPO_URL }}"
|
deployment_repo_path: "${{ secrets.DEPLOYMENT_REPO_URL }}"
|
||||||
|
|||||||
@@ -0,0 +1,395 @@
|
|||||||
|
name: meet Workflow
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- "*"
|
||||||
|
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@v6
|
||||||
|
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)**/meet.yml' | grep "print("
|
||||||
|
- name: Check absence of fixup commits
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
! git log | grep 'fixup!'
|
||||||
|
- name: Install gitlint
|
||||||
|
if: always()
|
||||||
|
run: pip install --user requests gitlint
|
||||||
|
- name: Lint commit messages added to main
|
||||||
|
if: always()
|
||||||
|
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
|
||||||
|
|
||||||
|
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@v6
|
||||||
|
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@v6
|
||||||
|
- 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@v6
|
||||||
|
|
||||||
|
- name: Install Node.js
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: "22"
|
||||||
|
|
||||||
|
- name: Restore the mail templates
|
||||||
|
uses: actions/cache@v5
|
||||||
|
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 yarn
|
||||||
|
|
||||||
|
- name: Install node dependencies
|
||||||
|
if: steps.mail-templates.outputs.cache-hit != 'true'
|
||||||
|
run: yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
- 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@v5
|
||||||
|
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@v6
|
||||||
|
- name: Install Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: "3.13"
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v7
|
||||||
|
- name: Install the project
|
||||||
|
run: uv sync --locked --all-extras
|
||||||
|
|
||||||
|
- name: Check code formatting with ruff
|
||||||
|
run: uv run ruff format . --diff
|
||||||
|
- name: Lint code with ruff
|
||||||
|
run: uv run ruff check .
|
||||||
|
- name: Lint code with pylint
|
||||||
|
run: uv run 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@v6
|
||||||
|
- name: Install Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: "3.13"
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v7
|
||||||
|
- name: Install the project
|
||||||
|
run: uv sync --locked --all-extras
|
||||||
|
- name: Check code formatting with ruff
|
||||||
|
run: uv run ruff format . --diff
|
||||||
|
- name: Lint code with ruff
|
||||||
|
run: uv run ruff check .
|
||||||
|
|
||||||
|
lint-summary:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: src/summary
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
- name: Install Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: "3.13"
|
||||||
|
cache: "pip"
|
||||||
|
- name: Install development dependencies
|
||||||
|
run: pip install --user .[dev]
|
||||||
|
- name: Check code formatting with ruff
|
||||||
|
run: ~/.local/bin/ruff format . --diff
|
||||||
|
- name: Lint code with ruff
|
||||||
|
run: ~/.local/bin/ruff check .
|
||||||
|
|
||||||
|
test-back:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: build-mails
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: src/backend
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: meet
|
||||||
|
POSTGRES_USER: dinum
|
||||||
|
POSTGRES_PASSWORD: pass
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
# needed because the postgres container does not provide a healthcheck
|
||||||
|
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||||
|
redis:
|
||||||
|
image: redis:5
|
||||||
|
ports:
|
||||||
|
- 6379:6379
|
||||||
|
# Set health checks to wait until redis has started
|
||||||
|
options: >-
|
||||||
|
--health-cmd "redis-cli ping"
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
|
||||||
|
env:
|
||||||
|
DJANGO_CONFIGURATION: Test
|
||||||
|
DJANGO_SETTINGS_MODULE: meet.settings
|
||||||
|
DJANGO_SECRET_KEY: ThisIsAnExampleKeyForTestPurposeOnly
|
||||||
|
OIDC_OP_JWKS_ENDPOINT: /endpoint-for-test-purpose-only
|
||||||
|
DB_HOST: localhost
|
||||||
|
DB_NAME: meet
|
||||||
|
DB_USER: dinum
|
||||||
|
DB_PASSWORD: pass
|
||||||
|
DB_PORT: 5432
|
||||||
|
REDIS_URL: redis://localhost:6379/1
|
||||||
|
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||||
|
AWS_S3_ENDPOINT_URL: http://localhost:9000
|
||||||
|
AWS_S3_ACCESS_KEY_ID: meet
|
||||||
|
AWS_S3_SECRET_ACCESS_KEY: password
|
||||||
|
OIDC_RS_CLIENT_ID: meet
|
||||||
|
OIDC_RS_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
||||||
|
OIDC_OP_INTROSPECTION_ENDPOINT: https://oidc.example.com/introspect
|
||||||
|
OIDC_OP_URL: https://oidc.example.com
|
||||||
|
MEDIA_BASE_URL: http://localhost:8083
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Create writable /data
|
||||||
|
run: |
|
||||||
|
sudo mkdir -p /data/media && \
|
||||||
|
sudo mkdir -p /data/static
|
||||||
|
|
||||||
|
- name: Restore the mail templates
|
||||||
|
uses: actions/cache@v5
|
||||||
|
id: mail-templates
|
||||||
|
with:
|
||||||
|
path: "src/backend/core/templates/mail"
|
||||||
|
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||||
|
|
||||||
|
- name: Start MinIO
|
||||||
|
run: |
|
||||||
|
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 \
|
||||||
|
minio/minio server --console-address :9001 /data
|
||||||
|
|
||||||
|
# Tool to wait for a service to be ready
|
||||||
|
- name: Install Dockerize
|
||||||
|
run: |
|
||||||
|
curl -sSL https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz | sudo tar -C /usr/local/bin -xzv
|
||||||
|
|
||||||
|
- name: Wait for MinIO to be ready
|
||||||
|
run: |
|
||||||
|
dockerize -wait tcp://localhost:9000 -timeout 10s
|
||||||
|
|
||||||
|
- name: Configure MinIO
|
||||||
|
run: |
|
||||||
|
MINIO=$(docker ps | grep minio/minio | sed -E 's/.*\s+([a-zA-Z0-9_-]+)$/\1/')
|
||||||
|
docker exec ${MINIO} sh -c \
|
||||||
|
"mc alias set meet http://localhost:9000 meet password && \
|
||||||
|
mc alias ls && \
|
||||||
|
mc mb meet/meet-media-storage"
|
||||||
|
|
||||||
|
- name: Install Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: "3.13"
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v7
|
||||||
|
- name: Install the dependencies
|
||||||
|
run: uv sync --locked --all-extras
|
||||||
|
|
||||||
|
- name: Install gettext (required to compile messages)
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y gettext
|
||||||
|
|
||||||
|
- name: Generate a MO file from strings extracted from the project
|
||||||
|
run: uv run python manage.py compilemessages
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: uv run pytest -n 2
|
||||||
|
|
||||||
|
test-summary:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: src/summary
|
||||||
|
|
||||||
|
env:
|
||||||
|
AUTHORIZED_TENANTS: '[{"id": "test-tenant", "api_key": "test-api-token", "webhook_url": "https://example.com/webhook", "webhook_api_key": "test-webhook-api-key"}]'
|
||||||
|
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
|
||||||
|
AWS_S3_ENDPOINT_URL: "minio:9000"
|
||||||
|
AWS_S3_ACCESS_KEY_ID: "meet"
|
||||||
|
AWS_S3_SECRET_ACCESS_KEY: "password"
|
||||||
|
WHISPERX_BASE_URL: "https://configure-your-url.com"
|
||||||
|
WHISPERX_ASR_MODEL: "large-v2"
|
||||||
|
WHISPERX_API_KEY: "test-whisperx-secret"
|
||||||
|
WHISPERX_DEFAULT_LANGUAGE: "fr"
|
||||||
|
LLM_BASE_URL: "https://configure-your-url.com"
|
||||||
|
LLM_API_KEY: "test-llm-secret"
|
||||||
|
LLM_MODEL: "test-llm-model"
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Install ffmpeg
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y ffmpeg
|
||||||
|
|
||||||
|
- name: Install Python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
|
with:
|
||||||
|
python-version: "3.13"
|
||||||
|
cache: "pip"
|
||||||
|
|
||||||
|
- name: Install development dependencies
|
||||||
|
run: pip install --user .[dev]
|
||||||
|
|
||||||
|
- name: Run summary tests
|
||||||
|
run: ~/.local/bin/pytest
|
||||||
|
|
||||||
|
lint-front:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: cd src/frontend/ && npm ci
|
||||||
|
|
||||||
|
- name: Check linting
|
||||||
|
run: cd src/frontend/ && npm run lint
|
||||||
|
|
||||||
|
- name: Check format
|
||||||
|
run: cd src/frontend/ && npm run check
|
||||||
|
|
||||||
|
lint-sdk:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: src/sdk/library
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Check linting
|
||||||
|
run: npm run lint
|
||||||
|
|
||||||
|
- name: Check format
|
||||||
|
run: npm run check
|
||||||
|
|
||||||
|
build-sdk:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
needs: lint-sdk
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: src/sdk/library
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Build SDK
|
||||||
|
run: npm run build
|
||||||
@@ -1,17 +1,33 @@
|
|||||||
name: Release Helm chart
|
name: Release Chart
|
||||||
|
run-name: Release Chart
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
paths:
|
paths:
|
||||||
- src/helm/meet/**
|
- src/helm/meet/**
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
uses: suitenumerique/ci/.github/workflows/_release-helm-chart.yml@main
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Cleanup
|
||||||
|
run: rm -rf ./src/helm/extra
|
||||||
|
|
||||||
|
- name: Install Helm
|
||||||
|
uses: azure/setup-helm@v4
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
|
|
||||||
|
- name: Publish Helm charts
|
||||||
|
uses: numerique-gouv/helm-gh-pages@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
|
# Frontend rollup-plugin-visualizer
|
||||||
/src/frontend/rollup-plugin-visualizer/*
|
/src/frontend/rollup-plugin-visualizer/*
|
||||||
|
|
||||||
# NixOS
|
|
||||||
.devenv
|
|
||||||
|
|||||||
+1
-94
@@ -8,107 +8,14 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- ✨(agent) support Voxtral realtime as inference engine
|
- ✨(agent) support Voxtral realtime as inference engine
|
||||||
- 🌐(i18n) add Spanish language support
|
|
||||||
- ✨(frontend) expose publish permissions on the media state element #1661
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- 🔥(backend) remove the S3 storage-event webhook for recordings
|
- 🔥(backend) remove the S3 storage-event webhook for recordings
|
||||||
- ♻️(backend) always finalize recordings using the LiveKit egress_ended webhook
|
- ♻️(backend) always finalize recordings using the LiveKit egress_ended webhook
|
||||||
- ⬆️(frontend) upgrade posthog-js from 1.409.5 to 1.414.0
|
|
||||||
- ⬆️(frontend) upgrade @fontsource-variable/lexend from 5.2.11 to 5.3.0
|
|
||||||
- ⬆️(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
|
|
||||||
|
|
||||||
- 🐛(frontend) fix chat text-area bug
|
|
||||||
|
|
||||||
## [1.29.0] - 2026-08-25
|
## [1.29.0] - 2026-08-25
|
||||||
|
|
||||||
@@ -374,7 +281,7 @@ and this project adheres to
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- ♿️(frontend) improve accessibility of the Effects panel #1401
|
- ♿️(frontend) improve accessibilty of the Effects panel #1401
|
||||||
|
|
||||||
## [1.20.0] - 2026-06-12
|
## [1.20.0] - 2026-06-12
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
# Django Meet
|
# Django Meet
|
||||||
|
|
||||||
# ---- base image to inherit from ----
|
# ---- 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
|
# Upgrade pip to its latest release to speed up dependencies installation
|
||||||
RUN python -m pip install --upgrade pip
|
RUN python -m pip install --upgrade pip
|
||||||
|
|||||||
@@ -39,16 +39,14 @@ DB_PORT = 5432
|
|||||||
DOCKER_UID = $(shell id -u)
|
DOCKER_UID = $(shell id -u)
|
||||||
DOCKER_GID = $(shell id -g)
|
DOCKER_GID = $(shell id -g)
|
||||||
DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
|
DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
|
||||||
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
|
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
|
||||||
COMPOSE_EXEC = $(COMPOSE) exec
|
COMPOSE_EXEC = $(COMPOSE) exec
|
||||||
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
|
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
|
||||||
COMPOSE_RUN = $(COMPOSE) run --rm
|
COMPOSE_RUN = $(COMPOSE) run --rm
|
||||||
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
|
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
|
||||||
COMPOSE_RUN_LINT_BACK = $(COMPOSE_RUN) --no-deps app-dev
|
COMPOSE_RUN_LINT = $(COMPOSE_RUN) --no-deps app-dev
|
||||||
COMPOSE_RUN_LINT_AGENTS = $(COMPOSE_RUN) --no-deps multi-user-transcriber-dev
|
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
|
||||||
COMPOSE_RUN_LINT_SUMMARY = $(COMPOSE_RUN) --no-deps app-summary-dev
|
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
|
||||||
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
|
|
||||||
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
|
|
||||||
|
|
||||||
# -- Backend
|
# -- Backend
|
||||||
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
||||||
@@ -61,10 +59,6 @@ LINT_PYLINT = pylint meet demo core
|
|||||||
LINT_BACK = echo 'lint:ruff-format started…' && $(LINT_RUFF_FORMAT) \
|
LINT_BACK = echo 'lint:ruff-format started…' && $(LINT_RUFF_FORMAT) \
|
||||||
&& echo 'lint:ruff-check started…' && $(LINT_RUFF_CHECK) \
|
&& echo 'lint:ruff-check started…' && $(LINT_RUFF_CHECK) \
|
||||||
&& echo 'lint:pylint started…' && $(LINT_PYLINT)
|
&& echo 'lint:pylint started…' && $(LINT_PYLINT)
|
||||||
LINT_AGENTS = echo 'lint:ruff-format started…' && $(LINT_RUFF_FORMAT) \
|
|
||||||
&& echo 'lint:ruff-check started…' && $(LINT_RUFF_CHECK)
|
|
||||||
LINT_SUMMARY = echo 'lint:ruff-format started…' && $(LINT_RUFF_FORMAT) \
|
|
||||||
&& echo 'lint:ruff-check started…' && $(LINT_RUFF_CHECK)
|
|
||||||
|
|
||||||
# -- Frontend
|
# -- Frontend
|
||||||
PATH_FRONT = ./src/frontend
|
PATH_FRONT = ./src/frontend
|
||||||
@@ -204,37 +198,23 @@ demo: ## flush db then create a demo for load testing purpose
|
|||||||
@$(MANAGE) create_demo
|
@$(MANAGE) create_demo
|
||||||
.PHONY: demo
|
.PHONY: demo
|
||||||
|
|
||||||
lint: ## lint all python sources (back-end, agents, summary)
|
lint: ## lint back-end python sources
|
||||||
@$(MAKE) lint-back
|
@$(COMPOSE_RUN_LINT) sh -c "$(LINT_BACK)"
|
||||||
@$(MAKE) lint-agents
|
|
||||||
@$(MAKE) lint-summary
|
|
||||||
.PHONY: lint
|
.PHONY: lint
|
||||||
|
|
||||||
lint-back: ## lint back-end python sources
|
|
||||||
@$(COMPOSE_RUN_LINT_BACK) sh -c "$(LINT_BACK)"
|
|
||||||
.PHONY: lint-back
|
|
||||||
|
|
||||||
lint-agents: ## lint agents python sources
|
|
||||||
@$(COMPOSE_RUN_LINT_AGENTS) sh -c "$(LINT_AGENTS)"
|
|
||||||
.PHONY: lint-agents
|
|
||||||
|
|
||||||
lint-summary: ## lint summary python sources
|
|
||||||
@$(COMPOSE_RUN_LINT_SUMMARY) sh -c "$(LINT_SUMMARY)"
|
|
||||||
.PHONY: lint-summary
|
|
||||||
|
|
||||||
lint-ruff-format: ## format back-end python sources with ruff
|
lint-ruff-format: ## format back-end python sources with ruff
|
||||||
@echo 'lint:ruff-format started…'
|
@echo 'lint:ruff-format started…'
|
||||||
@$(COMPOSE_RUN_LINT_BACK) $(LINT_RUFF_FORMAT)
|
@$(COMPOSE_RUN_LINT) $(LINT_RUFF_FORMAT)
|
||||||
.PHONY: lint-ruff-format
|
.PHONY: lint-ruff-format
|
||||||
|
|
||||||
lint-ruff-check: ## lint back-end python sources with ruff
|
lint-ruff-check: ## lint back-end python sources with ruff
|
||||||
@echo 'lint:ruff-check started…'
|
@echo 'lint:ruff-check started…'
|
||||||
@$(COMPOSE_RUN_LINT_BACK) $(LINT_RUFF_CHECK)
|
@$(COMPOSE_RUN_LINT) $(LINT_RUFF_CHECK)
|
||||||
.PHONY: lint-ruff-check
|
.PHONY: lint-ruff-check
|
||||||
|
|
||||||
lint-pylint: ## lint back-end python sources with pylint only on changed files from main
|
lint-pylint: ## lint back-end python sources with pylint only on changed files from main
|
||||||
@echo 'lint:pylint started…'
|
@echo 'lint:pylint started…'
|
||||||
@$(COMPOSE_RUN_LINT_BACK) $(LINT_PYLINT)
|
@$(COMPOSE_RUN_LINT) $(LINT_PYLINT)
|
||||||
.PHONY: lint-pylint
|
.PHONY: lint-pylint
|
||||||
|
|
||||||
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"`
|
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"`
|
||||||
@@ -292,7 +272,7 @@ shell: ## connect to database shell
|
|||||||
# -- Database
|
# -- Database
|
||||||
|
|
||||||
dbshell: ## connect to database shell
|
dbshell: ## connect to database shell
|
||||||
@$(COMPOSE_EXEC_APP) python manage.py dbshell
|
docker compose exec app-dev python manage.py dbshell
|
||||||
.PHONY: dbshell
|
.PHONY: dbshell
|
||||||
|
|
||||||
resetdb: FLUSH_ARGS ?=
|
resetdb: FLUSH_ARGS ?=
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ the following command inside your docker container:
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
## v1.30.0
|
|
||||||
|
|
||||||
### Removing S3 storage-event webhooks for recordings
|
### Removing S3 storage-event webhooks for recordings
|
||||||
|
|
||||||
Recordings were previously confirmed as saved by an S3 storage-event webhook posting to `/api/v1.0/recordings/storage-hook/`. That endpoint has been removed: recordings are now always finalized from LiveKit's own `egress_ended` webhook, which has been the default path since v1.22.0.
|
Recordings were previously confirmed as saved by an S3 storage-event webhook posting to `/api/v1.0/recordings/storage-hook/`. That endpoint has been removed: recordings are now always finalized from LiveKit's own `egress_ended` webhook, which has been the default path since v1.22.0.
|
||||||
|
|||||||
+10
-9
@@ -5,7 +5,7 @@ set -eo pipefail
|
|||||||
REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
|
REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
|
||||||
UNSET_USER=0
|
UNSET_USER=0
|
||||||
|
|
||||||
export COMPOSE_FILE="${COMPOSE_FILE:-${REPO_DIR}/compose.yml}"
|
COMPOSE_FILE="${REPO_DIR}/compose.yml"
|
||||||
COMPOSE_PROJECT="meet"
|
COMPOSE_PROJECT="meet"
|
||||||
|
|
||||||
|
|
||||||
@@ -42,6 +42,7 @@ function _docker_compose() {
|
|||||||
echo "🐳(compose) project: '${COMPOSE_PROJECT}' file: '${COMPOSE_FILE}'"
|
echo "🐳(compose) project: '${COMPOSE_PROJECT}' file: '${COMPOSE_FILE}'"
|
||||||
docker compose \
|
docker compose \
|
||||||
-p "${COMPOSE_PROJECT}" \
|
-p "${COMPOSE_PROJECT}" \
|
||||||
|
-f "${COMPOSE_FILE}" \
|
||||||
--project-directory "${REPO_DIR}" \
|
--project-directory "${REPO_DIR}" \
|
||||||
"$@"
|
"$@"
|
||||||
}
|
}
|
||||||
@@ -55,12 +56,12 @@ function _docker_compose() {
|
|||||||
function _dc_run() {
|
function _dc_run() {
|
||||||
_set_user
|
_set_user
|
||||||
|
|
||||||
user_args=()
|
user_args="--user=$USER_ID"
|
||||||
if [ -n "$USER_ID" ]; then
|
if [ -z $USER_ID ]; then
|
||||||
user_args=("--user=$USER_ID")
|
user_args=""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
_docker_compose run --rm "${user_args[@]}" "$@"
|
_docker_compose run --rm $user_args "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
# _dc_exec: wrap docker compose exec command
|
# _dc_exec: wrap docker compose exec command
|
||||||
@@ -74,12 +75,12 @@ function _dc_exec() {
|
|||||||
|
|
||||||
echo "🐳(compose) exec command: '\$@'"
|
echo "🐳(compose) exec command: '\$@'"
|
||||||
|
|
||||||
user_args=()
|
user_args="--user=$USER_ID"
|
||||||
if [ -n "$USER_ID" ]; then
|
if [ -z $USER_ID ]; then
|
||||||
user_args=("--user=$USER_ID")
|
user_args=""
|
||||||
fi
|
fi
|
||||||
|
|
||||||
_docker_compose exec "${user_args[@]}" "$@"
|
_docker_compose exec $user_args "$@"
|
||||||
}
|
}
|
||||||
|
|
||||||
# _django_manage: wrap django's manage.py command with docker compose
|
# _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
|
[[ "$IS_SVG" == false ]] && echo "[custom-logo] ERROR: not a valid SVG file" >&2 && exit 1
|
||||||
|
|
||||||
mv -f "$TMP_FILE" "$LOGO_FILE"
|
mv -f "$TMP_FILE" "$LOGO_FILE"
|
||||||
echo "[custom-logo] INFO: Custom logo downloaded successfully"
|
echo "[custom-logo] INFO: Custom logo downloaded successfuly"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mv src/backend/* ./
|
mv src/backend/* ./
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ gunicorn -b 0.0.0.0:8000 meet.wsgi:application --log-file - &
|
|||||||
bin/run &
|
bin/run &
|
||||||
|
|
||||||
# if the current shell is killed, also terminate all its children
|
# 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 for a single child to finish,
|
||||||
wait -n
|
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
|
#!/usr/bin/env bash
|
||||||
set -o errexit
|
set -o errexit
|
||||||
|
|
||||||
|
CURRENT_DIR=$(pwd)
|
||||||
NAMESPACE=${1:-meet}
|
NAMESPACE=${1:-meet}
|
||||||
SECRET_NAME=${2:-bitwarden-cli-meet}
|
SECRET_NAME=${2:-bitwarden-cli-meet}
|
||||||
TEMP_SECRET_FILE=$(mktemp)
|
TEMP_SECRET_FILE=$(mktemp)
|
||||||
@@ -29,10 +30,10 @@ check_secret_exists() {
|
|||||||
# Collect user input securely
|
# Collect user input securely
|
||||||
get_user_input() {
|
get_user_input() {
|
||||||
echo "Please provide the following information:"
|
echo "Please provide the following information:"
|
||||||
read -r -p "Enter your Vaultwarden email login: " LOGIN
|
read -p "Enter your Vaultwarden email login: " LOGIN
|
||||||
read -r -s -p "Enter your Vaultwarden password: " PASSWORD
|
read -s -p "Enter your Vaultwarden password: " PASSWORD
|
||||||
echo
|
echo
|
||||||
read -r -p "Enter your Vaultwarden server url: " URL
|
read -p "Enter your Vaultwarden server url: " URL
|
||||||
}
|
}
|
||||||
|
|
||||||
# Create and apply the secret
|
# Create and apply the secret
|
||||||
@@ -76,7 +77,7 @@ main() {
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "${TEMP_SECRET_FILE}"
|
echo -e ${TEMP_SECRET_FILE}
|
||||||
|
|
||||||
get_user_input
|
get_user_input
|
||||||
echo -e "\nCreating Vaultwarden secret…"
|
echo -e "\nCreating Vaultwarden secret…"
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
mkdir -p "$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/"
|
mkdir -p "$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/"
|
||||||
PRE_COMMIT_FILE="$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/pre-commit"
|
PRE_COMMIT_FILE="$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/pre-commit"
|
||||||
|
|
||||||
cat <<'EOF' >"$PRE_COMMIT_FILE"
|
cat <<'EOF' >$PRE_COMMIT_FILE
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
# directories containing potential secrets
|
# directories containing potential secrets
|
||||||
@@ -27,4 +27,4 @@ for d in $DIRS; do
|
|||||||
done
|
done
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
chmod +x "$PRE_COMMIT_FILE"
|
chmod +x $PRE_COMMIT_FILE
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ fi
|
|||||||
|
|
||||||
# Ask user for release version number
|
# Ask user for release version number
|
||||||
echo ""
|
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)
|
# Validate version format (basic semver check)
|
||||||
if ! [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
if ! [[ $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
@@ -110,12 +110,6 @@ cd -
|
|||||||
# Update summary pyproject.toml
|
# Update summary pyproject.toml
|
||||||
update_python_version "summary"
|
update_python_version "summary"
|
||||||
|
|
||||||
# Run uv lock in summary
|
|
||||||
print_info "Running uv lock in summary..."
|
|
||||||
cd "src/summary"
|
|
||||||
uv lock
|
|
||||||
cd -
|
|
||||||
|
|
||||||
# Update agents pyproject.toml
|
# Update agents pyproject.toml
|
||||||
update_python_version "agents"
|
update_python_version "agents"
|
||||||
|
|
||||||
@@ -169,7 +163,6 @@ echo " - src/mail/package.json"
|
|||||||
echo " - src/backend/pyproject.toml"
|
echo " - src/backend/pyproject.toml"
|
||||||
echo " - src/backend/uv.lock"
|
echo " - src/backend/uv.lock"
|
||||||
echo " - src/summary/pyproject.toml"
|
echo " - src/summary/pyproject.toml"
|
||||||
echo " - src/summary/uv.lock"
|
|
||||||
echo " - src/agents/pyproject.toml"
|
echo " - src/agents/pyproject.toml"
|
||||||
echo " - src/agents/uv.lock"
|
echo " - src/agents/uv.lock"
|
||||||
echo " - CHANGELOG.md"
|
echo " - CHANGELOG.md"
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
git submodule update --init --recursive
|
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'
|
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
|
for env in $environments; do
|
||||||
echo "################### $env lint ###################"
|
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"
|
echo -e "\n"
|
||||||
done
|
done
|
||||||
|
|||||||
+3
-5
@@ -17,7 +17,7 @@ services:
|
|||||||
|
|
||||||
minio:
|
minio:
|
||||||
user: ${DOCKER_USER:-1000}
|
user: ${DOCKER_USER:-1000}
|
||||||
image: quay.io/minio/minio
|
image: minio/minio
|
||||||
environment:
|
environment:
|
||||||
- MINIO_ROOT_USER=meet
|
- MINIO_ROOT_USER=meet
|
||||||
- MINIO_ROOT_PASSWORD=password
|
- MINIO_ROOT_PASSWORD=password
|
||||||
@@ -35,7 +35,7 @@ services:
|
|||||||
- ./data/media:/data
|
- ./data/media:/data
|
||||||
|
|
||||||
createbuckets:
|
createbuckets:
|
||||||
image: quay.io/minio/mc
|
image: minio/mc
|
||||||
depends_on:
|
depends_on:
|
||||||
minio:
|
minio:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -207,7 +207,7 @@ services:
|
|||||||
- kc_postgresql
|
- kc_postgresql
|
||||||
|
|
||||||
livekit:
|
livekit:
|
||||||
image: livekit/livekit-server:v1.13.6
|
image: livekit/livekit-server
|
||||||
entrypoint: /livekit-server --dev --bind 0.0.0.0 --config ./config.yaml
|
entrypoint: /livekit-server --dev --bind 0.0.0.0 --config ./config.yaml
|
||||||
ports:
|
ports:
|
||||||
- "7880:7880"
|
- "7880:7880"
|
||||||
@@ -235,7 +235,6 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./src/agents
|
context: ./src/agents
|
||||||
target: development
|
target: development
|
||||||
user: ${DOCKER_USER:-1000}
|
|
||||||
command: ["python", "metadata_collector.py", "dev"]
|
command: ["python", "metadata_collector.py", "dev"]
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/metadata_collector
|
- env.d/development/metadata_collector
|
||||||
@@ -254,7 +253,6 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./src/agents
|
context: ./src/agents
|
||||||
target: development
|
target: development
|
||||||
user: ${DOCKER_USER:-1000}
|
|
||||||
command: ["python", "multi_user_transcriber.py", "dev"]
|
command: ["python", "multi_user_transcriber.py", "dev"]
|
||||||
env_file:
|
env_file:
|
||||||
- env.d/development/multi_user_transcriber
|
- env.d/development/multi_user_transcriber
|
||||||
|
|||||||
-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 ----
|
# ---- 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
|
USER root
|
||||||
RUN apk upgrade --no-cache libexpat && \
|
|
||||||
apk del curl
|
# Security patches for known CVEs
|
||||||
USER nginx
|
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
|
USER nginx
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
:root {
|
:root {
|
||||||
--fonts-sans: 'Marianne', ui-sans-serif, system-ui, sans-serif;
|
--fonts-sans: 'Marianne', ui-sans-serif, system-ui, sans-serif;
|
||||||
--avatar-cap-height: 0.7;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.Header-beforeLogo {
|
.Header-beforeLogo {
|
||||||
|
|||||||
@@ -14,4 +14,3 @@ accesslog = "-"
|
|||||||
# Using '-' for the error log file makes gunicorn log errors to stderr
|
# Using '-' for the error log file makes gunicorn log errors to stderr
|
||||||
errorlog = "-"
|
errorlog = "-"
|
||||||
loglevel = "info"
|
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
|
# We inject the nip.io certificate manually because the livekit chart doesn't support volume mounting
|
||||||
COPY rootCA.pem /etc/ssl/certs/
|
COPY rootCA.pem /etc/ssl/certs/
|
||||||
|
|||||||
@@ -21,10 +21,3 @@ turn:
|
|||||||
- 192.168.0.0/16
|
- 192.168.0.0/16
|
||||||
- 172.16.0.0/12
|
- 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_ALLOWED_HOSTS | Hosts that are allowed | [] |
|
||||||
| DJANGO_SECRET_KEY | Secret key used for Django security | |
|
| DJANGO_SECRET_KEY | Secret key used for Django security | |
|
||||||
| DJANGO_SILENCED_SYSTEM_CHECKS | Silence Django system checks | [] |
|
| 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_ENGINE | Database engine used | django.db.backends.postgresql_psycopg2 |
|
||||||
| DB_NAME | Name of the database | meet |
|
| DB_NAME | Name of the database | meet |
|
||||||
| DB_USER | User used to connect to database | dinum |
|
| 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:list` – List rooms accessible to the delegated user.
|
||||||
* `rooms:retrieve` – Retrieve details of a specific room.
|
* `rooms:retrieve` – Retrieve details of a specific room.
|
||||||
* `rooms:create` – Create new rooms.
|
* `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.
|
* `rooms:delete` – **Coming soon** Delete rooms generated by the application.
|
||||||
|
|
||||||
#### Upcoming Features
|
#### 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.
|
* **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.
|
* **Delete application-generated rooms:** Rooms created via the application can be deleted when no longer needed.
|
||||||
|
|
||||||
@@ -310,67 +310,6 @@ paths:
|
|||||||
'404':
|
'404':
|
||||||
$ref: '#/components/responses/RoomNotFoundError'
|
$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:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
BearerAuth:
|
BearerAuth:
|
||||||
@@ -447,17 +386,6 @@ components:
|
|||||||
configuration:
|
configuration:
|
||||||
$ref: '#/components/schemas/RoomConfiguration'
|
$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:
|
RoomConfiguration:
|
||||||
type: object
|
type: object
|
||||||
description: |
|
description: |
|
||||||
@@ -499,9 +427,6 @@ components:
|
|||||||
- `public`: Anyone with the room link can join directly, no authentication required.
|
- `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.
|
- `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.
|
- `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"
|
example: "trusted"
|
||||||
|
|
||||||
Room:
|
Room:
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ info:
|
|||||||
* `lasuite_visio:rooms:list` – List rooms accessible to the delegated user.
|
* `lasuite_visio:rooms:list` – List rooms accessible to the delegated user.
|
||||||
* `lasuite_visio:rooms:retrieve` – Retrieve details of a specific room.
|
* `lasuite_visio:rooms:retrieve` – Retrieve details of a specific room.
|
||||||
* `lasuite_visio:rooms:create` – Create new rooms.
|
* `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.
|
* `lasuite_visio:rooms:delete` – **Coming soon** Delete rooms generated by the application.
|
||||||
|
|
||||||
#### Upcoming Features
|
#### Upcoming Features
|
||||||
@@ -206,67 +206,6 @@ paths:
|
|||||||
'404':
|
'404':
|
||||||
$ref: '#/components/responses/RoomNotFoundError'
|
$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:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
BearerAuth:
|
BearerAuth:
|
||||||
@@ -288,17 +227,6 @@ components:
|
|||||||
configuration:
|
configuration:
|
||||||
$ref: '#/components/schemas/RoomConfiguration'
|
$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:
|
RoomConfiguration:
|
||||||
type: object
|
type: object
|
||||||
description: |
|
description: |
|
||||||
@@ -340,9 +268,6 @@ components:
|
|||||||
- `public`: Anyone with the room link can join directly, no authentication required.
|
- `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.
|
- `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.
|
- `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"
|
example: "trusted"
|
||||||
|
|
||||||
Room:
|
Room:
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ Let's say you want to change the font of our application to a custom font. You c
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
--fonts-sans: 'Roboto', ui-sans-serif, system-ui, sans-serif;
|
--fonts-sans: 'Roboto', ui-sans-serif, system-ui, sans-serif;
|
||||||
--avatar-cap-height: 0.7;
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ Gitlint extra rule to validate that the message title is of the form
|
|||||||
"<gitmoji>(<scope>) <subject>"
|
"<gitmoji>(<scope>) <subject>"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
import re
|
import re
|
||||||
import urllib.request
|
|
||||||
|
import requests
|
||||||
|
|
||||||
from gitlint.rules import CommitMessageTitle, LineRule, RuleViolation
|
from gitlint.rules import CommitMessageTitle, LineRule, RuleViolation
|
||||||
|
|
||||||
GITMOJIS_URL = "https://raw.githubusercontent.com/carloscuesta/gitmoji/master/packages/gitmojis/src/gitmojis.json"
|
|
||||||
|
|
||||||
|
|
||||||
class GitmojiTitle(LineRule):
|
class GitmojiTitle(LineRule):
|
||||||
"""
|
"""
|
||||||
@@ -28,9 +28,10 @@ class GitmojiTitle(LineRule):
|
|||||||
Download the list possible gitmojis from the project's github repository and check that
|
Download the list possible gitmojis from the project's github repository and check that
|
||||||
title contains one of them.
|
title contains one of them.
|
||||||
"""
|
"""
|
||||||
with urllib.request.urlopen(GITMOJIS_URL, timeout=10) as response:
|
gitmojis = requests.get(
|
||||||
gitmojis = json.load(response)["gitmojis"]
|
"https://raw.githubusercontent.com/carloscuesta/gitmoji/master/packages/gitmojis/src/gitmojis.json"
|
||||||
emojis = [re.escape(item["emoji"]) for item in gitmojis]
|
).json()["gitmojis"]
|
||||||
|
emojis = [item["emoji"] for item in gitmojis]
|
||||||
pattern = r"^({:s})\(.*\)\s[a-z].*$".format("|".join(emojis))
|
pattern = r"^({:s})\(.*\)\s[a-z].*$".format("|".join(emojis))
|
||||||
if not re.search(pattern, title):
|
if not re.search(pattern, title):
|
||||||
violation_msg = 'Title does not match regex "<gitmoji>(<scope>) <subject>"'
|
violation_msg = 'Title does not match regex "<gitmoji>(<scope>) <subject>"'
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"extends": ["github>suitenumerique/ci//renovate/default"],
|
"extends": ["github>numerique-gouv/renovate-configuration"],
|
||||||
"dependencyDashboard": true,
|
"dependencyDashboard": true,
|
||||||
"labels": ["dependencies", "noChangeLog"],
|
"labels": ["dependencies", "noChangeLog"],
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
|
|||||||
Generated
+8
-11
@@ -9,8 +9,8 @@
|
|||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "3.50.0",
|
"core-js": "3.49.0",
|
||||||
"i18next": "26.4.2",
|
"i18next": "^26.3.6",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"regenerator-runtime": "0.14.1"
|
"regenerator-runtime": "0.14.1"
|
||||||
},
|
},
|
||||||
@@ -6863,14 +6863,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/core-js": {
|
"node_modules/core-js": {
|
||||||
"version": "3.50.0",
|
"version": "3.49.0",
|
||||||
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz",
|
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
|
||||||
"integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==",
|
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
|
||||||
"node": "*"
|
|
||||||
},
|
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/core-js"
|
"url": "https://opencollective.com/core-js"
|
||||||
@@ -9367,9 +9364,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next": {
|
"node_modules/i18next": {
|
||||||
"version": "26.4.2",
|
"version": "26.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
|
||||||
"integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==",
|
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
|
|||||||
@@ -26,8 +26,8 @@
|
|||||||
"watch": "webpack --mode development --watch"
|
"watch": "webpack --mode development --watch"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "3.50.0",
|
"core-js": "3.49.0",
|
||||||
"i18next": "26.4.2",
|
"i18next": "26.3.6",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"regenerator-runtime": "0.14.1"
|
"regenerator-runtime": "0.14.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const { initI18n, translateUI } = require("../common/i18n");
|
|||||||
document.querySelector("#close-msg").style.display = "block";
|
document.querySelector("#close-msg").style.display = "block";
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
console.error(`Error occurred: ${e}`);
|
console.error(`Error occured: ${e}`);
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
// NOTE: doesn't work with the desktop client — the browser considers
|
// NOTE: doesn't work with the desktop client — the browser considers
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
FROM python:3.14.6-slim AS base
|
FROM python:3.14.6-slim AS base
|
||||||
|
|
||||||
# Install system dependencies required by LiveKit, fetching packages over HTTPS only for Bureautix proxy
|
# Install system dependencies required by LiveKit
|
||||||
RUN sed -i "s|^URIs: http://|URIs: https://|" /etc/apt/sources.list.d/debian.sources \
|
RUN apt-get update && apt-get install -y \
|
||||||
&& apt-get update && apt-get install -y --no-install-recommends \
|
|
||||||
libglib2.0-0 \
|
libglib2.0-0 \
|
||||||
libgobject-2.0-0 \
|
libgobject-2.0-0 \
|
||||||
libssl3t64 \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.32.1"
|
version = "1.29.0"
|
||||||
requires-python = ">=3.12"
|
requires-python = ">=3.12"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"livekit-agents==1.6.7",
|
"livekit-agents==1.6.7",
|
||||||
|
|||||||
Generated
+169
-190
@@ -9,7 +9,7 @@ resolution-markers = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agents"
|
name = "agents"
|
||||||
version = "1.32.1"
|
version = "1.29.0"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
@@ -524,11 +524,14 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "click"
|
name = "click"
|
||||||
version = "8.5.0"
|
version = "8.4.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
|
{ url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -669,43 +672,43 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "grpcio"
|
name = "grpcio"
|
||||||
version = "1.83.1"
|
version = "1.83.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/e2/b1/46539f5050d7c316a13396d185451f95084a74ddc68b12d818595bef0377/grpcio-1.83.1.tar.gz", hash = "sha256:9cee6fcbf2eb57c4b49451787bfa87be8efc1ca02a0b327dd4b54d44502e362b", size = 13445033, upload-time = "2026-08-28T07:09:11.464Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/0c/98/304898ac4e04e2d5e4e4c2eadc178b1f2a16d5f4bc2f91306c87d64680b9/grpcio-1.83.0.tar.gz", hash = "sha256:7674587248fbbb2ac6e4eecf83a8a0f3d91a928f941de571acfd3a2f007fbc24", size = 13428824, upload-time = "2026-07-23T15:20:37.759Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/85/9e/a3ba13e08bbee5bf6e57597dfe4823961fd7e94c0b8afe3a4bb7dca639f3/grpcio-1.83.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:5acd14c6ddf047de62cbf8745b11103ea91abbf57d1b8edd5395ccd9fcd13abb", size = 6303170, upload-time = "2026-08-28T07:08:08.188Z" },
|
{ url = "https://files.pythonhosted.org/packages/15/2b/51e32514a4e9b715375c99721aadff0f24164cc2049b8269eda4de82a814/grpcio-1.83.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:28f6c35ac8fcf10e4594f138e468f194360089dde40d126a7033e863fc479930", size = 6303167, upload-time = "2026-07-23T15:19:33.78Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1c/ae/65ce56a2527faa17d02cba4c2231c74047ad898be339486ba87f093bfb66/grpcio-1.83.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:16138031a47b771860a16a975b53087f4fd5bbdbb2c03a188c5d90ad65d2bdae", size = 12165806, upload-time = "2026-08-28T07:08:10.309Z" },
|
{ url = "https://files.pythonhosted.org/packages/39/33/b5b50fc2c6fbe350e04814047bb2d409feec7b36ef8b170254c050e06bc0/grpcio-1.83.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:33898e6a28e4ae598f1577cb1c4fec2a15c033d0ec52b9b45a09610dd045b9da", size = 12160538, upload-time = "2026-07-23T15:19:35.958Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4e/91/40432480088a2243d360864de072ed5b78c4ebbaabd29c28918f1e1b1454/grpcio-1.83.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5ccc26715fd4defca5e129e280dd883b1737b65045ec50ffe22ce42104089519", size = 6872490, upload-time = "2026-08-28T07:08:12.355Z" },
|
{ url = "https://files.pythonhosted.org/packages/7b/5f/734e72e7b9f79bcf0b2c270b8d3bca0e4ebb97a27a50d06240b145f6d41e/grpcio-1.83.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6fb8a1dd0c6f0f931e69e9d0dc6d1c406ed2a44fa963414eafba07b7fb685d16", size = 6869310, upload-time = "2026-07-23T15:19:38.607Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c8/62/3da2300c8c79fd20a78a8a4bb6251e5068d9af33bc8fd389b98fec35e8a3/grpcio-1.83.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b74f2a1d9ab1dfa3e263ef33d581613679b78d0884babf11671af26e45570ead", size = 7618367, upload-time = "2026-08-28T07:08:14.025Z" },
|
{ url = "https://files.pythonhosted.org/packages/a4/17/a1735f215b2a5cd43c38b79eac072ad197e61be9829905b6b29550abd0db/grpcio-1.83.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:2b5e75c34842cd9c1b95285ca395c6a569664b81e3ffa6b714125922942abaaf", size = 7613472, upload-time = "2026-07-23T15:19:40.645Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bc/19/9fc702e31a631262d7a752fa699f6022821e707fefc8bff49b1550a57729/grpcio-1.83.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:72578aa07a4008f17521ef52debcc3acfd1e2c5426243bc3ffb56a38bfe610b7", size = 7040936, upload-time = "2026-08-28T07:08:15.963Z" },
|
{ url = "https://files.pythonhosted.org/packages/b2/78/c9e81f806ac704b6b145cb01628db398985b1f8dfdc10e23b55fb0902b3d/grpcio-1.83.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeb339838db07600481ef869507279b75326c75eac6d10f7afa62a0da1d2bcdd", size = 7040616, upload-time = "2026-07-23T15:19:42.349Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ec/56/95933cc44cba2429765fa065c951dd529e5771b119d9d2481b4646f1d6a5/grpcio-1.83.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c12e1fc59c6dc26d10d9144453ddc6cbfe4cd4c31e874ed2d0132f88e685eb8b", size = 7573096, upload-time = "2026-08-28T07:08:17.729Z" },
|
{ url = "https://files.pythonhosted.org/packages/9a/ba/94cd5af859876049d340480acbb61a959096c84b567f215534faa78d0424/grpcio-1.83.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f47d62808b4c0a97b78bff88a6d4ca283a2a492b9a04a87d814af95ca3b9c19c", size = 7570491, upload-time = "2026-07-23T15:19:44.357Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ac/80/af63359da06b016de48cb111f144703a10043850dafa43ae0a038907b9e8/grpcio-1.83.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:4910b62f7d12197160bfb7de06d876d64dd12d43483e8292f98f49ca09b628d9", size = 8609442, upload-time = "2026-08-28T07:08:19.777Z" },
|
{ url = "https://files.pythonhosted.org/packages/3e/15/108d30d5a5c964312ae8b9cb0e8cc5b3c1cc68d8f757cca52b3565534d26/grpcio-1.83.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62003babc444a606dcd1f009cd16391ce23669ae4ad6ec267a873da7937a69f5", size = 8605036, upload-time = "2026-07-23T15:19:46.454Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3d/fa/f0586c56bdfb8a7a2adda01e0ac2413447cde3141ab09411a5d5afdcffd3/grpcio-1.83.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9e703effe3ae779925c82ac24fdb82cf4105e1096810151ed9501c5f34546b9c", size = 7984321, upload-time = "2026-08-28T07:08:22.114Z" },
|
{ url = "https://files.pythonhosted.org/packages/ea/23/3828ae13c3db8233d123ad612747665817b952d8a954f32390230b582336/grpcio-1.83.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1aa567f8c3f19850ffd5d2858c9a8ea7c80f0db6c01186b71eb31e923ec984f5", size = 7981587, upload-time = "2026-07-23T15:19:48.913Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/25/8a/14ec05669f9eb295801e26c2ea8c561a1b786b0e3557c2c22131165ab010/grpcio-1.83.1-cp312-cp312-win32.whl", hash = "sha256:a2aea8bd6e0a34f12cbaddb7bb70bec836818789fa5c7ab7572c6b745396a2d4", size = 4395604, upload-time = "2026-08-28T07:08:24.08Z" },
|
{ url = "https://files.pythonhosted.org/packages/17/5b/77af31228f55f55a2a5112bb0077ad0a1c4d23dbb0c2853a62475bbdcc14/grpcio-1.83.0-cp312-cp312-win32.whl", hash = "sha256:cb2906c61db4f9c64cc360054b5df70eeb81846228e9e56a4944bd415a63dadc", size = 4394004, upload-time = "2026-07-23T15:19:50.618Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e9/37/8c2f7cc16089e36a3fbacaacc7a3d043912aa0d2dfae5556f6450414ea6e/grpcio-1.83.1-cp312-cp312-win_amd64.whl", hash = "sha256:583bf2e8255040a4a312f9572dfe62a05271437b149550e1a536d5c47d2d1e8a", size = 5161512, upload-time = "2026-08-28T07:08:25.81Z" },
|
{ url = "https://files.pythonhosted.org/packages/c0/da/f706e39550e7a3732ce2b9c5926107a93d74a802775b19b642a6df27dc96/grpcio-1.83.0-cp312-cp312-win_amd64.whl", hash = "sha256:1c699bbb20f143c8f2bff219de578aa2dc1f919399d67dc702b038b986ee62df", size = 5158525, upload-time = "2026-07-23T15:19:52.246Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7c/fd/d1fc58933bf88c9209f89dc570c810f1aa57cb04b3459cf2b26f61e32112/grpcio-1.83.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:8d228e253b77865efcbdd7b5894ca882c9e0ea98c02b7d20582e61ded8dfd4b5", size = 6305628, upload-time = "2026-08-28T07:08:27.872Z" },
|
{ url = "https://files.pythonhosted.org/packages/56/eb/135daaa713f32d33b8f99b4153b3f8dc3b2a124996ac15581bf9ebdad3c3/grpcio-1.83.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:6662f3b1e07cc7493d437351860dc867bddc6a93c83ecf33bbfdaf0c217ab2d0", size = 6304480, upload-time = "2026-07-23T15:19:53.962Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c4/49/0b40bae059c619505c9b751cee6caa208e4904e290aaefa1728c4c2c67a5/grpcio-1.83.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:0468b627f2987c9a77f7580030207cbd85457ffe52998beff4f0b5c38c58a72c", size = 12156839, upload-time = "2026-08-28T07:08:30.191Z" },
|
{ url = "https://files.pythonhosted.org/packages/8f/a1/121806ce69f23138dabe06aa595b0e5f1ae051a37e4c1954eed7d692c800/grpcio-1.83.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:74fe6f9e8a35c7dbf32255ee154d15e3e5338a81ed39173d079d594d2e544cd1", size = 12154419, upload-time = "2026-07-23T15:19:56.3Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/61/4b/e8c0d635da0ee5ddd9950c8d540f5dcdd0ef1854a382cc55496a487a8d31/grpcio-1.83.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a6a282e81530cead60bbd752cc04950a57f224379e9821495d6a35bd5ce9b1f4", size = 6877036, upload-time = "2026-08-28T07:08:32.285Z" },
|
{ url = "https://files.pythonhosted.org/packages/b0/e8/d0389e09cd6b4c4d3089b92967ae4e3ffd64795bd349bf2f85cd6656d3da/grpcio-1.83.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:10b3fa0475eb572c9a81a6fe37fa16a9c500c0c91cfc148cac15692b7e3c2867", size = 6873200, upload-time = "2026-07-23T15:19:58.701Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c5/d4/760a33f339a7dd3d5f4b3e0e9bec5472d95592a80f887b2e9dab4e41cfbc/grpcio-1.83.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:947d945f52e8ecf3cafd2bb7113502a16ccfda3e12c854443094de32d83ad432", size = 7624404, upload-time = "2026-08-28T07:08:34.194Z" },
|
{ url = "https://files.pythonhosted.org/packages/f8/51/f464c1d211fa50d5adbabe1b2e519948d99c13757052bfc9ea7afa28e284/grpcio-1.83.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:5f20a988480b0f28207f057f7f7ae1313393c3cef0adcfeae8248f9947eaf881", size = 7618811, upload-time = "2026-07-23T15:20:00.733Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/54/ec/bd798654b06fb42a92b57d1dc1b530084fa89ed442806fcd0a833a36f9b3/grpcio-1.83.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:55656318d5dd387077396dffb929171ca3966e24bfead9a6c5dba9f889062cb4", size = 7042942, upload-time = "2026-08-28T07:08:36.208Z" },
|
{ url = "https://files.pythonhosted.org/packages/e8/c0/539fe0832f2dd6500a28f5263071623fb34e8d4867aec632ccf81bd21156/grpcio-1.83.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7bd82671b39065ba18cd536e9cd45b27ff649053f81ddd2c6a966d595067080f", size = 7042310, upload-time = "2026-07-23T15:20:02.675Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/08/b0/c00f86614566dd0961825cf0f43d4f96a74371d9d95f952bcbc4b86d9a27/grpcio-1.83.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9daf5acf4fc9d5f5627229969c2580a91e511779d76e4ccdeb9f4770f05d8bc2", size = 7576937, upload-time = "2026-08-28T07:08:38.041Z" },
|
{ url = "https://files.pythonhosted.org/packages/8c/ca/ccf617d37ffa72567fa8e005ec7090c99da922799be2fb9847c8b21ca18c/grpcio-1.83.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc60215b5cb9fc8ca72942c498b551ac2305bd08f6ef8d4e3f0d21b64fbecd61", size = 7575412, upload-time = "2026-07-23T15:20:04.712Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b1/38/85eff43a5c89dc666a252b5c9f8e9ab03f89e11c95b6263d2933f08fdbe7/grpcio-1.83.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7b94174cbca93316888f805efbeb08f1c020f7b7493d2d50cc4f6b64ebb7e8bd", size = 8608391, upload-time = "2026-08-28T07:08:40.092Z" },
|
{ url = "https://files.pythonhosted.org/packages/eb/b9/fd8d5245f823a8e0fd35d90e20ea3aa4acd47f8d5318fa8df307df52dec6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f1c3e5689d4b90987b1d72022bcfe866a9a3dc66197484cf856d96b6150e7f45", size = 8604248, upload-time = "2026-07-23T15:20:06.77Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/35/4e/82835483e2f812494be865e7965c0d626cb9e71ab0d83a420d75aea4ad67/grpcio-1.83.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:65c5a7210911ffe0f67b1cdc5308f9854b6d1f1b345e3e49ab7cac1ba50fa346", size = 7980060, upload-time = "2026-08-28T07:08:42.434Z" },
|
{ url = "https://files.pythonhosted.org/packages/14/1e/f37632fc11db72dfa4bba86c3a43e54358e53030df111ecae5e91a733ad6/grpcio-1.83.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a21cb4eeeba124443f399be2e8b624943cde864dcbe588cb42e5c483a52a906c", size = 7977458, upload-time = "2026-07-23T15:20:09.109Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b2/b7/68a98bef733fef704fbcfb3957c8dba67e3e38ca7a7fea851195bc97c648/grpcio-1.83.1-cp313-cp313-win32.whl", hash = "sha256:179368d9361854616ce6f397d4716e07480129652752fcbcfc5a7260455ad6f2", size = 4395226, upload-time = "2026-08-28T07:08:44.463Z" },
|
{ url = "https://files.pythonhosted.org/packages/93/b6/d70b69ae5c0cfc341b9ba474980e4ed99cbf05c0e4a14e9eee8cb73db0a5/grpcio-1.83.0-cp313-cp313-win32.whl", hash = "sha256:8fe04f1050a59f875601eb55d42b4f66946fe89817f967e34db1462ccd07dadf", size = 4393993, upload-time = "2026-07-23T15:20:11.017Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/85/a0/df4de3b51d37ac8fb0320bb9668381ce2bd3b7aa990880bfc56a8a26f665/grpcio-1.83.1-cp313-cp313-win_amd64.whl", hash = "sha256:2e57af456385491a76e13c4aada8c8f43a8e47051e06ea97a9dbe2a49654e6db", size = 5160273, upload-time = "2026-08-28T07:08:46.216Z" },
|
{ url = "https://files.pythonhosted.org/packages/0f/13/45d4cccb555cf4c476226979bf3d2fd0b0254216f7564c3a053e35117efc/grpcio-1.83.0-cp313-cp313-win_amd64.whl", hash = "sha256:6e01ecd9d8ef280abe1365138a4dc318f9a5287f4cb1b41d07816f796653f735", size = 5159650, upload-time = "2026-07-23T15:20:12.979Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/42/9c/484d981d8b90c4e6abf3030bd2ed747e84d1eb192b3ec9cbb41e0b73e4bf/grpcio-1.83.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:8b3c87ca908296bf125f841d3e1a2225a2b39aaa8ed7a57e7ccde465ee519bab", size = 6306089, upload-time = "2026-08-28T07:08:48.379Z" },
|
{ url = "https://files.pythonhosted.org/packages/9c/60/f2cca8147ea213d3e43ae9158d03ad04e020fdf32ff027253e1fe93f921d/grpcio-1.83.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3f351629f6ae16ecc0ec3553e586a6763ffd9f6114044286d0cbec3e09241bfa", size = 6305607, upload-time = "2026-07-23T15:20:15.353Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/84/01/0afec1c92e4f292f74a44ecf75eabbf40903125b8c4df103c9868d6338da/grpcio-1.83.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:c0f3f20c90e72a171917ae65706500b096a1c3eb5f162c3ce702a2e25635f132", size = 12170381, upload-time = "2026-08-28T07:08:50.653Z" },
|
{ url = "https://files.pythonhosted.org/packages/d0/ab/d3874931d123a95e83a3ebf8aa04537988fb62425cedb8bf3cefc5ad41b2/grpcio-1.83.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d05ff664100d429335b93c91b8b34ddf9e94a112205e7fa06dede309e44a4e4c", size = 12166617, upload-time = "2026-07-23T15:20:17.435Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c7/5a/e9a2383804433a0a61d6d93777ad321c7f36ac1cfdaa4c6d1a7c9ac846b7/grpcio-1.83.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:81bbf35a46bf8cad2dfbb2eccc19c711befb58b288acb534bbcd0d74283202a6", size = 6883286, upload-time = "2026-08-28T07:08:53.654Z" },
|
{ url = "https://files.pythonhosted.org/packages/92/ff/6f18f9426b69306f4e00a9add3b0ee2748da8aad53836ef80cab0d62d04f/grpcio-1.83.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7936f2a56cf04f6514705c0fedf400971de01b6aa1719327e4718f410a765e2b", size = 6880213, upload-time = "2026-07-23T15:20:19.98Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/63/e7/f8ca8f76994e14c70b9a0052e82f10de497a23db450c36379c9716ebfc4d/grpcio-1.83.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:215cec07d11176507387bda4bf2751816e880f9bff8dc1ca524bfbb8ed8f2fad", size = 7624293, upload-time = "2026-08-28T07:08:55.709Z" },
|
{ url = "https://files.pythonhosted.org/packages/70/21/706d1147c6b93b98f179240c13991fbcc56880eba0c868abb1ad40d8a0a6/grpcio-1.83.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b0a0be840e51b6b7ee9df9269770faf77bdf4b771053c257c21d12bad607714c", size = 7618335, upload-time = "2026-07-23T15:20:22.161Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bb/7a/4b672814b0cd0fe63bdd735379d88b165759f3144ab023ad8ec5fc4d53ac/grpcio-1.83.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:abce7d43ec29cd39230fa8339de1a07643b55adc412a454850fbd875349950ff", size = 7044346, upload-time = "2026-08-28T07:08:57.802Z" },
|
{ url = "https://files.pythonhosted.org/packages/74/04/1a8443c889115ec9e213a213e86bc93a71ee9088027e5befa09aaa0edd9d/grpcio-1.83.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:009667eaf3dcd5224c713589cdc98e7ca4ed0ff0b61132c6b276e930eb83a2df", size = 7043416, upload-time = "2026-07-23T15:20:24.209Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/50/b8/d89fe60e4239ad51be333dd9cc703741d449a35064e51f8a0b5bfa755432/grpcio-1.83.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e256f95a40e3b0183a98556fb7164d24b97eeb353123ccabfcba94712b35ee2a", size = 7584187, upload-time = "2026-08-28T07:08:59.867Z" },
|
{ url = "https://files.pythonhosted.org/packages/86/c6/94e0fee5b12bc1da1370185b680988db6f739d19b42d9959db01a7ea50bf/grpcio-1.83.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bb669918fd88936b15599caff4160a77ab74bdeb25f2231f6e45b61282d6107b", size = 7583253, upload-time = "2026-07-23T15:20:26.313Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/dc/b2/b290d7402633d9166e4dd47e6f5f74a24ce10a8340b84455896ebc349f85/grpcio-1.83.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2110059146fb0ea216e1ffddb29377b5cc2fd412a5b0a92e102616bd5edf18c2", size = 8608730, upload-time = "2026-08-28T07:09:02.592Z" },
|
{ url = "https://files.pythonhosted.org/packages/a0/97/de1ccb671fb85575bc5192faedf9ecdbdf5b390d2e6584dcf552bcbd370e/grpcio-1.83.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:c19b454d3d3f28db81f2c7c4dbaee96e7f6fd149721733ffe79d6bc530f17404", size = 8605102, upload-time = "2026-07-23T15:20:28.437Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f5/44/fa89e44d1b5cf5b9fa71b2fd7abf506f182fd43917231a92fbf1ea326b02/grpcio-1.83.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20d944d967843f8183f9f23d5916388362e5f8eeeae855bbe4354d906dc9f31b", size = 7983283, upload-time = "2026-08-28T07:09:05.087Z" },
|
{ url = "https://files.pythonhosted.org/packages/17/0f/0e0ec749a7034ffcbaa050e39779872950ead90c22e7e0116be3f28b2b46/grpcio-1.83.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:61007cd08640abc5c54547ee32505474c482cd733a53cb87551ea81faa6350af", size = 7979826, upload-time = "2026-07-23T15:20:31.182Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ce/b8/9db73ed1f35ffa76124ac574bf296d06a359798dfd6b50d382f2b8a060a1/grpcio-1.83.1-cp314-cp314-win32.whl", hash = "sha256:623c87c6d4a1cb30d82c4e896f95477050f2e01b4a1f8cf91ff2b1abdf89c457", size = 4474327, upload-time = "2026-08-28T07:09:07.179Z" },
|
{ url = "https://files.pythonhosted.org/packages/83/fa/c3fda157287f64bc65acee6c5aa90c41acf9e0d3a8e69a265eecff6d00a1/grpcio-1.83.0-cp314-cp314-win32.whl", hash = "sha256:32e11c37f5285b0c6fa3042c05fe06903696689749833fc64e67dec71b9bbe33", size = 4471765, upload-time = "2026-07-23T15:20:33.195Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/65/22/fc9a622d885a7a37ff972a12faaef443d74e47407181da70d0ab62ab41f0/grpcio-1.83.1-cp314-cp314-win_amd64.whl", hash = "sha256:47e6934ad38779271e2e7cc5f78a63a407cf3d98114c65c1fdbcd3f5a716f29b", size = 5302032, upload-time = "2026-08-28T07:09:09.285Z" },
|
{ url = "https://files.pythonhosted.org/packages/a1/00/b1b26431c9d54eee11724fd6e5585473a2ed47fbc1fb95e5204906a642ce/grpcio-1.83.0-cp314-cp314-win_amd64.whl", hash = "sha256:2bb48cb5e6dd005ca12b89ce4b6ac0b48ff3112c747542ee7986ef611a8ca6d9", size = 5298932, upload-time = "2026-07-23T15:20:35.48Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -950,7 +953,7 @@ codecs = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "livekit-api"
|
name = "livekit-api"
|
||||||
version = "1.2.1"
|
version = "1.2.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
@@ -959,9 +962,9 @@ dependencies = [
|
|||||||
{ name = "pyjwt" },
|
{ name = "pyjwt" },
|
||||||
{ name = "types-protobuf" },
|
{ name = "types-protobuf" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/da/0d/ff9502faa5e9785563e9648313a030fab65ed7f6ff8cf52d7bc6c9e01c97/livekit_api-1.2.1.tar.gz", hash = "sha256:eee78dba493b736bc8422660debc7cf89340f1b80c63890a36a18d99c938045c", size = 21780, upload-time = "2026-08-27T14:06:57.419Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/f3/19/36ff6712ec638a4b7dad4d8f03795952e401dc31db0b04cddec7892650da/livekit_api-1.2.0.tar.gz", hash = "sha256:a89817b3bca9584873786ff07209839308217537a42f95ecb2609aafaa109ddc", size = 20778, upload-time = "2026-07-11T23:20:54.781Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/a1/c8/bc29cf16427fc446aa4487c552503cef42eebfcb920902bb54b7180e40d3/livekit_api-1.2.1-py3-none-any.whl", hash = "sha256:aa15b0a194c9e8167d4261bc61381682df23f98bc6d77760fcded93d2ce4b4ca", size = 27586, upload-time = "2026-08-27T14:06:56.286Z" },
|
{ url = "https://files.pythonhosted.org/packages/bf/e7/8926f16d4bc1b2e0ae46d4a507321bb899396d263a757f1adaabcd3b3867/livekit_api-1.2.0-py3-none-any.whl", hash = "sha256:307f8e5cfb0358c3ca091814ab768af55896022151bcd7f951954ccefa036a24", size = 26499, upload-time = "2026-07-11T23:20:53.736Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1056,15 +1059,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "livekit-protocol"
|
name = "livekit-protocol"
|
||||||
version = "1.1.26"
|
version = "1.1.24"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "protobuf" },
|
{ name = "protobuf" },
|
||||||
{ name = "types-protobuf" },
|
{ name = "types-protobuf" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/e8/49/733f3655717bd1bdccfbb4ab958ac7f51d7e17d37e61b98de2f6b2e0c107/livekit_protocol-1.1.26.tar.gz", hash = "sha256:de8d291abb2efd026fbe23394ee4c05358195f09102d3c13335978ba04d7bdb8", size = 123833, upload-time = "2026-08-28T17:15:50.225Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/d7/dc/65c106d689916e78e47d79d4f890f6e83b680442c67ed4909b44425084ea/livekit_protocol-1.1.24.tar.gz", hash = "sha256:b0a5699d3a4c4e42c3d37416dc3ed3c817c527c317c4cf3351d0bbe52887b9d4", size = 122624, upload-time = "2026-08-20T22:07:24.06Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/37/a9/b9a9a04b921b1aaef9312c3a16523cd4ddb508dcc4be18ca0e300b750b7e/livekit_protocol-1.1.26-py3-none-any.whl", hash = "sha256:37db1272487518df2ce3e13624a5217d1e158c6832fe01166bb3fb1875a8ddd9", size = 150535, upload-time = "2026-08-28T17:15:48.535Z" },
|
{ url = "https://files.pythonhosted.org/packages/88/1f/e06cbbaea37bbe12cab1d169767f3fff9b385b019e6bdcd02fb9c94df63d/livekit_protocol-1.1.24-py3-none-any.whl", hash = "sha256:794463c4ed209fc884194470595052de652477d92b10d81bb5b0f9dcf53050f9", size = 149448, upload-time = "2026-08-20T22:07:22.699Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1106,76 +1109,54 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "msgpack"
|
name = "msgpack"
|
||||||
version = "1.2.2"
|
version = "1.2.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/6d/44/ea2100ec54d30c46ee9dba10a3bfb79b655e96c6df237238a3234c75869b/msgpack-1.2.2.tar.gz", hash = "sha256:9eb0b0e602064527a045ea28c4f174ed69383587e29cebe28947e3b84106eb2a", size = 187025, upload-time = "2026-08-27T10:03:47.793Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/31/f9/c0a1c127f9049db9155afc316952ea571720dd01833ff5e4d7e8e6352dbb/msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647", size = 183960, upload-time = "2026-06-18T16:13:52.594Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/31/78/90c15bebb1a72667349ca62d4507e9d9369e7f8f76b95f490b823d3622e5/msgpack-1.2.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a4348705be86e029d04e741cf9ed0dfe03e942d7d3b92e838fa80d3aa2c3ebc", size = 84275, upload-time = "2026-08-27T10:02:07.106Z" },
|
{ url = "https://files.pythonhosted.org/packages/bc/dd/9e8cbd8f5582ca4b590336f2b91ee5662f6a6ca562b565abaf696a0f81ff/msgpack-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35", size = 83531, upload-time = "2026-06-18T16:12:58.249Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/88/88/c2b6d8e81571da87aa232c0e34a3f3a0e618e6235892065ec82d1d81fc7a/msgpack-1.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a652ceeededf71d3fa40c303a02a149d42338d310162367b91c539d4bd6e0a3", size = 83970, upload-time = "2026-08-27T10:02:08.488Z" },
|
{ url = "https://files.pythonhosted.org/packages/50/2e/ebdb85a8da151397a2790363676b7ed7c125924fe618e4c6d8befb0cc62c/msgpack-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c", size = 82657, upload-time = "2026-06-18T16:12:59.396Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/c0/d3ede9f5d16acb4c05a9281859f1e99ef9f877a928eb78454c37f70db001/msgpack-1.2.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90986cc9aab9d7d1d8f38bcbf65d3f7ac83bdd90c35765db7d691b4829698cba", size = 409401, upload-time = "2026-08-27T10:02:09.877Z" },
|
{ url = "https://files.pythonhosted.org/packages/26/aa/753ad8b007b464e1d8aa0c8e650b9c5f4f725e658fc5ac8a7635c55b7f6e/msgpack-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0", size = 410634, upload-time = "2026-06-18T16:13:00.383Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/41/f0/29f591bea185616cf417645ac03bd3ad9b317483ad8572160e325f7fe777/msgpack-1.2.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:77c2e018417dc1d66f235e383877ee885b60ade9d29e494dd581e08af2cb1923", size = 420619, upload-time = "2026-08-27T10:02:11.526Z" },
|
{ url = "https://files.pythonhosted.org/packages/6a/fd/6adabd4f6d5e686f97dd02ce7fce3fe4cf672cbac36b8f67ff4040e8ad8b/msgpack-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a", size = 419989, upload-time = "2026-06-18T16:13:01.776Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4e/8e/c70c8c9180c5ddf4440eb8658ebead98e22e7686fbf84f6b165031430750/msgpack-1.2.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0e91332144f69bc3018c91232fac26da580ef748fb8eaddd7914d4458001cc4f", size = 379747, upload-time = "2026-08-27T10:02:13.345Z" },
|
{ url = "https://files.pythonhosted.org/packages/5a/cc/85039b7b0eb168aaad7383a23c97e291a11f08351cb45a606ce865e4e3f1/msgpack-1.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6", size = 377544, upload-time = "2026-06-18T16:13:03.637Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/50/9a/f10ce11fa62700c9ab87a22e65b9ca272f7f673ddd31aeb2de6ae272ad35/msgpack-1.2.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3e915d390d7068b257ca8b62f3fc59fad135c8631d1017ab03b0b924b07c5367", size = 398944, upload-time = "2026-08-27T10:02:15.006Z" },
|
{ url = "https://files.pythonhosted.org/packages/ed/bf/35963899493b32030c85fc513b723ae66144ac70c11ebc52e889e16e3d99/msgpack-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a", size = 400842, upload-time = "2026-06-18T16:13:05.012Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/82/fe/d7be978456ff8552e69a8e270d882e7530e01513c096b293d83df03753ea/msgpack-1.2.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c522420d78db2431887d45b518e304d86e27b9ad0b30f24e3806a6ad5d8bdbfc", size = 373979, upload-time = "2026-08-27T10:02:16.618Z" },
|
{ url = "https://files.pythonhosted.org/packages/a6/df/8e2ac970c8f99264cd9997d1c73df5466bc19da3301d7dc5500862a9b089/msgpack-1.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1", size = 374108, upload-time = "2026-06-18T16:13:06.232Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/be/af/91b0d8d3fb3063e259daee3ea8515cea6282f68f4b0e5f0b6fea25762c6e/msgpack-1.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4b554d8164ebb526892194f71dcd96ef1fefe0c250087498785d3ffc04a80be3", size = 417781, upload-time = "2026-08-27T10:02:18.293Z" },
|
{ url = "https://files.pythonhosted.org/packages/17/dd/fa8bd265110dfa51c20cb529f9e6d240a16fafe7e645004c6af2d01353ba/msgpack-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64", size = 414939, upload-time = "2026-06-18T16:13:07.478Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8e/3c/ce8e9efe1fd9e95c78b3705e4300ba7feba3dc6c00fb76259895db155518/msgpack-1.2.2-cp312-cp312-win32.whl", hash = "sha256:0e3315de5a4b2920ccef48d96b4448025e064a10d0f5a250f6584477d839c8d4", size = 65267, upload-time = "2026-08-27T10:02:19.869Z" },
|
{ url = "https://files.pythonhosted.org/packages/2e/b9/8377a5ad8953fc0437c70cc98d9ae29f27fe5ac5109fbec0812085865735/msgpack-1.2.1-cp312-cp312-win32.whl", hash = "sha256:0c0d9802354507bcba62af19c17918e3eb437cc25e6f50657d511b5856a77aac", size = 64504, upload-time = "2026-06-18T16:13:08.822Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/85/98/a33b8b4af14e3476bb0da1b8c36ef7a0f28dcf95db1c5e68ff88cb89d591/msgpack-1.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:b68614fba0570349833b7dd999ff0aed4e5cc8d9eb6e3a7d4527be33c65e33d3", size = 72275, upload-time = "2026-08-27T10:02:21.141Z" },
|
{ url = "https://files.pythonhosted.org/packages/57/7f/ce1e377df7e62461fefd9eb23bfb93a4a523f40a517b377b8f844d836828/msgpack-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24", size = 71421, upload-time = "2026-06-18T16:13:09.828Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/df/5e/2f323a33a6aba5bd4b2d8b430e4fab21d92cd91c093b49ee287bc166ee54/msgpack-1.2.2-cp312-cp312-win_arm64.whl", hash = "sha256:59d5b93efa45fd09f620d0c9ba81cde339a2c9937af3eea42ee9653094ce6640", size = 65488, upload-time = "2026-08-27T10:02:22.575Z" },
|
{ url = "https://files.pythonhosted.org/packages/8f/32/ebfe84c9929f08f188d56c7a2fd913406a9ddad76a634697c1c43b8112e6/msgpack-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4227224aaec8f7fbcbfbd4272319347b2bb4030366502600f8c45588c5187b07", size = 64775, upload-time = "2026-06-18T16:13:11.056Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1f/eb/42f31c5a48811787ff59a9869721f70a49654d65ab6c455f4463c39b044e/msgpack-1.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8b2a281b556f120a43e591ea39915741b7ad54d4727b9c4350a0a11692252533", size = 83911, upload-time = "2026-08-27T10:02:24.06Z" },
|
{ url = "https://files.pythonhosted.org/packages/b0/ac/dcddcab6f6c20ecb387ca5e980371cdb3f87ff69aeca388be97eebc4c074/msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064", size = 83151, upload-time = "2026-06-18T16:13:12.173Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/33/54/10c6c16ddba8a5112e3680176b838e3694e4aad7284f9daa6d6d70d98817/msgpack-1.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1e8cdd1f3e7cc52c751092a9bf740e81e6919ab109cd376ae2d965dad0bbae34", size = 83734, upload-time = "2026-08-27T10:02:25.613Z" },
|
{ url = "https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056", size = 82351, upload-time = "2026-06-18T16:13:13.194Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d7/75/35823e4419df8792191b2a17ae3fe71b41d02c162b2c491c94d1a87f0caa/msgpack-1.2.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1814f92306ae7862908e9ece7cfd90e0dc87ded3e89b6ae7ffdd1175d6376fdc", size = 405635, upload-time = "2026-08-27T10:02:27.012Z" },
|
{ url = "https://files.pythonhosted.org/packages/e3/10/ddf7b06db879e8792d13934ddda09ff20bd2a583fd84c9b59aae9b0e650b/msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc", size = 407518, upload-time = "2026-06-18T16:13:14.233Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6e/d3/6592e4064619b04f2dd0054c5fa13e37e3d55eb26044483d871fadb2f46b/msgpack-1.2.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d24b38a825bcca41bb956de50eb98451ef291304a8607fad99e619043d3e79b9", size = 417332, upload-time = "2026-08-27T10:02:28.776Z" },
|
{ url = "https://files.pythonhosted.org/packages/79/d3/36a46a8ed992b781acbc05928bd5bee3c810cb0c3563bf81a7b0c04a1a76/msgpack-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d", size = 416405, upload-time = "2026-06-18T16:13:15.435Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e3/a1/b21c6818a545e9a4a976ac954a5c250eecde9a02e0ec82f415473dab1324/msgpack-1.2.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:34e83e345194a2a51d8bd447dea9de2104f91e75b247f4735f14f04529f0746b", size = 374378, upload-time = "2026-08-27T10:02:30.678Z" },
|
{ url = "https://files.pythonhosted.org/packages/f9/84/e8e9598b557c0ba6ddae901a73780a4c75ac667dddf59414b1e56a42fb34/msgpack-1.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155", size = 376257, upload-time = "2026-06-18T16:13:17.022Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/03/8b/7ada15c7b64151d6dbb562d1b091520efb2c37acf2403b1d4ae13797b27d/msgpack-1.2.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:682804bf31e43d46e51a9a33bd575b51e839d715ce6bd5612c055f7b28ad637b", size = 395809, upload-time = "2026-08-27T10:02:32.322Z" },
|
{ url = "https://files.pythonhosted.org/packages/40/16/738fe6d875ad7e2a9429c165322a4ec088f4f273cdfae63d96a89c467961/msgpack-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402", size = 397469, upload-time = "2026-06-18T16:13:18.287Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bb/f7/96283e50f7020df4dfeacc55612b7a210c8cdf0dda48bc262f1f9b3e4c49/msgpack-1.2.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9b659d77f8726fa5e7038967dda6b68d53cf34472c094cfa5b845454713b90d5", size = 373495, upload-time = "2026-08-27T10:02:33.832Z" },
|
{ url = "https://files.pythonhosted.org/packages/ca/be/6d5952df75a7f24f35833af764c3a6860780364cb3a0030beb8099e1b2b4/msgpack-1.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c", size = 372802, upload-time = "2026-06-18T16:13:19.685Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cc/fe/1548dede9d9ca482f2d424a2e110a9705d4e02627a16b8bc8d10ce0208a2/msgpack-1.2.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d9a562aec0a92fe536da2e533d313b3d2a6b929157b1dec7ff623446dc0a8ab", size = 414360, upload-time = "2026-08-27T10:02:35.396Z" },
|
{ url = "https://files.pythonhosted.org/packages/e1/39/e2ef7dbf0473bcb8dc7c50bf782a892d67414877b63e47fc88eb189ef5e6/msgpack-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6", size = 411273, upload-time = "2026-06-18T16:13:21.028Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/77/9d/4419b8f86c219174b1fb8bbd7faaf84a548935f7b1916d028401b9433417/msgpack-1.2.2-cp313-cp313-win32.whl", hash = "sha256:a4161eee7799863aee237c35c90427861f7b994416dd81ae829f560b0a81bdcd", size = 65196, upload-time = "2026-08-27T10:02:37.007Z" },
|
{ url = "https://files.pythonhosted.org/packages/ef/c5/133f4512a56e983a93445c836c9d94d88f3bc2e0980ff4b9e577bd8416ce/msgpack-1.2.1-cp313-cp313-win32.whl", hash = "sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707", size = 64471, upload-time = "2026-06-18T16:13:22.293Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3c/f8/593f5caf0dacab41cde1564c5f0419e61af55ec9628006205e8fd5eb5e03/msgpack-1.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:b07c03f0da7e5279170df7745ddc732d526c8a198208936ec1a95c11ed2b2d5f", size = 72203, upload-time = "2026-08-27T10:02:38.28Z" },
|
{ url = "https://files.pythonhosted.org/packages/e2/98/577e10b055096a7dd40732358cabaf7180a20c79ed1dcdbb618e4b9deac7/msgpack-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9", size = 71274, upload-time = "2026-06-18T16:13:23.455Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bc/9e/c6ef92046b4a2bbb9d3aa0cb581cbf4a4051afccf6e5fb301a1bd3086f39/msgpack-1.2.2-cp313-cp313-win_arm64.whl", hash = "sha256:d13d07efbf655f9ae7a2352b630c52727b359005b21ba08a507585c9ac8c0896", size = 65435, upload-time = "2026-08-27T10:02:39.534Z" },
|
{ url = "https://files.pythonhosted.org/packages/ba/ee/0c0048e7cfbef23c6a94791b8959ab28155232e7956de8a305b5ff588f05/msgpack-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a", size = 64795, upload-time = "2026-06-18T16:13:24.687Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5e/50/3e92c403346652cabd08cb8faceef847bae917ea3b3c81b64a5b6d09ed41/msgpack-1.2.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e497ee34e8a3342bbde51b27c22d8db05a651df3361dd3daef5b3ab0d66f3e04", size = 84315, upload-time = "2026-08-27T10:02:41.181Z" },
|
{ url = "https://files.pythonhosted.org/packages/77/58/cce442852c6b9e1639c7c8ac8fd9143121cb32dab0f308df4d1426a8eb9c/msgpack-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d", size = 83610, upload-time = "2026-06-18T16:13:25.724Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b6/dc/8efe6dd96a12ab043930cb4cffb40b6e7f061491d6ec7a3d2b75ef1fda42/msgpack-1.2.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0dd9173c5ebaf5ecc5ca86e7ae1db92934e1d57b856f3dd90698941431f4fd77", size = 84634, upload-time = "2026-08-27T10:02:42.621Z" },
|
{ url = "https://files.pythonhosted.org/packages/60/5c/15b4c7a0182f75ffa90751958ba36a9c01cafee367d49a3edc10ed140b01/msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7", size = 83138, upload-time = "2026-06-18T16:13:26.781Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1b/89/996573095bf7b038c04dd65ddbc4f1a4d381b0f7a44ff9186f3c7b8325c2/msgpack-1.2.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8dc4487097571f7311188c3eca2a3e86cd1f1db4c37c7a017bcc3fd38486cbfe", size = 404194, upload-time = "2026-08-27T10:02:44.096Z" },
|
{ url = "https://files.pythonhosted.org/packages/b8/a6/99e58722feaffc5f2fbcc0c8c0d1451ab9f84097f7af87291b46af2390f4/msgpack-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889", size = 406090, upload-time = "2026-06-18T16:13:28.072Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b6/4e/46f5a5d949dbd054dab60cb15aac7ac6ae6774c134532893414689bf2f53/msgpack-1.2.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:73b0e05c32c3cfc3cd84994908e57430c0ebc6813abf905d3f18ff115d54df3f", size = 412343, upload-time = "2026-08-27T10:02:45.747Z" },
|
{ url = "https://files.pythonhosted.org/packages/19/03/8c63e8cf52958534ef688625965ab04c269a6cadd8caef16758b380a821a/msgpack-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e2bf9280bceb5efca998435904b5d3e9fdbcc11d90dc9df30aec7973252b720", size = 412106, upload-time = "2026-06-18T16:13:29.427Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/e8/739a94197358a313307e6e9e7d8d22ef66add39222de911a44161aa96920/msgpack-1.2.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa1120c653b76d8eafa50423b5eba06b5c9737f8692c74fa3afe03e84b8978ea", size = 372620, upload-time = "2026-08-27T10:02:47.578Z" },
|
{ url = "https://files.pythonhosted.org/packages/63/d2/155d9e71b40e41fd934bc0c48b9b2770f22263e1ac20aad8e29fdca7be3f/msgpack-1.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6c4be5d1c02a42b066ca6ddb71adf36432868fdcdb6ee87e634e86e0674190", size = 374851, upload-time = "2026-06-18T16:13:30.631Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/03/d4/09b92e1fcdccea9466bfae45455367ac52362ae445d96a602e51b7a8df73/msgpack-1.2.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccfd880988f8438d1c91c77d7edc58e70f4d2012e999167bc154c64c6f06ea6b", size = 394603, upload-time = "2026-08-27T10:02:49.172Z" },
|
{ url = "https://files.pythonhosted.org/packages/98/48/deaf2326262a8d5ea3295ce9649912ecd3f551ba7ec8e33c665d2ba583f3/msgpack-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec0e675d59150a6269ddc9139087c722292664a37d071a849c05c473350f1f2d", size = 396168, upload-time = "2026-06-18T16:13:31.977Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/47/db/d11bd6f258a60703dcdc7a3772818ad0c2f602ee4c2acfb24088c6c3ebc3/msgpack-1.2.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:6195257a107bf25872ef84aab7295078271eea3ac6413f0506b631f6c9586ed5", size = 372666, upload-time = "2026-08-27T10:02:50.886Z" },
|
{ url = "https://files.pythonhosted.org/packages/10/2a/b4410f906c2ec0008f1608d3ab5143afc3ad3f4e6da0fed3ea2231d0bef4/msgpack-1.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:dd3bfe82d53edfe4b7fc9a7ec9761e23a7a5b1dac22264505af428253c29ed24", size = 371959, upload-time = "2026-06-18T16:13:33.282Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/71/cf/fbbbac0c6e5fbb9d51abc23e3b5fe8620f5c01e0588797cf664a623bb9e1/msgpack-1.2.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b8dd6c71d20c28d2d0eb0c51e7cccf3584afde3b1364f6629596186c9025bd54", size = 410889, upload-time = "2026-08-27T10:02:52.51Z" },
|
{ url = "https://files.pythonhosted.org/packages/59/86/1edc67270099a528fa2093ea60fe191233cd238e4bd30cfacf7db79fc959/msgpack-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ad5467fc3f68b5468e06c5f788d712e9f8ffc8b0cd1bcb160c105c1ee92dae7", size = 408457, upload-time = "2026-06-18T16:13:34.567Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/94/60/8366558da954095e04e7fbc351f9387d87a682feaee9a235ceda966f794b/msgpack-1.2.2-cp314-cp314-win32.whl", hash = "sha256:d242f3c4ccf55b056e6cf901720dccde58f1df117898f2bbf3bcd6e38ec7c248", size = 66774, upload-time = "2026-08-27T10:02:53.984Z" },
|
{ url = "https://files.pythonhosted.org/packages/82/90/8b630fef07d8c5ab457b71ff2c217910c83d333c7a68472c186e87cc504a/msgpack-1.2.1-cp314-cp314-win32.whl", hash = "sha256:98b58bdb89c46190e4609bb36abe17c6d4105ad13f9c5f8f6f64d320f8ced3fb", size = 65942, upload-time = "2026-06-18T16:13:36.056Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4b/3d/1ce873c8057c65e4fbb076ffe1c99c9ae39d90a00a2540d7b06c652a292f/msgpack-1.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:1510f24612d4b983dff6935d9273e02c320cfd525727fbcb58836a75f589fdbc", size = 73424, upload-time = "2026-08-27T10:02:55.277Z" },
|
{ url = "https://files.pythonhosted.org/packages/16/f1/467b81e98b24dd3885d7b1857728797b4ffc76a7a7483af4fb321a07de3c/msgpack-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:74847557e28ce71bd3c438a447ca90e4b507e997ddbdef8a12a7b283b86c156b", size = 72627, upload-time = "2026-06-18T16:13:37.079Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d5/55/e36f2a33e38657f33850d74e0bf256838a0d45802c298cc501a32bffcc08/msgpack-1.2.2-cp314-cp314-win_arm64.whl", hash = "sha256:7826f16edc763e768404f55605ef85dfcf5857e729c1ed29e0d7c180be4fe6d8", size = 67657, upload-time = "2026-08-27T10:02:56.493Z" },
|
{ url = "https://files.pythonhosted.org/packages/a7/1d/5d8c4c89985feb6acefb82a09e501c60392261856d2408d20bfe4f0360b1/msgpack-1.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:b50b727bd652bdc37d950336c848ef20ec54a4cafc38dce19b1cd86ad625d0f7", size = 66908, upload-time = "2026-06-18T16:13:38.23Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/64/58/7e764b957bae80ae281a9cb28761068c8bae8d5c6ac0873e43cc69d176c7/msgpack-1.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f466049b8e1ec0854287bbe9a074316826fe0e08dcf707245f98b1ae49e92650", size = 86594, upload-time = "2026-08-27T10:02:57.796Z" },
|
{ url = "https://files.pythonhosted.org/packages/1b/02/ad2afb678b4de94496cd432b581759b756a92c1192d8c767edd6b132efdc/msgpack-1.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8d00f177ca88a77c1cf848d204a38f249751650b601cb6532acc68805d8a8273", size = 86000, upload-time = "2026-06-18T16:13:39.44Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ae/f0/250f5985b6ee533e60d357571a808aaae03c54118294dc3db7158e27feb1/msgpack-1.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f6b6f8deb07d49090e1808c6ef9cb7d23ca17bef3aa6ed3e5e03df16606e60c", size = 87374, upload-time = "2026-08-27T10:02:59.256Z" },
|
{ url = "https://files.pythonhosted.org/packages/54/74/0b797484013128837f3b1cbb6cea019277c4de4e377dc512b4d9a0f92940/msgpack-1.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5bb9c386f0a329c035ddbab4b72d1028bf9627add8dda41070288563d57ed1b1", size = 86544, upload-time = "2026-06-18T16:13:40.447Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bd/2c/126ec8f187877c5f688631c543d1d3a3d75b2e66b83fb9de3ed7c13a39b6/msgpack-1.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b542ffc0a5c531eedc40419f291f1bd659aa8d4223408a5b51c88a2796083fd3", size = 428157, upload-time = "2026-08-27T10:03:00.9Z" },
|
{ url = "https://files.pythonhosted.org/packages/a9/b4/b774d7eb95561739907fec675582f83203cf41c597a418c2589b4bfb8e9d/msgpack-1.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20466cca18c49c7292a8984bc15d65857b171e7264bdcb5f96baf8be238791fc", size = 427661, upload-time = "2026-06-18T16:13:41.574Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/95/21/d2d81d50aaedb14147d01f22094185794db3ad8a8791b60afacba0627c89/msgpack-1.2.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d095df2627e5dd59ac7b0c5ad627a671c76e6020171e03cbe4621a61f0562c3", size = 426669, upload-time = "2026-08-27T10:03:02.457Z" },
|
{ url = "https://files.pythonhosted.org/packages/b2/f9/3243191dc9937e00756c8bc1b0272fed8f23758e43df2a3b46f533e5090f/msgpack-1.2.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:196300e7e5d6e74d50f1607ab9c06c4a1484c383cd22defd727902591f7e8dde", size = 426375, upload-time = "2026-06-18T16:13:42.936Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c6/fc/f7d484ee5b572719608e7ffad569bea22ff11309a96ca2fae85eec94226b/msgpack-1.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ffdd2f4950daf7815490f23087963e3420175b9609520b7ff5df64d351159c22", size = 380625, upload-time = "2026-08-27T10:03:04.244Z" },
|
{ url = "https://files.pythonhosted.org/packages/23/c7/1693111db9944ba4ad4b67a1e788400d78a0b6af7a6523dc7e4e58f8274b/msgpack-1.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575957e79cd51903a4e8495a242442949641e08f1efd5197b43bebd3ea7682b4", size = 380495, upload-time = "2026-06-18T16:13:44.306Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0a/c4/b924cbd5516676f4e612329f18602a833bd055ffbe27f808eeba0f01bfea/msgpack-1.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:652d1bf13d01bac8fd569def0fe76745e55bcda01e30aa6332d5947ea3788839", size = 411328, upload-time = "2026-08-27T10:03:05.869Z" },
|
{ url = "https://files.pythonhosted.org/packages/3e/2b/92f86956a0c13e8662f7e2ad630c4eb4db07497b967589bd5245e018b2c1/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c2ed1e48cc0f460bf3c7780e7137ff21a4e18433451916f2442c1b21036cd7d", size = 410897, upload-time = "2026-06-18T16:13:45.629Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/27/9d/0c1d9683a951a80f270c3b7dac1022c18b9307617344dd44d904135d5e12/msgpack-1.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9bf452ff4d4981f25a18e9476e002bcc9263e7928024aa4d7148e25f7be3f929", size = 377892, upload-time = "2026-08-27T10:03:07.37Z" },
|
{ url = "https://files.pythonhosted.org/packages/da/ea/1479f72d200313a76fc2f823a79d1e07ed052ab7b8a0280640aa7b95de42/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f6277e5f783c36786a145e0247fc189a03f35f84b251646e53592d2bc12b355", size = 378519, upload-time = "2026-06-18T16:13:46.998Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/06/bb/bf22338cdd22e0b40c8f28468cea5f3d9c320244c095d8303364bc012c41/msgpack-1.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:55faa6f8395e23b848c535ad5dcb96b3462f37f5e7f4ac500d500434f7345da7", size = 419426, upload-time = "2026-08-27T10:03:09Z" },
|
{ url = "https://files.pythonhosted.org/packages/f5/4d/fa006060ffa1011d32bfae826fe766fe73e02982183601633b7121058ab3/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9389552ecf4784886345ead0647e4edc96bee37cbab05b75540f542f766c48c", size = 419815, upload-time = "2026-06-18T16:13:48.205Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7d/42/6d02c19a01abd8d7ce817c321d2ee6af1a8e24d584dca619d1b6576a83bf/msgpack-1.2.2-cp314-cp314t-win32.whl", hash = "sha256:419a45c67a5c04213172a14b1864657e014665b77d7081b107a51707923dd39e", size = 71810, upload-time = "2026-08-27T10:03:10.498Z" },
|
{ url = "https://files.pythonhosted.org/packages/2f/e1/aab6c946570496b78e67804721f3d5e2d62a93081b9b37df77764ef56347/msgpack-1.2.1-cp314-cp314t-win32.whl", hash = "sha256:c1c79a604a2969a868a78b6ebd27a887e00c624f14f66b3038e0590cb23332d1", size = 70914, upload-time = "2026-06-18T16:13:49.385Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/af/df/fda3a204415dab0a8c0db5461ef7205416ea52bd8581c5cafd361be07f3b/msgpack-1.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:935b1cfad9b908b0fa845010f4271df4c2f04e1cd26e3f18acd61a45f93c9e36", size = 78919, upload-time = "2026-08-27T10:03:12.016Z" },
|
{ url = "https://files.pythonhosted.org/packages/13/0a/e608956488a2af014cfe6e3d665e090b8ee42aa14b07f8f95b8880d66b09/msgpack-1.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f12038a35fabd52e56a3547bab42401af49a45caa6dd00b34c44de235bc93ee2", size = 77999, upload-time = "2026-06-18T16:13:50.467Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/63/d4/4b4b0ef25a86deca91feaf7252ca885ba4f2ada40461379120122a04fe96/msgpack-1.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11e8c421e117d1c36728b423d0402555cccbf0c6f53e288f0e75b6b12100d70f", size = 71925, upload-time = "2026-08-27T10:03:13.332Z" },
|
{ url = "https://files.pythonhosted.org/packages/d2/8a/27e2e57055176e366a46b85d02d68e7a5bcfbdd8474c9706375d965f24d3/msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107", size = 71160, upload-time = "2026-06-18T16:13:51.498Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3b/92/4b44bc8f3243ef8cf9cb5368c17a299d45b9df858f6dfdd98a0482dbbb37/msgpack-1.2.2-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:e1b99ad34613d5f8477fa5cf99bc4eaeaf27965588007c102370cd9a78fe9de5", size = 84293, upload-time = "2026-08-27T10:03:14.718Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/80/05/c992bb65744665a41b5bf531fc0e1619bae0901f57738228ded90023c151/msgpack-1.2.2-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:0fbc1bed8a535389b41882cfae66376e248cd1680eaa94fd83193c73e1d24986", size = 84490, upload-time = "2026-08-27T10:03:16.12Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d7/bf/7f53b9e6709a4df7f9b9b81dc65f9dfaa32caf65bee94986ec2cb8fa07f1/msgpack-1.2.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:06d95f61de7afe4f4ff908a6feebfcb070d0582ac87c9cf3cedf8551cf634516", size = 405332, upload-time = "2026-08-27T10:03:17.692Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/d2/5a/305c4dca14b50d0b51fb88ef04ec125b8f0be3e2ce730dcc62dbaa651cc5/msgpack-1.2.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b5c696ae7cd7166b3657261adb855b461ff31f07823fdbae9de8bf80adfccc21", size = 416798, upload-time = "2026-08-27T10:03:19.389Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/7a/df/a645102b4cdfd9a94201cac4e900e9c1429fc16d86aa311c06eef82528c9/msgpack-1.2.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0708afbf6a9587f0bfe479a9825c141d14d91e2f6a5c8103cf28bc96f4edb5d9", size = 377312, upload-time = "2026-08-27T10:03:20.928Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/f4/26/c56d8d086d3fb1077bb48092b158b5ea2eee08b279e10c191275f13bc980/msgpack-1.2.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:226a62ffe99fe54c5c61d910ec64c3449b7766c3280bd286bf6c94838dde239a", size = 395182, upload-time = "2026-08-27T10:03:22.571Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/4d/b5/3d46ba367a565e536d8d2a61eebcee71b1dc803da3ce74a22313b573d6fa/msgpack-1.2.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:9fd7f32e2f0fb334e7ecc5adb5cf0458785bd3a9d9d86f950e1715f101cebce5", size = 377945, upload-time = "2026-08-27T10:03:24.151Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/a5/2c/d5d2df273ed5306357da25b69400fd8d7a53c4d87d8976604b677484d61c/msgpack-1.2.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9db1ba1c1e6a84245a9dd866265b56b8a1e9461549cc72ed296d8cbfbd32961b", size = 413341, upload-time = "2026-08-27T10:03:25.85Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/1b/fb/32613bced3cad47b40b1b73dd04d687121349d83f748efc2575929121903/msgpack-1.2.2-cp315-cp315-win32.whl", hash = "sha256:e2eb7ea0ac3911a7aac9d8aaa36d40f216d99455b3274cd3fac38181bcd910cf", size = 66730, upload-time = "2026-08-27T10:03:27.294Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/74/56/d86171f7251015e9312e5a7f9fdd4cf89752fc2114b88fed453d2a040c66/msgpack-1.2.2-cp315-cp315-win_amd64.whl", hash = "sha256:9352e6cdb510a7b1a5d3ccaccec730e82e50cf3484a3af7bdaab19e23b9589ff", size = 73477, upload-time = "2026-08-27T10:03:28.615Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/13/1a/56b90f6defef61700b86baca3637c15f62ac0f9b21ab0f16613ab9d1f101/msgpack-1.2.2-cp315-cp315-win_arm64.whl", hash = "sha256:29cc2d5291711a52956a79a51f41c732329df39ad727c886bd8f0b5b9237a808", size = 67660, upload-time = "2026-08-27T10:03:29.895Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cd/20/12751ca0d8ec874701b54c392c2b19f51af8dd1de40a92a10e356f0aaf58/msgpack-1.2.2-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:d886baa46b2532135e7320067e6a44edb09ba5883a6096b0f9c044533984b8a8", size = 86462, upload-time = "2026-08-27T10:03:31.348Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/91/4c/cf6d12a3d709fe5f9771dd917c35e6ebcd55597a5b792287382fde056c95/msgpack-1.2.2-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53679573c75cce5f82359e0bd4e6a97809a6b9a9b7a48fd1ba592f4a82cddc84", size = 87412, upload-time = "2026-08-27T10:03:32.74Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ca/0d/0aac5752d1708dcb458f8754db34a4999514db3df2d2b798b9381293f638/msgpack-1.2.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3c247d457ae9079974c7ce3c665396754a6d2baff7eaa51332212a8a5a3f13b", size = 422057, upload-time = "2026-08-27T10:03:34.124Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/81/30/70f281a3685b04aaf235a5237da11b978a02a865a5a479186205177ad676/msgpack-1.2.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:352ed831042549cca8be23780e1fe7c9177e65ff02bf183509c4b4d33f671782", size = 422696, upload-time = "2026-08-27T10:03:35.862Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/6d/f76e8425efb0aa38988cd778ae290bfa120491d80d26872d88bb52fedb3f/msgpack-1.2.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f80361592c13d7226b4379c8941529b63fe1a9d0e05d2de8f3306b70e522b53f", size = 376495, upload-time = "2026-08-27T10:03:37.339Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/95/77/0809aa9b52b2868f7d01862dc14073708f0440421a65197b48453480034c/msgpack-1.2.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:68df2947921d449f6dcfeafd86cb2cdde13327a8b447534bbe4ee5aaf32a5695", size = 404683, upload-time = "2026-08-27T10:03:38.87Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/02/d2/4e5ac915ba120172d210ef00165c5e6276c8a65db3a4a5cf36e946b83e23/msgpack-1.2.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:51dd39d23cfdea0400ed3ff2d29d1e83bd951d3aea79dc89be5b701a09edfe23", size = 375087, upload-time = "2026-08-27T10:03:40.486Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/cb/e3/8051d53e5495c87c6cf27eb42fb680361017037f87f322bdaf525f71e4a2/msgpack-1.2.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b13b59e66f107cca1ba708dd5307179870ca1b15b19fcee7ccf722e5308d9212", size = 414421, upload-time = "2026-08-27T10:03:42.308Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/ba/4e/13783aa7c17414d7186c72c49bc718366f75e49f0ea58d4f81cb63ac3187/msgpack-1.2.2-cp315-cp315t-win32.whl", hash = "sha256:8c6321a414f8b4a8dc43976b2fa8349156434ca9adedd9a187b796f7e1d3d3fc", size = 71790, upload-time = "2026-08-27T10:03:43.715Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/5e/9d/1d02994c7ae2603c98100984428ff0f67443572133bc18eca6058f732c1b/msgpack-1.2.2-cp315-cp315t-win_amd64.whl", hash = "sha256:6f53285f20d592ed309ee19e509cc4c77a3bda1db02ad67e8a0949bb227a5a6d", size = 78766, upload-time = "2026-08-27T10:03:45.036Z" },
|
|
||||||
{ url = "https://files.pythonhosted.org/packages/60/54/89ed16e6f966a050dc78b0e94a545025211b07ce9f4bdfe07dff70c03fc2/msgpack-1.2.2-cp315-cp315t-win_arm64.whl", hash = "sha256:a378e12ccc06d76efde115caf4073b7e5ff3cc18291d1341f9e65fb882e3f754", size = 71819, upload-time = "2026-08-27T10:03:46.375Z" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1393,7 +1374,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openai"
|
name = "openai"
|
||||||
version = "3.6.0"
|
version = "3.3.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anyio" },
|
{ name = "anyio" },
|
||||||
@@ -1403,9 +1384,9 @@ dependencies = [
|
|||||||
{ name = "sniffio" },
|
{ name = "sniffio" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/ac/76/913b755a1a6b54e2d9140eb8d488aa0d47c7359b1d7eac5e864cb7913bbf/openai-3.6.0.tar.gz", hash = "sha256:18fe3f6e96390ef41ee27b152fc9effefca321c33673bd9b956a572493d3ab9b", size = 1455376, upload-time = "2026-08-28T22:29:18.268Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/7d/9c/ba0c292b4032ede74c249ca314ad64eb1bb5a03a843f6e01facb02f80cd8/openai-3.3.1.tar.gz", hash = "sha256:6f22807de1a976c932cecda620e8172a8c3fdbaeed29c7f21564e0c2410edf56", size = 1282113, upload-time = "2026-08-19T16:31:35.006Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/a1/94/805b87ecc951c49ec8f247f5e8eb324ab064bd2ad73b6a0e704dd49aa073/openai-3.6.0-py3-none-any.whl", hash = "sha256:508e2158bf971687f953b62e44b02f207792c815aac306816386d7ba34d37f5f", size = 1699841, upload-time = "2026-08-28T22:29:16.436Z" },
|
{ url = "https://files.pythonhosted.org/packages/6a/db/2b7a1b3de659bb82aef979116c74e809982b13e42c057759767552b5155f/openai-3.3.1-py3-none-any.whl", hash = "sha256:9652df7fdf8ee6f5bd58e0a12f2b1d414a18e0f06bb7a9a57c8643a5f5469bd3", size = 1690337, upload-time = "2026-08-19T16:31:32.812Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1717,7 +1698,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic"
|
name = "pydantic"
|
||||||
version = "2.13.5"
|
version = "2.13.4"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "annotated-types" },
|
{ name = "annotated-types" },
|
||||||
@@ -1725,84 +1706,84 @@ dependencies = [
|
|||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
{ name = "typing-inspection" },
|
{ name = "typing-inspection" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" },
|
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic-core"
|
name = "pydantic-core"
|
||||||
version = "2.46.5"
|
version = "2.46.4"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" },
|
{ url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" },
|
{ url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" },
|
{ url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" },
|
{ url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" },
|
{ url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" },
|
{ url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" },
|
{ url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" },
|
{ url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" },
|
{ url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" },
|
{ url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" },
|
{ url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" },
|
{ url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" },
|
{ url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" },
|
{ url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" },
|
{ url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" },
|
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" },
|
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" },
|
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" },
|
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" },
|
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" },
|
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" },
|
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" },
|
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" },
|
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" },
|
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" },
|
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" },
|
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" },
|
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" },
|
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" },
|
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" },
|
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" },
|
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" },
|
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" },
|
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" },
|
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" },
|
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" },
|
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" },
|
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" },
|
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" },
|
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" },
|
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" },
|
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" },
|
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" },
|
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" },
|
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" },
|
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" },
|
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" },
|
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" },
|
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" },
|
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" },
|
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" },
|
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" },
|
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" },
|
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" },
|
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" },
|
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" },
|
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" },
|
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" },
|
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" },
|
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" },
|
{ url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" },
|
{ url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" },
|
{ url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" },
|
{ url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1989,7 +1970,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typer"
|
name = "typer"
|
||||||
version = "0.27.2"
|
version = "0.27.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "annotated-doc" },
|
{ name = "annotated-doc" },
|
||||||
@@ -1997,18 +1978,18 @@ dependencies = [
|
|||||||
{ name = "rich" },
|
{ name = "rich" },
|
||||||
{ name = "shellingham" },
|
{ name = "shellingham" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/16/f7/57713ba479fd405eb76de31404b2c744c289e336b2d999511ebf51e496f7/typer-0.27.2.tar.gz", hash = "sha256:269b7eb9d3c202ca84b4bc9618cb04ebb43d3d4d1e567e4c768607232c05f945", size = 204045, upload-time = "2026-08-28T10:26:55.046Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/ae/40/4a3db7990d1f62a53182aa96eaef57aeb2886a27f90a195bc66713565d31/typer-0.27.1.tar.gz", hash = "sha256:a79bef8469a79c45498e7b814ecf8d603cc7644e9acbd9e19cac0334240b18df", size = 203994, upload-time = "2026-08-03T14:41:03.438Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/dc/bf/205d0004930ede8f542fb58f601526fccf4ae7626075ca1e6c4de5d3d652/typer-0.27.2-py3-none-any.whl", hash = "sha256:b3a5fc4342d5fc8fda8fc3010b1cf117e9249aab7fae800c2eff62fd3842d97d", size = 123130, upload-time = "2026-08-28T10:26:53.752Z" },
|
{ url = "https://files.pythonhosted.org/packages/43/89/9518bc0c3929bee36b3a4a8e3daddd6e03f92f9961c66d4983b837160543/typer-0.27.1-py3-none-any.whl", hash = "sha256:53150287edd11baeb4e4722c8e394fcdf8181c0ae89485cba8d25c778d5edd56", size = 122874, upload-time = "2026-08-03T14:41:04.391Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "types-protobuf"
|
name = "types-protobuf"
|
||||||
version = "7.35.1.20260827"
|
version = "7.35.1.20260826"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/44/07/541ffc94a4baee7d92ec91d91018aa684f97ece608b74619863e3cfd9cb3/types_protobuf-7.35.1.20260827.tar.gz", hash = "sha256:5a98c18177cfd968d6a0de55ecfc4221aa829ef88bcd86104298e028516b04af", size = 69646, upload-time = "2026-08-27T12:06:04.93Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/5a/8d/63939858680cda558e43de17822e1bcdf74fb3d1cdf5a80ce7a90a0c2a0f/types_protobuf-7.35.1.20260826.tar.gz", hash = "sha256:e7083f061500bcbf6d1f32eb83dfff0983d1583b028ae7a791dbe33f47e8bf3a", size = 69626, upload-time = "2026-08-26T02:54:39.711Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/01/e2/7ce7c4802bcf3732155dedc23fe3d2b506a387d6b42da61261886f42af80/types_protobuf-7.35.1.20260827-py3-none-any.whl", hash = "sha256:7cc452a012678a6a889287d4cb702ea54b9f90a6ab0f3aa4c019d21f3ae7529b", size = 86412, upload-time = "2026-08-27T12:06:03.826Z" },
|
{ url = "https://files.pythonhosted.org/packages/fe/a8/24fa08c2b9307ead7e9c3565de007e47315ca396c78e9cc1350643737052/types_protobuf-7.35.1.20260826-py3-none-any.whl", hash = "sha256:49adc6d989e146b7b2ba4498aeae36937619d4b95a21cd20025fc88b2cd29b62", size = 86412, upload-time = "2026-08-26T02:54:38.673Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2131,7 +2112,6 @@ wheels = [
|
|||||||
name = "websockets"
|
name = "websockets"
|
||||||
version = "17.1"
|
version = "17.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/18/72/fba934cb3dff7a85d811820efffcd141ddd52b5a2a01637f64551373ff4d/websockets-17.1.tar.gz", hash = "sha256:acfea4c20bf54384883ea33b1240fc1db4f52e190823a4e2b334bc3e8bfca96a", size = 187520, upload-time = "2026-08-26T17:25:33.063Z" }
|
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/a6/0d/098f23c4c858e5de9459ffc554fa07d5493fbcfca7f040b5800cf1cecc35/websockets-17.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:76dd004f59115087c7b700474cb18f01325e37250032e19396c08ae41448e4b3", size = 217015, upload-time = "2026-08-26T14:55:45.194Z" },
|
{ url = "https://files.pythonhosted.org/packages/a6/0d/098f23c4c858e5de9459ffc554fa07d5493fbcfca7f040b5800cf1cecc35/websockets-17.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:76dd004f59115087c7b700474cb18f01325e37250032e19396c08ae41448e4b3", size = 217015, upload-time = "2026-08-26T14:55:45.194Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/13/86/bc1317b1a4d8c4688e2a7e564b5e004dab44c2534d7ca05de6ae9a863fca/websockets-17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:581fa678ef46f4277cc8491312468e582f8ad609dbab907ba6096a08c6a0ff98", size = 214692, upload-time = "2026-08-26T14:55:46.366Z" },
|
{ url = "https://files.pythonhosted.org/packages/13/86/bc1317b1a4d8c4688e2a7e564b5e004dab44c2534d7ca05de6ae9a863fca/websockets-17.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:581fa678ef46f4277cc8491312468e582f8ad609dbab907ba6096a08c6a0ff98", size = 214692, upload-time = "2026-08-26T14:55:46.366Z" },
|
||||||
@@ -2253,7 +2233,6 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/4e/f5/99857c3dd9676749f33e3668665a34ad6099505fb8d75eb084f49f7807a9/websockets-17.1-cp315-cp315t-win32.whl", hash = "sha256:f78a3ffb1994304db2c0c4588e4d1a518079b557054fa3bb985a6f5e50ff49a3", size = 217130, upload-time = "2026-08-26T14:58:20.037Z" },
|
{ url = "https://files.pythonhosted.org/packages/4e/f5/99857c3dd9676749f33e3668665a34ad6099505fb8d75eb084f49f7807a9/websockets-17.1-cp315-cp315t-win32.whl", hash = "sha256:f78a3ffb1994304db2c0c4588e4d1a518079b557054fa3bb985a6f5e50ff49a3", size = 217130, upload-time = "2026-08-26T14:58:20.037Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2c/84/77599922ab441bfe61508f97dab2c71f8e114d31793993ea54011db16199/websockets-17.1-cp315-cp315t-win_amd64.whl", hash = "sha256:ad68c28a27246fed109a4409393d677b7e1388345cbbd2f5aee5c182d8506110", size = 217448, upload-time = "2026-08-26T14:58:21.382Z" },
|
{ url = "https://files.pythonhosted.org/packages/2c/84/77599922ab441bfe61508f97dab2c71f8e114d31793993ea54011db16199/websockets-17.1-cp315-cp315t-win_amd64.whl", hash = "sha256:ad68c28a27246fed109a4409393d677b7e1388345cbbd2f5aee5c182d8506110", size = 217448, upload-time = "2026-08-26T14:58:21.382Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ce/3c/8b9a225b523f06a9389be81f1b0ab07c49bec6014742e6aa359c1f920f1f/websockets-17.1-cp315-cp315t-win_arm64.whl", hash = "sha256:e552e0037230ac16e5f568de7012041344d1b18c9feed30ec2891b8eba55af81", size = 217372, upload-time = "2026-08-26T14:58:22.807Z" },
|
{ url = "https://files.pythonhosted.org/packages/ce/3c/8b9a225b523f06a9389be81f1b0ab07c49bec6014742e6aa359c1f920f1f/websockets-17.1-cp315-cp315t-win_arm64.whl", hash = "sha256:e552e0037230ac16e5f568de7012041344d1b18c9feed30ec2891b8eba55af81", size = 217372, upload-time = "2026-08-26T14:58:22.807Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/41/63/23572870e01836a98346075b9e17a8bc24a6ddd9800a3204ceee58677f3c/websockets-17.1-py3-none-any.whl", hash = "sha256:f221081107b8c48184d99f7019604486376e7ef826037e70aad6b02540732c23", size = 211134, upload-time = "2026-08-26T17:25:31.397Z" },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ class AnalyticsEvent(StrEnum):
|
|||||||
|
|
||||||
# Rooms
|
# Rooms
|
||||||
ROOM_CREATED = "room_created"
|
ROOM_CREATED = "room_created"
|
||||||
ROOM_UPDATED = "room_updated"
|
|
||||||
|
|
||||||
# Roomkit (meeting-room SIP devices)
|
# Roomkit (meeting-room SIP devices)
|
||||||
ROOMKIT_JOINED = "roomkit_joined"
|
ROOMKIT_JOINED = "roomkit_joined"
|
||||||
|
|||||||
@@ -71,7 +71,6 @@ def get_frontend_configuration(request):
|
|||||||
"force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL,
|
"force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL,
|
||||||
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
|
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
|
||||||
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
||||||
"default_video_codec": settings.LIVEKIT_DEFAULT_VIDEO_CODEC,
|
|
||||||
},
|
},
|
||||||
"authenticated_users_can_edit_display_name": (
|
"authenticated_users_can_edit_display_name": (
|
||||||
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
|
settings.AUTHENTICATED_PARTICIPANTS_CAN_EDIT_DISPLAY_NAME
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ from ..services.participants_management import (
|
|||||||
ParticipantsManagementException,
|
ParticipantsManagementException,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ACTION_FOR_METHOD_TO_PERMISSION = {
|
||||||
|
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class IsAuthenticated(permissions.BasePermission):
|
class IsAuthenticated(permissions.BasePermission):
|
||||||
"""
|
"""
|
||||||
@@ -23,6 +27,15 @@ class IsAuthenticated(permissions.BasePermission):
|
|||||||
return bool(request.auth) or request.user.is_authenticated
|
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):
|
class IsSelf(IsAuthenticated):
|
||||||
"""
|
"""
|
||||||
Allows access only to authenticated users. Alternative method checking the presence
|
Allows access only to authenticated users. Alternative method checking the presence
|
||||||
|
|||||||
@@ -75,7 +75,11 @@ from core.services.participants_management import (
|
|||||||
ParticipantsManagementException,
|
ParticipantsManagementException,
|
||||||
)
|
)
|
||||||
from core.services.room_creation import RoomCreation
|
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 (
|
from core.services.room_roles import (
|
||||||
RoomRoleError,
|
RoomRoleError,
|
||||||
RoomRoleService,
|
RoomRoleService,
|
||||||
@@ -95,6 +99,60 @@ from .feature_flag import FeatureFlag
|
|||||||
logger = getLogger(__name__)
|
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:
|
class SerializerPerActionMixin:
|
||||||
"""
|
"""
|
||||||
A mixin to allow to define serializer classes for each action.
|
A mixin to allow to define serializer classes for each action.
|
||||||
@@ -298,7 +356,26 @@ class RoomViewSet(
|
|||||||
):
|
):
|
||||||
return
|
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(
|
@decorators.action(
|
||||||
detail=True,
|
detail=True,
|
||||||
@@ -855,15 +932,6 @@ class RoomViewSet(
|
|||||||
"""Rename the current participant in the room."""
|
"""Rename the current participant in the room."""
|
||||||
room = self.get_object()
|
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 = serializers.RenameParticipantSerializer(data=request.data)
|
||||||
serializer.is_valid(raise_exception=True)
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
@@ -1008,10 +1076,9 @@ class RecordingViewSet(
|
|||||||
|
|
||||||
def _auth_get_original_url(self, request):
|
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.
|
Raises PermissionDenied if the header is missing.
|
||||||
The original url is passed by the reverse proxy in the header named by the
|
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
|
||||||
MEDIA_AUTH_ORIGINAL_URL_HEADER setting, which defaults to "HTTP_X_ORIGINAL_URL".
|
|
||||||
See corresponding ingress configuration in Helm chart and read about the
|
See corresponding ingress configuration in Helm chart and read about the
|
||||||
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
|
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
|
||||||
is configured to do this.
|
is configured to do this.
|
||||||
@@ -1021,13 +1088,9 @@ class RecordingViewSet(
|
|||||||
reasons.
|
reasons.
|
||||||
"""
|
"""
|
||||||
# Extract the original URL from the request header
|
# 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:
|
if not original_url:
|
||||||
logger.warning(
|
logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest")
|
||||||
"Missing %s header in subrequest. Set MEDIA_AUTH_ORIGINAL_URL_HEADER "
|
|
||||||
"to the header your reverse proxy sends.",
|
|
||||||
settings.MEDIA_AUTH_ORIGINAL_URL_HEADER,
|
|
||||||
)
|
|
||||||
raise drf_exceptions.PermissionDenied()
|
raise drf_exceptions.PermissionDenied()
|
||||||
|
|
||||||
logger.debug("Original url: '%s'", original_url)
|
logger.debug("Original url: '%s'", original_url)
|
||||||
@@ -1352,8 +1415,7 @@ class FileViewSet(
|
|||||||
Authorize access based on the original URL of an Nginx subrequest
|
Authorize access based on the original URL of an Nginx subrequest
|
||||||
and user permissions. Returns a dictionary of URL parameters if authorized.
|
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
|
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
|
||||||
MEDIA_AUTH_ORIGINAL_URL_HEADER setting, which defaults to "HTTP_X_ORIGINAL_URL".
|
|
||||||
See corresponding ingress configuration in Helm chart and read about the
|
See corresponding ingress configuration in Helm chart and read about the
|
||||||
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
|
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
|
||||||
is configured to do this.
|
is configured to do this.
|
||||||
@@ -1372,13 +1434,9 @@ class FileViewSet(
|
|||||||
- PermissionDenied if authorization fails.
|
- PermissionDenied if authorization fails.
|
||||||
"""
|
"""
|
||||||
# Extract the original URL from the request header
|
# 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:
|
if not original_url:
|
||||||
logger.warning(
|
logger.warning("Missing HTTP_X_ORIGINAL_URL header in subrequest")
|
||||||
"Missing %s header in subrequest. Set MEDIA_AUTH_ORIGINAL_URL_HEADER "
|
|
||||||
"to the header your reverse proxy sends.",
|
|
||||||
settings.MEDIA_AUTH_ORIGINAL_URL_HEADER,
|
|
||||||
)
|
|
||||||
raise drf_exceptions.PermissionDenied()
|
raise drf_exceptions.PermissionDenied()
|
||||||
|
|
||||||
parsed_url = urlparse(original_url)
|
parsed_url = urlparse(original_url)
|
||||||
|
|||||||
@@ -3,11 +3,7 @@
|
|||||||
import contextlib
|
import contextlib
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.exceptions import (
|
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
|
||||||
ImproperlyConfigured,
|
|
||||||
SuspiciousOperation,
|
|
||||||
ValidationError,
|
|
||||||
)
|
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from lasuite.oidc_login.backends import (
|
from lasuite.oidc_login.backends import (
|
||||||
@@ -21,7 +17,6 @@ from core.services.marketing import (
|
|||||||
ContactData,
|
ContactData,
|
||||||
get_marketing_service,
|
get_marketing_service,
|
||||||
)
|
)
|
||||||
from core.validators import sub_validator
|
|
||||||
|
|
||||||
|
|
||||||
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||||
@@ -89,19 +84,6 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
|||||||
|
|
||||||
def get_existing_user(self, sub, email):
|
def get_existing_user(self, sub, email):
|
||||||
"""Fetch existing user by sub or 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:
|
try:
|
||||||
return User.objects.get(sub=sub)
|
return User.objects.get(sub=sub)
|
||||||
except User.DoesNotExist:
|
except User.DoesNotExist:
|
||||||
|
|||||||
@@ -286,10 +286,6 @@ class ResourceServerBackend(LaSuiteBackend):
|
|||||||
if user is None and settings.OIDC_CREATE_USER:
|
if user is None and settings.OIDC_CREATE_USER:
|
||||||
user = self.create_user(sub)
|
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
|
return user
|
||||||
|
|
||||||
def create_user(self, sub):
|
def create_user(self, sub):
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
"""External API endpoints"""
|
"""External API endpoints"""
|
||||||
|
|
||||||
import copy
|
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -26,7 +25,6 @@ from rest_framework import (
|
|||||||
from core import analytics, api, models
|
from core import analytics, api, models
|
||||||
from core.api.feature_flag import FeatureFlag
|
from core.api.feature_flag import FeatureFlag
|
||||||
from core.services.jwt_token import JwtTokenService
|
from core.services.jwt_token import JwtTokenService
|
||||||
from core.services.room_management import RoomManagement
|
|
||||||
|
|
||||||
from ..services.provisional_user_service import (
|
from ..services.provisional_user_service import (
|
||||||
ProvisionalUserCreationDisabledError,
|
ProvisionalUserCreationDisabledError,
|
||||||
@@ -144,7 +142,6 @@ class RoomViewSet(
|
|||||||
mixins.CreateModelMixin,
|
mixins.CreateModelMixin,
|
||||||
mixins.RetrieveModelMixin,
|
mixins.RetrieveModelMixin,
|
||||||
mixins.ListModelMixin,
|
mixins.ListModelMixin,
|
||||||
mixins.UpdateModelMixin,
|
|
||||||
viewsets.GenericViewSet,
|
viewsets.GenericViewSet,
|
||||||
):
|
):
|
||||||
"""Application-delegated API for room management.
|
"""Application-delegated API for room management.
|
||||||
@@ -157,12 +154,8 @@ class RoomViewSet(
|
|||||||
- list: List rooms the user has access to (requires 'rooms:list' scope)
|
- list: List rooms the user has access to (requires 'rooms:list' scope)
|
||||||
- retrieve: Get room details (requires 'rooms:retrieve' scope)
|
- retrieve: Get room details (requires 'rooms:retrieve' scope)
|
||||||
- create: Create a new room owned by the user (requires 'rooms:create' 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_classes = [
|
||||||
authentication.ApplicationJWTAuthentication,
|
authentication.ApplicationJWTAuthentication,
|
||||||
authentication.AddonsJWTAuthentication,
|
authentication.AddonsJWTAuthentication,
|
||||||
@@ -196,39 +189,7 @@ class RoomViewSet(
|
|||||||
serializer = self.get_serializer(queryset, many=True)
|
serializer = self.get_serializer(queryset, many=True)
|
||||||
return drf_response.Response(serializer.data)
|
return drf_response.Response(serializer.data)
|
||||||
|
|
||||||
def _track_room_event(self, room, event, **extra_properties):
|
def perform_create(self, serializer):
|
||||||
"""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):
|
|
||||||
"""Set the current user as owner of the newly created room."""
|
"""Set the current user as owner of the newly created room."""
|
||||||
room = serializer.save()
|
room = serializer.save()
|
||||||
models.ResourceAccess.objects.create(
|
models.ResourceAccess.objects.create(
|
||||||
@@ -237,31 +198,27 @@ class RoomViewSet(
|
|||||||
role=models.RoleChoices.OWNER,
|
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):
|
# Log for auditing
|
||||||
"""Persist the room update, sync it to LiveKit, then log and track it."""
|
logger.info(
|
||||||
|
"Room created via application: room_id=%s, user_id=%s, client_id=%s, auth_method=%s",
|
||||||
previous_values = {
|
room.id,
|
||||||
"access_level": serializer.instance.access_level,
|
self.request.user.id,
|
||||||
"configuration": copy.deepcopy(serializer.instance.configuration),
|
client_id,
|
||||||
}
|
auth_method,
|
||||||
|
|
||||||
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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if updated_fields:
|
analytics.capture(
|
||||||
RoomManagement.sync_room_metadata(room)
|
self.request.user,
|
||||||
|
analytics.AnalyticsEvent.ROOM_CREATED,
|
||||||
self._track_room_event(
|
{
|
||||||
room,
|
"room_id": str(room.pk),
|
||||||
analytics.AnalyticsEvent.ROOM_UPDATED,
|
"access_level": room.access_level,
|
||||||
updated_fields=updated_fields,
|
"client_id": client_id,
|
||||||
previous_access_level=previous_values["access_level"],
|
"external_api": True,
|
||||||
|
"auth_method": auth_method,
|
||||||
|
"$set": {"email": self.request.user.email},
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ class ResourceFactory(factory.django.DjangoModelFactory):
|
|||||||
else:
|
else:
|
||||||
UserResourceAccessFactory(resource=self, user=item[0], role=item[1])
|
UserResourceAccessFactory(resource=self, user=item[0], role=item[1])
|
||||||
|
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
class UserResourceAccessFactory(factory.django.DjangoModelFactory):
|
class UserResourceAccessFactory(factory.django.DjangoModelFactory):
|
||||||
"""Create fake resource user accesses for testing."""
|
"""Create fake resource user accesses for testing."""
|
||||||
@@ -95,6 +97,8 @@ class RecordingFactory(factory.django.DjangoModelFactory):
|
|||||||
recording=self, user=item[0], role=item[1]
|
recording=self, user=item[0], role=item[1]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
class UserRecordingAccessFactory(factory.django.DjangoModelFactory):
|
class UserRecordingAccessFactory(factory.django.DjangoModelFactory):
|
||||||
"""Create fake recording user accesses for testing."""
|
"""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.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
import core.validators
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
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')),
|
('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')),
|
('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')),
|
('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')),
|
('email', models.EmailField(blank=True, max_length=254, null=True, verbose_name='identity email address')),
|
||||||
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
|
('admin_email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='admin email address')),
|
||||||
('language', models.CharField(choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE, help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language')),
|
('language', models.CharField(choices=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 . import fields, utils
|
||||||
from .recording.enums import FileExtension
|
from .recording.enums import FileExtension
|
||||||
from .validators import sub_validator
|
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
@@ -58,7 +57,6 @@ class RecordingStatusChoices(models.TextChoices):
|
|||||||
STOPPED = "stopped", _("Stopped")
|
STOPPED = "stopped", _("Stopped")
|
||||||
SAVED = "saved", _("Saved")
|
SAVED = "saved", _("Saved")
|
||||||
ABORTED = "aborted", _("Aborted")
|
ABORTED = "aborted", _("Aborted")
|
||||||
FAILED = "failed", _("Failed")
|
|
||||||
FAILED_TO_START = "failed_to_start", _("Failed to Start")
|
FAILED_TO_START = "failed_to_start", _("Failed to Start")
|
||||||
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
|
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
|
||||||
NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded")
|
NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded")
|
||||||
@@ -80,13 +78,17 @@ class RecordingStatusChoices(models.TextChoices):
|
|||||||
cls.STOPPED,
|
cls.STOPPED,
|
||||||
cls.SAVED,
|
cls.SAVED,
|
||||||
cls.ABORTED,
|
cls.ABORTED,
|
||||||
cls.FAILED,
|
|
||||||
cls.EXTERNAL_PROCESS_SUCCESSFUL,
|
cls.EXTERNAL_PROCESS_SUCCESSFUL,
|
||||||
cls.EXTERNAL_PROCESS_FAILED,
|
cls.EXTERNAL_PROCESS_FAILED,
|
||||||
cls.FAILED_TO_START,
|
cls.FAILED_TO_START,
|
||||||
cls.FAILED_TO_STOP,
|
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):
|
class RecordingModeChoices(models.TextChoices):
|
||||||
"""Recording mode choices."""
|
"""Recording mode choices."""
|
||||||
@@ -143,11 +145,19 @@ class BaseModel(models.Model):
|
|||||||
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||||
"""User model to work with OIDC only authentication."""
|
"""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 = models.CharField(
|
||||||
_("sub"),
|
_("sub"),
|
||||||
help_text=_(
|
help_text=_(
|
||||||
"Optional for pending users; required upon account activation. "
|
"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,
|
max_length=255,
|
||||||
unique=True,
|
unique=True,
|
||||||
@@ -579,7 +589,6 @@ class Recording(BaseModel):
|
|||||||
4. NOTIFICATION_SUCCEEDED: External service has been notified of this recording
|
4. NOTIFICATION_SUCCEEDED: External service has been notified of this recording
|
||||||
|
|
||||||
Error States:
|
Error States:
|
||||||
- FAILED: Egress failed mid-recording
|
|
||||||
- FAILED_TO_START: Worker failed to initialize recording
|
- FAILED_TO_START: Worker failed to initialize recording
|
||||||
- FAILED_TO_STOP: Worker failed during stop operation
|
- FAILED_TO_STOP: Worker failed during stop operation
|
||||||
- ABORTED: Recording was terminated before completion
|
- ABORTED: Recording was terminated before completion
|
||||||
|
|||||||
@@ -8,50 +8,3 @@ class FileExtension(Enum):
|
|||||||
|
|
||||||
OGG = "ogg"
|
OGG = "ogg"
|
||||||
MP4 = "mp4"
|
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 logging import getLogger
|
||||||
|
|
||||||
|
from livekit import api
|
||||||
|
|
||||||
from core import models, utils
|
from core import models, utils
|
||||||
from core.models import Recording
|
from core.models import Recording
|
||||||
from core.recording.enums import (
|
|
||||||
UNSUCCESSFUL_EVENTS,
|
|
||||||
RecordingWorkerEvent,
|
|
||||||
)
|
|
||||||
from core.recording.event.notification import notification_service
|
from core.recording.event.notification import notification_service
|
||||||
from core.services.room_management import (
|
from core.services.room_management import (
|
||||||
RoomManagement,
|
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"""
|
"""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:
|
class RecordingEventsService:
|
||||||
"""Handles recording-related worker events.
|
"""Handles recording-related LiveKit webhook events."""
|
||||||
|
|
||||||
Two entry points: `handle_update` for the events a running recording
|
|
||||||
reports, and `handle_terminal_event` for the one ending it.
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def log_worker_error(recording, event, error=None, error_code=None):
|
def handle_update(recording: Recording, egress_status):
|
||||||
"""Log FAILED at error level and expected ABORTED outcomes at info level."""
|
"""Handle egress status updates and sync recording state to room metadata."""
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
room_name = str(recording.room.id)
|
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:
|
if recording_status:
|
||||||
try:
|
try:
|
||||||
RoomManagement.update_metadata(
|
RoomManagement().update_metadata(
|
||||||
room_name, {"recording_status": recording_status}
|
room_name, {"recording_status": recording_status}
|
||||||
)
|
)
|
||||||
except RoomNotFoundException:
|
except RoomNotFoundException:
|
||||||
@@ -143,113 +55,42 @@ class RecordingEventsService:
|
|||||||
except RoomManagementException as e:
|
except RoomManagementException as e:
|
||||||
logger.exception("Failed to update room's metadata: %s", e)
|
logger.exception("Failed to update room's metadata: %s", e)
|
||||||
|
|
||||||
def handle_terminal_event(self, recording: Recording, event: RecordingWorkerEvent):
|
@staticmethod
|
||||||
"""Run the appropriate handlers for a terminal event, given the recording's state."""
|
def handle_limit_reached(recording: Recording):
|
||||||
|
|
||||||
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):
|
|
||||||
"""Stop recording and notify participants when limit is reached."""
|
"""Stop recording and notify participants when limit is reached."""
|
||||||
|
|
||||||
recording.status = models.RecordingStatusChoices.STOPPED
|
recording.status = models.RecordingStatusChoices.STOPPED
|
||||||
recording.save()
|
recording.save()
|
||||||
|
|
||||||
cls._notify_participants(recording, RecordingWorkerEvent.LIMIT_REACHED)
|
notification_mapping = {
|
||||||
|
models.RecordingModeChoices.SCREEN_RECORDING: "screenRecordingLimitReached",
|
||||||
|
models.RecordingModeChoices.TRANSCRIPT: "transcriptionLimitReached",
|
||||||
|
}
|
||||||
|
|
||||||
@classmethod
|
notification_type = notification_mapping.get(recording.mode)
|
||||||
def _handle_failed(cls, recording: Recording):
|
if not notification_type:
|
||||||
"""Set recording status to failed, matching the worker event, and notify participants.
|
return
|
||||||
|
|
||||||
FAILED: used when an actual runtime/pipeline error occurs after the
|
try:
|
||||||
recording has started
|
utils.notify_participants(
|
||||||
"""
|
room_name=str(recording.room.id),
|
||||||
recording.status = models.RecordingStatusChoices.FAILED
|
notification_data={"type": notification_type},
|
||||||
recording.save()
|
)
|
||||||
|
except utils.NotificationError as e:
|
||||||
cls._notify_participants(recording, RecordingWorkerEvent.FAILED)
|
logger.exception(
|
||||||
|
"Failed to notify participants about recording limit reached: "
|
||||||
@classmethod
|
"room=%s, recording_id=%s, mode=%s",
|
||||||
def _handle_aborted(cls, recording: Recording):
|
recording.room.id,
|
||||||
"""Set recording status to aborted, matching the worker event, and notify participants.
|
recording.id,
|
||||||
|
recording.mode,
|
||||||
ABORTED: used when the worker stops before it ever became
|
)
|
||||||
active/recording
|
raise RecordingEventsError(
|
||||||
"""
|
f"Failed to notify participants in room '{recording.room.id}' about "
|
||||||
recording.status = models.RecordingStatusChoices.ABORTED
|
f"recording limit reached (recording_id={recording.id})"
|
||||||
recording.save()
|
) from e
|
||||||
|
|
||||||
cls._notify_participants(recording, RecordingWorkerEvent.ABORTED)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _handle_successful(recording: Recording):
|
def handle_complete(recording: Recording):
|
||||||
"""Notify external services and save recording."""
|
"""Notify external services and save recording."""
|
||||||
|
|
||||||
if not recording.is_savable():
|
if not recording.is_savable():
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ class WorkerServiceMediator:
|
|||||||
mode = recording.options.get("original_mode", None) or recording.mode
|
mode = recording.options.get("original_mode", None) or recording.mode
|
||||||
|
|
||||||
try:
|
try:
|
||||||
RoomManagement.update_metadata(
|
RoomManagement().update_metadata(
|
||||||
room_name, {"recording_mode": mode, "recording_status": "starting"}
|
room_name, {"recording_mode": mode, "recording_status": "starting"}
|
||||||
)
|
)
|
||||||
except RoomNotFoundException:
|
except RoomNotFoundException:
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
# pylint: disable=no-member
|
# pylint: disable=no-member
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
from livekit import api as livekit_api
|
from livekit import api as livekit_api
|
||||||
|
|
||||||
@@ -12,8 +10,6 @@ from ..enums import FileExtension
|
|||||||
from .exceptions import WorkerConnectionError, WorkerResponseError
|
from .exceptions import WorkerConnectionError, WorkerResponseError
|
||||||
from .factories import WorkerServiceConfig
|
from .factories import WorkerServiceConfig
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class BaseEgressService:
|
class BaseEgressService:
|
||||||
"""Base egress defining common methods to manage and interact with LiveKit egress processes."""
|
"""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:
|
def _get_filepath(self, filename: str, extension: str) -> str:
|
||||||
"""Construct the file path for a given filename and extension.
|
"""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}"
|
return f"{self._config.output_folder}/{filename}.{extension}"
|
||||||
|
|
||||||
@@ -53,22 +49,6 @@ class BaseEgressService:
|
|||||||
finally:
|
finally:
|
||||||
await lkapi.aclose()
|
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:
|
def stop(self, worker_id: str) -> str:
|
||||||
"""Stop an ongoing egress worker.
|
"""Stop an ongoing egress worker.
|
||||||
The StopEgressRequest is shared among all types of egress,
|
The StopEgressRequest is shared among all types of egress,
|
||||||
@@ -86,26 +66,14 @@ class BaseEgressService:
|
|||||||
"LiveKit response is missing the recording status."
|
"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:
|
if response.status == livekit_api.EgressStatus.EGRESS_ENDING:
|
||||||
return "STOPPED"
|
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"
|
return "FAILED_TO_STOP"
|
||||||
|
|
||||||
def start(self, room_name, recording_id):
|
def start(self, room_name, recording_id):
|
||||||
|
|||||||
@@ -12,12 +12,15 @@ from django.conf import settings
|
|||||||
from livekit import api
|
from livekit import api
|
||||||
|
|
||||||
from core import models
|
from core import models
|
||||||
from core.recording.enums import RecordingWorkerEvent
|
|
||||||
from core.recording.services.metadata_collector import (
|
from core.recording.services.metadata_collector import (
|
||||||
MetadataCollectorException,
|
MetadataCollectorException,
|
||||||
MetadataCollectorService,
|
MetadataCollectorService,
|
||||||
)
|
)
|
||||||
from core.recording.services.recording_events import RecordingEventsService
|
from core.recording.services.recording_events import (
|
||||||
|
RecordingEventsError,
|
||||||
|
RecordingEventsService,
|
||||||
|
RecordingNotSavableError,
|
||||||
|
)
|
||||||
|
|
||||||
from .lobby import LobbyService
|
from .lobby import LobbyService
|
||||||
from .presence import PresenceCache
|
from .presence import PresenceCache
|
||||||
@@ -49,6 +52,12 @@ class InvalidPayloadError(LiveKitWebhookError):
|
|||||||
status_code = 400
|
status_code = 400
|
||||||
|
|
||||||
|
|
||||||
|
class UnsupportedEventTypeError(LiveKitWebhookError):
|
||||||
|
"""Unsupported event type."""
|
||||||
|
|
||||||
|
status_code = 422
|
||||||
|
|
||||||
|
|
||||||
class ActionFailedError(LiveKitWebhookError):
|
class ActionFailedError(LiveKitWebhookError):
|
||||||
"""Webhook action fails to process or complete."""
|
"""Webhook action fails to process or complete."""
|
||||||
|
|
||||||
@@ -65,7 +74,6 @@ class LiveKitWebhookEventType(Enum):
|
|||||||
# Participant events
|
# Participant events
|
||||||
PARTICIPANT_JOINED = "participant_joined"
|
PARTICIPANT_JOINED = "participant_joined"
|
||||||
PARTICIPANT_LEFT = "participant_left"
|
PARTICIPANT_LEFT = "participant_left"
|
||||||
PARTICIPANT_CONNECTION_ABORTED = "participant_connection_aborted"
|
|
||||||
|
|
||||||
# Track events
|
# Track events
|
||||||
TRACK_PUBLISHED = "track_published"
|
TRACK_PUBLISHED = "track_published"
|
||||||
@@ -81,30 +89,6 @@ class LiveKitWebhookEventType(Enum):
|
|||||||
INGRESS_ENDED = "ingress_ended"
|
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:
|
class LiveKitEventsService:
|
||||||
"""Service for processing and handling LiveKit webhook events and notifications."""
|
"""Service for processing and handling LiveKit webhook events and notifications."""
|
||||||
|
|
||||||
@@ -169,13 +153,10 @@ class LiveKitEventsService:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
webhook_type = LiveKitWebhookEventType(data.event)
|
webhook_type = LiveKitWebhookEventType(data.event)
|
||||||
except ValueError:
|
except ValueError as e:
|
||||||
logger.warning(
|
raise UnsupportedEventTypeError(
|
||||||
"Ignoring unknown LiveKit webhook event type '%s' for room '%s'",
|
f"Unknown webhook type: {data.event}"
|
||||||
data.event,
|
) from e
|
||||||
room_name,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Handle according to received webhook type
|
# Handle according to received webhook type
|
||||||
handler = self._webhook_handlers.get(webhook_type.value)
|
handler = self._webhook_handlers.get(webhook_type.value)
|
||||||
@@ -194,20 +175,12 @@ class LiveKitEventsService:
|
|||||||
f"Recording with worker ID {egress_id} does not exist"
|
f"Recording with worker ID {egress_id} does not exist"
|
||||||
) from err
|
) from err
|
||||||
|
|
||||||
event = to_recording_event(data.egress_info.status)
|
egress_status = data.egress_info.status
|
||||||
if event is None:
|
self.recording_events.handle_update(recording, egress_status)
|
||||||
return
|
|
||||||
|
|
||||||
self.recording_events.handle_update(recording, event)
|
|
||||||
|
|
||||||
def _handle_egress_ended(self, data):
|
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:
|
try:
|
||||||
recording = models.Recording.objects.select_related("room").get(
|
recording = models.Recording.objects.select_related("room").get(
|
||||||
worker_id=data.egress_info.egress_id
|
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"
|
f"Recording with worker ID {data.egress_info.egress_id} does not exist"
|
||||||
) from err
|
) 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:
|
try:
|
||||||
room_name = str(recording.room.id)
|
room_name = str(recording.room.id)
|
||||||
RoomManagement.update_metadata(
|
RoomManagement().update_metadata(
|
||||||
room_name, remove_keys=["recording_mode", "recording_status"]
|
room_name, remove_keys=["recording_mode", "recording_status"]
|
||||||
)
|
)
|
||||||
except RoomNotFoundException:
|
except RoomNotFoundException:
|
||||||
@@ -241,17 +203,38 @@ class LiveKitEventsService:
|
|||||||
except RoomManagementException as e:
|
except RoomManagementException as e:
|
||||||
logger.exception("Failed to update room's metadata: %s", 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:
|
if recording.options.get("metadata_collector_dispatch_id", None) is not None:
|
||||||
try:
|
try:
|
||||||
MetadataCollectorService().stop(recording)
|
MetadataCollectorService().stop(recording)
|
||||||
except MetadataCollectorException:
|
except MetadataCollectorException:
|
||||||
logger.warning("Failed to stop the MetadataCollectorService")
|
logger.warning("Failed to stop the MetadataCollectorService")
|
||||||
|
|
||||||
if event is None:
|
if (
|
||||||
return
|
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
|
@staticmethod
|
||||||
def _is_connection_test_room(room_name: str) -> bool:
|
def _is_connection_test_room(room_name: str) -> bool:
|
||||||
|
|||||||
@@ -4,12 +4,11 @@ import logging
|
|||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Dict, FrozenSet, Optional, Sequence, Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.utils import timezone
|
|
||||||
|
|
||||||
from core import models, utils
|
from core import models, utils
|
||||||
|
|
||||||
@@ -47,7 +46,6 @@ class LobbyParticipant:
|
|||||||
username: str
|
username: str
|
||||||
color: str
|
color: str
|
||||||
id: str
|
id: str
|
||||||
entered_at: str
|
|
||||||
|
|
||||||
def to_dict(self) -> Dict[str, str]:
|
def to_dict(self) -> Dict[str, str]:
|
||||||
"""Serialize the participant object to a dict representation."""
|
"""Serialize the participant object to a dict representation."""
|
||||||
@@ -56,7 +54,6 @@ class LobbyParticipant:
|
|||||||
"username": self.username,
|
"username": self.username,
|
||||||
"id": self.id,
|
"id": self.id,
|
||||||
"color": self.color,
|
"color": self.color,
|
||||||
"entered_at": self.entered_at,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -71,7 +68,6 @@ class LobbyParticipant:
|
|||||||
username=data["username"],
|
username=data["username"],
|
||||||
id=data["id"],
|
id=data["id"],
|
||||||
color=data["color"],
|
color=data["color"],
|
||||||
entered_at=data["entered_at"],
|
|
||||||
)
|
)
|
||||||
except (KeyError, ValueError) as e:
|
except (KeyError, ValueError) as e:
|
||||||
logger.exception("Error creating Participant from dict:")
|
logger.exception("Error creating Participant from dict:")
|
||||||
@@ -90,47 +86,6 @@ class LobbyService:
|
|||||||
"""Generate cache key for participant(s) data."""
|
"""Generate cache key for participant(s) data."""
|
||||||
return f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
|
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
|
@staticmethod
|
||||||
def _get_or_create_participant_id(request) -> str:
|
def _get_or_create_participant_id(request) -> str:
|
||||||
"""Extract unique participant identifier from the request."""
|
"""Extract unique participant identifier from the request."""
|
||||||
@@ -207,7 +162,6 @@ class LobbyService:
|
|||||||
username=username,
|
username=username,
|
||||||
id=participant_id,
|
id=participant_id,
|
||||||
color=utils.generate_color(participant_id),
|
color=utils.generate_color(participant_id),
|
||||||
entered_at=timezone.now().isoformat(),
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
participant.status = LobbyParticipantStatus.ACCEPTED
|
participant.status = LobbyParticipantStatus.ACCEPTED
|
||||||
@@ -255,12 +209,15 @@ class LobbyService:
|
|||||||
cache.touch(
|
cache.touch(
|
||||||
self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT
|
self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT
|
||||||
)
|
)
|
||||||
self._index_touch(room_id)
|
|
||||||
|
|
||||||
def enter(
|
def enter(
|
||||||
self, room_id: UUID, participant_id: str, username: str
|
self, room_id: UUID, participant_id: str, username: str
|
||||||
) -> LobbyParticipant:
|
) -> 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)
|
color = utils.generate_color(participant_id)
|
||||||
|
|
||||||
@@ -269,7 +226,6 @@ class LobbyService:
|
|||||||
username=username,
|
username=username,
|
||||||
id=participant_id,
|
id=participant_id,
|
||||||
color=color,
|
color=color,
|
||||||
entered_at=timezone.now().isoformat(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -289,7 +245,6 @@ class LobbyService:
|
|||||||
participant.to_dict(),
|
participant.to_dict(),
|
||||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||||
)
|
)
|
||||||
self._index_add(room_id, participant_id)
|
|
||||||
|
|
||||||
return participant
|
return participant
|
||||||
|
|
||||||
@@ -311,42 +266,28 @@ class LobbyService:
|
|||||||
cache.delete(cache_key)
|
cache.delete(cache_key)
|
||||||
return None
|
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."""
|
"""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:
|
if not keys:
|
||||||
return ()
|
return []
|
||||||
|
|
||||||
keys_by_id = {
|
data = cache.get_many(keys)
|
||||||
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()))
|
|
||||||
|
|
||||||
dead_ids = []
|
|
||||||
waiting_participants = []
|
waiting_participants = []
|
||||||
|
for cache_key, raw_participant in data.items():
|
||||||
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
|
|
||||||
try:
|
try:
|
||||||
participant = LobbyParticipant.from_dict(raw_participant)
|
participant = LobbyParticipant.from_dict(raw_participant)
|
||||||
except LobbyParticipantParsingError:
|
except LobbyParticipantParsingError:
|
||||||
cache.delete(cache_key)
|
cache.delete(cache_key)
|
||||||
dead_ids.append(participant_id)
|
|
||||||
continue
|
continue
|
||||||
if participant.status == LobbyParticipantStatus.WAITING:
|
if participant.status == LobbyParticipantStatus.WAITING:
|
||||||
waiting_participants.append(participant.to_dict())
|
waiting_participants.append(participant.to_dict())
|
||||||
|
|
||||||
self._index_remove(room_id, *dead_ids)
|
return waiting_participants
|
||||||
|
|
||||||
waiting_participants.sort(key=lambda p: p["entered_at"], reverse=True)
|
|
||||||
|
|
||||||
return tuple(waiting_participants)
|
|
||||||
|
|
||||||
def handle_participant_entry(
|
def handle_participant_entry(
|
||||||
self,
|
self,
|
||||||
@@ -400,24 +341,16 @@ class LobbyService:
|
|||||||
|
|
||||||
participant.status = status
|
participant.status = status
|
||||||
cache.set(cache_key, participant.to_dict(), timeout=timeout)
|
cache.set(cache_key, participant.to_dict(), timeout=timeout)
|
||||||
self._index_touch(room_id)
|
|
||||||
|
|
||||||
def clear_room_cache(self, room_id: UUID) -> None:
|
def clear_room_cache(self, room_id: UUID) -> None:
|
||||||
"""Clear all participant entries from the cache for a specific room."""
|
"""Clear all participant entries from the cache for a specific room."""
|
||||||
|
|
||||||
member_ids = self._index_members(room_id)
|
cache.delete_pattern(
|
||||||
if member_ids:
|
self._get_cache_key(room_id, "*"), itersize=utils.CACHE_SCAN_ITERSIZE
|
||||||
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))
|
|
||||||
|
|
||||||
def clear_participant_cache(self, room_id: UUID, participant_id: str) -> None:
|
def clear_participant_cache(self, room_id: UUID, participant_id: str) -> None:
|
||||||
"""Clear a given participant entry from the cache for a specific room."""
|
"""Clear a given participant entry from the cache for a specific room."""
|
||||||
|
|
||||||
cache_key = self._get_cache_key(room_id, participant_id)
|
cache_key = self._get_cache_key(room_id, participant_id)
|
||||||
cache.delete(cache_key)
|
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 uuid import UUID
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
|
||||||
|
from core.utils import CACHE_SCAN_ITERSIZE
|
||||||
|
|
||||||
|
|
||||||
class PresenceCache:
|
class PresenceCache:
|
||||||
"""Store and invalidate (room, identity) presence entries."""
|
"""Store and invalidate (room, identity) presence entries."""
|
||||||
@@ -15,65 +29,24 @@ class PresenceCache:
|
|||||||
"""Cache key for a (room, identity) presence entry."""
|
"""Cache key for a (room, identity) presence entry."""
|
||||||
return f"{settings.PRESENCE_KEY_PREFIX}_{room_id!s}_{identity}"
|
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:
|
def is_marked_present(self, room_id: UUID | str, identity: str) -> bool:
|
||||||
"""Return True if a positive presence entry exists in cache."""
|
"""Return True if a positive presence entry exists in cache."""
|
||||||
return bool(cache.get(self._get_cache_key(room_id, identity)))
|
return bool(cache.get(self._get_cache_key(room_id, identity)))
|
||||||
|
|
||||||
def mark_present(self, room_id: UUID | str, identity: str) -> None:
|
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(
|
cache.set(
|
||||||
self._get_cache_key(room_id, identity),
|
self._get_cache_key(room_id, identity),
|
||||||
True,
|
True,
|
||||||
timeout=settings.PRESENCE_CACHE_TIMEOUT,
|
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:
|
def clear(self, room_id: UUID | str, identity: str) -> None:
|
||||||
"""Forget presence for one participant (e.g. on participant_left)."""
|
"""Forget presence for one participant (e.g. on participant_left)."""
|
||||||
cache.delete(self._get_cache_key(room_id, identity))
|
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:
|
def clear_room(self, room_id: UUID | str) -> None:
|
||||||
"""Forget presence for every participant of a room (on room_finished).
|
"""Forget presence for every participant of a room (on room_finished)."""
|
||||||
|
cache.delete_pattern(
|
||||||
Deletes the indexed entries and the index itself with targeted
|
self._get_cache_key(room_id, "*"), itersize=CACHE_SCAN_ITERSIZE
|
||||||
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))
|
|
||||||
|
|||||||
@@ -30,10 +30,9 @@ class RoomNotFoundException(RoomManagementException):
|
|||||||
class RoomManagement:
|
class RoomManagement:
|
||||||
"""Service for managing LiveKit rooms."""
|
"""Service for managing LiveKit rooms."""
|
||||||
|
|
||||||
@classmethod
|
|
||||||
@async_to_sync
|
@async_to_sync
|
||||||
async def update_metadata(
|
async def update_metadata(
|
||||||
cls,
|
self,
|
||||||
room_name: str,
|
room_name: str,
|
||||||
metadata: Optional[Dict] = None,
|
metadata: Optional[Dict] = None,
|
||||||
remove_keys: Optional[list[str]] = None,
|
remove_keys: Optional[list[str]] = None,
|
||||||
@@ -76,6 +75,10 @@ class RoomManagement:
|
|||||||
|
|
||||||
except TwirpError as e:
|
except TwirpError as e:
|
||||||
if e.code == "not_found":
|
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
|
raise RoomNotFoundException("Room does not exist") from e
|
||||||
|
|
||||||
logger.exception(
|
logger.exception(
|
||||||
@@ -87,9 +90,8 @@ class RoomManagement:
|
|||||||
finally:
|
finally:
|
||||||
await lkapi.aclose()
|
await lkapi.aclose()
|
||||||
|
|
||||||
@classmethod
|
|
||||||
@async_to_sync
|
@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.
|
"""Delete a LiveKit room and disconnect all participants.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
@@ -114,32 +116,3 @@ class RoomManagement:
|
|||||||
raise RoomManagementException("Could not delete room") from e
|
raise RoomManagementException("Could not delete room") from e
|
||||||
finally:
|
finally:
|
||||||
await lkapi.aclose()
|
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
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
RoomManagement.delete_room(room_name)
|
RoomManagement().delete_room(room_name)
|
||||||
except RoomNotFoundException:
|
except RoomNotFoundException:
|
||||||
# Room may already be gone after empty/departure timeout.
|
# Room may already be gone after empty/departure timeout.
|
||||||
logger.info("Connection test room '%s' already gone.", room_name)
|
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
|
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):
|
def test_authentication_getter_new_user_no_email(monkeypatch):
|
||||||
"""
|
"""
|
||||||
If no user matches, a user should be created.
|
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():
|
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()
|
user = factories.UserFactory()
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ from urllib.parse import quote, urlparse
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.files.storage import default_storage
|
from django.core.files.storage import default_storage
|
||||||
from django.test import override_settings
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -144,59 +143,3 @@ def test_api_files_media_auth_own_file_deleted():
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 403
|
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.
|
Test RecordingEventsService service.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# pylint: disable=redefined-outer-name,protected-access
|
# pylint: disable=redefined-outer-name
|
||||||
|
|
||||||
import logging
|
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from core.factories import RecordingFactory
|
from core.factories import RecordingFactory
|
||||||
from core.recording.enums import RecordingWorkerEvent
|
|
||||||
from core.recording.services.recording_events import (
|
from core.recording.services.recording_events import (
|
||||||
RecordingEventsError,
|
RecordingEventsError,
|
||||||
RecordingEventsService,
|
RecordingEventsService,
|
||||||
RecordingNotSavableError,
|
RecordingNotSavableError,
|
||||||
)
|
)
|
||||||
from core.services.room_management import (
|
|
||||||
RoomManagementException,
|
|
||||||
)
|
|
||||||
from core.utils import NotificationError
|
from core.utils import NotificationError
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
@@ -39,10 +34,10 @@ def service():
|
|||||||
)
|
)
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
def test_handle_limit_reached_success(mock_notify, mode, notification_type, service):
|
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)
|
recording = RecordingFactory(status="active", mode=mode)
|
||||||
service._handle_limit_reached(recording)
|
service.handle_limit_reached(recording)
|
||||||
|
|
||||||
assert recording.status == "stopped"
|
assert recording.status == "stopped"
|
||||||
mock_notify.assert_called_once_with(
|
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(
|
@pytest.mark.parametrize(
|
||||||
("mode", "notification_type"),
|
("mode", "notification_type"),
|
||||||
(
|
(
|
||||||
("screen_recording", "screenRecordingFailed"),
|
("screen_recording", "screenRecordingLimitReached"),
|
||||||
("transcript", "transcriptionFailed"),
|
("transcript", "transcriptionLimitReached"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
def test_handle_failed_success(mock_notify, mode, notification_type, service):
|
def test_handle_limit_reached_error(mock_notify, mode, notification_type, service):
|
||||||
"""Test _handle_failed marks recording as failed and notifies participants."""
|
"""Test handle_limit_reached raises RecordingEventsError when notification fails."""
|
||||||
|
|
||||||
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.
|
|
||||||
"""
|
|
||||||
|
|
||||||
mock_notify.side_effect = NotificationError("Error notifying")
|
mock_notify.side_effect = NotificationError("Error notifying")
|
||||||
|
|
||||||
@@ -123,15 +62,14 @@ def test_handle_event_notification_error( # noqa: PLR0913, PLR0917
|
|||||||
|
|
||||||
with pytest.raises(
|
with pytest.raises(
|
||||||
RecordingEventsError,
|
RecordingEventsError,
|
||||||
match=rf"Failed to notify participants in room '.+' "
|
match=r"Failed to notify participants in room '.+' "
|
||||||
rf"about recording {event} \(recording_id=.+\)",
|
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(
|
mock_notify.assert_called_once_with(
|
||||||
room_name=str(recording.room.id),
|
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||||
notification_data={"type": f"{notification_prefix}{notification_suffix}"},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -144,19 +82,19 @@ def test_handle_event_notification_error( # noqa: PLR0913, PLR0917
|
|||||||
"core.recording.services.recording_events.notification_service."
|
"core.recording.services.recording_events.notification_service."
|
||||||
"notify_external_services"
|
"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,
|
mock_notify_external_services,
|
||||||
notify_return_value,
|
notify_return_value,
|
||||||
expected_status,
|
expected_status,
|
||||||
status,
|
status,
|
||||||
service,
|
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
|
mock_notify_external_services.return_value = notify_return_value
|
||||||
|
|
||||||
recording = RecordingFactory(status=status)
|
recording = RecordingFactory(status=status)
|
||||||
service._handle_successful(recording)
|
service.handle_complete(recording)
|
||||||
|
|
||||||
mock_notify_external_services.assert_called_once_with(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(
|
@pytest.mark.parametrize(
|
||||||
"status",
|
"status",
|
||||||
[
|
["initiated", "saved", "notification_succeeded", "aborted", "failed_to_start"],
|
||||||
"initiated",
|
|
||||||
"saved",
|
|
||||||
"notification_succeeded",
|
|
||||||
"aborted",
|
|
||||||
"failed",
|
|
||||||
"failed_to_start",
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
@mock.patch(
|
@mock.patch(
|
||||||
"core.recording.services.recording_events.notification_service."
|
"core.recording.services.recording_events.notification_service."
|
||||||
"notify_external_services"
|
"notify_external_services"
|
||||||
)
|
)
|
||||||
def test_handle_successful_non_savable_recording(
|
def test_handle_complete_non_savable_recording(
|
||||||
mock_notify_external_services, status, service
|
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)
|
recording = RecordingFactory(status=status)
|
||||||
|
|
||||||
with pytest.raises(RecordingNotSavableError):
|
with pytest.raises(RecordingNotSavableError):
|
||||||
service._handle_successful(recording)
|
service.handle_complete(recording)
|
||||||
|
|
||||||
mock_notify_external_services.assert_not_called()
|
mock_notify_external_services.assert_not_called()
|
||||||
|
|
||||||
recording.refresh_from_db()
|
recording.refresh_from_db()
|
||||||
assert recording.status == status
|
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.conf import settings
|
||||||
from django.core.files.storage import default_storage
|
from django.core.files.storage import default_storage
|
||||||
from django.test import override_settings
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -283,63 +282,3 @@ def test_api_recordings_media_auth_success_administrator(mode):
|
|||||||
timeout=1,
|
timeout=1,
|
||||||
)
|
)
|
||||||
assert response.content.decode("utf-8") == "my prose"
|
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.INITIATED,
|
||||||
RecordingStatusChoices.ACTIVE,
|
RecordingStatusChoices.ACTIVE,
|
||||||
RecordingStatusChoices.SAVED,
|
RecordingStatusChoices.SAVED,
|
||||||
RecordingStatusChoices.FAILED,
|
|
||||||
RecordingStatusChoices.FAILED_TO_START,
|
RecordingStatusChoices.FAILED_TO_START,
|
||||||
RecordingStatusChoices.FAILED_TO_STOP,
|
RecordingStatusChoices.FAILED_TO_STOP,
|
||||||
RecordingStatusChoices.ABORTED,
|
RecordingStatusChoices.ABORTED,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ Test worker service classes.
|
|||||||
|
|
||||||
# pylint: disable=protected-access,redefined-outer-name,unused-argument,no-member
|
# pylint: disable=protected-access,redefined-outer-name,unused-argument,no-member
|
||||||
|
|
||||||
import logging
|
|
||||||
from unittest.mock import AsyncMock, Mock, patch
|
from unittest.mock import AsyncMock, Mock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -155,9 +154,9 @@ def test_base_egress_filepath_construction(service, filename, extension, expecte
|
|||||||
"response_status,expected_result",
|
"response_status,expected_result",
|
||||||
[
|
[
|
||||||
(livekit_api.EgressStatus.EGRESS_ABORTED, "ABORTED"),
|
(livekit_api.EgressStatus.EGRESS_ABORTED, "ABORTED"),
|
||||||
(livekit_api.EgressStatus.EGRESS_FAILED, "FAILED"),
|
|
||||||
(livekit_api.EgressStatus.EGRESS_COMPLETE, "FAILED_TO_STOP"),
|
(livekit_api.EgressStatus.EGRESS_COMPLETE, "FAILED_TO_STOP"),
|
||||||
(livekit_api.EgressStatus.EGRESS_ENDING, "STOPPED"),
|
(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):
|
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
|
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):
|
def test_base_egress_stop_missing_status(service):
|
||||||
"""Test stop method when response is missing status"""
|
"""Test stop method when response is missing status"""
|
||||||
# Mock _handle_request with missing status
|
# Mock _handle_request with missing status
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from unittest import mock
|
|||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from freezegun import freeze_time
|
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from ... import utils
|
from ... import utils
|
||||||
@@ -25,7 +24,6 @@ pytestmark = pytest.mark.django_db
|
|||||||
# Tests for request_entry endpoint
|
# Tests for request_entry endpoint
|
||||||
|
|
||||||
|
|
||||||
@freeze_time("2025-01-01 10:00:00")
|
|
||||||
def test_request_entry_anonymous(settings):
|
def test_request_entry_anonymous(settings):
|
||||||
"""Anonymous users should be allowed to request entry to a room."""
|
"""Anonymous users should be allowed to request entry to a room."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
@@ -61,7 +59,6 @@ def test_request_entry_anonymous(settings):
|
|||||||
"username": "test_user",
|
"username": "test_user",
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
"color": "mocked-color",
|
"color": "mocked-color",
|
||||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
|
||||||
"livekit": None,
|
"livekit": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,7 +71,6 @@ def test_request_entry_anonymous(settings):
|
|||||||
assert participant_data.get("username") == "test_user"
|
assert participant_data.get("username") == "test_user"
|
||||||
|
|
||||||
|
|
||||||
@freeze_time("2025-01-01 10:00:00")
|
|
||||||
def test_request_entry_authenticated_user(settings):
|
def test_request_entry_authenticated_user(settings):
|
||||||
"""Authenticated users should be allowed to request entry."""
|
"""Authenticated users should be allowed to request entry."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
@@ -112,7 +108,6 @@ def test_request_entry_authenticated_user(settings):
|
|||||||
"username": "test_user",
|
"username": "test_user",
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
"color": "mocked-color",
|
"color": "mocked-color",
|
||||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
|
||||||
"livekit": None,
|
"livekit": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +120,6 @@ def test_request_entry_authenticated_user(settings):
|
|||||||
assert participant_data.get("username") == "test_user"
|
assert participant_data.get("username") == "test_user"
|
||||||
|
|
||||||
|
|
||||||
@freeze_time("2025-01-01 10:00:00")
|
|
||||||
def test_request_entry_with_existing_participants(settings):
|
def test_request_entry_with_existing_participants(settings):
|
||||||
"""Anonymous users should be allowed to request entry to a room with existing participants."""
|
"""Anonymous users should be allowed to request entry to a room with existing participants."""
|
||||||
# Create a restricted access room
|
# Create a restricted access room
|
||||||
@@ -144,7 +138,6 @@ def test_request_entry_with_existing_participants(settings):
|
|||||||
"username": "user1",
|
"username": "user1",
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
cache.set(
|
cache.set(
|
||||||
@@ -154,7 +147,6 @@ def test_request_entry_with_existing_participants(settings):
|
|||||||
"username": "user2",
|
"username": "user2",
|
||||||
"status": "accepted",
|
"status": "accepted",
|
||||||
"color": "#654321",
|
"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() == {
|
assert response.json() == {
|
||||||
"id": participant_id,
|
"id": participant_id,
|
||||||
"username": "test_user",
|
"username": "test_user",
|
||||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
"color": "mocked-color",
|
"color": "mocked-color",
|
||||||
"livekit": None,
|
"livekit": None,
|
||||||
@@ -201,7 +192,6 @@ def test_request_entry_with_existing_participants(settings):
|
|||||||
assert participant_data.get("username") == "test_user"
|
assert participant_data.get("username") == "test_user"
|
||||||
|
|
||||||
|
|
||||||
@freeze_time("2025-01-01 10:00:00")
|
|
||||||
def test_request_entry_public_room(settings):
|
def test_request_entry_public_room(settings):
|
||||||
"""Entry requests to public rooms should return ACCEPTED status with LiveKit config."""
|
"""Entry requests to public rooms should return ACCEPTED status with LiveKit config."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||||
@@ -240,7 +230,6 @@ def test_request_entry_public_room(settings):
|
|||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": "123",
|
"id": "123",
|
||||||
"username": "test_user",
|
"username": "test_user",
|
||||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
|
||||||
"status": "accepted",
|
"status": "accepted",
|
||||||
"color": "mocked-color",
|
"color": "mocked-color",
|
||||||
"livekit": {"token": "test-token"},
|
"livekit": {"token": "test-token"},
|
||||||
@@ -251,7 +240,6 @@ def test_request_entry_public_room(settings):
|
|||||||
assert not lobby_keys
|
assert not lobby_keys
|
||||||
|
|
||||||
|
|
||||||
@freeze_time("2025-01-01 10:00:00")
|
|
||||||
def test_request_entry_authenticated_user_public_room(settings):
|
def test_request_entry_authenticated_user_public_room(settings):
|
||||||
"""While authenticated, entry request to public rooms should get accepted."""
|
"""While authenticated, entry request to public rooms should get accepted."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||||
@@ -294,7 +282,6 @@ def test_request_entry_authenticated_user_public_room(settings):
|
|||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||||
"username": "test_user",
|
"username": "test_user",
|
||||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
|
||||||
"status": "accepted",
|
"status": "accepted",
|
||||||
"color": "mocked-color",
|
"color": "mocked-color",
|
||||||
"livekit": {"token": "test-token"},
|
"livekit": {"token": "test-token"},
|
||||||
@@ -305,7 +292,6 @@ def test_request_entry_authenticated_user_public_room(settings):
|
|||||||
assert not lobby_keys
|
assert not lobby_keys
|
||||||
|
|
||||||
|
|
||||||
@freeze_time("2025-01-01 10:00:00")
|
|
||||||
def test_request_entry_waiting_participant_public_room(settings):
|
def test_request_entry_waiting_participant_public_room(settings):
|
||||||
"""While waiting, entry request to public rooms should get accepted."""
|
"""While waiting, entry request to public rooms should get accepted."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||||
@@ -322,7 +308,6 @@ def test_request_entry_waiting_participant_public_room(settings):
|
|||||||
"username": "user1",
|
"username": "user1",
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
"color": "#123456",
|
"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",
|
"username": "user1",
|
||||||
"status": "accepted",
|
"status": "accepted",
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
|
||||||
"livekit": {"token": "test-token"},
|
"livekit": {"token": "test-token"},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,7 +443,6 @@ def test_allow_participant_to_enter_success(settings, allow_entry, updated_statu
|
|||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
"username": "foo",
|
"username": "foo",
|
||||||
"color": "123",
|
"color": "123",
|
||||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -595,7 +578,6 @@ def test_list_waiting_participants_success(settings):
|
|||||||
"username": "user1",
|
"username": "user1",
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
cache.set(
|
cache.set(
|
||||||
@@ -605,35 +587,28 @@ def test_list_waiting_participants_success(settings):
|
|||||||
"username": "user2",
|
"username": "user2",
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
"color": "#654321",
|
"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/")
|
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
assert response.json() == {
|
participants = response.json().get("participants")
|
||||||
"participants": [
|
assert sorted(participants, key=lambda p: p["id"]) == [
|
||||||
{
|
{
|
||||||
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
|
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||||
"username": "user2",
|
"username": "user1",
|
||||||
"status": "waiting",
|
"status": "waiting",
|
||||||
"color": "#654321",
|
"color": "#123456",
|
||||||
"entered_at": "2025-01-01T10:05:00+00:00",
|
},
|
||||||
},
|
{
|
||||||
{
|
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
|
||||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
"username": "user2",
|
||||||
"username": "user1",
|
"status": "waiting",
|
||||||
"status": "waiting",
|
"color": "#654321",
|
||||||
"color": "#123456",
|
},
|
||||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
]
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_waiting_participants_empty(settings):
|
def test_list_waiting_participants_empty(settings):
|
||||||
|
|||||||
@@ -458,7 +458,7 @@ def test_mute_participant_livekit_token_presence_check_twirp_error_forbidden(
|
|||||||
room = RoomFactory()
|
room = RoomFactory()
|
||||||
|
|
||||||
mock_livekit_client.room.get_participant.side_effect = TwirpError(
|
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()
|
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()
|
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(
|
def test_rename_participant_success_anonymous(
|
||||||
mock_livekit_client, room, anonymous_token
|
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"}
|
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."""
|
"""Test that subtitle functionality is disabled when feature flag is off."""
|
||||||
|
|
||||||
settings.ROOM_SUBTITLE_ENABLED = False
|
|
||||||
|
|
||||||
room = RoomFactory()
|
room = RoomFactory()
|
||||||
user = UserFactory()
|
user = UserFactory()
|
||||||
client = APIClient()
|
client = APIClient()
|
||||||
|
|||||||
@@ -381,11 +381,38 @@ def test_api_rooms_update_administrators_of_another():
|
|||||||
assert other_room.slug == "old-name"
|
assert other_room.slug == "old-name"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("exception", [RoomNotFoundException, RoomManagementException])
|
@patch.object(RoomManagement, "update_metadata", side_effect=RoomNotFoundException)
|
||||||
@patch.object(RoomManagement, "update_metadata")
|
def test_api_rooms_update_livekit_room_not_found(mock_update_metadata):
|
||||||
def test_api_rooms_update_livekit_sync_failure(mock_update_metadata, exception):
|
"""Should not fail the API request when the LiveKit room does not exist yet."""
|
||||||
"""Should not fail the API request when the LiveKit metadata sync fails."""
|
user = UserFactory()
|
||||||
mock_update_metadata.side_effect = exception
|
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()
|
user = UserFactory()
|
||||||
room = RoomFactory(
|
room = RoomFactory(
|
||||||
users=[(user, random.choice(["administrator", "owner"]))],
|
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):
|
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"})
|
event_data = json.dumps({"event": "unknown_event_type"})
|
||||||
|
|
||||||
# Generate auth token for this specific payload
|
# Generate auth token for this specific payload
|
||||||
@@ -112,8 +112,10 @@ def test_unknown_event_type(client, mock_livekit_config):
|
|||||||
HTTP_AUTHORIZATION=auth_token,
|
HTTP_AUTHORIZATION=auth_token,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 422
|
||||||
assert response.json() == {"status": "success"}
|
assert response.json() == {
|
||||||
|
"status": "error",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(LiveKitEventsService, "_handle_room_finished")
|
@mock.patch.object(LiveKitEventsService, "_handle_room_finished")
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ Test LiveKitEvents service.
|
|||||||
"""
|
"""
|
||||||
# pylint: disable=W0621,W0613, W0212, E0611
|
# pylint: disable=W0621,W0613, W0212, E0611
|
||||||
|
|
||||||
import logging
|
|
||||||
import uuid
|
import uuid
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
@@ -11,16 +10,14 @@ import pytest
|
|||||||
from livekit.api import EgressStatus
|
from livekit.api import EgressStatus
|
||||||
|
|
||||||
from core.factories import RecordingFactory, RoomFactory
|
from core.factories import RecordingFactory, RoomFactory
|
||||||
from core.recording.enums import RecordingWorkerEvent
|
|
||||||
from core.recording.services.recording_events import RecordingEventsService
|
from core.recording.services.recording_events import RecordingEventsService
|
||||||
from core.services.livekit_events import (
|
from core.services.livekit_events import (
|
||||||
EGRESS_STATUS_TO_RECORDING_EVENT,
|
|
||||||
ActionFailedError,
|
ActionFailedError,
|
||||||
AuthenticationError,
|
AuthenticationError,
|
||||||
InvalidPayloadError,
|
InvalidPayloadError,
|
||||||
LiveKitEventsService,
|
LiveKitEventsService,
|
||||||
|
UnsupportedEventTypeError,
|
||||||
api,
|
api,
|
||||||
to_recording_event,
|
|
||||||
)
|
)
|
||||||
from core.services.lobby import LobbyService
|
from core.services.lobby import LobbyService
|
||||||
from core.services.room_management import RoomManagementException
|
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
|
def test_handle_egress_ended_success( # pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||||
mock_update_metadata, mock_notify, mode, notification_type, service
|
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")
|
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
||||||
mock_data = mock.MagicMock()
|
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_ACTIVE, "started"),
|
||||||
(EgressStatus.EGRESS_ENDING, "saving"),
|
(EgressStatus.EGRESS_ENDING, "saving"),
|
||||||
|
(EgressStatus.EGRESS_ABORTED, "aborted"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@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(
|
@pytest.mark.parametrize(
|
||||||
("mode", "notification_type"),
|
("mode", "notification_type"),
|
||||||
(
|
(
|
||||||
@@ -160,38 +181,33 @@ def test_handle_egress_ended_metadata_update_fails( # pylint: disable=too-many-
|
|||||||
assert recording.status == "saved"
|
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.utils.notify_participants")
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_notification_fails(
|
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."""
|
"""Should raise ActionFailedError when notification fails but still stop recording."""
|
||||||
|
|
||||||
mock_notify_external_services.return_value = False
|
|
||||||
mock_notify.side_effect = NotificationError("Error notifying")
|
|
||||||
|
|
||||||
recording = RecordingFactory(worker_id="worker-1", status="active")
|
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
mock_data.egress_info.egress_id = recording.worker_id
|
mock_data.egress_info.egress_id = recording.worker_id
|
||||||
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
|
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(
|
mock_update_metadata.assert_called_once_with(
|
||||||
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
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.utils.notify_participants")
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@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"
|
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.utils.notify_participants")
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_recording_should_not_be_saved(
|
def test_handle_egress_ended_recording_not_active(
|
||||||
mock_update_metadata, mock_notify, egress_status, service
|
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")
|
recording = RecordingFactory(worker_id="worker-1", status="failed_to_stop")
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
mock_data.egress_info.egress_id = "worker-1"
|
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)
|
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"
|
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.utils.notify_participants")
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_complete_does_not_notify_participants(
|
def test_handle_egress_ended_recording_not_limit_reached(
|
||||||
mock_update_metadata, mock_notify, mock_notify_external_services, service
|
mock_update_metadata, mock_notify, service
|
||||||
):
|
):
|
||||||
"""Shouldn't notify participants on a successful egress.
|
"""Should ignore egress non-limit-reached statuses."""
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
recording = RecordingFactory(worker_id="worker-1", status="stopped")
|
recording = RecordingFactory(worker_id="worker-1", status="stopped")
|
||||||
mock_data = mock.MagicMock()
|
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(
|
mock_update_metadata.assert_called_once_with(
|
||||||
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||||
)
|
)
|
||||||
mock_notify_external_services.assert_called_once_with(recording)
|
assert recording.status == "stopped"
|
||||||
|
|
||||||
recording.refresh_from_db()
|
|
||||||
assert recording.status == "saved"
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.livekit_events.MetadataCollectorService")
|
@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()
|
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(
|
@mock.patch(
|
||||||
"core.recording.services.recording_events.notification_service."
|
"core.recording.services.recording_events.notification_service."
|
||||||
"notify_external_services"
|
"notify_external_services"
|
||||||
)
|
)
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@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_update_metadata,
|
||||||
mock_notify,
|
mock_notify,
|
||||||
mock_notify_external_services,
|
mock_notify_external_services,
|
||||||
|
notify_return_value,
|
||||||
|
recording_status,
|
||||||
egress_status,
|
egress_status,
|
||||||
service,
|
service,
|
||||||
caplog,
|
|
||||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
): # 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")
|
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
mock_data.egress_info.egress_id = recording.worker_id
|
mock_data.egress_info.egress_id = recording.worker_id
|
||||||
mock_data.egress_info.status = egress_status
|
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")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_logs_livekit_error_before_cleaning_up(
|
def test_handle_egress_ended_does_not_save_on_wrong_status(
|
||||||
mock_update_metadata, mock_notify, service, caplog
|
mock_update_metadata, egress_status, service
|
||||||
):
|
):
|
||||||
"""Should log the failure reason even when the cleanup fails afterwards."""
|
"""Shouldn't save on invalid status."""
|
||||||
|
|
||||||
mock_update_metadata.side_effect = RuntimeError("LiveKit is unreachable")
|
|
||||||
|
|
||||||
recording = RecordingFactory(worker_id="worker-1", status="active")
|
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
mock_data.egress_info.egress_id = recording.worker_id
|
mock_data.egress_info.egress_id = recording.worker_id
|
||||||
mock_data.egress_info.status = EgressStatus.EGRESS_FAILED
|
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.ERROR), pytest.raises(RuntimeError):
|
service._handle_egress_ended(mock_data)
|
||||||
service._handle_egress_ended(mock_data)
|
|
||||||
|
|
||||||
assert (
|
recording.refresh_from_db()
|
||||||
f"Recording worker reported failed for recording {recording.id}" in caplog.text
|
assert recording.status == "active"
|
||||||
)
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("egress_status", "expected_event"),
|
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
|
||||||
(
|
|
||||||
(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),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
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")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_updated_ignores_unmapped_status(mock_update_metadata, service):
|
def test_handle_egress_ended_ignores_non_savable_recording(
|
||||||
"""Shouldn't touch the room's metadata when the egress status is unknown."""
|
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 = mock.MagicMock()
|
||||||
mock_data.egress_info.egress_id = "worker-1"
|
mock_data.egress_info.egress_id = recording.worker_id
|
||||||
mock_data.egress_info.status = 999
|
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")
|
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||||
@@ -739,27 +665,22 @@ def test_receive_missing_auth(service):
|
|||||||
|
|
||||||
|
|
||||||
@mock.patch.object(api.WebhookReceiver, "receive")
|
@mock.patch.object(api.WebhookReceiver, "receive")
|
||||||
def test_receive_unknown_event_is_acknowledged(mock_receive, service, caplog):
|
def test_receive_unsupported_event(mock_receive, service):
|
||||||
"""Unknown event types are logged and ignored, not rejected.
|
"""Should raise LiveKitWebhookError for unsupported events."""
|
||||||
|
|
||||||
LiveKit adds event types over time and does not retry 4xx responses, so
|
|
||||||
raising here would silently drop the event.
|
|
||||||
"""
|
|
||||||
mock_request = mock.MagicMock()
|
mock_request = mock.MagicMock()
|
||||||
mock_request.headers = {"Authorization": "test_token"}
|
mock_request.headers = {"Authorization": "test_token"}
|
||||||
mock_request.body = b"{}"
|
mock_request.body = b"{}"
|
||||||
|
|
||||||
|
# Mock returned data with unsupported event type
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
mock_data.room.name = str(uuid.uuid4())
|
mock_data.room.name = str(uuid.uuid4())
|
||||||
mock_data.event = "some_future_event"
|
mock_data.event = "unsupported_event"
|
||||||
mock_receive.return_value = mock_data
|
mock_receive.return_value = mock_data
|
||||||
|
|
||||||
with caplog.at_level("WARNING", logger="core.services.livekit_events"):
|
with pytest.raises(
|
||||||
service.receive(mock_request) # must not raise
|
UnsupportedEventTypeError, match="Unknown webhook type: unsupported_event"
|
||||||
|
):
|
||||||
assert "Ignoring unknown LiveKit webhook event type 'some_future_event'" in (
|
service.receive(mock_request)
|
||||||
caplog.text
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(api.WebhookReceiver, "receive")
|
@mock.patch.object(api.WebhookReceiver, "receive")
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
Test lobby service.
|
Test lobby service.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# pylint: disable=W0621,W0613, W0212, R0913, C0302
|
# pylint: disable=W0621,W0613, W0212, R0913
|
||||||
# ruff: noqa: PLR0913, PLR0917
|
# ruff: noqa: PLR0913, PLR0917
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
@@ -14,7 +14,6 @@ from django.core.cache import cache
|
|||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from freezegun import freeze_time
|
|
||||||
|
|
||||||
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||||
from core.models import RoleChoices, RoomAccessLevel
|
from core.models import RoleChoices, RoomAccessLevel
|
||||||
@@ -25,6 +24,7 @@ from core.services.lobby import (
|
|||||||
LobbyParticipantStatus,
|
LobbyParticipantStatus,
|
||||||
LobbyService,
|
LobbyService,
|
||||||
)
|
)
|
||||||
|
from core.services.presence import CACHE_SCAN_ITERSIZE
|
||||||
from core.utils import NotificationError
|
from core.utils import NotificationError
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
@@ -56,7 +56,6 @@ def participant_dict():
|
|||||||
"username": "test-username",
|
"username": "test-username",
|
||||||
"id": "test-participant-id",
|
"id": "test-participant-id",
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -68,7 +67,6 @@ def participant_data():
|
|||||||
username="test-username",
|
username="test-username",
|
||||||
id="test-participant-id",
|
id="test-participant-id",
|
||||||
color="#123456",
|
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["username"] == "test-username"
|
||||||
assert result["id"] == "test-participant-id"
|
assert result["id"] == "test-participant-id"
|
||||||
assert result["color"] == "#123456"
|
assert result["color"] == "#123456"
|
||||||
assert result["entered_at"] == "2025-01-01T10:00:00+00:00"
|
|
||||||
|
|
||||||
|
|
||||||
def test_lobby_participant_from_dict_success(participant_dict):
|
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.username == "test-username"
|
||||||
assert participant.id == "test-participant-id"
|
assert participant.id == "test-participant-id"
|
||||||
assert participant.color == "#123456"
|
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():
|
def test_lobby_participant_from_dict_default_status():
|
||||||
@@ -113,7 +96,6 @@ def test_lobby_participant_from_dict_default_status():
|
|||||||
"username": "test-username",
|
"username": "test-username",
|
||||||
"id": "test-participant-id",
|
"id": "test-participant-id",
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
participant = LobbyParticipant.from_dict(data_without_status)
|
participant = LobbyParticipant.from_dict(data_without_status)
|
||||||
@@ -139,7 +121,6 @@ def test_lobby_participant_from_dict_invalid_status():
|
|||||||
"username": "test-username",
|
"username": "test-username",
|
||||||
"id": "test-participant-id",
|
"id": "test-participant-id",
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
with pytest.raises(LobbyParticipantParsingError, match="Invalid participant data"):
|
with pytest.raises(LobbyParticipantParsingError, match="Invalid participant data"):
|
||||||
@@ -284,7 +265,6 @@ def test_request_entry_public_room(
|
|||||||
username=username,
|
username=username,
|
||||||
id=participant_id,
|
id=participant_id,
|
||||||
color="#123456",
|
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_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||||
@@ -323,7 +303,6 @@ def test_request_entry_trusted_room(
|
|||||||
username=username,
|
username=username,
|
||||||
id=participant_id,
|
id=participant_id,
|
||||||
color="#123456",
|
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_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||||
@@ -366,7 +345,6 @@ def test_request_entry_new_participant(
|
|||||||
username=username,
|
username=username,
|
||||||
id=participant_id,
|
id=participant_id,
|
||||||
color="#123456",
|
color="#123456",
|
||||||
entered_at="2025-01-01T10:00:00+00:00",
|
|
||||||
)
|
)
|
||||||
mock_enter.return_value = participant_data
|
mock_enter.return_value = participant_data
|
||||||
|
|
||||||
@@ -394,7 +372,6 @@ def test_request_entry_waiting_participant(
|
|||||||
username=username,
|
username=username,
|
||||||
id=participant_id,
|
id=participant_id,
|
||||||
color="#123456",
|
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_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||||
@@ -423,7 +400,6 @@ def test_request_entry_accepted_participant(
|
|||||||
username=username,
|
username=username,
|
||||||
id=participant_id,
|
id=participant_id,
|
||||||
color="#123456",
|
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_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||||
@@ -464,7 +440,6 @@ def test_request_entry_participant_with_role(
|
|||||||
username=username,
|
username=username,
|
||||||
id=participant_id,
|
id=participant_id,
|
||||||
color="#123456",
|
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_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
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):
|
def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
|
||||||
"""Test refreshing waiting status for a participant."""
|
"""Test refreshing waiting status for a participant."""
|
||||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||||
lobby_service._index_touch = mock.Mock()
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
lobby_service.refresh_waiting_status(room.id, participant_id)
|
lobby_service.refresh_waiting_status(room.id, participant_id)
|
||||||
mock_cache.touch.assert_called_once_with(
|
mock_cache.touch.assert_called_once_with(
|
||||||
"mocked_cache_key", settings.LOBBY_WAITING_TIMEOUT
|
"mocked_cache_key", settings.LOBBY_WAITING_TIMEOUT
|
||||||
)
|
)
|
||||||
lobby_service._index_touch.assert_called_once_with(room.id)
|
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=R0917
|
# pylint: disable=R0917
|
||||||
@mock.patch("core.services.lobby.cache")
|
@mock.patch("core.services.lobby.cache")
|
||||||
@mock.patch("core.utils.generate_color")
|
@mock.patch("core.utils.generate_color")
|
||||||
@mock.patch("core.utils.notify_participants")
|
@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(
|
def test_enter_success(
|
||||||
mock_index_add,
|
|
||||||
mock_notify,
|
mock_notify,
|
||||||
mock_generate_color,
|
mock_generate_color,
|
||||||
mock_cache,
|
mock_cache,
|
||||||
@@ -527,7 +497,6 @@ def test_enter_success(
|
|||||||
assert participant.username == username
|
assert participant.username == username
|
||||||
assert participant.id == participant_id
|
assert participant.id == participant_id
|
||||||
assert participant.color == "#123456"
|
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)
|
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(
|
mock_notify.assert_called_once_with(
|
||||||
room_name=str(room.pk), notification_data={"type": "participantWaiting"}
|
room_name=str(room.pk), notification_data={"type": "participantWaiting"}
|
||||||
)
|
)
|
||||||
mock_index_add.assert_called_once_with(room.id, participant_id)
|
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=R0917
|
# pylint: disable=R0917
|
||||||
@mock.patch("core.services.lobby.cache")
|
@mock.patch("core.services.lobby.cache")
|
||||||
@mock.patch("core.utils.generate_color")
|
@mock.patch("core.utils.generate_color")
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.services.lobby.LobbyService._index_add")
|
|
||||||
def test_enter_with_notification_error(
|
def test_enter_with_notification_error(
|
||||||
mock_index_add,
|
|
||||||
mock_notify,
|
mock_notify,
|
||||||
mock_generate_color,
|
mock_generate_color,
|
||||||
mock_cache,
|
mock_cache,
|
||||||
@@ -575,7 +541,6 @@ def test_enter_with_notification_error(
|
|||||||
participant.to_dict(),
|
participant.to_dict(),
|
||||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||||
)
|
)
|
||||||
mock_index_add.assert_called_once_with(room.id, participant_id)
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.lobby.cache")
|
@mock.patch("core.services.lobby.cache")
|
||||||
@@ -614,15 +579,14 @@ def test_get_participant_parsing_error(
|
|||||||
@mock.patch("core.services.lobby.cache")
|
@mock.patch("core.services.lobby.cache")
|
||||||
def test_list_waiting_participants_empty(mock_cache, lobby_service):
|
def test_list_waiting_participants_empty(mock_cache, lobby_service):
|
||||||
"""Test listing waiting participants when none exist."""
|
"""Test listing waiting participants when none exist."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
mock_cache.iter_keys.return_value = []
|
||||||
lobby_service._index_members = mock.Mock(return_value=[])
|
|
||||||
lobby_service._index_remove = mock.Mock()
|
|
||||||
|
|
||||||
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
result = lobby_service.list_waiting_participants(room.id)
|
result = lobby_service.list_waiting_participants(room.id)
|
||||||
|
|
||||||
assert result == ()
|
assert result == []
|
||||||
lobby_service._index_members.assert_called_once_with(room.id)
|
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||||
lobby_service._index_remove.assert_not_called()
|
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
||||||
mock_cache.get_many.assert_not_called()
|
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."""
|
"""Test listing waiting participants with valid data."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||||
lobby_service._index_members = mock.Mock(return_value=["participant1"])
|
mock_cache.iter_keys.return_value = [cache_key]
|
||||||
lobby_service._index_remove = mock.Mock()
|
|
||||||
mock_cache.get_many.return_value = {cache_key: participant_dict}
|
mock_cache.get_many.return_value = {cache_key: participant_dict}
|
||||||
|
|
||||||
result = lobby_service.list_waiting_participants(room.id)
|
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 len(result) == 1
|
||||||
assert result[0]["status"] == "waiting"
|
assert result[0]["status"] == "waiting"
|
||||||
assert result[0]["username"] == "test-username"
|
assert result[0]["username"] == "test-username"
|
||||||
lobby_service._index_members.assert_called_once_with(room.id)
|
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||||
lobby_service._index_remove.assert_called_once_with(room.id)
|
mock_cache.iter_keys.assert_called_once_with(pattern, itersize=CACHE_SCAN_ITERSIZE)
|
||||||
mock_cache.get_many.assert_called_once_with([cache_key])
|
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",
|
"username": "user1",
|
||||||
"id": "participant1",
|
"id": "participant1",
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
participant2 = {
|
participant2 = {
|
||||||
@@ -665,13 +627,9 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
|||||||
"username": "user2",
|
"username": "user2",
|
||||||
"id": "participant2",
|
"id": "participant2",
|
||||||
"color": "#654321",
|
"color": "#654321",
|
||||||
"entered_at": "2025-01-01T10:05:00+00:00",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lobby_service._index_members = mock.Mock(
|
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
|
||||||
return_value=["participant1", "participant2"]
|
|
||||||
)
|
|
||||||
lobby_service._index_remove = mock.Mock()
|
|
||||||
mock_cache.get_many.return_value = {
|
mock_cache.get_many.return_value = {
|
||||||
cache_key1: participant1,
|
cache_key1: participant1,
|
||||||
cache_key2: participant2,
|
cache_key2: participant2,
|
||||||
@@ -681,15 +639,15 @@ def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
|||||||
|
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
|
|
||||||
# Most recent entry comes first
|
# Verify both participants are in the result
|
||||||
assert [p["id"] for p in result] == ["participant2", "participant1"]
|
assert any(p["id"] == "participant1" and p["username"] == "user1" for p in result)
|
||||||
assert result[0]["username"] == "user2"
|
assert any(p["id"] == "participant2" and p["username"] == "user2" for p in result)
|
||||||
assert result[1]["username"] == "user1"
|
|
||||||
|
|
||||||
# Verify all participants have waiting status
|
# Verify all participants have waiting status
|
||||||
assert all(p["status"] == "waiting" for p in result)
|
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])
|
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."""
|
"""Test listing waiting participants with corrupted data."""
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||||
lobby_service._index_members = mock.Mock(return_value=["participant1"])
|
mock_cache.iter_keys.return_value = [cache_key]
|
||||||
lobby_service._index_remove = mock.Mock()
|
|
||||||
mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}}
|
mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}}
|
||||||
|
|
||||||
result = lobby_service.list_waiting_participants(room.id)
|
result = lobby_service.list_waiting_participants(room.id)
|
||||||
|
|
||||||
assert result == ()
|
assert result == []
|
||||||
mock_cache.delete.assert_called_once_with(cache_key)
|
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",
|
"username": "user2",
|
||||||
"id": "participant2",
|
"id": "participant2",
|
||||||
"color": "#654321",
|
"color": "#654321",
|
||||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
corrupted_participant = {"invalid": "data"}
|
corrupted_participant = {"invalid": "data"}
|
||||||
|
|
||||||
lobby_service._index_members = mock.Mock(
|
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
|
||||||
return_value=["participant1", "participant2"]
|
|
||||||
)
|
|
||||||
lobby_service._index_remove = mock.Mock()
|
|
||||||
mock_cache.get_many.return_value = {
|
mock_cache.get_many.return_value = {
|
||||||
cache_key1: corrupted_participant,
|
cache_key1: corrupted_participant,
|
||||||
cache_key2: valid_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)
|
mock_cache.delete.assert_called_once_with(cache_key1)
|
||||||
|
|
||||||
# Verify both cache keys were queried
|
# 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])
|
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",
|
"username": "user1",
|
||||||
"id": "participant1",
|
"id": "participant1",
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
|
||||||
}
|
}
|
||||||
participant2 = {
|
participant2 = {
|
||||||
"status": "accepted",
|
"status": "accepted",
|
||||||
"username": "user2",
|
"username": "user2",
|
||||||
"id": "participant2",
|
"id": "participant2",
|
||||||
"color": "#654321",
|
"color": "#654321",
|
||||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lobby_service._index_members = mock.Mock(
|
mock_cache.iter_keys.return_value = [cache_key1, cache_key2]
|
||||||
return_value=["participant1", "participant2"]
|
|
||||||
)
|
|
||||||
lobby_service._index_remove = mock.Mock()
|
|
||||||
mock_cache.get_many.return_value = {
|
mock_cache.get_many.return_value = {
|
||||||
cache_key1: participant1,
|
cache_key1: participant1,
|
||||||
cache_key2: participant2,
|
cache_key2: participant2,
|
||||||
@@ -866,12 +816,10 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
|
|||||||
"username": "test-username",
|
"username": "test-username",
|
||||||
"id": participant_id,
|
"id": participant_id,
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
mock_cache.get.return_value = participant_dict
|
mock_cache.get.return_value = participant_dict
|
||||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||||
lobby_service._index_touch = mock.Mock()
|
|
||||||
|
|
||||||
lobby_service._update_participant_status(
|
lobby_service._update_participant_status(
|
||||||
room.id,
|
room.id,
|
||||||
@@ -885,12 +833,10 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
|
|||||||
"username": "test-username",
|
"username": "test-username",
|
||||||
"id": participant_id,
|
"id": participant_id,
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
|
||||||
}
|
}
|
||||||
mock_cache.set.assert_called_once_with(
|
mock_cache.set.assert_called_once_with(
|
||||||
"mocked_cache_key", expected_data, timeout=60
|
"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)
|
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",
|
username="participant1",
|
||||||
id="participant1",
|
id="participant1",
|
||||||
color="#123456",
|
color="#123456",
|
||||||
entered_at="2025-01-01T10:00:00+00:00",
|
|
||||||
),
|
),
|
||||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||||
)
|
)
|
||||||
@@ -922,7 +867,6 @@ def test_clear_room_cache(settings, lobby_service):
|
|||||||
username="participant2",
|
username="participant2",
|
||||||
id="participant2",
|
id="participant2",
|
||||||
color="#123456",
|
color="#123456",
|
||||||
entered_at="2025-01-01T10:00:00+00:00",
|
|
||||||
),
|
),
|
||||||
timeout=settings.LOBBY_ACCEPTED_TIMEOUT,
|
timeout=settings.LOBBY_ACCEPTED_TIMEOUT,
|
||||||
)
|
)
|
||||||
@@ -933,18 +877,13 @@ def test_clear_room_cache(settings, lobby_service):
|
|||||||
username="participant3",
|
username="participant3",
|
||||||
id="participant3",
|
id="participant3",
|
||||||
color="#123456",
|
color="#123456",
|
||||||
entered_at="2025-01-01T10:00:00+00:00",
|
|
||||||
),
|
),
|
||||||
timeout=settings.LOBBY_DENIED_TIMEOUT,
|
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)
|
lobby_service.clear_room_cache(room_id)
|
||||||
|
|
||||||
assert cache.keys(f"test-lobby_{room_id!s}_*") == []
|
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):
|
def test_clear_room_empty(settings, lobby_service):
|
||||||
@@ -969,16 +908,12 @@ def test_clear_participant_cache(lobby_service):
|
|||||||
"username": "test-username",
|
"username": "test-username",
|
||||||
"id": participant_id,
|
"id": participant_id,
|
||||||
"color": "#123456",
|
"color": "#123456",
|
||||||
"entered_at": "2025-01-01T10:00:00+00:00",
|
|
||||||
}
|
}
|
||||||
cache.set(cache_key, participant_data, timeout=settings.LOBBY_WAITING_TIMEOUT)
|
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 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)
|
lobby_service.clear_participant_cache(room_id, participant_id)
|
||||||
assert cache.get(cache_key) is None
|
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):
|
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)
|
lobby_service.clear_participant_cache(room_id, participant_id)
|
||||||
|
|
||||||
assert cache.get(cache_key) is None
|
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
|
assert presence.is_marked_present(other_room, "a") is True
|
||||||
|
|
||||||
|
|
||||||
def test_presence_clear_room_removes_many_entries_and_the_index():
|
def test_presence_clear_room_scans_in_pages():
|
||||||
"""clear_room removes every entry of the room through the index — never
|
"""clear_room removes every match, even across several SCAN pages,
|
||||||
a keyspace scan — and leaves other rooms untouched."""
|
and only within the room."""
|
||||||
room_id, other_room = str(uuid4()), str(uuid4())
|
room_id, other_room = str(uuid4()), str(uuid4())
|
||||||
presence = PresenceCache()
|
presence = PresenceCache()
|
||||||
for i in range(7):
|
for i in range(7):
|
||||||
presence.mark_present(room_id, f"user-{i}")
|
presence.mark_present(room_id, f"user-{i}")
|
||||||
presence.mark_present(other_room, "user-0")
|
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 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.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
|
import pytest
|
||||||
from livekit.api import TwirpError
|
from livekit.api import TwirpError
|
||||||
|
|
||||||
from core.factories import RoomFactory
|
|
||||||
from core.models import RoomAccessLevel
|
|
||||||
from core.services.room_management import (
|
from core.services.room_management import (
|
||||||
RoomManagement,
|
RoomManagement,
|
||||||
RoomManagementException,
|
RoomManagementException,
|
||||||
@@ -22,7 +20,7 @@ def test_delete_room_calls_livekit(mock_create_livekit_client):
|
|||||||
mock_api.aclose = mock.AsyncMock()
|
mock_api.aclose = mock.AsyncMock()
|
||||||
mock_create_livekit_client.return_value = mock_api
|
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()
|
mock_api.room.delete_room.assert_awaited_once()
|
||||||
request = mock_api.room.delete_room.await_args.args[0]
|
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
|
mock_create_livekit_client.return_value = mock_api
|
||||||
|
|
||||||
with pytest.raises(RoomNotFoundException):
|
with pytest.raises(RoomNotFoundException):
|
||||||
RoomManagement.delete_room("missing-room")
|
RoomManagement().delete_room("missing-room")
|
||||||
|
|
||||||
mock_api.aclose.assert_awaited_once()
|
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
|
mock_create_livekit_client.return_value = mock_api
|
||||||
|
|
||||||
with pytest.raises(RoomManagementException):
|
with pytest.raises(RoomManagementException):
|
||||||
RoomManagement.delete_room("room-abc")
|
RoomManagement().delete_room("room-abc")
|
||||||
|
|
||||||
mock_api.aclose.assert_awaited_once()
|
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
|
# 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 lasuite.oidc_resource_server.authentication import ResourceServerAuthentication
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from core.analytics import AnalyticsEvent
|
|
||||||
from core.factories import ApplicationFactory, RoomFactory, UserFactory
|
from core.factories import ApplicationFactory, RoomFactory, UserFactory
|
||||||
from core.models import (
|
from core.models import ApplicationScope, RoleChoices, Room, RoomAccessLevel, User
|
||||||
Application,
|
|
||||||
ApplicationScope,
|
|
||||||
RoleChoices,
|
|
||||||
Room,
|
|
||||||
RoomAccessLevel,
|
|
||||||
User,
|
|
||||||
)
|
|
||||||
from core.services.room_management import RoomManagement
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
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
|
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):
|
def test_api_rooms_response_no_url(settings):
|
||||||
"""Response should not include url field when APPLICATION_BASE_URL is None."""
|
"""Response should not include url field when APPLICATION_BASE_URL is None."""
|
||||||
settings.APPLICATION_BASE_URL = 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
|
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
|
# Addons
|
||||||
# ==============================
|
# ==============================
|
||||||
@@ -2160,32 +1548,6 @@ def test_api_rooms_create_with_valid_addons_token():
|
|||||||
assert room.get_role(user) == RoleChoices.OWNER
|
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():
|
def test_api_rooms_addons_token_inactive_user():
|
||||||
"""Addons token for an inactive user should return 401."""
|
"""Addons token for an inactive user should return 401."""
|
||||||
user = UserFactory(is_active=False)
|
user = UserFactory(is_active=False)
|
||||||
|
|||||||
@@ -157,7 +157,6 @@ def test_models_recording_is_savable_normal():
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"status",
|
"status",
|
||||||
[
|
[
|
||||||
RecordingStatusChoices.FAILED,
|
|
||||||
RecordingStatusChoices.FAILED_TO_STOP,
|
RecordingStatusChoices.FAILED_TO_STOP,
|
||||||
RecordingStatusChoices.FAILED_TO_START,
|
RecordingStatusChoices.FAILED_TO_START,
|
||||||
RecordingStatusChoices.ABORTED,
|
RecordingStatusChoices.ABORTED,
|
||||||
@@ -280,7 +279,6 @@ def test_models_recording_is_saved_false_initiated():
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"status",
|
"status",
|
||||||
[
|
[
|
||||||
RecordingStatusChoices.FAILED,
|
|
||||||
RecordingStatusChoices.FAILED_TO_STOP,
|
RecordingStatusChoices.FAILED_TO_STOP,
|
||||||
RecordingStatusChoices.FAILED_TO_START,
|
RecordingStatusChoices.FAILED_TO_START,
|
||||||
RecordingStatusChoices.ABORTED,
|
RecordingStatusChoices.ABORTED,
|
||||||
|
|||||||
@@ -26,64 +26,6 @@ def test_models_users_id_unique():
|
|||||||
factories.UserFactory(id=user.id)
|
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():
|
def test_models_users_send_mail_main_existing():
|
||||||
"""The "email_user' method should send mail to the user's email address."""
|
"""The "email_user' method should send mail to the user's email address."""
|
||||||
user = factories.UserFactory()
|
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)
|
|
||||||
+43
-27
@@ -381,31 +381,6 @@ def detect_mimetype(file_buffer: bytes, filename: str | None = None) -> str:
|
|||||||
return mimetype_from_content or "application/octet-stream"
|
return mimetype_from_content or "application/octet-stream"
|
||||||
|
|
||||||
|
|
||||||
def _get_s3_client(*, override_domain: bool = True):
|
|
||||||
"""Return an S3 client, honoring the AWS_S3_DOMAIN_REPLACE endpoint override.
|
|
||||||
|
|
||||||
AWS_S3_DOMAIN_REPLACE is used when the backend and frontend reach object
|
|
||||||
storage under different domains (this is the case in the docker compose stack
|
|
||||||
used in development: the frontend connects to the object storage on localhost
|
|
||||||
while the backend uses the object storage service name declared in the stack).
|
|
||||||
The domain name is used to compute the signature, so it can't be changed
|
|
||||||
dynamically by the frontend; we build a dedicated boto3 client pointed at that
|
|
||||||
endpoint. Otherwise we reuse the default storage client.
|
|
||||||
"""
|
|
||||||
if settings.AWS_S3_DOMAIN_REPLACE and override_domain:
|
|
||||||
return boto3.client(
|
|
||||||
"s3",
|
|
||||||
aws_access_key_id=settings.AWS_S3_ACCESS_KEY_ID,
|
|
||||||
aws_secret_access_key=settings.AWS_S3_SECRET_ACCESS_KEY,
|
|
||||||
endpoint_url=settings.AWS_S3_DOMAIN_REPLACE,
|
|
||||||
config=botocore.client.Config(
|
|
||||||
region_name=settings.AWS_S3_REGION_NAME,
|
|
||||||
signature_version=settings.AWS_S3_SIGNATURE_VERSION,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return default_storage.connection.meta.client
|
|
||||||
|
|
||||||
|
|
||||||
def generate_upload_policy(file):
|
def generate_upload_policy(file):
|
||||||
"""
|
"""
|
||||||
Generate a S3 upload policy for a given file.
|
Generate a S3 upload policy for a given file.
|
||||||
@@ -416,7 +391,26 @@ def generate_upload_policy(file):
|
|||||||
|
|
||||||
key = file.temporary_file_key
|
key = file.temporary_file_key
|
||||||
|
|
||||||
s3_client = _get_s3_client()
|
# This settings should be used if the backend application and the frontend application
|
||||||
|
# can't connect to the object storage with the same domain. This is the case in the
|
||||||
|
# docker compose stack used in development. The frontend application will use localhost
|
||||||
|
# to connect to the object storage while the backend application will use the object storage
|
||||||
|
# service name declared in the docker compose stack.
|
||||||
|
# This is needed because the domain name is used to compute the signature. So it can't be
|
||||||
|
# changed dynamically by the frontend application.
|
||||||
|
if settings.AWS_S3_DOMAIN_REPLACE:
|
||||||
|
s3_client = boto3.client(
|
||||||
|
"s3",
|
||||||
|
aws_access_key_id=settings.AWS_S3_ACCESS_KEY_ID,
|
||||||
|
aws_secret_access_key=settings.AWS_S3_SECRET_ACCESS_KEY,
|
||||||
|
endpoint_url=settings.AWS_S3_DOMAIN_REPLACE,
|
||||||
|
config=botocore.client.Config(
|
||||||
|
region_name=settings.AWS_S3_REGION_NAME,
|
||||||
|
signature_version=settings.AWS_S3_SIGNATURE_VERSION,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
s3_client = default_storage.connection.meta.client
|
||||||
|
|
||||||
# Generate the policy
|
# Generate the policy
|
||||||
policy = s3_client.generate_presigned_url(
|
policy = s3_client.generate_presigned_url(
|
||||||
@@ -437,7 +431,26 @@ def generate_download_s3_url(
|
|||||||
if not key:
|
if not key:
|
||||||
raise ValueError("key cannot be empty")
|
raise ValueError("key cannot be empty")
|
||||||
|
|
||||||
s3_client = _get_s3_client(override_domain=override_domain)
|
# This setting should be used if the backend application and the frontend application
|
||||||
|
# can't connect to the object storage with the same domain. This is the case in the
|
||||||
|
# docker compose stack used in development. The frontend application will use localhost
|
||||||
|
# to connect to the object storage while the backend application will use the object storage
|
||||||
|
# service name declared in the docker compose stack.
|
||||||
|
# This is needed because the domain name is used to compute the signature. So it can't be
|
||||||
|
# changed dynamically by the frontend application.
|
||||||
|
if settings.AWS_S3_DOMAIN_REPLACE and override_domain:
|
||||||
|
s3_client = boto3.client(
|
||||||
|
"s3",
|
||||||
|
aws_access_key_id=settings.AWS_S3_ACCESS_KEY_ID,
|
||||||
|
aws_secret_access_key=settings.AWS_S3_SECRET_ACCESS_KEY,
|
||||||
|
endpoint_url=settings.AWS_S3_DOMAIN_REPLACE,
|
||||||
|
config=botocore.client.Config(
|
||||||
|
region_name=settings.AWS_S3_REGION_NAME,
|
||||||
|
signature_version=settings.AWS_S3_SIGNATURE_VERSION,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
s3_client = default_storage.connection.meta.client
|
||||||
|
|
||||||
return s3_client.generate_presigned_url(
|
return s3_client.generate_presigned_url(
|
||||||
ClientMethod="get_object",
|
ClientMethod="get_object",
|
||||||
@@ -499,3 +512,6 @@ def build_telephony_config():
|
|||||||
"default_country": country,
|
"default_country": country,
|
||||||
"international_phone_number": international,
|
"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 ""
|
msgstr ""
|
||||||
"Project-Id-Version: PACKAGE VERSION\n"
|
"Project-Id-Version: PACKAGE VERSION\n"
|
||||||
"Report-Msgid-Bugs-To: \n"
|
"Report-Msgid-Bugs-To: \n"
|
||||||
"POT-Creation-Date: 2026-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"
|
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||||
@@ -51,11 +51,11 @@ msgstr ""
|
|||||||
msgid "File preview"
|
msgid "File preview"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: core/admin.py:300 core/admin.py:450
|
#: core/admin.py:300 core/admin.py:443
|
||||||
msgid "No owner"
|
msgid "No owner"
|
||||||
msgstr "Kein Eigentümer"
|
msgstr "Kein Eigentümer"
|
||||||
|
|
||||||
#: core/admin.py:303 core/admin.py:453
|
#: core/admin.py:303 core/admin.py:446
|
||||||
msgid "Multiple owners"
|
msgid "Multiple owners"
|
||||||
msgstr "Mehrere Eigentümer"
|
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."
|
msgid "Skipped %(count)s recording(s) with an ineligible status."
|
||||||
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
|
msgstr "%(count)s abgelaufene Aufnahme(n) übersprungen."
|
||||||
|
|
||||||
#: core/admin.py:517
|
#: core/admin.py:510
|
||||||
msgid "No scopes"
|
msgid "No scopes"
|
||||||
msgstr "Keine Scopes"
|
msgstr "Keine Scopes"
|
||||||
|
|
||||||
#: core/admin.py:519
|
#: core/admin.py:512
|
||||||
msgid "Scopes"
|
msgid "Scopes"
|
||||||
msgstr "Scopes"
|
msgstr "Scopes"
|
||||||
|
|
||||||
@@ -110,201 +110,185 @@ msgstr "Scopes"
|
|||||||
msgid "Creator is me"
|
msgid "Creator is me"
|
||||||
msgstr "Ersteller bin ich"
|
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."
|
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
|
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
|
||||||
"hinzuzufügen."
|
"hinzuzufügen."
|
||||||
|
|
||||||
#: core/api/serializers.py:560
|
#: core/api/serializers.py:534
|
||||||
msgid "This file extension is not allowed."
|
msgid "This file extension is not allowed."
|
||||||
msgstr "Diese Dateiendung ist nicht erlaubt."
|
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."
|
msgid "You have reached the maximum number of files for this type."
|
||||||
msgstr "Sie haben die maximale Anzahl an Dateien dieses Typs erreicht."
|
msgstr "Sie haben die maximale Anzahl an Dateien dieses Typs erreicht."
|
||||||
|
|
||||||
#: core/models.py:38
|
#: core/models.py:37
|
||||||
msgid "Member"
|
msgid "Member"
|
||||||
msgstr "Mitglied"
|
msgstr "Mitglied"
|
||||||
|
|
||||||
#: core/models.py:39
|
#: core/models.py:38
|
||||||
msgid "Administrator"
|
msgid "Administrator"
|
||||||
msgstr "Administrator"
|
msgstr "Administrator"
|
||||||
|
|
||||||
#: core/models.py:40
|
#: core/models.py:39
|
||||||
msgid "Owner"
|
msgid "Owner"
|
||||||
msgstr "Eigentümer"
|
msgstr "Eigentümer"
|
||||||
|
|
||||||
#: core/models.py:56
|
#: core/models.py:55
|
||||||
msgid "Initiated"
|
msgid "Initiated"
|
||||||
msgstr "Gestartet"
|
msgstr "Gestartet"
|
||||||
|
|
||||||
#: core/models.py:57
|
#: core/models.py:56
|
||||||
msgid "Active"
|
msgid "Active"
|
||||||
msgstr "Aktiv"
|
msgstr "Aktiv"
|
||||||
|
|
||||||
#: core/models.py:58
|
#: core/models.py:57
|
||||||
msgid "Stopped"
|
msgid "Stopped"
|
||||||
msgstr "Beendet"
|
msgstr "Beendet"
|
||||||
|
|
||||||
#: core/models.py:59
|
#: core/models.py:58
|
||||||
msgid "Saved"
|
msgid "Saved"
|
||||||
msgstr "Gespeichert"
|
msgstr "Gespeichert"
|
||||||
|
|
||||||
#: core/models.py:60
|
#: core/models.py:59
|
||||||
msgid "Aborted"
|
msgid "Aborted"
|
||||||
msgstr "Abgebrochen"
|
msgstr "Abgebrochen"
|
||||||
|
|
||||||
#: core/models.py:61
|
#: core/models.py:60
|
||||||
msgid "Failed to Start"
|
msgid "Failed to Start"
|
||||||
msgstr "Start fehlgeschlagen"
|
msgstr "Start fehlgeschlagen"
|
||||||
|
|
||||||
#: core/models.py:62
|
#: core/models.py:61
|
||||||
msgid "Failed to Stop"
|
msgid "Failed to Stop"
|
||||||
msgstr "Stopp fehlgeschlagen"
|
msgstr "Stopp fehlgeschlagen"
|
||||||
|
|
||||||
#: core/models.py:63
|
#: core/models.py:62
|
||||||
msgid "Notification succeeded"
|
msgid "Notification succeeded"
|
||||||
msgstr "Benachrichtigung erfolgreich"
|
msgstr "Benachrichtigung erfolgreich"
|
||||||
|
|
||||||
#: core/models.py:66
|
#: core/models.py:65
|
||||||
msgid "External process successful"
|
msgid "External process successful"
|
||||||
msgstr "Externer Prozess erfolgreich"
|
msgstr "Externer Prozess erfolgreich"
|
||||||
|
|
||||||
#: core/models.py:68
|
#: core/models.py:67
|
||||||
msgid "External process failed"
|
msgid "External process failed"
|
||||||
msgstr "Externer Prozess fehlgeschlagen"
|
msgstr "Externer Prozess fehlgeschlagen"
|
||||||
|
|
||||||
#: core/models.py:97
|
#: core/models.py:96
|
||||||
msgid "SCREEN_RECORDING"
|
msgid "SCREEN_RECORDING"
|
||||||
msgstr "BILDSCHIRMAUFZEICHNUNG"
|
msgstr "BILDSCHIRMAUFZEICHNUNG"
|
||||||
|
|
||||||
#: core/models.py:98
|
#: core/models.py:97
|
||||||
msgid "TRANSCRIPT"
|
msgid "TRANSCRIPT"
|
||||||
msgstr "TRANSKRIPT"
|
msgstr "TRANSKRIPT"
|
||||||
|
|
||||||
#: core/models.py:104
|
#: core/models.py:103
|
||||||
msgid "Public Access"
|
msgid "Public Access"
|
||||||
msgstr "Öffentlicher Zugriff"
|
msgstr "Öffentlicher Zugriff"
|
||||||
|
|
||||||
#: core/models.py:105
|
#: core/models.py:104
|
||||||
msgid "Trusted Access"
|
msgid "Trusted Access"
|
||||||
msgstr "Vertrauenswürdiger Zugriff"
|
msgstr "Vertrauenswürdiger Zugriff"
|
||||||
|
|
||||||
#: core/models.py:106
|
#: core/models.py:105
|
||||||
msgid "Restricted Access"
|
msgid "Restricted Access"
|
||||||
msgstr "Eingeschränkter Zugriff"
|
msgstr "Eingeschränkter Zugriff"
|
||||||
|
|
||||||
#: core/models.py:118
|
#: core/models.py:117
|
||||||
msgid "id"
|
msgid "id"
|
||||||
msgstr "ID"
|
msgstr "ID"
|
||||||
|
|
||||||
#: core/models.py:119
|
#: core/models.py:118
|
||||||
msgid "primary key for the record as UUID"
|
msgid "primary key for the record as UUID"
|
||||||
msgstr "Primärschlüssel des Eintrags als UUID"
|
msgstr "Primärschlüssel des Eintrags als UUID"
|
||||||
|
|
||||||
#: core/models.py:125
|
#: core/models.py:124
|
||||||
msgid "created on"
|
msgid "created on"
|
||||||
msgstr "erstellt am"
|
msgstr "erstellt am"
|
||||||
|
|
||||||
#: core/models.py:126
|
#: core/models.py:125
|
||||||
msgid "date and time at which a record was created"
|
msgid "date and time at which a record was created"
|
||||||
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
|
msgstr "Datum und Uhrzeit der Erstellung eines Eintrags"
|
||||||
|
|
||||||
#: core/models.py:131
|
#: core/models.py:130
|
||||||
msgid "updated on"
|
msgid "updated on"
|
||||||
msgstr "aktualisiert am"
|
msgstr "aktualisiert am"
|
||||||
|
|
||||||
#: core/models.py:132
|
#: core/models.py:131
|
||||||
msgid "date and time at which a record was last updated"
|
msgid "date and time at which a record was last updated"
|
||||||
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
|
msgstr "Datum und Uhrzeit der letzten Aktualisierung eines Eintrags"
|
||||||
|
|
||||||
#: core/models.py: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"
|
msgid "sub"
|
||||||
msgstr "Sub"
|
msgstr "Sub"
|
||||||
|
|
||||||
#: core/models.py:152
|
#: core/models.py:159
|
||||||
msgid ""
|
msgid ""
|
||||||
"Optional for pending users; required upon account activation. 255 characters "
|
"Optional for pending users; required upon account activation. 255 characters "
|
||||||
"or fewer. Printable ASCII characters only."
|
"or fewer. Letters, numbers, and @/./+/-/_ characters only."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Optional für ausstehende Benutzer; erforderlich nach Kontoaktivierung. "
|
"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
|
#: core/models.py:168
|
||||||
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
|
|
||||||
msgid "identity email address"
|
msgid "identity email address"
|
||||||
msgstr "Identitäts-E-Mail-Adresse"
|
msgstr "Identitäts-E-Mail-Adresse"
|
||||||
|
|
||||||
#: core/models.py:166
|
#: core/models.py:173
|
||||||
msgid "admin email address"
|
msgid "admin email address"
|
||||||
msgstr "Administrator-E-Mail-Adresse"
|
msgstr "Administrator-E-Mail-Adresse"
|
||||||
|
|
||||||
#: core/models.py:168
|
#: core/models.py:175
|
||||||
msgid "full name"
|
msgid "full name"
|
||||||
msgstr "Vollständiger Name"
|
msgstr "Vollständiger Name"
|
||||||
|
|
||||||
#: core/models.py:170
|
#: core/models.py:177
|
||||||
msgid "short name"
|
msgid "short name"
|
||||||
msgstr "Kurzname"
|
msgstr "Kurzname"
|
||||||
|
|
||||||
#: core/models.py:176
|
#: core/models.py:183
|
||||||
msgid "language"
|
msgid "language"
|
||||||
msgstr "Sprache"
|
msgstr "Sprache"
|
||||||
|
|
||||||
#: core/models.py:177
|
#: core/models.py:184
|
||||||
msgid "The language in which the user wants to see the interface."
|
msgid "The language in which the user wants to see the interface."
|
||||||
msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
|
msgstr "Die Sprache, in der der Benutzer die Oberfläche sehen möchte."
|
||||||
|
|
||||||
#: core/models.py:183
|
#: core/models.py:190
|
||||||
msgid "The timezone in which the user wants to see times."
|
msgid "The timezone in which the user wants to see times."
|
||||||
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
|
msgstr "Die Zeitzone, in der der Benutzer die Zeiten sehen möchte."
|
||||||
|
|
||||||
#: core/models.py:190
|
#: core/models.py:193
|
||||||
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
|
|
||||||
msgid "device"
|
msgid "device"
|
||||||
msgstr "Gerät"
|
msgstr "Gerät"
|
||||||
|
|
||||||
#: core/models.py:207
|
#: core/models.py:195
|
||||||
msgid "Whether the user is a device or a real user."
|
msgid "Whether the user is a device or a real user."
|
||||||
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
|
msgstr "Ob es sich um ein Gerät oder einen echten Benutzer handelt."
|
||||||
|
|
||||||
#: core/models.py:210
|
#: core/models.py:198
|
||||||
msgid "staff status"
|
msgid "staff status"
|
||||||
msgstr "Mitarbeiterstatus"
|
msgstr "Mitarbeiterstatus"
|
||||||
|
|
||||||
#: core/models.py:212
|
#: core/models.py:200
|
||||||
msgid "Whether the user can log into this admin site."
|
msgid "Whether the user can log into this admin site."
|
||||||
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
|
msgstr "Ob der Benutzer sich bei dieser Admin-Seite anmelden kann."
|
||||||
|
|
||||||
#: core/models.py:215
|
#: core/models.py:203
|
||||||
msgid "active"
|
msgid "active"
|
||||||
msgstr "aktiv"
|
msgstr "aktiv"
|
||||||
|
|
||||||
#: core/models.py:218
|
#: core/models.py:206
|
||||||
msgid ""
|
msgid ""
|
||||||
"Whether this user should be treated as active. Unselect this instead of "
|
"Whether this user should be treated as active. Unselect this instead of "
|
||||||
"deleting accounts."
|
"deleting accounts."
|
||||||
@@ -312,66 +296,66 @@ msgstr ""
|
|||||||
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
|
"Ob dieser Benutzer als aktiv behandelt werden soll. Deaktivieren Sie dies "
|
||||||
"anstelle des Löschens des Kontos."
|
"anstelle des Löschens des Kontos."
|
||||||
|
|
||||||
#: core/models.py:231
|
#: core/models.py:219
|
||||||
msgid "user"
|
msgid "user"
|
||||||
msgstr "Benutzer"
|
msgstr "Benutzer"
|
||||||
|
|
||||||
#: core/models.py:232
|
#: core/models.py:220
|
||||||
msgid "users"
|
msgid "users"
|
||||||
msgstr "Benutzer"
|
msgstr "Benutzer"
|
||||||
|
|
||||||
#: core/models.py:298
|
#: core/models.py:286
|
||||||
msgid "Resource"
|
msgid "Resource"
|
||||||
msgstr "Ressource"
|
msgstr "Ressource"
|
||||||
|
|
||||||
#: core/models.py:299
|
#: core/models.py:287
|
||||||
msgid "Resources"
|
msgid "Resources"
|
||||||
msgstr "Ressourcen"
|
msgstr "Ressourcen"
|
||||||
|
|
||||||
#: core/models.py:357
|
#: core/models.py:345
|
||||||
msgid "Resource access"
|
msgid "Resource access"
|
||||||
msgstr "Ressourcenzugriff"
|
msgstr "Ressourcenzugriff"
|
||||||
|
|
||||||
#: core/models.py:358
|
#: core/models.py:346
|
||||||
msgid "Resource accesses"
|
msgid "Resource accesses"
|
||||||
msgstr "Ressourcenzugriffe"
|
msgstr "Ressourcenzugriffe"
|
||||||
|
|
||||||
#: core/models.py:364
|
#: core/models.py:352
|
||||||
msgid "Resource access with this User and Resource already exists."
|
msgid "Resource access with this User and Resource already exists."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
|
"Ein Ressourcenzugriff mit diesem Benutzer und dieser Ressource existiert "
|
||||||
"bereits."
|
"bereits."
|
||||||
|
|
||||||
#: core/models.py:421
|
#: core/models.py:409
|
||||||
msgid "Visio room configuration"
|
msgid "Visio room configuration"
|
||||||
msgstr "Visio-Raumkonfiguration"
|
msgstr "Visio-Raumkonfiguration"
|
||||||
|
|
||||||
#: core/models.py:422
|
#: core/models.py:410
|
||||||
msgid "Values for Visio parameters to configure the room."
|
msgid "Values for Visio parameters to configure the room."
|
||||||
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
|
msgstr "Werte für Visio-Parameter zur Konfiguration des Raums."
|
||||||
|
|
||||||
#: core/models.py:429
|
#: core/models.py:417
|
||||||
msgid "Room PIN code"
|
msgid "Room PIN code"
|
||||||
msgstr "PIN-Code für den Raum"
|
msgstr "PIN-Code für den Raum"
|
||||||
|
|
||||||
#: core/models.py:430
|
#: core/models.py:418
|
||||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
|
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
|
||||||
|
|
||||||
#: core/models.py:436 core/models.py:597
|
#: core/models.py:424 core/models.py:578
|
||||||
msgid "Room"
|
msgid "Room"
|
||||||
msgstr "Raum"
|
msgstr "Raum"
|
||||||
|
|
||||||
#: core/models.py:437
|
#: core/models.py:425
|
||||||
msgid "Rooms"
|
msgid "Rooms"
|
||||||
msgstr "Räume"
|
msgstr "Räume"
|
||||||
|
|
||||||
#: core/models.py:608
|
#: core/models.py:589
|
||||||
msgid "Worker ID"
|
msgid "Worker ID"
|
||||||
msgstr "Worker-ID"
|
msgstr "Worker-ID"
|
||||||
|
|
||||||
#: core/models.py:610
|
#: core/models.py:591
|
||||||
msgid ""
|
msgid ""
|
||||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||||
"the worker stops, allowing for easy tracking."
|
"the worker stops, allowing for easy tracking."
|
||||||
@@ -380,153 +364,153 @@ msgstr ""
|
|||||||
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
|
"erhalten, auch wenn der Worker stoppt, was ein einfaches Nachverfolgen "
|
||||||
"ermöglicht."
|
"ermöglicht."
|
||||||
|
|
||||||
#: core/models.py:618
|
#: core/models.py:599
|
||||||
msgid "Recording mode"
|
msgid "Recording mode"
|
||||||
msgstr "Aufzeichnungsmodus"
|
msgstr "Aufzeichnungsmodus"
|
||||||
|
|
||||||
#: core/models.py:619
|
#: core/models.py:600
|
||||||
msgid "Defines the mode of recording being called."
|
msgid "Defines the mode of recording being called."
|
||||||
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
|
msgstr "Definiert den aufgerufenen Aufzeichnungsmodus."
|
||||||
|
|
||||||
#: core/models.py:624 core/models.py:625
|
#: core/models.py:605 core/models.py:606
|
||||||
msgid "Recording options"
|
msgid "Recording options"
|
||||||
msgstr "Aufnahmeoptionen"
|
msgstr "Aufnahmeoptionen"
|
||||||
|
|
||||||
#: core/models.py:632
|
#: core/models.py:613
|
||||||
msgid "External Process ID"
|
msgid "External Process ID"
|
||||||
msgstr "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."
|
msgid "ID of the external process associated with the recording."
|
||||||
msgstr "ID des externen Prozesses, der mit der Aufzeichnung verknüpft ist"
|
msgstr "ID des externen Prozesses, der mit der Aufzeichnung verknüpft ist"
|
||||||
|
|
||||||
#: core/models.py:639
|
#: core/models.py:620
|
||||||
msgid "Recording"
|
msgid "Recording"
|
||||||
msgstr "Aufzeichnung"
|
msgstr "Aufzeichnung"
|
||||||
|
|
||||||
#: core/models.py:640
|
#: core/models.py:621
|
||||||
msgid "Recordings"
|
msgid "Recordings"
|
||||||
msgstr "Aufzeichnungen"
|
msgstr "Aufzeichnungen"
|
||||||
|
|
||||||
#: core/models.py:750
|
#: core/models.py:731
|
||||||
msgid "Recording/user relation"
|
msgid "Recording/user relation"
|
||||||
msgstr "Beziehung Aufzeichnung/Benutzer"
|
msgstr "Beziehung Aufzeichnung/Benutzer"
|
||||||
|
|
||||||
#: core/models.py:751
|
#: core/models.py:732
|
||||||
msgid "Recording/user relations"
|
msgid "Recording/user relations"
|
||||||
msgstr "Beziehungen Aufzeichnung/Benutzer"
|
msgstr "Beziehungen Aufzeichnung/Benutzer"
|
||||||
|
|
||||||
#: core/models.py:757
|
#: core/models.py:738
|
||||||
msgid "This user is already in this recording."
|
msgid "This user is already in this recording."
|
||||||
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
|
msgstr "Dieser Benutzer ist bereits Teil dieser Aufzeichnung."
|
||||||
|
|
||||||
#: core/models.py:763
|
#: core/models.py:744
|
||||||
msgid "This team is already in this recording."
|
msgid "This team is already in this recording."
|
||||||
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
|
msgstr "Dieses Team ist bereits Teil dieser Aufzeichnung."
|
||||||
|
|
||||||
#: core/models.py:769
|
#: core/models.py:750
|
||||||
msgid "Either user or team must be set, not both."
|
msgid "Either user or team must be set, not both."
|
||||||
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
|
msgstr "Entweder Benutzer oder Team muss festgelegt werden, nicht beides."
|
||||||
|
|
||||||
#: core/models.py:786
|
#: core/models.py:767
|
||||||
msgid "Create rooms"
|
msgid "Create rooms"
|
||||||
msgstr "Räume erstellen"
|
msgstr "Räume erstellen"
|
||||||
|
|
||||||
#: core/models.py:787
|
#: core/models.py:768
|
||||||
msgid "List rooms"
|
msgid "List rooms"
|
||||||
msgstr "Räume auflisten"
|
msgstr "Räume auflisten"
|
||||||
|
|
||||||
#: core/models.py:788
|
#: core/models.py:769
|
||||||
msgid "Retrieve room details"
|
msgid "Retrieve room details"
|
||||||
msgstr "Raumdetails abrufen"
|
msgstr "Raumdetails abrufen"
|
||||||
|
|
||||||
#: core/models.py:789
|
#: core/models.py:770
|
||||||
msgid "Update rooms"
|
msgid "Update rooms"
|
||||||
msgstr "Räume aktualisieren"
|
msgstr "Räume aktualisieren"
|
||||||
|
|
||||||
#: core/models.py:790
|
#: core/models.py:771
|
||||||
msgid "Delete rooms"
|
msgid "Delete rooms"
|
||||||
msgstr "Räume löschen"
|
msgstr "Räume löschen"
|
||||||
|
|
||||||
#: core/models.py:803
|
#: core/models.py:784
|
||||||
msgid "Application name"
|
msgid "Application name"
|
||||||
msgstr "Anwendungsname"
|
msgstr "Anwendungsname"
|
||||||
|
|
||||||
#: core/models.py:804
|
#: core/models.py:785
|
||||||
msgid "Descriptive name for this application."
|
msgid "Descriptive name for this application."
|
||||||
msgstr "Beschreibender Name für diese Anwendung."
|
msgstr "Beschreibender Name für diese Anwendung."
|
||||||
|
|
||||||
#: core/models.py:814
|
#: core/models.py:795
|
||||||
msgid "Hashed on Save. Copy it now if this is a new secret."
|
msgid "Hashed on Save. Copy it now if this is a new secret."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
|
"Beim Speichern gehasht. Jetzt kopieren, wenn dies ein neues Geheimnis ist."
|
||||||
|
|
||||||
#: core/models.py:825
|
#: core/models.py:806
|
||||||
msgid "Application"
|
msgid "Application"
|
||||||
msgstr "Anwendung"
|
msgstr "Anwendung"
|
||||||
|
|
||||||
#: core/models.py:826
|
#: core/models.py:807
|
||||||
msgid "Applications"
|
msgid "Applications"
|
||||||
msgstr "Anwendungen"
|
msgstr "Anwendungen"
|
||||||
|
|
||||||
#: core/models.py:849
|
#: core/models.py:830
|
||||||
msgid "Enter a valid domain"
|
msgid "Enter a valid domain"
|
||||||
msgstr "Geben Sie eine gültige Domain ein"
|
msgstr "Geben Sie eine gültige Domain ein"
|
||||||
|
|
||||||
#: core/models.py:852
|
#: core/models.py:833
|
||||||
msgid "Domain"
|
msgid "Domain"
|
||||||
msgstr "Domain"
|
msgstr "Domain"
|
||||||
|
|
||||||
#: core/models.py:853
|
#: core/models.py:834
|
||||||
msgid "Email domain this application can act on behalf of."
|
msgid "Email domain this application can act on behalf of."
|
||||||
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
|
msgstr "E-Mail-Domain, im Namen der diese Anwendung handeln kann."
|
||||||
|
|
||||||
#: core/models.py:865
|
#: core/models.py:846
|
||||||
msgid "Application domain"
|
msgid "Application domain"
|
||||||
msgstr "Anwendungsdomain"
|
msgstr "Anwendungsdomain"
|
||||||
|
|
||||||
#: core/models.py:866
|
#: core/models.py:847
|
||||||
msgid "Application domains"
|
msgid "Application domains"
|
||||||
msgstr "Anwendungsdomains"
|
msgstr "Anwendungsdomains"
|
||||||
|
|
||||||
#: core/models.py:884
|
#: core/models.py:865
|
||||||
msgid "Pending"
|
msgid "Pending"
|
||||||
msgstr "Ausstehend"
|
msgstr "Ausstehend"
|
||||||
|
|
||||||
#: core/models.py:885
|
#: core/models.py:866
|
||||||
msgid "Analyzing"
|
msgid "Analyzing"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: core/models.py:892
|
#: core/models.py:873
|
||||||
msgid "Ready"
|
msgid "Ready"
|
||||||
msgstr "Bereit"
|
msgstr "Bereit"
|
||||||
|
|
||||||
#: core/models.py:898
|
#: core/models.py:879
|
||||||
msgid "Background image"
|
msgid "Background image"
|
||||||
msgstr "Hintergrundbild"
|
msgstr "Hintergrundbild"
|
||||||
|
|
||||||
#: core/models.py:910
|
#: core/models.py:891
|
||||||
msgid "title"
|
msgid "title"
|
||||||
msgstr "Titel"
|
msgstr "Titel"
|
||||||
|
|
||||||
#: core/models.py:934
|
#: core/models.py:915
|
||||||
msgid "Malware detection info when the analysis status is unsafe."
|
msgid "Malware detection info when the analysis status is unsafe."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
"Informationen zur Malware-Erkennung, wenn der Analyse-Status unsicher ist."
|
"Informationen zur Malware-Erkennung, wenn der Analyse-Status unsicher ist."
|
||||||
|
|
||||||
#: core/models.py:939
|
#: core/models.py:920
|
||||||
msgid "File"
|
msgid "File"
|
||||||
msgstr "Datei"
|
msgstr "Datei"
|
||||||
|
|
||||||
#: core/models.py:940
|
#: core/models.py:921
|
||||||
msgid "Files"
|
msgid "Files"
|
||||||
msgstr "Dateien"
|
msgstr "Dateien"
|
||||||
|
|
||||||
#: core/models.py:1060
|
#: core/models.py:1041
|
||||||
msgid "This file is already hard deleted."
|
msgid "This file is already hard deleted."
|
||||||
msgstr "Diese Datei wurde bereits endgültig gelöscht."
|
msgstr "Diese Datei wurde bereits endgültig gelöscht."
|
||||||
|
|
||||||
#: core/models.py:1070
|
#: core/models.py:1051
|
||||||
#, fuzzy
|
#, 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."
|
||||||
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 "
|
"Um eine Datei endgültig zu löschen, muss sie zuvor weich gelöscht worden "
|
||||||
"sein."
|
"sein."
|
||||||
|
|
||||||
#: core/recording/event/notification.py:124
|
#: core/recording/event/notification.py:123
|
||||||
msgid "Your recording is ready"
|
msgid "Your recording is ready"
|
||||||
msgstr "Ihre Aufzeichnung ist bereit"
|
msgstr "Ihre Aufzeichnung ist bereit"
|
||||||
|
|
||||||
#: core/recording/event/notification.py:195
|
#: core/recording/event/notification.py:194
|
||||||
msgid "Transcription"
|
msgid "Transcription"
|
||||||
msgstr "Transkription"
|
msgstr "Transkription"
|
||||||
|
|
||||||
#: core/recording/event/notification.py:206
|
#: core/recording/event/notification.py:204
|
||||||
#, python-brace-format
|
#, python-brace-format
|
||||||
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
msgid "Meeting \"{room}\" on {room_recording_date} at {room_recording_time}"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
@@ -553,25 +537,25 @@ msgstr ""
|
|||||||
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
msgid "Video call in progress: {sender.email} is waiting for you to connect"
|
||||||
msgstr "Videoanruf läuft: {sender.email} wartet auf Ihre Teilnahme"
|
msgstr "Videoanruf läuft: {sender.email} wartet auf Ihre Teilnahme"
|
||||||
|
|
||||||
#: core/templates/mail/html/invitation.html:151
|
#: core/templates/mail/html/invitation.html:159
|
||||||
#: core/templates/mail/html/screen_recording.html:151
|
#: core/templates/mail/html/screen_recording.html:159
|
||||||
#: core/templates/mail/text/invitation.txt:4
|
#: core/templates/mail/text/invitation.txt:3
|
||||||
#: core/templates/mail/text/screen_recording.txt:4
|
#: core/templates/mail/text/screen_recording.txt:3
|
||||||
msgid "Logo email"
|
msgid "Logo email"
|
||||||
msgstr "Logo-E-Mail"
|
msgstr "Logo-E-Mail"
|
||||||
|
|
||||||
#: core/templates/mail/html/invitation.html:181
|
#: core/templates/mail/html/invitation.html:189
|
||||||
#: core/templates/mail/text/invitation.txt:6
|
#: core/templates/mail/text/invitation.txt:5
|
||||||
msgid "invites you to join an ongoing video call"
|
msgid "invites you to join an ongoing video call"
|
||||||
msgstr "lädt Sie zu einem laufenden Videoanruf ein"
|
msgstr "lädt Sie zu einem laufenden Videoanruf ein"
|
||||||
|
|
||||||
#: core/templates/mail/html/invitation.html:192
|
#: core/templates/mail/html/invitation.html:200
|
||||||
#: core/templates/mail/text/invitation.txt:8
|
#: core/templates/mail/text/invitation.txt:7
|
||||||
msgid "JOIN THE CALL"
|
msgid "JOIN THE CALL"
|
||||||
msgstr "AM ANRUF TEILNEHMEN"
|
msgstr "AM ANRUF TEILNEHMEN"
|
||||||
|
|
||||||
#: core/templates/mail/html/invitation.html:219
|
#: core/templates/mail/html/invitation.html:227
|
||||||
#: core/templates/mail/text/invitation.txt:14
|
#: core/templates/mail/text/invitation.txt:13
|
||||||
msgid ""
|
msgid ""
|
||||||
"If you can't click the button, copy and paste the URL into your browser to "
|
"If you can't click the button, copy and paste the URL into your browser to "
|
||||||
"join the call."
|
"join the call."
|
||||||
@@ -579,47 +563,41 @@ msgstr ""
|
|||||||
"Wenn Sie den Button nicht anklicken können, kopieren Sie die URL und fügen "
|
"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."
|
"Sie sie in Ihren Browser ein, um am Anruf teilzunehmen."
|
||||||
|
|
||||||
#: core/templates/mail/html/invitation.html:227
|
#: core/templates/mail/html/invitation.html:235
|
||||||
#: core/templates/mail/text/invitation.txt:16
|
#: core/templates/mail/text/invitation.txt:15
|
||||||
msgid "Tips for a better experience:"
|
msgid "Tips for a better experience:"
|
||||||
msgstr "Tipps für ein besseres Erlebnis:"
|
msgstr "Tipps für ein besseres Erlebnis:"
|
||||||
|
|
||||||
#: core/templates/mail/html/invitation.html:229
|
#: core/templates/mail/html/invitation.html:237
|
||||||
#: core/templates/mail/text/invitation.txt:18
|
#: core/templates/mail/text/invitation.txt:17
|
||||||
msgid "Use Chrome or Firefox for better call quality"
|
msgid "Use Chrome or Firefox for better call quality"
|
||||||
msgstr "Verwenden Sie Chrome oder Firefox für eine bessere Anrufqualität"
|
msgstr "Verwenden Sie Chrome oder Firefox für eine bessere Anrufqualität"
|
||||||
|
|
||||||
#: core/templates/mail/html/invitation.html:230
|
#: core/templates/mail/html/invitation.html:238
|
||||||
#: core/templates/mail/text/invitation.txt:19
|
#: core/templates/mail/text/invitation.txt:18
|
||||||
msgid "Test your microphone and camera before joining"
|
msgid "Test your microphone and camera before joining"
|
||||||
msgstr "Testen Sie Ihr Mikrofon und Ihre Kamera vor dem Beitritt"
|
msgstr "Testen Sie Ihr Mikrofon und Ihre Kamera vor dem Beitritt"
|
||||||
|
|
||||||
#: core/templates/mail/html/invitation.html:231
|
#: core/templates/mail/html/invitation.html:239
|
||||||
#: core/templates/mail/text/invitation.txt:20
|
#: core/templates/mail/text/invitation.txt:19
|
||||||
msgid "Make sure you have a stable internet connection"
|
msgid "Make sure you have a stable internet connection"
|
||||||
msgstr "Stellen Sie sicher, dass Sie eine stabile Internetverbindung haben"
|
msgstr "Stellen Sie sicher, dass Sie eine stabile Internetverbindung haben"
|
||||||
|
|
||||||
#: core/templates/mail/html/invitation.html:240
|
#: core/templates/mail/html/invitation.html:248
|
||||||
#: core/templates/mail/html/screen_recording.html:237
|
#: core/templates/mail/html/screen_recording.html:245
|
||||||
#: core/templates/mail/text/invitation.txt:22
|
#: core/templates/mail/text/invitation.txt:21
|
||||||
#: core/templates/mail/text/screen_recording.txt:24
|
#: core/templates/mail/text/screen_recording.txt:23
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid " Thank you for using %(brandname)s. "
|
msgid " Thank you for using %(brandname)s. "
|
||||||
msgstr " Vielen Dank für die Nutzung von %(brandname)s. "
|
msgstr " Vielen Dank für die Nutzung von %(brandname)s. "
|
||||||
|
|
||||||
#: core/templates/mail/html/invitation.html:271
|
#: core/templates/mail/html/screen_recording.html:188
|
||||||
#, python-format
|
#: core/templates/mail/text/screen_recording.txt:6
|
||||||
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
|
|
||||||
msgid "Your recording is ready!"
|
msgid "Your recording is ready!"
|
||||||
msgstr "Ihre Aufzeichnung ist fertig!"
|
msgstr "Ihre Aufzeichnung ist fertig!"
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:187
|
#: core/templates/mail/html/screen_recording.html:195
|
||||||
#: core/templates/mail/text/screen_recording.txt:9
|
#: core/templates/mail/text/screen_recording.txt:8
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid ""
|
msgid ""
|
||||||
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
|
" 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 "
|
" Ihre Aufzeichnung von \"%(room_name)s\" am %(recording_date)s um "
|
||||||
"%(recording_time)s steht nun zum Herunterladen bereit. "
|
"%(recording_time)s steht nun zum Herunterladen bereit. "
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:187
|
#: core/templates/mail/html/screen_recording.html:195
|
||||||
#: core/templates/mail/text/screen_recording.txt:9
|
#: core/templates/mail/text/screen_recording.txt:8
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid " The recording will expire in %(days)s days. "
|
msgid " The recording will expire in %(days)s days. "
|
||||||
msgstr " Die Aufzeichnung wird in %(days)s Tagen ablaufen. "
|
msgstr " Die Aufzeichnung wird in %(days)s Tagen ablaufen. "
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:192
|
#: core/templates/mail/html/screen_recording.html:200
|
||||||
#: core/templates/mail/text/screen_recording.txt:10
|
#: core/templates/mail/text/screen_recording.txt:9
|
||||||
msgid ""
|
msgid ""
|
||||||
" Sharing the recording via link is not yet available. Only organizers can "
|
" Sharing the recording via link is not yet available. Only organizers can "
|
||||||
"download it. "
|
"download it. "
|
||||||
@@ -643,33 +621,33 @@ msgstr ""
|
|||||||
" Die Freigabe der Aufzeichnung per Link ist noch nicht verfügbar. Nur "
|
" Die Freigabe der Aufzeichnung per Link ist noch nicht verfügbar. Nur "
|
||||||
"Organisatoren können sie herunterladen. "
|
"Organisatoren können sie herunterladen. "
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:198
|
#: core/templates/mail/html/screen_recording.html:206
|
||||||
#: core/templates/mail/text/screen_recording.txt:12
|
#: core/templates/mail/text/screen_recording.txt:11
|
||||||
msgid "To keep this recording permanently:"
|
msgid "To keep this recording permanently:"
|
||||||
msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
|
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
|
#, python-format
|
||||||
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||||
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
|
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:201
|
#: core/templates/mail/html/screen_recording.html:209
|
||||||
#: core/templates/mail/text/screen_recording.txt:15
|
#: core/templates/mail/text/screen_recording.txt:14
|
||||||
msgid "Use the \"Download\" button in the interface "
|
msgid "Use the \"Download\" button in the interface "
|
||||||
msgstr "Verwenden Sie den Button „Herunterladen“ in der Oberfläche "
|
msgstr "Verwenden Sie den Button „Herunterladen“ in der Oberfläche "
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:202
|
#: core/templates/mail/html/screen_recording.html:210
|
||||||
#: core/templates/mail/text/screen_recording.txt:16
|
#: core/templates/mail/text/screen_recording.txt:15
|
||||||
msgid "Save the file to your preferred location"
|
msgid "Save the file to your preferred location"
|
||||||
msgstr "Speichern Sie die Datei an einem gewünschten Ort"
|
msgstr "Speichern Sie die Datei an einem gewünschten Ort"
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:213
|
#: core/templates/mail/html/screen_recording.html:221
|
||||||
#: core/templates/mail/text/screen_recording.txt:18
|
#: core/templates/mail/text/screen_recording.txt:17
|
||||||
msgid "Open"
|
msgid "Open"
|
||||||
msgstr "Öffnen"
|
msgstr "Öffnen"
|
||||||
|
|
||||||
#: core/templates/mail/html/screen_recording.html:222
|
#: core/templates/mail/html/screen_recording.html:230
|
||||||
#: core/templates/mail/text/screen_recording.txt:20
|
#: core/templates/mail/text/screen_recording.txt:19
|
||||||
#, python-format
|
#, python-format
|
||||||
msgid ""
|
msgid ""
|
||||||
" If you have any questions or need assistance, please contact our support "
|
" If you have any questions or need assistance, please contact our support "
|
||||||
@@ -678,33 +656,24 @@ msgstr ""
|
|||||||
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
|
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
|
||||||
"an unser Support-Team unter %(support_email)s. "
|
"an unser Support-Team unter %(support_email)s. "
|
||||||
|
|
||||||
#: core/templates/mail/text/invitation.txt:24
|
#: core/templates/mail/text/screen_recording.txt:13
|
||||||
#, 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
|
|
||||||
#, fuzzy, python-format
|
#, fuzzy, python-format
|
||||||
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
#| msgid "Click the \"<a href=\"%(link)s\">Open</a>\" link below "
|
||||||
msgid "Click the \"Open [%(link)s]\" link below "
|
msgid "Click the \"Open [%(link)s]\" link below "
|
||||||
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
|
msgstr "Klicken Sie auf den Link „<a href=\"%(link)s\">Öffnen</a>\" unten "
|
||||||
|
|
||||||
#: meet/settings.py:238
|
#: meet/settings.py:228
|
||||||
msgid "English"
|
msgid "English"
|
||||||
msgstr "Englisch"
|
msgstr "Englisch"
|
||||||
|
|
||||||
#: meet/settings.py:239
|
#: meet/settings.py:229
|
||||||
msgid "French"
|
msgid "French"
|
||||||
msgstr "Französisch"
|
msgstr "Französisch"
|
||||||
|
|
||||||
#: meet/settings.py:240
|
#: meet/settings.py:230
|
||||||
msgid "Dutch"
|
msgid "Dutch"
|
||||||
msgstr "Niederländisch"
|
msgstr "Niederländisch"
|
||||||
|
|
||||||
#: meet/settings.py:241
|
#: meet/settings.py:231
|
||||||
msgid "German"
|
msgid "German"
|
||||||
msgstr "Deutsch"
|
msgstr "Deutsch"
|
||||||
|
|
||||||
#: meet/settings.py:242
|
|
||||||
msgid "Spanish"
|
|
||||||
msgstr "Spanisch"
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user