Compare commits

..

3 Commits

Author SHA1 Message Date
Nathan Vasse 3adb5a897b (front) implement new ui of EffectsConfiguration
This new ui implement the new sketches and also enables the usage of
virtual background in a nice way.
2025-01-31 15:48:27 +01:00
Nathan Vasse 9295d3bc2c (front) handle virtual background in custom processor
BackgroundBlurCustomProcessor is renamed to BackgroundCustomProcessor in
order to reflect the fact that is now handles virtual backgrounds too.
BackgroundBlurFactory is also renamed to BackgroundProcessorFactory.
The processor serialization handling has also been updated in order
to support various options, also if persisted in local storage.
2025-01-31 15:48:27 +01:00
Nathan Vasse c8ef7b37f3 (front) add BackgroundVirtualTrackProcessorJsWrapper
This is a wrapper around track-processor-js virtual background
processor. This is needed in order to be used in a generic way
in our code between firefox processors.
2025-01-31 15:45:03 +01:00
582 changed files with 7534 additions and 66972 deletions
+52
View File
@@ -0,0 +1,52 @@
name: Deploy
on:
push:
tags:
- 'preprod'
- 'production'
jobs:
notify-argocd:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "meet,secrets"
-
name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Call argocd github webhook
run: |
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_WEBHOOK_URL
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_PRODUCTION_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_PRODUCTION_WEBHOOK_URL
start-test-on-preprod:
needs:
- notify-argocd
runs-on: ubuntu-latest
if: startsWith(github.event.ref, 'refs/tags/preprod')
steps:
-
name: Debug
run: |
echo "Start test when preprod is ready"
+83 -89
View File
@@ -19,9 +19,26 @@ jobs:
build-and-push-backend:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "meet,secrets"
-
name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Docker meta
id: meta
@@ -31,10 +48,7 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
@@ -52,12 +66,29 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-and-push-frontend-generic:
build-and-push-frontend:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "meet,secrets"
-
name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Docker meta
id: meta
@@ -67,10 +98,7 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Run trivy scan
uses: numerique-gouv/action-trivy-cache@main
@@ -89,49 +117,29 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-and-push-frontend-dinum:
runs-on: ubuntu-latest
steps:
-
name: Checkout repository
uses: actions/checkout@v4
-
name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: lasuite/meet-frontend-dinum
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
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: 'docker.io/lasuite/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
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-and-push-summary:
runs-on: ubuntu-latest
steps:
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "meet,secrets"
-
name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Docker meta
id: meta
@@ -141,10 +149,7 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
-
name: Build and push
uses: docker/build-push-action@v6
@@ -157,50 +162,39 @@ jobs:
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-and-push-agents:
runs-on: ubuntu-latest
steps:
-
name: Checkout repository
uses: actions/checkout@v4
-
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'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Build and push
uses: docker/build-push-action@v6
with:
context: ./src/agents
file: ./src/agents/Dockerfile
target: production
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
notify-argocd:
needs:
- build-and-push-frontend-generic
- build-and-push-frontend-dinum
- build-and-push-frontend
- build-and-push-backend
- build-and-push-summary
- build-and-push-agents
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
if: |
github.event_name != 'pull_request'
steps:
- uses: numerique-gouv/action-argocd-webhook-notification@main
id: notify
-
uses: actions/create-github-app-token@v1
id: app-token
with:
deployment_repo_path: "${{ secrets.DEPLOYMENT_REPO_URL }}"
argocd_webhook_secret: "${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET }}"
argocd_url: "${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }}"
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "meet,secrets"
-
name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
-
name: Call argocd github webhook
run: |
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_WEBHOOK_URL
+61 -115
View File
@@ -20,18 +20,14 @@ jobs:
- 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
build-mails:
@@ -85,8 +81,7 @@ jobs:
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
cache: "pip"
python-version: "3.10"
- name: Install development dependencies
run: pip install --user .[dev]
- name: Check code formatting with ruff
@@ -96,46 +91,6 @@ jobs:
- name: Lint code with pylint
run: ~/.local/bin/pylint meet demo core
lint-agents:
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/agents
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Python
uses: actions/setup-python@v5
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 .
lint-summary:
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/summary
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Python
uses: actions/setup-python@v5
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
@@ -155,16 +110,6 @@ jobs:
- 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
@@ -176,13 +121,9 @@ jobs:
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
REDIS_URL: redis://localhost:6379/1
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
LIVEKIT_API_SECRET: secret
LIVEKIT_API_KEY: devkey
AWS_S3_ENDPOINT_URL: http://localhost:9000
AWS_S3_ACCESS_KEY_ID: meet
AWS_S3_SECRET_ACCESS_KEY: password
steps:
- name: Checkout repository
@@ -200,38 +141,10 @@ jobs:
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@v5
with:
python-version: "3.13"
cache: "pip"
python-version: "3.10"
- name: Install development dependencies
run: pip install --user .[dev]
@@ -262,36 +175,69 @@ jobs:
- name: Check format
run: cd src/frontend/ && npm run check
lint-sdk:
i18n-crowdin:
runs-on: ubuntu-latest
defaults:
run:
working-directory: src/sdk/library
steps:
- name: Checkout repository
-
uses: actions/create-github-app-token@v1
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: "infrastructure,secrets"
-
name: Checkout repository
uses: actions/checkout@v4
with:
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
-
name: Load sops secrets
uses: rouja/actions-sops@main
with:
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
age-key: ${{ secrets.SOPS_PRIVATE }}
- name: Install gettext (required to make messages)
run: |
sudo apt-get update
sudo apt-get install -y gettext
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Install development dependencies
working-directory: src/backend
run: pip install --user .[dev]
- name: Generate the translation base file
run: ~/.local/bin/django-admin makemessages --keep-pot --all
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "18.x"
cache: "npm"
cache-dependency-path: src/frontend/package-lock.json
- name: Install dependencies
run: npm ci
run: cd src/frontend/ && npm ci
- name: Check linting
run: npm run lint
- name: Extract the frontend translation
run: make frontend-i18n-extract
- name: Check format
run: npm run check
build-sdk:
runs-on: ubuntu-latest
needs: lint-sdk
defaults:
run:
working-directory: src/sdk/library
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install dependencies
run: npm ci
- name: Build SDK
run: npm run build
- name: Upload files to Crowdin
uses: crowdin/github-action@v2
with:
config: crowdin/config.yml
upload_sources: true
upload_translations: true
download_translations: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
CROWDIN_BASE_PATH: ${{ github.workspace }}
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
+3 -1
View File
@@ -3,8 +3,10 @@ run-name: Release Chart
on:
push:
branches:
- 'main'
paths:
- src/helm/meet/**
- ./src/helm/meet/**
jobs:
release:
-3
View File
@@ -79,6 +79,3 @@ db.sqlite3
# Egress output
docker/livekit/out
# LiveKit CA configuration
docker/livekit/rootCA.pem
+3
View File
@@ -0,0 +1,3 @@
[submodule "secrets"]
path = secrets
url = ../secrets
-3
View File
@@ -1,4 +1,3 @@
# Changelog
All notable changes to this project will be documented in this file.
@@ -8,5 +7,3 @@ and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
- 🔧(backend) support `_FILE` for secret environment variables #566
-76
View File
@@ -1,76 +0,0 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, sex characteristics, gender identity and expression,
level of experience, education, socio-economic status, nationality, personal
appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at visio@numerique.gouv.fr. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [PostHog Code of Conduct](https://github.com/PostHog/posthog/blob/master/CODE_OF_CONDUCT.md), inspired from Contributor Covenant version 1.4,
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see
https://www.contributor-covenant.org/faq
-77
View File
@@ -1,77 +0,0 @@
# Contributing to the Project
Thank you for taking the time to contribute! Please follow these guidelines to ensure a smooth and productive workflow. 🚀🚀🚀
To get started with the project, please refer to the [README.md](https://github.com/suitenumerique/meet/blob/main/README.md) for detailed instructions.
Please also check out our [dev handbook](https://suitenumerique.gitbook.io/handbook) to learn our best practices.
## Creating an Issue
When creating an issue, please provide the following details:
1. **Title**: A concise and descriptive title for the issue.
2. **Description**: A detailed explanation of the issue, including relevant context or screenshots if applicable.
3. **Steps to Reproduce**: If the issue is a bug, include the steps needed to reproduce the problem.
4. **Expected vs. Actual Behavior**: Describe what you expected to happen and what actually happened.
5. **Labels**: Add appropriate labels to categorize the issue (e.g., bug, feature request, documentation).
## Commit Message Format
All commit messages must adhere to the following format:
`<gitmoji>(type) title description`
* <**gitmoji**>: Use a gitmoji to represent the purpose of the commit. For example, ✨ for adding a new feature or 🔥 for removing something, see the list here: <https://gitmoji.dev/>.
* **(type)**: Describe the type of change. Common types include `backend`, `frontend`, `CI`, `docker` etc...
* **title**: A short, descriptive title for the change, starting with a lowercase character.
* **description**: Include additional details about what was changed and why.
### Example Commit Message
```
✨(frontend) add user authentication logic
Implemented login and signup features, and integrated OAuth2 for social login.
```
## Changelog Update
Please add a line to the changelog describing your development. The changelog entry should include a brief summary of the changes, this helps in tracking changes effectively and keeping everyone informed. We usually include the title of the pull request, followed by the pull request ID to finish the log entry. The changelog line should be less than 80 characters in total.
### Example Changelog Message
```
## [Unreleased]
## Added
- ✨(frontend) add AI to the project #321
```
## Pull Requests
It is nice to add information about the purpose of the pull request to help reviewers understand the context and intent of the changes. If you can, add some pictures or a small video to show the changes.
### Don't forget to:
- check your commits
- check the linting: `make lint && make frontend-lint`
- check the tests: `make test`
- add a changelog entry
Once all the required tests have passed, you can request a review from the project maintainers.
## Code Style
Please maintain consistency in code style. Run any linting tools available to make sure the code is clean and follows the project's conventions.
## Tests
Make sure that all new features or fixes have corresponding tests. Run the test suite before pushing your changes to ensure that nothing is broken.
## Asking for Help
If you need any help while contributing, feel free to open a discussion or ask for guidance in the issue tracker. We are more than happy to assist!
Thank you for your contributions! 👍
+4 -4
View File
@@ -1,7 +1,7 @@
# Django Meet
# ---- base image to inherit from ----
FROM python:3.13.5-alpine3.21 AS base
FROM python:3.12.6-alpine3.20 AS base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip setuptools
@@ -62,11 +62,11 @@ FROM base AS core
ENV PYTHONUNBUFFERED=1
RUN apk --no-cache add \
cairo \
gdk-pixbuf \
RUN apk add \
gettext \
cairo \
libffi-dev \
gdk-pixbuf \
pango \
shared-mime-info
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2024-2025 DINUM/Etalab
Copyright (c) 2023 Direction Interministérielle du Numérique - Gouvernement Français
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2024-2025 DINUM/Etalab
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-79
View File
@@ -1,79 +0,0 @@
# LICENCE OUVERTE 2.0/OPEN LICENCE 2.0
## Réutilisation de l’« Information » sous cette licence
Le « Concédant » concède au « Réutilisateur » un droit non exclusif et gratuit de libre « Réutilisation » de l’« Information » objet de la présente licence, à des fins commerciales ou non, dans le monde entier et pour une durée illimitée, dans les conditions exprimées ci-dessous.
**Le « Réutilisateur » est libre de réutiliser l’« Information » :**
- de la communiquer, la reproduire, la copier ;
- de ladapter, la modifier, lextraire et la transformer, notamment pour créer des « Informations dérivées » ;
- de la diffuser, la redistribuer, la publier et la transmettre, de lexploiter à titre commercial, par exemple en la combinant avec dautres informations, ou en lincluant dans votre propre produit ou application.
**Sous réserve de :**
- mentionner la paternité de l’«Information» : sa source (a minima le nom du « Concédant ») et la date de la dernière mise à jour de l’« Information » réutilisée.
Le « Réutilisateur » peut notamment s acquitter de cette condition en indiquant ladresse (URL) renvoyant vers « lInformation » et assurant une mention effective de sa paternité.
**Par exemple :**
Dans le cas dune réutilisation de la base SIRENE de lINSEE, mentionner lURL du « Concédant » : www.insee.fr + la date de dernière mise à jour de lInformation réutilisée.
Cette mention de paternité ne doit ni conférer un caractère officiel à la « Réutilisation » de l’« Information », ni suggérer une quelconque reconnaissance ou caution par le « Concédant », ou par toute autre entité publique, du « Réutilisateur » ou de sa « Réutilisation ».
## Données à caractère personnel
L’« Information » mise à disposition peut contenir des « Données à caractère personnel » pouvant faire lobjet dune « Réutilisation ». Alors, le « Concédant » informe le « Réutilisateur » (par tous moyens) de leur présence, l « Information » peut être librement réutilisée, sans faire obstacle aux libertés accordées par la présente licence, à condition de respecter le cadre légal relatif à la protection des données à caractère personnel.
## Droits de propriété intellectuelle
Il est garanti au « Réutilisateur » que l « Information » ne contient pas de « Droits de propriété intellectuelle » appartenant à des tiers qui pourraient faire obstacle aux libertés qui lui sont accordées par la présente licence.
Les éventuels « Droits de propriété intellectuelle » détenus par le « Concédant » sur l « Information » ne font pas obstacle aux libertés qui sont accordées par la présente licence. Lorsque le « Concédant » détient des « Droits de propriété intellectuelle » » sur l « Information », il les cède au « Réutilisateur » de façon non exclusive, à titre gracieux, pour le monde entier, pour toute la durée des « Droits de propriété intellectuelle », et le « Réutilisateur » peut en faire tout usage conformément aux libertés et aux conditions définies par la présente licence.
## Responsabilité
L «Information» est mise à disposition telle que produite ou reçue, sans autre garantie expresse ou tacite qui nest pas prévue par la présente licence. Labsence de défauts ou derreurs éventuellement contenues dans l «Information», comme la fourniture continue de l « Information » nest pas garantie par le «Concédant». Il ne peut être tenu pour responsable de toute perte, préjudice ou dommage de quelque sorte causé à des tiers du fait de la « Réutilisation ».
Le « Réutilisateur » est seul responsable de la « Réutilisation » de l’« Information ».
La « Réutilisation » ne doit pas induire en erreur des tiers quant au contenu de l’« Information », sa source et sa date de mise à jour.
## Droit applicable
La présente licence est régie par le droit français.
### Compatibilité de la présente licence
Elle a été conçue pour être compatible avec toute licence libre qui exige _a minima_ la mention de paternité. Elle est notamment compatible avec la version antérieure de la présente licence ainsi quavec les licences « Open Government Licence » (OGL) du Royaume-Uni, « Creative Commons Attribution » (CC-BY) de Creative Commons et « Open Data Commons Attribution » (ODC-BY) de lOpen Knowledge Foundation.
## Définitions
Sont considérés, au sens de la présente licence comme :
- Le « **Concédant** » : toute personne concédant un droit de « Réutilisation » sur l’« Information » dans les libertés et les conditions prévues par la présente licence.
- L’« **Information** » :
- toute information publique figurant dans des documents communiqués ou publiés par une administration mentionnée au premier alinéa de larticle L.300-2 du CRPA ;
- toute information mise à disposition par toute personne selon les termes et conditions de la présente licence.
- La « **Réutilisation** » : lutilisation de l’« Information » à dautres fins que celles pour lesquelles elle a été produite ou reçue.
- Le « **Réutilisateur** » : toute personne qui réutilise les « Informations » conformément aux conditions de la présente licence.
- Des « **Données à caractère personnel** » : toute information se rapportant à une personne physique identifiée ou identifiable, pouvant être identifiée directement ou indirectement. Leur « Réutilisation » est subordonnée au respect du cadre juridique en vigueur.
- Une « **Information dérivée** » : toute nouvelle donnée ou information créées directement à partir de l’« Information » ou à partir dune combinaison de l « Information » et dautres données ou informations non soumises à cette licence.
- Les « **Droits de propriété intellectuelle** » : tous droits identifiés comme tels par le Code de la propriété intellectuelle (droit dauteur, droits voisins au droit dauteur, droit sui generis des producteurs de bases de données).
## À propos de cette licence
La présente licence a vocation à être utilisée par les administrations pour la réutilisation de leurs informations publiques. Elle peut également être utilisée par toute personne souhaitant mettre à disposition de l’« Information » dans les conditions définies par la présente licence
La France est dotée dun cadre juridique global visant à une diffusion spontanée par les administrations de leurs informations publiques afin den permettre la plus large réutilisation.
Le droit de la « Réutilisation » de l’« Information » des administrations est régi par le code des relations entre le public et ladministration (CRPA) et, le cas échéant, le code du patrimoine (livre II relatif aux archives).
Cette licence facilite la réutilisation libre et gratuite des informations publiques et figure parmi les licences qui peuvent être utilisées par ladministration en vertu du décret pris en application de larticle L.323-2 du CRPA.
Etalab est la mission chargée, sous lautorité du Premier ministre, douvrir le plus grand nombre de données publiques des administrations de l’État et de ses établissements publics. Elle a réalisé la Licence Ouverte pour faciliter la réutilisation libre et gratuite de ces informations publiques, telles que définies par larticle L321-1 du CRPA.
Cette licence est une version 2.0 de la Licence Ouverte.
Etalab se réserve la faculté de proposer de nouvelles versions de la Licence Ouverte. Cependant, les « Réutilisateurs » pourront continuer à réutiliser les informations disponibles sous cette licence sils le souhaitent.
+4 -118
View File
@@ -45,7 +45,6 @@ COMPOSE_RUN = $(COMPOSE) run --rm
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
WAIT_MINIO = @$(COMPOSE_RUN) dockerize -wait tcp://minio:9000 -timeout 60s
# -- Backend
MANAGE = $(COMPOSE_RUN_APP) python manage.py
@@ -54,21 +53,6 @@ MAIL_NPM = $(COMPOSE_RUN) -w /app/src/mail node npm
# -- Frontend
PATH_FRONT = ./src/frontend
# -- MinIO / Webhook
MINIO_ALIAS ?= meet
MINIO_ENDPOINT ?= http://127.0.0.1:9000
MINIO_ACCESS_KEY ?= meet
MINIO_SECRET_KEY ?= password
MINIO_BUCKET ?= meet-media-storage
MINIO_WEBHOOK_NAME ?= recording
MINIO_WEBHOOK_ENDPOINT ?= http://app-dev:8000/api/v1.0/recordings/storage-hook/
MINIO_QUEUE_DIR ?= /data/minio/events
MINIO_QUEUE_LIMIT ?= 100000
STORAGE_EVENT_TOKEN ?= password
# ==============================================================================
# RULES
@@ -87,8 +71,7 @@ create-env-files: \
env.d/development/common \
env.d/development/crowdin \
env.d/development/postgresql \
env.d/development/kc_postgresql \
env.d/development/summary
env.d/development/kc_postgresql
.PHONY: create-env-files
bootstrap: ## Prepare Docker images for the project
@@ -105,20 +88,10 @@ bootstrap: \
.PHONY: bootstrap
# -- Docker/compose
build: ## build the project containers
@$(MAKE) build-backend
@$(MAKE) build-frontend
build: ## build the app-dev container
@$(COMPOSE) build app-dev --no-cache
.PHONY: build
build-backend: ## build the app-dev container
@$(COMPOSE) build app-dev
.PHONY: build-backend
build-frontend: ## build the frontend container
@$(COMPOSE) build frontend
.PHONY: build-frontend
down: ## stop and remove containers, networks, images, and volumes
@$(COMPOSE) down
.PHONY: down
@@ -127,22 +100,10 @@ logs: ## display app-dev logs (follow mode)
@$(COMPOSE) logs -f app-dev
.PHONY: logs
run-backend: ## start only the backend application and all needed services
run: ## start the wsgi (production) and development server
@$(COMPOSE) up --force-recreate -d celery-dev
@echo "Wait for postgresql to be up..."
@$(WAIT_DB)
.PHONY: run-backend
run-summary: ## start only the summary application and all needed services
@$(COMPOSE) up --force-recreate -d celery-summary-transcribe
@$(COMPOSE) up --force-recreate -d celery-summary-summarize
.PHONY: run-summary
run:
run: ## start the wsgi (production) and development server
@$(MAKE) run-backend
@$(MAKE) run-summary
@$(COMPOSE) up --force-recreate -d frontend
.PHONY: run
status: ## an alias for "docker compose ps"
@@ -153,74 +114,6 @@ stop: ## stop the development server using Docker
@$(COMPOSE) stop
.PHONY: stop
# -- MinIO webhook (configuration & events)
minio-wait: ## wait for minio to be ready
@echo "$(BOLD)Waiting for MinIO$(RESET)"
$(WAIT_MINIO)
.PHONY: minio-wait
minio-queue-dir: minio-wait ## ensure queue dir exists in MinIO container
@echo "$(BOLD)Ensuring MinIO queue dir$(RESET)"
@$(COMPOSE) exec minio sh -lc 'mkdir -p $(MINIO_QUEUE_DIR) && chmod -R 777 $(dir $(MINIO_QUEUE_DIR)) || true'
.PHONY: minio-queue-dir
minio-alias: minio-wait ## set mc alias to MinIO
@echo "$(BOLD)Setting mc alias $(MINIO_ALIAS) -> $(MINIO_ENDPOINT)$(RESET)"
@mc alias set $(MINIO_ALIAS) $(MINIO_ENDPOINT) $(MINIO_ACCESS_KEY) $(MINIO_SECRET_KEY) >/dev/null
.PHONY: minio-alias
minio-webhook-config: minio-alias minio-queue-dir ## configure webhook on MinIO
@echo "$(BOLD)Configuring MinIO webhook $(MINIO_WEBHOOK_NAME)$(RESET)"
@mc admin config set $(MINIO_ALIAS) notify_webhook:$(MINIO_WEBHOOK_NAME) \
endpoint="$(MINIO_WEBHOOK_ENDPOINT)" \
auth_token="Bearer $(STORAGE_EVENT_TOKEN)" \
queue_dir="$(MINIO_QUEUE_DIR)" \
queue_limit="$(MINIO_QUEUE_LIMIT)"
.PHONY: minio-webhook-config
minio-restart: minio-alias ## restart MinIO after config change
@echo "$(BOLD)Restarting MinIO service$(RESET)"
@mc admin service restart $(MINIO_ALIAS)
.PHONY: minio-restart
minio-events-reset: minio-alias ## remove all bucket notifications
@echo "$(BOLD)Removing existing bucket events on $(MINIO_BUCKET)$(RESET)"
@mc event remove --force $(MINIO_ALIAS)/$(MINIO_BUCKET) >/dev/null || true
.PHONY: minio-events-reset
minio-event-add: minio-alias ## add put event -> webhook
@echo "$(BOLD)Adding put event -> webhook $(MINIO_WEBHOOK_NAME)$(RESET)"
@mc event add $(MINIO_ALIAS)/$(MINIO_BUCKET) arn:minio:sqs::$(MINIO_WEBHOOK_NAME):webhook --event put
@mc event list $(MINIO_ALIAS)/$(MINIO_BUCKET)
.PHONY: minio-event-add
minio-webhook-setup: ## full setup: alias, config, restart, reset events, add event
minio-webhook-setup: \
minio-webhook-config \
minio-restart \
minio-events-reset \
minio-event-add
.PHONY: minio-webhook-setup
# -- Front
frontend-development-install: ## install the frontend locally
cd $(PATH_FRONT) && npm i
.PHONY: frontend-development-install
frontend-lint: ## run the frontend linter
cd $(PATH_FRONT) && npm run lint
.PHONY: frontend-lint
frontend-format: ## run the frontend format
cd $(PATH_FRONT) && npm run format
.PHONY: frontend-format
run-frontend-development: ## run the frontend in development mode
@$(COMPOSE) stop frontend
cd $(PATH_FRONT) && npm run dev
.PHONY: run-frontend-development
# -- Backend
demo: ## flush db then create a demo for load testing purpose
@@ -318,9 +211,6 @@ env.d/development/postgresql:
env.d/development/kc_postgresql:
cp -n env.d/development/kc_postgresql.dist env.d/development/kc_postgresql
env.d/development/summary:
cp -n env.d/development/summary.dist env.d/development/summary
# -- Internationalization
env.d/development/crowdin:
@@ -422,7 +312,3 @@ start-tilt: ## start the kubernetes cluster using kind
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
DEV_ENV=dev-keycloak tilt up -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
start-tilt-dinum: ## start the kubernetes cluster using kind, without Pro Connect for authentication, but with DINUM styles
DEV_ENV=dev-dinum tilt up -f ./bin/Tiltfile
.PHONY: build-k8s-cluster
+135 -98
View File
@@ -1,120 +1,157 @@
<p align="center">
<img alt="meet logo" src="./docs/assets/banner-meet-fr.png" maxWidth="100%">
</p>
# Meet
Meet is a simple video and phone conferencing tool, powered by [LiveKit](https://livekit.io/).
Meet is built on top of [Django Rest
Framework](https://www.django-rest-framework.org/) and [Vite.js](https://vitejs.dev/).
## Getting started
### Prerequisite
#### Docker
Make sure you have a recent version of Docker and [Docker
Compose](https://docs.docker.com/compose/install) installed on your laptop:
```bash
$ docker -v
Docker version 20.10.2, build 2291f61
$ docker compose -v
docker compose version 1.27.4, build 40524192
```
> ⚠️ You may need to run the following commands with `sudo` but this can be
> avoided by assigning your user to the `docker` group.
#### LiveKit CLI
Install LiveKit CLI, which provides utilities for interacting with the LiveKit ecosystem (including the server, egress, and more), please follow the instructions available in the [official repository](https://github.com/livekit/livekit-cli).
### Project bootstrap
The easiest way to start working on the project is to use GNU Make:
```bash
$ make bootstrap FLUSH_ARGS='--no-input'
```
Then you can access to the project in development mode by going to http://localhost:3000.
You will be prompted to log in, the default credentials are:
```bash
username: meet
password: meet
```
---
This command builds the `app` container, installs dependencies, performs
database migrations and compile translations. It's a good idea to use this
command each time you are pulling code from the project repository to avoid
dependency-related or migration-related issues.
Your Docker services should now be up and running 🎉
[FIXME] Explain how to run the frontend project.
### Configure LiveKit CLI
For the optimal DX, create a default project named `meet` to use with `livekit-cli` commands:
```bash
$ livekit-cli project add
URL: http://localhost:7880
API Key: devkey
API Secret: secret
Give it a name for later reference: meet
? Make this project default?? [y/N] y
```
Thus, you won't need to pass the project API Key and API Secret for each command.
<p align="center">
<a href="https://github.com/suitenumerique/meet/stargazers/">
<img src="https://img.shields.io/github/stars/suitenumerique/meet" alt="">
</a>
<a href='http://makeapullrequest.com'><img alt='PRs Welcome' src='https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=shields'/></a>
<img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/suitenumerique/meet"/>
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/meet"/>
<a href="https://github.com/suitenumerique/meet/blob/main/LICENSE">
<img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/meet"/>
</a>
</p>
### Adding content
<p align="center">
<a href="https://livekit.io/">LiveKit</a> - <a href="https://matrix.to/#/#meet-official:matrix.org">Chat with us</a> - <a href="https://github.com/orgs/suitenumerique/projects/3/views/2">Roadmap</a> - <a href="https://github.com/suitenumerique/meet/blob/main/CHANGELOG.md">Changelog</a> - <a href="https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md">Bug reports</a>
</p>
You can create a basic demo site by running:
<p align="center">
<a href="https://visio.numerique.gouv.fr/">
<img src="https://github.com/user-attachments/assets/09c1faa1-de88-4848-af3a-6fbe793999bf" alt="La Suite Meet Demonstration">
</a>
</p>
```bash
$ make demo
```
## La Suite Meet: Simple Video Conferencing
Finally, you can check all available Make rules using:
Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level performance with high-quality video and audio. No installation required—simply join calls directly from your browser. Check out LiveKit's impressive optimizations in their [blog post](https://blog.livekit.io/livekit-one-dot-zero/).
### Features
- Optimized for stability in large meetings (+100 p.)
- Support for multiple screen sharing streams
- Non-persistent, secure chat
- End-to-end encryption (coming soon)
- Meeting recording
- Meeting transcription (currently in beta)
- Telephony integration
- Secure participation with robust authentication and access control
- LiveKit Advances features including :
- speaker detection
- simulcast
- end-to-end optimizations
- selective subscription
- SVC codecs (VP9, AV1)
```bash
$ make help
```
### Django admin
You can access the Django admin site at
[http://localhost:8071/admin](http://localhost:8071/admin).
You first need to create a superuser account:
```bash
$ make superuser
```
### Run application on local Kubernetes
The application is deployed across staging, preprod, and production environments using Kubernetes (K8s).
Reproducing environment conditions locally is crucial for developing new features or debugging issues.
This is facilitated by [Tilt](https://tilt.dev/) ("Kubernetes for Prod, Tilt for Dev"). Tilt enables smart rebuilds and live updates for services running locally in Kubernetes. We defined our services in a Tiltfile located at `bin/Tiltfile`.
La Suite Meet is fully self-hostable and released under the MIT License, ensuring complete control and flexibility. It's simple to [get started](https://visio.numerique.gouv.fr/) or [request a demo](mailto:visio@numerique.gouv.fr).
#### Getting Started
Were continuously adding new features to enhance your experience, with the latest updates coming soon!
Make sure you have installed:
- kubectl
- helm
- helmfile
- tilt
To build and start the Kubernetes cluster using Kind:
```shell
$ make build-k8s-cluster
```
## Table of Contents
Once the Kubernetes cluster is ready, start the application stack locally:
```shell
$ make start-tilt
or
$ make start-tilt-keycloak # start stack without Pro Connect, use keycloak
```
These commands set up and run your application environment using Tilt for local Kubernetes development.
- [Get started](#get-started)
- [Docs](#docs)
- [Contributing](#contributing)
- [Philosophy](#philosophy)
- [Open source](#open-source)
You can monitor Tilt's at `http://localhost:10350/`. After Tilt actions finish, you can access the app at `https://meet.127.0.0.1.nip.io/`.
#### Debugging frontend
## Get started
Tilt deploys the `meet-dev` for the frontend by default, to benefit from Vite.js hot reloading while developing.
To troubleshoot production issues, please modify the Tiltfile, switch frontend's target to `frontend-production`:
### La Suite Meet Cloud (Recommended)
Sign up for La Suite Meet Cloud, designed for french public servants. Hosted on SecNumCloud-compliant providers and accessible via government SSO, [ProConnect](https://www.proconnect.gouv.fr/). The easiest way to try our product. Reach out if your entity isn't connected yet to our sso.
```yaml
...
### Open-source deployment (Advanced)
Deploy La Suite Meet on your own infrastructure using [our self-hosting guide](https://github.com/suitenumerique/meet/blob/main/docs/installation.md). Our open-source deployment is optimized for Kubernetes, and we're working on supporting additional deployment options. Keycloak integration and any SSO are supported. We offer customer support for open-source setups—just reach out for assistance.
## Docs
We're currently working on both technical and user documentation for La Suite Meet. In the meantime, many of the essential aspects are already well covered by the [LiveKit documentation](https://docs.livekit.io/home/) and their [self-hosting guide](https://docs.livekit.io/home/self-hosting/deployment/). Stay tuned for more updates!
docker_build(
'localhost:5001/meet-frontend:latest',
context='..',
dockerfile='../src/frontend/Dockerfile',
only=['./src/frontend', './docker', './.dockerignore'],
target='frontend-production', # Update this line when needed
live_update=[
sync('../src/frontend', '/home/frontend'),
]
)
...
```
## Contributing
We <3 contributions of any kind, big and small:
- Vote on features or get early access to beta functionality in our [roadmap](https://github.com/orgs/suitenumerique/projects/3/views/2)
- Open a PR (see our instructions on [developing La Suite Meet locally](https://github.com/suitenumerique/meet/blob/main/docs/developping_locally.md))
- Submit a [feature request](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=enhancement&template=Feature_request.md) or [bug report](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md)
## Philosophy
Were relentlessly focused on building the best open-source video conferencing product—La Suite Meet. Growth comes from creating something people truly need, not just from chasing metrics.
Our users come first. Were committed to making La Suite Meet as accessible and easy to use as proprietary solutions, ensuring it meets the highest standards.
Most of the heavy engineering is handled by the incredible LiveKit team, allowing us to focus on delivering a top-tier product. We follow extreme programming practices, favoring pair programming and quick, iterative releases. Challenge our tech and architecture—simplicity is always our top priority.
## Open-source
Gov 🇫🇷 supports open source! This project is available under [MIT license](https://github.com/suitenumerique/meet/blob/0cc2a7b7b4f4821e2c4d9d790efa739622bb6601/LICENSE).
All features we develop will always remain open-source, and we are committed to contributing back to the LiveKit community whenever feasible.
To learn more, don't hesitate to [reach out](mailto:visio@numerique.gouv.fr).
### Help us!
Come help us make La Suite Meet even better. We're growing fast and [would love some help](mailto:visio@numerique.gouv.fr).
## Contributors 🧞
<a href="https://github.com/suitenumerique/meet/graphs/contributors">
<img src="https://contrib.rocks/image?repo=suitenumerique/meet" />
</a>
## Credits
We're using the awesome [LiveKit](https://livekit.io/) implementation. We're also thankful to the teams behind [Django Rest Framework](https://www.django-rest-framework.org/), [Vite.js](https://vite.dev/), and [React Aria](https://github.com/adobe/react-spectrum) — Thanks for your amazing work!
This project is tested with BrowserStack.
This project is intended to be community-driven, so please, do not hesitate to
get in touch if you have any question related to our implementation or design
decisions.
## License
Code in this repository is published under the MIT license by DINUM (Direction interministériel du numérique).
Documentation (in the docs/) directory is released under the [Etalab-2.0 license](https://spdx.org/licenses/etalab-2.0.html).
This work is released under the MIT License (see [LICENSE](./LICENSE)).
-23
View File
@@ -1,23 +0,0 @@
# Security Policy
## Reporting a Vulnerability
Security is very important to us.
If you have any issue regarding security, please disclose the information responsibly submiting [this form](https://vdp.numerique.gouv.fr/p/Send-a-report?lang=en) and not by creating an issue on the repository. You can also email us at visio@numerique.gouv.fr
We appreciate your effort to make Visio more secure.
## Vulnerability disclosure policy
Working with security issues in an open source project can be challenging, as we are required to disclose potential problems that could be exploited by attackers. With this in mind, our security fix policy is as follows:
1. The Maintainers team will handle the fix as usual (Pull Request,
release).
2. In the release notes, we will include the identification numbers from the
GitHub Advisory Database (GHSA) and, if applicable, the Common Vulnerabilities
and Exposures (CVE) identifier for the vulnerability.
3. Once this grace period has passed, we will publish the vulnerability.
By adhering to this security policy, we aim to address security concerns
effectively and responsibly in our open source software project.
+3 -67
View File
@@ -1,19 +1,6 @@
load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
load('ext://namespace', 'namespace_create', 'namespace_inject')
namespace_create('meet')
DEV_ENV = os.getenv('DEV_ENV', 'dev')
if DEV_ENV == 'dev-dinum':
update_settings(suppress_unused_image_warnings=["localhost:5001/meet-frontend-generic:latest"])
if DEV_ENV == 'dev-keycloak':
update_settings(suppress_unused_image_warnings=["localhost:5001/meet-frontend-dinum:latest"])
def clean_old_images(image_name):
local('docker images -q %s | tail -n +2 | xargs -r docker rmi' % image_name)
docker_build(
'localhost:5001/meet-backend:latest',
context='..',
@@ -28,22 +15,9 @@ docker_build(
)
]
)
clean_old_images('localhost:5001/meet-backend')
docker_build(
'localhost:5001/meet-frontend-dinum:latest',
context='..',
dockerfile='../docker/dinum-frontend/Dockerfile',
only=['./src/frontend', './docker', './.dockerignore'],
target = 'frontend-production',
live_update=[
sync('../src/frontend', '/home/frontend'),
]
)
clean_old_images('localhost:5001/meet-frontend-dinum')
docker_build(
'localhost:5001/meet-frontend-generic:latest',
'localhost:5001/meet-frontend:latest',
context='..',
dockerfile='../src/frontend/Dockerfile',
only=['./src/frontend', './docker', './.dockerignore'],
@@ -52,58 +26,20 @@ docker_build(
sync('../src/frontend', '/home/frontend'),
]
)
clean_old_images('localhost:5001/meet-frontend-generic')
docker_build(
'localhost:5001/meet-summary:latest',
context='../src/summary',
dockerfile='../src/summary/Dockerfile',
only=['.'],
only=['.', '../../docker', '../../.dockerignore'],
target = 'production',
live_update=[
sync('../src/summary', '/app'),
sync('../src/summary', '/home/summary'),
]
)
clean_old_images('localhost:5001/meet-summary')
docker_build(
'localhost:5001/meet-agents:latest',
context='../src/agents',
dockerfile='../src/agents/Dockerfile',
only=['.'],
target = 'production',
live_update=[
sync('../src/agents', '/app'),
]
)
clean_old_images('localhost:5001/meet-agents')
# Copy the mkcert root CA certificate to our Docker build context
# This is necessary because we need to inject the certificate into our LiveKit container
local_resource(
'copy-root-ca',
cmd='cp -f "$(mkcert -CAROOT)/rootCA.pem" ../docker/livekit/rootCA.pem',
deps=[], # No dependencies needed
)
# Build a custom LiveKit Docker image that includes our root CA certificate
# This allows LiveKit to trust our local development certificates
docker_build(
'localhost:5001/meet-livekit:latest',
context='../docker/livekit',
dockerfile='./../docker/livekit/Dockerfile',
only=['.'],
)
clean_old_images('localhost:5001/meet-livekit')
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .'))
k8s_resource('minio-bucket', resource_deps=['minio'])
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
k8s_resource('meet-backend-migrate', resource_deps=['meet-backend'])
k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
k8s_resource('keycloak', resource_deps=['kc-postgresql'])
k8s_resource('meet-backend-createsuperuser', resource_deps=['meet-backend-migrate'])
migration = '''
set -eu
# get k8s pod name from tilt resource name
-110
View File
@@ -15,37 +15,6 @@ services:
ports:
- "1081:1080"
minio:
user: ${DOCKER_USER:-1000}
image: minio/minio
environment:
- MINIO_ROOT_USER=meet
- MINIO_ROOT_PASSWORD=password
ports:
- '9000:9000'
- '9001:9001'
healthcheck:
test: [ "CMD", "mc", "ready", "local" ]
interval: 1s
timeout: 20s
retries: 300
entrypoint: ""
command: minio server --console-address :9001 /data
volumes:
- ./data/media:/data
createbuckets:
image: minio/mc
depends_on:
minio:
condition: service_healthy
restart: true
entrypoint: >
sh -c "
/usr/bin/mc alias set meet http://minio:9000 meet password && \
/usr/bin/mc mb meet/meet-media-storage && \
exit 0;"
app-dev:
build:
context: .
@@ -71,9 +40,6 @@ services:
- redis
- nginx
- livekit
- createbuckets
extra_hosts:
- "127.0.0.1.nip.io:host-gateway"
celery-dev:
user: ${DOCKER_USER:-1000}
@@ -107,7 +73,6 @@ services:
- postgresql
- redis
- livekit
- minio
celery:
user: ${DOCKER_USER:-1000}
@@ -130,22 +95,8 @@ services:
depends_on:
- keycloak
frontend:
user: "${DOCKER_USER:-1000}"
build:
context: .
dockerfile: ./src/frontend/Dockerfile
target: frontend-production
args:
VITE_API_BASE_URL: "http://localhost:8071"
VITE_APP_TITLE: "LaSuite Meet"
image: meet:frontend-development
ports:
- "3000:8080"
dockerize:
image: jwilder/dockerize
platform: linux/x86_64
crowdin:
image: crowdin/cli:4.0.0
@@ -221,64 +172,3 @@ services:
- ./docker/livekit/out:/out
depends_on:
- redis
redis-summary:
image: redis
ports:
- "6379:6379"
app-summary-dev:
build:
context: src/summary
target: development
args:
DOCKER_USER: ${DOCKER_USER:-1000}
user: ${DOCKER_USER:-1000}
env_file:
- env.d/development/summary
ports:
- "8001:8000"
volumes:
- ./src/summary:/app
depends_on:
- redis-summary
celery-summary-transcribe:
container_name: celery-summary-transcribe
build:
context: ./src/summary
dockerfile: Dockerfile
target: production
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q transcribe-queue
env_file:
- env.d/development/summary
volumes:
- ./src/summary:/app
depends_on:
- redis-summary
- app-summary-dev
- minio
develop:
watch:
- action: rebuild
path: ./src/summary
celery-summary-summarize:
container_name: celery-summary-summarize
build:
context: ./src/summary
dockerfile: Dockerfile
target: production
command: celery -A summary.core.celery_worker worker --pool=solo --loglevel=debug -Q summarize-queue
env_file:
- env.d/development/summary
volumes:
- ./src/summary:/app
depends_on:
- redis-summary
- app-summary-dev
- minio
develop:
watch:
- action: rebuild
path: ./src/summary
-62
View File
@@ -1,62 +0,0 @@
# ---- Front-end image ----
FROM node:20-alpine AS frontend-deps
WORKDIR /home/frontend/
COPY ./src/frontend/package.json ./package.json
COPY ./src/frontend/package-lock.json ./package-lock.json
RUN npm ci
COPY .dockerignore ./.dockerignore
COPY ./src/frontend/ .
# ---- Front-end builder image ----
FROM frontend-deps AS meet-builder
WORKDIR /home/frontend
ENV VITE_APP_TITLE="Visio"
ENV VITE_BUILD_SOURCEMAP="true"
RUN npm run build
# Inject PostHog sourcemap metadata into the built assets
# This metadata is essential for correctly mapping errors to source maps in production
RUN set -e && \
npx @posthog/cli sourcemap inject --directory ./dist/assets
COPY ./docker/dinum-frontend/dinum-styles.css \
./dist/assets/
COPY ./docker/dinum-frontend/logo.svg \
./dist/assets/logo.svg
COPY ./docker/dinum-frontend/assets/ \
./dist/assets/
COPY ./docker/dinum-frontend/fonts/ \
./dist/assets/fonts/
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
USER root
RUN apk update && apk upgrade libssl3 libcrypto3 libxml2>=2.12.7-r2 libxslt>=1.1.39-r2
USER nginx
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
COPY --from=meet-builder \
/home/frontend/dist \
/usr/share/nginx/html
COPY ./src/frontend/default.conf /etc/nginx/conf.d
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
CMD ["nginx", "-g", "daemon off;"]
@@ -1,61 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="38.194309mm"
height="3.4877367mm"
viewBox="0 0 38.194309 3.4877367"
version="1.1"
id="svg1"
sodipodi:docname="gouvernement.svg"
inkscape:version="1.4.1 (93de688d07, 2025-03-30)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="mm"
inkscape:zoom="6.1918409"
inkscape:cx="79.620909"
inkscape:cy="44.332534"
inkscape:window-width="1901"
inkscape:window-height="1037"
inkscape:window-x="5"
inkscape:window-y="5"
inkscape:window-maximized="0"
inkscape:current-layer="layer1" />
<defs
id="defs1">
<rect
x="111.07374"
y="333.22122"
width="541.4845"
height="297.51895"
id="rect2" />
<rect
x="158.10136"
y="358.3631"
width="425.55618"
height="204.21426"
id="rect1" />
</defs>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-53.656101,-100.08408)">
<path
d="m 55.435527,103.08144 c -0.72898,0 -1.249045,-0.56007 -1.249045,-1.25349 0,-0.69342 0.502285,-1.25349 1.204595,-1.25349 0.41783,0 0.760095,0.20447 0.96901,0.50673 l 0.333375,-0.25781 c -0.28448,-0.38227 -0.742315,-0.64008 -1.302385,-0.64008 -0.973455,0 -1.63576,0.75565 -1.63576,1.64465 0,0.889 0.680085,1.64465 1.68021,1.64465 0.582295,0 1.05791,-0.24892 1.346835,-0.64008 v -1.21349 h -1.31572 v 0.36005 h 0.89789 v 0.70231 c -0.217805,0.24447 -0.54229,0.40005 -0.929005,0.40005 z m 3.569334,-2.89814 c -0.973455,0 -1.63576,0.75565 -1.63576,1.64465 0,0.889 0.662305,1.64465 1.63576,1.64465 0.96901,0 1.631315,-0.75565 1.631315,-1.64465 0,-0.889 -0.662305,-1.64465 -1.631315,-1.64465 z m 0,2.89814 c -0.70231,0 -1.204595,-0.56007 -1.204595,-1.25349 0,-0.69342 0.502285,-1.25349 1.204595,-1.25349 0.697865,0 1.20015,0.56007 1.20015,1.25349 0,0.69342 -0.502285,1.25349 -1.20015,1.25349 z m 4.276088,-0.84455 c 0,0.53784 -0.31115,0.84455 -0.786765,0.84455 -0.48006,0 -0.79121,-0.30671 -0.79121,-0.84455 v -1.96469 h -0.41783 v 1.93802 c 0,0.8001 0.48006,1.26238 1.20904,1.26238 0.72898,0 1.204595,-0.46228 1.204595,-1.26238 v -1.93802 h -0.41783 z m 0.942341,-1.96469 1.2446,3.1115 h 0.55118 l 1.2446,-3.1115 h -0.4445 l -1.07569,2.68922 -1.07569,-2.68922 z m 3.653795,3.1115 h 1.67132 v -0.3556 h -1.25349 v -1.05347 h 1.07569 v -0.35115 h -1.07569 v -0.99568 h 1.25349 v -0.3556 h -1.67132 z m 2.502533,0 h 0.41783 v -1.36462 h 0.40894 c 0.03111,0 0.06667,0 0.09779,-0.004 l 0.902335,1.36906 h 0.493395 l -1.00457,-1.44907 c 0.333375,-0.13335 0.52451,-0.41339 0.52451,-0.78677 0,-0.5334 -0.386715,-0.87566 -1.01346,-0.87566 h -0.82677 z m 0.84455,-2.7559 c 0.3556,0 0.564515,0.19558 0.564515,0.51117 0,0.33338 -0.208915,0.52451 -0.564515,0.52451 h -0.42672 v -1.03568 z m 2.004699,2.7559 h 0.41783 v -2.60033 l 1.76022,2.60033 h 0.546735 v -3.1115 h -0.41783 v 2.60032 l -1.76022,-2.60032 h -0.546735 z m 3.71158,0 h 1.67132 v -0.3556 h -1.25349 v -1.05347 h 1.07569 v -0.35115 h -1.07569 v -0.99568 h 1.25349 v -0.3556 h -1.67132 z m 2.502533,0 h 0.41783 v -2.55143 l 0.90678,1.48463 h 0.32004 l 0.90678,-1.48463 v 2.55143 h 0.41783 v -3.1115 h -0.5334 l -0.95123,1.56908 -0.95123,-1.56908 h -0.5334 z m 3.951605,0 h 1.67132 v -0.3556 h -1.25349 v -1.05347 h 1.07569 v -0.35115 h -1.07569 v -0.99568 h 1.25349 v -0.3556 h -1.67132 z m 2.502535,0 h 0.41783 v -2.60033 l 1.76022,2.60033 h 0.546735 v -3.1115 h -0.41783 v 2.60032 l -1.76022,-2.60032 H 85.89712 Z m 3.373758,-2.72923 h 1.03124 v 2.72923 h 0.41783 v -2.72923 h 1.03124 v -0.38227 h -2.48031 z"
id="text3"
style="font-size:4.445px;line-height:0.661464px;font-family:Marianne;-inkscape-font-specification:'Marianne, Normal';letter-spacing:0px;word-spacing:0px;display:inline;stroke:#000000;stroke-width:0.198437"
aria-label="GOUVERNEMENT" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.3 KiB

-70
View File
@@ -1,70 +0,0 @@
:root {
--fonts-sans: 'Marianne', ui-sans-serif, system-ui, sans-serif;
}
.Header-beforeLogo {
display: block;
}
.Header-beforeLogo::before {
content: '';
display: block;
background-image: url(/assets/marianne.svg);
background-position: 0 -0.046875rem;
background-size: 2.0625rem 0.84375rem;
height: 0.75rem;
margin-bottom: 0.1rem;
width: 2.0625rem;
}
.Header-beforeLogo::after {
content: '';
display: block;
background-image: url(/assets/gouvernement.svg), url(/assets/devise.svg);
background-repeat: no-repeat, no-repeat;
background-size: 108.8px 10px, 40px 29px;
background-position: 0 3px, 0 18.9px;
width: 108.8px;
height: 48px;
margin-top: 0.25rem;
}
@font-face {
font-family: 'Marianne';
src: url('/assets/fonts/Marianne-Regular-subset.woff2') format('woff2');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Marianne';
src: url('/assets/fonts/Marianne-Regular_Italic-subset.woff2') format('woff2');
font-weight: 400;
font-style: italic;
font-display: swap;
}
@font-face {
font-family: 'Marianne';
src: url('/assets/fonts/Marianne-Medium-subset.woff2') format('woff2');
font-weight: 500;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Marianne';
src: url('/assets/fonts/Marianne-Bold-subset.woff2') format('woff2');
font-weight: 700;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Marianne';
src: url('/assets/fonts/Marianne-ExtraBold-subset.woff2') format('woff2');
font-weight: 800;
font-style: normal;
font-display: swap;
}
-6
View File
@@ -1,6 +0,0 @@
FROM livekit/livekit-server:v1.9.0
# We inject the nip.io certificate manually because the livekit chart doesn't support volume mounting
COPY rootCA.pem /etc/ssl/certs/
ENTRYPOINT ["/livekit-server"]
Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

-145
View File
@@ -1,145 +0,0 @@
# Getting Started
Before setting up, let's review Visio's architecture.
Visio consists of four main components that run simultaneously:
- React frontend, built with Vite.js
- Django server
- LiveKit server
- FastAPI server (optional, required for AI beta features)
These components rely on a few key services:
- PostgreSQL for storing data (users, rooms, recordings)
- Redis for caching and inter-service communication
- MinIO for storing files (room recordings)
- Celery workers for meeting transcript (optional, required for AI beta features)
We provide two stack options for getting Visio up and running for development:
- Docker Compose stack (recommended for most users)
- Kubernetes stack powered by Tilt (Advanced)
We recommend starting with the **Docker Compose** option for simplicity. However, if you're comfortable with running Kubernetes locally, the advanced option mirrors the production environment and provides most of the tools required for development (e.g., hot reloading).
These instructions are for macOS or Ubuntu. For other distros, adjust as needed.
If any steps are outdated, please let us know!
---
We also provide **GNU make utilities**. To view all available Make rules, run:
```shellscript
$ make help
```
---
## Need Help?
If you need any assistance or have questions while getting started, feel free to reach out to @lebaudantoine anytime! Antoine is available to help you onboard and guide you through the process. Chat with him @antoine.lebaud:matrix.org, or from the [support hotline](https://go.crisp.chat/chat/embed/?website_id=58ea6697-8eba-4492-bc59-ad6562585041).
---
## Option 1: Developing with Docker
### Prerequisites
1. Ensure you have a recent version of **Docker** and **Docker Compose** installed:
```shellscript
$ docker -v
Docker version 20.10.2, build 2291f61
$ docker compose version
Docker Compose version v2.32.4
```
2. Install **LiveKit CLI** by following the instructions available in the [official repository](https://github.com/livekit/livekit-cli). After installation, verify that it's working:
```shellscript
$ lk --version
lk version 2.3.1
```
---
### Project Bootstrap
1. Bootstrap the project using the **Make** command. This will build the `app` container, install dependencies, run database migrations, and compile translations:
```shellscript
$ make bootstrap FLUSH_ARGS='--no-input'
```
2. Access the project:
- The frontend is available at [http://localhost:3000](http://localhost:3000) with the default credentials:
- username: meet
- password: meet
- The Django backend is available at [http://localhost:8071](http://localhost:8071)
---
## Developing
- To **stop** the application:
```shellscript
$ make stop
```
- To **restart** the application:
```shellscript
$ make run
```
- For **frontend development**, start all backend services without the frontend container:
```shellscript
$ make run-backend
```
Then:
```shellscript
$ make frontend-development-install
$ make run-frontend-development
```
Which is equivalent to these direct npm commands:
```shellscript
$ cd src/frontend
$ npm i
$ npm run dev
```
---
## Adding Content
You can bootstrap demo data with a single command:
```shellscript
$ make demo
```
---
## Option 2: Developing with Kubernetes
Visio is deployed across staging, preprod, and production environments using **Kubernetes (K8s)**. Reproducing the environment locally is crucial for developing new features or debugging.
This is facilitated by [Tilt](https://tilt.dev/), which provides Kubernetes-like development for local environments, enabling smart rebuilds and live updates.
### Getting Started
Make sure you have the following installed:
- kubectl
- helm
- helmfile
- tilt
To build and start the Kubernetes cluster using **Kind**:
```shellscript
$ make build-k8s-cluster
```
Once the Kubernetes cluster is ready, start the application stack locally:
```shellscript
$ make start-tilt-keycloak
```
Monitor Tilts progress at [http://localhost:10350/](http://localhost:10350/). After Tilt actions finish, you can access the app at [https://meet.127.0.0.1.nip.io/](https://meet.127.0.0.1.nip.io/).
+3
View File
@@ -35,6 +35,9 @@ backend:
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
POSTGRES_DB: meet
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
REDIS_URL: redis://default:pass@redis-master:6379/1
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
LIVEKIT_API_SECRET: secret
+24 -161
View File
@@ -1,8 +1,9 @@
# Installation on a k8s cluster
This document is a step-by-step guide that describes how to install LaSuite Meet on a k8s cluster without AI features.
This document is a step-by-step guide that describes how to install Visio on a k8s cluster without AI features.
## Prerequisites for a kubernetes setup
## Prerequisites
- k8s cluster with an nginx-ingress controller
- an OIDC provider (if you don't have one, we will provide an example)
@@ -12,25 +13,14 @@ This document is a step-by-step guide that describes how to install LaSuite Meet
### Test cluster
If you do not have a kubernetes test cluster, you can install everything on a local kind cluster. In this case, the simplest way is to use our script located in this repo under **bin/start-kind.sh**.
If you do not have a test cluster, you can install everything on a local kind cluster. In this case, the simplest way is to use our script **bin/start-kind.sh**.
IMPORTANT: The kind method will only deploy meet as a local instance(127.0.0.1) that can only be accessed from the device where it has been deployed.
To be able to use the script, you will need to install the following components:
To be able to use the script, you will need to install:
- Docker (https://docs.docker.com/desktop/)
- Kind (https://kind.sigs.k8s.io/docs/user/quick-start/#installation)
- Mkcert (https://github.com/FiloSottile/mkcert#installation)
- Helm (https://helm.sh/docs/intro/quickstart/#install-helm)
- kubectl (https://kubernetes.io/docs/tasks/tools/)
In order to initiate the local kind installation via **start-kind.sh** do the following:
1) Make sure administrator/root user context is able to execute mkcert, docker, kind etc. commands or the script might fail
2) Download the script to the device where the above components are installed
3) Make the script executable
4) Run the script with proper permissions (administrator/sudo etc.)
The output of the script will resemble the below example:
```
$ ./bin/start-kind.sh
@@ -53,11 +43,11 @@ It will expire on 23 March 2027 🗓
2. Create kind cluster with containerd registry config dir enabled
Creating cluster "visio" ...
✓ Ensuring node image (kindest/node:v1.27.3) 🖼
✓ Preparing nodes 📦
✓ Writing configuration 📜
✓ Starting control-plane 🕹️
✓ Installing CNI 🔌
✓ Installing StorageClass 💾
✓ Preparing nodes 📦
✓ Writing configuration 📜
✓ Starting control-plane 🕹️
✓ Installing CNI 🔌
✓ Installing StorageClass 💾
Set kubectl context to "kind-visio"
You can now use your cluster with:
@@ -105,16 +95,15 @@ ingress-nginx-admission-create-jgnc9 0/1 Completed 0 2m44s
ingress-nginx-admission-patch-wrt47 0/1 Completed 0 2m44s
ingress-nginx-controller-57c548c4cd-9xwt6 1/1 Running 0 2m44s
```
When your k8s cluster is ready, you can start the deployment. This cluster is special because it uses the *.127.0.0.1.nip.io domain and mkcert certificates to have full HTTPS support and easy domain name management.
When your k8s cluster is ready, you can start the deployment. This cluster is special because it uses the \*.127.0.0.1.nip.io domain and mkcert certificates to have full HTTPS support and easy domain name management.
Please remember that *.127.0.0.1.nip.io will always resolve to 127.0.0.1, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP.
Please remember that \*.127.0.0.1.nip.io will always resolve to 127.0.0.1, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP.
## Preparation
## Preparation of components
### What will you use to authenticate your users ?
### What will you use to authenticate your users ?
LaSuite Meet uses OIDC, so if you already have an OIDC provider, obtain the necessary information to use it. In the next step, we will see how to configure Django (and thus LaSuite Meet) to use it. If you do not have a provider, we will show you how to deploy a local Keycloak instance (this is not a production deployment, just a demo).
Visio uses OIDC, so if you already have an OIDC provider, obtain the necessary information to use it. In the next step, we will see how to configure Django (and thus Visio) to use it. If you do not have a provider, we will show you how to deploy a local Keycloak instance (this is not a production deployment, just a demo).
If you haven't run the script **bin/start-kind.sh**, you'll need to manually create the namespace by running the following command:
@@ -122,7 +111,7 @@ If you haven't run the script **bin/start-kind.sh**, you'll need to manually cre
$ kubectl create namespace meet
```
If you have already run the script, you can skip this step and proceed to the next instruction. NOTE: Before you proceed, and is using the kind method, make sure you download this repo examples/ directory and its contents to the location where you will be executing the helm command. Helm will look for "examples/<name>values.yaml" from based on the path it is being executed.
If you have already run the script, you can skip this step and proceed to the next instruction.
```
$ kubectl config set-context --current --namespace=meet
@@ -134,8 +123,6 @@ keycloak-0 1/1 Running 0 6m48s
keycloak-postgresql-0 1/1 Running 0 6m48s
```
In your OIDC provider, set LaSuite Meet's redirect URI to `https://.../api/v1.0/callback/` where `...` should be replaced with the domain name LaSuite Meet is hosted on.
From here the important information you will need are :
```
@@ -154,7 +141,7 @@ You can find these values in **examples/keycloak.values.yaml**
### Find livekit server connexion values
LaSuite Meet use livekit for streaming part so if you have a livekit provider, obtain the necessary information to use it. If you do not have a provider, you can install a livekit testing environment as follows:
Visio use livekit for streaming part so if you have a livekit provider, obtain the necessary information to use it. If you do not have a provider, you can install a livekit testing environment as follows:
Livekit need a redis (and meet too) so we will start by deploying a redis :
@@ -166,7 +153,6 @@ keycloak-0 1/1 Running 0 26m
keycloak-postgresql-0 1/1 Running 0 26m
redis-master-0 1/1 Running 0 35s
```
When the redis is ready we can deploy livekit-server.
```
@@ -196,7 +182,7 @@ CELERY_RESULT_BACKEND: redis://default:pass@redis-master:6379/1
### Find postgresql connexion values
LaSuite Meet uses a postgresql db as backend so if you have a provider, obtain the necessary information to use it. If you do not have, you can install a postgresql testing environment as follows:
Visio uses a postgresql db as backend so if you have a provider, obtain the necessary information to use it. If you do not have, you can install a postgresql testing environment as follows:
```
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml
@@ -208,7 +194,6 @@ livekit-livekit-server-5c5fb87f7f-ct6x5 1/1 Running 0 15m
postgresql-0 1/1 Running 0 50s
redis-master-0 1/1 Running 0 19
```
From here important information you will need are :
```
@@ -217,14 +202,17 @@ DB_NAME: meet
DB_USER: dinum
DB_PASSWORD: pass
DB_PORT: 5432
POSTGRES_DB: meet
POSTGRES_USER: dinum
POSTGRES_PASSWORD: pass
```
## Deployment
Now you are ready to deploy LaSuite Meet without AI. AI required more dependencies (Openai-compliant API, LiveKit Egress, Cold storage and a docs deployment to push resumes). To deploy meet you need to provide all previous information to the helm chart.
Now you are ready to deploy Visio without AI. AI required more dependencies (Openai-compliant API, LiveKit Egress, Cold storage and a docs deployment to push resumes). To deploy meet you need to provide all previous information to the helm chart.
```
$ helm repo add meet https://suitenumerique.github.io/meet/
$ helm repo add meet https://numerique-gouv.github.io/meet/
$ helm repo update
$ helm install meet meet/meet -f examples/meet.values.yaml
```
@@ -242,129 +230,4 @@ meet <none> meet.127.0.0.1.nip.io localhost 80, 44
meet-admin <none> meet.127.0.0.1.nip.io localhost 80, 443 52m
```
You can use LaSuite Meet on https://meet.127.0.0.1.nip.io from the local device. The provisioning user in keycloak is meet/meet.
## All options
These are the environmental options available on meet backend.
| Option | Description | default |
|-------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|
| DATA_DIR | Data directory location | /data |
| DJANGO_ALLOWED_HOSTS | Hosts that are allowed | [] |
| DJANGO_SECRET_KEY | Secret key used for Django security | |
| DJANGO_SILENCED_SYSTEM_CHECKS | Silence Django system checks | [] |
| DJANGO_ALLOW_UNSECURE_USER_LISTING | Allow unsecure user listing | false |
| DB_ENGINE | Database engine used | django.db.backends.postgresql_psycopg2 |
| DB_NAME | Name of the database | meet |
| DB_USER | User used to connect to database | dinum |
| DB_PASSWORD | Password used to connect to the database | pass |
| DB_HOST | Hostname of the database | localhost |
| DB_PORT | Port to connect to database | 5432 |
| STORAGES_STATICFILES_BACKEND | Static file serving engine | whitenoise.storage.CompressedManifestStaticFilesStorage |
| AWS_S3_ENDPOINT_URL | S3 host endpoint | |
| AWS_S3_ACCESS_KEY_ID | S3 access key | |
| AWS_S3_SECRET_ACCESS_KEY | S3 secret key | |
| AWS_S3_REGION_NAME | S3 region | |
| AWS_STORAGE_BUCKET_NAME | S3 bucket name | meet-media-storage |
| DJANGO_LANGUAGE_CODE | Default language | en-us |
| REDIS_URL | Redis endpoint | redis://redis:6379/1 |
| SESSION_COOKIE_AGE | Session cookie expiration in seconds | 43200 (12 hours) |
| REQUEST_ENTRY_THROTTLE_RATES | Entry request throttle rates | 150/minute |
| CREATION_CALLBACK_THROTTLE_RATES | Creation callback throttle rates | 600/minute |
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | Enable Django deploy check | false |
| CSRF_TRUSTED_ORIGINS | CSRF trusted origins list | [] |
| FRONTEND_CUSTOM_CSS_URL | URL of an additional CSS file to load in the frontend app. If set, a `<link>` tag with this URL as href is added to the `<head>` of the frontend app | |
| FRONTEND_ANALYTICS | Analytics information | {} |
| FRONTEND_SUPPORT | Crisp frontend support configuration, also you can pass help articles, with `help_article_transcript`, `help_article_recording`, `help_article_more_tools` | {} |
| FRONTEND_TRANSCRIPT | Frontend transcription configuration, you can pass a beta form, with `form_beta_users` | {} |
| FRONTEND_MANIFEST_LINK | Link to the "Learn more" button on the homepage | {} |
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
| FRONTEND_USE_FRENCH_GOV_FOOTER | Show the French government footer in the homepage | false |
| FRONTEND_USE_PROCONNECT_BUTTON | Show a "Login with ProConnect" button in the homepage instead of a "Login" button | false |
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
| DJANGO_EMAIL_HOST | Host of the email server | |
| DJANGO_EMAIL_HOST_USER | User to connect to the email server | |
| DJANGO_EMAIL_HOST_PASSWORD | Password to connect to the email server | |
| DJANGO_EMAIL_PORT | Port to connect to the email server | |
| DJANGO_EMAIL_USE_TLS | Enable TLS on email connection | false |
| DJANGO_EMAIL_USE_SSL | Enable SSL on email connection | false |
| DJANGO_EMAIL_FROM | Email from account | from@example.com |
| EMAIL_BRAND_NAME | Email branding name | |
| EMAIL_SUPPORT_EMAIL | Support email address | |
| EMAIL_LOGO_IMG | Email logo image | |
| EMAIL_DOMAIN | Email domain | |
| EMAIL_APP_BASE_URL | Email app base URL | |
| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false |
| DJANGO_CORS_ALLOWED_ORIGINS | Origins to allow (string list) | [] |
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | Origins to allow (regex patterns) | [] |
| SENTRY_DSN | Sentry server DSN | |
| DJANGO_CELERY_BROKER_URL | Celery broker host | redis://redis:6379/0 |
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker options | {} |
| OIDC_CREATE_USER | Create OIDC user if not exists | true |
| OIDC_VERIFY_SSL | Verify SSL for OIDC | true |
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | false |
| OIDC_RP_SIGN_ALGO | Token verification algorithm used by OIDC | RS256 |
| OIDC_RP_CLIENT_ID | OIDC client ID | meet |
| OIDC_RP_CLIENT_SECRET | OIDC client secret | |
| OIDC_OP_JWKS_ENDPOINT | OIDC endpoint for JWKS | |
| OIDC_OP_AUTHORIZATION_ENDPOINT | OIDC endpoint for authorization | |
| OIDC_OP_TOKEN_ENDPOINT | OIDC endpoint for token | |
| OIDC_OP_USER_ENDPOINT | OIDC endpoint for user | |
| OIDC_OP_USER_ENDPOINT_FORMAT | OIDC endpoint format (AUTO, JWT, JSON) | AUTO |
| OIDC_OP_LOGOUT_ENDPOINT | OIDC endpoint for logout | |
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | Extra parameters for OIDC request | {} |
| OIDC_RP_SCOPES | OIDC scopes | openid email |
| OIDC_USE_NONCE | Use nonce for OIDC | true |
| OIDC_REDIRECT_REQUIRE_HTTPS | Require HTTPS for OIDC | false |
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed redirect hosts for OIDC | [] |
| OIDC_STORE_ID_TOKEN | Store OIDC ID token | true |
| OIDC_REDIRECT_FIELD_NAME | Redirect field for OIDC | returnTo |
| OIDC_USERINFO_FULLNAME_FIELDS | Full name claim from OIDC token | ["given_name", "usual_name"] |
| OIDC_USERINFO_SHORTNAME_FIELD | Short name claim from OIDC token | given_name |
| OIDC_USERINFO_ESSENTIAL_CLAIMS | Required claims from OIDC token | [] |
| OIDC_USE_PKCE | Enable the use of PKCE (Proof Key for Code Exchange) during the OAuth 2.0 authorization code flow. Recommended for enhanced security. | False |
| OIDC_PKCE_CODE_CHALLENGE_METHOD | Method used to generate the PKCE code challenge. Common values include S256 and plain. Refer to the mozilla-django-oidc documentation for supported options. | S256 |
| OIDC_PKCE_CODE_VERIFIER_SIZE | Length of the random string used as the PKCE code verifier. Must be an integer between 43 and 128, inclusive. | 64 |
| LOGIN_REDIRECT_URL | Login redirect URL | |
| LOGIN_REDIRECT_URL_FAILURE | Login redirect URL for failure | |
| LOGOUT_REDIRECT_URL | URL to redirect to on logout | |
| ALLOW_LOGOUT_GET_METHOD | Allow logout through GET method | true |
| LIVEKIT_API_KEY | LiveKit API key | |
| LIVEKIT_API_SECRET | LiveKit API secret | |
| LIVEKIT_API_URL | LiveKit API URL | |
| LIVEKIT_VERIFY_SSL | Verify SSL for LiveKit connections | true |
| LIVEKIT_FORCE_WSS_PROTOCOL | Enables WSS protocol conversion for legacy browser compatibility (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs fail in WebSocket() constructor. | false |
| LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND | Firefox-only connection warmup: pre-calls WebSocket endpoint (expecting 401) to initialize cache, resolving proxy/network connectivity issues. | false |
| RESOURCE_DEFAULT_ACCESS_LEVEL | Default resource access level for rooms | public |
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
| RECORDING_ENABLE | Record meeting option | false |
| RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings |
| RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
| RECORDING_EVENT_PARSER_CLASS | Storage event engine for recording | core.recording.event.parsers.MinioParser |
| RECORDING_ENABLE_STORAGE_EVENT_AUTH | Enable storage event authorization | true |
| RECORDING_STORAGE_EVENT_ENABLE | Enable recording storage events | false |
| RECORDING_STORAGE_EVENT_TOKEN | Recording storage event token | |
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
| RECORDING_MAX_DURATION | Maximum recording duration in milliseconds. Must match LiveKit Egress configuration exactly. | |
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
| SUMMARY_SERVICE_ENDPOINT | Summary service endpoint | |
| SUMMARY_SERVICE_API_TOKEN | API token for summary service | |
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Signup users to marketing emails | false |
| MARKETING_SERVICE_CLASS | Marketing service class | core.services.marketing.BrevoMarketingService |
| BREVO_API_KEY | Brevo API key for marketing emails | |
| BREVO_API_CONTACT_LIST_IDS | Brevo API contact list IDs | [] |
| DJANGO_BREVO_API_CONTACT_ATTRIBUTES | Brevo contact attributes | {"VISIO_USER": true} |
| BREVO_API_TIMEOUT | Brevo timeout in seconds | 1 |
| LOBBY_KEY_PREFIX | Lobby key prefix | room_lobby |
| LOBBY_WAITING_TIMEOUT | Lobby waiting timeout in seconds | 3 |
| LOBBY_DENIED_TIMEOUT | Lobby deny timeout in seconds | 5 |
| LOBBY_ACCEPTED_TIMEOUT | Lobby accept timeout in seconds | 21600 (6 hours) |
| LOBBY_NOTIFICATION_TYPE | Lobby notification types | participantWaiting |
| LOBBY_COOKIE_NAME | Lobby cookie name | lobbyParticipantId |
| ROOM_CREATION_CALLBACK_CACHE_TIMEOUT | Room creation callback cache timeout | 600 (10 minutes) |
| ROOM_TELEPHONY_ENABLED | Enable SIP telephony feature | false |
| ROOM_TELEPHONY_PIN_LENGTH | Telephony PIN length | 10 |
| ROOM_TELEPHONY_PIN_MAX_RETRIES | Telephony PIN maximum retries | 5 |
You can use Visio on https://meet.127.0.0.1.nip.io. The provisioning user in keycloak is meet/meet.
+2 -25
View File
@@ -12,20 +12,12 @@ PYTHONPATH=/app
# Mail
DJANGO_EMAIL_HOST="mailcatcher"
DJANGO_EMAIL_PORT=1025
DJANGO_EMAIL_BRAND_NAME=La Suite Numérique
DJANGO_EMAIL_SUPPORT_EMAIL=test@yopmail.com
DJANGO_EMAIL_LOGO_IMG=http://localhost:3000/assets/logo-suite-numerique.png
DJANGO_EMAIL_DOMAIN=localhost:3000
DJANGO_EMAIL_APP_BASE_URL=http://localhost:3000
# Backend url
MEET_BASE_URL="http://localhost:8072"
# Media
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
AWS_S3_ENDPOINT_URL=http://minio:9000
AWS_S3_ACCESS_KEY_ID=meet
AWS_S3_SECRET_ACCESS_KEY=password
# OIDC
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs
@@ -42,26 +34,11 @@ LOGIN_REDIRECT_URL=http://localhost:3000
LOGIN_REDIRECT_URL_FAILURE=http://localhost:3000
LOGOUT_REDIRECT_URL=http://localhost:3000
OIDC_REDIRECT_ALLOWED_HOSTS=localhost:8083,localhost:3000
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
# Livekit Token settings
LIVEKIT_API_SECRET=secret
LIVEKIT_API_KEY=devkey
LIVEKIT_API_URL=http://127.0.0.1.nip.io:7880
LIVEKIT_VERIFY_SSL=False
LIVEKIT_API_URL=http://localhost:7880
ALLOW_UNREGISTERED_ROOMS=False
# Recording
RECORDING_ENABLE=True
RECORDING_STORAGE_EVENT_ENABLE=True
RECORDING_STORAGE_EVENT_TOKEN=password
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v1/tasks/
SUMMARY_SERVICE_API_TOKEN=password
SCREEN_RECORDING_BASE_URL=http://localhost:3000/recordings
# Telephony
ROOM_TELEPHONY_ENABLED=True
FRONTEND_USE_FRENCH_GOV_FOOTER=False
FRONTEND_USE_PROCONNECT_BUTTON=False
-23
View File
@@ -1,23 +0,0 @@
APP_NAME="meet-app-summary-dev"
APP_API_TOKEN="password"
AWS_STORAGE_BUCKET_NAME="meet-media-storage"
AWS_S3_ENDPOINT_URL="minio:9000"
AWS_S3_SECURE_ACCESS=false
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="your-secret-key"
LLM_BASE_URL="https://configure-your-url.com"
LLM_API_KEY="dev-apikey"
LLM_MODEL="Qwen/Qwen2.5-Coder-32B-Instruct-AWQ"
WEBHOOK_API_TOKEN="secret"
WEBHOOK_URL="https://configure-your-url.com"
POSTHOG_API_KEY="your-posthog-key"
POSTHOG_ENABLED="False"
+1 -1
View File
@@ -7,7 +7,7 @@
"enabled": false,
"groupName": "ignored python dependencies",
"matchManagers": ["pep621"],
"matchPackageNames": ["redis"]
"matchPackageNames": []
},
{
"enabled": false,
Submodule
+1
Submodule secrets added at 2ba12db71d
-24
View File
@@ -1,24 +0,0 @@
FROM python:3.13-slim AS base
FROM base AS builder
WORKDIR /builder
COPY pyproject.toml .
RUN mkdir /install && \
pip install --prefix=/install .
FROM base AS production
WORKDIR /app
ARG DOCKER_USER
USER ${DOCKER_USER}
# Un-privileged user running the application
COPY --from=builder /install /usr/local
COPY . .
CMD ["python", "multi-user-transcriber.py", "start"]
-189
View File
@@ -1,189 +0,0 @@
"""Multi user transcription agent."""
import asyncio
import logging
import os
from dotenv import load_dotenv
from livekit import api, rtc
from livekit.agents import (
Agent,
AgentSession,
AutoSubscribe,
JobContext,
JobProcess,
JobRequest,
RoomInputOptions,
RoomIO,
RoomOutputOptions,
WorkerOptions,
WorkerPermissions,
cli,
utils,
)
from livekit.plugins import deepgram, silero
load_dotenv()
logger = logging.getLogger("transcriber")
TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcriber")
class Transcriber(Agent):
"""Create a transcription agent for a specific participant."""
def __init__(self, *, participant_identity: str):
"""Init transcription agent."""
super().__init__(
instructions="not-needed",
stt=deepgram.STT(),
)
self.participant_identity = participant_identity
class MultiUserTranscriber:
"""Manage transcription sessions for multiple room participants."""
def __init__(self, ctx: JobContext):
"""Init multi user transcription agent."""
self.ctx = ctx
self._sessions: dict[str, AgentSession] = {}
self._tasks: set[asyncio.Task] = set()
def start(self):
"""Start listening for participant connection events."""
self.ctx.room.on("participant_connected", self.on_participant_connected)
self.ctx.room.on("participant_disconnected", self.on_participant_disconnected)
async def aclose(self):
"""Close all sessions and cleanup resources."""
await utils.aio.cancel_and_wait(*self._tasks)
await asyncio.gather(
*[self._close_session(session) for session in self._sessions.values()]
)
self.ctx.room.off("participant_connected", self.on_participant_connected)
self.ctx.room.off("participant_disconnected", self.on_participant_disconnected)
def on_participant_connected(self, participant: rtc.RemoteParticipant):
"""Handle new participant connection by starting transcription session."""
if participant.identity in self._sessions:
return
logger.info(f"starting session for {participant.identity}")
task = asyncio.create_task(self._start_session(participant))
self._tasks.add(task)
def on_task_done(task: asyncio.Task):
try:
self._sessions[participant.identity] = task.result()
finally:
self._tasks.discard(task)
task.add_done_callback(on_task_done)
def on_participant_disconnected(self, participant: rtc.RemoteParticipant):
"""Handle participant disconnection by closing transcription session."""
if (session := self._sessions.pop(participant.identity)) is None:
return
logger.info(f"closing session for {participant.identity}")
task = asyncio.create_task(self._close_session(session))
self._tasks.add(task)
task.add_done_callback(lambda _: self._tasks.discard(task))
async def _start_session(self, participant: rtc.RemoteParticipant) -> AgentSession:
"""Create and start transcription session for participant."""
if participant.identity in self._sessions:
return self._sessions[participant.identity]
session = AgentSession(
vad=self.ctx.proc.userdata["vad"],
)
room_io = RoomIO(
agent_session=session,
room=self.ctx.room,
participant=participant,
input_options=RoomInputOptions(
text_enabled=False,
),
output_options=RoomOutputOptions(
transcription_enabled=True,
audio_enabled=False,
),
)
await room_io.start()
await session.start(
agent=Transcriber(
participant_identity=participant.identity,
)
)
return session
async def _close_session(self, sess: AgentSession) -> None:
"""Close and cleanup transcription session."""
await sess.drain()
await sess.aclose()
async def entrypoint(ctx: JobContext):
"""Initialize and run the multi-user transcriber."""
transcriber = MultiUserTranscriber(ctx)
transcriber.start()
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
for participant in ctx.room.remote_participants.values():
transcriber.on_participant_connected(participant)
async def cleanup():
await transcriber.aclose()
ctx.add_shutdown_callback(cleanup)
async def handle_transcriber_job_request(job_req: JobRequest) -> None:
"""Accept job if no transcriber exists in room, otherwise reject."""
room_name = job_req.room.name
transcriber_id = f"{TRANSCRIBER_AGENT_NAME}-{room_name}"
async with api.LiveKitAPI() as lkapi:
try:
response = await lkapi.room.list_participants(
list=api.ListParticipantsRequest(room=room_name)
)
transcriber_exists = any(
p.kind == rtc.ParticipantKind.PARTICIPANT_KIND_AGENT
and p.identity == transcriber_id
for p in response.participants
)
if transcriber_exists:
logger.info(f"Transcriber exists in {room_name} - rejecting")
await job_req.reject()
else:
logger.info(f"Accepting job for {room_name}")
await job_req.accept(identity=transcriber_id)
except Exception:
logger.exception(f"Error processing job for {room_name}")
await job_req.reject()
def prewarm(proc: JobProcess):
"""Preload voice activity detection model."""
proc.userdata["vad"] = silero.VAD.load()
if __name__ == "__main__":
cli.run_app(
WorkerOptions(
entrypoint_fnc=entrypoint,
request_fnc=handle_transcriber_job_request,
prewarm_fnc=prewarm,
agent_name=TRANSCRIBER_AGENT_NAME,
permissions=WorkerPermissions(hidden=True),
)
)
-51
View File
@@ -1,51 +0,0 @@
[project]
name = "agents"
version = "0.1.38"
requires-python = ">=3.12"
dependencies = [
"livekit-agents==1.2.6",
"livekit-plugins-deepgram==1.2.6",
"livekit-plugins-silero==1.2.6",
"python-dotenv==1.1.1"
]
[project.optional-dependencies]
dev = [
"ruff==0.12.0",
]
[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"
[tool.ruff]
target-version = "py313"
[tool.ruff.lint]
select = [
"B", # flake8-bugbear
"C4", # flake8-comprehensions
"D", # pydocstyle
"E", # pycodestyle error
"F", # Pyflakes
"I", # Isort
"ISC", # flake8-implicit-str-concat
"PLC", # Pylint Convention
"PLE", # Pylint Error
"PLR", # Pylint Refactor
"PLW", # Pylint Warning
"RUF100", # Ruff unused-noqa
"S", # flake8-bandit
"T20", # flake8-print
"W", # pycodestyle warning
]
[tool.ruff.lint.per-file-ignores]
"tests/*" = [
"S101", # use of assert
]
[tool.ruff.lint.pydocstyle]
# Use Google-style docstrings.
convention = "google"
+1 -30
View File
@@ -99,7 +99,6 @@ class ResourceAccessInline(admin.TabularInline):
model = models.ResourceAccess
extra = 0
autocomplete_fields = ["user"]
@admin.register(models.Room)
@@ -107,10 +106,6 @@ class RoomAdmin(admin.ModelAdmin):
"""Room admin interface declaration."""
inlines = (ResourceAccessInline,)
search_fields = ["name", "slug", "=id"]
list_display = ["name", "slug", "access_level", "created_at"]
list_filter = ["access_level", "created_at"]
readonly_fields = ["id", "created_at", "updated_at"]
class RecordingAccessInline(admin.TabularInline):
@@ -125,28 +120,4 @@ class RecordingAdmin(admin.ModelAdmin):
"""Recording admin interface declaration."""
inlines = (RecordingAccessInline,)
search_fields = ["status", "=id", "worker_id", "room__slug", "=room__id"]
list_display = ("id", "status", "room", "get_owner", "created_at", "worker_id")
list_filter = ["status", "room", "created_at"]
readonly_fields = ["id", "created_at", "updated_at"]
def get_queryset(self, request):
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
return super().get_queryset(request).prefetch_related("accesses__user")
def get_owner(self, obj):
"""Return the owner of the recording for display in the admin list."""
owners = [
access
for access in obj.accesses.all()
if access.role == models.RoleChoices.OWNER
]
if not owners:
return _("No owner")
if len(owners) > 1:
return _("Multiple owners")
return str(owners[0].user)
list_display = ("id", "status", "room", "created_at", "worker_id")
-16
View File
@@ -40,22 +40,6 @@ def get_frontend_configuration(request):
"recording": {
"is_enabled": settings.RECORDING_ENABLE,
"available_modes": settings.RECORDING_WORKER_CLASSES.keys(),
"expiration_days": settings.RECORDING_EXPIRATION_DAYS,
"max_duration": settings.RECORDING_MAX_DURATION,
},
"telephony": {
"enabled": settings.ROOM_TELEPHONY_ENABLED,
"phone_number": settings.ROOM_TELEPHONY_PHONE_NUMBER
if settings.ROOM_TELEPHONY_ENABLED
else None,
"default_country": settings.ROOM_TELEPHONY_DEFAULT_COUNTRY,
},
"subtitle": {"enabled": settings.ROOM_SUBTITLE_ENABLED},
"livekit": {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL,
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
},
}
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
-45
View File
@@ -1,45 +0,0 @@
"""Feature flag handler for the Meet core app."""
from functools import wraps
from django.conf import settings
from django.http import Http404
class FeatureFlag:
"""Check if features are enabled and return error responses."""
FLAGS = {
"recording": "RECORDING_ENABLE",
"storage_event": "RECORDING_STORAGE_EVENT_ENABLE",
"subtitle": "ROOM_SUBTITLE_ENABLED",
}
@classmethod
def flag_is_active(cls, flag_name):
"""Check if a feature flag is active."""
setting_name = cls.FLAGS.get(flag_name)
if setting_name is None:
return False
return getattr(settings, setting_name, False)
@classmethod
def require(cls, flag_name):
"""Decorator to check feature at the beginning of endpoint methods."""
if flag_name not in cls.FLAGS:
raise ValueError(f"Unknown feature flag: {flag_name}")
def decorator(view_func):
@wraps(view_func)
def wrapper(self, request, *args, **kwargs):
if not cls.flag_is_active(flag_name):
raise Http404
return view_func(self, request, *args, **kwargs)
return wrapper
return decorator
+23 -10
View File
@@ -1,5 +1,7 @@
"""Permission handlers for the Meet core app."""
from django.conf import settings
from rest_framework import permissions
from ..models import RoleChoices
@@ -62,7 +64,7 @@ class RoomPermissions(permissions.BasePermission):
if request.method == "DELETE":
return obj.is_owner(user)
return obj.is_administrator_or_owner(user)
return obj.is_administrator(user)
class ResourceAccessPermission(IsAuthenticated):
@@ -78,7 +80,7 @@ class ResourceAccessPermission(IsAuthenticated):
if request.method == "DELETE" and obj.role == RoleChoices.OWNER:
return obj.user == user
return obj.resource.is_administrator_or_owner(user)
return obj.resource.is_administrator(user)
class HasAbilityPermission(IsAuthenticated):
@@ -92,17 +94,28 @@ class HasAbilityPermission(IsAuthenticated):
class HasPrivilegesOnRoom(IsAuthenticated):
"""Check if user has privileges on a given room."""
message = "You must have privileges on room to perform this action."
message = "You must have privileges to start a recording."
def has_object_permission(self, request, view, obj):
"""Determine if user has privileges on room."""
return obj.is_administrator_or_owner(request.user)
return obj.is_owner(request.user) or obj.is_administrator(request.user)
class HasLiveKitRoomAccess(permissions.BasePermission):
"""Check if authenticated user's LiveKit token is for the specific room."""
class IsRecordingEnabled(permissions.BasePermission):
"""Check if the recording feature is enabled."""
def has_object_permission(self, request, view, obj):
if not request.auth or not hasattr(request.auth, "video"):
return False
return request.auth.video.room == str(obj.id)
message = "Access denied, recording is disabled."
def has_permission(self, request, view):
"""Determine if access is allowed based on settings."""
return settings.RECORDING_ENABLE
class IsStorageEventEnabled(permissions.BasePermission):
"""Check if the storage event feature is enabled."""
message = "Access denied, storage event is disabled."
def has_permission(self, request, view):
"""Determine if access is allowed based on settings."""
return settings.RECORDING_STORAGE_EVENT_ENABLE
+30 -154
View File
@@ -1,13 +1,10 @@
"""Client serializers for the Meet core app."""
# pylint: disable=abstract-method,no-name-in-module
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from livekit.api import ParticipantPermission
from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied
from timezone_field.rest_framework import TimeZoneSerializerField
from core import models, utils
@@ -15,11 +12,9 @@ from core import models, utils
class UserSerializer(serializers.ModelSerializer):
"""Serialize users."""
timezone = TimeZoneSerializerField()
class Meta:
model = models.User
fields = ["id", "email", "full_name", "short_name", "timezone", "language"]
fields = ["id", "email", "full_name", "short_name"]
read_only_fields = ["id", "email", "full_name", "short_name"]
@@ -40,10 +35,10 @@ class ResourceAccessSerializerMixin:
# Update
self.instance
and (
data.get("role") == models.RoleChoices.OWNER
data["role"] == models.RoleChoices.OWNER
and not self.instance.resource.is_owner(user)
or self.instance.role == models.RoleChoices.OWNER
and self.instance.user != user
and not self.instance.user == user
)
) or (
# Create
@@ -61,9 +56,7 @@ class ResourceAccessSerializerMixin:
request = self.context.get("request", None)
user = getattr(request, "user", None)
if not (
user and user.is_authenticated and resource.is_administrator_or_owner(user)
):
if not (user and user.is_authenticated and resource.is_administrator(user)):
raise PermissionDenied(
_("You must be administrator or owner of a room to add accesses to it.")
)
@@ -99,7 +92,7 @@ class ListRoomSerializer(serializers.ModelSerializer):
class Meta:
model = models.Room
fields = ["id", "name", "slug", "access_level"]
fields = ["id", "name", "slug", "is_public"]
read_only_fields = ["id", "slug"]
@@ -108,8 +101,8 @@ class RoomSerializer(serializers.ModelSerializer):
class Meta:
model = models.Room
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"]
read_only_fields = ["id", "slug", "pin_code"]
fields = ["id", "name", "slug", "configuration", "is_public"]
read_only_fields = ["id", "slug"]
def to_representation(self, instance):
"""
@@ -123,11 +116,9 @@ class RoomSerializer(serializers.ModelSerializer):
return output
role = instance.get_role(request.user)
is_admin_or_owner = models.RoleChoices.check_administrator_role(
role
) or models.RoleChoices.check_owner_role(role)
is_admin = models.RoleChoices.check_administrator_role(role)
if is_admin_or_owner:
if role is not None:
access_serializer = NestedResourceAccessSerializer(
instance.accesses.select_related("resource", "user").all(),
context=self.context,
@@ -135,32 +126,22 @@ class RoomSerializer(serializers.ModelSerializer):
)
output["accesses"] = access_serializer.data
configuration = output["configuration"]
if not is_admin_or_owner:
if not is_admin:
del output["configuration"]
should_access_room = (
(
instance.access_level == models.RoomAccessLevel.TRUSTED
and request.user.is_authenticated
)
or role is not None
or instance.is_public
)
if should_access_room:
room_id = f"{instance.id!s}"
if role is not None or instance.is_public:
slug = f"{instance.id!s}"
username = request.query_params.get("username", None)
output["livekit"] = utils.generate_livekit_config(
room_id=room_id,
user=request.user,
username=username,
configuration=configuration,
is_admin_or_owner=is_admin_or_owner,
)
output["is_administrable"] = is_admin_or_owner
output["livekit"] = {
"url": settings.LIVEKIT_CONFIGURATION["url"],
"room": slug,
"token": utils.generate_token(
room=slug, user=request.user, username=username
),
}
output["is_administrable"] = is_admin
return output
@@ -172,33 +153,11 @@ class RecordingSerializer(serializers.ModelSerializer):
class Meta:
model = models.Recording
fields = [
"id",
"room",
"created_at",
"updated_at",
"status",
"mode",
"key",
"is_expired",
"expired_at",
]
fields = ["id", "room", "created_at", "updated_at", "status"]
read_only_fields = fields
class BaseValidationOnlySerializer(serializers.Serializer):
"""Base serializer for validation-only operations."""
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
class StartRecordingSerializer(BaseValidationOnlySerializer):
class StartRecordingSerializer(serializers.Serializer):
"""Validate start recording requests."""
mode = serializers.ChoiceField(
@@ -211,93 +170,10 @@ class StartRecordingSerializer(BaseValidationOnlySerializer):
},
)
def create(self, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("StartRecordingSerializer is validation-only")
class RequestEntrySerializer(BaseValidationOnlySerializer):
"""Validate request entry data."""
username = serializers.CharField(required=True)
class ParticipantEntrySerializer(BaseValidationOnlySerializer):
"""Validate participant entry decision data."""
participant_id = serializers.UUIDField(required=True)
allow_entry = serializers.BooleanField(required=True)
class CreationCallbackSerializer(BaseValidationOnlySerializer):
"""Validate room creation callback data."""
callback_id = serializers.CharField(required=True)
class RoomInviteSerializer(serializers.Serializer):
"""Validate room invite creation data."""
emails = serializers.ListField(child=serializers.EmailField(), allow_empty=False)
class BaseParticipantsManagementSerializer(BaseValidationOnlySerializer):
"""Base serializer for participant management operations."""
participant_identity = serializers.UUIDField(
help_text="LiveKit participant identity (UUID format)"
)
class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
"""Validate participant muting data."""
track_sid = serializers.CharField(
max_length=255, help_text="LiveKit track SID to mute"
)
class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
"""Validate participant update data."""
metadata = serializers.DictField(
required=False, allow_null=True, help_text="Participant metadata as JSON object"
)
attributes = serializers.DictField(
required=False,
allow_null=True,
help_text="Participant attributes as JSON object",
)
permission = serializers.DictField(
required=False,
allow_null=True,
help_text="Participant permission as JSON object",
)
name = serializers.CharField(
max_length=255,
required=False,
allow_blank=True,
allow_null=True,
help_text="Display name for the participant",
)
def validate(self, attrs):
"""Ensure at least one update field is provided."""
update_fields = ["metadata", "attributes", "permission", "name"]
has_update = any(
field in attrs and attrs[field] is not None and attrs[field] != ""
for field in update_fields
)
if not has_update:
raise serializers.ValidationError(
f"At least one of the following fields must be provided: "
f"{', '.join(update_fields)}."
)
if "permission" in attrs:
try:
ParticipantPermission(**attrs["permission"])
except ValueError as e:
raise serializers.ValidationError(
{"permission": f"Invalid permission: {str(e)}"}
) from e
return attrs
def update(self, instance, validated_data):
"""Not implemented as this is a validation-only serializer."""
raise NotImplementedError("StartRecordingSerializer is validation-only")
+37 -435
View File
@@ -2,7 +2,6 @@
import uuid
from logging import getLogger
from urllib.parse import urlparse
from django.conf import settings
from django.db.models import Q
@@ -10,7 +9,12 @@ from django.http import Http404
from django.shortcuts import get_object_or_404
from django.utils.text import slugify
from rest_framework import decorators, mixins, pagination, throttling, viewsets
from rest_framework import (
decorators,
mixins,
pagination,
viewsets,
)
from rest_framework import (
exceptions as drf_exceptions,
)
@@ -21,8 +25,7 @@ from rest_framework import (
status as drf_status,
)
from core import enums, models, utils
from core.recording.enums import FileExtension
from core import models, utils
from core.recording.event.authentication import StorageEventAuthentication
from core.recording.event.exceptions import (
InvalidBucketError,
@@ -41,25 +44,8 @@ from core.recording.worker.factories import (
from core.recording.worker.mediator import (
WorkerServiceMediator,
)
from core.services.invitation import InvitationService
from core.services.livekit_events import (
LiveKitEventsService,
LiveKitWebhookError,
)
from core.services.lobby import (
LobbyParticipantNotFound,
LobbyService,
)
from core.services.participants_management import (
ParticipantsManagement,
ParticipantsManagementException,
)
from core.services.room_creation import RoomCreation
from core.services.subtitle import SubtitleException, SubtitleService
from ..authentication.livekit import LiveKitTokenAuthentication
from . import permissions, serializers
from .feature_flag import FeatureFlag
# pylint: disable=too-many-ancestors
@@ -165,8 +151,9 @@ class UserViewSet(
queryset = self.queryset
if self.action == "list":
if not settings.ALLOW_UNSECURE_USER_LISTING:
return models.User.objects.none()
# Exclude all users already in the given document
if document_id := self.request.GET.get("document_id", ""):
queryset = queryset.exclude(documentaccess__document_id=document_id)
# Filter users by email similarity
if query := self.request.GET.get("q", ""):
@@ -191,18 +178,6 @@ class UserViewSet(
)
class RequestEntryAnonRateThrottle(throttling.AnonRateThrottle):
"""Throttle Anonymous user requesting room entry"""
scope = "request_entry"
class CreationCallbackAnonRateThrottle(throttling.AnonRateThrottle):
"""Throttle Anonymous user requesting room generation callback"""
scope = "creation_callback"
class RoomViewSet(
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
@@ -285,18 +260,15 @@ class RoomViewSet(
role=models.RoleChoices.OWNER,
)
if callback_id := self.request.data.get("callback_id"):
RoomCreation().persist_callback_state(callback_id, room)
@decorators.action(
detail=True,
methods=["post"],
url_path="start-recording",
permission_classes=[
permissions.HasPrivilegesOnRoom,
permissions.IsRecordingEnabled,
],
)
@FeatureFlag.require("recording")
def start_room_recording(self, request, pk=None): # pylint: disable=unused-argument
"""Start recording a room."""
@@ -339,9 +311,9 @@ class RoomViewSet(
url_path="stop-recording",
permission_classes=[
permissions.HasPrivilegesOnRoom,
permissions.IsRecordingEnabled,
],
)
@FeatureFlag.require("recording")
def stop_room_recording(self, request, pk=None): # pylint: disable=unused-argument
"""Stop room recording."""
@@ -371,309 +343,41 @@ class RoomViewSet(
{"message": f"Recording stopped for room {room.slug}."}
)
@decorators.action(
detail=True,
methods=["post"],
url_path="request-entry",
permission_classes=[],
throttle_classes=[RequestEntryAnonRateThrottle],
)
def request_entry(self, request, pk=None): # pylint: disable=unused-argument
"""Request entry to a room"""
serializer = serializers.RequestEntrySerializer(data=request.data)
serializer.is_valid(raise_exception=True)
class ResourceAccessListModelMixin:
"""List mixin for resource access API."""
room = self.get_object()
lobby_service = LobbyService()
def get_permissions(self):
"""User only needs to be authenticated to list rooms access"""
if self.action == "list":
permission_classes = [permissions.IsAuthenticated]
else:
return super().get_permissions()
participant, livekit = lobby_service.request_entry(
room=room,
request=request,
**serializer.validated_data,
)
response = drf_response.Response({**participant.to_dict(), "livekit": livekit})
lobby_service.prepare_response(response, participant.id)
return [permission() for permission in permission_classes]
return response
@decorators.action(
detail=True,
methods=["post"],
url_path="enter",
permission_classes=[
permissions.HasPrivilegesOnRoom,
],
)
def allow_participant_to_enter(self, request, pk=None): # pylint: disable=unused-argument
"""Accept or deny a participant's entry request."""
serializer = serializers.ParticipantEntrySerializer(data=request.data)
serializer.is_valid(raise_exception=True)
room = self.get_object()
if room.is_public:
return drf_response.Response(
{"message": "Room has no lobby system."},
status=drf_status.HTTP_404_NOT_FOUND,
)
lobby_service = LobbyService()
try:
lobby_service.handle_participant_entry(
room_id=room.id,
participant_id=str(serializer.validated_data.get("participant_id")),
allow_entry=serializer.validated_data.get("allow_entry"),
)
return drf_response.Response({"message": "Participant was updated."})
except LobbyParticipantNotFound:
return drf_response.Response(
{"message": "Participant not found."},
status=drf_status.HTTP_404_NOT_FOUND,
)
@decorators.action(
detail=True,
methods=["GET"],
url_path="waiting-participants",
permission_classes=[
permissions.HasPrivilegesOnRoom,
],
)
def list_waiting_participants(self, request, pk=None): # pylint: disable=unused-argument
"""List waiting participants."""
room = self.get_object()
if room.is_public:
return drf_response.Response({"participants": []})
lobby_service = LobbyService()
participants = lobby_service.list_waiting_participants(room.id)
return drf_response.Response({"participants": participants})
@decorators.action(
detail=False,
methods=["post"],
url_path="webhooks-livekit",
permission_classes=[],
)
def webhooks_livekit(self, request):
"""Process webhooks from LiveKit."""
livekit_events_service = LiveKitEventsService()
try:
livekit_events_service.receive(request)
return drf_response.Response(
{"status": "success"}, status=drf_status.HTTP_200_OK
)
except LiveKitWebhookError as e:
status_code = getattr(e, "status_code", drf_status.HTTP_400_BAD_REQUEST)
if status_code == drf_status.HTTP_500_INTERNAL_SERVER_ERROR:
raise e
return drf_response.Response(
{"status": "error", "message": str(e)}, status=status_code
)
@decorators.action(
detail=False,
methods=["post"],
url_path="creation-callback",
permission_classes=[],
throttle_classes=[CreationCallbackAnonRateThrottle],
)
def creation_callback(self, request):
"""Retrieve cached room data via an unauthenticated request with a unique ID.
Designed for interoperability across iframes, popups, and other contexts,
even on the same domain, bypassing browser security restrictions on direct communication.
"""
serializer = serializers.CreationCallbackSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
room = RoomCreation().get_callback_state(
callback_id=serializer.validated_data.get("callback_id")
)
return drf_response.Response(
{"status": "success", "room": room}, status=drf_status.HTTP_200_OK
)
@decorators.action(
detail=True,
methods=["post"],
url_path="invite",
permission_classes=[
permissions.HasPrivilegesOnRoom,
],
)
def invite(self, request, pk=None): # pylint: disable=unused-argument
"""Send email invitations to join a room.
This API endpoint allows a user with appropriate privileges to send email invitations
to one or more recipients, inviting them to join the specified room.
"""
room = self.get_object()
serializer = serializers.RoomInviteSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
emails = serializer.validated_data.get("emails")
emails = list(set(emails))
InvitationService().invite_to_room(
room=room, sender=request.user, emails=emails
)
return drf_response.Response(
{"status": "success", "message": "invitations sent"},
status=drf_status.HTTP_200_OK,
)
@decorators.action(
detail=True,
methods=["post"],
url_path="start-subtitle",
permission_classes=[
permissions.HasLiveKitRoomAccess,
],
authentication_classes=[LiveKitTokenAuthentication],
)
@FeatureFlag.require("subtitle")
def start_subtitle(self, request, pk=None): # pylint: disable=unused-argument
"""Start realtime transcription for the room.
Requires valid LiveKit token for room authorization.
Anonymous users can start subtitles if they have room access tokens.
"""
room = self.get_object()
try:
SubtitleService().start_subtitle(room)
except SubtitleException:
return drf_response.Response(
{"error": f"Subtitles failed to start for room {room.slug}"},
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return drf_response.Response(
{"status": "success"}, status=drf_status.HTTP_200_OK
)
@decorators.action(
detail=True,
methods=["post"],
url_path="mute-participant",
url_name="mute-participant",
permission_classes=[permissions.HasPrivilegesOnRoom],
)
def mute_participant(self, request, pk=None): # pylint: disable=unused-argument
"""Mute a specific track for a participant in the room."""
room = self.get_object()
serializer = serializers.MuteParticipantSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
ParticipantsManagement().mute(
room_name=str(room.pk),
identity=str(serializer.validated_data["participant_identity"]),
track_sid=serializer.validated_data["track_sid"],
)
except ParticipantsManagementException:
return drf_response.Response(
{"error": "Failed to mute participant"},
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return drf_response.Response(
{
"status": "success",
},
status=drf_status.HTTP_200_OK,
)
@decorators.action(
detail=True,
methods=["post"],
url_path="update-participant",
url_name="update-participant",
permission_classes=[permissions.HasPrivilegesOnRoom],
)
def update_participant(self, request, pk=None): # pylint: disable=unused-argument
"""Update participant attributes, permissions, or metadata."""
room = self.get_object()
serializer = serializers.UpdateParticipantSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
ParticipantsManagement().update(
room_name=str(room.pk),
identity=str(serializer.validated_data["participant_identity"]),
metadata=serializer.validated_data.get("metadata"),
attributes=serializer.validated_data.get("attributes"),
permission=serializer.validated_data.get("permission"),
name=serializer.validated_data.get("name"),
)
except ParticipantsManagementException:
return drf_response.Response(
{"error": "Failed to update participant"},
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return drf_response.Response(
{
"status": "success",
},
status=drf_status.HTTP_200_OK,
)
@decorators.action(
detail=True,
methods=["post"],
url_path="remove-participant",
url_name="remove-participant",
permission_classes=[permissions.HasPrivilegesOnRoom],
)
def remove_participant(self, request, pk=None): # pylint: disable=unused-argument
"""Remove a participant from the room."""
room = self.get_object()
serializer = serializers.BaseParticipantsManagementSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
ParticipantsManagement().remove(
room_name=str(room.pk),
identity=str(serializer.validated_data["participant_identity"]),
)
except ParticipantsManagementException:
return drf_response.Response(
{"error": "Failed to remove participant"},
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
)
return drf_response.Response(
{"status": "success"}, status=drf_status.HTTP_200_OK
)
def get_queryset(self):
"""Return the queryset according to the action."""
queryset = super().get_queryset()
if self.action == "list":
user = self.request.user
queryset = queryset.filter(
Q(resource__accesses__user=user),
resource__accesses__role__in=[
models.RoleChoices.ADMIN,
models.RoleChoices.OWNER,
],
).distinct()
return queryset
class ResourceAccessViewSet(
ResourceAccessListModelMixin,
mixins.CreateModelMixin,
mixins.DestroyModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet,
):
"""
@@ -684,30 +388,10 @@ class ResourceAccessViewSet(
queryset = models.ResourceAccess.objects.all()
serializer_class = serializers.ResourceAccessSerializer
def get_queryset(self):
"""Return the queryset according to the action."""
queryset = super().get_queryset()
# Restrict access to resources the user either has explicit
# permissions for or administrative privileges over.
if self.action == "list":
user = self.request.user
queryset = queryset.filter(
Q(resource__accesses__user=user),
resource__accesses__role__in=[
models.RoleChoices.ADMIN,
models.RoleChoices.OWNER,
],
).distinct()
return queryset
class RecordingViewSet(
mixins.DestroyModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
viewsets.GenericViewSet,
):
"""
@@ -733,8 +417,8 @@ class RecordingViewSet(
methods=["post"],
url_path="storage-hook",
authentication_classes=[StorageEventAuthentication],
permission_classes=[permissions.IsStorageEventEnabled],
)
@FeatureFlag.require("storage_event")
def on_storage_event_received(self, request, pk=None): # pylint: disable=unused-argument
"""Handle incoming storage hook events for recordings."""
@@ -781,85 +465,3 @@ class RecordingViewSet(
return drf_response.Response(
{"message": "Event processed."},
)
def _auth_get_original_url(self, request):
"""
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
Raises PermissionDenied if the header is missing.
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
See corresponding ingress configuration in Helm chart and read about the
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
is configured to do this.
Based on the original url and the logged-in user, we must decide if we authorize Nginx
to let this request go through (by returning a 200 code) or if we block it (by returning
a 403 error). Note that we return 403 errors without any further details for security
reasons.
"""
# Extract the original URL from the request header
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
if not original_url:
logger.debug("Missing HTTP_X_ORIGINAL_URL header in subrequest")
raise drf_exceptions.PermissionDenied()
logger.debug("Original url: '%s'", original_url)
return urlparse(original_url)
def _auth_get_url_params(self, pattern, fragment):
"""
Extracts URL parameters from the given fragment using the specified regex pattern.
Raises PermissionDenied if parameters cannot be extracted.
"""
match = pattern.search(fragment)
try:
return match.groupdict()
except (ValueError, AttributeError) as exc:
logger.debug("Failed to extract parameters from subrequest URL: %s", exc)
raise drf_exceptions.PermissionDenied() from exc
@decorators.action(detail=False, methods=["get"], url_path="media-auth")
def media_auth(self, request, *args, **kwargs):
"""
This view is used by an Nginx subrequest to control access to a recording's
media file.
When we let the request go through, we compute authorization headers that will be added to
the request going through thanks to the nginx.ingress.kubernetes.io/auth-response-headers
annotation. The request will then be proxied to the object storage backend who will
respond with the file after checking the signature included in headers.
"""
parsed_url = self._auth_get_original_url(request)
url_params = self._auth_get_url_params(
enums.RECORDING_STORAGE_URL_PATTERN, parsed_url.path
)
user = request.user
recording_id = url_params["recording_id"]
extension = url_params["extension"]
if extension not in [item.value for item in FileExtension]:
raise drf_exceptions.ValidationError({"detail": "Unsupported extension."})
try:
recording = models.Recording.objects.get(id=recording_id)
except models.Recording.DoesNotExist as e:
raise drf_exceptions.NotFound("No recording found for this event.") from e
if extension != recording.extension:
raise drf_exceptions.NotFound("No recording found with this extension.")
abilities = recording.get_abilities(user)
if not abilities["retrieve"]:
logger.debug("User '%s' lacks permission for attachment", user.id)
raise drf_exceptions.PermissionDenied()
if not recording.is_saved:
logger.debug("Recording '%s' has not been saved", recording)
raise drf_exceptions.PermissionDenied()
request = utils.generate_s3_authorization_headers(recording.key)
return drf_response.Response("authorized", headers=request.headers, status=200)
+11
View File
@@ -0,0 +1,11 @@
"""Meet Core application"""
# from django.apps import AppConfig
# from django.utils.translation import gettext_lazy as _
# class CoreConfig(AppConfig):
# """Configuration class for the Meet core app."""
# name = "core"
# app_label = "core"
# verbose_name = _("meet core application")
+108 -35
View File
@@ -1,63 +1,109 @@
"""Authentication Backends for the Meet core app."""
import contextlib
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
from django.utils.translation import gettext_lazy as _
from lasuite.oidc_login.backends import (
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
import requests
from mozilla_django_oidc.auth import (
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
)
from core.models import User
from core.services.marketing import (
from core.services.marketing_service import (
ContactCreationError,
ContactData,
get_marketing_service,
)
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
"""Custom OpenID Connect (OIDC) Authentication Backend.
This class overrides the default OIDC Authentication Backend to accommodate differences
in the User and Identity models, and handles signed and/or encrypted UserInfo response.
"""
def get_extra_claims(self, user_info):
"""
Return extra claims from user_info.
def get_userinfo(self, access_token, id_token, payload):
"""Return user details dictionary.
Args:
user_info (dict): The user information dictionary.
Parameters:
- access_token (str): The access token.
- id_token (str): The id token (unused).
- payload (dict): The token payload (unused).
Note: The id_token and payload parameters are unused in this implementation,
but were kept to preserve base method signature.
Note: It handles signed and/or encrypted UserInfo Response. It is required by
Agent Connect, which follows the OIDC standard. It forces us to override the
base method, which deal with 'application/json' response.
Returns:
dict: A dictionary of extra claims.
- dict: User details dictionary obtained from the OpenID Connect user endpoint.
"""
return {
# Get user's full name from OIDC fields defined in settings
user_response = requests.get(
self.OIDC_OP_USER_ENDPOINT,
headers={"Authorization": f"Bearer {access_token}"},
verify=self.get_settings("OIDC_VERIFY_SSL", True),
timeout=self.get_settings("OIDC_TIMEOUT", None),
proxies=self.get_settings("OIDC_PROXY", None),
)
user_response.raise_for_status()
userinfo = self.verify_token(user_response.text)
return userinfo
def get_or_create_user(self, access_token, id_token, payload):
"""Return a User based on userinfo. Get or create a new user if no user matches the Sub.
Parameters:
- access_token (str): The access token.
- id_token (str): The ID token.
- payload (dict): The user payload.
Returns:
- User: An existing or newly created User instance.
Raises:
- Exception: Raised when user creation is not allowed and no existing user is found.
"""
user_info = self.get_userinfo(access_token, id_token, payload)
sub = user_info.get("sub")
if not sub:
raise SuspiciousOperation(
_("User info contained no recognizable user identification")
)
email = user_info.get("email")
user = self.get_existing_user(sub, email)
claims = {
"email": email,
"full_name": self.compute_full_name(user_info),
"short_name": user_info.get(settings.OIDC_USERINFO_SHORTNAME_FIELD),
}
if not user and self.get_settings("OIDC_CREATE_USER", True):
user = User.objects.create(
sub=sub,
password="!", # noqa: S106
**claims,
)
def post_get_or_create_user(self, user, claims, is_new_user):
"""
Post-processing after user creation or retrieval.
if settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
self.signup_to_marketing_email(email)
Args:
user (User): The user instance.
claims (dict): The claims dictionary.
is_new_user (bool): Indicates if the user was newly created.
elif not user:
return None
Returns:
- None
if not user.is_active:
raise SuspiciousOperation(_("User account is disabled"))
"""
email = claims["email"]
if is_new_user and email and settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
self.signup_to_marketing_email(email)
self.update_user_if_needed(user, claims)
return user
@staticmethod
def signup_to_marketing_email(email):
@@ -70,16 +116,14 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
Note: For a more robust solution, consider using Async task processing (Celery/Django-Q)
"""
with contextlib.suppress(
ContactCreationError, ImproperlyConfigured, ImportError
):
try:
marketing_service = get_marketing_service()
contact_data = ContactData(
email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
)
marketing_service.create_contact(
contact_data, timeout=settings.BREVO_API_TIMEOUT
)
marketing_service.create_contact(contact_data, timeout=1)
except (ContactCreationError, ImproperlyConfigured, ImportError):
pass
def get_existing_user(self, sub, email):
"""Fetch existing user by sub or email."""
@@ -93,6 +137,35 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
pass
except User.MultipleObjectsReturned as e:
raise SuspiciousOperation(
"Multiple user accounts share a common email."
_("Multiple user accounts share a common email.")
) from e
return None
@staticmethod
def compute_full_name(user_info):
"""Compute user's full name based on OIDC fields in settings."""
full_name = " ".join(
filter(
None,
(
user_info.get(field)
for field in settings.OIDC_USERINFO_FULLNAME_FIELDS
),
)
)
return full_name or None
@staticmethod
def update_user_if_needed(user, claims):
"""Update user claims if they have changed."""
user_fields = vars(user.__class__) # Get available model fields
updated_claims = {
key: value
for key, value in claims.items()
if value and key in user_fields and value != getattr(user, key)
}
if not updated_claims:
return
User.objects.filter(sub=user.sub).update(**updated_claims)
@@ -1,42 +0,0 @@
"""Authentication using LiveKit token for the Meet core app."""
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from livekit.api import TokenVerifier
from rest_framework import authentication, exceptions
UserModel = get_user_model()
class LiveKitTokenAuthentication(authentication.BaseAuthentication):
"""Authenticate using LiveKit token and load the associated Django user."""
def authenticate(self, request):
token = request.data.get("token")
if not token:
return None # No authentication attempted
try:
verifier = TokenVerifier(
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
api_secret=settings.LIVEKIT_CONFIGURATION["api_secret"],
)
claims = verifier.verify(token)
user_id = claims.identity
if not user_id:
raise exceptions.AuthenticationFailed("Token missing user identity")
try:
user = UserModel.objects.get(id=user_id)
except UserModel.DoesNotExist:
user = AnonymousUser()
return (user, claims)
except Exception as e:
raise exceptions.AuthenticationFailed(
f"Invalid LiveKit token: {str(e)}"
) from e
+18
View File
@@ -0,0 +1,18 @@
"""Authentication URLs for the People core app."""
from django.urls import path
from mozilla_django_oidc.urls import urlpatterns as mozzila_oidc_urls
from .views import OIDCLogoutCallbackView, OIDCLogoutView
urlpatterns = [
# Override the default 'logout/' path from Mozilla Django OIDC with our custom view.
path("logout/", OIDCLogoutView.as_view(), name="oidc_logout_custom"),
path(
"logout-callback/",
OIDCLogoutCallbackView.as_view(),
name="oidc_logout_callback",
),
*mozzila_oidc_urls,
]
+181
View File
@@ -0,0 +1,181 @@
"""Authentication Views for the People core app."""
import copy
from urllib.parse import urlencode
from django.contrib import auth
from django.core.exceptions import SuspiciousOperation
from django.http import HttpResponseRedirect
from django.urls import reverse
from django.utils import crypto
from mozilla_django_oidc.utils import (
absolutify,
)
from mozilla_django_oidc.views import (
OIDCAuthenticationCallbackView as MozillaOIDCAuthenticationCallbackView,
)
from mozilla_django_oidc.views import (
OIDCAuthenticationRequestView as MozillaOIDCAuthenticationRequestView,
)
from mozilla_django_oidc.views import (
OIDCLogoutView as MozillaOIDCOIDCLogoutView,
)
class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
"""Custom logout view for handling OpenID Connect (OIDC) logout flow.
Adds support for handling logout callbacks from the identity provider (OP)
by initiating the logout flow if the user has an active session.
The Django session is retained during the logout process to persist the 'state' OIDC parameter.
This parameter is crucial for maintaining the integrity of the logout flow between this call
and the subsequent callback.
"""
@staticmethod
def persist_state(request, state):
"""Persist the given 'state' parameter in the session's 'oidc_states' dictionary
This method is used to store the OIDC state parameter in the session, according to the
structure expected by Mozilla Django OIDC's 'add_state_and_verifier_and_nonce_to_session'
utility function.
"""
if "oidc_states" not in request.session or not isinstance(
request.session["oidc_states"], dict
):
request.session["oidc_states"] = {}
request.session["oidc_states"][state] = {}
request.session.save()
def construct_oidc_logout_url(self, request):
"""Create the redirect URL for interfacing with the OIDC provider.
Retrieves the necessary parameters from the session and constructs the URL
required to initiate logout with the OpenID Connect provider.
If no ID token is found in the session, the logout flow will not be initiated,
and the method will return the default redirect URL.
The 'state' parameter is generated randomly and persisted in the session to ensure
its integrity during the subsequent callback.
"""
oidc_logout_endpoint = self.get_settings("OIDC_OP_LOGOUT_ENDPOINT")
if not oidc_logout_endpoint:
return self.redirect_url
reverse_url = reverse("oidc_logout_callback")
id_token = request.session.get("oidc_id_token", None)
if not id_token:
return self.redirect_url
query = {
"id_token_hint": id_token,
"state": crypto.get_random_string(self.get_settings("OIDC_STATE_SIZE", 32)),
"post_logout_redirect_uri": absolutify(request, reverse_url),
}
self.persist_state(request, query["state"])
return f"{oidc_logout_endpoint}?{urlencode(query)}"
def post(self, request):
"""Handle user logout.
If the user is not authenticated, redirects to the default logout URL.
Otherwise, constructs the OIDC logout URL and redirects the user to start
the logout process.
If the user is redirected to the default logout URL, ensure her Django session
is terminated.
"""
logout_url = self.redirect_url
if request.user.is_authenticated:
logout_url = self.construct_oidc_logout_url(request)
# If the user is not redirected to the OIDC provider, ensure logout
if logout_url == self.redirect_url:
auth.logout(request)
return HttpResponseRedirect(logout_url)
class OIDCLogoutCallbackView(MozillaOIDCOIDCLogoutView):
"""Custom view for handling the logout callback from the OpenID Connect (OIDC) provider.
Handles the callback after logout from the identity provider (OP).
Verifies the state parameter and performs necessary logout actions.
The Django session is maintained during the logout process to ensure the integrity
of the logout flow initiated in the previous step.
"""
http_method_names = ["get"]
def get(self, request):
"""Handle the logout callback.
If the user is not authenticated, redirects to the default logout URL.
Otherwise, verifies the state parameter and performs necessary logout actions.
"""
if not request.user.is_authenticated:
return HttpResponseRedirect(self.redirect_url)
state = request.GET.get("state")
if state not in request.session.get("oidc_states", {}):
msg = "OIDC callback state not found in session `oidc_states`!"
raise SuspiciousOperation(msg)
del request.session["oidc_states"][state]
request.session.save()
auth.logout(request)
return HttpResponseRedirect(self.redirect_url)
class OIDCAuthenticationCallbackView(MozillaOIDCAuthenticationCallbackView):
"""Custom callback view for handling the silent login flow."""
@property
def failure_url(self):
"""Override the failure URL property to handle silent login flow
A silent login failure (e.g., no active user session) should not be
considered as an authentication failure.
"""
if self.request.session.get("silent", None):
del self.request.session["silent"]
self.request.session.save()
return self.success_url
return super().failure_url
class OIDCAuthenticationRequestView(MozillaOIDCAuthenticationRequestView):
"""Custom authentication view for handling the silent login flow."""
def get_extra_params(self, request):
"""Handle 'prompt' extra parameter for the silent login flow
This extra parameter is necessary to distinguish between a standard
authentication flow and the silent login flow.
"""
extra_params = self.get_settings("OIDC_AUTH_REQUEST_EXTRA_PARAMS", None)
if extra_params is None:
extra_params = {}
if request.GET.get("silent") == "true":
extra_params = copy.deepcopy(extra_params)
extra_params.update({"prompt": "none"})
request.session["silent"] = True
request.session.save()
return extra_params
-12
View File
@@ -2,21 +2,9 @@
Core application enums declaration
"""
import re
from django.conf import global_settings, settings
from django.utils.translation import gettext_lazy as _
UUID_REGEX = (
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
)
FILE_EXT_REGEX = r"[a-zA-Z0-9]{1,10}"
# pylint: disable=line-too-long
RECORDING_STORAGE_URL_PATTERN = re.compile(
f"/media/{settings.RECORDING_OUTPUT_FOLDER}/(?P<recording_id>{UUID_REGEX:s}).(?P<extension>{FILE_EXT_REGEX:s})"
)
# Django sets `LANGUAGES` by default with all supported languages. We can use it for
# the choice of languages which should not be limited to the few languages active in
# the app.
+3 -1
View File
@@ -1,3 +1,4 @@
# ruff: noqa: S311
"""
Core application factories
"""
@@ -35,6 +36,8 @@ class ResourceFactory(factory.django.DjangoModelFactory):
model = models.Resource
skip_postgeneration_save = True
is_public = factory.Faker("boolean", chance_of_getting_true=50)
@factory.post_generation
def users(self, create, extracted, **kwargs):
"""Add users to resource from a given list of users."""
@@ -67,7 +70,6 @@ class RoomFactory(ResourceFactory):
name = factory.Faker("catch_phrase")
slug = factory.LazyAttribute(lambda o: slugify(o.name))
access_level = factory.fuzzy.FuzzyChoice(models.RoomAccessLevel)
class RecordingFactory(factory.django.DjangoModelFactory):
@@ -1,22 +0,0 @@
# Generated by Django 5.1.5 on 2025-02-16 11:42
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0010_alter_resourceaccess_options_alter_user_options'),
]
operations = [
migrations.RemoveField(
model_name='resource',
name='is_public',
),
migrations.AddField(
model_name='room',
name='access_level',
field=models.CharField(choices=[('public', 'Public Access'), ('restricted', 'Restricted Access')], default='public', max_length=50),
),
]
@@ -1,18 +0,0 @@
# Generated by Django 5.1.6 on 2025-03-04 09:51
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0011_remove_resource_is_public_room_access_level'),
]
operations = [
migrations.AlterField(
model_name='room',
name='access_level',
field=models.CharField(choices=[('public', 'Public Access'), ('trusted', 'Trusted Access'), ('restricted', 'Restricted Access')], default='public', max_length=50),
)
]
@@ -1,18 +0,0 @@
# Generated by Django 5.1.8 on 2025-04-22 14:52
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0012_alter_room_access_level'),
]
operations = [
migrations.AlterField(
model_name='user',
name='language',
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('nl-nl', 'Dutch'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
),
]
@@ -1,45 +0,0 @@
# Generated by Django 5.1.9 on 2025-05-13 08:22
import secrets
from django.db import migrations, models
def generate_pin_for_rooms(apps, schema_editor):
"""Generate unique 10-digit PIN codes for existing rooms.
The PIN code is required for SIP telephony features.
"""
Room = apps.get_model('core', 'Room')
rooms_without_pin_code = Room.objects.filter(pin_code__isnull=True)
existing_pins = set(Room.objects.values_list('pin_code', flat=True))
length = 10
def generate_pin_code():
while True:
pin_code = str(secrets.randbelow(10 ** length)).zfill(length)
if pin_code not in existing_pins:
return pin_code
for room in rooms_without_pin_code:
room.pin_code = generate_pin_code()
room.save()
class Migration(migrations.Migration):
dependencies = [
('core', '0013_alter_user_language'),
]
operations = [
migrations.AddField(
model_name='room',
name='pin_code',
field=models.CharField(blank=True, help_text='Unique n-digit code that identifies this room in telephony mode.', max_length=None, null=True, unique=True, verbose_name='Room PIN code'),
),
migrations.RunPython(
generate_pin_for_rooms,
reverse_code=migrations.RunPython.noop
),
]
+12 -125
View File
@@ -2,11 +2,9 @@
Declare and configure the models for the Meet core application
"""
import secrets
import uuid
from datetime import datetime, timedelta
from logging import getLogger
from typing import List, Optional
from typing import List
from django.conf import settings
from django.contrib.auth import models as auth_models
@@ -14,14 +12,12 @@ from django.contrib.auth.base_user import AbstractBaseUser
from django.core import mail, validators
from django.core.exceptions import PermissionDenied, ValidationError
from django.db import models
from django.utils import timezone
from django.utils.functional import lazy
from django.utils.text import capfirst, slugify
from django.utils.translation import gettext_lazy as _
from timezone_field import TimeZoneField
from .recording.enums import FileExtension
logger = getLogger(__name__)
@@ -35,7 +31,7 @@ class RoleChoices(models.TextChoices):
@classmethod
def check_administrator_role(cls, role):
"""Check if a role is administrator."""
return role == cls.ADMIN
return role in [cls.ADMIN, cls.OWNER]
@classmethod
def check_owner_role(cls, role):
@@ -84,14 +80,6 @@ class RecordingModeChoices(models.TextChoices):
TRANSCRIPT = "transcript", _("TRANSCRIPT")
class RoomAccessLevel(models.TextChoices):
"""Room access level choices."""
PUBLIC = "public", _("Public Access")
TRUSTED = "trusted", _("Trusted Access")
RESTRICTED = "restricted", _("Restricted Access")
class BaseModel(models.Model):
"""
Serves as an abstract base model for other models, ensuring that records are validated
@@ -164,7 +152,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
)
language = models.CharField(
max_length=10,
choices=settings.LANGUAGES,
choices=lazy(lambda: settings.LANGUAGES, tuple)(),
default=settings.LANGUAGE_CODE,
verbose_name=_("language"),
help_text=_("The language in which the user wants to see the interface."),
@@ -253,6 +241,7 @@ def get_resource_roles(resource: models.Model, user: User) -> List[str]:
class Resource(BaseModel):
"""Model to define access control"""
is_public = models.BooleanField(default=settings.RESOURCE_DEFAULT_IS_PUBLIC)
users = models.ManyToManyField(
User,
through="ResourceAccess",
@@ -288,13 +277,13 @@ class Resource(BaseModel):
role = RoleChoices.MEMBER
return role
def is_administrator_or_owner(self, user):
def is_administrator(self, user):
"""
Check if a user is administrator or owner of the resource."""
role = self.get_role(user)
return RoleChoices.check_administrator_role(
role
) or RoleChoices.check_owner_role(role)
Check if a user is administrator of the resource.
Users carrying the "owner" role are considered as administrators a fortiori.
"""
return RoleChoices.check_administrator_role(self.get_role(user))
def is_owner(self, user):
"""Check if a user is owner of the resource."""
@@ -372,25 +361,13 @@ class Room(Resource):
primary_key=True,
)
slug = models.SlugField(max_length=100, blank=True, null=True, unique=True)
access_level = models.CharField(
max_length=50,
choices=RoomAccessLevel.choices,
default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
)
configuration = models.JSONField(
blank=True,
default=dict,
verbose_name=_("Visio room configuration"),
help_text=_("Values for Visio parameters to configure the room."),
)
pin_code = models.CharField(
max_length=None,
unique=True,
blank=True,
null=True,
verbose_name=_("Room PIN code"),
help_text=_("Unique n-digit code that identifies this room in telephony mode."),
)
class Meta:
db_table = "meet_room"
@@ -401,14 +378,6 @@ class Room(Resource):
def __str__(self):
return capfirst(self.name)
def save(self, *args, **kwargs):
"""Generate a unique n-digit pin code for new rooms."""
if settings.ROOM_TELEPHONY_ENABLED and not self.pk and not self.pin_code:
self.pin_code = self.generate_unique_pin_code(
length=settings.ROOM_TELEPHONY_PIN_LENGTH
)
super().save(*args, **kwargs)
def clean_fields(self, exclude=None):
"""
Automatically generate the slug from the name and make sure it does not look like a UUID.
@@ -423,39 +392,8 @@ class Room(Resource):
pass
else:
raise ValidationError({"name": f'Room name "{self.name:s}" is reserved.'})
super().clean_fields(exclude=exclude)
@property
def is_public(self):
"""Check if a room is public"""
return self.access_level == RoomAccessLevel.PUBLIC
@staticmethod
def generate_unique_pin_code(length):
"""Generate a unique n-digit PIN code"""
if length < 4:
raise ValueError(
"PIN code length must be at least 4 digits for minimal security"
)
max_value = 10**length
for _ in range(settings.ROOM_TELEPHONY_PIN_MAX_RETRIES):
pin_code = str(secrets.randbelow(max_value)).zfill(length)
if not Room.objects.filter(pin_code=pin_code).exists():
return pin_code
# Log a warning as a temporary measure until backend observability is implemented.
logger.warning(
"Failed to generate unique PIN code of length %s after %s attempts",
length,
settings.ROOM_TELEPHONY_PIN_MAX_RETRIES,
)
return None
class BaseAccessManager(models.Manager):
"""Base manager for handling resource access control."""
@@ -622,57 +560,6 @@ class Recording(BaseModel):
RecordingStatusChoices.STOPPED,
}
@property
def is_saved(self) -> bool:
"""Check if the recording is in a saved state."""
return self.status in {
RecordingStatusChoices.NOTIFICATION_SUCCEEDED,
RecordingStatusChoices.SAVED,
}
@property
def extension(self):
"""Get recording extension based on its mode."""
extensions = {
RecordingModeChoices.TRANSCRIPT: FileExtension.OGG.value,
RecordingModeChoices.SCREEN_RECORDING: FileExtension.MP4.value,
}
return extensions.get(self.mode, FileExtension.MP4.value)
@property
def key(self):
"""Generate the file key based on recording mode."""
return f"{settings.RECORDING_OUTPUT_FOLDER}/{self.id}.{self.extension}"
@property
def expired_at(self) -> Optional[datetime]:
"""
Calculate the expiration date based on created_at and RECORDING_EXPIRATION_DAYS.
Returns None if no expiration is configured.
Note: This is a naive and imperfect implementation since recordings are actually
saved to the bucket after created_at timestamp is set. The actual expiration
will be determined by the bucket lifecycle policy which operates on the object's
timestamp in the storage system, not this value.
"""
if not settings.RECORDING_EXPIRATION_DAYS:
return None
return self.created_at + timedelta(days=settings.RECORDING_EXPIRATION_DAYS)
@property
def is_expired(self) -> bool:
"""
Determine if the recording has expired by comparing expired_at with current UTC time.
Returns False if no expiration is configured or if expiration date is in the future.
"""
if not self.expired_at:
return False
return self.expired_at < timezone.now()
class RecordingAccess(BaseAccess):
"""Relation model to give access to a recording for a user or a team with a role."""
-10
View File
@@ -1,10 +0,0 @@
"""Enums related to recordings."""
from enum import Enum
class FileExtension(Enum):
"""Enum for file extensions used in recordings."""
OGG = "ogg"
MP4 = "mp4"
@@ -4,6 +4,7 @@ import logging
import secrets
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
@@ -54,7 +55,7 @@ class StorageEventAuthentication(BaseAuthentication):
if not required_token:
if settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
raise AuthenticationFailed(
"Authentication is enabled but token is not configured."
_("Authentication is enabled but token is not configured.")
)
return MachineUser(), None
@@ -66,7 +67,7 @@ class StorageEventAuthentication(BaseAuthentication):
"Authentication failed: Missing Authorization header (ip: %s)",
request.META.get("REMOTE_ADDR"),
)
raise AuthenticationFailed("Authorization header is required")
raise AuthenticationFailed(_("Authorization header is required"))
auth_parts = auth_header.split(" ")
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
@@ -74,7 +75,7 @@ class StorageEventAuthentication(BaseAuthentication):
"Authentication failed: Invalid authorization header (ip: %s)",
request.META.get("REMOTE_ADDR"),
)
raise AuthenticationFailed("Invalid authorization header.")
raise AuthenticationFailed(_("Invalid authorization header."))
token = auth_parts[1]
@@ -84,7 +85,7 @@ class StorageEventAuthentication(BaseAuthentication):
"Authentication failed: Invalid token (ip: %s)",
request.META.get("REMOTE_ADDR"),
)
raise AuthenticationFailed("Invalid token")
raise AuthenticationFailed(_("Invalid token"))
return MachineUser(), token
@@ -1,13 +1,8 @@
"""Service to notify external services when a new recording is ready."""
import logging
import smtplib
from django.conf import settings
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.translation import get_language, override
from django.utils.translation import gettext_lazy as _
import requests
@@ -26,7 +21,10 @@ class NotificationService:
return self._notify_summary_service(recording)
if recording.mode == models.RecordingModeChoices.SCREEN_RECORDING:
return self._notify_user_by_email(recording)
logger.warning(
"Screen recording mode not implemented for recording %s", recording.id
)
return False
logger.error(
"Unknown recording mode %s for recording %s",
@@ -35,79 +33,6 @@ class NotificationService:
)
return False
@staticmethod
def _notify_user_by_email(recording) -> bool:
"""
Send an email notification to recording owners when their recording is ready.
The email includes a direct link that redirects owners to a dedicated download
page in the frontend where they can access their specific recording.
"""
owner_accesses = (
models.RecordingAccess.objects.select_related("user")
.filter(
role=models.RoleChoices.OWNER,
recording_id=recording.id,
)
.order_by("created_at")
)
if not owner_accesses:
logger.error("No owner found for recording %s", recording.id)
return False
context = {
"brandname": settings.EMAIL_BRAND_NAME,
"support_email": settings.EMAIL_SUPPORT_EMAIL,
"logo_img": settings.EMAIL_LOGO_IMG,
"domain": settings.EMAIL_DOMAIN,
"room_name": recording.room.name,
"recording_expiration_days": settings.RECORDING_EXPIRATION_DAYS,
"link": f"{settings.SCREEN_RECORDING_BASE_URL}/{recording.id}",
}
has_failures = False
# We process emails individually rather than in batch because:
# 1. Each email requires personalization (timezone, language)
# 2. The number of recipients per recording is typically small (not thousands)
for access in owner_accesses:
user = access.user
language = user.language or get_language()
with override(language):
personalized_context = {
"recording_date": recording.created_at.astimezone(
user.timezone
).strftime("%Y-%m-%d"),
"recording_time": recording.created_at.astimezone(
user.timezone
).strftime("%H:%M"),
**context,
}
msg_html = render_to_string(
"mail/html/screen_recording.html", personalized_context
)
msg_plain = render_to_string(
"mail/text/screen_recording.txt", personalized_context
)
subject = str(_("Your recording is ready")) # Force translation
try:
send_mail(
subject.capitalize(),
msg_plain,
settings.EMAIL_FROM,
[user.email],
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as exception:
logger.error("notification could not be sent: %s", exception)
has_failures = True
return not has_failures
@staticmethod
def _notify_summary_service(recording):
"""Notify summary service about a new recording."""
@@ -132,17 +57,12 @@ class NotificationService:
logger.error("No owner found for recording %s", recording.id)
return False
key = f"{settings.RECORDING_OUTPUT_FOLDER}/{recording.id}.ogg"
payload = {
"filename": recording.key,
"filename": key,
"email": owner_access.user.email,
"sub": owner_access.user.sub,
"room": recording.room.name,
"recording_date": recording.created_at.astimezone(
owner_access.user.timezone
).strftime("%Y-%m-%d"),
"recording_time": recording.created_at.astimezone(
owner_access.user.timezone
).strftime("%H:%M"),
}
headers = {
+2 -2
View File
@@ -86,7 +86,7 @@ class MinioParser:
# pylint: disable=line-too-long
self._filepath_regex = re.compile(
r"(?P<url_encoded_folder_path>(?:[^%]+%2F)+)?(?P<recording_id>[0-9a-fA-F\-]{36})\.(?P<extension>[a-zA-Z0-9]+)"
r"(?P<url_encoded_folder_path>(?:[^%]+%2F)*)?(?P<recording_id>[0-9a-fA-F\-]{36})\.(?P<extension>[a-zA-Z0-9]+)"
)
@staticmethod
@@ -123,7 +123,7 @@ class MinioParser:
f"Invalid bucket: expected {self._bucket_name}, got {event_data.bucket_name}"
)
if event_data.filetype not in self._allowed_filetypes:
if not event_data.filetype in self._allowed_filetypes:
raise InvalidFileTypeError(
f"Invalid file type, expected {self._allowed_filetypes},"
f"got '{event_data.filetype}'"
@@ -1,49 +0,0 @@
"""Recording-related LiveKit Events Service"""
from logging import getLogger
from core import models, utils
logger = getLogger(__name__)
class RecordingEventsError(Exception):
"""Recording event handling fails."""
class RecordingEventsService:
"""Handles recording-related Livekit webhook events."""
@staticmethod
def handle_limit_reached(recording):
"""Stop recording and notify participants when limit is reached."""
recording.status = models.RecordingStatusChoices.STOPPED
recording.save()
notification_mapping = {
models.RecordingModeChoices.SCREEN_RECORDING: "screenRecordingLimitReached",
models.RecordingModeChoices.TRANSCRIPT: "transcriptionLimitReached",
}
notification_type = notification_mapping.get(recording.mode)
if not notification_type:
return
try:
utils.notify_participants(
room_name=str(recording.room.id),
notification_data={"type": notification_type},
)
except utils.NotificationError as e:
logger.exception(
"Failed to notify participants about recording limit reached: "
"room=%s, recording_id=%s, mode=%s",
recording.room.id,
recording.id,
recording.mode,
)
raise RecordingEventsError(
f"Failed to notify participants in room '{recording.room.id}' about "
f"recording limit reached (recording_id={recording.id})"
) from e
@@ -17,6 +17,7 @@ class WorkerServiceConfig:
output_folder: str
server_configurations: Dict[str, Any]
verify_ssl: Optional[bool]
bucket_args: Optional[dict]
@classmethod
@@ -28,6 +29,7 @@ class WorkerServiceConfig:
return cls(
output_folder=settings.RECORDING_OUTPUT_FOLDER,
server_configurations=settings.LIVEKIT_CONFIGURATION,
verify_ssl=settings.RECORDING_VERIFY_SSL,
bucket_args={
"endpoint": settings.AWS_S3_ENDPOINT_URL,
"access_key": settings.AWS_S3_ACCESS_KEY_ID,
+18 -26
View File
@@ -2,11 +2,11 @@
# pylint: disable=no-member
import aiohttp
from asgiref.sync import async_to_sync
from livekit import api as livekit_api
from livekit.api.egress_service import EgressService
from ... import utils
from ..enums import FileExtension
from .exceptions import WorkerConnectionError, WorkerResponseError
from .factories import WorkerServiceConfig
@@ -28,26 +28,21 @@ class BaseEgressService:
async def _handle_request(self, request, method_name: str):
"""Handle making a request to the LiveKit API and returns the response."""
lkapi = utils.create_livekit_client(self._config.server_configurations)
# Use HTTP connector for local development with Tilt,
# where cluster communications are unsecure
connector = aiohttp.TCPConnector(ssl=self._config.verify_ssl)
# ruff: noqa: SLF001
# pylint: disable=protected-access
method = getattr(lkapi._egress, method_name)
async with aiohttp.ClientSession(connector=connector) as session:
client = EgressService(session, **self._config.server_configurations)
method = getattr(client, method_name)
try:
response = await method(request)
except livekit_api.TwirpError as e:
raise WorkerConnectionError(
f"LiveKit client connection error, {e.message}."
) from e
try:
response = await method(request)
return response
except livekit_api.TwirpError as e:
raise WorkerConnectionError(
f"LiveKit client connection error, {e.message}."
) from e
except Exception as e:
raise WorkerConnectionError(
f"Unexpected error during LiveKit client connection: {str(e)}"
) from e
finally:
await lkapi.aclose()
def stop(self, worker_id: str) -> str:
"""Stop an ongoing egress worker.
@@ -94,9 +89,7 @@ class VideoCompositeEgressService(BaseEgressService):
# Save room's recording as a mp4 video file.
file_type = livekit_api.EncodedFileType.MP4
filepath = self._get_filepath(
filename=recording_id, extension=FileExtension.MP4.value
)
filepath = self._get_filepath(filename=recording_id, extension="mp4")
file_output = livekit_api.EncodedFileOutput(
file_type=file_type,
@@ -105,7 +98,8 @@ class VideoCompositeEgressService(BaseEgressService):
)
request = livekit_api.RoomCompositeEgressRequest(
room_name=room_name, file_outputs=[file_output], layout="speaker-light"
room_name=room_name,
file_outputs=[file_output],
)
response = self._handle_request(request, "start_room_composite_egress")
@@ -126,9 +120,7 @@ class AudioCompositeEgressService(BaseEgressService):
# Save room's recording as an ogg audio file.
file_type = livekit_api.EncodedFileType.OGG
filepath = self._get_filepath(
filename=recording_id, extension=FileExtension.OGG.value
)
filepath = self._get_filepath(filename=recording_id, extension="ogg")
file_output = livekit_api.EncodedFileOutput(
file_type=file_type,
-59
View File
@@ -1,59 +0,0 @@
"""Invitation Service."""
import smtplib
from logging import getLogger
from django.conf import settings
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.utils.translation import get_language, override
from django.utils.translation import gettext_lazy as _
logger = getLogger(__name__)
class InvitationError(Exception):
"""Exception raised when invitation emails cannot be sent."""
status_code = 500
class InvitationService:
"""Service for invitations to users."""
@staticmethod
def invite_to_room(room, sender, emails):
"""Send invitation emails to join a room."""
language = get_language()
context = {
"brandname": settings.EMAIL_BRAND_NAME,
"logo_img": settings.EMAIL_LOGO_IMG,
"domain": settings.EMAIL_DOMAIN,
"room_url": f"{settings.EMAIL_APP_BASE_URL}/{room.slug}",
"room_link": f"{settings.EMAIL_DOMAIN}/{room.slug}",
"sender_email": sender.email,
}
with override(language):
msg_html = render_to_string("mail/html/invitation.html", context)
msg_plain = render_to_string("mail/text/invitation.txt", context)
subject = str(
_(
f"Video call in progress: {sender.email} is waiting for you to connect"
)
) # Force translation
try:
send_mail(
subject,
msg_plain,
settings.EMAIL_FROM,
emails,
html_message=msg_html,
fail_silently=False,
)
except smtplib.SMTPException as e:
logger.error("invitation to %s was not sent: %s", emails, e)
raise InvitationError("Could not send invitation") from e
-198
View File
@@ -1,198 +0,0 @@
"""LiveKit Events Service"""
# pylint: disable=no-member
import uuid
from enum import Enum
from logging import getLogger
from django.conf import settings
from livekit import api
from core import models
from core.recording.services.recording_events import (
RecordingEventsError,
RecordingEventsService,
)
from .lobby import LobbyService
from .telephony import TelephonyException, TelephonyService
logger = getLogger(__name__)
class LiveKitWebhookError(Exception):
"""Base exception for LiveKit webhook processing errors."""
status_code = 500
class AuthenticationError(LiveKitWebhookError):
"""Authentication failed."""
status_code = 401
class InvalidPayloadError(LiveKitWebhookError):
"""Invalid webhook payload."""
status_code = 400
class UnsupportedEventTypeError(LiveKitWebhookError):
"""Unsupported event type."""
status_code = 422
class ActionFailedError(LiveKitWebhookError):
"""Webhook action fails to process or complete."""
status_code = 500
class LiveKitWebhookEventType(Enum):
"""LiveKit webhook event types."""
# Room events
ROOM_STARTED = "room_started"
ROOM_FINISHED = "room_finished"
# Participant events
PARTICIPANT_JOINED = "participant_joined"
PARTICIPANT_LEFT = "participant_left"
# Track events
TRACK_PUBLISHED = "track_published"
TRACK_UNPUBLISHED = "track_unpublished"
# Egress events
EGRESS_STARTED = "egress_started"
EGRESS_UPDATED = "egress_updated"
EGRESS_ENDED = "egress_ended"
# Ingress events
INGRESS_STARTED = "ingress_started"
INGRESS_ENDED = "ingress_ended"
class LiveKitEventsService:
"""Service for processing and handling LiveKit webhook events and notifications."""
def __init__(self):
"""Initialize with required services."""
token_verifier = api.TokenVerifier(
settings.LIVEKIT_CONFIGURATION["api_key"],
settings.LIVEKIT_CONFIGURATION["api_secret"],
)
self.webhook_receiver = api.WebhookReceiver(token_verifier)
self.lobby_service = LobbyService()
self.telephony_service = TelephonyService()
self.recording_events = RecordingEventsService()
def receive(self, request):
"""Process webhook and route to appropriate handler."""
auth_token = request.headers.get("Authorization")
if not auth_token:
raise AuthenticationError("Authorization header missing")
try:
data = self.webhook_receiver.receive(
request.body.decode("utf-8"), auth_token
)
except Exception as e:
raise InvalidPayloadError("Invalid webhook payload") from e
try:
webhook_type = LiveKitWebhookEventType(data.event)
except ValueError as e:
raise UnsupportedEventTypeError(
f"Unknown webhook type: {data.event}"
) from e
handler_name = f"_handle_{webhook_type.value}"
handler = getattr(self, handler_name, None)
if not handler or not callable(handler):
return
# pylint: disable=not-callable
handler(data)
def _handle_egress_ended(self, data):
"""Handle 'egress_ended' event."""
try:
recording = models.Recording.objects.get(
worker_id=data.egress_info.egress_id
)
except models.Recording.DoesNotExist as err:
raise ActionFailedError(
f"Recording with worker ID {data.egress_info.egress_id} does not exist"
) from err
if (
data.egress_info.status == api.EgressStatus.EGRESS_LIMIT_REACHED
and recording.status == models.RecordingStatusChoices.ACTIVE
):
try:
self.recording_events.handle_limit_reached(recording)
except RecordingEventsError as e:
raise ActionFailedError(
f"Failed to process limit reached event for recording {recording}"
) from e
def _handle_room_started(self, data):
"""Handle 'room_started' event."""
try:
room_id = uuid.UUID(data.room.name)
except ValueError as e:
logger.warning(
"Ignoring room event: room name '%s' is not a valid UUID format.",
data.room.name,
)
raise ActionFailedError("Failed to process room started event") from e
try:
room = models.Room.objects.get(id=room_id)
except models.Room.DoesNotExist as err:
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
if settings.ROOM_TELEPHONY_ENABLED:
try:
self.telephony_service.create_dispatch_rule(room)
except TelephonyException as e:
raise ActionFailedError(
f"Failed to create telephony dispatch rule for room {room_id}"
) from e
def _handle_room_finished(self, data):
"""Handle 'room_finished' event."""
try:
room_id = uuid.UUID(data.room.name)
except ValueError as e:
logger.warning(
"Ignoring room event: room name '%s' is not a valid UUID format.",
data.room.name,
)
raise ActionFailedError("Failed to process room finished event") from e
if settings.ROOM_TELEPHONY_ENABLED:
try:
self.telephony_service.delete_dispatch_rule(room_id)
except TelephonyException as e:
raise ActionFailedError(
f"Failed to delete telephony dispatch rule for room {room_id}"
) from e
try:
self.lobby_service.clear_room_cache(room_id)
except Exception as e:
raise ActionFailedError(
f"Failed to clear room cache for room {room_id}"
) from e
-350
View File
@@ -1,350 +0,0 @@
"""Lobby Service"""
import logging
import uuid
from dataclasses import dataclass
from enum import Enum
from typing import Dict, List, Optional, Tuple
from uuid import UUID
from django.conf import settings
from django.core.cache import cache
from core import models, utils
logger = logging.getLogger(__name__)
class LobbyParticipantStatus(Enum):
"""Possible states of a participant in the lobby system.
Values are lowercase strings for consistent serialization and API responses.
"""
UNKNOWN = "unknown"
WAITING = "waiting"
ACCEPTED = "accepted"
DENIED = "denied"
class LobbyError(Exception):
"""Base exception for lobby-related errors."""
class LobbyParticipantParsingError(LobbyError):
"""Raised when participant data parsing fails."""
class LobbyParticipantNotFound(LobbyError):
"""Raised when participant is not found."""
@dataclass
class LobbyParticipant:
"""Participant in a lobby system."""
status: LobbyParticipantStatus
username: str
color: str
id: str
def to_dict(self) -> Dict[str, str]:
"""Serialize the participant object to a dict representation."""
return {
"status": self.status.value,
"username": self.username,
"id": self.id,
"color": self.color,
}
@classmethod
def from_dict(cls, data: dict) -> "LobbyParticipant":
"""Create a LobbyParticipant instance from a dictionary."""
try:
status = LobbyParticipantStatus(
data.get("status", LobbyParticipantStatus.UNKNOWN.value)
)
return cls(
status=status,
username=data["username"],
id=data["id"],
color=data["color"],
)
except (KeyError, ValueError) as e:
logger.exception("Error creating Participant from dict:")
raise LobbyParticipantParsingError("Invalid participant data") from e
class LobbyService:
"""Service for managing participant access through a lobby system.
Handles participant entry requests, status management, and notifications
using cache for state management and LiveKit for real-time updates.
"""
@staticmethod
def _get_cache_key(room_id: UUID, participant_id: str) -> str:
"""Generate cache key for participant(s) data."""
return f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
@staticmethod
def _get_or_create_participant_id(request) -> str:
"""Extract unique participant identifier from the request."""
return request.COOKIES.get(settings.LOBBY_COOKIE_NAME, str(uuid.uuid4()))
@staticmethod
def prepare_response(response, participant_id):
"""Set participant cookie if needed."""
if not response.cookies.get(settings.LOBBY_COOKIE_NAME):
response.set_cookie(
key=settings.LOBBY_COOKIE_NAME,
value=participant_id,
httponly=True,
secure=True,
samesite="Lax",
)
@staticmethod
def can_bypass_lobby(room, user) -> bool:
"""Determines if a user can bypass the waiting lobby and join a room directly.
A user can bypass the lobby if:
1. The room is public (open to everyone)
2. The room has TRUSTED access level and the user is authenticated
Note: Room access levels can change while participants are waiting in the lobby.
This function only checks the current state and should be called each time
a participant requests entry to ensure consistent access control, even for
participants who have already begun waiting.
"""
return room.is_public or (
room.access_level == models.RoomAccessLevel.TRUSTED
and user.is_authenticated
)
def request_entry(
self,
room,
request,
username: str,
) -> Tuple[LobbyParticipant, Optional[Dict]]:
"""Request entry to a room for a participant.
This usual status transitions is:
UNKNOWN -> WAITING -> (ACCEPTED | DENIED)
Flow:
1. Check current status
2. If waiting, refresh timeout to maintain position
3. If unknown, add to waiting list
4. If accepted, generate LiveKit config
5. If denied, do nothing.
"""
participant_id = self._get_or_create_participant_id(request)
participant = self._get_participant(room.id, participant_id)
room_id = str(room.id)
if self.can_bypass_lobby(room=room, user=request.user):
if participant is None:
participant = LobbyParticipant(
status=LobbyParticipantStatus.ACCEPTED,
username=username,
id=participant_id,
color=utils.generate_color(participant_id),
)
else:
participant.status = LobbyParticipantStatus.ACCEPTED
livekit_config = utils.generate_livekit_config(
room_id=room_id,
user=request.user,
username=username,
color=participant.color,
configuration=room.configuration,
is_admin_or_owner=False,
participant_id=participant_id,
)
return participant, livekit_config
livekit_config = None
if participant is None:
participant = self.enter(room.id, participant_id, username)
elif participant.status == LobbyParticipantStatus.WAITING:
self.refresh_waiting_status(room.id, participant_id)
elif participant.status == LobbyParticipantStatus.ACCEPTED:
# wrongly named, contains access token to join a room
livekit_config = utils.generate_livekit_config(
room_id=room_id,
user=request.user,
username=username,
color=participant.color,
configuration=room.configuration,
is_admin_or_owner=False,
participant_id=participant_id,
)
return participant, livekit_config
def refresh_waiting_status(self, room_id: UUID, participant_id: str):
"""Refresh timeout for waiting participant.
Extends the waiting period for a participant to maintain their position
in the lobby queue. Automatic removal if the participant is not
actively checking their status.
"""
cache.touch(
self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT
)
def enter(
self, room_id: UUID, participant_id: str, username: str
) -> LobbyParticipant:
"""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)
participant = LobbyParticipant(
status=LobbyParticipantStatus.WAITING,
username=username,
id=participant_id,
color=color,
)
try:
utils.notify_participants(
room_name=str(room_id),
notification_data={
"type": settings.LOBBY_NOTIFICATION_TYPE,
},
)
except utils.NotificationError:
# If room not created yet, there is no participants to notify
logger.exception("Failed to notify room participants")
cache_key = self._get_cache_key(room_id, participant_id)
cache.set(
cache_key,
participant.to_dict(),
timeout=settings.LOBBY_WAITING_TIMEOUT,
)
return participant
def _get_participant(
self, room_id: UUID, participant_id: str
) -> Optional[LobbyParticipant]:
"""Check participant's current status in the lobby."""
cache_key = self._get_cache_key(room_id, participant_id)
data = cache.get(cache_key)
if not data:
return None
try:
return LobbyParticipant.from_dict(data)
except LobbyParticipantParsingError:
logger.error("Corrupted participant data found and removed: %s", cache_key)
cache.delete(cache_key)
return None
def list_waiting_participants(self, room_id: UUID) -> List[dict]:
"""List all waiting participants for a room."""
pattern = self._get_cache_key(room_id, "*")
keys = cache.keys(pattern)
if not keys:
return []
data = cache.get_many(keys)
waiting_participants = []
for cache_key, raw_participant in data.items():
try:
participant = LobbyParticipant.from_dict(raw_participant)
except LobbyParticipantParsingError:
cache.delete(cache_key)
continue
if participant.status == LobbyParticipantStatus.WAITING:
waiting_participants.append(participant.to_dict())
return waiting_participants
def handle_participant_entry(
self,
room_id: UUID,
participant_id: str,
allow_entry: bool,
) -> None:
"""Handle decision on participant entry.
Updates participant status based on allow_entry:
- If accepted: ACCEPTED status with extended timeout matching LiveKit token
- If denied: DENIED status with short timeout allowing status check and retry
"""
if allow_entry:
decision = {
"status": LobbyParticipantStatus.ACCEPTED,
"timeout": settings.LOBBY_ACCEPTED_TIMEOUT,
}
else:
decision = {
"status": LobbyParticipantStatus.DENIED,
"timeout": settings.LOBBY_DENIED_TIMEOUT,
}
self._update_participant_status(room_id, participant_id, **decision)
def _update_participant_status(
self,
room_id: UUID,
participant_id: str,
status: LobbyParticipantStatus,
timeout: int,
) -> None:
"""Update participant status with appropriate timeout."""
cache_key = self._get_cache_key(room_id, participant_id)
data = cache.get(cache_key)
if not data:
logger.error("Participant %s not found", participant_id)
raise LobbyParticipantNotFound("Participant not found")
try:
participant = LobbyParticipant.from_dict(data)
except LobbyParticipantParsingError:
logger.exception(
"Removed corrupted data for participant %s:", participant_id
)
cache.delete(cache_key)
raise
participant.status = status
cache.set(cache_key, participant.to_dict(), timeout=timeout)
def clear_room_cache(self, room_id: UUID) -> None:
"""Clear all participant entries from the cache for a specific room."""
pattern = self._get_cache_key(room_id, "*")
keys = cache.keys(pattern)
if not keys:
return
cache.delete_many(keys)
def clear_participant_cache(self, room_id: UUID, participant_id: str) -> None:
"""Clear a given participant entry from the cache for a specific room."""
cache_key = self._get_cache_key(room_id, participant_id)
cache.delete(cache_key)
@@ -10,7 +10,6 @@ from django.core.exceptions import ImproperlyConfigured
from django.utils.module_loading import import_string
import brevo_python
import urllib3
logger = logging.getLogger(__name__)
@@ -121,11 +120,8 @@ class BrevoMarketingService:
try:
response = contact_api.create_contact(contact, **api_configurations)
except (
brevo_python.rest.ApiException,
urllib3.exceptions.ReadTimeoutError,
) as err:
logger.warning("Failed to create contact in Brevo", exc_info=True)
except brevo_python.rest.ApiException as err:
logger.exception("Failed to create contact in Brevo")
raise ContactCreationError("Failed to create contact in Brevo") from err
return response
@@ -1,128 +0,0 @@
"""Participants management service for LiveKit rooms."""
# pylint: disable=too-many-arguments,no-name-in-module,too-many-positional-arguments
# ruff: noqa:PLR0913
import json
import uuid
from logging import getLogger
from typing import Dict, Optional
from asgiref.sync import async_to_sync
from livekit.api import (
MuteRoomTrackRequest,
RoomParticipantIdentity,
TwirpError,
UpdateParticipantRequest,
)
from core import utils
from .lobby import LobbyService
logger = getLogger(__name__)
class ParticipantsManagementException(Exception):
"""Exception raised when a participant management operations fail."""
class ParticipantsManagement:
"""Service for managing participants."""
@async_to_sync
async def mute(self, room_name: str, identity: str, track_sid: str):
"""Mute a specific audio or video track for a participant in a room."""
lkapi = utils.create_livekit_client()
try:
await lkapi.room.mute_published_track(
MuteRoomTrackRequest(
room=room_name,
identity=identity,
track_sid=track_sid,
muted=True,
)
)
except TwirpError as e:
logger.exception(
"Unexpected error muting participant %s for room %s",
identity,
room_name,
)
raise ParticipantsManagementException("Could not mute participant") from e
finally:
await lkapi.aclose()
@async_to_sync
async def remove(self, room_name: str, identity: str):
"""Remove a participant from a room and clear their lobby cache."""
try:
LobbyService().clear_participant_cache(
room_id=uuid.UUID(room_name), participant_id=identity
)
except (ValueError, TypeError) as exc:
logger.warning(
"participants_management.remove: room_name '%s' is not a UUID; "
"skipping lobby cache clear",
room_name,
exc_info=exc,
)
lkapi = utils.create_livekit_client()
try:
await lkapi.room.remove_participant(
RoomParticipantIdentity(room=room_name, identity=identity)
)
except TwirpError as e:
logger.exception(
"Unexpected error removing participant %s for room %s",
identity,
room_name,
)
raise ParticipantsManagementException("Could not remove participant") from e
finally:
await lkapi.aclose()
@async_to_sync
async def update(
self,
room_name: str,
identity: str,
metadata: Optional[Dict] = None,
attributes: Optional[Dict] = None,
permission: Optional[Dict] = None,
name: Optional[str] = None,
):
"""Update participant properties such as metadata, attributes, permissions, or name."""
lkapi = utils.create_livekit_client()
try:
await lkapi.room.update_participant(
UpdateParticipantRequest(
room=room_name,
identity=identity,
metadata=json.dumps(metadata),
permission=permission,
attributes=attributes,
name=name,
)
)
except TwirpError as e:
logger.exception(
"Unexpected error updating participant %s for room %s",
identity,
room_name,
)
raise ParticipantsManagementException("Could not update participant") from e
finally:
await lkapi.aclose()
@@ -1,37 +0,0 @@
"""Room creation service."""
from django.conf import settings
from django.core.cache import cache
class RoomCreation:
"""Room creation related methods"""
@staticmethod
def _get_cache_key(callback_id):
"""Generate a standardized cache key for room creation callbacks."""
return f"room-creation-callback_{callback_id}"
def persist_callback_state(self, callback_id: str, room) -> None:
"""Store room data in cache using the callback ID as an identifier."""
data = {
"slug": room.slug,
}
cache.set(
self._get_cache_key(callback_id),
data,
timeout=settings.ROOM_CREATION_CALLBACK_CACHE_TIMEOUT,
)
def get_callback_state(self, callback_id: str) -> dict:
"""Retrieve and clear cached room data for the given callback ID."""
cache_key = self._get_cache_key(callback_id)
data = cache.get(cache_key)
if not data:
return {}
cache.delete(cache_key)
return data
-47
View File
@@ -1,47 +0,0 @@
"""Service for managing subtitle agents in LiveKit rooms."""
from logging import getLogger
from django.conf import settings
from asgiref.sync import async_to_sync
from livekit.protocol.agent_dispatch import CreateAgentDispatchRequest
from core import utils
logger = getLogger(__name__)
class SubtitleException(Exception):
"""Exception raised when subtitle operations fail."""
class SubtitleService:
"""Service for managing subtitle agents in LiveKit rooms."""
@async_to_sync
async def start_subtitle(self, room):
"""Start subtitle agent for the specified room."""
lkapi = utils.create_livekit_client()
try:
# Transcriber agent prevents duplicate subtitle agents per room
# No error is raised if agent already exists
await lkapi.agent_dispatch.create_dispatch(
CreateAgentDispatchRequest(
agent_name=settings.ROOM_SUBTITLE_AGENT_NAME, room=str(room.id)
)
)
except Exception as e:
logger.exception("Failed to create agent dispatch for room %s", room.id)
raise SubtitleException("Failed to create subtitle agent") from e
finally:
await lkapi.aclose()
@async_to_sync
async def stop_subtitle(self, room) -> None:
"""Stop subtitle agent for the specified room."""
raise NotImplementedError("Subtitle agent stopping not yet implemented")
-124
View File
@@ -1,124 +0,0 @@
"""Telephony service for managing SIP dispatch rules for room access."""
from logging import getLogger
from asgiref.sync import async_to_sync
from livekit.api import TwirpError
from livekit.protocol.sip import (
CreateSIPDispatchRuleRequest,
DeleteSIPDispatchRuleRequest,
ListSIPDispatchRuleRequest,
SIPDispatchRule,
SIPDispatchRuleDirect,
)
from core import utils
logger = getLogger(__name__)
class TelephonyException(Exception):
"""Exception raised when telephony operations fail."""
class TelephonyService:
"""Service for managing participant access through the telephony system (SIP)."""
def _rule_name(self, room_id):
"""Generate the rule name for a room based on its ID."""
return f"SIP_{str(room_id)}"
@async_to_sync
async def create_dispatch_rule(self, room):
"""Create a SIP inbound dispatch rule for direct room routing.
Configures telephony to route incoming SIP calls directly to the specified room
using the room's ID and PIN code for authentication.
"""
direct_rule = SIPDispatchRule(
dispatch_rule_direct=SIPDispatchRuleDirect(
room_name=str(room.pk), pin=str(room.pin_code)
)
)
request = CreateSIPDispatchRuleRequest(
rule=direct_rule, name=self._rule_name(room.pk)
)
lkapi = utils.create_livekit_client()
try:
await lkapi.sip.create_sip_dispatch_rule(create=request)
except TwirpError as e:
logger.exception(
"Unexpected error creating dispatch rule for room %s", room.id
)
raise TelephonyException("Could not create dispatch rule") from e
finally:
await lkapi.aclose()
async def _list_dispatch_rules_ids(self, room_id):
"""List SIP dispatch rule IDs for a specific room.
Fetches all existing SIP dispatch rules and filters them by room name
since LiveKit API doesn't support server-side filtering by 'room_name'.
This approach is acceptable for moderate scale but may need refactoring
for high-volume scenarios.
Note:
Feature request for server-side filtering: livekit/sip#405
"""
lkapi = utils.create_livekit_client()
try:
existing_rules = await lkapi.sip.list_sip_dispatch_rule(
list=ListSIPDispatchRuleRequest()
)
except TwirpError as e:
logger.exception("Failed to list dispatch rules for room %s", room_id)
raise TelephonyException("Could not list dispatch rules") from e
finally:
await lkapi.aclose()
if not existing_rules or not existing_rules.items:
return []
rule_name = self._rule_name(room_id)
return [
existing_rule.sip_dispatch_rule_id
for existing_rule in existing_rules.items
if existing_rule.name == rule_name
]
@async_to_sync
async def delete_dispatch_rule(self, room_id):
"""Delete all SIP inbound dispatch rules associated with a specific room."""
rules_ids = await self._list_dispatch_rules_ids(room_id)
if not rules_ids:
logger.info("No dispatch rules found for room %s", room_id)
return False
if len(rules_ids) > 1:
logger.error("Multiple dispatch rules found for room %s", room_id)
lkapi = utils.create_livekit_client()
try:
for rule_id in rules_ids:
await lkapi.sip.delete_sip_dispatch_rule(
delete=DeleteSIPDispatchRuleRequest(sip_dispatch_rule_id=rule_id)
)
return True
except TwirpError as e:
logger.exception("Failed to delete dispatch rules for room %s", room_id)
raise TelephonyException("Could not delete dispatch rules") from e
finally:
await lkapi.aclose()
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<title>Generate Document</title>
</head>
<body>
<h2>Generate Document</h2>
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<button type="submit">Generate PDF</button>
</form>
</body>
</html>
@@ -9,7 +9,7 @@ import pytest
from core import models
from core.authentication.backends import OIDCAuthenticationBackend
from core.factories import UserFactory
from core.services import marketing
from core.services import marketing_service
pytestmark = pytest.mark.django_db
@@ -58,7 +58,7 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
assert user.sub == "123"
assert user.email is None
assert user.has_usable_password() is False
assert user.password == "!"
assert models.User.objects.count() == 1
@@ -84,7 +84,7 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
assert user.email == email
assert user.full_name is None
assert user.short_name is None
assert user.has_usable_password() is False
assert user.password == "!"
assert models.User.objects.count() == 1
@@ -110,7 +110,7 @@ def test_authentication_getter_new_user_with_names(monkeypatch, email):
assert user.email == email
assert user.full_name == "John Doe"
assert user.short_name == "John"
assert user.has_usable_password() is False
assert user.password == "!"
assert models.User.objects.count() == 1
@@ -129,7 +129,7 @@ def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkey
django_assert_num_queries(0),
pytest.raises(
SuspiciousOperation,
match="Claims verification failed",
match="User info contained no recognizable user identification",
),
):
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
@@ -287,7 +287,9 @@ def test_finds_user_whitespace_email(django_assert_num_queries, settings):
[
"john.doe@xample.com", # Fullwidth character in domain
"john.doe@еxample.com", # Cyrillic 'е' in domain
"JOHN.DOe@exam𝔭le.com", # Mixed Gothic '𝔭' in domain
"john.doe@exаmple.com", # Cyrillic 'а' (a) in domain
"john.doe@e𝓧𝓪𝓶𝓹𝓵𝓮.com", # Mixed fullwidth and cursive in domain
],
)
def test_authentication_getter_existing_user_email_tricky(email, monkeypatch, settings):
@@ -533,7 +535,7 @@ def test_marketing_signup_handles_service_initialization_errors(
@pytest.mark.parametrize(
"error",
[
marketing.ContactCreationError,
marketing_service.ContactCreationError,
ImproperlyConfigured,
ImportError,
],
@@ -0,0 +1,10 @@
"""Unit tests for the Authentication URLs."""
from core.authentication.urls import urlpatterns
def test_urls_override_default_mozilla_django_oidc():
"""Custom URL patterns should override default ones from Mozilla Django OIDC."""
url_names = [u.name for u in urlpatterns]
assert url_names.index("oidc_logout_custom") < url_names.index("oidc_logout")
@@ -0,0 +1,359 @@
"""Unit tests for the Authentication Views."""
from unittest import mock
from urllib.parse import parse_qs, urlparse
from django.contrib.auth.models import AnonymousUser
from django.contrib.sessions.middleware import SessionMiddleware
from django.core.exceptions import SuspiciousOperation
from django.test import RequestFactory
from django.test.utils import override_settings
from django.urls import reverse
from django.utils import crypto
import pytest
from rest_framework.test import APIClient
from core import factories
from core.authentication.views import (
MozillaOIDCAuthenticationCallbackView,
OIDCAuthenticationCallbackView,
OIDCAuthenticationRequestView,
OIDCLogoutCallbackView,
OIDCLogoutView,
)
pytestmark = pytest.mark.django_db
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
def test_view_logout_anonymous():
"""Anonymous users calling the logout url,
should be redirected to the specified LOGOUT_REDIRECT_URL."""
url = reverse("oidc_logout_custom")
response = APIClient().get(url)
assert response.status_code == 302
assert response.url == "/example-logout"
@mock.patch.object(
OIDCLogoutView, "construct_oidc_logout_url", return_value="/example-logout"
)
def test_view_logout(mocked_oidc_logout_url):
"""Authenticated users should be redirected to OIDC provider for logout."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
url = reverse("oidc_logout_custom")
response = client.get(url)
mocked_oidc_logout_url.assert_called_once()
assert response.status_code == 302
assert response.url == "/example-logout"
@override_settings(LOGOUT_REDIRECT_URL="/default-redirect-logout")
@mock.patch.object(
OIDCLogoutView, "construct_oidc_logout_url", return_value="/default-redirect-logout"
)
def test_view_logout_no_oidc_provider(mocked_oidc_logout_url):
"""Authenticated users should be logged out when no OIDC provider is available."""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
url = reverse("oidc_logout_custom")
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
response = client.get(url)
mocked_oidc_logout_url.assert_called_once()
mock_logout.assert_called_once()
assert response.status_code == 302
assert response.url == "/default-redirect-logout"
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
def test_view_logout_callback_anonymous():
"""Anonymous users calling the logout callback url,
should be redirected to the specified LOGOUT_REDIRECT_URL."""
url = reverse("oidc_logout_callback")
response = APIClient().get(url)
assert response.status_code == 302
assert response.url == "/example-logout"
@pytest.mark.parametrize(
"initial_oidc_states",
[{}, {"other_state": "foo"}],
)
def test_view_logout_persist_state(initial_oidc_states):
"""State value should be persisted in session's data."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
if initial_oidc_states:
request.session["oidc_states"] = initial_oidc_states
request.session.save()
mocked_state = "mock_state"
OIDCLogoutView().persist_state(request, mocked_state)
assert "oidc_states" in request.session
assert request.session["oidc_states"] == {
"mock_state": {},
**initial_oidc_states,
}
@override_settings(OIDC_OP_LOGOUT_ENDPOINT="/example-logout")
@mock.patch.object(OIDCLogoutView, "persist_state")
@mock.patch.object(crypto, "get_random_string", return_value="mocked_state")
def test_view_logout_construct_oidc_logout_url(
mocked_get_random_string, mocked_persist_state
):
"""Should construct the logout URL to initiate the logout flow with the OIDC provider."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
request.session["oidc_id_token"] = "mocked_oidc_id_token"
request.session.save()
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
mocked_persist_state.assert_called_once()
mocked_get_random_string.assert_called_once()
params = parse_qs(urlparse(redirect_url).query)
assert params["id_token_hint"][0] == "mocked_oidc_id_token"
assert params["state"][0] == "mocked_state"
url = reverse("oidc_logout_callback")
assert url in params["post_logout_redirect_uri"][0]
@override_settings(LOGOUT_REDIRECT_URL="/")
def test_view_logout_construct_oidc_logout_url_none_id_token():
"""If no ID token is available in the session,
the user should be redirected to the final URL."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
assert redirect_url == "/"
@pytest.mark.parametrize(
"initial_state",
[None, {"other_state": "foo"}],
)
def test_view_logout_callback_wrong_state(initial_state):
"""Should raise an error if OIDC state doesn't match session data."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
if initial_state:
request.session["oidc_states"] = initial_state
request.session.save()
callback_view = OIDCLogoutCallbackView.as_view()
with pytest.raises(SuspiciousOperation) as excinfo:
callback_view(request)
assert (
str(excinfo.value) == "OIDC callback state not found in session `oidc_states`!"
)
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
def test_view_logout_callback():
"""If state matches, callback should clear OIDC state and redirects."""
user = factories.UserFactory()
request = RequestFactory().get("/logout-callback/", data={"state": "mocked_state"})
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
mocked_state = "mocked_state"
request.session["oidc_states"] = {mocked_state: {}}
request.session.save()
callback_view = OIDCLogoutCallbackView.as_view()
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
def clear_user(request):
# Assert state is cleared prior to logout
assert request.session["oidc_states"] == {}
request.user = AnonymousUser()
mock_logout.side_effect = clear_user
response = callback_view(request)
mock_logout.assert_called_once()
assert response.status_code == 302
assert response.url == "/example-logout"
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
def test_view_authentication_default(settings, mocked_extra_params_setting):
"""By default, authentication request should not trigger silent login."""
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
request.GET = {}
view = OIDCAuthenticationRequestView()
extra_params = view.get_extra_params(request)
assert extra_params == (mocked_extra_params_setting or {})
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
def test_view_authentication_silent_false(settings, mocked_extra_params_setting):
"""Ensure setting 'silent' parameter to a random value doesn't trigger the silent login flow."""
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
request.GET = {"silent": "foo"}
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
view = OIDCAuthenticationRequestView()
extra_params = view.get_extra_params(request)
assert extra_params == (mocked_extra_params_setting or {})
assert not request.session.get("silent")
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
def test_view_authentication_silent_true(settings, mocked_extra_params_setting):
"""If 'silent' parameter is set to True, the silent login should be triggered."""
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
request.GET = {"silent": "true"}
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
view = OIDCAuthenticationRequestView()
extra_params = view.get_extra_params(request)
expected_params = {"prompt": "none"}
assert (
extra_params == {**mocked_extra_params_setting, **expected_params}
if mocked_extra_params_setting
else expected_params
)
assert request.session.get("silent") is True
@mock.patch.object(
MozillaOIDCAuthenticationCallbackView,
"failure_url",
new_callable=mock.PropertyMock,
return_value="foo",
)
def test_view_callback_failure_url(mocked_failure_url):
"""Test default behavior of the 'failure_url' property"""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
view = OIDCAuthenticationCallbackView()
view.request = request
returned_url = view.failure_url
mocked_failure_url.assert_called_once()
assert returned_url == "foo"
@mock.patch.object(
OIDCAuthenticationCallbackView,
"success_url",
new_callable=mock.PropertyMock,
return_value="foo",
)
def test_view_callback_failure_url_silent_login(mocked_success_url):
"""If a silent login was initiated and failed, it should not be treated as a failure."""
user = factories.UserFactory()
request = RequestFactory().request()
request.user = user
middleware = SessionMiddleware(get_response=lambda x: x)
middleware.process_request(request)
request.session["silent"] = True
request.session.save()
view = OIDCAuthenticationCallbackView()
view.request = request
returned_url = view.failure_url
mocked_success_url.assert_called_once()
assert returned_url == "foo"
assert not request.session.get("silent")
@@ -2,7 +2,7 @@
Test event authentication.
"""
# pylint: disable=assignment-from-no-return
# pylint: disable=E1128
from django.test import RequestFactory
@@ -1,224 +0,0 @@
"""
Test event notification.
"""
# pylint: disable=assignment-from-no-return,redefined-outer-name,unused-argument,protected-access
import datetime
import smtplib
from unittest import mock
from django.contrib.sites.models import Site
import pytest
from core import factories, models
from core.recording.event.notification import NotificationService, notification_service
pytestmark = pytest.mark.django_db
@pytest.fixture
def mocked_current_site():
"""Mocks the Site.objects.get_current()to return a controlled predefined domain."""
site_mock = mock.Mock()
site_mock.domain = "test-domain.com"
with mock.patch.object(
Site.objects, "get_current", return_value=site_mock
) as patched:
yield patched
@mock.patch.object(NotificationService, "_notify_summary_service", return_value=True)
def test_notify_external_services_transcript_mode(mock_notify_summary):
"""Test notification routing for transcript mode recordings."""
service = NotificationService()
recording = factories.RecordingFactory(mode=models.RecordingModeChoices.TRANSCRIPT)
result = service.notify_external_services(recording)
assert result is True
mock_notify_summary.assert_called_once_with(recording)
@mock.patch.object(NotificationService, "_notify_user_by_email", return_value=True)
def test_notify_external_services_screen_recording_mode(mock_notify_email):
"""Test notification routing for screen recording mode."""
service = NotificationService()
recording = factories.RecordingFactory(
mode=models.RecordingModeChoices.SCREEN_RECORDING
)
result = service.notify_external_services(recording)
assert result is True
mock_notify_email.assert_called_once_with(recording)
def test_notify_external_services_unknown_mode(caplog):
"""Test notification for unknown recording mode."""
recording = factories.RecordingFactory()
# Bypass validation
recording.mode = "unknown"
service = NotificationService()
result = service.notify_external_services(recording)
assert result is False
assert f"Unknown recording mode unknown for recording {recording.id}" in caplog.text
# pylint: disable=too-many-locals
def test_notify_user_by_email_success(mocked_current_site, settings):
"""Test successful email notification to recording owners."""
settings.EMAIL_BRAND_NAME = "ACME"
settings.EMAIL_SUPPORT_EMAIL = "support@acme.com"
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
settings.SCREEN_RECORDING_BASE_URL = "https://acme.com/recordings"
settings.EMAIL_FROM = "notifications@acme.com"
recording = factories.RecordingFactory(room__name="Conference Room A")
# Fix recording test to avoid flaky tests
recording.created_at = datetime.datetime(2023, 5, 15, 14, 30, 0)
french_user = factories.UserFactory(
email="franc@test.com", language="fr-fr", timezone="Europe/Paris"
)
dutch_user = factories.UserFactory(
email="berry@test.com", language="nl-nl", timezone="Europe/Amsterdam"
)
english_user = factories.UserFactory(
email="john@test.com", language="en-us", timezone="America/Phoenix"
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER, user=french_user
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER, user=dutch_user
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER, user=english_user
)
# Create non-owner users to verify they don't receive emails
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.MEMBER
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.ADMIN
)
notification_service = NotificationService()
with mock.patch("core.recording.event.notification.send_mail") as mock_send_mail:
result = notification_service._notify_user_by_email(recording)
assert result is True
assert mock_send_mail.call_count == 3
call_args_list = mock_send_mail.call_args_list
base_content = [
"ACME", # Brand name
"support@acme.com", # Support email
"https://acme.com/logo", # Logo URL
f"https://acme.com/recordings/{recording.id}", # Recording link
"Conference Room A", # Room name
]
# First call verification
subject1, body1, sender1, recipients1 = call_args_list[0][0]
assert subject1 == "Votre enregistrement est prêt"
# Verify email contains expected content
personalized_content1 = [
*base_content,
"2023-05-15", # Formatted date
"16:30", # Formatted time
"Votre enregistrement est prêt !", # Intro
]
for content in personalized_content1:
assert content in body1
assert recipients1 == ["franc@test.com"]
assert sender1 == "notifications@acme.com"
# Second call verification
subject2, body2, sender2, recipients2 = call_args_list[1][0]
assert subject2 == "Je opname is klaar"
# Verify second email content (if needed)
personalized_content2 = [
*base_content,
"2023-05-15", # Formatted date
"16:30", # Formatted time
"Je opname is klaar!", # Intro
]
for content in personalized_content2:
assert content in body2
assert recipients2 == ["berry@test.com"]
assert sender2 == "notifications@acme.com"
# Third call verification
subject3, body3, sender3, recipients3 = call_args_list[2][0]
assert subject3 == "Your recording is ready"
# Verify second email content (if needed)
personalized_content3 = [
*base_content,
"Conference Room A", # Room name
"2023-05-15", # Formatted date
"07:30", # Formatted time
"Your recording is ready!", # Intro
]
for content in personalized_content3:
assert content in body3
assert recipients3 == ["john@test.com"]
assert sender3 == "notifications@acme.com"
def test_notify_user_by_email_no_owners(mocked_current_site, caplog):
"""Test email notification when no owners are found."""
# Recording with no access
recording = factories.RecordingFactory()
result = notification_service._notify_user_by_email(recording)
assert result is False
assert f"No owner found for recording {recording.id}" in caplog.text
def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
"""Test email notification when an exception occurs."""
recording = factories.RecordingFactory(room__name="Conference Room A")
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER
)
factories.UserRecordingAccessFactory(
recording=recording, role=models.RoleChoices.OWNER
)
notification_service = NotificationService()
with mock.patch(
"core.recording.event.notification.send_mail",
side_effect=smtplib.SMTPException("SMTP Error"),
) as mock_send_mail:
result = notification_service._notify_user_by_email(recording)
assert result is False
assert mock_send_mail.call_count == 2
assert "notification could not be sent:" in caplog.text
@@ -2,7 +2,7 @@
Test event parsers.
"""
# pylint: disable=protected-access,redefined-outer-name,unused-argument
# pylint: disable=W0212,W0621,W0613
from unittest import mock
@@ -129,11 +129,8 @@ def test_validate_invalid_filetype(minio_parser):
@pytest.mark.parametrize(
"invalid_filepath",
[
"invalid_filepath", # totally invalid string
"invalid_filepath",
"recording/46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
"recording/46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing extension
"46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing url_encoded_folder_path and extension
"", # empty string
"recording%2F46d1a1212426484d8fb309b5d886f7a8.ogg",
],
)
@@ -1,72 +0,0 @@
"""
Test RecordingEventsService service.
"""
# pylint: disable=redefined-outer-name
from unittest import mock
import pytest
from core.factories import RecordingFactory
from core.recording.services.recording_events import (
RecordingEventsError,
RecordingEventsService,
)
from core.utils import NotificationError
pytestmark = pytest.mark.django_db
@pytest.fixture
def service():
"""Initialize RecordingEventsService."""
return RecordingEventsService()
@pytest.mark.parametrize(
("mode", "notification_type"),
(
("screen_recording", "screenRecordingLimitReached"),
("transcript", "transcriptionLimitReached"),
),
)
@mock.patch("core.utils.notify_participants")
def test_handle_limit_reached_success(mock_notify, mode, notification_type, service):
"""Test handle_limit_reached stops recording and notifies participants."""
recording = RecordingFactory(status="active", mode=mode)
service.handle_limit_reached(recording)
assert recording.status == "stopped"
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", "screenRecordingLimitReached"),
("transcript", "transcriptionLimitReached"),
),
)
@mock.patch("core.utils.notify_participants")
def test_handle_limit_reached_error(mock_notify, mode, notification_type, service):
"""Test handle_limit_reached raises RecordingEventsError when notification fails."""
mock_notify.side_effect = NotificationError("Error notifying")
recording = RecordingFactory(status="active", mode=mode)
with pytest.raises(
RecordingEventsError,
match=r"Failed to notify participants in room '.+' "
r"about recording limit reached \(recording_id=.+\)",
):
service.handle_limit_reached(recording)
assert recording.status == "stopped"
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
@@ -22,37 +22,15 @@ def test_api_recordings_list_anonymous():
assert response.status_code == 401
def test_api_recordings_list_authenticated_no_recording():
"""
Authenticated users listing recordings should only
see recordings to which they have access.
"""
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
other_user = factories.UserFactory()
factories.UserRecordingAccessFactory(user=other_user)
response = client.get(
"/api/v1.0/recordings/",
)
assert response.status_code == 200
results = response.json()["results"]
assert results == []
@pytest.mark.parametrize(
"role",
["administrator", "member", "owner"],
)
def test_api_recordings_list_authenticated_direct(role, settings):
def test_api_recordings_list_authenticated_direct(role):
"""
Authenticated users listing recordings, should only see the recordings
to which they are related.
"""
settings.RECORDING_EXPIRATIONS_DAYS = None
user = factories.UserFactory()
client = APIClient()
client.force_login(user)
@@ -79,19 +57,15 @@ def test_api_recordings_list_authenticated_direct(role, settings):
assert expected_ids == result_ids
assert results[0] == {
"id": str(recording.id),
"key": recording.key,
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
"mode": recording.mode,
"room": {
"access_level": str(room.access_level),
"id": str(room.id),
"is_public": room.is_public,
"name": room.name,
"slug": room.slug,
},
"status": "initiated",
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
"expired_at": None,
"is_expired": False,
}
@@ -1,284 +0,0 @@
"""
Test media-auth authorization API endpoint in docs core app.
"""
from io import BytesIO
from urllib.parse import urlparse
from uuid import uuid4
from django.conf import settings
from django.core.files.storage import default_storage
from django.utils import timezone
import pytest
import requests
from rest_framework.test import APIClient
from core import models
from core.factories import RecordingFactory, UserFactory, UserRecordingAccessFactory
pytestmark = pytest.mark.django_db
def test_api_recordings_media_auth_unauthenticated():
"""
Test that unauthenticated requests to download media are rejected
"""
original_url = f"http://localhost/media/recordings/{uuid4()!s}.mp4"
response = APIClient().get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 401
def test_api_recordings_media_auth_wrong_path():
"""
Test that media URLs with incorrect path structures are rejected
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
original_url = f"http://localhost/media/wrong-path/{uuid4()!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
def test_api_recordings_media_auth_unknown_recording():
"""
Test that requests for non-existent recordings are properly handled
"""
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 == 404
def test_api_recordings_media_auth_no_abilities():
"""
Test that users without any access permissions cannot download recordings
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED)
original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
def test_api_recordings_media_auth_wrong_abilities():
"""
Test that users with insufficient role permissions cannot download recordings
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED)
UserRecordingAccessFactory(user=user, recording=recording, role="member")
original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
@pytest.mark.parametrize("wrong_status", ["initiated", "active", "failed_to_stop"])
def test_api_recordings_media_auth_unsaved(wrong_status):
"""
Test that recordings that aren't in 'saved' status cannot be downloaded
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
recording = RecordingFactory(status=wrong_status)
UserRecordingAccessFactory(user=user, recording=recording, role="owner")
original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 403
def test_api_recordings_media_auth_mismatched_extension():
"""
Test that requests with mismatched file extensions are rejected
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
recording = RecordingFactory(
status=models.RecordingStatusChoices.SAVED,
mode=models.RecordingModeChoices.TRANSCRIPT,
)
UserRecordingAccessFactory(user=user, recording=recording, role="owner")
original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 404
assert response.json() == {"detail": "No recording found with this extension."}
@pytest.mark.parametrize(
"wrong_extension", ["jpg", "txt", "mp3"], ids=["image", "text", "audio"]
)
def test_api_recordings_media_auth_wrong_extension(wrong_extension):
"""
Trying to download a recording with an unsupported extension should return
a validation error (400) with details about allowed extensions.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED)
UserRecordingAccessFactory(user=user, recording=recording, role="owner")
original_url = (
f"http://localhost/media/recordings/{recording.id!s}.{wrong_extension}"
)
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 400
assert response.json() == {"detail": "Unsupported extension."}
@pytest.mark.parametrize("mode", ["screen_recording", "transcript"])
def test_api_recordings_media_auth_success_owner(mode):
"""
Test downloading a recording when logged in and authorized.
Verifies S3 authentication headers and successful file retrieval.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED, mode=mode)
UserRecordingAccessFactory(user=user, recording=recording, role="owner")
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=recording.key,
Body=BytesIO(b"my prose"),
ContentType="text/plain",
)
original_url = f"http://localhost/media/{recording.key:s}"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/meet-media-storage/{recording.key:s}"
response = requests.get(
file_url,
headers={
"authorization": authorization,
"x-amz-date": response["x-amz-date"],
"x-amz-content-sha256": response["x-amz-content-sha256"],
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
},
timeout=1,
)
assert response.content.decode("utf-8") == "my prose"
@pytest.mark.parametrize("mode", ["screen_recording", "transcript"])
def test_api_recordings_media_auth_success_administrator(mode):
"""
Test downloading a recording when logged in and authorized.
Verifies S3 authentication headers and successful file retrieval.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED, mode=mode)
UserRecordingAccessFactory(user=user, recording=recording, role="administrator")
default_storage.connection.meta.client.put_object(
Bucket=default_storage.bucket_name,
Key=recording.key,
Body=BytesIO(b"my prose"),
ContentType="text/plain",
)
original_url = f"http://localhost/media/{recording.key:s}"
response = client.get(
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
)
assert response.status_code == 200
authorization = response["Authorization"]
assert "AWS4-HMAC-SHA256 Credential=" in authorization
assert (
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
in authorization
)
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/meet-media-storage/{recording.key:s}"
response = requests.get(
file_url,
headers={
"authorization": authorization,
"x-amz-date": response["x-amz-date"],
"x-amz-content-sha256": response["x-amz-content-sha256"],
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
},
timeout=1,
)
assert response.content.decode("utf-8") == "my prose"
@@ -1,243 +0,0 @@
"""
Test recordings API endpoints in the Meet core app: retrieve.
"""
import random
import pytest
from freezegun import freeze_time
from rest_framework.test import APIClient
from ...factories import RecordingFactory, UserFactory, UserRecordingAccessFactory
from ...models import RecordingStatusChoices
pytestmark = pytest.mark.django_db
def test_api_recording_retrieve_anonymous():
"""Anonymous users should not be able to retrieve recordings."""
recording = RecordingFactory()
client = APIClient()
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 401
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_api_recording_retrieve_authenticated():
"""Authenticated users without access receive 404 when requesting recordings.
The API returns 404 instead of 403 to avoid revealing the existence of
resources the user doesn't have permission to access.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
other_user = UserFactory()
recording = UserRecordingAccessFactory(user=other_user).recording
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 404
assert response.json() == {"detail": "No Recording matches the given query."}
def test_api_recording_retrieve_members():
"""
A user who is a member of a recording should not be able to retrieve it.
"""
user = UserFactory()
recording = RecordingFactory()
UserRecordingAccessFactory(recording=recording, user=user, role="member")
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_api_recording_retrieve_administrators(settings):
"""A user who is an administrator of a recording should be able to retrieve it."""
settings.RECORDING_EXPIRATION_DAYS = None
user = UserFactory()
recording = RecordingFactory()
UserRecordingAccessFactory(recording=recording, user=user, role="administrator")
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 200
content = response.json()
room = recording.room
assert content == {
"id": str(recording.id),
"key": recording.key,
"room": {
"access_level": str(room.access_level),
"id": str(room.id),
"name": room.name,
"slug": room.slug,
},
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
"status": str(recording.status),
"mode": str(recording.mode),
"expired_at": None,
"is_expired": False,
}
def test_api_recording_retrieve_owners(settings):
"""A user who is an owner of a recording should be able to retrieve it."""
settings.RECORDING_EXPIRATION_DAYS = None
user = UserFactory()
recording = RecordingFactory()
UserRecordingAccessFactory(recording=recording, user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 200
content = response.json()
room = recording.room
assert content == {
"id": str(recording.id),
"key": recording.key,
"room": {
"access_level": str(room.access_level),
"id": str(room.id),
"name": room.name,
"slug": room.slug,
},
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
"status": str(recording.status),
"mode": str(recording.mode),
"expired_at": None,
"is_expired": False,
}
@freeze_time("2023-01-15 12:00:00")
def test_api_recording_retrieve_compute_expiration_date_correctly(settings):
"""Test that the API returns the correct expiration date for a non-expired recording."""
settings.RECORDING_EXPIRATION_DAYS = 1
user = UserFactory()
recording = RecordingFactory()
UserRecordingAccessFactory(
recording=recording, user=user, role=random.choice(["administrator", "owner"])
)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 200
content = response.json()
room = recording.room
assert content == {
"id": str(recording.id),
"key": recording.key,
"room": {
"access_level": str(room.access_level),
"id": str(room.id),
"name": room.name,
"slug": room.slug,
},
"created_at": "2023-01-15T12:00:00Z",
"updated_at": "2023-01-15T12:00:00Z",
"status": str(recording.status),
"mode": str(recording.mode),
"expired_at": "2023-01-16T12:00:00Z",
"is_expired": False, # Ensure the recording is still valid and hasn't expired
}
def test_api_recording_retrieve_expired(settings):
"""Test that the API returns the correct expiration date and flag for an expired recording."""
settings.RECORDING_EXPIRATION_DAYS = 2
user = UserFactory()
with freeze_time("2023-01-15 12:00:00"):
recording = RecordingFactory()
UserRecordingAccessFactory(
recording=recording, user=user, role=random.choice(["administrator", "owner"])
)
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 200
content = response.json()
room = recording.room
assert content == {
"id": str(recording.id),
"key": recording.key,
"room": {
"access_level": str(room.access_level),
"id": str(room.id),
"name": room.name,
"slug": room.slug,
},
"created_at": "2023-01-15T12:00:00Z",
"updated_at": "2023-01-15T12:00:00Z",
"status": str(recording.status),
"mode": str(recording.mode),
"expired_at": "2023-01-17T12:00:00Z",
"is_expired": True, # Ensure the recording has expired
}
@pytest.mark.parametrize(
"status",
[
RecordingStatusChoices.INITIATED,
RecordingStatusChoices.ACTIVE,
RecordingStatusChoices.SAVED,
RecordingStatusChoices.FAILED_TO_START,
RecordingStatusChoices.FAILED_TO_STOP,
RecordingStatusChoices.ABORTED,
],
)
def test_api_recording_retrieve_any_status(status):
"""Test that recordings with any status can be retrieved."""
user = UserFactory()
recording = RecordingFactory(status=status)
UserRecordingAccessFactory(recording=recording, user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
assert response.status_code == 200
content = response.json()
assert content["id"] == str(recording.id)
assert content["status"] == status
@@ -2,7 +2,7 @@
Test recordings API endpoints in the Meet core app: save recording.
"""
# pylint: disable=redefined-outer-name,unused-argument
# pylint: disable=W0621,W0613
import uuid
from unittest import mock
@@ -77,8 +77,7 @@ def test_save_recording_permission_needed(settings, client):
HTTP_AUTHORIZATION="Bearer testAuthToken",
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.status_code == 403
def test_save_recording_parsing_error(recording_settings, mock_get_parser, client):
@@ -2,7 +2,7 @@
Test worker service factories.
"""
# pylint: disable=protected-access,redefined-outer-name,unused-argument
# pylint: disable=W0212,W0621,W0613
from dataclasses import FrozenInstanceError
from unittest.mock import Mock
@@ -32,6 +32,7 @@ def test_settings():
mocked_settings = {
"RECORDING_OUTPUT_FOLDER": "/test/output",
"LIVEKIT_CONFIGURATION": {"server": "test.example.com"},
"RECORDING_VERIFY_SSL": True,
"AWS_S3_ENDPOINT_URL": "https://s3.test.com",
"AWS_S3_ACCESS_KEY_ID": "test_key",
"AWS_S3_SECRET_ACCESS_KEY": "test_secret",
@@ -55,6 +56,7 @@ def test_config_initialization(default_config):
"""Test that WorkerServiceConfig is properly initialized from settings"""
assert default_config.output_folder == "/test/output"
assert default_config.server_configurations == {"server": "test.example.com"}
assert default_config.verify_ssl is True
assert default_config.bucket_args == {
"endpoint": "https://s3.test.com",
"access_key": "test_key",
@@ -74,6 +76,7 @@ def test_config_immutability(default_config):
@override_settings(
RECORDING_OUTPUT_FOLDER="/test/output",
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
RECORDING_VERIFY_SSL=True,
AWS_S3_ENDPOINT_URL="https://s3.test.com",
AWS_S3_ACCESS_KEY_ID="test_key",
AWS_S3_SECRET_ACCESS_KEY="test_secret",
@@ -1,6 +1,6 @@
"""Test WorkerServiceMediator class."""
# pylint: disable=redefined-outer-name,unused-argument
# pylint: disable=W0621,W0613
from unittest.mock import Mock
@@ -2,13 +2,14 @@
Test worker service classes.
"""
# pylint: disable=protected-access,redefined-outer-name,unused-argument,no-member
# pylint: disable=W0212,W0621,W0613,E1101
from unittest.mock import AsyncMock, Mock, patch
import aiohttp
import pytest
from core.recording.worker.exceptions import WorkerResponseError
from core.recording.worker.exceptions import WorkerConnectionError, WorkerResponseError
from core.recording.worker.factories import WorkerServiceConfig
from core.recording.worker.services import (
AudioCompositeEgressService,
@@ -24,10 +25,11 @@ def config():
return WorkerServiceConfig(
output_folder="/test/output",
server_configurations={
"url": "test.livekit.io",
"host": "test.livekit.io",
"api_key": "test_key",
"api_secret": "test_secret",
},
verify_ssl=True,
bucket_args={
"endpoint": "https://s3.test.com",
"access_key": "test_key",
@@ -125,6 +127,58 @@ def test_base_egress_filepath_construction(service, filename, extension, expecte
assert result.endswith(f"{filename}.{extension}")
def test_base_egress_handle_request_success(
config, service, mock_client_session, mock_egress_service, mock_tcp_connector
):
"""Test successful request handling"""
# Setup mock response
mock_response = Mock()
mock_method = AsyncMock(return_value=mock_response)
mock_egress_instance = Mock()
mock_egress_instance.test_method = mock_method
mock_egress_service.return_value = mock_egress_instance
# Create test request
test_request = Mock()
response = service._handle_request(test_request, "test_method")
mock_client_session.assert_called_once_with(
connector=mock_tcp_connector.return_value
)
# Verify EgressService initialization
mock_egress_service.assert_called_once_with(
mock_client_session.return_value.__aenter__.return_value,
**service._config.server_configurations,
)
# Verify method call and response
mock_method.assert_called_once_with(test_request)
assert response == mock_response
def test_base_egress_handle_request_connection_error(service, mock_egress_service):
"""Test handling of connection errors"""
# Setup mock error
mock_method = AsyncMock(
side_effect=livekit_api.TwirpError(msg="Connection failed", code=500)
)
mock_egress_instance = Mock()
mock_egress_instance.test_method = mock_method
mock_egress_service.return_value = mock_egress_instance
# Create test request
test_request = Mock()
# Verify error handling
with pytest.raises(WorkerConnectionError) as exc:
service._handle_request(test_request, "test_method")
assert "LiveKit client connection error" in str(exc.value)
assert "Connection failed" in str(exc.value)
@pytest.mark.parametrize(
"response_status,expected_result",
[
@@ -170,6 +224,43 @@ def test_base_egress_start_not_implemented(service):
assert "Subclass must implement this method" in str(exc.value)
@pytest.mark.parametrize("verify_ssl", [True, False])
def test_base_egress_ssl_verification_config(verify_ssl):
"""Test SSL verification configuration"""
config = WorkerServiceConfig(
output_folder="/test/output",
server_configurations={
"host": "test.livekit.io",
"api_key": "test_key",
"api_secret": "test_secret",
},
verify_ssl=verify_ssl,
bucket_args={
"endpoint": "https://s3.test.com",
"access_key": "test_key",
"secret": "test_secret",
"region": "test-region",
"bucket": "test-bucket",
"force_path_style": True,
},
)
service = BaseEgressService(config)
# Mock ClientSession to capture connector configuration
with patch("aiohttp.ClientSession") as mock_session:
mock_session.return_value.__aenter__ = AsyncMock()
mock_session.return_value.__aexit__ = AsyncMock()
# Trigger request to verify connector configuration
service._handle_request(Mock(), "test_method")
# Verify SSL configuration
connector = mock_session.call_args[1]["connector"]
assert isinstance(connector, aiohttp.TCPConnector)
assert connector._ssl == verify_ssl
def test_video_composite_egress_hrid(video_service):
"""Test HRID is correct"""
assert video_service.hrid == "video-recording-composite-livekit-egress"
@@ -2,9 +2,6 @@
Test rooms API endpoints in the Meet core app: create.
"""
# pylint: disable=redefined-outer-name,unused-argument
from django.core.cache import cache
import pytest
from rest_framework.test import APIClient
@@ -14,15 +11,6 @@ from ...models import Room
pytestmark = pytest.mark.django_db
@pytest.fixture
def reset_cache():
"""Provide cache cleanup after each test to maintain test isolation."""
yield
keys = cache.keys("room-creation-callback_*")
if keys:
cache.delete(*keys)
def test_api_rooms_create_anonymous():
"""Anonymous users should not be allowed to create rooms."""
client = APIClient()
@@ -38,7 +26,7 @@ def test_api_rooms_create_anonymous():
assert Room.objects.exists() is False
def test_api_rooms_create_authenticated(reset_cache):
def test_api_rooms_create_authenticated():
"""
Authenticated users should be able to create rooms and should automatically be declared
as owner of the newly created room.
@@ -61,33 +49,6 @@ def test_api_rooms_create_authenticated(reset_cache):
assert room.slug == "my-room"
assert room.accesses.filter(role="owner", user=user).exists() is True
rooms_data = cache.keys("room-creation-callback_*")
assert not rooms_data
def test_api_rooms_create_generation_cache(reset_cache):
"""
Authenticated users creating a room with a callback ID should have room data stored in cache.
"""
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
"/api/v1.0/rooms/",
{"name": "my room", "callback_id": "1234"},
)
assert response.status_code == 201
room = Room.objects.get()
assert room.name == "my room"
assert room.slug == "my-room"
assert room.accesses.filter(role="owner", user=user).exists() is True
room_data = cache.get("room-creation-callback_1234")
assert room_data.get("slug") == "my-room"
def test_api_rooms_create_authenticated_existing_slug():
"""
@@ -1,98 +0,0 @@
"""
Test rooms API endpoints in the Meet core app: creation callback functionality.
"""
# pylint: disable=redefined-outer-name,unused-argument
from django.core.cache import cache
import pytest
from rest_framework.test import APIClient
from ...factories import UserFactory
pytestmark = pytest.mark.django_db
# Tests for creation_callback endpoint
@pytest.fixture
def reset_cache():
"""Provide cache cleanup after each test to maintain test isolation."""
yield
keys = cache.keys("room-creation-callback_*")
if keys:
cache.delete(*keys)
def test_api_rooms_create_anonymous(reset_cache):
"""Anonymous user can retrieve room data once using a valid callback ID."""
client = APIClient()
cache.set("room-creation-callback_123", {"slug": "my room"})
response = client.post(
"/api/v1.0/rooms/creation-callback/",
{
"callback_id": "123",
},
)
assert response.status_code == 200
assert response.json() == {"status": "success", "room": {"slug": "my room"}}
# Data should be cleared after retrieval
response = client.post(
"/api/v1.0/rooms/creation-callback/",
{
"callback_id": "123",
},
)
assert response.status_code == 200
assert response.json() == {"status": "success", "room": {}}
def test_api_rooms_create_empty_cache():
"""Valid callback ID return empty room data when nothing is stored in the cache."""
client = APIClient()
response = client.post(
"/api/v1.0/rooms/creation-callback/",
{
"callback_id": "123",
},
)
assert response.status_code == 200
assert response.json() == {"status": "success", "room": {}}
def test_api_rooms_create_missing_callback_id():
"""Requests without a callback ID properly fail with a 400 status code."""
client = APIClient()
response = client.post(
"/api/v1.0/rooms/creation-callback/",
{},
)
assert response.status_code == 400
def test_api_rooms_create_authenticated(reset_cache):
"""Authenticated users can also successfully retrieve room data using a valid callback ID"""
user = UserFactory()
client = APIClient()
client.force_login(user)
cache.set("room-creation-callback_123", {"slug": "my room"})
response = client.post(
"/api/v1.0/rooms/creation-callback/",
{
"callback_id": "123",
},
)
assert response.status_code == 200
assert response.json() == {"status": "success", "room": {"slug": "my room"}}
@@ -1,294 +0,0 @@
"""
Test rooms API endpoints in the Meet core app: invite.
"""
# pylint: disable=redefined-outer-name,unused-argument
import json
import random
from unittest import mock
import pytest
from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory
from ...services.invitation import InvitationError, InvitationService
pytestmark = pytest.mark.django_db
def test_api_rooms_invite_anonymous():
"""Test anonymous users should not be allowed to invite people to rooms."""
client = APIClient()
room = RoomFactory()
data = {"emails": ["toto@yopmail.com"]}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 401
def test_api_rooms_invite_no_access():
"""Test non-privileged users should not be allowed to invite people to rooms."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
client.force_login(user)
data = {"emails": ["toto@yopmail.com"]}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You must have privileges on room to perform this action.",
}
def test_api_rooms_invite_member():
"""Test member users should not be allowed to invite people to rooms."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
client.force_login(user)
room.accesses.create(user=user, role="member")
data = {"emails": ["toto@yopmail.com"]}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 403
assert response.json() == {
"detail": "You must have privileges on room to perform this action.",
}
def test_api_rooms_invite_missing_emails():
"""Test missing email list should return validation error."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user)
data = {"foo": []}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 400
assert response.json() == {
"emails": [
"This field is required.",
]
}
def test_api_rooms_invite_empty_emails():
"""Test empty email list should return validation error."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user)
data = {"emails": []}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 400
assert response.json() == {
"emails": [
"This list may not be empty.",
]
}
def test_api_rooms_invite_invalid_emails():
"""Test invalid email addresses should return validation errors."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user)
data = {"emails": ["abdc", "efg"]}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 400
assert response.json() == {
"emails": {
"0": ["Enter a valid email address."],
"1": ["Enter a valid email address."],
}
}
def test_api_rooms_invite_partially_invalid_emails():
"""Test partially invalid email addresses should return validation errors."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user)
data = {"emails": ["fabrice@yopmail.com", "efg"]}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 400
assert response.json() == {
"emails": {
"1": ["Enter a valid email address."],
}
}
@mock.patch.object(InvitationService, "invite_to_room")
def test_api_rooms_invite_duplicates(mock_invite_to_room):
"""Test duplicate emails should be deduplicated before processing."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user)
data = {"emails": ["toto@yopmail.com", "toto@yopmail.com", "Toto@yopmail.com"]}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 200
mock_invite_to_room.assert_called_once()
_, kwargs = mock_invite_to_room.call_args
assert kwargs["room"] == room
assert kwargs["sender"] == user
assert sorted(kwargs["emails"]) == sorted(["Toto@yopmail.com", "toto@yopmail.com"])
@mock.patch.object(InvitationService, "invite_to_room", side_effect=InvitationError())
def test_api_rooms_invite_error(mock_invite_to_room):
"""Test invitation service error should return appropriate error response."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user)
data = {"emails": ["toto@yopmail.com", "toto@yopmail.com"]}
with pytest.raises(InvitationError):
client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
mock_invite_to_room.assert_called_once()
@mock.patch("core.services.invitation.send_mail")
def test_api_rooms_invite_success(mock_send_mail, settings):
"""Test privileged users should successfully send invitation emails."""
settings.EMAIL_BRAND_NAME = "ACME"
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
settings.EMAIL_APP_BASE_URL = "https://acme.com"
settings.EMAIL_FROM = "notifications@acme.com"
settings.EMAIL_DOMAIN = "acme.com"
client = APIClient()
room = RoomFactory()
user = UserFactory()
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
client.force_login(user)
data = {"emails": ["fabien@yopmail.com", "gerald@yopmail.com"]}
response = client.post(
f"/api/v1.0/rooms/{room.id}/invite/",
json.dumps(data),
content_type="application/json",
)
assert response.status_code == 200
assert response.json() == {"status": "success", "message": "invitations sent"}
mock_send_mail.assert_called_once()
subject, body, sender, recipients = mock_send_mail.call_args[0]
assert (
subject == f"Video call in progress: {user.email} is waiting for you to connect"
)
# Verify email contains expected content
required_content = [
"ACME", # Brand name
"https://acme.com/logo", # Logo URL
f"https://acme.com/{room.slug}", # Room url
f"acme.com/{room.slug}", # Room link
]
for content in required_content:
assert content in body
assert sender == "notifications@acme.com"
# Verify all owners received the email (order-independent comparison)
assert sorted(recipients) == sorted(["fabien@yopmail.com", "gerald@yopmail.com"])
@@ -9,16 +9,14 @@ from rest_framework.pagination import PageNumberPagination
from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory
from ...models import RoomAccessLevel
pytestmark = pytest.mark.django_db
def test_api_rooms_list_anonymous():
"""Anonymous users should not be able to list rooms."""
RoomFactory(access_level=RoomAccessLevel.PUBLIC)
RoomFactory(access_level=RoomAccessLevel.TRUSTED)
RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
RoomFactory(is_public=False)
RoomFactory(is_public=True)
client = APIClient()
@@ -40,13 +38,10 @@ def test_api_rooms_list_authenticated():
other_user = UserFactory()
RoomFactory(access_level=RoomAccessLevel.PUBLIC)
RoomFactory(access_level=RoomAccessLevel.TRUSTED)
RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
room_user_accesses = RoomFactory(
access_level=RoomAccessLevel.RESTRICTED, users=[user]
)
RoomFactory(access_level=RoomAccessLevel.RESTRICTED, users=[other_user])
RoomFactory(is_public=False)
RoomFactory(is_public=True)
room_user_accesses = RoomFactory(is_public=False, users=[user])
RoomFactory(is_public=False, users=[other_user])
response = client.get(
"/api/v1.0/rooms/",
@@ -110,7 +105,7 @@ def test_api_rooms_list_authenticated_distinct():
client = APIClient()
client.force_login(user)
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC, users=[user, other_user])
room = RoomFactory(is_public=True, users=[user, other_user])
response = client.get(
"/api/v1.0/rooms/",
@@ -1,633 +0,0 @@
"""
Test rooms API endpoints in the Meet core app: lobby functionality.
"""
# pylint: disable=W0621,W0613,W0212
import uuid
from unittest import mock
from django.core.cache import cache
import pytest
from rest_framework.test import APIClient
from ... import utils
from ...factories import RoomFactory, UserFactory
from ...models import RoomAccessLevel
from ...services.lobby import (
LobbyService,
)
pytestmark = pytest.mark.django_db
# Tests for request_entry endpoint
def test_request_entry_anonymous(settings):
"""Anonymous users should be allowed to request entry to a room."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Lobby cache should be empty before the request
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert not lobby_keys
with (
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
):
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
# Verify the lobby cookie was properly set
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
participant_id = cookie.value
# Verify response content matches expected structure and values
assert response.json() == {
"id": participant_id,
"username": "test_user",
"status": "waiting",
"color": "mocked-color",
"livekit": None,
}
# Verify a participant was stored in cache
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert len(lobby_keys) == 1
# Verify participant data was correctly stored in cache
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{participant_id}")
assert participant_data.get("username") == "test_user"
def test_request_entry_authenticated_user(settings):
"""Authenticated users should be allowed to request entry."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
user = UserFactory()
client = APIClient()
client.force_login(user)
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Lobby cache should be empty before the request
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert not lobby_keys
with (
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
):
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
# Verify the lobby cookie was properly set
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
participant_id = cookie.value
# Verify response content matches expected structure and values
assert response.json() == {
"id": participant_id,
"username": "test_user",
"status": "waiting",
"color": "mocked-color",
"livekit": None,
}
# Verify a participant was stored in cache
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert len(lobby_keys) == 1
# Verify participant data was correctly stored in cache
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{participant_id}")
assert participant_data.get("username") == "test_user"
def test_request_entry_with_existing_participants(settings):
"""Anonymous users should be allowed to request entry to a room with existing participants."""
# Create a restricted access room
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
client = APIClient()
# Configure test settings for cookies and cache
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Add two participants already waiting in the lobby
cache.set(
f"mocked-cache-prefix_{room.id}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
{
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"username": "user1",
"status": "waiting",
"color": "#123456",
},
)
cache.set(
f"mocked-cache-prefix_{room.id}_f4ca3ab8a6c04ad88097b8da33f60f10",
{
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
"username": "user2",
"status": "accepted",
"color": "#654321",
},
)
# Verify two participants are in the lobby before the request
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert len(lobby_keys) == 2
# Mock external service calls to isolate the test
with (
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
):
# Make request as a new anonymous user
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
# Verify successful response
assert response.status_code == 200
# Verify the lobby cookie was properly set for the new participant
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
participant_id = cookie.value
# Verify response content matches expected structure and values
assert response.json() == {
"id": participant_id,
"username": "test_user",
"status": "waiting",
"color": "mocked-color",
"livekit": None,
}
# Verify now three participants are in the lobby cache
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert len(lobby_keys) == 3
# Verify the new participant data was correctly stored in cache
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{participant_id}")
assert participant_data.get("username") == "test_user"
def test_request_entry_public_room(settings):
"""Entry requests to public rooms should return ACCEPTED status with LiveKit config."""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
client = APIClient()
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Lobby cache should be empty before the request
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert not lobby_keys
with (
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(
LobbyService, "_get_or_create_participant_id", return_value="123"
),
mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"}
),
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
):
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
# Verify the lobby cookie was set
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
assert cookie.value == "123"
# Verify response content matches expected structure and values
assert response.json() == {
"id": "123",
"username": "test_user",
"status": "accepted",
"color": "mocked-color",
"livekit": {"token": "test-token"},
}
# Verify lobby cache is still empty after the request
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert not lobby_keys
def test_request_entry_authenticated_user_public_room(settings):
"""While authenticated, entry request to public rooms should get accepted."""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
user = UserFactory()
client = APIClient()
client.force_login(user)
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Lobby cache should be empty before the request
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert not lobby_keys
with (
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(
LobbyService,
"_get_or_create_participant_id",
return_value="2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
),
mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"}
),
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
):
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "test_user"},
)
assert response.status_code == 200
# Verify the lobby cookie was set
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
assert cookie.value == "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
# Verify response content matches expected structure and values
assert response.json() == {
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"username": "test_user",
"status": "accepted",
"color": "mocked-color",
"livekit": {"token": "test-token"},
}
# Verify lobby cache is still empty after the request
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert not lobby_keys
def test_request_entry_waiting_participant_public_room(settings):
"""While waiting, entry request to public rooms should get accepted."""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
client = APIClient()
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Add a waiting participant to the room's lobby cache
cache.set(
f"mocked-cache-prefix_{room.id}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
{
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"username": "user1",
"status": "waiting",
"color": "#123456",
},
)
# Simulate a browser with existing participant cookie
client.cookies.load({"mocked-cookie": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"})
with (
mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"}
),
):
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{"username": "user1"},
)
assert response.status_code == 200
# Verify the lobby cookie was set
cookie = response.cookies.get("mocked-cookie")
assert cookie is not None
assert cookie.value == "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
# Verify response content matches expected structure and values
assert response.json() == {
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"username": "user1",
"status": "accepted",
"color": "#123456",
"livekit": {"token": "test-token"},
}
# Verify participant remains in the lobby cache after acceptance
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert len(lobby_keys) == 1
def test_request_entry_invalid_data():
"""Should return 400 for invalid request data."""
room = RoomFactory()
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/request-entry/",
{}, # Missing required username field
)
assert response.status_code == 400
def test_request_entry_room_not_found():
"""Should return 404 for non-existent room."""
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{uuid.uuid4()!s}/request-entry/",
{"username": "anonymous"},
)
assert response.status_code == 404
# Tests for allow_participant_to_enter endpoint
def test_allow_participant_to_enter_anonymous():
"""Anonymous users should not be allowed to manage entry requests."""
room = RoomFactory()
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True},
)
assert response.status_code == 401
def test_allow_participant_to_enter_non_owner():
"""Non-privileged users should not be allowed to manage entry requests."""
room = RoomFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True},
)
assert response.status_code == 403
def test_allow_participant_to_enter_public_room():
"""Should return 404 for public rooms that don't use the lobby system."""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
user = UserFactory()
# Make user the room owner
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True},
)
assert response.status_code == 404
assert response.json() == {"message": "Room has no lobby system."}
@pytest.mark.parametrize(
"allow_entry, updated_status", [(True, "accepted"), (False, "denied")]
)
def test_allow_participant_to_enter_success(settings, allow_entry, updated_status):
"""Should successfully update participant status when everything is correct."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
user = UserFactory()
# Make user the room owner
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
cache.set(
f"mocked-cache-prefix_{room.id!s}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
{
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"status": "waiting",
"username": "foo",
"color": "123",
},
)
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{
"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"allow_entry": allow_entry,
},
)
assert response.status_code == 200
assert response.json() == {"message": "Participant was updated."}
participant_data = cache.get(
f"mocked-cache-prefix_{room.id!s}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
)
assert participant_data.get("status") == updated_status
def test_allow_participant_to_enter_participant_not_found(settings):
"""Should handle case when participant is not found."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
user = UserFactory()
# Make user the room owner
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
participant_data = cache.get(
f"mocked-cache-prefix_{room.id!s}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
)
assert participant_data is None
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True},
)
assert response.status_code == 404
assert response.json() == {"message": "Participant not found."}
def test_allow_participant_to_enter_invalid_data():
"""Should return 400 for invalid request data."""
room = RoomFactory()
user = UserFactory()
# Make user the room owner
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/enter/",
{}, # Missing required fields
)
assert response.status_code == 400
# Tests for list_waiting_participants endpoint
def test_list_waiting_participants_anonymous():
"""Anonymous users should not be allowed to list waiting participants."""
room = RoomFactory()
client = APIClient()
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
assert response.status_code == 401
def test_list_waiting_participants_non_owner():
"""Non-privileged users should not be allowed to list waiting participants."""
room = RoomFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
assert response.status_code == 403
def test_list_waiting_participants_public_room():
"""Should return empty list for public rooms."""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
user = UserFactory()
# Make user the room owner
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
# Lobby cache should be empty before the request
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert not lobby_keys
with mock.patch(
"core.api.viewsets.LobbyService", autospec=True
) as mocked_lobby_service:
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
# Verify lobby service was not instantiated
mocked_lobby_service.assert_not_called()
assert response.status_code == 200
assert response.json() == {"participants": []}
def test_list_waiting_participants_success(settings):
"""Should successfully return list of waiting participants."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
user = UserFactory()
# Make user the room owner
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Add participants in the lobby
cache.set(
f"mocked-cache-prefix_{room.id}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
{
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"username": "user1",
"status": "waiting",
"color": "#123456",
},
)
cache.set(
f"mocked-cache-prefix_{room.id}_f4ca3ab8a6c04ad88097b8da33f60f10",
{
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
"username": "user2",
"status": "waiting",
"color": "#654321",
},
)
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
assert response.status_code == 200
participants = response.json().get("participants")
assert sorted(participants, key=lambda p: p["id"]) == [
{
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
"username": "user1",
"status": "waiting",
"color": "#123456",
},
{
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
"username": "user2",
"status": "waiting",
"color": "#654321",
},
]
def test_list_waiting_participants_empty(settings):
"""Should handle case when there are no waiting participants."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
user = UserFactory()
# Make user the room owner
room.accesses.create(user=user, role="owner")
client = APIClient()
client.force_login(user)
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
# Lobby cache should be empty before the request
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
assert not lobby_keys
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
assert response.status_code == 200
assert response.json() == {"participants": []}
@@ -1,417 +0,0 @@
"""
Test rooms API endpoints in the Meet core app: participants management.
"""
# pylint: disable=redefined-outer-name,unused-argument,protected-access
import random
from unittest import mock
from uuid import uuid4
from django.urls import reverse
import pytest
from livekit.api import TwirpError
from rest_framework import status
from rest_framework.test import APIClient
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
from core.services.lobby import LobbyService
pytestmark = pytest.mark.django_db
@pytest.fixture
def mock_livekit_client():
"""Mock LiveKit API client."""
with mock.patch("core.utils.create_livekit_client") as mock_create:
mock_client = mock.AsyncMock()
mock_create.return_value = mock_client
yield mock_client
def test_mute_participant_success(mock_livekit_client):
"""Test successful participant muting."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.mute_published_track.assert_called_once()
mock_livekit_client.aclose.assert_called_once()
def test_mute_participant_forbidden_without_access():
"""Test mute participant returns 403 when user lacks room privileges."""
client = APIClient()
room = RoomFactory()
user = UserFactory() # User without UserResourceAccess
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_mute_participant_invalid_payload():
"""Test mute participant with invalid payload."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {"participant_identity": "invalid-uuid", "track_sid": ""}
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_mute_participant_unexpected_twirp_error(mock_livekit_client):
"""Test mute participant when LiveKit API raises TwirpError."""
client = APIClient()
mock_livekit_client.room.mute_published_track.side_effect = TwirpError(
msg="Internal server error", code=500, status=500
)
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert response.data == {"error": "Failed to mute participant"}
mock_livekit_client.aclose.assert_called_once()
def test_update_participant_success(mock_livekit_client):
"""Test successful participant update."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4()),
"metadata": {"role": "presenter"},
"permission": {
"can_subscribe": True,
"can_publish": True,
"can_publish_data": True,
"can_publish_sources": [
1,
2,
], # [TrackSource.CAMERA, TrackSource.MICROPHONE]
"hidden": False,
"recorder": False,
"can_update_metadata": True,
"agent": False,
"can_subscribe_metrics": False,
},
"name": "John Doe",
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.update_participant.assert_called_once()
mock_livekit_client.aclose.assert_called_once()
def test_update_participant_forbidden_without_access():
"""Test update participant returns 403 when user lacks room privileges."""
client = APIClient()
room = RoomFactory()
user = UserFactory() # User without UserResourceAccess
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4()), "name": "Test User"}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_update_participant_invalid_payload():
"""Test update participant with invalid payload."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {"participant_identity": "invalid-uuid"}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "Must be a valid UUID." in str(response.data)
def test_update_participant_no_update_fields():
"""Test update participant with no update fields provided."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4())
# No metadata, attributes, permission, or name
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "At least one of the following fields must be provided" in str(response.data)
def test_update_participant_invalid_permission():
"""Test update participant with wrong permission object."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4()),
"permission": {"invalid-attributes": True},
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "Invalid permission" in str(response.data)
def test_update_participant_wrong_metadata_attributes():
"""Test update participant with wrong metadata or attributes provided."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {
"participant_identity": str(uuid4()),
"metadata": "wrong string",
"attributes": "wrong string",
}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert "metadata" in response.data or "attributes" in response.data
def test_update_participant_unexpected_twirp_error(mock_livekit_client):
"""Test update participant when LiveKit API raises TwirpError."""
client = APIClient()
mock_livekit_client.room.update_participant.side_effect = TwirpError(
msg="Internal server error", code=500, status=500
)
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4()), "name": "Test User"}
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert response.data == {"error": "Failed to update participant"}
mock_livekit_client.aclose.assert_called_once()
def test_remove_participant_success_lobby_cache(mock_livekit_client):
"""Test successful participant removal.
The lobby cache cleanup is crucial for security - without it, removed
participants could potentially re-enter the room using their cached
lobby session.
"""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
participant_identity = str(uuid4())
# Create participant in lobby cache first
LobbyService().enter(room.id, participant_identity, "John doe")
# Accept participant
LobbyService().handle_participant_entry(room.id, participant_identity, True)
payload = {"participant_identity": participant_identity}
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.remove_participant.assert_called_once()
# called twice: once for Lobby, once for ParticipantManagement
mock_livekit_client.aclose.assert_called()
# Verify lobby cache was cleared - participant should no longer exist
participant = LobbyService()._get_participant(room.id, participant_identity)
assert participant is None
def test_remove_participant_success(mock_livekit_client):
"""Test successful participant removal."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4())}
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_200_OK
assert response.data == {"status": "success"}
mock_livekit_client.room.remove_participant.assert_called_once()
mock_livekit_client.aclose.assert_called_once()
def test_remove_participant_forbidden_without_access():
"""Test remove participant returns 403 when user lacks room privileges."""
client = APIClient()
room = RoomFactory()
user = UserFactory() # User without UserResourceAccess
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4())}
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_403_FORBIDDEN
def test_remove_participant_invalid_payload():
"""Test remove participant with invalid payload."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {"participant_identity": "invalid-uuid"}
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_remove_participant_missing_identity():
"""Test remove participant with missing participant_identity."""
client = APIClient()
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {} # Missing participant_identity
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_400_BAD_REQUEST
def test_remove_participant_unexpected_twirp_error(mock_livekit_client):
"""Test remove participant when LiveKit API raises TwirpError."""
client = APIClient()
mock_livekit_client.room.remove_participant.side_effect = TwirpError(
msg="Internal server error", code=500, status=500
)
room = RoomFactory()
user = UserFactory()
UserResourceAccessFactory(
resource=room, user=user, role=random.choice(["administrator", "owner"])
)
client.force_authenticate(user=user)
payload = {"participant_identity": str(uuid4())}
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
response = client.post(url, payload, format="json")
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
assert response.data == {"error": "Failed to remove participant"}
mock_livekit_client.aclose.assert_called_once()
@@ -12,7 +12,6 @@ import pytest
from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory, UserResourceAccessFactory
from ...models import RoomAccessLevel
pytestmark = pytest.mark.django_db
@@ -22,44 +21,23 @@ def test_api_rooms_retrieve_anonymous_private_pk():
Anonymous users should be allowed to retrieve a private room but should not be
given any token.
"""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
room = RoomFactory(is_public=False)
client = APIClient()
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
assert response.status_code == 200
assert response.json() == {
"access_level": "restricted",
"id": str(room.id),
"is_administrable": False,
"is_public": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
def test_api_rooms_retrieve_anonymous_trusted_pk():
"""
Anonymous users should be allowed to retrieve a room that has a trusted access_level,
but should not be given any token.
"""
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
client = APIClient()
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
assert response.status_code == 200
assert response.json() == {
"access_level": "trusted",
"id": str(room.id),
"is_administrable": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
"""It should be possible to get a room by its id stripped of its dashes."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
room = RoomFactory(is_public=False)
id_no_dashes = str(room.id)
client = APIClient()
@@ -67,45 +45,42 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
assert response.status_code == 200
assert response.json() == {
"access_level": "restricted",
"id": str(room.id),
"is_administrable": False,
"is_public": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
def test_api_rooms_retrieve_anonymous_private_slug():
"""It should be possible to get a room by its slug."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
room = RoomFactory(is_public=False)
client = APIClient()
response = client.get(f"/api/v1.0/rooms/{room.slug!s}/")
assert response.status_code == 200
assert response.json() == {
"access_level": "restricted",
"id": str(room.id),
"is_administrable": False,
"is_public": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
"""Getting a room by a slug that is not normalized should work."""
room = RoomFactory(name="Réunion", access_level=RoomAccessLevel.RESTRICTED)
room = RoomFactory(name="Réunion", is_public=False)
client = APIClient()
response = client.get("/api/v1.0/rooms/Réunion/")
assert response.status_code == 200
assert response.json() == {
"access_level": "restricted",
"id": str(room.id),
"is_administrable": False,
"is_public": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -196,25 +171,24 @@ def test_api_rooms_retrieve_anonymous_unregistered_not_allowed():
)
def test_api_rooms_retrieve_anonymous_public(mock_token):
"""
Anonymous users should be able to retrieve a room with a token provided, if the room is public.
Anonymous users should be able to retrieve a room with a token provided it is public.
"""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
room = RoomFactory(is_public=True)
client = APIClient()
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
assert response.status_code == 200
expected_name = f"{room.id!s}"
assert response.json() == {
"access_level": str(room.access_level),
"id": str(room.id),
"is_administrable": False,
"is_public": True,
"livekit": {
"url": "test_url_value",
"room": expected_name,
"token": "foo",
},
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -235,10 +209,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
which they are not related, provided the room is public.
They should not see related users.
"""
room = RoomFactory(
access_level=RoomAccessLevel.PUBLIC,
configuration={"can_publish_sources": ["mock-source"]},
)
room = RoomFactory(is_public=True)
user = UserFactory()
client = APIClient()
@@ -251,79 +222,19 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
expected_name = f"{room.id!s}"
assert response.json() == {
"access_level": str(room.access_level),
"id": str(room.id),
"is_administrable": False,
"is_public": True,
"livekit": {
"url": "test_url_value",
"room": expected_name,
"token": "foo",
},
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
mock_token.assert_called_once_with(
room=expected_name,
user=user,
username=None,
color=None,
sources=["mock-source"],
is_admin_or_owner=False,
participant_id=None,
)
@mock.patch("core.utils.generate_token", return_value="foo")
@override_settings(
LIVEKIT_CONFIGURATION={
"api_key": "key",
"api_secret": "secret",
"url": "test_url_value",
}
)
def test_api_rooms_retrieve_authenticated_trusted(mock_token):
"""
Authenticated users should be allowed to retrieve a room and get a token for a room to
which they are not related, provided the room has a trusted access_level.
They should not see related users.
"""
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.get(
f"/api/v1.0/rooms/{room.id!s}/",
)
assert response.status_code == 200
expected_name = f"{room.id!s}"
assert response.json() == {
"access_level": str(room.access_level),
"id": str(room.id),
"is_administrable": False,
"livekit": {
"url": "test_url_value",
"room": expected_name,
"token": "foo",
},
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
mock_token.assert_called_once_with(
room=expected_name,
user=user,
username=None,
color=None,
sources=None,
is_admin_or_owner=False,
participant_id=None,
)
mock_token.assert_called_once_with(room=expected_name, user=user, username=None)
def test_api_rooms_retrieve_authenticated():
@@ -331,7 +242,7 @@ def test_api_rooms_retrieve_authenticated():
Authenticated users should be allowed to retrieve a private room to which they
are not related but should not be given any token.
"""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
room = RoomFactory(is_public=False)
user = UserFactory()
client = APIClient()
@@ -343,11 +254,10 @@ def test_api_rooms_retrieve_authenticated():
assert response.status_code == 200
assert response.json() == {
"access_level": "restricted",
"id": str(room.id),
"is_administrable": False,
"is_public": False,
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
@@ -360,24 +270,23 @@ def test_api_rooms_retrieve_authenticated():
"url": "test_url_value",
}
)
def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, settings):
def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
"""
Users who are members of a room should not be allowed to see related users.
Users who are members of a room should be allowed to see related users.
"""
settings.TIME_ZONE = "UTC"
user = UserFactory()
other_user = UserFactory()
room = RoomFactory(
configuration={"can_publish_sources": ["mock-source"]},
room = RoomFactory()
user_access = UserResourceAccessFactory(resource=room, user=user, role="member")
other_user_access = UserResourceAccessFactory(
resource=room, user=other_user, role="member"
)
UserResourceAccessFactory(resource=room, user=user, role="member")
UserResourceAccessFactory(resource=room, user=other_user, role="member")
client = APIClient()
client.force_login(user)
with django_assert_num_queries(3):
with django_assert_num_queries(4):
response = client.get(
f"/api/v1.0/rooms/{room.id!s}/",
)
@@ -385,32 +294,49 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
assert response.status_code == 200
content_dict = response.json()
assert "accesses" not in content_dict
assert sorted(content_dict.pop("accesses"), key=lambda x: x["id"]) == sorted(
[
{
"id": str(user_access.id),
"user": {
"id": str(user_access.user.id),
"email": user_access.user.email,
"full_name": user_access.user.full_name,
"short_name": user_access.user.short_name,
},
"resource": str(room.id),
"role": user_access.role,
},
{
"id": str(other_user_access.id),
"user": {
"id": str(other_user_access.user.id),
"email": other_user_access.user.email,
"full_name": other_user_access.user.full_name,
"short_name": other_user_access.user.short_name,
},
"resource": str(room.id),
"role": other_user_access.role,
},
],
key=lambda x: x["id"],
)
expected_name = str(room.id)
assert content_dict == {
"access_level": str(room.access_level),
"id": str(room.id),
"is_administrable": False,
"is_public": room.is_public,
"livekit": {
"url": "test_url_value",
"room": expected_name,
"token": "foo",
},
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
mock_token.assert_called_once_with(
room=expected_name,
user=user,
username=None,
color=None,
sources=["mock-source"],
is_admin_or_owner=False,
participant_id=None,
)
mock_token.assert_called_once_with(room=expected_name, user=user, username=None)
@mock.patch("core.utils.generate_token", return_value="foo")
@@ -421,14 +347,11 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
"url": "test_url_value",
}
)
def test_api_rooms_retrieve_administrators(
mock_token, django_assert_num_queries, settings
):
def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries):
"""
A user who is an administrator or owner of a room should be allowed
to see related users.
"""
settings.TIME_ZONE = "UTC"
user = UserFactory()
other_user = UserFactory()
room = RoomFactory()
@@ -457,8 +380,6 @@ def test_api_rooms_retrieve_administrators(
"email": other_user_access.user.email,
"full_name": other_user_access.user.full_name,
"short_name": other_user_access.user.short_name,
"timezone": "UTC",
"language": other_user_access.user.language,
},
"resource": str(room.id),
"role": other_user_access.role,
@@ -470,8 +391,6 @@ def test_api_rooms_retrieve_administrators(
"email": user_access.user.email,
"full_name": user_access.user.full_name,
"short_name": user_access.user.short_name,
"timezone": "UTC",
"language": user_access.user.language,
},
"resource": str(room.id),
"role": user_access.role,
@@ -481,9 +400,9 @@ def test_api_rooms_retrieve_administrators(
)
expected_name = str(room.id)
assert content_dict == {
"access_level": str(room.access_level),
"id": str(room.id),
"is_administrable": True,
"is_public": room.is_public,
"configuration": {},
"livekit": {
"url": "test_url_value",
@@ -491,16 +410,7 @@ def test_api_rooms_retrieve_administrators(
"token": "foo",
},
"name": room.name,
"pin_code": room.pin_code,
"slug": room.slug,
}
mock_token.assert_called_once_with(
room=expected_name,
user=user,
username=None,
color=None,
sources=None,
is_admin_or_owner=True,
participant_id=None,
)
mock_token.assert_called_once_with(room=expected_name, user=user, username=None)
@@ -2,7 +2,7 @@
Test rooms API endpoints in the Meet core app: start recording.
"""
# pylint: disable=redefined-outer-name,unused-argument
# pylint: disable=W0621,W0613
from unittest import mock
@@ -55,9 +55,8 @@ def test_start_recording_anonymous():
assert Recording.objects.count() == 0
def test_start_recording_non_owner_and_non_administrator(settings):
def test_start_recording_non_owner_and_non_administrator():
"""Non-owner and Non-Administrator users should not be allowed to start room recordings."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
client = APIClient()
@@ -89,8 +88,8 @@ def test_start_recording_recording_disabled(settings):
{"mode": "screen_recording"},
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.status_code == 403
assert response.json() == {"detail": "Access denied, recording is disabled."}
assert Recording.objects.count() == 0
@@ -2,7 +2,7 @@
Test rooms API endpoints in the Meet core app: stop recording.
"""
# pylint: disable=redefined-outer-name,unused-argument
# pylint: disable=W0621,W0613
from unittest import mock
@@ -54,9 +54,8 @@ def test_stop_recording_anonymous():
assert Recording.objects.filter(status=RecordingStatusChoices.ACTIVE).count() == 1
def test_stop_recording_non_owner_and_non_administrator(settings):
def test_stop_recording_non_owner_and_non_administrator():
"""Non-owner and Non-Administrator users should not be allowed to stop room recordings."""
settings.RECORDING_ENABLE = True
room = RoomFactory()
user = UserFactory()
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
@@ -85,8 +84,8 @@ def test_stop_recording_recording_disabled(settings):
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
assert response.status_code == 403
assert response.json() == {"detail": "Access denied, recording is disabled."}
# Verify no recording exists
assert Recording.objects.count() == 0
@@ -1,221 +0,0 @@
"""
Test rooms API endpoints in the Meet core app: start subtitle.
"""
# pylint: disable=W0621
import uuid
from unittest import mock
from django.conf import settings
import pytest
from livekit.api import AccessToken, TwirpError, VideoGrants
from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory
pytestmark = pytest.mark.django_db
@pytest.fixture
def mock_room_id() -> str:
"""Mock room's id."""
return "d2aeb774-1ecd-4d73-a3ac-3d3530cad7ff"
@pytest.fixture
def mock_livekit_token(mock_room_id):
"""Mock LiveKit JWT token."""
video_grants = VideoGrants(
room=mock_room_id,
room_join=True,
room_admin=True,
can_update_own_metadata=True,
can_publish_sources=[
"camera",
"microphone",
"screen_share",
"screen_share_audio",
],
)
token = (
AccessToken(
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
api_secret=settings.LIVEKIT_CONFIGURATION["api_secret"],
)
.with_grants(video_grants)
.with_identity(str(uuid.uuid4()))
)
return token.to_jwt()
@pytest.fixture
def mock_livekit_client():
"""Mock LiveKit API client."""
with mock.patch("core.utils.create_livekit_client") as mock_create:
mock_client = mock.AsyncMock()
mock_create.return_value = mock_client
yield mock_client
def test_start_subtitle_missing_token_anonymous(settings):
"""Test that anonymous users cannot start subtitles without a valid LiveKit token."""
settings.ROOM_SUBTITLE_ENABLED = True
room = RoomFactory()
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_start_subtitle_missing_token_authenticated(settings):
"""Test that authenticated users still need a valid LiveKit token to start subtitles."""
settings.ROOM_SUBTITLE_ENABLED = True
room = RoomFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
)
assert response.status_code == 403
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
def test_start_subtitle_invalid_token():
"""Test that malformed or invalid LiveKit tokens are rejected."""
room = RoomFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/", {"token": "invalid-token"}
)
assert response.status_code == 403
assert response.json() == {"detail": "Invalid LiveKit token: Not enough segments"}
def test_start_subtitle_disabled_by_default(mock_livekit_token):
"""Test that subtitle functionality is disabled when feature flag is off."""
room = RoomFactory()
user = UserFactory()
client = APIClient()
client.force_login(user)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{"token": mock_livekit_token},
)
assert response.status_code == 404
assert response.json() == {"detail": "Not found."}
def test_start_subtitle_valid_token(
settings, mock_livekit_client, mock_livekit_token, mock_room_id
):
"""Test successful subtitle initiation with valid token and enabled feature."""
settings.ROOM_SUBTITLE_ENABLED = True
room = RoomFactory(id=mock_room_id)
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{"token": mock_livekit_token},
)
assert response.status_code == 200
assert response.json() == {"status": "success"}
mock_livekit_client.agent_dispatch.create_dispatch.assert_called_once()
call_args = mock_livekit_client.agent_dispatch.create_dispatch.call_args[0][0]
assert call_args.agent_name == "multi-user-transcriber"
assert call_args.room == "d2aeb774-1ecd-4d73-a3ac-3d3530cad7ff"
def test_start_subtitle_twirp_error(
settings, mock_livekit_client, mock_livekit_token, mock_room_id
):
"""Test handling of LiveKit service errors during subtitle initiation."""
settings.ROOM_SUBTITLE_ENABLED = True
room = RoomFactory(id=mock_room_id)
client = APIClient()
mock_livekit_client.agent_dispatch.create_dispatch.side_effect = TwirpError(
msg="Internal server error", code=500, status=500
)
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{"token": mock_livekit_token},
)
assert response.status_code == 500
assert response.json() == {
"error": f"Subtitles failed to start for room {room.slug}"
}
def test_start_subtitle_wrong_room(settings, mock_livekit_token):
"""Test that tokens are validated against the correct room ID."""
settings.ROOM_SUBTITLE_ENABLED = True
room = RoomFactory()
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{"token": mock_livekit_token},
)
assert response.status_code == 403
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
def test_start_subtitle_wrong_signature(settings, mock_livekit_token):
"""Test that tokens signed with incorrect signature are rejected."""
settings.ROOM_SUBTITLE_ENABLED = True
settings.LIVEKIT_CONFIGURATION["api_secret"] = "wrong-secret"
room = RoomFactory()
client = APIClient()
response = client.post(
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
{"token": mock_livekit_token},
)
assert response.status_code == 403
assert response.json() == {
"detail": "Invalid LiveKit token: Signature verification failed"
}
@@ -8,7 +8,6 @@ import pytest
from rest_framework.test import APIClient
from ...factories import RoomFactory, UserFactory
from ...models import RoomAccessLevel
pytestmark = pytest.mark.django_db
@@ -55,18 +54,17 @@ def test_api_rooms_update_members():
not be allowed to update it.
"""
user = UserFactory()
room = RoomFactory(
access_level=RoomAccessLevel.PUBLIC, name="Old name", users=[(user, "member")]
)
room = RoomFactory(name="Old name", users=[(user, "member")])
client = APIClient()
client.force_login(user)
new_is_public = not room.is_public
response = client.put(
f"/api/v1.0/rooms/{room.id!s}/",
{
"name": "New name",
"slug": "should-be-ignored",
"access_level": RoomAccessLevel.RESTRICTED,
"is_public": new_is_public,
"configuration": {"the_key": "the_value"},
},
format="json",
@@ -75,26 +73,24 @@ def test_api_rooms_update_members():
room.refresh_from_db()
assert room.name == "Old name"
assert room.slug == "old-name"
assert room.access_level != RoomAccessLevel.RESTRICTED
assert room.is_public != new_is_public
assert room.configuration == {}
def test_api_rooms_update_administrators():
"""Administrators or owners of a room should be allowed to update it."""
user = UserFactory()
room = RoomFactory(
access_level=RoomAccessLevel.RESTRICTED,
users=[(user, random.choice(["administrator", "owner"]))],
)
room = RoomFactory(users=[(user, random.choice(["administrator", "owner"]))])
client = APIClient()
client.force_login(user)
new_is_public = not room.is_public
response = client.put(
f"/api/v1.0/rooms/{room.id!s}/",
{
"name": "New name",
"slug": "should-be-ignored",
"access_level": RoomAccessLevel.PUBLIC,
"is_public": new_is_public,
"configuration": {"the_key": "the_value"},
},
format="json",
@@ -103,7 +99,7 @@ def test_api_rooms_update_administrators():
room.refresh_from_db()
assert room.name == "New name"
assert room.slug == "new-name"
assert room.access_level == RoomAccessLevel.PUBLIC
assert room.is_public == new_is_public
assert room.configuration == {"the_key": "the_value"}
@@ -1,190 +0,0 @@
"""
Test LiveKit webhook endpoint on the rooms API.
"""
# pylint: disable=too-many-arguments,redefined-outer-name,too-many-positional-arguments,unused-argument
import base64
import hashlib
import json
from unittest import mock
import pytest
from livekit import api
from ...services.livekit_events import ActionFailedError, LiveKitEventsService
@pytest.fixture
def webhook_event_data():
"""Sample webhook event data for testing."""
return {
"event": "room_finished",
"room": {
"sid": "RM_hycBMAjmt6Ub",
"name": "00000000-0000-0000-0000-000000000000",
"emptyTimeout": 300,
"creationTime": "1692627281",
"turnPassword": "fake-turn-password",
"enabledCodecs": [
{"mime": "audio/opus"},
{"mime": "video/H264"},
{"mime": "video/VP8"},
],
},
"id": "EV_eugWmGhovZmm",
"createdAt": "1692985556",
}
@pytest.fixture
def serialized_event_data(webhook_event_data):
"""Serialize event data to JSON."""
return json.dumps(webhook_event_data)
@pytest.fixture
def mock_livekit_config(settings):
"""Mock LiveKit configuration."""
settings.LIVEKIT_CONFIGURATION = {
"api_key": "test_api_key",
"api_secret": "test_api_secret",
"url": "https://test-livekit.example.com/",
}
return settings.LIVEKIT_CONFIGURATION
@pytest.fixture
def auth_token(serialized_event_data, mock_livekit_config):
"""Generate authentication token for webhook request."""
hash64 = base64.b64encode(
hashlib.sha256(serialized_event_data.encode()).digest()
).decode()
token = api.AccessToken(
mock_livekit_config["api_key"], mock_livekit_config["api_secret"]
)
token.claims.sha256 = hash64
return token.to_jwt()
def test_missing_auth_header(client, serialized_event_data, mock_livekit_config):
"""Should return 401 when auth header is missing."""
response = client.post(
"/api/v1.0/rooms/webhooks-livekit/",
data=serialized_event_data,
content_type="application/json",
)
assert response.status_code == 401
assert response.json() == {
"status": "error",
"message": "Authorization header missing",
}
def test_invalid_payload(client, auth_token, mock_livekit_config):
"""Should return 400 for invalid payload."""
response = client.post(
"/api/v1.0/rooms/webhooks-livekit/",
data=json.dumps({"invalid": "payload"}),
content_type="application/json",
HTTP_AUTHORIZATION=auth_token,
)
assert response.status_code == 400
assert response.json() == {"status": "error", "message": "Invalid webhook payload"}
def test_unknown_event_type(client, mock_livekit_config):
"""Should return 422 for unknown event type."""
event_data = json.dumps({"event": "unknown_event_type"})
# Generate auth token for this specific payload
hash64 = base64.b64encode(hashlib.sha256(event_data.encode()).digest()).decode()
token = api.AccessToken(
mock_livekit_config["api_key"], mock_livekit_config["api_secret"]
)
token.claims.sha256 = hash64
auth_token = token.to_jwt()
response = client.post(
"/api/v1.0/rooms/webhooks-livekit/",
data=event_data,
content_type="application/json",
HTTP_AUTHORIZATION=auth_token,
)
assert response.status_code == 422
assert response.json() == {
"status": "error",
"message": "Unknown webhook type: unknown_event_type",
}
@mock.patch.object(LiveKitEventsService, "_handle_room_finished")
def test_handled_event_type(
mock_handler,
client,
serialized_event_data,
auth_token,
mock_livekit_config,
):
"""Should process valid webhook successfully."""
response = client.post(
"/api/v1.0/rooms/webhooks-livekit/",
data=serialized_event_data,
content_type="application/json",
HTTP_AUTHORIZATION=auth_token,
)
mock_handler.assert_called_once()
assert response.status_code == 200
assert response.json() == {"status": "success"}
def test_unhandled_event_type(client, mock_livekit_config):
"""Should return 200 for event types that have no handler."""
event_data = json.dumps({"event": "participant_joined"})
hash64 = base64.b64encode(hashlib.sha256(event_data.encode()).digest()).decode()
token = api.AccessToken(
mock_livekit_config["api_key"], mock_livekit_config["api_secret"]
)
token.claims.sha256 = hash64
auth_token = token.to_jwt()
response = client.post(
"/api/v1.0/rooms/webhooks-livekit/",
data=event_data,
content_type="application/json",
HTTP_AUTHORIZATION=auth_token,
)
assert response.status_code == 200
assert response.json() == {"status": "success"}
def test_action_error(client, mock_livekit_config):
"""Should raise exceptions when errors occur during LiveKit webhook processing."""
event_data = json.dumps(
{
"event": "room_finished",
"room": {"sid": "RM_hycBMAjmt6Ub", "name": "invalid-uuid"},
}
)
hash64 = base64.b64encode(hashlib.sha256(event_data.encode()).digest()).decode()
token = api.AccessToken(
mock_livekit_config["api_key"], mock_livekit_config["api_secret"]
)
token.claims.sha256 = hash64
auth_token = token.to_jwt()
with pytest.raises(
ActionFailedError,
match="Failed to process room finished event",
):
client.post(
"/api/v1.0/rooms/webhooks-livekit/",
data=event_data,
content_type="application/json",
HTTP_AUTHORIZATION=auth_token,
)
@@ -1,345 +0,0 @@
"""
Test LiveKitEvents service.
"""
# pylint: disable=W0621,W0613, W0212, E0611
import uuid
from unittest import mock
import pytest
from livekit.api import EgressStatus
from core.factories import RecordingFactory, RoomFactory
from core.recording.services.recording_events import RecordingEventsService
from core.services.livekit_events import (
ActionFailedError,
AuthenticationError,
InvalidPayloadError,
LiveKitEventsService,
UnsupportedEventTypeError,
api,
)
from core.services.lobby import LobbyService
from core.services.telephony import TelephonyException, TelephonyService
from core.utils import NotificationError
pytestmark = pytest.mark.django_db
@pytest.fixture
def mock_livekit_config(settings):
"""Mock LiveKit configuration."""
settings.LIVEKIT_CONFIGURATION = {
"api_key": "test_api_key",
"api_secret": "test_api_secret",
"url": "https://test-livekit.example.com/",
}
return settings.LIVEKIT_CONFIGURATION
@pytest.fixture
def service(mock_livekit_config):
"""Initialize LiveKitEventsService."""
return LiveKitEventsService()
@mock.patch("livekit.api.TokenVerifier")
@mock.patch("livekit.api.WebhookReceiver")
def test_initialization(
mock_webhook_receiver, mock_token_verifier, mock_livekit_config
):
"""Should correctly initialize the service with required dependencies."""
api_key = mock_livekit_config["api_key"]
api_secret = mock_livekit_config["api_secret"]
service = LiveKitEventsService()
mock_token_verifier.assert_called_once_with(api_key, api_secret)
mock_webhook_receiver.assert_called_once_with(mock_token_verifier.return_value)
assert isinstance(service.lobby_service, LobbyService)
assert isinstance(service.telephony_service, TelephonyService)
assert isinstance(service.recording_events, RecordingEventsService)
@pytest.mark.parametrize(
("mode", "notification_type"),
(
("screen_recording", "screenRecordingLimitReached"),
("transcript", "transcriptionLimitReached"),
),
)
@mock.patch("core.utils.notify_participants")
def test_handle_egress_ended_success(mock_notify, mode, notification_type, service):
"""Should successfully stop recording and notifies all participant."""
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
service._handle_egress_ended(mock_data)
mock_notify.assert_called_once_with(
room_name=str(recording.room.id), notification_data={"type": notification_type}
)
recording.refresh_from_db()
assert recording.status == "stopped"
@mock.patch("core.utils.notify_participants")
def test_handle_egress_ended_notification_fails(mock_notify, service):
"""Should raise ActionFailedError when notification fails but still stop recording."""
recording = RecordingFactory(worker_id="worker-1", status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = recording.worker_id
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
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.patch("core.utils.notify_participants")
def test_handle_egress_ended_recording_not_found(mock_notify, service):
"""Should raise ActionFailedError when recording doesn't exist."""
recording = RecordingFactory(worker_id="worker-1", status="active")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = "worker-2"
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
with pytest.raises(
ActionFailedError, match=r"Recording with worker ID .+ does not exist"
):
service._handle_egress_ended(mock_data)
mock_notify.assert_not_called()
recording.refresh_from_db()
assert recording.status == "active"
@mock.patch("core.utils.notify_participants")
def test_handle_egress_ended_recording_not_active(mock_notify, service):
"""Should ignore non-active recordings."""
recording = RecordingFactory(worker_id="worker-1", status="failed_to_stop")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = "worker-1"
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
service._handle_egress_ended(mock_data)
mock_notify.assert_not_called()
recording.refresh_from_db()
assert recording.status == "failed_to_stop"
@mock.patch("core.utils.notify_participants")
def test_handle_egress_ended_recording_not_limit_reached(mock_notify, service):
"""Should ignore egress non-limit-reached statuses."""
recording = RecordingFactory(worker_id="worker-1", status="stopped")
mock_data = mock.MagicMock()
mock_data.egress_info.egress_id = "worker-1"
mock_data.egress_info.status = EgressStatus.EGRESS_COMPLETE
service._handle_egress_ended(mock_data)
mock_notify.assert_not_called()
assert recording.status == "stopped"
@mock.patch.object(LobbyService, "clear_room_cache")
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
mock_delete_dispatch_rule, mock_clear_cache, service, settings
):
"""Should clear lobby cache and delete telephony dispatch rule when room finishes."""
settings.ROOM_TELEPHONY_ENABLED = True
mock_room_name = uuid.uuid4()
mock_data = mock.MagicMock()
mock_data.room.name = str(mock_room_name)
service._handle_room_finished(mock_data)
mock_delete_dispatch_rule.assert_called_once_with(mock_room_name)
mock_clear_cache.assert_called_once_with(mock_room_name)
@mock.patch.object(LobbyService, "clear_room_cache")
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
def test_handle_room_finished_skips_telephony_when_disabled(
mock_delete_dispatch_rule, mock_clear_cache, service, settings
):
"""Should clear lobby cache but skip dispatch rule deletion when telephony is disabled."""
settings.ROOM_TELEPHONY_ENABLED = False
mock_room_name = uuid.uuid4()
mock_data = mock.MagicMock()
mock_data.room.name = str(mock_room_name)
service._handle_room_finished(mock_data)
mock_delete_dispatch_rule.assert_not_called()
mock_clear_cache.assert_called_once_with(mock_room_name)
@mock.patch.object(
LobbyService, "clear_room_cache", side_effect=Exception("Test error")
)
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
def test_handle_room_finished_raises_error_when_cache_clearing_fails(
mock_delete_dispatch_rule, mock_clear_cache, service, settings
):
"""Should raise ActionFailedError when lobby cache clearing fails when room finishes."""
settings.ROOM_TELEPHONY_ENABLED = True
mock_data = mock.MagicMock()
mock_data.room.name = "00000000-0000-0000-0000-000000000000"
expected_error = (
"Failed to clear room cache for room 00000000-0000-0000-0000-000000000000"
)
with pytest.raises(ActionFailedError, match=expected_error):
service._handle_room_finished(mock_data)
mock_delete_dispatch_rule.assert_called_once_with(
uuid.UUID("00000000-0000-0000-0000-000000000000")
)
@mock.patch.object(LobbyService, "clear_room_cache")
@mock.patch.object(
TelephonyService,
"delete_dispatch_rule",
side_effect=TelephonyException("Test error"),
)
def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
mock_delete_dispatch_rule, mock_clear_cache, service, settings
):
"""Should raise ActionFailedError when dispatch rule deletion fails when room finishes."""
settings.ROOM_TELEPHONY_ENABLED = True
mock_data = mock.MagicMock()
mock_data.room.name = "00000000-0000-0000-0000-000000000000"
expected_error = (
"Failed to delete telephony dispatch rule for room "
"00000000-0000-0000-0000-000000000000"
)
with pytest.raises(ActionFailedError, match=expected_error):
service._handle_room_finished(mock_data)
mock_clear_cache.assert_not_called()
def test_handle_room_finished_raises_error_for_invalid_room_name(service):
"""Should raise ActionFailedError when room name format is invalid when room finishes."""
mock_data = mock.MagicMock()
mock_data.room.name = "invalid"
with pytest.raises(
ActionFailedError, match="Failed to process room finished event"
):
service._handle_room_finished(mock_data)
@mock.patch.object(TelephonyService, "create_dispatch_rule")
def test_handle_room_started_creates_dispatch_rule_successfully(
mock_create_dispatch_rule, service, settings
):
"""Should create telephony dispatch rule when room starts successfully."""
settings.ROOM_TELEPHONY_ENABLED = True
room = RoomFactory()
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
service._handle_room_started(mock_data)
mock_create_dispatch_rule.assert_called_once_with(room)
@mock.patch.object(TelephonyService, "create_dispatch_rule")
def test_handle_room_started_skips_dispatch_rule_when_telephony_disabled(
mock_create_dispatch_rule, service, settings
):
"""Should skip creating telephony dispatch rule when telephony is disabled during room start."""
settings.ROOM_TELEPHONY_ENABLED = False
room = RoomFactory()
mock_data = mock.MagicMock()
mock_data.room.name = str(room.id)
service._handle_room_started(mock_data)
mock_create_dispatch_rule.assert_not_called()
def test_handle_room_started_raises_error_for_invalid_room_name(service):
"""Should raise ActionFailedError when room name format is invalid when room starts."""
mock_data = mock.MagicMock()
mock_data.room.name = "invalid"
with pytest.raises(ActionFailedError, match="Failed to process room started event"):
service._handle_room_started(mock_data)
def test_handle_room_started_raises_error_for_nonexistent_room(service):
"""Should raise ActionFailedError when a room starts that doesn't exist in the database."""
mock_data = mock.MagicMock()
mock_data.room.name = str(uuid.uuid4())
expected_error = f"Room with ID {mock_data.room.name} does not exist"
with pytest.raises(ActionFailedError, match=expected_error):
service._handle_room_started(mock_data)
@mock.patch.object(
api.WebhookReceiver, "receive", side_effect=Exception("Invalid payload")
)
def test_receive_invalid_payload(mock_receive, service):
"""Should raise InvalidPayloadError for invalid payloads."""
mock_request = mock.MagicMock()
mock_request.headers = {"Authorization": "test_token"}
mock_request.body = b"{}"
with pytest.raises(InvalidPayloadError, match="Invalid webhook payload"):
service.receive(mock_request)
def test_receive_missing_auth(service):
"""Should raise AuthenticationError when auth header is missing."""
mock_request = mock.MagicMock()
mock_request.headers = {}
with pytest.raises(AuthenticationError, match="Authorization header missing"):
service.receive(mock_request)
@mock.patch.object(api.WebhookReceiver, "receive")
def test_receive_unsupported_event(mock_receive, service):
"""Should raise LiveKitWebhookError for unsupported events."""
mock_request = mock.MagicMock()
mock_request.headers = {"Authorization": "test_token"}
mock_request.body = b"{}"
# Mock returned data with unsupported event type
mock_data = mock.MagicMock()
mock_data.event = "unsupported_event"
mock_receive.return_value = mock_data
with pytest.raises(
UnsupportedEventTypeError, match="Unknown webhook type: unsupported_event"
):
service.receive(mock_request)
@@ -1,873 +0,0 @@
"""
Test lobby service.
"""
# pylint: disable=W0621,W0613, W0212, R0913
# ruff: noqa: PLR0913
import uuid
from unittest import mock
from django.conf import settings
from django.core.cache import cache
from django.http import HttpResponse
import pytest
from core.factories import RoomFactory
from core.models import RoomAccessLevel
from core.services.lobby import (
LobbyParticipant,
LobbyParticipantNotFound,
LobbyParticipantParsingError,
LobbyParticipantStatus,
LobbyService,
)
from core.utils import NotificationError
pytestmark = pytest.mark.django_db
@pytest.fixture
def lobby_service():
"""Return a LobbyService instance."""
return LobbyService()
@pytest.fixture
def participant_id():
"""Return a string ID for test participant."""
return "test-participant-id"
@pytest.fixture
def username():
"""Return a username for test participant."""
return "test-username"
@pytest.fixture
def participant_dict():
"""Return a valid participant dictionary."""
return {
"status": "waiting",
"username": "test-username",
"id": "test-participant-id",
"color": "#123456",
}
@pytest.fixture
def participant_data():
"""Return a valid LobbyParticipant instance."""
return LobbyParticipant(
status=LobbyParticipantStatus.WAITING,
username="test-username",
id="test-participant-id",
color="#123456",
)
def test_lobby_participant_to_dict(participant_data):
"""Test LobbyParticipant serialization to dict."""
result = participant_data.to_dict()
assert result["status"] == "waiting"
assert result["username"] == "test-username"
assert result["id"] == "test-participant-id"
assert result["color"] == "#123456"
def test_lobby_participant_from_dict_success(participant_dict):
"""Test successful LobbyParticipant creation from dict."""
participant = LobbyParticipant.from_dict(participant_dict)
assert participant.status == LobbyParticipantStatus.WAITING
assert participant.username == "test-username"
assert participant.id == "test-participant-id"
assert participant.color == "#123456"
def test_lobby_participant_from_dict_default_status():
"""Test LobbyParticipant creation with missing status defaults to UNKNOWN."""
data_without_status = {
"username": "test-username",
"id": "test-participant-id",
"color": "#123456",
}
participant = LobbyParticipant.from_dict(data_without_status)
assert participant.status == LobbyParticipantStatus.UNKNOWN
assert participant.username == "test-username"
assert participant.id == "test-participant-id"
assert participant.color == "#123456"
def test_lobby_participant_from_dict_missing_fields():
"""Test LobbyParticipant creation with missing fields."""
invalid_data = {"username": "test-username"}
with pytest.raises(LobbyParticipantParsingError, match="Invalid participant data"):
LobbyParticipant.from_dict(invalid_data)
def test_lobby_participant_from_dict_invalid_status():
"""Test LobbyParticipant creation with invalid status."""
invalid_data = {
"status": "invalid_status",
"username": "test-username",
"id": "test-participant-id",
"color": "#123456",
}
with pytest.raises(LobbyParticipantParsingError, match="Invalid participant data"):
LobbyParticipant.from_dict(invalid_data)
def test_get_cache_key(lobby_service, participant_id):
"""Test cache key generation."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key = lobby_service._get_cache_key(room.id, participant_id)
expected_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_{participant_id}"
assert cache_key == expected_key
def test_get_or_create_participant_id_from_cookie(lobby_service):
"""Test extracting participant ID from cookie."""
request = mock.Mock()
request.COOKIES = {settings.LOBBY_COOKIE_NAME: "existing-id"}
participant_id = lobby_service._get_or_create_participant_id(request)
assert participant_id == "existing-id"
@mock.patch.object(uuid, "uuid4", return_value="generated-id")
def test_get_or_create_participant_id_new(mock_uuid4, lobby_service):
"""Test creating new participant ID when cookie is missing."""
request = mock.Mock()
request.COOKIES = {}
participant_id = lobby_service._get_or_create_participant_id(request)
assert participant_id == "generated-id"
mock_uuid4.assert_called_once()
def test_prepare_response_existing_cookie(lobby_service, participant_id):
"""Test response preparation with existing cookie."""
response = HttpResponse()
response.cookies[settings.LOBBY_COOKIE_NAME] = "existing-cookie"
lobby_service.prepare_response(response, participant_id)
# Verify cookie wasn't set again
cookie = response.cookies.get(settings.LOBBY_COOKIE_NAME)
assert cookie.value == "existing-cookie"
assert cookie.value != participant_id
def test_prepare_response_new_cookie(lobby_service, participant_id):
"""Test response preparation with new cookie."""
response = HttpResponse()
lobby_service.prepare_response(response, participant_id)
# Verify cookie was set
cookie = response.cookies.get(settings.LOBBY_COOKIE_NAME)
assert cookie is not None
assert cookie.value == participant_id
assert cookie["httponly"] is True
assert cookie["secure"] is True
assert cookie["samesite"] == "Lax"
# It's a session cookies (no max_age specified):
assert not cookie["max-age"]
def test_can_bypass_lobby_public_room(lobby_service):
"""Should return True for public rooms regardless of user auth."""
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
# Anonymous user
user = mock.Mock()
user.is_authenticated = False
assert lobby_service.can_bypass_lobby(room, user) is True
# Authenticated user
user.is_authenticated = True
assert lobby_service.can_bypass_lobby(room, user) is True
def test_can_bypass_lobby_trusted_room_authenticated(lobby_service):
"""Should return True for trusted rooms with authenticated users."""
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
# Authenticated user
user = mock.Mock()
user.is_authenticated = True
assert lobby_service.can_bypass_lobby(room, user) is True
def test_can_bypass_lobby_trusted_room_anonymous(lobby_service):
"""Should return False for trusted rooms with anonymous users."""
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
# Anonymous user
user = mock.Mock()
user.is_authenticated = False
assert lobby_service.can_bypass_lobby(room, user) is False
def test_can_bypass_lobby_private_room(lobby_service):
"""Should return False for private rooms regardless of user auth."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
# Anonymous user
user = mock.Mock()
user.is_authenticated = False
assert lobby_service.can_bypass_lobby(room, user) is False
# Authenticated user
user.is_authenticated = True
assert lobby_service.can_bypass_lobby(room, user) is False
@mock.patch("core.utils.generate_livekit_config")
def test_request_entry_public_room(
mock_generate_config, lobby_service, participant_id, username
):
"""Test requesting entry to a public room."""
request = mock.Mock()
request.user = mock.Mock()
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
mocked_participant = LobbyParticipant(
status=LobbyParticipantStatus.UNKNOWN,
username=username,
id=participant_id,
color="#123456",
)
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
mock_generate_config.return_value = {"token": "test-token"}
participant, livekit_config = lobby_service.request_entry(room, request, username)
assert participant.status == LobbyParticipantStatus.ACCEPTED
assert livekit_config == {"token": "test-token"}
mock_generate_config.assert_called_once_with(
room_id=str(room.id),
user=request.user,
username=username,
color=participant.color,
configuration=room.configuration,
is_admin_or_owner=False,
participant_id="test-participant-id",
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@mock.patch("core.utils.generate_livekit_config")
def test_request_entry_trusted_room(
mock_generate_config, lobby_service, participant_id, username
):
"""Test requesting entry to a trusted room when the user is authenticated."""
request = mock.Mock()
request.user = mock.Mock()
request.user.is_authenticated = True
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
mocked_participant = LobbyParticipant(
status=LobbyParticipantStatus.UNKNOWN,
username=username,
id=participant_id,
color="#123456",
)
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
mock_generate_config.return_value = {"token": "test-token"}
participant, livekit_config = lobby_service.request_entry(room, request, username)
assert participant.status == LobbyParticipantStatus.ACCEPTED
assert livekit_config == {"token": "test-token"}
mock_generate_config.assert_called_once_with(
room_id=str(room.id),
user=request.user,
username=username,
color=participant.color,
configuration=room.configuration,
is_admin_or_owner=False,
participant_id="test-participant-id",
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@mock.patch("core.services.lobby.LobbyService.enter")
def test_request_entry_new_participant(
mock_enter, lobby_service, participant_id, username
):
"""Test requesting entry for a new participant."""
request = mock.Mock()
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=None)
participant_data = LobbyParticipant(
status=LobbyParticipantStatus.WAITING,
username=username,
id=participant_id,
color="#123456",
)
mock_enter.return_value = participant_data
participant, livekit_config = lobby_service.request_entry(room, request, username)
assert participant == participant_data
assert livekit_config is None
mock_enter.assert_called_once_with(room.id, participant_id, username)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@mock.patch("core.services.lobby.LobbyService.refresh_waiting_status")
def test_request_entry_waiting_participant(
mock_refresh, lobby_service, participant_id, username
):
"""Test requesting entry for a waiting participant."""
request = mock.Mock()
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
mocked_participant = LobbyParticipant(
status=LobbyParticipantStatus.WAITING,
username=username,
id=participant_id,
color="#123456",
)
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
participant, livekit_config = lobby_service.request_entry(room, request, username)
assert participant.status == LobbyParticipantStatus.WAITING
assert livekit_config is None
mock_refresh.assert_called_once_with(room.id, participant_id)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@mock.patch("core.utils.generate_livekit_config")
def test_request_entry_accepted_participant(
mock_generate_config, lobby_service, participant_id, username
):
"""Test requesting entry for an accepted participant."""
request = mock.Mock()
request.user = mock.Mock()
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
mocked_participant = LobbyParticipant(
status=LobbyParticipantStatus.ACCEPTED,
username=username,
id=participant_id,
color="#123456",
)
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
mock_generate_config.return_value = {"token": "test-token"}
participant, livekit_config = lobby_service.request_entry(room, request, username)
assert participant.status == LobbyParticipantStatus.ACCEPTED
assert livekit_config == {"token": "test-token"}
mock_generate_config.assert_called_once_with(
room_id=str(room.id),
user=request.user,
username=username,
color="#123456",
configuration=room.configuration,
is_admin_or_owner=False,
participant_id="test-participant-id",
)
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
@mock.patch("core.services.lobby.cache")
def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
"""Test refreshing waiting status for a participant."""
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
lobby_service.refresh_waiting_status(room.id, participant_id)
mock_cache.touch.assert_called_once_with(
"mocked_cache_key", settings.LOBBY_WAITING_TIMEOUT
)
# pylint: disable=R0917
@mock.patch("core.services.lobby.cache")
@mock.patch("core.utils.generate_color")
@mock.patch("core.utils.notify_participants")
def test_enter_success(
mock_notify,
mock_generate_color,
mock_cache,
lobby_service,
participant_id,
username,
):
"""Test successful participant entry."""
mock_generate_color.return_value = "#123456"
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
participant = lobby_service.enter(room.id, participant_id, username)
mock_generate_color.assert_called_once_with(participant_id)
assert participant.status == LobbyParticipantStatus.WAITING
assert participant.username == username
assert participant.id == participant_id
assert participant.color == "#123456"
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
mock_cache.set.assert_called_once_with(
"mocked_cache_key",
participant.to_dict(),
timeout=settings.LOBBY_WAITING_TIMEOUT,
)
mock_notify.assert_called_once_with(
room_name=str(room.pk), notification_data={"type": "participantWaiting"}
)
# pylint: disable=R0917
@mock.patch("core.services.lobby.cache")
@mock.patch("core.utils.generate_color")
@mock.patch("core.utils.notify_participants")
def test_enter_with_notification_error(
mock_notify,
mock_generate_color,
mock_cache,
lobby_service,
participant_id,
username,
):
"""Test participant entry with notification error."""
mock_generate_color.return_value = "#123456"
mock_notify.side_effect = NotificationError("Error notifying")
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
participant = lobby_service.enter(room.id, participant_id, username)
mock_generate_color.assert_called_once_with(participant_id)
assert participant.status == LobbyParticipantStatus.WAITING
assert participant.username == username
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
mock_cache.set.assert_called_once_with(
"mocked_cache_key",
participant.to_dict(),
timeout=settings.LOBBY_WAITING_TIMEOUT,
)
@mock.patch("core.services.lobby.cache")
def test_get_participant_not_found(mock_cache, lobby_service, participant_id):
"""Test getting a participant that doesn't exist."""
mock_cache.get.return_value = None
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
result = lobby_service._get_participant(room.id, participant_id)
assert result is None
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
mock_cache.get.assert_called_once_with("mocked_cache_key")
@mock.patch("core.services.lobby.cache")
@mock.patch("core.services.lobby.LobbyParticipant.from_dict")
def test_get_participant_parsing_error(
mock_from_dict, mock_cache, lobby_service, participant_id
):
"""Test handling corrupted participant data."""
mock_cache.get.return_value = {"some": "data"}
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
mock_from_dict.side_effect = LobbyParticipantParsingError("Invalid data")
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
result = lobby_service._get_participant(room.id, participant_id)
assert result is None
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
mock_cache.delete.assert_called_once_with("mocked_cache_key")
@mock.patch("core.services.lobby.cache")
def test_list_waiting_participants_empty(mock_cache, lobby_service):
"""Test listing waiting participants when none exist."""
mock_cache.keys.return_value = []
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
result = lobby_service.list_waiting_participants(room.id)
assert result == []
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.keys.assert_called_once_with(pattern)
mock_cache.get_many.assert_not_called()
@mock.patch("core.services.lobby.cache")
def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
"""Test listing waiting participants with valid data."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
mock_cache.keys.return_value = [cache_key]
mock_cache.get_many.return_value = {cache_key: participant_dict}
result = lobby_service.list_waiting_participants(room.id)
assert len(result) == 1
assert result[0]["status"] == "waiting"
assert result[0]["username"] == "test-username"
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.keys.assert_called_once_with(pattern)
mock_cache.get_many.assert_called_once_with([cache_key])
@mock.patch("core.services.lobby.cache")
def test_list_waiting_participants_multiple(mock_cache, lobby_service):
"""Test listing multiple waiting participants with valid data."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key1 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
cache_key2 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
participant1 = {
"status": "waiting",
"username": "user1",
"id": "participant1",
"color": "#123456",
}
participant2 = {
"status": "waiting",
"username": "user2",
"id": "participant2",
"color": "#654321",
}
mock_cache.keys.return_value = [cache_key1, cache_key2]
mock_cache.get_many.return_value = {
cache_key1: participant1,
cache_key2: participant2,
}
result = lobby_service.list_waiting_participants(room.id)
assert len(result) == 2
# Verify both participants are in the result
assert any(p["id"] == "participant1" and p["username"] == "user1" for p in result)
assert any(p["id"] == "participant2" and p["username"] == "user2" for p in result)
# Verify all participants have waiting status
assert all(p["status"] == "waiting" for p in result)
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.keys.assert_called_once_with(pattern)
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
@mock.patch("core.services.lobby.cache")
def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service):
"""Test listing waiting participants with corrupted data."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
mock_cache.keys.return_value = [cache_key]
mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}}
result = lobby_service.list_waiting_participants(room.id)
assert result == []
mock_cache.delete.assert_called_once_with(cache_key)
@mock.patch("core.services.lobby.cache")
def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service):
"""Test listing waiting participants with one valid and one corrupted entry."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key1 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
cache_key2 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
valid_participant = {
"status": "waiting",
"username": "user2",
"id": "participant2",
"color": "#654321",
}
corrupted_participant = {"invalid": "data"}
mock_cache.keys.return_value = [cache_key1, cache_key2]
mock_cache.get_many.return_value = {
cache_key1: corrupted_participant,
cache_key2: valid_participant,
}
result = lobby_service.list_waiting_participants(room.id)
# Check that only the valid participant is returned
assert len(result) == 1
assert result[0]["id"] == "participant2"
assert result[0]["status"] == "waiting"
assert result[0]["username"] == "user2"
# Verify corrupted entry was deleted
mock_cache.delete.assert_called_once_with(cache_key1)
# Verify both cache keys were queried
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
mock_cache.keys.assert_called_once_with(pattern)
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
@mock.patch("core.services.lobby.cache")
def test_list_waiting_participants_non_waiting(mock_cache, lobby_service):
"""Test listing only waiting participants (not accepted/denied)."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
cache_key1 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
cache_key2 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
participant1 = {
"status": "waiting",
"username": "user1",
"id": "participant1",
"color": "#123456",
}
participant2 = {
"status": "accepted",
"username": "user2",
"id": "participant2",
"color": "#654321",
}
mock_cache.keys.return_value = [cache_key1, cache_key2]
mock_cache.get_many.return_value = {
cache_key1: participant1,
cache_key2: participant2,
}
result = lobby_service.list_waiting_participants(room.id)
assert len(result) == 1
assert result[0]["id"] == "participant1"
assert result[0]["status"] == "waiting"
@mock.patch("core.services.lobby.LobbyService._update_participant_status")
def test_handle_participant_entry_allow(mock_update, lobby_service, participant_id):
"""Test handling allowed participant entry."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
lobby_service.handle_participant_entry(room.id, participant_id, allow_entry=True)
mock_update.assert_called_once_with(
room.id,
participant_id,
status=LobbyParticipantStatus.ACCEPTED,
timeout=settings.LOBBY_ACCEPTED_TIMEOUT,
)
@mock.patch("core.services.lobby.LobbyService._update_participant_status")
def test_handle_participant_entry_deny(mock_update, lobby_service, participant_id):
"""Test handling denied participant entry."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
lobby_service.handle_participant_entry(room.id, participant_id, allow_entry=False)
mock_update.assert_called_once_with(
room.id,
participant_id,
status=LobbyParticipantStatus.DENIED,
timeout=settings.LOBBY_DENIED_TIMEOUT,
)
@mock.patch("core.services.lobby.cache")
def test_update_participant_status_not_found(mock_cache, lobby_service, participant_id):
"""Test updating status for non-existent participant."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
mock_cache.get.return_value = None
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
with pytest.raises(LobbyParticipantNotFound, match="Participant not found"):
lobby_service._update_participant_status(
room.id,
participant_id,
status=LobbyParticipantStatus.ACCEPTED,
timeout=60,
)
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
mock_cache.get.assert_called_once_with("mocked_cache_key")
@mock.patch("core.services.lobby.cache")
@mock.patch("core.services.lobby.LobbyParticipant.from_dict")
def test_update_participant_status_corrupted_data(
mock_from_dict, mock_cache, lobby_service, participant_id
):
"""Test updating status with corrupted participant data."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
mock_cache.get.return_value = {"some": "data"}
mock_from_dict.side_effect = LobbyParticipantParsingError("Invalid data")
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
with pytest.raises(LobbyParticipantParsingError):
lobby_service._update_participant_status(
room.id,
participant_id,
status=LobbyParticipantStatus.ACCEPTED,
timeout=60,
)
mock_cache.delete.assert_called_once_with("mocked_cache_key")
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
@mock.patch("core.services.lobby.cache")
def test_update_participant_status_success(mock_cache, lobby_service, participant_id):
"""Test successful participant status update."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
participant_dict = {
"status": "waiting",
"username": "test-username",
"id": participant_id,
"color": "#123456",
}
mock_cache.get.return_value = participant_dict
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
lobby_service._update_participant_status(
room.id,
participant_id,
status=LobbyParticipantStatus.ACCEPTED,
timeout=60,
)
expected_data = {
"status": "accepted",
"username": "test-username",
"id": participant_id,
"color": "#123456",
}
mock_cache.set.assert_called_once_with(
"mocked_cache_key", expected_data, timeout=60
)
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
def test_clear_room_cache(settings, lobby_service):
"""Test clearing room cache actually removes entries from cache."""
settings.LOBBY_KEY_PREFIX = "test-lobby"
settings.LOBBY_WAITING_TIMEOUT = 10000
settings.LOBBY_ACCEPTED_TIMEOUT = 10000
settings.LOBBY_DENIED_TIMEOUT = 10000
room_id = uuid.uuid4()
cache.set(
f"test-lobby_{room_id!s}_participant1",
LobbyParticipant(
status=LobbyParticipantStatus.WAITING,
username="participant1",
id="participant1",
color="#123456",
),
timeout=settings.LOBBY_WAITING_TIMEOUT,
)
cache.set(
f"test-lobby_{room_id!s}_participant2",
LobbyParticipant(
status=LobbyParticipantStatus.ACCEPTED,
username="participant2",
id="participant2",
color="#123456",
),
timeout=settings.LOBBY_ACCEPTED_TIMEOUT,
)
cache.set(
f"test-lobby_{room_id!s}_participant3",
LobbyParticipant(
status=LobbyParticipantStatus.DENIED,
username="participant3",
id="participant3",
color="#123456",
),
timeout=settings.LOBBY_DENIED_TIMEOUT,
)
lobby_service.clear_room_cache(room_id)
assert cache.keys(f"test-lobby_{room_id!s}_*") == []
def test_clear_room_empty(settings, lobby_service):
"""Test clearing room cache when it's already empty."""
settings.LOBBY_KEY_PREFIX = "test-lobby"
room_id = uuid.uuid4()
assert cache.keys(f"test-lobby_{room_id!s}_*") == []
lobby_service.clear_room_cache(room_id)
assert cache.keys(f"test-lobby_{room_id!s}_*") == []
def test_clear_participant_cache(lobby_service):
"""Test clearing a specific participant entry from cache."""
room_id = uuid.uuid4()
participant_id = "test-participant-id"
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
participant_data = {
"status": "waiting",
"username": "test-username",
"id": participant_id,
"color": "#123456",
}
cache.set(cache_key, participant_data, timeout=settings.LOBBY_WAITING_TIMEOUT)
assert cache.get(cache_key) is not None
lobby_service.clear_participant_cache(room_id, participant_id)
assert cache.get(cache_key) is None
def test_clear_participant_cache_nonexistent(lobby_service):
"""Test clearing a participant that doesn't exist in cache."""
room_id = uuid.uuid4()
participant_id = "nonexistent-participant"
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
assert cache.get(cache_key) is None
lobby_service.clear_participant_cache(room_id, participant_id)
assert cache.get(cache_key) is None
@@ -11,9 +11,8 @@ from django.core.exceptions import ImproperlyConfigured
import brevo_python
import pytest
import urllib3
from core.services.marketing import (
from core.services.marketing_service import (
BrevoMarketingService,
ContactCreationError,
ContactData,
@@ -134,34 +133,6 @@ def test_create_contact_api_error(mock_contact_api):
brevo_service.create_contact(valid_contact_data)
@mock.patch("brevo_python.ContactsApi")
def test_create_contact_timeout_error(mock_contact_api):
"""Test contact creation timeout error handling."""
mock_api = mock_contact_api.return_value
settings.BREVO_API_KEY = "test-api-key"
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
valid_contact_data = ContactData(
email="test@example.com",
attributes={"first_name": "Test"},
list_ids=[1, 2],
update_enabled=True,
)
brevo_service = BrevoMarketingService()
mock_api.create_contact.side_effect = urllib3.exceptions.ReadTimeoutError(
pool=mock.Mock(),
url="https://api.brevo.com/v3/endpoint",
message="HTTPSConnectionPool(host='api.brevo.com', port=443): Read timed out.",
)
with pytest.raises(ContactCreationError, match="Failed to create contact in Brevo"):
brevo_service.create_contact(valid_contact_data)
@pytest.fixture
def clear_marketing_cache():
"""Clear marketing service cache between tests."""
@@ -173,7 +144,9 @@ def clear_marketing_cache():
def test_get_marketing_service_caching(clear_marketing_cache):
"""Test marketing service caching behavior."""
settings.BREVO_API_KEY = "test-api-key"
settings.MARKETING_SERVICE_CLASS = "core.services.marketing.BrevoMarketingService"
settings.MARKETING_SERVICE_CLASS = (
"core.services.marketing_service.BrevoMarketingService"
)
service1 = get_marketing_service()
service2 = get_marketing_service()
@@ -190,12 +163,14 @@ def test_get_marketing_service_invalid_class(clear_marketing_cache):
get_marketing_service()
@mock.patch("core.services.marketing.import_string")
@mock.patch("core.services.marketing_service.import_string")
def test_service_instantiation_called_once(mock_import_string, clear_marketing_cache):
"""Test service class is instantiated only once."""
settings.BREVO_API_KEY = "test-api-key"
settings.MARKETING_SERVICE_CLASS = "core.services.marketing.BrevoMarketingService"
settings.MARKETING_SERVICE_CLASS = (
"core.services.marketing_service.BrevoMarketingService"
)
get_marketing_service.cache_clear()
mock_service_cls = mock.Mock()
@@ -1,48 +0,0 @@
"""
Test subtitle service.
"""
# pylint: disable=W0621
from unittest import mock
import pytest
from core.factories import RoomFactory
from core.services.subtitle import SubtitleService
pytestmark = pytest.mark.django_db
@pytest.fixture
def mock_livekit_client():
"""Mock LiveKit API client."""
with mock.patch("core.utils.create_livekit_client") as mock_create:
mock_client = mock.AsyncMock()
mock_create.return_value = mock_client
yield mock_client
def test_start_subtitle_settings(mock_livekit_client, settings):
"""Test that start_subtitle uses the configured agent name from Django settings."""
settings.ROOM_SUBTITLE_AGENT_NAME = "fake-subtitle-agent-name"
room = RoomFactory(name="my room")
SubtitleService().start_subtitle(room)
mock_livekit_client.agent_dispatch.create_dispatch.assert_called_once()
call_args = mock_livekit_client.agent_dispatch.create_dispatch.call_args[0][0]
assert call_args.agent_name == "fake-subtitle-agent-name"
assert call_args.room == str(room.id)
def test_stop_subtitle_not_implemented():
"""Test that stop_subtitle raises NotImplementedError."""
room = RoomFactory(name="my room")
with pytest.raises(
NotImplementedError, match="Subtitle agent stopping not yet implemented"
):
SubtitleService().stop_subtitle(room)
@@ -1,305 +0,0 @@
"""
Test telephony service.
"""
# pylint: disable=W0212
from unittest import mock
import pytest
from asgiref.sync import async_to_sync
from livekit.api import TwirpError
from livekit.protocol.sip import (
CreateSIPDispatchRuleRequest,
DeleteSIPDispatchRuleRequest,
ListSIPDispatchRuleRequest,
ListSIPDispatchRuleResponse,
SIPDispatchRule,
SIPDispatchRuleInfo,
)
from core.factories import RoomFactory
from core.models import RoomAccessLevel
from core.services.telephony import TelephonyException, TelephonyService
pytestmark = pytest.mark.django_db
def create_mock_livekit_client():
"""Factory for creating LiveKit client mock."""
mock_api = mock.Mock()
mock_api.sip = mock.Mock()
mock_api.aclose = mock.AsyncMock()
return mock_api
def test_rule_name():
"""Test rule name generation."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
rule_name = telephony_service._rule_name(room.id)
assert rule_name == f"SIP_{str(room.id)}"
@mock.patch("core.utils.create_livekit_client")
def test_create_dispatch_rule_success(mock_client_factory):
"""Test successful dispatch rule creation."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock()
mock_client_factory.return_value = mock_api
telephony_service.create_dispatch_rule(room)
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
create_request = mock_api.sip.create_sip_dispatch_rule.call_args[1]["create"]
assert isinstance(create_request, CreateSIPDispatchRuleRequest)
assert create_request.name == f"SIP_{str(room.id)}"
assert create_request.rule.dispatch_rule_direct.room_name == str(room.id)
assert create_request.rule.dispatch_rule_direct.pin == str(room.pin_code)
mock_api.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_create_dispatch_rule_api_failure(mock_client_factory):
"""Test dispatch rule creation when API fails."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock(
side_effect=TwirpError(msg="Internal server error", code=500, status=500)
)
mock_client_factory.return_value = mock_api
with pytest.raises(TelephonyException, match="Could not create dispatch rule"):
telephony_service.create_dispatch_rule(room)
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
mock_api.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_list_dispatch_rules_ids_success(mock_client_factory):
"""Test successful listing of dispatch rule IDs."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_rules = [
SIPDispatchRuleInfo(
sip_dispatch_rule_id="rule-1",
name=f"SIP_{str(room.id)}",
rule=SIPDispatchRule(),
),
SIPDispatchRuleInfo(
sip_dispatch_rule_id="rule-2", name="OTHER_RULE", rule=SIPDispatchRule()
),
SIPDispatchRuleInfo(
sip_dispatch_rule_id="rule-3",
name=f"SIP_{str(room.id)}",
rule=SIPDispatchRule(),
),
]
mock_api = create_mock_livekit_client()
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
return_value=ListSIPDispatchRuleResponse(items=mock_rules)
)
mock_client_factory.return_value = mock_api
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
assert len(result) == 2
assert "rule-1" in result
assert "rule-3" in result
assert "rule-2" not in result
mock_api.sip.list_sip_dispatch_rule.assert_called_once()
list_request = mock_api.sip.list_sip_dispatch_rule.call_args[1]["list"]
assert isinstance(list_request, ListSIPDispatchRuleRequest)
mock_api.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
"""Test listing dispatch rule IDs when no rules exist."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
return_value=ListSIPDispatchRuleResponse(items=[])
)
mock_client_factory.return_value = mock_api
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
assert result == []
mock_api.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
"""Test listing dispatch rule IDs when no rules match the room."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_rules = [
SIPDispatchRuleInfo(
sip_dispatch_rule_id="rule-1", name="OTHER_RULE_1", rule=SIPDispatchRule()
),
SIPDispatchRuleInfo(
sip_dispatch_rule_id="rule-2", name="OTHER_RULE_2", rule=SIPDispatchRule()
),
]
mock_api = create_mock_livekit_client()
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
return_value=ListSIPDispatchRuleResponse(items=mock_rules)
)
mock_client_factory.return_value = mock_api
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
assert result == []
mock_api.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_list_dispatch_rules_ids_api_failure(mock_client_factory):
"""Test listing dispatch rule IDs when API fails."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_api = create_mock_livekit_client()
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
side_effect=TwirpError(msg="Internal server error", code=500, status=500)
)
mock_client_factory.return_value = mock_api
with pytest.raises(TelephonyException, match="Could not list dispatch rules"):
async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
mock_api.sip.list_sip_dispatch_rule.assert_called_once()
mock_api.aclose.assert_called_once()
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_no_rules(mock_client_factory, mock_list_rules):
"""Test deleting dispatch rules when no rules exist."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = []
result = telephony_service.delete_dispatch_rule(room.id)
assert result is False
mock_list_rules.assert_called_once_with(room.id)
mock_client_factory.assert_not_called()
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
"""Test deleting a single dispatch rule."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1"]
mock_api = create_mock_livekit_client()
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
mock_client_factory.return_value = mock_api
result = telephony_service.delete_dispatch_rule(room.id)
assert result is True
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
delete_request = mock_api.sip.delete_sip_dispatch_rule.call_args[1]["delete"]
assert isinstance(delete_request, DeleteSIPDispatchRuleRequest)
assert delete_request.sip_dispatch_rule_id == "rule-1"
mock_api.aclose.assert_called_once()
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rules):
"""Test deleting multiple dispatch rules."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
mock_api = create_mock_livekit_client()
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
mock_client_factory.return_value = mock_api
result = telephony_service.delete_dispatch_rule(room.id)
assert result is True
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 3
deleted_rule_ids = [
call_args[1]["delete"].sip_dispatch_rule_id
for call_args in mock_api.sip.delete_sip_dispatch_rule.call_args_list
]
assert all(
rule_id in deleted_rule_ids for rule_id in ["rule-1", "rule-2", "rule-3"]
)
mock_api.aclose.assert_called_once()
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rules):
"""Test deleting multiple dispatch rules when one deletion fails."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
mock_api = create_mock_livekit_client()
call_count = 0
def delete_side_effect(*args, **kwargs):
nonlocal call_count
if call_count == 0:
call_count += 1
return None
raise TwirpError(msg="Deletion failed", code=500, status=500)
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock(
side_effect=delete_side_effect
)
mock_client_factory.return_value = mock_api
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"):
telephony_service.delete_dispatch_rule(room.id)
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 2
mock_api.aclose.assert_called_once()
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
@mock.patch("core.utils.create_livekit_client")
def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules):
"""Test deleting dispatch rules when API fails immediately."""
telephony_service = TelephonyService()
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
mock_list_rules.return_value = ["rule-1"]
mock_api = create_mock_livekit_client()
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock(
side_effect=TwirpError(msg="Internal server error", code=500, status=500)
)
mock_client_factory.return_value = mock_api
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"):
telephony_service.delete_dispatch_rule(room.id)
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
mock_api.aclose.assert_called_once()

Some files were not shown because too many files have changed in this diff Show More