Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f1122def1 |
@@ -0,0 +1,35 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'preprod'
|
||||
- 'production'
|
||||
|
||||
|
||||
jobs:
|
||||
notify-argocd:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
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 ''${{ secrets.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}" ${{ vars.ARGOCD_WEBHOOK_URL }}
|
||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${{ secrets.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}" ${{ secrets.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"
|
||||
@@ -31,10 +31,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 "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
@@ -52,7 +49,7 @@ 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:
|
||||
-
|
||||
@@ -67,10 +64,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 "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
@@ -89,43 +83,6 @@ 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:
|
||||
@@ -141,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 "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -157,44 +111,11 @@ 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'
|
||||
steps:
|
||||
|
||||
@@ -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.12"
|
||||
- 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
|
||||
@@ -230,8 +185,7 @@ jobs:
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
cache: "pip"
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
|
||||
@@ -8,5 +8,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
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
|
||||
@@ -71,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
|
||||
@@ -117,15 +116,9 @@ run-backend: ## start only the backend application and all needed services
|
||||
@$(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
|
||||
|
||||
@@ -253,9 +246,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:
|
||||
@@ -357,7 +347,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
|
||||
|
||||
@@ -33,11 +33,10 @@ Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level perfo
|
||||
- Support for multiple screen sharing streams
|
||||
- Non-persistent, secure chat
|
||||
- End-to-end encryption (coming soon)
|
||||
- Meeting recording
|
||||
- Meeting transcription & Summary (currently in beta)
|
||||
- Telephony integration
|
||||
- Meeting recording (coming soon)
|
||||
- Meeting transcription (currently in beta)
|
||||
- Telephony integration (in development)
|
||||
- Secure participation with robust authentication and access control
|
||||
- Customizable frontend style
|
||||
- LiveKit Advances features including :
|
||||
- speaker detection
|
||||
- simulcast
|
||||
@@ -55,7 +54,6 @@ We’re continuously adding new features to enhance your experience, with the la
|
||||
|
||||
- [Get started](#get-started)
|
||||
- [Docs](#docs)
|
||||
- [Self-host](#self-host)
|
||||
- [Contributing](#contributing)
|
||||
- [Philosophy](#philosophy)
|
||||
- [Open source](#open-source)
|
||||
@@ -63,37 +61,22 @@ We’re continuously adding new features to enhance your experience, with the la
|
||||
|
||||
## Get started
|
||||
|
||||
### 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.
|
||||
|
||||
### 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!
|
||||
|
||||
## Self-host
|
||||
|
||||
### La Suite Meet is easy to install on your own servers
|
||||
|
||||
We use Kubernetes for our [production instance](https://visio.numerique.gouv.fr/) but also support Docker Compose. The community contributed a couple other methods (Nix, YunoHost etc.) check out the [docs](/docs/installation/README.md) to get detailed instructions and examples.
|
||||
|
||||
**Questions?** Open an issue on [GitHub](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) or join our [Matrix community](https://matrix.to/#/#meet-official:matrix.org).
|
||||
|
||||
> [!NOTE]
|
||||
> Some advanced features (ex: recording, transcription) lack detailed documentation. We're working hard to provide comprehensive guides soon.
|
||||
|
||||
#### Known instances
|
||||
We hope to see many more, here is an incomplete list of public La Suite Meet instances. Feel free to make a PR to add ones that are not listed below🙏
|
||||
|
||||
| Url | Org | Access |
|
||||
|---------------------------------------------------------------| --- | ------- |
|
||||
| [visio.numerique.gouv.fr](https://visio.numerique.gouv.fr/) | DINUM | French public agents working for the central administration and the extended public sphere. ProConnect is required to login in or sign up|
|
||||
| [visio.suite.anct.gouv.fr](https://visio.suite.anct.gouv.fr/) | ANCT | French public agents working for the territorial administration and the extended public sphere. ProConnect is required to login in or sign up|
|
||||
| [visio.lasuite.coop](https://visio.lasuite.coop/) | lasuite.coop | Free and open demo to all. Content and accounts are reset after one month |
|
||||
| [meet.demo.mosacloud.eu](https://meet.demo.mosacloud.eu/) | mosa.cloud | Demo instance of mosa.cloud, a dutch company providing services around La Suite apps. |
|
||||
|
||||
|
||||
## 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/11/views/4)
|
||||
- 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)
|
||||
|
||||
@@ -128,7 +111,6 @@ Come help us make La Suite Meet even better. We're growing fast and [would love
|
||||
## 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.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+4
-44
@@ -2,15 +2,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)
|
||||
|
||||
@@ -31,19 +22,7 @@ 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,32 +31,20 @@ docker_build(
|
||||
sync('../src/frontend', '/home/frontend'),
|
||||
]
|
||||
)
|
||||
clean_old_images('localhost:5001/meet-frontend-generic')
|
||||
clean_old_images('localhost:5001/meet-frontend')
|
||||
|
||||
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(
|
||||
@@ -97,13 +64,6 @@ 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
|
||||
|
||||
-78
@@ -46,21 +46,6 @@ services:
|
||||
/usr/bin/mc mb meet/meet-media-storage && \
|
||||
exit 0;"
|
||||
|
||||
createwebhook:
|
||||
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 admin config set meet notify_webhook:meet-webhook endpoint='http://app-dev:8000/api/v1.0/recordings/storage-hook/' auth_token='Bearer password' &&
|
||||
/usr/bin/mc admin service restart meet --wait --json &&
|
||||
sleep 15 &&
|
||||
/usr/bin/mc event add meet/meet-media-storage arn:minio:sqs::meet-webhook:webhook --event put &&
|
||||
exit 0;"
|
||||
|
||||
app-dev:
|
||||
build:
|
||||
context: .
|
||||
@@ -87,7 +72,6 @@ services:
|
||||
- nginx
|
||||
- livekit
|
||||
- createbuckets
|
||||
- createwebhook
|
||||
extra_hosts:
|
||||
- "127.0.0.1.nip.io:host-gateway"
|
||||
|
||||
@@ -154,7 +138,6 @@ services:
|
||||
target: frontend-production
|
||||
args:
|
||||
VITE_API_BASE_URL: "http://localhost:8071"
|
||||
VITE_APP_TITLE: "LaSuite Meet"
|
||||
image: meet:frontend-development
|
||||
ports:
|
||||
- "3000:8080"
|
||||
@@ -237,64 +220,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
|
||||
|
||||
@@ -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 libexpat>=2.7.2-r0
|
||||
|
||||
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 |
@@ -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;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM livekit/livekit-server:v1.9.0
|
||||
FROM livekit/livekit-server:v1.8.0
|
||||
|
||||
# We inject the nip.io certificate manually because the livekit chart doesn't support volume mounting
|
||||
COPY rootCA.pem /etc/ssl/certs/
|
||||
|
||||
@@ -142,4 +142,4 @@ Once the Kubernetes cluster is ready, start the application stack locally:
|
||||
$ make start-tilt-keycloak
|
||||
```
|
||||
|
||||
Monitor Tilt’s 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/).
|
||||
Monitor Tilt’s progress at [http://localhost:10350/](http://localhost:10350/). After Tilt actions finish, you can access the app at [https://visio.127.0.0.1.nip.io/](https://visio.127.0.0.1.nip.io/).
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# LiveKit Egress
|
||||
|
||||
LiveKit offers Universal Egress, designed to provide universal exports of LiveKit sessions or tracks to a file or stream data.
|
||||
It is kept in a separate system to keep the load off the [Single Forwarding Unit (SFU)](https://docs.livekit.io/reference/internals/livekit-sfu/) and avoid impacting real-time audio or video performance/quality.
|
||||
|
||||
## Getting started
|
||||
|
||||
### Prerequisite
|
||||
|
||||
1. **Verify Services**: Ensure the LiveKit server and Egress service are both up and running.
|
||||
2. **Install CLI**: Confirm that the LiveKit CLI utility is installed on your system.
|
||||
3. **Set Permissions**: Since the Egress service does not run as the root user, you need to grant write permissions to all users for the output directory. Update the permissions of the `docker/livekit/out` folder before starting the docker-compose stack:
|
||||
|
||||
```bash
|
||||
$ chmod o+w ./docker/livekit/out
|
||||
```
|
||||
|
||||
### Make a recording
|
||||
|
||||
LiveKit provides examples for creating Egress requests, which you can find [here](https://github.com/livekit/livekit-cli/tree/main/cmd/livekit-cli/examples). One of these examples has been added to the repository under `docker/livekit/egress-example`.
|
||||
|
||||
Follow these steps to start an Egress request:
|
||||
|
||||
1. **Create a Room**: Create a room either through the frontend or using the `livekit-cli` command.
|
||||
2. **Retrieve Room Name**: Get the room's name (e.g., the UUID4 in the URL from the frontend).
|
||||
3. **Update Configuration**: Edit the `docker/livekit/egress-example/room-composite-file.json` file with your room's name.
|
||||
4. **Start Egress Request**: Initiate a new Egress request.
|
||||
```bash
|
||||
$ livekit-cli start-room-composite-egress --request ./docker/livekit/egress-example/room-composite-file.json
|
||||
|
||||
Using default project meet
|
||||
EgressID: EG_XXXXXXXXXXXX Status: EGRESS_STARTING
|
||||
```
|
||||
|
||||
You can list running Egress:
|
||||
|
||||
```Bash
|
||||
$ livekit-cli list-egress
|
||||
|
||||
Using default project meet
|
||||
+-----------------+---------------+----------------+--------------------------------------+--------------------------------+-------+
|
||||
| EGRESSID | STATUS | TYPE | SOURCE | STARTED AT | ERROR |
|
||||
+-----------------+---------------+----------------+--------------------------------------+--------------------------------+-------+
|
||||
| EG_XXXXXXXXXXXX | EGRESS_ACTIVE | room_composite | your-room-name-XXXXXXXXXXX-XXXXXXXXX | 2024-07-05 18:11:37.073847924 | |
|
||||
| | | | | +0200 CEST | |
|
||||
+-----------------+---------------+----------------+--------------------------------------+--------------------------------+-------+
|
||||
```
|
||||
|
||||
You can stop the Egress at any time once your recording is finished:
|
||||
```Bash
|
||||
$ livekit-cli stop-egress --id EG_XXXXXXXXXXXX
|
||||
|
||||
Using default project meet
|
||||
Stopping Egress EG_XXXXXXXXXXXX
|
||||
```
|
||||
|
||||
The Egress should be marked as completed:
|
||||
```bash
|
||||
$ livekit-cli list-egress
|
||||
|
||||
Using default project meet
|
||||
+-----------------+-----------------+----------------+--------------------------------------+--------------------------------+-------+
|
||||
| EGRESSID | STATUS | TYPE | SOURCE | STARTED AT | ERROR |
|
||||
+-----------------+-----------------+----------------+--------------------------------------+--------------------------------+-------+
|
||||
| EG_XXXXXXXXXXXX | EGRESS_COMPLETE | room_composite | your-room-name-XXXXXXXXXXX-XXXXXXXXX | 2024-07-05 18:11:37.073847924 | |
|
||||
| | | | | +0200 CEST | |
|
||||
+-----------------+-----------------+----------------+--------------------------------------+--------------------------------+-------+
|
||||
```
|
||||
|
||||
|
||||
Finally, you should find two new files in the `./docker/livekit/out directory`: an `.mp4` recording and its associated metadata in a `.json` file:
|
||||
```bash
|
||||
$ ls ./docker/livekit/out
|
||||
|
||||
your-room-name-YYYY-MM-DDTHHMMSS.mp4
|
||||
your-room-name-YYYY-MM-DDTHHMMSS.mp4.json
|
||||
```
|
||||
|
||||
### Resources
|
||||
|
||||
[Official Egress repository](https://github.com/livekit/egress)
|
||||
@@ -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
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
# Authentication (OIDC)
|
||||
|
||||
La Suite Meet supports **OIDC authentication** using the Authorization Code Flow.
|
||||
Authentication relies on [django-lasuite](https://github.com/suitenumerique/django-lasuite) for OIDC integration, token validation, and user management.
|
||||
|
||||
|
||||
## OIDC Configuration
|
||||
|
||||
| Option | Description | Default |
|
||||
|-------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| ------------------------------ |
|
||||
| **Client Settings** | | |
|
||||
| OIDC_RP_CLIENT_ID | OIDC client identifier registered with your provider | `meet` |
|
||||
| OIDC_RP_CLIENT_SECRET | OIDC client secret (keep confidential) | — |
|
||||
| OIDC_CREATE_USER | Automatically create a local user if none exists | `true` |
|
||||
| **Security & Verification** | | |
|
||||
| OIDC_VERIFY_SSL | Verify SSL certificates when contacting the OIDC provider | `true` |
|
||||
| OIDC_USE_NONCE | Use `nonce` to prevent replay attacks | `true` |
|
||||
| OIDC_STORE_ID_TOKEN | Store the ID token returned by the OIDC provider (useful for backend validation) | `true` |
|
||||
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to identifying users by email if `sub` claim does not match. Enable only if emails are unique. | `false` |
|
||||
| **Endpoints** | | |
|
||||
| OIDC_OP_JWKS_ENDPOINT | URL to retrieve JSON Web Key Sets (for token verification) | — |
|
||||
| OIDC_OP_AUTHORIZATION_ENDPOINT | URL for authorization requests | — |
|
||||
| OIDC_OP_TOKEN_ENDPOINT | URL to exchange authorization code for tokens | — |
|
||||
| OIDC_OP_USER_ENDPOINT | URL to fetch user information | — |
|
||||
| OIDC_OP_USER_ENDPOINT_FORMAT | Format of user endpoint response. Options: `AUTO` (detect automatically), `JWT`, or `JSON` | `AUTO` |
|
||||
| OIDC_OP_LOGOUT_ENDPOINT | URL for logout requests | — |
|
||||
| **User Info Mapping** | | |
|
||||
| OIDC_USERINFO_FULLNAME_FIELDS | List of OIDC claims used to build user’s full name | `["given_name", "usual_name"]` |
|
||||
| OIDC_USERINFO_SHORTNAME_FIELD | OIDC claim used for the user’s short name | `given_name` |
|
||||
| OIDC_USERINFO_ESSENTIAL_CLAIMS | List of essential claims required from the provider | `[]` |
|
||||
| **Redirects & Scopes** | | |
|
||||
| OIDC_REDIRECT_REQUIRE_HTTPS | Require HTTPS for OIDC redirect URIs (**recommended in production**) | `false` |
|
||||
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed hosts for OIDC redirects | `[]` |
|
||||
| OIDC_REDIRECT_FIELD_NAME | Query parameter name used for redirect after login | `returnTo` |
|
||||
| OIDC_RP_SCOPES | Scopes to request during authentication | `openid email` |
|
||||
| LOGIN_REDIRECT_URL | URL to redirect after successful login | — |
|
||||
| LOGIN_REDIRECT_URL_FAILURE | URL to redirect after failed login | — |
|
||||
| LOGOUT_REDIRECT_URL | URL to redirect after logout | — |
|
||||
| ALLOW_LOGOUT_GET_METHOD | Allow logout through HTTP GET (POST is recommended for security) | `true` |
|
||||
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | Extra parameters to include in OIDC authentication requests | `{}` |
|
||||
| **PKCE (Proof Key for Code Exchange)** | | |
|
||||
| OIDC_USE_PKCE | Enable PKCE for enhanced security (**recommended**) | `false` |
|
||||
| OIDC_PKCE_CODE_CHALLENGE_METHOD | Method to generate PKCE code challenge (`S256` recommended) | `S256` |
|
||||
| OIDC_PKCE_CODE_VERIFIER_SIZE | Length of the random string used as PKCE code verifier (43–128 characters) | `64` |
|
||||
| **Other** | | |
|
||||
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Silent login allows La Suite Meet to authenticate users automatically without showing a login prompt, providing a seamless experience when an active session already exists with the OIDC provider. It works by replaying the authentication request with prompt=none: if the user has a valid session, login succeeds silently; otherwise, it fails gracefully and redirects the user to the initial page. Silent login is optional and enabled by default in standard deployments. The app retries silent login after any 401 response, with at least a 30-second interval between attempts (not configurable via environment variables). Controlled by the backend parameter. /!\ Your OIDC provider must support `prompt=none`. | `false` |
|
||||
|
||||
|
||||
## Sessions
|
||||
|
||||
* After login, users receive a **Django session cookie** to maintain authentication across requests.
|
||||
* Default session duration is 12 hours (`SESSION_COOKIE_AGE = 60 * 60 * 12`).
|
||||
* Ensure your session policy matches your security requirements.
|
||||
@@ -1,6 +0,0 @@
|
||||
|
||||
# Calendar integrations (WIP)
|
||||
|
||||
These features are currently under active development and are not yet ready for official documentation. Comprehensive documentation will be provided as soon as possible.
|
||||
|
||||
An initial integration with OpenExchange is already available and will be documented shortly.
|
||||
@@ -1,143 +0,0 @@
|
||||
|
||||
# Room Recording (Beta)
|
||||
|
||||
La Suite Meet offers a room recording feature that is currently in beta, with ongoing improvements planned.
|
||||
|
||||
The feature allows users to record their room sessions. When a recording is complete, the room owner receives a notification with a link to download the recorded file. Recordings are automatically deleted after `RECORDING_EXPIRATION_DAYS`.
|
||||
|
||||
It uses LiveKit Egress to record room sessions. For reference, see the [LiveKit Egress repository](https://github.com/livekit/egress) and the [official documentation](https://docs.livekit.io/home/egress/overview/).
|
||||
|
||||
**Current Limitations**:
|
||||
|
||||
* Users cannot record and transcribe simultaneously. ([Issue #527](https://github.com/suitenumerique/meet/issues/527)
|
||||
is on our backlog)
|
||||
* Recording layout cannot be configured from the frontend. By default, the egress captures the active speaker and any shared screens. (not yet planned)
|
||||
* Shareable links with an embedded video player are not yet supported. (not yet planned)
|
||||
|
||||
> [!NOTE]
|
||||
> Questions? Open an issue on [GitHub](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) or join our [Matrix community](https://matrix.to/#/#meet-official:matrix.org).
|
||||
|
||||
|
||||
## Special requirements
|
||||
|
||||
To use the room recording feature, the following components are required:
|
||||
|
||||
- A running [LiveKit Egress](https://github.com/livekit/egress) server capable of handling room composite recordings.
|
||||
- A S3-compatible object storage that supports webhook events to notify the backend when recordings are uploaded.
|
||||
- An email service to notify room owners when a recording is available for download.
|
||||
- Webhook events configured between LiveKit Server and the backend.
|
||||
|
||||
|
||||
> [!CAUTION]
|
||||
> Minio supports lifecycle events; other providers may not work out of the box. There is currently a dependency on Minio, which is planned to be refactored in the future.
|
||||
|
||||
> [!NOTE]
|
||||
> Celery isn’t in use for these async tasks yet. It’s something we’d like to add, but it’s not planned at this stage.
|
||||
|
||||
|
||||
## How It Works
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant User
|
||||
participant Frontend as Frontend (React)
|
||||
participant Backend as Django Backend
|
||||
participant LiveKit as LiveKit API
|
||||
participant Egress as LiveKit Egress
|
||||
participant Storage as Object Storage
|
||||
participant Room as LiveKit Room
|
||||
participant Email as Email Service
|
||||
|
||||
User->>Frontend: Click start recording button
|
||||
|
||||
Frontend->>Backend: POST /api/v1.0/rooms/{id}/start-recording/
|
||||
|
||||
Backend->>LiveKit: Create egress request
|
||||
|
||||
LiveKit->>Egress: Start room composite egress
|
||||
Egress->>Room: Join room as recording participant
|
||||
Note over Egress,Room: Egress joins room to capture audio/video
|
||||
|
||||
LiveKit-->>Backend: Return egress_id
|
||||
Backend->>Backend: Update Recording with worker_id
|
||||
Backend-->>Frontend: HTTP 201 - Recording started
|
||||
|
||||
Frontend->>Frontend: Update recording status
|
||||
Frontend->>Frontend: Notify other participants
|
||||
Note over Frontend: Via LiveKit data channel
|
||||
|
||||
Note over Egress,Room: Recording in progress...
|
||||
|
||||
User->>Frontend: Click stop recording button
|
||||
Frontend->>Backend: POST /api/v1.0/rooms/{id}/stop-recording/
|
||||
|
||||
Backend->>LiveKit: Stop egress request
|
||||
LiveKit->>Egress: Stop recording
|
||||
Egress->>Storage: Upload recorded file
|
||||
|
||||
Storage->>Backend: Storage event notification
|
||||
Backend->>Backend: Update Recording status to SAVED
|
||||
Backend->>Email: Send notification to room owner
|
||||
|
||||
Backend-->>Frontend: HTTP 200 - Recording stopped
|
||||
Frontend->>Frontend: Update UI and notify participants
|
||||
|
||||
Email->>User: Send email with recording link
|
||||
User->>Frontend: Navigate to /recording/{id} to download file
|
||||
Frontend->>Frontend: Download recording file
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| --------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| **RECORDING_ENABLE** | Boolean | `False` | Enable or disable the room recording feature. |
|
||||
| **RECORDING_OUTPUT_FOLDER** | String | `"recordings"` | Folder/prefix where recordings are stored in the object storage. |
|
||||
| **RECORDING_WORKER_CLASSES** | Dict | `{ "screen_recording": "core.recording.worker.services.VideoCompositeEgressService", "transcript": "core.recording.worker.services.AudioCompositeEgressService" }` | Maps recording types to their worker service classes. |
|
||||
| **RECORDING_EVENT_PARSER_CLASS** | String | `"core.recording.event.parsers.MinioParser"` | Class responsible for parsing storage events and updating the backend. |
|
||||
| **RECORDING_ENABLE_STORAGE_EVENT_AUTH** | Boolean | `True` | Enable authentication for storage event webhook requests. |
|
||||
| **RECORDING_STORAGE_EVENT_ENABLE** | Boolean | `False` | Enable handling of storage events (must configure webhook in storage). |
|
||||
| **RECORDING_STORAGE_EVENT_TOKEN** | Secret/File | `None` | Token used to authenticate storage webhook requests, if `RECORDING_ENABLE_STORAGE_EVENT_AUTH` is enabled. |
|
||||
| **RECORDING_EXPIRATION_DAYS** | Integer | `None` | Number of days before recordings expire. Should match bucket lifecycle policy. Set to `None` for no expiration. |
|
||||
| **RECORDING_MAX_DURATION** | Integer | `None` | Maximum duration of a recording in milliseconds. Must be synced with the LiveKit Egress configuration. Set to None for unlimited duration. When the maximum duration is reached, the recording is automatically stopped and saved, and the user is prompted in the frontend with an alert message. |
|
||||
|
||||
|
||||
### Manual Storage Webhook
|
||||
|
||||
Storage events must be configured manually; the Kubernetes chart does not do this automatically.
|
||||
|
||||
1. Configure your S3 bucket to send file creation events to the backend webhook.
|
||||
2. Enable events and token in settings:
|
||||
|
||||
```python
|
||||
RECORDING_STORAGE_EVENT_ENABLE = True
|
||||
RECORDING_ENABLE_STORAGE_EVENT_AUTH = True
|
||||
RECORDING_STORAGE_EVENT_TOKEN = <token>
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Questions? Open an issue on [GitHub](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) or join our [Matrix community](https://matrix.to/#/#meet-official:matrix.org).
|
||||
|
||||
|
||||
## LiveKit Egress
|
||||
|
||||
La Suite Meet uses LiveKit Egress to record room sessions. For reference, see the [LiveKit Egress repository](https://github.com/livekit/egress) and the [official documentation](https://docs.livekit.io/home/egress/overview/).
|
||||
|
||||
Currently, only `RoomCompositeEgress` is supported. This mode combines all video and audio tracks from the room into a single recording.
|
||||
|
||||
To monitor egress workers and inspect recording status, it is recommended to install `livekit-cli`. For example, you can list active egress sessions using the following command:
|
||||
|
||||
```bash
|
||||
$ livekit-cli list-egress
|
||||
|
||||
Using default project meet
|
||||
+-----------------+---------------+----------------+--------------------------------------+--------------------------------+-------+
|
||||
| EGRESSID | STATUS | TYPE | SOURCE | STARTED AT | ERROR |
|
||||
+-----------------+---------------+----------------+--------------------------------------+--------------------------------+-------+
|
||||
| EG_XXXXXXXXXXXX | EGRESS_ACTIVE | room_composite | your-room-name-XXXXXXXXXXX-XXXXXXXXX | 2024-07-05 18:11:37.073847924 | |
|
||||
| | | | | +0200 CEST | |
|
||||
+-----------------+---------------+----------------+--------------------------------------+--------------------------------+-------+
|
||||
```
|
||||
|
||||
This allows you to verify which recordings are in progress, troubleshoot egress issues, and confirm that recordings are being processed correctly.
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# Signaling
|
||||
|
||||
Signaling is essential for LiveKit’s real-time communication. It enables peers to discover each other, exchange session descriptions, and negotiate network paths for audio and video streams.
|
||||
|
||||
## How Signaling Works
|
||||
|
||||
LiveKit signaling relies on a WebSocket connection between the client and the LiveKit API server. This WebSocket is required for all signaling messages, including session descriptions, ICE candidates, and connection state updates.
|
||||
|
||||
We do not cover internal signaling behavior. For full reference, see the [LiveKit client protocol](https://docs.livekit.io/reference/internals/client-protocol/).
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The WebSocket is the backbone of LiveKit signaling. All signaling messages rely on it, and without it, ICE candidate exchange and peer connection setup cannot occur. If the WebSocket connection is lost, the client automatically attempts to resume the RTC session once connectivity is restored.
|
||||
|
||||
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Type | Default | Purpose |
|
||||
| ----------------------------------------- | ------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `LIVEKIT_FORCE_WSS_PROTOCOL` | Boolean | `True` | Forces the WebSocket URL to use `wss://`. Required for legacy browsers (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs in `WebSocket()` may fail. |
|
||||
| `LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND` | Boolean | `True` | Workaround for Firefox clients behind proxies that fail to establish WebSocket connections. Pre-establishes a dummy connection to “prime” the WebSocket. |
|
||||
|
||||
> [!NOTE]
|
||||
> Questions? Open an issue on [GitHub](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) or join our [Matrix community](https://matrix.to/#/#meet-official:matrix.org).
|
||||
@@ -1,4 +0,0 @@
|
||||
|
||||
# Live subtitles (WIP)
|
||||
|
||||
This feature is currently under development and not yet ready for production use. Documentation and detailed instructions will be provided once the feature is stable and officially released.
|
||||
@@ -1,4 +0,0 @@
|
||||
|
||||
# Meeting summarization (WIP)
|
||||
|
||||
This feature is currently under development and not yet ready for production use. Documentation and detailed instructions will be provided once the feature is stable and officially released.
|
||||
@@ -1,87 +0,0 @@
|
||||
# Telephony SIP (Beta)
|
||||
|
||||
Enable participants to join a video conference via phone, allowing them to participate in the room even when their internet connection is poor or unavailable.
|
||||
|
||||
**Current Limitations**:
|
||||
|
||||
* Supports only a single SIP trunk provider per instance.
|
||||
* A participant joining over the phone cannot enter the room until the first WebRTC participant has connected.
|
||||
|
||||
## Special requirements
|
||||
|
||||
To use the telephony feature, the following components are required:
|
||||
* A running [LiveKit SIP server](https://github.com/livekit/sip) ([documentation](https://docs.livekit.io/home/self-hosting/sip-server/)) to handle SIP participants and connect them to room sessions.
|
||||
* A SIP trunk to route incoming and outgoing phone calls.
|
||||
* Webhook events configured between the LiveKit server and the backend.
|
||||
|
||||
|
||||
## How It Works
|
||||
|
||||
### Room Lifecycle
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Backend
|
||||
participant LiveKit as LiveKit Service
|
||||
participant SIP as LiveKit SIP
|
||||
participant Dispatch as SIP Dispatch
|
||||
|
||||
Backend->>Backend: Create new room
|
||||
Backend->>Backend: Assign unique pin code to room
|
||||
|
||||
LiveKit-->>Backend: Webhook room_started
|
||||
Backend->>Dispatch: Create LiveKit SIP dispatch rule
|
||||
|
||||
LiveKit-->>Backend: Webhook room_ended
|
||||
Backend->>Dispatch: Clear LiveKit SIP dispatch rule
|
||||
```
|
||||
|
||||
### Participant calling
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Caller as Caller
|
||||
participant SIPProvider as SIP Trunk Provider
|
||||
participant LiveKitSIP as LiveKit SIP
|
||||
participant Dispatch as SIP Dispatch
|
||||
participant Room as LiveKit Room
|
||||
|
||||
Caller->>SIPProvider: Dial phone number
|
||||
SIPProvider->>LiveKitSIP: Route call to SIP server
|
||||
LiveKitSIP->>Caller: Prompt for room pin code
|
||||
Caller->>LiveKitSIP: Enter pin code
|
||||
LiveKitSIP->>Dispatch: Check dispatch rule for pin and trunk ID
|
||||
Dispatch-->>LiveKitSIP: Return room ID if found
|
||||
LiveKitSIP->>Room: Connect participant to room
|
||||
```
|
||||
|
||||
|
||||
## Configuration
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ------------------------------ | ---------------- | ------- |-----------------------------------------------------------------------------------------------------------------|
|
||||
| ROOM_TELEPHONY_ENABLED | Boolean | False | Enable or disable telephony (phone call) support for rooms. |
|
||||
| ROOM_TELEPHONY_PIN_LENGTH | Positive Integer | 10 | Length of the PIN code participants must enter to join a call. |
|
||||
| ROOM_TELEPHONY_PIN_MAX_RETRIES | Positive Integer | 5 | Maximum number of attempts a participant can make when entering the PIN. |
|
||||
| ROOM_TELEPHONY_PHONE_NUMBER | String | None | The phone number associated with the room for incoming calls. Required to route calls via the telephony system. |
|
||||
| ROOM_TELEPHONY_DEFAULT_COUNTRY | String | "US" | Default country code for phone numbers, used for parsing and formatting phone numbers. |
|
||||
|
||||
|
||||
### SIP Trunk Authentication
|
||||
|
||||
You may need to configure authentication between LiveKit SIP and your SIP trunk provider to enable participants to join via phone.
|
||||
Please refer to [the official documentation](https://docs.livekit.io/sip/quickstarts/configuring-sip-trunk/).
|
||||
|
||||
> [!NOTE]
|
||||
> Questions? Open an issue on [GitHub](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) or join our [Matrix community](https://matrix.to/#/#meet-official:matrix.org).
|
||||
|
||||
|
||||
### Language Customization for Audio Prompts
|
||||
|
||||
You may need to configure the default LiveKit voice to match your locale. By default, all LiveKit audio instructions are in English.
|
||||
To customize the prompts, mount the appropriate audio files as a volume in your deployment. The audio resources are available here: [LiveKit SIP audio files](https://github.com/livekit/sip/tree/main/res).
|
||||
|
||||
|
||||
## Documentation
|
||||
|
||||
For detailed information on integrating and configuring SIP with LiveKit, refer to the official LiveKit SIP documentation: [LiveKit SIP Documentation](https://docs.livekit.io/sip/). This guide covers SIP server setup, trunk configuration, dispatch rules, etc.
|
||||
@@ -1,92 +0,0 @@
|
||||
# Transcription
|
||||
|
||||
La Suite Meet provides a room transcription capability, currently available in beta. This feature is under active development, with ongoing enhancements planned.
|
||||
|
||||
The transcription feature enables users to record room sessions. Upon completion of a recording, the room owner receives a notification containing a link to LaSuite Docs, where the transcribed meeting content can be accessed.
|
||||
|
||||
> [!NOTE]
|
||||
> Audio recordings are automatically deleted after the configured `RECORDING_EXPIRATION_DAYS` period.
|
||||
|
||||
For configuration and setup details of the recording functionality, refer to the [Recording feature documentation](https://github.com/suitenumerique/meet/blob/main/docs/features/recording.md).
|
||||
This page only describes the supplementary tools required for audio processing.
|
||||
|
||||
Example of a transcript :
|
||||
|
||||
```
|
||||
**SPEAKER_00**: Hello everyone!
|
||||
**SPEAKER_01**: Yes, it works.
|
||||
```
|
||||
|
||||
|
||||
### Current Limitations
|
||||
|
||||
* Participant identification is not yet implemented; participants are labeled generically (e.g., `PARTICIPANT_1`).
|
||||
* Transcription backend relies on [WhisperX](https://github.com/m-bain/whisperX), which does not provide an OpenAI-compatible API.
|
||||
|
||||
> [!NOTE]
|
||||
> Questions? Open an issue on [GitHub](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) or join our [Matrix community](https://matrix.to/#/#meet-official:matrix.org).
|
||||
|
||||
## Special requirements
|
||||
|
||||
To enable the transcription feature, the following components must be in place:
|
||||
|
||||
* Recording feature components: All dependencies and configurations required for the [recording feature](https://github.com/suitenumerique/meet/blob/main/docs/features/recording.md).
|
||||
* LaSuite Docs instance: A running [LaSuite Docs](https://github.com/suitenumerique/docs) capable of handling requests to the `/create-for-owner` endpoint.
|
||||
* WhisperX API: A running WhisperX service. An open-source implementation combining WhisperX and FastAPI is available [here](https://github.com/suitenumerique/meet-whisperx).
|
||||
* Deployment of the [summary service](https://hub.docker.com/r/lasuite/meet-summary), a Celery worker, and a Redis instance.
|
||||
|
||||
## How It Works
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Backend as Backend API
|
||||
participant Summary as Summary Service
|
||||
participant Celery as Celery Workers (transcribe-queue)
|
||||
participant MinIO as MinIO (Object Storage)
|
||||
participant STT as WhisperX API
|
||||
participant Docs as LaSuite Docs
|
||||
|
||||
Backend->>Summary: POST /api/v1/tasks/ (bearer token, payload)
|
||||
Note right of Backend: Payload contains 7 params: owner_id, filename, email, sub, room, recording_date, recording_time
|
||||
|
||||
Summary->>Celery: Register task (transcribe-queue)
|
||||
Celery->>MinIO: Fetch audio file
|
||||
Celery->>STT: Transcribe audio (WhisperX)
|
||||
STT-->>Celery: Segmented transcript
|
||||
|
||||
Celery->>Celery: Format transcript (text)
|
||||
|
||||
Celery->>Docs: POST /create-for-owner (title, content, email, sub, api token)
|
||||
Docs-->>Celery: Acknowledgement
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ------------------------ | --------- |-----------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| app_name | String | `"app"` | Name of the application/service. |
|
||||
| app_api_v1_str | String | `"/api/v1"` | Base path for the API endpoints. |
|
||||
| app_api_token | Secret | — | API token for authenticating requests. |
|
||||
| recording_max_duration | Integer | `None` | Maximum duration of audio recordings in milliseconds. Set to `None` for unlimited. Audio recordings longer than the configured limit will be ignored and not processed. |
|
||||
| celery_broker_url | String | `"redis://redis/0"` | Celery broker URL. |
|
||||
| celery_result_backend | String | `"redis://redis/0"` | Celery result backend URL. |
|
||||
| celery_max_retries | Integer | `1` | Maximum number of retries for Celery tasks. |
|
||||
| transcribe_queue | String | `"transcribe-queue"` | Name of the Celery queue for transcription tasks. |
|
||||
| aws_storage_bucket_name | String | — | Name of the S3/MinIO bucket used for storing recordings. |
|
||||
| aws_s3_endpoint_url | String | — | Endpoint URL of the S3/MinIO storage. |
|
||||
| aws_s3_access_key_id | String | — | Access key for S3/MinIO. |
|
||||
| aws_s3_secret_access_key | Secret | — | Secret key for S3/MinIO. |
|
||||
| aws_s3_secure_access | Boolean | `True` | Use HTTPS for S3/MinIO requests. |
|
||||
| whisperx_api_key | Secret | — | API key for accessing WhisperX. |
|
||||
| whisperx_base_url | String | `"https://api.whisperx.com/v1"` | Base URL for the WhisperX API. |
|
||||
| whisperx_asr_model | String | `"whisper-1"` | ASR model used for transcription. |
|
||||
| whisperx_max_retries | Integer | `0` | Maximum number of retries for WhisperX API requests. |
|
||||
| webhook_max_retries | Integer | `2` | Maximum retries for webhook requests. |
|
||||
| webhook_status_forcelist | List[Int] | `[502, 503, 504]` | HTTP status codes triggering webhook retry. |
|
||||
| webhook_backoff_factor | Float | `0.1` | Exponential backoff factor for webhook retries. |
|
||||
| webhook_api_token | Secret | — | Token to authenticate incoming webhook requests. |
|
||||
| webhook_url | String | — | URL to which webhook events are sent. |
|
||||
| document_default_title | String | `"Transcription"` | Default title for generated documents. |
|
||||
| document_title_template | String | `'Réunion "{room}" du {room_recording_date} à {room_recording_time}'` | Template for document title. |
|
||||
| sentry_is_enabled | Boolean | `False` | Enable or disable Sentry error tracking. |
|
||||
| sentry_dsn | String | `None` | DSN for Sentry integration. |
|
||||
@@ -0,0 +1,360 @@
|
||||
# Installation on a k8s cluster
|
||||
|
||||
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
|
||||
|
||||
- k8s cluster with an nginx-ingress controller
|
||||
- an OIDC provider (if you don't have one, we will provide an example)
|
||||
- a LiveKit server (if you don't have one, we will provide an example)
|
||||
- a PostgreSQL server (if you don't have one, we will provide an example)
|
||||
- a Memcached server (if you don't have one, we will provide an example)
|
||||
|
||||
### 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**.
|
||||
|
||||
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:
|
||||
|
||||
- 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
|
||||
0. Create ca
|
||||
The local CA is already installed in the system trust store! 👍
|
||||
The local CA is already installed in the Firefox and/or Chrome/Chromium trust store! 👍
|
||||
|
||||
|
||||
Created a new certificate valid for the following names 📜
|
||||
- "127.0.0.1.nip.io"
|
||||
- "*.127.0.0.1.nip.io"
|
||||
|
||||
Reminder: X.509 wildcards only go one level deep, so this won't match a.b.127.0.0.1.nip.io ℹ️
|
||||
|
||||
The certificate is at "./127.0.0.1.nip.io+1.pem" and the key at "./127.0.0.1.nip.io+1-key.pem" ✅
|
||||
|
||||
It will expire on 23 March 2027 🗓
|
||||
|
||||
1. Create registry container unless it already exists
|
||||
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 💾
|
||||
Set kubectl context to "kind-visio"
|
||||
You can now use your cluster with:
|
||||
|
||||
kubectl cluster-info --context kind-visio
|
||||
|
||||
Thanks for using kind! 😊
|
||||
3. Add the registry config to the nodes
|
||||
4. Connect the registry to the cluster network if not already connected
|
||||
5. Document the local registry
|
||||
configmap/local-registry-hosting created
|
||||
Warning: resource configmaps/coredns is missing the kubectl.kubernetes.io/last-applied-configuration annotation which is required by kubectl apply. kubectl apply should only be used on resources created declaratively by either kubectl create --save-config or kubectl apply. The missing annotation will be patched automatically.
|
||||
configmap/coredns configured
|
||||
deployment.apps/coredns restarted
|
||||
6. Install ingress-nginx
|
||||
namespace/ingress-nginx created
|
||||
serviceaccount/ingress-nginx created
|
||||
serviceaccount/ingress-nginx-admission created
|
||||
role.rbac.authorization.k8s.io/ingress-nginx created
|
||||
role.rbac.authorization.k8s.io/ingress-nginx-admission created
|
||||
clusterrole.rbac.authorization.k8s.io/ingress-nginx created
|
||||
clusterrole.rbac.authorization.k8s.io/ingress-nginx-admission created
|
||||
rolebinding.rbac.authorization.k8s.io/ingress-nginx created
|
||||
rolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created
|
||||
clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx created
|
||||
clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created
|
||||
configmap/ingress-nginx-controller created
|
||||
service/ingress-nginx-controller created
|
||||
service/ingress-nginx-controller-admission created
|
||||
deployment.apps/ingress-nginx-controller created
|
||||
job.batch/ingress-nginx-admission-create created
|
||||
job.batch/ingress-nginx-admission-patch created
|
||||
ingressclass.networking.k8s.io/nginx created
|
||||
validatingwebhookconfiguration.admissionregistration.k8s.io/ingress-nginx-admission created
|
||||
secret/mkcert created
|
||||
deployment.apps/ingress-nginx-controller patched
|
||||
7. Setup namespace
|
||||
namespace/meet created
|
||||
Context "kind-visio" modified.
|
||||
secret/mkcert created
|
||||
$ kind get clusters
|
||||
visio
|
||||
$ kubectl -n ingress-nginx get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
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.
|
||||
|
||||
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 of components
|
||||
|
||||
### What will you use to authenticate your users ?
|
||||
|
||||
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:
|
||||
|
||||
```
|
||||
$ 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.
|
||||
|
||||
```
|
||||
$ kubectl config set-context --current --namespace=meet
|
||||
$ helm install keycloak oci://registry-1.docker.io/bitnamicharts/keycloak -f examples/keycloak.values.yaml
|
||||
$ #wait until
|
||||
$ kubectl get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
keycloak-0 1/1 Running 0 6m48s
|
||||
keycloak-postgresql-0 1/1 Running 0 6m48s
|
||||
```
|
||||
|
||||
From here the important information you will need are :
|
||||
|
||||
```
|
||||
OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/certs
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/auth
|
||||
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/token
|
||||
OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/userinfo
|
||||
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/session/end
|
||||
OIDC_RP_CLIENT_ID: meet
|
||||
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_RP_SIGN_ALGO: RS256
|
||||
OIDC_RP_SCOPES: "openid email"
|
||||
```
|
||||
|
||||
You can find these values in **examples/keycloak.values.yaml**
|
||||
|
||||
### Find livekit server connexion values
|
||||
|
||||
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 :
|
||||
|
||||
```
|
||||
$ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/redis.values.yaml
|
||||
$ kubectl get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
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.
|
||||
|
||||
```
|
||||
$ helm repo add livekit https://helm.livekit.io
|
||||
$ helm repo update
|
||||
$ helm install livekit livekit/livekit-server -f examples/livekit.values.yaml
|
||||
$ kubectl get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
keycloak-0 1/1 Running 0 30m
|
||||
keycloak-postgresql-0 1/1 Running 0 30m
|
||||
livekit-livekit-server-5c5fb87f7f-ct6x5 1/1 Running 0 7s
|
||||
redis-master-0 1/1 Running 0 4m30s
|
||||
$ curl https://livekit.127.0.0.1.nip.io
|
||||
OK
|
||||
```
|
||||
|
||||
From here important information you will need are :
|
||||
|
||||
```
|
||||
LIVEKIT_API_SECRET: secret
|
||||
LIVEKIT_API_KEY: devkey
|
||||
LIVEKIT_API_URL: https://livekit.127.0.0.1.nip.io/
|
||||
REDIS_URL: redis://default:pass@redis-master:6379/1
|
||||
CELERY_BROKER_URL: redis://default:pass@redis-master:6379/1
|
||||
CELERY_RESULT_BACKEND: redis://default:pass@redis-master:6379/1
|
||||
```
|
||||
|
||||
### Find postgresql connexion values
|
||||
|
||||
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
|
||||
$ kubectl get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
keycloak-0 1/1 Running 0 45m
|
||||
keycloak-postgresql-0 1/1 Running 0 45m
|
||||
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 :
|
||||
|
||||
```
|
||||
DB_HOST: postgres-postgresql
|
||||
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 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 update
|
||||
$ helm install meet meet/meet -f examples/meet.values.yaml
|
||||
```
|
||||
|
||||
## Test your deployment
|
||||
|
||||
In order to test your deployment you have to log in to your instance. If you use exclusively our examples you can do:
|
||||
|
||||
```
|
||||
$ kubectl get ingress
|
||||
NAME CLASS HOSTS ADDRESS PORTS AGE
|
||||
keycloak <none> keycloak.127.0.0.1.nip.io localhost 80 58m
|
||||
livekit-livekit-server <none> livekit.127.0.0.1.nip.io localhost 80, 443 106m
|
||||
meet <none> meet.127.0.0.1.nip.io localhost 80, 443 52m
|
||||
meet-admin <none> meet.127.0.0.1.nip.io localhost 80, 443 52m
|
||||
```
|
||||
|
||||
You can use Visio 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_ANALYTICS | Analytics information | {} |
|
||||
| FRONTEND_SUPPORT | Crisp frontend support configuration | {} |
|
||||
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
|
||||
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
|
||||
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
|
||||
| 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 | true |
|
||||
| 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 | [] |
|
||||
| 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 |
|
||||
| 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 | |
|
||||
| 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 |
|
||||
@@ -1,28 +0,0 @@
|
||||
# Installation
|
||||
If you want to install La Suite Meet you've come to the right place.
|
||||
Here are a bunch of resources to help you install the project.
|
||||
|
||||
## Kubernetes
|
||||
La Suite Meet maintainers use only the Kubernetes deployment method in production, so advanced support is available exclusively for this setup. Please follow the instructions provided [here](/docs/installation/kubernetes.md).
|
||||
|
||||
## Docker Compose
|
||||
We understand that not everyone has a Kubernetes cluster available.
|
||||
We also provide [Docker images](https://hub.docker.com/u/lasuite?page=1&search=meet) that can be deployed using Compose.
|
||||
|
||||
> [!WARNING]
|
||||
> Under construction: A PR is in progress to support deploying La Suite Meet via Docker Compose.
|
||||
|
||||
## Other ways to install La Suite Meet
|
||||
Community members have contributed alternative ways to install La Suite Meet 🙏. While maintainers may not provide direct support, we help keep these instructions up to date, and you can reach out to contributors or the community for assistance.
|
||||
|
||||
Here is the list of other methods in alphabetical order:
|
||||
- Nix: [Packages](https://search.nixos.org/packages?channel=unstable&show=lasuite-meet&query=lasuite-meet), ⚠️ unstable
|
||||
- Yunohost: [Packages](https://github.com/YunoHost-Apps/meet_ynh), ⚠️ under construction (for small instances only)
|
||||
|
||||
> [!TIP]
|
||||
> Feel free to make a PR to add ones that are not listed above
|
||||
|
||||
## Cloud providers
|
||||
Currently, no cloud providers are listed for deploying La Suite Meet.
|
||||
> [!TIP]
|
||||
> Feel free to make a PR to add ones that are not listed above
|
||||
@@ -1,370 +0,0 @@
|
||||
# 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.
|
||||
|
||||
## Prerequisites for a kubernetes setup
|
||||
|
||||
- k8s cluster with an nginx-ingress controller
|
||||
- an OIDC provider (if you don't have one, we will provide an example)
|
||||
- a LiveKit server (if you don't have one, we will provide an example)
|
||||
- a PostgreSQL server (if you don't have one, we will provide an example)
|
||||
- a Memcached server (if you don't have one, we will provide an example)
|
||||
|
||||
### 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**.
|
||||
|
||||
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:
|
||||
|
||||
- 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
|
||||
0. Create ca
|
||||
The local CA is already installed in the system trust store! 👍
|
||||
The local CA is already installed in the Firefox and/or Chrome/Chromium trust store! 👍
|
||||
|
||||
|
||||
Created a new certificate valid for the following names 📜
|
||||
- "127.0.0.1.nip.io"
|
||||
- "*.127.0.0.1.nip.io"
|
||||
|
||||
Reminder: X.509 wildcards only go one level deep, so this won't match a.b.127.0.0.1.nip.io ℹ️
|
||||
|
||||
The certificate is at "./127.0.0.1.nip.io+1.pem" and the key at "./127.0.0.1.nip.io+1-key.pem" ✅
|
||||
|
||||
It will expire on 23 March 2027 🗓
|
||||
|
||||
1. Create registry container unless it already exists
|
||||
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 💾
|
||||
Set kubectl context to "kind-visio"
|
||||
You can now use your cluster with:
|
||||
|
||||
kubectl cluster-info --context kind-visio
|
||||
|
||||
Thanks for using kind! 😊
|
||||
3. Add the registry config to the nodes
|
||||
4. Connect the registry to the cluster network if not already connected
|
||||
5. Document the local registry
|
||||
configmap/local-registry-hosting created
|
||||
Warning: resource configmaps/coredns is missing the kubectl.kubernetes.io/last-applied-configuration annotation which is required by kubectl apply. kubectl apply should only be used on resources created declaratively by either kubectl create --save-config or kubectl apply. The missing annotation will be patched automatically.
|
||||
configmap/coredns configured
|
||||
deployment.apps/coredns restarted
|
||||
6. Install ingress-nginx
|
||||
namespace/ingress-nginx created
|
||||
serviceaccount/ingress-nginx created
|
||||
serviceaccount/ingress-nginx-admission created
|
||||
role.rbac.authorization.k8s.io/ingress-nginx created
|
||||
role.rbac.authorization.k8s.io/ingress-nginx-admission created
|
||||
clusterrole.rbac.authorization.k8s.io/ingress-nginx created
|
||||
clusterrole.rbac.authorization.k8s.io/ingress-nginx-admission created
|
||||
rolebinding.rbac.authorization.k8s.io/ingress-nginx created
|
||||
rolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created
|
||||
clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx created
|
||||
clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created
|
||||
configmap/ingress-nginx-controller created
|
||||
service/ingress-nginx-controller created
|
||||
service/ingress-nginx-controller-admission created
|
||||
deployment.apps/ingress-nginx-controller created
|
||||
job.batch/ingress-nginx-admission-create created
|
||||
job.batch/ingress-nginx-admission-patch created
|
||||
ingressclass.networking.k8s.io/nginx created
|
||||
validatingwebhookconfiguration.admissionregistration.k8s.io/ingress-nginx-admission created
|
||||
secret/mkcert created
|
||||
deployment.apps/ingress-nginx-controller patched
|
||||
7. Setup namespace
|
||||
namespace/meet created
|
||||
Context "kind-visio" modified.
|
||||
secret/mkcert created
|
||||
$ kind get clusters
|
||||
visio
|
||||
$ kubectl -n ingress-nginx get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
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.
|
||||
|
||||
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 of components
|
||||
|
||||
### 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).
|
||||
|
||||
If you haven't run the script **bin/start-kind.sh**, you'll need to manually create the namespace by running the following command:
|
||||
|
||||
```
|
||||
$ 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.
|
||||
|
||||
```
|
||||
$ kubectl config set-context --current --namespace=meet
|
||||
$ helm install keycloak oci://registry-1.docker.io/bitnamicharts/keycloak -f examples/keycloak.values.yaml
|
||||
$ #wait until
|
||||
$ kubectl get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
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 :
|
||||
|
||||
```
|
||||
OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/certs
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/auth
|
||||
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/token
|
||||
OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/userinfo
|
||||
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/session/end
|
||||
OIDC_RP_CLIENT_ID: meet
|
||||
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_RP_SIGN_ALGO: RS256
|
||||
OIDC_RP_SCOPES: "openid email"
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
Livekit need a redis (and meet too) so we will start by deploying a redis :
|
||||
|
||||
```
|
||||
$ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/redis.values.yaml
|
||||
$ kubectl get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
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.
|
||||
|
||||
```
|
||||
$ helm repo add livekit https://helm.livekit.io
|
||||
$ helm repo update
|
||||
$ helm install livekit livekit/livekit-server -f examples/livekit.values.yaml
|
||||
$ kubectl get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
keycloak-0 1/1 Running 0 30m
|
||||
keycloak-postgresql-0 1/1 Running 0 30m
|
||||
livekit-livekit-server-5c5fb87f7f-ct6x5 1/1 Running 0 7s
|
||||
redis-master-0 1/1 Running 0 4m30s
|
||||
$ curl https://livekit.127.0.0.1.nip.io
|
||||
OK
|
||||
```
|
||||
|
||||
From here important information you will need are :
|
||||
|
||||
```
|
||||
LIVEKIT_API_SECRET: secret
|
||||
LIVEKIT_API_KEY: devkey
|
||||
LIVEKIT_API_URL: https://livekit.127.0.0.1.nip.io/
|
||||
REDIS_URL: redis://default:pass@redis-master:6379/1
|
||||
CELERY_BROKER_URL: redis://default:pass@redis-master:6379/1
|
||||
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:
|
||||
|
||||
```
|
||||
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml
|
||||
$ kubectl get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
keycloak-0 1/1 Running 0 45m
|
||||
keycloak-postgresql-0 1/1 Running 0 45m
|
||||
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 :
|
||||
|
||||
```
|
||||
DB_HOST: postgres-postgresql
|
||||
DB_NAME: meet
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
DB_PORT: 5432
|
||||
```
|
||||
|
||||
## 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.
|
||||
|
||||
```
|
||||
$ helm repo add meet https://suitenumerique.github.io/meet/
|
||||
$ helm repo update
|
||||
$ helm install meet meet/meet -f examples/meet.values.yaml
|
||||
```
|
||||
|
||||
## Test your deployment
|
||||
|
||||
In order to test your deployment you have to log in to your instance. If you use exclusively our examples you can do:
|
||||
|
||||
```
|
||||
$ kubectl get ingress
|
||||
NAME CLASS HOSTS ADDRESS PORTS AGE
|
||||
keycloak <none> keycloak.127.0.0.1.nip.io localhost 80 58m
|
||||
livekit-livekit-server <none> livekit.127.0.0.1.nip.io localhost 80, 443 106m
|
||||
meet <none> meet.127.0.0.1.nip.io localhost 80, 443 52m
|
||||
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 |
|
||||
@@ -1,475 +0,0 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Meet External API
|
||||
version: 1.0.0
|
||||
description: |
|
||||
External API for room management with application-delegated authentication.
|
||||
|
||||
#### Authentication Flow
|
||||
|
||||
1. Exchange application credentials for a JWT token via `/external-api/v1.0/applications/token`.
|
||||
2. Use the JWT token in the `Authorization: Bearer <token>` header for all subsequent requests.
|
||||
3. Tokens are scoped and allow applications to act on behalf of specific users.
|
||||
|
||||
#### Scopes
|
||||
|
||||
* `rooms:list` – List rooms accessible to the delegated user.
|
||||
* `rooms:retrieve` – Retrieve details of a specific room.
|
||||
* `rooms:create` – Create new rooms.
|
||||
* `rooms:update` – **Coming soon** Update existing rooms, e.g., add attendees to a room.
|
||||
* `rooms:delete` – **Coming soon** Delete rooms generated by the application.
|
||||
|
||||
#### Upcoming Features
|
||||
|
||||
* **Create rooms for unknown users from the web app:** Support for generating rooms for users who are not yet registered in the system.
|
||||
* **Add attendees to a room:** You will be able to update a room to include a list of attendees, allowing them to bypass the lobby system automatically.
|
||||
* **Delete application-generated rooms:** Rooms created via the application can be deleted when no longer needed.
|
||||
|
||||
contact:
|
||||
name: API Support
|
||||
email: antoine.lebaud@mail.numerique.gouv.fr
|
||||
|
||||
servers:
|
||||
- url: https://visio-sandbox.beta.numerique.gouv.fr/external-api/v1.0
|
||||
description: Sandbox server
|
||||
|
||||
tags:
|
||||
- name: Authentication
|
||||
description: Application authentication and token generation
|
||||
- name: Rooms
|
||||
description: Room management operations
|
||||
|
||||
paths:
|
||||
/applications/token:
|
||||
post:
|
||||
tags:
|
||||
- Authentication
|
||||
summary: Generate JWT token
|
||||
description: |
|
||||
Exchange application credentials for a scoped JWT token that allows the application
|
||||
to act on behalf of a specific user.
|
||||
|
||||
The application must be authorized for the user's email domain.
|
||||
The returned token expires after a configured duration and must be refreshed by calling this endpoint again.
|
||||
operationId: generateToken
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TokenRequest'
|
||||
examples:
|
||||
tokenRequest:
|
||||
summary: Request token for user delegation
|
||||
value:
|
||||
client_id: "550e8400-e29b-41d4-a716-446655440000"
|
||||
client_secret: "1234567890abcdefghijklmnopqrstuvwxyz"
|
||||
grant_type: "client_credentials"
|
||||
scope: "user@example.com"
|
||||
responses:
|
||||
'200':
|
||||
description: Token generated successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/TokenResponse'
|
||||
examples:
|
||||
tokenResponse:
|
||||
summary: Successful token generation
|
||||
value:
|
||||
access_token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
token_type: "Bearer"
|
||||
expires_in: 3600
|
||||
scope: "rooms:list rooms:retrieve rooms:create"
|
||||
'401':
|
||||
description: Authentication failed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OAuthError'
|
||||
examples:
|
||||
invalidCredentials:
|
||||
summary: Invalid credentials
|
||||
value:
|
||||
error: "Invalid credentials"
|
||||
inactiveApplication:
|
||||
summary: Application is inactive
|
||||
value:
|
||||
error: "Application is inactive"
|
||||
'400':
|
||||
description: Invalid request
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OAuthError'
|
||||
examples:
|
||||
userNotFound:
|
||||
summary: User not found
|
||||
value:
|
||||
error: "User not found."
|
||||
'403':
|
||||
description: Access denied - cannot delegate user
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/OAuthError'
|
||||
examples:
|
||||
delegationDenied:
|
||||
summary: Domain not authorized
|
||||
value:
|
||||
error: "This application is not authorized for this email domain."
|
||||
|
||||
/rooms:
|
||||
get:
|
||||
tags:
|
||||
- Rooms
|
||||
summary: List rooms
|
||||
description: |
|
||||
Returns a list of rooms accessible to the authenticated user.
|
||||
Only rooms where the delegated user has access will be returned.
|
||||
operationId: listRooms
|
||||
security:
|
||||
- BearerAuth: [rooms:list]
|
||||
parameters:
|
||||
- name: page
|
||||
in: query
|
||||
description: Page number for pagination
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
default: 1
|
||||
- name: page_size
|
||||
in: query
|
||||
description: Number of items per page
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 100
|
||||
default: 20
|
||||
responses:
|
||||
'200':
|
||||
description: List of accessible rooms
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
count:
|
||||
type: integer
|
||||
description: Total number of rooms
|
||||
next:
|
||||
type: string
|
||||
nullable: true
|
||||
description: URL to next page
|
||||
previous:
|
||||
type: string
|
||||
nullable: true
|
||||
description: URL to previous page
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Room'
|
||||
examples:
|
||||
roomList:
|
||||
summary: Paginated room list
|
||||
value:
|
||||
count: 2
|
||||
next: "https://visio-sandbox.beta.numerique.gouv.fr/external-api/v1.0/rooms?page=2"
|
||||
previous: null
|
||||
results:
|
||||
- id: "7c9e6679-7425-40de-944b-e07fc1f90ae7"
|
||||
slug: "aae-erez-aaz"
|
||||
access_level: "trusted"
|
||||
url: "https://visio-sandbox.beta.numerique.gouv.fr/aae-erez-aaz"
|
||||
telephony:
|
||||
enabled: true
|
||||
pin_code: "123456"
|
||||
phone_number: "+1-555-0100"
|
||||
default_country: "US"
|
||||
'401':
|
||||
$ref: '#/components/responses/UnauthorizedError'
|
||||
'403':
|
||||
$ref: '#/components/responses/ForbiddenError'
|
||||
|
||||
post:
|
||||
tags:
|
||||
- Rooms
|
||||
summary: Create a room
|
||||
description: |
|
||||
Creates a new room with secure defaults for external API usage.
|
||||
|
||||
**Restrictions:**
|
||||
- Rooms are always created with `trusted` access (no public rooms via API)
|
||||
- Room access_level can be updated from the webapp interface.
|
||||
|
||||
**Defaults:**
|
||||
- Delegated user is set as owner
|
||||
- Room slug auto-generated for uniqueness
|
||||
- Telephony PIN auto-generated when enabled
|
||||
- Creation tracked with application client_id for auditing
|
||||
operationId: createRoom
|
||||
security:
|
||||
- BearerAuth: [rooms:create]
|
||||
requestBody:
|
||||
required: false
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RoomCreate'
|
||||
examples:
|
||||
emptyBody:
|
||||
summary: No parameters (default)
|
||||
value: {}
|
||||
responses:
|
||||
'201':
|
||||
description: Room created successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Room'
|
||||
'401':
|
||||
$ref: '#/components/responses/UnauthorizedError'
|
||||
'403':
|
||||
$ref: '#/components/responses/ForbiddenError'
|
||||
|
||||
/rooms/{id}:
|
||||
get:
|
||||
tags:
|
||||
- Rooms
|
||||
summary: Retrieve a room
|
||||
description: Get detailed information about a specific room by its ID
|
||||
operationId: retrieveRoom
|
||||
security:
|
||||
- BearerAuth: [rooms:retrieve]
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
description: Room UUID
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
responses:
|
||||
'200':
|
||||
description: Room details
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Room'
|
||||
examples:
|
||||
room:
|
||||
summary: Room details
|
||||
value:
|
||||
id: "7c9e6679-7425-40de-944b-e07fc1f90ae7"
|
||||
slug: "aae-erez-aaz"
|
||||
access_level: "trusted"
|
||||
url: "https://visio-sandbox.beta.numerique.gouv.fr/aae-erez-aaz"
|
||||
telephony:
|
||||
enabled: true
|
||||
pin_code: "123456"
|
||||
phone_number: "+1-555-0100"
|
||||
default_country: "US"
|
||||
'401':
|
||||
$ref: '#/components/responses/UnauthorizedError'
|
||||
'403':
|
||||
$ref: '#/components/responses/ForbiddenError'
|
||||
'404':
|
||||
$ref: '#/components/responses/RoomNotFoundError'
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
BearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
description: |
|
||||
JWT token obtained from the `/applications/token` endpoint.
|
||||
Include in requests as: `Authorization: Bearer <token>`
|
||||
|
||||
schemas:
|
||||
TokenRequest:
|
||||
type: object
|
||||
required:
|
||||
- client_id
|
||||
- client_secret
|
||||
- grant_type
|
||||
- scope
|
||||
properties:
|
||||
client_id:
|
||||
type: string
|
||||
description: Application client identifier
|
||||
example: "550e8400-e29b-41d4-a716-446655440000"
|
||||
client_secret:
|
||||
type: string
|
||||
format: password
|
||||
writeOnly: true
|
||||
description: Application secret key
|
||||
example: "1234567890abcdefghijklmnopqrstuvwxyz"
|
||||
grant_type:
|
||||
type: string
|
||||
enum:
|
||||
- client_credentials
|
||||
description: OAuth2 grant type (must be 'client_credentials')
|
||||
example: "client_credentials"
|
||||
scope:
|
||||
type: string
|
||||
format: email
|
||||
description: |
|
||||
Email address of the user to delegate.
|
||||
The application will act on behalf of this user.
|
||||
Note: This parameter is named 'scope' to align with OAuth2 conventions,
|
||||
but accepts an email address to identify the user. This design allows
|
||||
for future extensibility.
|
||||
example: "user@example.com"
|
||||
|
||||
TokenResponse:
|
||||
type: object
|
||||
properties:
|
||||
access_token:
|
||||
type: string
|
||||
description: JWT access token
|
||||
example: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJtZWV0LWFwaSIsImF1ZCI6Im1lZXQtY2xpZW50cyIsImlhdCI6MTcwOTQ5MTIwMCwiZXhwIjoxNzA5NDk0ODAwLCJjbGllbnRfaWQiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAiLCJzY29wZSI6InJvb21zOmxpc3Qgcm9vbXM6cmV0cmlldmUgcm9vbXM6Y3JlYXRlIiwidXNlcl9pZCI6IjdiOGQ5YzQwLTNhMmItNGVkZi04NzFjLTJmM2Q0ZTVmNmE3YiIsImRlbGVnYXRlZCI6dHJ1ZX0.signature"
|
||||
token_type:
|
||||
type: string
|
||||
description: Token type (always 'Bearer')
|
||||
example: "Bearer"
|
||||
expires_in:
|
||||
type: integer
|
||||
description: Token lifetime in seconds
|
||||
example: 3600
|
||||
scope:
|
||||
type: string
|
||||
description: Space-separated list of granted permission scopes
|
||||
example: "rooms:list rooms:retrieve rooms:create"
|
||||
|
||||
RoomCreate:
|
||||
type: object
|
||||
description: Empty object - all room properties are auto-generated
|
||||
properties: {}
|
||||
|
||||
Room:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
format: uuid
|
||||
readOnly: true
|
||||
description: Unique room identifier
|
||||
example: "7c9e6679-7425-40de-944b-e07fc1f90ae7"
|
||||
slug:
|
||||
type: string
|
||||
readOnly: true
|
||||
description: URL-friendly room identifier (auto-generated)
|
||||
example: "aze-eere-zer"
|
||||
access_level:
|
||||
type: string
|
||||
readOnly: true
|
||||
description: Room access level (always 'trusted' for API-created rooms)
|
||||
example: "trusted"
|
||||
url:
|
||||
type: string
|
||||
format: uri
|
||||
readOnly: true
|
||||
description: Full URL to access the room
|
||||
example: "https://visio-sandbox.beta.numerique.gouv.fr/aze-eere-zer"
|
||||
telephony:
|
||||
type: object
|
||||
readOnly: true
|
||||
description: Telephony dial-in information (if enabled)
|
||||
properties:
|
||||
enabled:
|
||||
type: boolean
|
||||
description: Whether telephony is available
|
||||
example: true
|
||||
pin_code:
|
||||
type: string
|
||||
description: PIN code for dial-in access
|
||||
example: "123456"
|
||||
phone_number:
|
||||
type: string
|
||||
description: Phone number to dial
|
||||
example: "+1-555-0100"
|
||||
default_country:
|
||||
type: string
|
||||
description: Default country code
|
||||
example: "US"
|
||||
|
||||
OAuthError:
|
||||
type: object
|
||||
description: OAuth2-compliant error response
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
description: Human-readable error description
|
||||
example: "Invalid credentials"
|
||||
|
||||
Error:
|
||||
type: object
|
||||
properties:
|
||||
detail:
|
||||
type: string
|
||||
description: Error message
|
||||
example: "Invalid token."
|
||||
|
||||
ValidationError:
|
||||
type: object
|
||||
properties:
|
||||
field_name:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: List of validation errors for this field
|
||||
example: ["This field is required."]
|
||||
|
||||
responses:
|
||||
UnauthorizedError:
|
||||
description: Authentication required or token invalid/expired
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
examples:
|
||||
invalidToken:
|
||||
summary: Invalid or expired token
|
||||
value:
|
||||
error: "Invalid token."
|
||||
tokenExpired:
|
||||
summary: Token has expired
|
||||
value:
|
||||
error: "Token expired."
|
||||
|
||||
ForbiddenError:
|
||||
description: Insufficient permissions for this operation
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
examples:
|
||||
insufficientScope:
|
||||
summary: Missing required scope
|
||||
value:
|
||||
detail: "Insufficient permissions. Required scope: 'rooms:xxxx'"
|
||||
|
||||
RoomNotFoundError:
|
||||
description: Room not found
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/Error'
|
||||
examples:
|
||||
roomNotFound:
|
||||
summary: Room does not exist
|
||||
value:
|
||||
detail: "Not found."
|
||||
|
||||
BadRequestError:
|
||||
description: Invalid request data
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ValidationError'
|
||||
examples:
|
||||
validationError:
|
||||
summary: Field validation failed
|
||||
value:
|
||||
scope: ["Invalid email address."]
|
||||
@@ -0,0 +1,65 @@
|
||||
# Releasing a new version
|
||||
|
||||
Whenever we are cooking a new release (e.g. `4.18.1`) we should follow a standard procedure described below:
|
||||
|
||||
1. Create a new branch named: `release/4.18.1`.
|
||||
2. Bump the release number for backend project, frontend projects, and Helm files:
|
||||
|
||||
- for backend, update the version number by hand in `pyproject.toml`,
|
||||
- for each frontend projects (`src/frontend`, `src/mail`), run `npm version 4.18.1` in their directory. This will update both their `package.json` and `package-lock.json` for you,
|
||||
- for Helm, update Docker image tag in files located at `src/helm/env.d` for both `preprod` and `production` environments:
|
||||
|
||||
```yaml
|
||||
image:
|
||||
repository: lasuite/meet-backend
|
||||
pullPolicy: Always
|
||||
tag: "v4.18.1" # Replace with your new version number, without forgetting the "v" prefix
|
||||
|
||||
...
|
||||
|
||||
frontend:
|
||||
image:
|
||||
repository: lasuite/meet-frontend
|
||||
pullPolicy: Always
|
||||
tag: "v4.18.1" # Replace with your new version number, without forgetting the "v" prefix
|
||||
```
|
||||
|
||||
The new images don't exist _yet_: they will be created automatically later in the process.
|
||||
|
||||
3. ~~Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations~~ _we don't keep a changelog yet for now as the project is still in its infancy. Soon™!_
|
||||
4. Commit your changes with the following format: the 🔖 release emoji, the type of release (patch/minor/patch) and the release version:
|
||||
|
||||
```text
|
||||
🔖(minor) bump release to 4.18.0
|
||||
```
|
||||
|
||||
5. Open a pull request, wait for an approval from your peers and merge it.
|
||||
6. Checkout and pull changes from the `main` branch to ensure you have the latest updates.
|
||||
7. Tag and push your commit:
|
||||
|
||||
```bash
|
||||
git tag v4.18.1 && git push origin --tags
|
||||
```
|
||||
|
||||
Doing this triggers the CI and tells it to build the new Docker image versions that you targeted earlier in the Helm files.
|
||||
|
||||
8. Ensure the new [backend](https://hub.docker.com/r/lasuite/meet-frontend/tags) and [frontend](https://hub.docker.com/r/lasuite/meet-frontend/tags) image tags are on Docker Hub.
|
||||
9. The release is now done!
|
||||
|
||||
# Deploying
|
||||
|
||||
> [!TIP]
|
||||
> The `staging` platform is deployed automatically with every update of the `main` branch.
|
||||
|
||||
Making a new release doesn't publish it automatically in production.
|
||||
|
||||
Deployment is done by ArgoCD. ArgoCD checks for the `production` tag and automatically deploys the production platform with the targeted commit.
|
||||
|
||||
To publish, we mark the commit we want with the `production` tag. ArgoCD is then notified that the tag has changed. It then deploys the Docker image tags specified in the Helm files of the targeted commit.
|
||||
|
||||
To publish the release you just made:
|
||||
|
||||
```bash
|
||||
git tag --force production v4.18.1
|
||||
git push --force origin production
|
||||
```
|
||||
-118
@@ -1,118 +0,0 @@
|
||||
|
||||
# Theming La Suite Meet
|
||||
|
||||
There are two ways to customize LaSuite Meet:
|
||||
|
||||
- **Runtime Theming**. You can load a custom CSS file to apply any CSS you want. You can change all design-system tokens through CSS variables: colors, fonts, spacing multipliers, and more.
|
||||
- **Build-time Theming**. Some additional things, like the app name appearing in the browser tab, can be customized through environment variables that are applied at build-time.
|
||||
|
||||
|
||||
## Runtime Theming
|
||||
|
||||
### How to Use
|
||||
|
||||
To use this feature, simply set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. For example:
|
||||
|
||||
```javascript
|
||||
FRONTEND_CSS_URL=https://example.com/custom-style.css
|
||||
```
|
||||
|
||||
> [!TIP]
|
||||
> If you serve your CSS file on the same domain as LaSuite Meet, paths are supported, i.e. `FRONTEND_CSS_URL=/custom/style.css` will load `https://your-domain.com/custom/style.css`.
|
||||
|
||||
Setting this variable makes the app load your CSS at runtime, adding a `<link>` to `<head>` so you can override CSS variables and customize the frontend without rebuilding.
|
||||
|
||||
|
||||
This feature lets you customize the app’s look with any CSS, giving full flexibility and allowing changes to take effect instantly at runtime without touching the code.
|
||||
|
||||
### Example Use Case
|
||||
|
||||
Let's say you want to change the font of our application to a custom font. You can create a custom CSS file with the following contents:
|
||||
|
||||
```css
|
||||
@import url(https://fonts.bunny.net/css?family=Roboto:wght@400;700&display=swap);
|
||||
|
||||
:root {
|
||||
--fonts-sans: 'Roboto', ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
```
|
||||
|
||||
Then, set the `FRONTEND_CSS_URL` environment variable to the URL of your custom CSS file. Once you've done this, our application will load your custom CSS file and apply the styles, changing the default font to the one you specified.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> You can override any CSS token—semantic or palette. See [panda.config.ts](../src/frontend/panda.config.ts) for all defined semantic tokens.
|
||||
> The app does **not provide separate light/dark themes**: outside a meeting it defaults to light, and in a room it switches to dark.
|
||||
|
||||
|
||||
### Key Semantic Tokens
|
||||
|
||||
These control the main visual aspects of the interface:
|
||||
|
||||
| Category | Purpose | Example Token |
|
||||
| ---------- | ------------------------------- | --------------------------- |
|
||||
| Primary | Brand color, buttons, links | `--colors-primary` |
|
||||
| Dark Mode | Primary color in room/dark mode | `--colors-primary-dark-500` |
|
||||
| Greyscale | Text, backgrounds, borders | `--colors-greyscale-500` |
|
||||
| Success | Success states | `--colors-success` |
|
||||
| Error | Errors and destructive actions | `--colors-error` |
|
||||
| Warning | Warnings and alerts | `--colors-warning` |
|
||||
| Alert | Notification backgrounds | `--colors-alert` |
|
||||
| Font Sans | Main UI font | `--fonts-sans` |
|
||||
| Font Serif | Alternate/reading font | `--fonts-serif` |
|
||||
| Font Mono | Code or technical font | `--fonts-mono` |
|
||||
|
||||
|
||||
### Assets (Logo, Images)
|
||||
|
||||
You can override built-in assets (such as the logo or images) by mounting your own files into the container.
|
||||
Simply bind-mount your custom assets to the following path inside the container:
|
||||
|
||||
```
|
||||
/usr/share/nginx/html/assets
|
||||
```
|
||||
|
||||
Any files you mount here will **override the defaults at runtime**.
|
||||
|
||||
For example, to replace the images used in the landing page carousel, provide your own versions with the same filenames and paths:
|
||||
|
||||
```
|
||||
/usr/share/nginx/html/
|
||||
└── assets/intro-slider/
|
||||
├── 1.png
|
||||
├── 2.png
|
||||
├── 3.png
|
||||
└── 4.png
|
||||
```
|
||||
|
||||
|
||||
## Build-Time Theming
|
||||
|
||||
Some settings cannot be applied at runtime and require rebuilding the Docker image.
|
||||
One key example is the **application title and name**, controlled by the `VITE_APP_TITLE` build argument.
|
||||
|
||||
* **Default:** `La Suite Meet`
|
||||
* **Override:** supply your own value at build time
|
||||
|
||||
```bash
|
||||
docker build \
|
||||
--build-arg VITE_APP_TITLE="My Custom Meet" \
|
||||
-t my-org/meet:latest .
|
||||
```
|
||||
|
||||
```dockerfile
|
||||
# Dockerfile
|
||||
ARG VITE_APP_TITLE="La Suite Meet"
|
||||
ENV VITE_APP_TITLE=${VITE_APP_TITLE}
|
||||
```
|
||||
|
||||
For a real-world example, see how DINUM rebuilds the frontend to match their branding:
|
||||
[DINUM Dockerfile](../docker/dinum-frontend/Dockerfile)
|
||||
|
||||
----
|
||||
|
||||
# **Footer Configuration**
|
||||
|
||||
The footer cannot be customized yet. This is a work in progress, and we welcome contributions — feel free to open a pull request if you’d like to help add this feature.
|
||||
|
||||
You can enable the official French government footer by setting the environment variable `FRONTEND_USE_FRENCH_GOV_FOOTER` to true. This option is disabled (false) by default.
|
||||
|
||||
@@ -42,7 +42,7 @@ 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
|
||||
@@ -53,21 +53,7 @@ LIVEKIT_VERIFY_SSL=False
|
||||
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
|
||||
|
||||
# External Applications
|
||||
EXTERNAL_API_ENABLED=True
|
||||
APPLICATION_JWT_AUDIENCE=http://localhost:8071/external-api/v1.0/
|
||||
APPLICATION_JWT_SECRET_KEY=devKey
|
||||
APPLICATION_BASE_URL=http://localhost:3000
|
||||
|
||||
@@ -1,24 +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"
|
||||
WHISPERX_DEFAULT_LANGUAGE="fr"
|
||||
|
||||
LLM_BASE_URL="https://configure-your-url.com"
|
||||
LLM_API_KEY="dev-apikey"
|
||||
LLM_MODEL="albert-large"
|
||||
|
||||
WEBHOOK_API_TOKEN="secret"
|
||||
WEBHOOK_URL="https://configure-your-url.com"
|
||||
|
||||
POSTHOG_API_KEY="your-posthog-key"
|
||||
POSTHOG_ENABLED="False"
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
"enabled": false,
|
||||
"groupName": "ignored python dependencies",
|
||||
"matchManagers": ["pep621"],
|
||||
"matchPackageNames": ["redis"]
|
||||
"matchPackageNames": []
|
||||
},
|
||||
{
|
||||
"enabled": false,
|
||||
|
||||
@@ -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"]
|
||||
@@ -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),
|
||||
)
|
||||
)
|
||||
@@ -1,51 +0,0 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "0.1.40"
|
||||
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,6 +1,5 @@
|
||||
"""Admin classes and registrations for core app."""
|
||||
|
||||
from django import forms
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth import admin as auth_admin
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -151,66 +150,3 @@ class RecordingAdmin(admin.ModelAdmin):
|
||||
return _("Multiple owners")
|
||||
|
||||
return str(owners[0].user)
|
||||
|
||||
|
||||
class ApplicationDomainInline(admin.TabularInline):
|
||||
"""Inline admin for managing allowed domains per application."""
|
||||
|
||||
model = models.ApplicationDomain
|
||||
extra = 0
|
||||
|
||||
|
||||
class ApplicationAdminForm(forms.ModelForm):
|
||||
"""Custom form for Application admin with multi-select scopes."""
|
||||
|
||||
scopes = forms.MultipleChoiceField(
|
||||
choices=models.ApplicationScope.choices,
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
required=False,
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.instance.pk and self.instance.scopes:
|
||||
self.fields["scopes"].initial = self.instance.scopes
|
||||
|
||||
|
||||
@admin.register(models.Application)
|
||||
class ApplicationAdmin(admin.ModelAdmin):
|
||||
"""Admin interface for managing applications and their permissions."""
|
||||
|
||||
form = ApplicationAdminForm
|
||||
|
||||
list_display = ("id", "name", "client_id", "get_scopes_display")
|
||||
fields = [
|
||||
"name",
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"scopes",
|
||||
"client_id",
|
||||
"client_secret",
|
||||
]
|
||||
readonly_fields = ["id", "created_at", "updated_at"]
|
||||
inlines = [ApplicationDomainInline]
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
"""Make client_id and client_secret readonly after creation."""
|
||||
if obj: # Editing existing object
|
||||
return self.readonly_fields + ["client_id", "client_secret"]
|
||||
return self.readonly_fields
|
||||
|
||||
def get_fields(self, request, obj=None):
|
||||
"""Hide client_secret after creation."""
|
||||
fields = super().get_fields(request, obj)
|
||||
if obj:
|
||||
return [f for f in fields if f != "client_secret"]
|
||||
return fields
|
||||
|
||||
def get_scopes_display(self, obj):
|
||||
"""Display scopes in list view."""
|
||||
if obj.scopes:
|
||||
return ", ".join(obj.scopes)
|
||||
return _("No scopes")
|
||||
|
||||
get_scopes_display.short_description = _("Scopes")
|
||||
|
||||
@@ -41,21 +41,6 @@ def get_frontend_configuration(request):
|
||||
"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)
|
||||
|
||||
@@ -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
|
||||
@@ -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):
|
||||
@@ -96,13 +98,24 @@ class HasPrivilegesOnRoom(IsAuthenticated):
|
||||
|
||||
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
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
"""Client serializers for the Meet core app."""
|
||||
|
||||
# pylint: disable=abstract-method,no-name-in-module
|
||||
|
||||
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
|
||||
@@ -43,7 +40,7 @@ class ResourceAccessSerializerMixin:
|
||||
data.get("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 +58,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.")
|
||||
)
|
||||
@@ -108,8 +103,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", "access_level"]
|
||||
read_only_fields = ["id", "slug"]
|
||||
|
||||
def to_representation(self, instance):
|
||||
"""
|
||||
@@ -123,11 +118,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 is_admin:
|
||||
access_serializer = NestedResourceAccessSerializer(
|
||||
instance.accesses.select_related("resource", "user").all(),
|
||||
context=self.context,
|
||||
@@ -135,9 +128,7 @@ 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 = (
|
||||
@@ -153,14 +144,10 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
room_id = 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,
|
||||
room_id=room_id, user=request.user, username=username
|
||||
)
|
||||
|
||||
output["is_administrable"] = is_admin_or_owner
|
||||
output["is_administrable"] = is_admin
|
||||
|
||||
return output
|
||||
|
||||
@@ -186,19 +173,7 @@ class RecordingSerializer(serializers.ModelSerializer):
|
||||
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 +186,67 @@ 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):
|
||||
def update(self, instance, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("StartRecordingSerializer is validation-only")
|
||||
|
||||
|
||||
class RequestEntrySerializer(serializers.Serializer):
|
||||
"""Validate request entry data."""
|
||||
|
||||
username = serializers.CharField(required=True)
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("RequestEntrySerializer is validation-only")
|
||||
|
||||
class ParticipantEntrySerializer(BaseValidationOnlySerializer):
|
||||
def update(self, instance, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("RequestEntrySerializer is validation-only")
|
||||
|
||||
|
||||
class ParticipantEntrySerializer(serializers.Serializer):
|
||||
"""Validate participant entry decision data."""
|
||||
|
||||
participant_id = serializers.UUIDField(required=True)
|
||||
participant_id = serializers.CharField(required=True)
|
||||
allow_entry = serializers.BooleanField(required=True)
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("ParticipantEntrySerializer is validation-only")
|
||||
|
||||
class CreationCallbackSerializer(BaseValidationOnlySerializer):
|
||||
def update(self, instance, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("ParticipantEntrySerializer is validation-only")
|
||||
|
||||
|
||||
class CreationCallbackSerializer(serializers.Serializer):
|
||||
"""Validate room creation callback data."""
|
||||
|
||||
callback_id = serializers.CharField(required=True)
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("CreationCallbackSerializer is validation-only")
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("CreationCallbackSerializer is validation-only")
|
||||
|
||||
|
||||
class RoomInviteSerializer(serializers.Serializer):
|
||||
"""Validate room invite creation data."""
|
||||
|
||||
emails = serializers.ListField(child=serializers.EmailField(), allow_empty=False)
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("RoomInviteSerializer is validation-only")
|
||||
|
||||
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("RoomInviteSerializer is validation-only")
|
||||
|
||||
@@ -50,16 +50,9 @@ 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
|
||||
|
||||
@@ -294,9 +287,9 @@ class RoomViewSet(
|
||||
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 +332,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."""
|
||||
|
||||
@@ -424,8 +417,7 @@ class RoomViewSet(
|
||||
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"),
|
||||
**serializer.validated_data,
|
||||
)
|
||||
return drf_response.Response({"message": "Participant was updated."})
|
||||
|
||||
@@ -538,135 +530,6 @@ class RoomViewSet(
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
class ResourceAccessViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
@@ -733,8 +596,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."""
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -1 +0,0 @@
|
||||
"""Meet core external API endpoints"""
|
||||
@@ -1,109 +0,0 @@
|
||||
"""Authentication Backends for external application to the Meet core app."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
import jwt
|
||||
from rest_framework import authentication, exceptions
|
||||
|
||||
User = get_user_model()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ApplicationJWTAuthentication(authentication.BaseAuthentication):
|
||||
"""JWT authentication for application-delegated API access.
|
||||
|
||||
Validates JWT tokens issued to applications that are acting on behalf
|
||||
of users. Tokens must include user_id, client_id, and delegation flag.
|
||||
"""
|
||||
|
||||
def authenticate(self, request):
|
||||
"""Extract and validate JWT from Authorization header.
|
||||
|
||||
Returns:
|
||||
Tuple of (user, payload) if authentication successful, None otherwise
|
||||
"""
|
||||
auth_header = authentication.get_authorization_header(request).split()
|
||||
|
||||
if not auth_header or auth_header[0].lower() != b"bearer":
|
||||
return None
|
||||
|
||||
if len(auth_header) != 2:
|
||||
logger.warning("Invalid token header format")
|
||||
raise exceptions.AuthenticationFailed("Invalid token header.")
|
||||
|
||||
try:
|
||||
token = auth_header[1].decode("utf-8")
|
||||
except UnicodeError as e:
|
||||
logger.warning("Token decode error: %s", e)
|
||||
raise exceptions.AuthenticationFailed("Invalid token encoding.") from e
|
||||
|
||||
return self.authenticate_credentials(token)
|
||||
|
||||
def authenticate_credentials(self, token):
|
||||
"""Validate JWT token and return authenticated user.
|
||||
|
||||
Args:
|
||||
token: JWT token string
|
||||
|
||||
Returns:
|
||||
Tuple of (user, payload)
|
||||
|
||||
Raises:
|
||||
AuthenticationFailed: If token is invalid, expired, or user not found
|
||||
"""
|
||||
# Decode and validate JWT
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.APPLICATION_JWT_SECRET_KEY,
|
||||
algorithms=[settings.APPLICATION_JWT_ALG],
|
||||
issuer=settings.APPLICATION_JWT_ISSUER,
|
||||
audience=settings.APPLICATION_JWT_AUDIENCE,
|
||||
)
|
||||
except jwt.ExpiredSignatureError as e:
|
||||
logger.warning("Token expired")
|
||||
raise exceptions.AuthenticationFailed("Token expired.") from e
|
||||
except jwt.InvalidIssuerError as e:
|
||||
logger.warning("Invalid JWT issuer: %s", e)
|
||||
raise exceptions.AuthenticationFailed("Invalid token.") from e
|
||||
except jwt.InvalidAudienceError as e:
|
||||
logger.warning("Invalid JWT audience: %s", e)
|
||||
raise exceptions.AuthenticationFailed("Invalid token.") from e
|
||||
except jwt.InvalidTokenError as e:
|
||||
logger.warning("Invalid JWT token: %s", e)
|
||||
raise exceptions.AuthenticationFailed("Invalid token.") from e
|
||||
|
||||
user_id = payload.get("user_id")
|
||||
client_id = payload.get("client_id")
|
||||
is_delegated = payload.get("delegated", False)
|
||||
|
||||
if not user_id:
|
||||
logger.warning("Missing 'user_id' in JWT payload")
|
||||
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
||||
|
||||
if not client_id:
|
||||
logger.warning("Missing 'client_id' in JWT payload")
|
||||
raise exceptions.AuthenticationFailed("Invalid token claims.")
|
||||
|
||||
if not is_delegated:
|
||||
logger.warning("Token is not marked as delegated")
|
||||
raise exceptions.AuthenticationFailed("Invalid token type.")
|
||||
|
||||
try:
|
||||
user = User.objects.get(id=user_id)
|
||||
except User.DoesNotExist as e:
|
||||
logger.warning("User not found: %s", user_id)
|
||||
raise exceptions.AuthenticationFailed("User not found.") from e
|
||||
|
||||
if not user.is_active:
|
||||
logger.warning("Inactive user attempted authentication: %s", user_id)
|
||||
raise exceptions.AuthenticationFailed("User account is disabled.")
|
||||
|
||||
return (user, payload)
|
||||
|
||||
def authenticate_header(self, request):
|
||||
"""Return authentication scheme for WWW-Authenticate header."""
|
||||
return "Bearer"
|
||||
@@ -1,76 +0,0 @@
|
||||
"""Permission handlers for application-delegated API access."""
|
||||
|
||||
import logging
|
||||
from typing import Dict
|
||||
|
||||
from rest_framework import exceptions, permissions
|
||||
|
||||
from .. import models
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseScopePermission(permissions.BasePermission):
|
||||
"""Base class for scope-based permission checking.
|
||||
|
||||
Subclasses must define `scope_map` attribute mapping actions to required scopes.
|
||||
"""
|
||||
|
||||
scope_map: Dict[str, str] = {}
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Check if the JWT token contains the required scope for this action.
|
||||
|
||||
Args:
|
||||
request: DRF request object with authenticated user
|
||||
view: ViewSet instance
|
||||
|
||||
Returns:
|
||||
bool: True if permission granted
|
||||
|
||||
Raises:
|
||||
PermissionDenied: If required scope is missing from token
|
||||
"""
|
||||
# Get the current action (e.g., 'list', 'create')
|
||||
action = getattr(view, "action", None)
|
||||
if not action:
|
||||
raise exceptions.PermissionDenied(
|
||||
"Insufficient permissions. Unknown action."
|
||||
)
|
||||
|
||||
required_scope = self.scope_map.get(action)
|
||||
if not required_scope:
|
||||
# Action not in scope_map, deny by default
|
||||
raise exceptions.PermissionDenied(
|
||||
f"Insufficient permissions. Required scope: {required_scope}"
|
||||
)
|
||||
|
||||
token_payload = request.auth
|
||||
token_scopes = token_payload.get("scope")
|
||||
|
||||
if not token_scopes:
|
||||
raise exceptions.PermissionDenied("Insufficient permissions.")
|
||||
|
||||
# Ensure scopes is a list (handle both list and space-separated string)
|
||||
if isinstance(token_scopes, str):
|
||||
token_scopes = token_scopes.split()
|
||||
|
||||
if required_scope not in token_scopes:
|
||||
raise exceptions.PermissionDenied(
|
||||
f"Insufficient permissions. Required scope: {required_scope}"
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class HasRequiredRoomScope(BaseScopePermission):
|
||||
"""Permission class for Room-related operations."""
|
||||
|
||||
scope_map = {
|
||||
"list": models.ApplicationScope.ROOMS_LIST,
|
||||
"retrieve": models.ApplicationScope.ROOMS_RETRIEVE,
|
||||
"create": models.ApplicationScope.ROOMS_CREATE,
|
||||
"update": models.ApplicationScope.ROOMS_UPDATE,
|
||||
"partial_update": models.ApplicationScope.ROOMS_UPDATE,
|
||||
"destroy": models.ApplicationScope.ROOMS_DELETE,
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
"""Serializers for the external API of the Meet core app."""
|
||||
|
||||
# pylint: disable=abstract-method
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from core import models, utils
|
||||
from core.api.serializers import BaseValidationOnlySerializer
|
||||
|
||||
OAUTH2_GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials"
|
||||
|
||||
|
||||
class ApplicationJwtSerializer(BaseValidationOnlySerializer):
|
||||
"""Validate OAuth2 JWT token request data."""
|
||||
|
||||
client_id = serializers.CharField(write_only=True)
|
||||
client_secret = serializers.CharField(write_only=True)
|
||||
grant_type = serializers.ChoiceField(choices=[OAUTH2_GRANT_TYPE_CLIENT_CREDENTIALS])
|
||||
scope = serializers.CharField(write_only=True)
|
||||
|
||||
|
||||
class RoomSerializer(serializers.ModelSerializer):
|
||||
"""External API serializer for room data exposed to applications.
|
||||
|
||||
Provides limited, safe room information for third-party integrations:
|
||||
- Secure defaults for room creation (trusted access level)
|
||||
- Computed fields (url, telephony) for external consumption
|
||||
- Filtered data appropriate for delegation scenarios
|
||||
- Tracks creation source for auditing
|
||||
|
||||
Intentionally exposes minimal information to external applications,
|
||||
following the principle of least privilege.
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
model = models.Room
|
||||
fields = ["id", "name", "slug", "pin_code", "access_level"]
|
||||
read_only_fields = ["id", "name", "slug", "pin_code", "access_level"]
|
||||
|
||||
def to_representation(self, instance):
|
||||
"""Enrich response with application-specific computed fields."""
|
||||
output = super().to_representation(instance)
|
||||
request = self.context.get("request")
|
||||
pin_code = output.pop("pin_code", None)
|
||||
|
||||
if not request:
|
||||
return output
|
||||
|
||||
# Add room URL for direct access
|
||||
if settings.APPLICATION_BASE_URL:
|
||||
output["url"] = f"{settings.APPLICATION_BASE_URL}/{instance.slug}"
|
||||
|
||||
# Add telephony information if enabled
|
||||
if settings.ROOM_TELEPHONY_ENABLED:
|
||||
output["telephony"] = {
|
||||
"enabled": True,
|
||||
"phone_number": settings.ROOM_TELEPHONY_PHONE_NUMBER,
|
||||
"pin_code": pin_code,
|
||||
"default_country": settings.ROOM_TELEPHONY_DEFAULT_COUNTRY,
|
||||
}
|
||||
|
||||
return output
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Create room with secure defaults for application delegation."""
|
||||
|
||||
# Set secure defaults
|
||||
validated_data["name"] = utils.generate_room_slug()
|
||||
validated_data["access_level"] = models.RoomAccessLevel.TRUSTED
|
||||
|
||||
return super().create(validated_data)
|
||||
@@ -1,194 +0,0 @@
|
||||
"""External API endpoints"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import check_password
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_email
|
||||
|
||||
import jwt
|
||||
from rest_framework import decorators, mixins, viewsets
|
||||
from rest_framework import (
|
||||
exceptions as drf_exceptions,
|
||||
)
|
||||
from rest_framework import (
|
||||
response as drf_response,
|
||||
)
|
||||
from rest_framework import (
|
||||
status as drf_status,
|
||||
)
|
||||
|
||||
from core import api, models
|
||||
|
||||
from . import authentication, permissions, serializers
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class ApplicationViewSet(viewsets.GenericViewSet):
|
||||
"""API endpoints for application authentication and token generation."""
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["post"],
|
||||
url_path="token",
|
||||
url_name="token",
|
||||
)
|
||||
def generate_jwt_access_token(self, request, *args, **kwargs):
|
||||
"""Generate JWT access token for application delegation.
|
||||
|
||||
Validates application credentials and generates a JWT token scoped
|
||||
to a specific user email, allowing the application to act on behalf
|
||||
of that user.
|
||||
|
||||
Note: The 'scope' parameter accepts an email address to identify the user
|
||||
being delegated. This design allows applications to obtain user-scoped tokens
|
||||
for delegation purposes. The scope field is intentionally generic and can be
|
||||
extended to support other values in the future.
|
||||
|
||||
Reference: https://stackoverflow.com/a/27711422
|
||||
"""
|
||||
serializer = serializers.ApplicationJwtSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
client_id = serializer.validated_data["client_id"]
|
||||
client_secret = serializer.validated_data["client_secret"]
|
||||
|
||||
try:
|
||||
application = models.Application.objects.get(client_id=client_id)
|
||||
except models.Application.DoesNotExist as e:
|
||||
raise drf_exceptions.AuthenticationFailed("Invalid credentials") from e
|
||||
|
||||
if not application.active:
|
||||
raise drf_exceptions.AuthenticationFailed("Application is inactive")
|
||||
|
||||
if not check_password(client_secret, application.client_secret):
|
||||
raise drf_exceptions.AuthenticationFailed("Invalid credentials")
|
||||
|
||||
email = serializer.validated_data["scope"]
|
||||
try:
|
||||
validate_email(email)
|
||||
except ValidationError:
|
||||
return drf_response.Response(
|
||||
{
|
||||
"error": "Scope should be a valid email address.",
|
||||
},
|
||||
status=drf_status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
if not application.can_delegate_email(email):
|
||||
logger.warning(
|
||||
"Application %s denied delegation for %s",
|
||||
application.client_id,
|
||||
email,
|
||||
)
|
||||
return drf_response.Response(
|
||||
{
|
||||
"error": "This application is not authorized for this email domain.",
|
||||
},
|
||||
status=drf_status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
try:
|
||||
user = models.User.objects.get(email=email)
|
||||
except models.User.DoesNotExist as e:
|
||||
raise drf_exceptions.NotFound(
|
||||
{
|
||||
"error": "User not found.",
|
||||
}
|
||||
) from e
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
scope = " ".join(application.scopes or [])
|
||||
|
||||
payload = {
|
||||
"iss": settings.APPLICATION_JWT_ISSUER,
|
||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS),
|
||||
"client_id": client_id,
|
||||
"scope": scope,
|
||||
"user_id": str(user.id),
|
||||
"delegated": True,
|
||||
}
|
||||
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
settings.APPLICATION_JWT_SECRET_KEY,
|
||||
algorithm=settings.APPLICATION_JWT_ALG,
|
||||
)
|
||||
|
||||
return drf_response.Response(
|
||||
{
|
||||
"access_token": token,
|
||||
"token_type": settings.APPLICATION_JWT_TOKEN_TYPE,
|
||||
"expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS,
|
||||
"scope": scope,
|
||||
},
|
||||
status=drf_status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
|
||||
class RoomViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""Application-delegated API for room management.
|
||||
|
||||
Provides JWT-authenticated access to room operations for external applications
|
||||
acting on behalf of users. All operations are scope-based and filtered to the
|
||||
authenticated user's accessible rooms.
|
||||
|
||||
Supported operations:
|
||||
- list: List rooms the user has access to (requires 'rooms:list' scope)
|
||||
- retrieve: Get room details (requires 'rooms:retrieve' scope)
|
||||
- create: Create a new room owned by the user (requires 'rooms:create' scope)
|
||||
"""
|
||||
|
||||
authentication_classes = [authentication.ApplicationJWTAuthentication]
|
||||
permission_classes = [
|
||||
api.permissions.IsAuthenticated & permissions.HasRequiredRoomScope
|
||||
]
|
||||
queryset = models.Room.objects.all()
|
||||
serializer_class = serializers.RoomSerializer
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""Limit listed rooms to the ones related to the authenticated user."""
|
||||
|
||||
user = self.request.user
|
||||
|
||||
if user.is_authenticated:
|
||||
queryset = (
|
||||
self.filter_queryset(self.get_queryset()).filter(users=user).distinct()
|
||||
)
|
||||
else:
|
||||
queryset = self.get_queryset().none()
|
||||
|
||||
page = self.paginate_queryset(queryset)
|
||||
if page is not None:
|
||||
serializer = self.get_serializer(page, many=True)
|
||||
return self.get_paginated_response(serializer.data)
|
||||
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return drf_response.Response(serializer.data)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Set the current user as owner of the newly created room."""
|
||||
room = serializer.save()
|
||||
models.ResourceAccess.objects.create(
|
||||
resource=room,
|
||||
user=self.request.user,
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
# Log for auditing
|
||||
logger.info(
|
||||
"Room created via application: room_id=%s, user_id=%s, client_id=%s",
|
||||
room.id,
|
||||
self.request.user.id,
|
||||
getattr(self.request.auth, "client_id", "unknown"),
|
||||
)
|
||||
@@ -1,3 +1,4 @@
|
||||
# ruff: noqa: S311
|
||||
"""
|
||||
Core application factories
|
||||
"""
|
||||
@@ -9,7 +10,7 @@ from django.utils.text import slugify
|
||||
import factory.fuzzy
|
||||
from faker import Faker
|
||||
|
||||
from core import models, utils
|
||||
from core import models
|
||||
|
||||
fake = Faker()
|
||||
|
||||
@@ -117,39 +118,3 @@ class TeamRecordingAccessFactory(factory.django.DjangoModelFactory):
|
||||
recording = factory.SubFactory(RecordingFactory)
|
||||
team = factory.Sequence(lambda n: f"team{n}")
|
||||
role = factory.fuzzy.FuzzyChoice(models.RoleChoices.values)
|
||||
|
||||
|
||||
class ApplicationFactory(factory.django.DjangoModelFactory):
|
||||
"""Create fake applications for testing."""
|
||||
|
||||
class Meta:
|
||||
model = models.Application
|
||||
|
||||
name = factory.Faker("company")
|
||||
active = True
|
||||
client_id = factory.LazyFunction(utils.generate_client_id)
|
||||
client_secret = factory.LazyFunction(utils.generate_client_secret)
|
||||
scopes = []
|
||||
|
||||
class Params:
|
||||
"""Factory traits for common application configurations."""
|
||||
|
||||
with_all_scopes = factory.Trait(
|
||||
scopes=[
|
||||
models.ApplicationScope.ROOMS_LIST,
|
||||
models.ApplicationScope.ROOMS_RETRIEVE,
|
||||
models.ApplicationScope.ROOMS_CREATE,
|
||||
models.ApplicationScope.ROOMS_UPDATE,
|
||||
models.ApplicationScope.ROOMS_DELETE,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class ApplicationDomainFactory(factory.django.DjangoModelFactory):
|
||||
"""Create fake application domains for testing."""
|
||||
|
||||
class Meta:
|
||||
model = models.ApplicationDomain
|
||||
|
||||
domain = factory.Faker("domain_name")
|
||||
application = factory.SubFactory(ApplicationFactory)
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
"""
|
||||
Core application fields
|
||||
"""
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
from django.contrib.auth.hashers import identify_hasher, make_password
|
||||
from django.db import models
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class SecretField(models.CharField):
|
||||
"""CharField that automatically hashes secrets before saving.
|
||||
|
||||
Use for API keys, client secrets, or tokens that should never be stored
|
||||
in plain text. Already-hashed values are preserved to prevent double-hashing.
|
||||
|
||||
Inspired by: https://github.com/django-oauth-toolkit/django-oauth-toolkit
|
||||
"""
|
||||
|
||||
def pre_save(self, model_instance, add):
|
||||
"""Hash the secret if not already hashed, otherwise preserve it."""
|
||||
|
||||
secret = getattr(model_instance, self.attname)
|
||||
|
||||
try:
|
||||
hasher = identify_hasher(secret)
|
||||
logger.debug(
|
||||
"%s: %s is already hashed with %s.",
|
||||
model_instance,
|
||||
self.attname,
|
||||
hasher,
|
||||
)
|
||||
except ValueError:
|
||||
logger.debug(
|
||||
"%s: %s is not hashed; hashing it now.", model_instance, self.attname
|
||||
)
|
||||
hashed_secret = make_password(secret)
|
||||
setattr(model_instance, self.attname, hashed_secret)
|
||||
return hashed_secret
|
||||
|
||||
return super().pre_save(model_instance, add)
|
||||
@@ -17,7 +17,7 @@ def generate_pin_for_rooms(apps, schema_editor):
|
||||
def generate_pin_code():
|
||||
while True:
|
||||
pin_code = str(secrets.randbelow(10 ** length)).zfill(length)
|
||||
if pin_code not in existing_pins:
|
||||
if not pin_code in existing_pins:
|
||||
return pin_code
|
||||
|
||||
for room in rooms_without_pin_code:
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
# Generated by Django 5.2.6 on 2025-10-02 20:55
|
||||
|
||||
import core.utils
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0014_room_pin_code'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Application',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('name', models.CharField(help_text='Descriptive name for this application.', max_length=255, verbose_name='Application name')),
|
||||
('active', models.BooleanField(default=True)),
|
||||
('client_id', models.CharField(default=core.utils.generate_client_id, max_length=100, unique=True)),
|
||||
('client_secret', core.fields.SecretField(blank=True, default=core.utils.generate_client_secret, help_text='Hashed on Save. Copy it now if this is a new secret.', max_length=255)),
|
||||
('scopes', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(choices=[('rooms:create', 'Create rooms'), ('rooms:list', 'List rooms'), ('rooms:retrieve', 'Retrieve room details'), ('rooms:update', 'Update rooms'), ('rooms:delete', 'Delete rooms')], max_length=50), blank=True, default=list, size=None)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Application',
|
||||
'verbose_name_plural': 'Applications',
|
||||
'db_table': 'meet_application',
|
||||
'ordering': ('-created_at',),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='ApplicationDomain',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('domain', models.CharField(help_text='Email domain this application can act on behalf of.', max_length=253, validators=[django.core.validators.DomainNameValidator(accept_idna=False, message='Enter a valid domain')], verbose_name='Domain')),
|
||||
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='allowed_domains', to='core.application')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Application domain',
|
||||
'verbose_name_plural': 'Application domains',
|
||||
'db_table': 'meet_application_domain',
|
||||
'ordering': ('domain',),
|
||||
'unique_together': {('application', 'domain')},
|
||||
},
|
||||
),
|
||||
]
|
||||
+7
-108
@@ -11,7 +11,6 @@ from typing import List, Optional
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import models as auth_models
|
||||
from django.contrib.auth.base_user import AbstractBaseUser
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core import mail, validators
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.db import models
|
||||
@@ -19,10 +18,8 @@ from django.utils import timezone
|
||||
from django.utils.text import capfirst, slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from lasuite.tools.email import get_domain_from_email
|
||||
from timezone_field import TimeZoneField
|
||||
|
||||
from . import fields, utils
|
||||
from .recording.enums import FileExtension
|
||||
|
||||
logger = getLogger(__name__)
|
||||
@@ -38,7 +35,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):
|
||||
@@ -291,13 +288,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."""
|
||||
@@ -720,101 +717,3 @@ class RecordingAccess(BaseAccess):
|
||||
Compute and return abilities for a given user on the recording access.
|
||||
"""
|
||||
return self._get_abilities(self.recording, user)
|
||||
|
||||
|
||||
class ApplicationScope(models.TextChoices):
|
||||
"""Available permission scopes for application operations."""
|
||||
|
||||
ROOMS_CREATE = "rooms:create", _("Create rooms")
|
||||
ROOMS_LIST = "rooms:list", _("List rooms")
|
||||
ROOMS_RETRIEVE = "rooms:retrieve", _("Retrieve room details")
|
||||
ROOMS_UPDATE = "rooms:update", _("Update rooms")
|
||||
ROOMS_DELETE = "rooms:delete", _("Delete rooms")
|
||||
|
||||
|
||||
class Application(BaseModel):
|
||||
"""External application for API authentication and authorization.
|
||||
|
||||
Represents a third-party integration or automated system that accesses
|
||||
the API using OAuth2-style client credentials (client_id/client_secret).
|
||||
Supports scoped permissions and optional domain restrictions for delegation.
|
||||
"""
|
||||
|
||||
name = models.CharField(
|
||||
max_length=255,
|
||||
verbose_name=_("Application name"),
|
||||
help_text=_("Descriptive name for this application."),
|
||||
)
|
||||
active = models.BooleanField(default=True)
|
||||
client_id = models.CharField(
|
||||
max_length=100, unique=True, default=utils.generate_client_id
|
||||
)
|
||||
client_secret = fields.SecretField(
|
||||
max_length=255,
|
||||
blank=True,
|
||||
default=utils.generate_client_secret,
|
||||
help_text=_("Hashed on Save. Copy it now if this is a new secret."),
|
||||
)
|
||||
scopes = ArrayField(
|
||||
models.CharField(max_length=50, choices=ApplicationScope.choices),
|
||||
default=list,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "meet_application"
|
||||
ordering = ("-created_at",)
|
||||
verbose_name = _("Application")
|
||||
verbose_name_plural = _("Applications")
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name!s}"
|
||||
|
||||
def can_delegate_email(self, email):
|
||||
"""Check if this application can delegate the given email."""
|
||||
|
||||
if not self.allowed_domains.exists():
|
||||
return True # No domain restrictions
|
||||
|
||||
domain = get_domain_from_email(email)
|
||||
return self.allowed_domains.filter(domain__iexact=domain).exists()
|
||||
|
||||
|
||||
class ApplicationDomain(BaseModel):
|
||||
"""Domain authorized for application delegation."""
|
||||
|
||||
domain = models.CharField(
|
||||
max_length=253, # Max domain length per RFC 1035
|
||||
validators=[
|
||||
validators.DomainNameValidator(
|
||||
accept_idna=False,
|
||||
message=_("Enter a valid domain"),
|
||||
)
|
||||
],
|
||||
verbose_name=_("Domain"),
|
||||
help_text=_("Email domain this application can act on behalf of."),
|
||||
)
|
||||
|
||||
application = models.ForeignKey(
|
||||
"Application",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="allowed_domains",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "meet_application_domain"
|
||||
ordering = ("domain",)
|
||||
verbose_name = _("Application domain")
|
||||
verbose_name_plural = _("Application domains")
|
||||
unique_together = [("application", "domain")]
|
||||
|
||||
def __str__(self):
|
||||
"""Return string representation of the domain."""
|
||||
|
||||
return self.domain
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Save the domain after normalizing to lowercase."""
|
||||
|
||||
self.domain = self.domain.lower().strip()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -131,18 +131,11 @@ class NotificationService:
|
||||
if not owner_access:
|
||||
logger.error("No owner found for recording %s", recording.id)
|
||||
return False
|
||||
|
||||
payload = {
|
||||
"owner_id": str(owner_access.user.id),
|
||||
"filename": recording.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 = {
|
||||
|
||||
@@ -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
|
||||
@@ -1,26 +1,13 @@
|
||||
"""LiveKit Events Service"""
|
||||
|
||||
# pylint: disable=no-member
|
||||
|
||||
import re
|
||||
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):
|
||||
@@ -90,19 +77,6 @@ class LiveKitEventsService:
|
||||
)
|
||||
self.webhook_receiver = api.WebhookReceiver(token_verifier)
|
||||
self.lobby_service = LobbyService()
|
||||
self.telephony_service = TelephonyService()
|
||||
self.recording_events = RecordingEventsService()
|
||||
|
||||
self._filter_regex = None
|
||||
if settings.LIVEKIT_WEBHOOK_EVENTS_FILTER_REGEX:
|
||||
try:
|
||||
self._filter_regex = re.compile(
|
||||
settings.LIVEKIT_WEBHOOK_EVENTS_FILTER_REGEX
|
||||
)
|
||||
except re.error:
|
||||
logger.exception(
|
||||
"Invalid LIVEKIT_WEBHOOK_EVENTS_FILTER_REGEX. Webhook filtering disabled."
|
||||
)
|
||||
|
||||
def receive(self, request):
|
||||
"""Process webhook and route to appropriate handler."""
|
||||
@@ -118,10 +92,6 @@ class LiveKitEventsService:
|
||||
except Exception as e:
|
||||
raise InvalidPayloadError("Invalid webhook payload") from e
|
||||
|
||||
if self._filter_regex and not self._filter_regex.search(data.room.name):
|
||||
logger.info("Filtered webhook event for room '%s'", data.room.name)
|
||||
return
|
||||
|
||||
try:
|
||||
webhook_type = LiveKitWebhookEventType(data.event)
|
||||
except ValueError as e:
|
||||
@@ -138,77 +108,10 @@ class LiveKitEventsService:
|
||||
# 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
|
||||
raise ActionFailedError("Failed to process room finished event") from e
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Lobby Service"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
@@ -10,6 +11,13 @@ from uuid import UUID
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit.api import ( # pylint: disable=E0611
|
||||
ListRoomsRequest,
|
||||
SendDataRequest,
|
||||
TwirpError,
|
||||
)
|
||||
|
||||
from core import models, utils
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -38,6 +46,10 @@ class LobbyParticipantNotFound(LobbyError):
|
||||
"""Raised when participant is not found."""
|
||||
|
||||
|
||||
class LobbyNotificationError(LobbyError):
|
||||
"""Raised when LiveKit notification fails."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class LobbyParticipant:
|
||||
"""Participant in a lobby system."""
|
||||
@@ -89,7 +101,7 @@ class LobbyService:
|
||||
@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()))
|
||||
return request.COOKIES.get(settings.LOBBY_COOKIE_NAME, uuid.uuid4().hex)
|
||||
|
||||
@staticmethod
|
||||
def prepare_response(response, participant_id):
|
||||
@@ -143,8 +155,6 @@ class LobbyService:
|
||||
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(
|
||||
@@ -157,13 +167,10 @@ class LobbyService:
|
||||
participant.status = LobbyParticipantStatus.ACCEPTED
|
||||
|
||||
livekit_config = utils.generate_livekit_config(
|
||||
room_id=room_id,
|
||||
room_id=str(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
|
||||
|
||||
@@ -178,13 +185,10 @@ class LobbyService:
|
||||
elif participant.status == LobbyParticipantStatus.ACCEPTED:
|
||||
# wrongly named, contains access token to join a room
|
||||
livekit_config = utils.generate_livekit_config(
|
||||
room_id=room_id,
|
||||
room_id=str(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
|
||||
@@ -207,6 +211,9 @@ class LobbyService:
|
||||
|
||||
Create a new participant entry in waiting status and notify room
|
||||
participants of the new entry request.
|
||||
|
||||
Raises:
|
||||
LobbyNotificationError: If room notification fails
|
||||
"""
|
||||
|
||||
color = utils.generate_color(participant_id)
|
||||
@@ -219,15 +226,10 @@ class LobbyService:
|
||||
)
|
||||
|
||||
try:
|
||||
utils.notify_participants(
|
||||
room_name=str(room_id),
|
||||
notification_data={
|
||||
"type": settings.LOBBY_NOTIFICATION_TYPE,
|
||||
},
|
||||
)
|
||||
except utils.NotificationError:
|
||||
self.notify_participants(room_id=room_id)
|
||||
except LobbyNotificationError:
|
||||
# If room not created yet, there is no participants to notify
|
||||
logger.exception("Failed to notify room participants")
|
||||
pass
|
||||
|
||||
cache_key = self._get_cache_key(room_id, participant_id)
|
||||
cache.set(
|
||||
@@ -332,6 +334,44 @@ class LobbyService:
|
||||
participant.status = status
|
||||
cache.set(cache_key, participant.to_dict(), timeout=timeout)
|
||||
|
||||
@async_to_sync
|
||||
async def notify_participants(self, room_id: UUID):
|
||||
"""Notify room participants about a new waiting participant using LiveKit.
|
||||
|
||||
Raises:
|
||||
LobbyNotificationError: If notification fails to send
|
||||
"""
|
||||
|
||||
notification_data = {
|
||||
"type": settings.LOBBY_NOTIFICATION_TYPE,
|
||||
}
|
||||
|
||||
lkapi = utils.create_livekit_client()
|
||||
|
||||
try:
|
||||
room_response = await lkapi.room.list_rooms(
|
||||
ListRoomsRequest(
|
||||
names=[str(room_id)],
|
||||
)
|
||||
)
|
||||
|
||||
# Check if the room exists
|
||||
if not room_response.rooms:
|
||||
return
|
||||
|
||||
await lkapi.room.send_data(
|
||||
SendDataRequest(
|
||||
room=str(room_id),
|
||||
data=json.dumps(notification_data).encode("utf-8"),
|
||||
kind="RELIABLE",
|
||||
)
|
||||
)
|
||||
except TwirpError as e:
|
||||
logger.exception("Failed to notify room participants")
|
||||
raise LobbyNotificationError("Failed to notify room participants") from e
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
def clear_room_cache(self, room_id: UUID) -> None:
|
||||
"""Clear all participant entries from the cache for a specific room."""
|
||||
|
||||
@@ -342,9 +382,3 @@ class LobbyService:
|
||||
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)
|
||||
|
||||
@@ -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,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")
|
||||
@@ -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()
|
||||
@@ -287,7 +287,9 @@ def test_finds_user_whitespace_email(django_assert_num_queries, settings):
|
||||
[
|
||||
"john.doe@example.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):
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Test event authentication.
|
||||
"""
|
||||
|
||||
# pylint: disable=assignment-from-no-return
|
||||
# pylint: disable=E1128
|
||||
|
||||
from django.test import RequestFactory
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Test event notification.
|
||||
"""
|
||||
|
||||
# pylint: disable=assignment-from-no-return,redefined-outer-name,unused-argument,protected-access
|
||||
# pylint: disable=E1128,W0621,W0613,W0212
|
||||
|
||||
import datetime
|
||||
import smtplib
|
||||
|
||||
@@ -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}
|
||||
)
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Test WorkerServiceMediator class."""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
# pylint: disable=W0621,W0613
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
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
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Test rooms API endpoints in the Meet core app: create.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
# pylint: disable=W0621,W0613
|
||||
from django.core.cache import cache
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Test rooms API endpoints in the Meet core app: creation callback functionality.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
# pylint: disable=W0621,W0613
|
||||
from django.core.cache import cache
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Test rooms API endpoints in the Meet core app: invite.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
# pylint: disable=W0621,W0613
|
||||
|
||||
import json
|
||||
import random
|
||||
@@ -230,14 +230,15 @@ def test_api_rooms_invite_error(mock_invite_to_room):
|
||||
|
||||
data = {"emails": ["toto@yopmail.com", "toto@yopmail.com"]}
|
||||
|
||||
with pytest.raises(InvitationError):
|
||||
with pytest.raises(InvitationError) as excinfo:
|
||||
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_invite_to_room.assert_called_once()
|
||||
assert "Could not send invitation" in str(excinfo.value)
|
||||
|
||||
|
||||
@mock.patch("core.services.invitation.send_mail")
|
||||
|
||||
@@ -37,7 +37,7 @@ def test_request_entry_anonymous(settings):
|
||||
assert not lobby_keys
|
||||
|
||||
with (
|
||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||
mock.patch.object(LobbyService, "notify_participants", return_value=None),
|
||||
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||
):
|
||||
response = client.post(
|
||||
@@ -86,7 +86,7 @@ def test_request_entry_authenticated_user(settings):
|
||||
assert not lobby_keys
|
||||
|
||||
with (
|
||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||
mock.patch.object(LobbyService, "notify_participants", return_value=None),
|
||||
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||
):
|
||||
response = client.post(
|
||||
@@ -132,18 +132,18 @@ def test_request_entry_with_existing_participants(settings):
|
||||
|
||||
# Add two participants already waiting in the lobby
|
||||
cache.set(
|
||||
f"mocked-cache-prefix_{room.id}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
f"mocked-cache-prefix_{room.id}_participant1",
|
||||
{
|
||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
"id": "participant1",
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
},
|
||||
)
|
||||
cache.set(
|
||||
f"mocked-cache-prefix_{room.id}_f4ca3ab8a6c04ad88097b8da33f60f10",
|
||||
f"mocked-cache-prefix_{room.id}_participant2",
|
||||
{
|
||||
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
|
||||
"id": "participant2",
|
||||
"username": "user2",
|
||||
"status": "accepted",
|
||||
"color": "#654321",
|
||||
@@ -156,7 +156,7 @@ def test_request_entry_with_existing_participants(settings):
|
||||
|
||||
# Mock external service calls to isolate the test
|
||||
with (
|
||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||
mock.patch.object(LobbyService, "notify_participants", return_value=None),
|
||||
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||
):
|
||||
# Make request as a new anonymous user
|
||||
@@ -205,7 +205,7 @@ def test_request_entry_public_room(settings):
|
||||
assert not lobby_keys
|
||||
|
||||
with (
|
||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||
mock.patch.object(LobbyService, "notify_participants", return_value=None),
|
||||
mock.patch.object(
|
||||
LobbyService, "_get_or_create_participant_id", return_value="123"
|
||||
),
|
||||
@@ -255,11 +255,9 @@ def test_request_entry_authenticated_user_public_room(settings):
|
||||
assert not lobby_keys
|
||||
|
||||
with (
|
||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||
mock.patch.object(LobbyService, "notify_participants", return_value=None),
|
||||
mock.patch.object(
|
||||
LobbyService,
|
||||
"_get_or_create_participant_id",
|
||||
return_value="2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
LobbyService, "_get_or_create_participant_id", return_value="123"
|
||||
),
|
||||
mock.patch.object(
|
||||
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
||||
@@ -276,11 +274,11 @@ def test_request_entry_authenticated_user_public_room(settings):
|
||||
# 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"
|
||||
assert cookie.value == "123"
|
||||
|
||||
# Verify response content matches expected structure and values
|
||||
assert response.json() == {
|
||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
"id": "123",
|
||||
"username": "test_user",
|
||||
"status": "accepted",
|
||||
"color": "mocked-color",
|
||||
@@ -302,9 +300,9 @@ def test_request_entry_waiting_participant_public_room(settings):
|
||||
|
||||
# Add a waiting participant to the room's lobby cache
|
||||
cache.set(
|
||||
f"mocked-cache-prefix_{room.id}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
f"mocked-cache-prefix_{room.id}_participant1",
|
||||
{
|
||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
"id": "participant1",
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
@@ -312,10 +310,10 @@ def test_request_entry_waiting_participant_public_room(settings):
|
||||
)
|
||||
|
||||
# Simulate a browser with existing participant cookie
|
||||
client.cookies.load({"mocked-cookie": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"})
|
||||
client.cookies.load({"mocked-cookie": "participant1"})
|
||||
|
||||
with (
|
||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||
mock.patch.object(LobbyService, "notify_participants", return_value=None),
|
||||
mock.patch.object(
|
||||
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
||||
),
|
||||
@@ -330,11 +328,11 @@ def test_request_entry_waiting_participant_public_room(settings):
|
||||
# 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"
|
||||
assert cookie.value == "participant1"
|
||||
|
||||
# Verify response content matches expected structure and values
|
||||
assert response.json() == {
|
||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
"id": "participant1",
|
||||
"username": "user1",
|
||||
"status": "accepted",
|
||||
"color": "#123456",
|
||||
@@ -381,7 +379,7 @@ def test_allow_participant_to_enter_anonymous():
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/enter/",
|
||||
{"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True},
|
||||
{"participant_id": "test-id", "allow_entry": True},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
@@ -396,7 +394,7 @@ def test_allow_participant_to_enter_non_owner():
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/enter/",
|
||||
{"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True},
|
||||
{"participant_id": "test-id", "allow_entry": True},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
@@ -414,7 +412,7 @@ def test_allow_participant_to_enter_public_room():
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/enter/",
|
||||
{"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True},
|
||||
{"participant_id": "test-id", "allow_entry": True},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
@@ -437,9 +435,9 @@ def test_allow_participant_to_enter_success(settings, allow_entry, updated_statu
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
cache.set(
|
||||
f"mocked-cache-prefix_{room.id!s}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
f"mocked-cache-prefix_{room.id!s}_participant1",
|
||||
{
|
||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
"id": "test-id",
|
||||
"status": "waiting",
|
||||
"username": "foo",
|
||||
"color": "123",
|
||||
@@ -448,18 +446,13 @@ def test_allow_participant_to_enter_success(settings, allow_entry, updated_statu
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/enter/",
|
||||
{
|
||||
"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
"allow_entry": allow_entry,
|
||||
},
|
||||
{"participant_id": "participant1", "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"
|
||||
)
|
||||
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_participant1")
|
||||
assert participant_data.get("status") == updated_status
|
||||
|
||||
|
||||
@@ -475,14 +468,12 @@ def test_allow_participant_to_enter_participant_not_found(settings):
|
||||
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
participant_data = cache.get(
|
||||
f"mocked-cache-prefix_{room.id!s}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
|
||||
)
|
||||
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_test-id")
|
||||
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},
|
||||
{"participant_id": "test-id", "allow_entry": True},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
@@ -572,18 +563,18 @@ def test_list_waiting_participants_success(settings):
|
||||
|
||||
# Add participants in the lobby
|
||||
cache.set(
|
||||
f"mocked-cache-prefix_{room.id}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
f"mocked-cache-prefix_{room.id}_participant1",
|
||||
{
|
||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
"id": "participant1",
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
},
|
||||
)
|
||||
cache.set(
|
||||
f"mocked-cache-prefix_{room.id}_f4ca3ab8a6c04ad88097b8da33f60f10",
|
||||
f"mocked-cache-prefix_{room.id}_participant2",
|
||||
{
|
||||
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
|
||||
"id": "participant2",
|
||||
"username": "user2",
|
||||
"status": "waiting",
|
||||
"color": "#654321",
|
||||
@@ -597,13 +588,13 @@ def test_list_waiting_participants_success(settings):
|
||||
participants = response.json().get("participants")
|
||||
assert sorted(participants, key=lambda p: p["id"]) == [
|
||||
{
|
||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
"id": "participant1",
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
},
|
||||
{
|
||||
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
|
||||
"id": "participant2",
|
||||
"username": "user2",
|
||||
"status": "waiting",
|
||||
"color": "#654321",
|
||||
|
||||
@@ -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()
|
||||
@@ -32,7 +32,6 @@ def test_api_rooms_retrieve_anonymous_private_pk():
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
@@ -52,7 +51,6 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
@@ -71,7 +69,6 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
@@ -88,7 +85,6 @@ def test_api_rooms_retrieve_anonymous_private_slug():
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
@@ -105,7 +101,6 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
@@ -214,7 +209,6 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
|
||||
"token": "foo",
|
||||
},
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
@@ -235,10 +229,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(access_level=RoomAccessLevel.PUBLIC)
|
||||
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
@@ -260,18 +251,11 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
"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,
|
||||
room=expected_name, user=user, username=None, color=None
|
||||
)
|
||||
|
||||
|
||||
@@ -311,18 +295,11 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
||||
"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,
|
||||
room=expected_name, user=user, username=None, color=None
|
||||
)
|
||||
|
||||
|
||||
@@ -347,7 +324,6 @@ def test_api_rooms_retrieve_authenticated():
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
@@ -368,9 +344,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
user = UserFactory()
|
||||
other_user = UserFactory()
|
||||
|
||||
room = RoomFactory(
|
||||
configuration={"can_publish_sources": ["mock-source"]},
|
||||
)
|
||||
room = RoomFactory()
|
||||
UserResourceAccessFactory(resource=room, user=user, role="member")
|
||||
UserResourceAccessFactory(resource=room, user=other_user, role="member")
|
||||
|
||||
@@ -398,18 +372,11 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
"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,
|
||||
room=expected_name, user=user, username=None, color=None
|
||||
)
|
||||
|
||||
|
||||
@@ -491,16 +458,9 @@ 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,
|
||||
room=expected_name, user=user, username=None, color=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"
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
Test LiveKit webhook endpoint on the rooms API.
|
||||
"""
|
||||
|
||||
# pylint: disable=too-many-arguments,redefined-outer-name,too-many-positional-arguments,unused-argument
|
||||
# ruff: noqa: PLR0913
|
||||
# pylint: disable=R0913,W0621,R0917,W0613
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
@@ -24,7 +25,7 @@ def webhook_event_data():
|
||||
"name": "00000000-0000-0000-0000-000000000000",
|
||||
"emptyTimeout": 300,
|
||||
"creationTime": "1692627281",
|
||||
"turnPassword": "fake-turn-password",
|
||||
"turnPassword": "2Pvdj+/WV1xV4EkB8klJ9xkXDWY=",
|
||||
"enabledCodecs": [
|
||||
{"mime": "audio/opus"},
|
||||
{"mime": "video/H264"},
|
||||
@@ -143,7 +144,7 @@ def test_handled_event_type(
|
||||
|
||||
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"})
|
||||
event_data = json.dumps({"event": "room_started"})
|
||||
|
||||
hash64 = base64.b64encode(hashlib.sha256(event_data.encode()).digest()).decode()
|
||||
token = api.AccessToken(
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
"""
|
||||
Test LiveKitEvents service.
|
||||
"""
|
||||
# pylint: disable=W0621,W0613, W0212, E0611
|
||||
# pylint: disable=W0621,W0613, W0212
|
||||
|
||||
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,
|
||||
@@ -20,8 +17,6 @@ from core.services.livekit_events import (
|
||||
api,
|
||||
)
|
||||
from core.services.lobby import LobbyService
|
||||
from core.services.telephony import TelephonyException, TelephonyService
|
||||
from core.utils import NotificationError
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -58,251 +53,43 @@ def test_initialization(
|
||||
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
|
||||
def test_handle_room_finished(mock_clear_cache, service):
|
||||
"""Should clear lobby cache when room is finished."""
|
||||
|
||||
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
|
||||
def test_handle_room_finished_error(mock_clear_cache, service):
|
||||
"""Should raise ActionFailedError when processing fails."""
|
||||
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."""
|
||||
def test_handle_room_finished_invalid_room_name(service):
|
||||
"""Should raise ActionFailedError when processing fails."""
|
||||
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)
|
||||
with pytest.raises(
|
||||
ActionFailedError, match="Failed to process room finished event"
|
||||
):
|
||||
service._handle_room_finished(mock_data)
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
@@ -343,99 +130,3 @@ def test_receive_unsupported_event(mock_receive, service):
|
||||
UnsupportedEventTypeError, match="Unknown webhook type: unsupported_event"
|
||||
):
|
||||
service.receive(mock_request)
|
||||
|
||||
|
||||
@mock.patch.object(api.WebhookReceiver, "receive")
|
||||
@mock.patch.object(LiveKitEventsService, "_handle_room_started")
|
||||
def test_receive_no_filter_processes_all_events(
|
||||
mock_handle_room_started, mock_receive, mock_livekit_config, settings
|
||||
):
|
||||
"""Should process all events when filter regex is not configured."""
|
||||
settings.LIVEKIT_WEBHOOK_EVENTS_FILTER_REGEX = None
|
||||
|
||||
mock_request = mock.MagicMock()
|
||||
mock_request.headers = {"Authorization": "test_token"}
|
||||
mock_request.body = b"{}"
|
||||
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = "!JIfCxVLcKKkWrmVBOb:your-domain.com"
|
||||
mock_data.event = "room_started"
|
||||
mock_receive.return_value = mock_data
|
||||
|
||||
service = LiveKitEventsService()
|
||||
service.receive(mock_request)
|
||||
|
||||
mock_handle_room_started.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch.object(api.WebhookReceiver, "receive")
|
||||
@mock.patch.object(LiveKitEventsService, "_handle_room_started")
|
||||
def test_receive_invalid_filter_regex_processes_all_events(
|
||||
mock_handle_room_started, mock_receive, mock_livekit_config, settings
|
||||
):
|
||||
"""Should process all events when filter regex is invalid (fail-safe)."""
|
||||
settings.LIVEKIT_WEBHOOK_EVENTS_FILTER_REGEX = "(abc"
|
||||
|
||||
mock_request = mock.MagicMock()
|
||||
mock_request.headers = {"Authorization": "test_token"}
|
||||
mock_request.body = b"{}"
|
||||
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = "!JIfCxVLcKKkWrmVBOb:your-domain.com"
|
||||
mock_data.event = "room_started"
|
||||
mock_receive.return_value = mock_data
|
||||
|
||||
service = LiveKitEventsService()
|
||||
service.receive(mock_request)
|
||||
|
||||
mock_handle_room_started.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch.object(api.WebhookReceiver, "receive")
|
||||
@mock.patch.object(LiveKitEventsService, "_handle_room_started")
|
||||
def test_receive_filter_drops_non_matching_events(
|
||||
mock_handle_room_started, mock_receive, mock_livekit_config, settings
|
||||
):
|
||||
"""Should drop events when room name does not match filter regex."""
|
||||
settings.LIVEKIT_WEBHOOK_EVENTS_FILTER_REGEX = (
|
||||
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
|
||||
)
|
||||
|
||||
mock_request = mock.MagicMock()
|
||||
mock_request.headers = {"Authorization": "test_token"}
|
||||
mock_request.body = b"{}"
|
||||
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = "!JIfCxVLcKKkWrmVBOb:your-domain.com"
|
||||
mock_data.event = "room_started"
|
||||
mock_receive.return_value = mock_data
|
||||
|
||||
service = LiveKitEventsService()
|
||||
service.receive(mock_request)
|
||||
|
||||
mock_handle_room_started.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch.object(api.WebhookReceiver, "receive")
|
||||
@mock.patch.object(LiveKitEventsService, "_handle_room_started")
|
||||
def test_receive_filter_processes_matching_events(
|
||||
mock_handle_room_started, mock_receive, mock_livekit_config, settings
|
||||
):
|
||||
"""Should process events when room name matches filter regex."""
|
||||
settings.LIVEKIT_WEBHOOK_EVENTS_FILTER_REGEX = (
|
||||
r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
|
||||
)
|
||||
|
||||
mock_request = mock.MagicMock()
|
||||
mock_request.headers = {"Authorization": "test_token"}
|
||||
mock_request.body = b"{}"
|
||||
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(uuid.uuid4())
|
||||
mock_data.event = "room_started"
|
||||
mock_receive.return_value = mock_data
|
||||
|
||||
service = LiveKitEventsService()
|
||||
service.receive(mock_request)
|
||||
|
||||
mock_handle_room_started.assert_called_once()
|
||||
|
||||
@@ -5,6 +5,7 @@ Test lobby service.
|
||||
# pylint: disable=W0621,W0613, W0212, R0913
|
||||
# ruff: noqa: PLR0913
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
@@ -13,17 +14,18 @@ from django.core.cache import cache
|
||||
from django.http import HttpResponse
|
||||
|
||||
import pytest
|
||||
from livekit.api import TwirpError
|
||||
|
||||
from core.factories import RoomFactory
|
||||
from core.models import RoomAccessLevel
|
||||
from core.services.lobby import (
|
||||
LobbyNotificationError,
|
||||
LobbyParticipant,
|
||||
LobbyParticipantNotFound,
|
||||
LobbyParticipantParsingError,
|
||||
LobbyParticipantStatus,
|
||||
LobbyService,
|
||||
)
|
||||
from core.utils import NotificationError
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -144,9 +146,10 @@ def test_get_or_create_participant_id_from_cookie(lobby_service):
|
||||
assert participant_id == "existing-id"
|
||||
|
||||
|
||||
@mock.patch.object(uuid, "uuid4", return_value="generated-id")
|
||||
@mock.patch("uuid.uuid4")
|
||||
def test_get_or_create_participant_id_new(mock_uuid4, lobby_service):
|
||||
"""Test creating new participant ID when cookie is missing."""
|
||||
mock_uuid4.return_value = mock.Mock(hex="generated-id")
|
||||
request = mock.Mock()
|
||||
request.COOKIES = {}
|
||||
|
||||
@@ -265,9 +268,6 @@ def test_request_entry_public_room(
|
||||
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)
|
||||
@@ -304,9 +304,6 @@ def test_request_entry_trusted_room(
|
||||
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)
|
||||
@@ -399,9 +396,6 @@ def test_request_entry_accepted_participant(
|
||||
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)
|
||||
|
||||
@@ -420,7 +414,7 @@ def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
|
||||
# pylint: disable=R0917
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
@mock.patch("core.utils.generate_color")
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.lobby.LobbyService.notify_participants")
|
||||
def test_enter_success(
|
||||
mock_notify,
|
||||
mock_generate_color,
|
||||
@@ -449,15 +443,13 @@ def test_enter_success(
|
||||
participant.to_dict(),
|
||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||
)
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(room.pk), notification_data={"type": "participantWaiting"}
|
||||
)
|
||||
mock_notify.assert_called_once_with(room_id=room.id)
|
||||
|
||||
|
||||
# pylint: disable=R0917
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
@mock.patch("core.utils.generate_color")
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.lobby.LobbyService.notify_participants")
|
||||
def test_enter_with_notification_error(
|
||||
mock_notify,
|
||||
mock_generate_color,
|
||||
@@ -468,7 +460,7 @@ def test_enter_with_notification_error(
|
||||
):
|
||||
"""Test participant entry with notification error."""
|
||||
mock_generate_color.return_value = "#123456"
|
||||
mock_notify.side_effect = NotificationError("Error notifying")
|
||||
mock_notify.side_effect = LobbyNotificationError("Error notifying")
|
||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
@@ -784,6 +776,116 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
|
||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_notify_participants_success_no_room(mock_create_livekit_client, lobby_service):
|
||||
"""Test the notify_participants method when the LiveKit room doesn't exist yet."""
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
# Set up the mock LiveKitAPI and its behavior
|
||||
mock_api_instance = mock.Mock()
|
||||
mock_api_instance.room = mock.Mock()
|
||||
mock_api_instance.room.send_data = mock.AsyncMock()
|
||||
|
||||
# Create a proper response object with an empty rooms list
|
||||
class MockResponse:
|
||||
"""LiveKit API response mock with empty rooms list."""
|
||||
|
||||
rooms = []
|
||||
|
||||
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
|
||||
mock_api_instance.aclose = mock.AsyncMock()
|
||||
mock_create_livekit_client.return_value = mock_api_instance
|
||||
|
||||
# Act
|
||||
lobby_service.notify_participants(room.id)
|
||||
|
||||
# Verify that the service checked for existing rooms
|
||||
mock_api_instance.room.list_rooms.assert_called_once()
|
||||
|
||||
# Verify the send_data method was not called since no room exists
|
||||
mock_api_instance.room.send_data.assert_not_called()
|
||||
|
||||
# Verify the connection was properly closed
|
||||
mock_api_instance.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_notify_participants_success(mock_create_livekit_client, lobby_service):
|
||||
"""Test successful participant notification."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
# Set up the mock LiveKitAPI and its behavior
|
||||
mock_api_instance = mock.Mock()
|
||||
mock_api_instance.room = mock.Mock()
|
||||
mock_api_instance.room.send_data = mock.AsyncMock()
|
||||
|
||||
class MockResponse:
|
||||
"""LiveKit API response mock with non-empty rooms list."""
|
||||
|
||||
rooms = ["room-1"]
|
||||
|
||||
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
|
||||
|
||||
mock_api_instance.aclose = mock.AsyncMock()
|
||||
mock_create_livekit_client.return_value = mock_api_instance
|
||||
|
||||
# Call the function
|
||||
lobby_service.notify_participants(room.id)
|
||||
|
||||
# Verify that the service checked for existing rooms
|
||||
mock_api_instance.room.list_rooms.assert_called_once()
|
||||
|
||||
# Verify the send_data method was called
|
||||
mock_api_instance.room.send_data.assert_called_once()
|
||||
send_data_request = mock_api_instance.room.send_data.call_args[0][0]
|
||||
assert send_data_request.room == str(room.id)
|
||||
assert (
|
||||
json.loads(send_data_request.data.decode("utf-8"))["type"]
|
||||
== settings.LOBBY_NOTIFICATION_TYPE
|
||||
)
|
||||
assert send_data_request.kind == 0 # RELIABLE mode in Livekit protocol
|
||||
|
||||
# Verify aclose was called
|
||||
mock_api_instance.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_notify_participants_error(mock_create_livekit_client, lobby_service):
|
||||
"""Test participant notification with API error."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
# Set up the mock LiveKitAPI and its behavior
|
||||
mock_api_instance = mock.Mock()
|
||||
mock_api_instance.room = mock.Mock()
|
||||
mock_api_instance.room.send_data = mock.AsyncMock(
|
||||
side_effect=TwirpError(msg="test error", code=123, status=123)
|
||||
)
|
||||
|
||||
class MockResponse:
|
||||
"""LiveKit API response mock with non-empty rooms list."""
|
||||
|
||||
rooms = ["room-1"]
|
||||
|
||||
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
|
||||
|
||||
mock_api_instance.aclose = mock.AsyncMock()
|
||||
mock_create_livekit_client.return_value = mock_api_instance
|
||||
|
||||
# Call the function and expect an exception
|
||||
with pytest.raises(
|
||||
LobbyNotificationError, match="Failed to notify room participants"
|
||||
):
|
||||
lobby_service.notify_participants(room.id)
|
||||
|
||||
# Verify that the service checked for existing rooms
|
||||
mock_api_instance.room.list_rooms.assert_called_once()
|
||||
|
||||
# Verify send_data was called
|
||||
mock_api_instance.room.send_data.assert_called_once()
|
||||
|
||||
# Verify aclose was still called after the exception
|
||||
mock_api_instance.aclose.assert_called_once()
|
||||
|
||||
|
||||
def test_clear_room_cache(settings, lobby_service):
|
||||
"""Test clearing room cache actually removes entries from cache."""
|
||||
|
||||
@@ -839,35 +941,3 @@ def test_clear_room_empty(settings, lobby_service):
|
||||
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
|
||||
|
||||
@@ -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()
|
||||
@@ -1,334 +0,0 @@
|
||||
"""
|
||||
Tests for external API /room endpoint
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import (
|
||||
RoomFactory,
|
||||
UserFactory,
|
||||
)
|
||||
from core.models import ApplicationScope, RoleChoices, Room
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def generate_test_token(user, scopes):
|
||||
"""Generate a valid JWT token for testing."""
|
||||
now = datetime.now(timezone.utc)
|
||||
scope_string = " ".join(scopes)
|
||||
|
||||
payload = {
|
||||
"iss": settings.APPLICATION_JWT_ISSUER,
|
||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=settings.APPLICATION_JWT_EXPIRATION_SECONDS),
|
||||
"client_id": "test-client-id",
|
||||
"scope": scope_string,
|
||||
"user_id": str(user.id),
|
||||
"delegated": True,
|
||||
}
|
||||
|
||||
return jwt.encode(
|
||||
payload,
|
||||
settings.APPLICATION_JWT_SECRET_KEY,
|
||||
algorithm=settings.APPLICATION_JWT_ALG,
|
||||
)
|
||||
|
||||
|
||||
def test_api_rooms_list_requires_authentication():
|
||||
"""Listing rooms without authentication should return 401."""
|
||||
client = APIClient()
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_rooms_list_with_valid_token(settings):
|
||||
"""Listing rooms with valid token should succeed."""
|
||||
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
# Generate valid token
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["count"] == 1
|
||||
assert response.data["results"][0]["id"] == str(room.id)
|
||||
|
||||
|
||||
def test_api_rooms_list_with_expired_token(settings):
|
||||
"""Listing rooms with expired token should return 401."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
settings.APPLICATION_JWT_EXPIRATION_SECONDS = 0
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
# Generate expired token
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "expired" in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_list_with_invalid_token():
|
||||
"""Listing rooms with invalid token should return 401."""
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION="Bearer invalid-token-123")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_rooms_list_missing_scope(settings):
|
||||
"""Listing rooms without required scope should return 403."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
# Token without ROOMS_LIST scope
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "Insufficient permissions. Required scope: rooms:list" in str(response.data)
|
||||
|
||||
|
||||
def test_api_rooms_list_filters_by_user(settings):
|
||||
"""List should only return rooms accessible to the authenticated user."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user1 = UserFactory()
|
||||
user2 = UserFactory()
|
||||
|
||||
room1 = RoomFactory(users=[(user1, RoleChoices.OWNER)])
|
||||
room2 = RoomFactory(users=[(user2, RoleChoices.OWNER)])
|
||||
room3 = RoomFactory(users=[(user1, RoleChoices.MEMBER)])
|
||||
|
||||
token = generate_test_token(user1, [ApplicationScope.ROOMS_LIST])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["count"] == 2
|
||||
returned_ids = [r["id"] for r in response.data["results"]]
|
||||
assert str(room1.id) in returned_ids
|
||||
assert str(room3.id) in returned_ids
|
||||
assert str(room2.id) not in returned_ids
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_requires_scope(settings):
|
||||
"""Retrieving a room requires ROOMS_RETRIEVE scope."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
# Token without ROOMS_RETRIEVE scope
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "Insufficient permissions. Required scope: rooms:retrieve" in str(
|
||||
response.data
|
||||
)
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_success(settings):
|
||||
"""Retrieving a room with correct scope should succeed."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
settings.APPLICATION_BASE_URL = "http://your-application.com"
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
settings.ROOM_TELEPHONY_PHONE_NUMBER = "+1-555-0100"
|
||||
settings.ROOM_TELEPHONY_DEFAULT_COUNTRY = "US"
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
assert response.data == {
|
||||
"id": str(room.id),
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
"access_level": str(room.access_level),
|
||||
"url": f"http://your-application.com/{room.slug}",
|
||||
"telephony": {
|
||||
"enabled": True,
|
||||
"phone_number": "+1-555-0100",
|
||||
"pin_code": room.pin_code,
|
||||
"default_country": "US",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_api_rooms_create_requires_scope(settings):
|
||||
"""Creating a room requires ROOMS_CREATE scope."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
user = UserFactory()
|
||||
|
||||
# Token without ROOMS_CREATE scope
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_LIST])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post("/external-api/v1.0/rooms/", {}, format="json")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "Insufficient permissions. Required scope: rooms:create" in str(
|
||||
response.data
|
||||
)
|
||||
|
||||
|
||||
def test_api_rooms_create_success(settings):
|
||||
"""Creating a room with correct scope should succeed."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_CREATE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.post("/external-api/v1.0/rooms/", {}, format="json")
|
||||
|
||||
assert response.status_code == 201
|
||||
assert "id" in response.data
|
||||
assert "slug" in response.data
|
||||
|
||||
# Verify room was created with user as owner
|
||||
room = Room.objects.get(id=response.data["id"])
|
||||
assert room.get_role(user) == RoleChoices.OWNER
|
||||
assert room.access_level == "trusted"
|
||||
|
||||
|
||||
def test_api_rooms_response_no_url(settings):
|
||||
"""Response should not include url field when APPLICATION_BASE_URL is None."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
settings.APPLICATION_BASE_URL = None
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "url" not in response.data
|
||||
assert response.data["id"] == str(room.id)
|
||||
|
||||
|
||||
def test_api_rooms_response_no_telephony(settings):
|
||||
"""Response should not include telephony field when ROOM_TELEPHONY_ENABLED is False."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
settings.ROOM_TELEPHONY_ENABLED = False
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "telephony" not in response.data
|
||||
assert response.data["id"] == str(room.id)
|
||||
|
||||
|
||||
def test_api_rooms_token_without_delegated_flag(settings):
|
||||
"""Token without delegated flag should be rejected."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
user = UserFactory()
|
||||
|
||||
# Generate token without delegated flag
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"iss": settings.APPLICATION_JWT_ISSUER,
|
||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=1),
|
||||
"client_id": "test-client",
|
||||
"scope": "rooms:list",
|
||||
"user_id": str(user.id),
|
||||
"delegated": False, # Not delegated
|
||||
}
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
settings.APPLICATION_JWT_SECRET_KEY,
|
||||
algorithm=settings.APPLICATION_JWT_ALG,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Invalid token type." in str(response.data)
|
||||
|
||||
|
||||
def test_api_rooms_token_missing_client_id(settings):
|
||||
"""Token without client_id should be rejected."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
user = UserFactory()
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"iss": settings.APPLICATION_JWT_ISSUER,
|
||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
||||
"iat": now,
|
||||
"exp": now + timedelta(hours=1),
|
||||
"scope": "rooms:list",
|
||||
"user_id": str(user.id),
|
||||
"delegated": True,
|
||||
# Missing client_id
|
||||
}
|
||||
token = jwt.encode(
|
||||
payload,
|
||||
settings.APPLICATION_JWT_SECRET_KEY,
|
||||
algorithm=settings.APPLICATION_JWT_ALG,
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Invalid token claims." in str(response.data)
|
||||
@@ -1,275 +0,0 @@
|
||||
"""
|
||||
Tests for external API /token endpoint
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
from freezegun import freeze_time
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import (
|
||||
ApplicationDomainFactory,
|
||||
ApplicationFactory,
|
||||
UserFactory,
|
||||
)
|
||||
from core.models import ApplicationScope
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_applications_generate_token_success(settings):
|
||||
"""Valid credentials should return a JWT token."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
user = UserFactory(email="user@example.com")
|
||||
application = ApplicationFactory(
|
||||
active=True,
|
||||
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
||||
)
|
||||
|
||||
# Store plain secret before it's hashed
|
||||
plain_secret = "test-secret-123"
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
{
|
||||
"client_id": application.client_id,
|
||||
"client_secret": plain_secret,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": user.email,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "access_token" in response.data
|
||||
|
||||
response.data.pop("access_token")
|
||||
|
||||
assert response.data == {
|
||||
"token_type": "Bearer",
|
||||
"expires_in": settings.APPLICATION_JWT_EXPIRATION_SECONDS,
|
||||
"scope": "rooms:list rooms:create",
|
||||
}
|
||||
|
||||
|
||||
def test_api_applications_generate_token_invalid_client_id():
|
||||
"""Invalid client_id should return 401."""
|
||||
user = UserFactory(email="user@example.com")
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
{
|
||||
"client_id": "invalid-client-id",
|
||||
"client_secret": "some-secret",
|
||||
"grant_type": "client_credentials",
|
||||
"scope": user.email,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Invalid credentials" in str(response.data)
|
||||
|
||||
|
||||
def test_api_applications_generate_token_invalid_client_secret():
|
||||
"""Invalid client_secret should return 401."""
|
||||
user = UserFactory(email="user@example.com")
|
||||
application = ApplicationFactory(active=True)
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
{
|
||||
"client_id": application.client_id,
|
||||
"client_secret": "wrong-secret",
|
||||
"grant_type": "client_credentials",
|
||||
"scope": user.email,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Invalid credentials" in str(response.data)
|
||||
|
||||
|
||||
def test_api_applications_generate_token_inactive_application():
|
||||
"""Inactive application should return 401."""
|
||||
user = UserFactory(email="user@example.com")
|
||||
application = ApplicationFactory(active=False)
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
{
|
||||
"client_id": application.client_id,
|
||||
"client_secret": plain_secret,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": user.email,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Application is inactive" in str(response.data)
|
||||
|
||||
|
||||
def test_api_applications_generate_token_invalid_email_format():
|
||||
"""Invalid email format should return 400."""
|
||||
application = ApplicationFactory(active=True)
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
{
|
||||
"client_id": application.client_id,
|
||||
"client_secret": plain_secret,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": "not-an-email",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "scope should be a valid email address." in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_applications_generate_token_domain_not_authorized():
|
||||
"""Application without domain authorization should return 403."""
|
||||
user = UserFactory(email="user@denied.com")
|
||||
application = ApplicationFactory(active=True)
|
||||
ApplicationDomainFactory(application=application, domain="allowed.com")
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
{
|
||||
"client_id": application.client_id,
|
||||
"client_secret": plain_secret,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": user.email,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert "not authorized for this email domain" in str(response.data)
|
||||
|
||||
|
||||
def test_api_applications_generate_token_domain_authorized(settings):
|
||||
"""Application with domain authorization should succeed."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
user = UserFactory(email="user@allowed.com")
|
||||
application = ApplicationFactory(
|
||||
active=True,
|
||||
scopes=[ApplicationScope.ROOMS_LIST],
|
||||
)
|
||||
ApplicationDomainFactory(application=application, domain="allowed.com")
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
{
|
||||
"client_id": application.client_id,
|
||||
"client_secret": plain_secret,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": user.email,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "access_token" in response.data
|
||||
|
||||
|
||||
def test_api_applications_generate_token_user_not_found():
|
||||
"""Non-existent user should return 404."""
|
||||
application = ApplicationFactory(active=True)
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
{
|
||||
"client_id": application.client_id,
|
||||
"client_secret": plain_secret,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": "nonexistent@example.com",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert "User not found" in str(response.data)
|
||||
|
||||
|
||||
@freeze_time("2023-01-15 12:00:00")
|
||||
def test_api_applications_token_payload_structure(settings):
|
||||
"""Generated token should have correct payload structure."""
|
||||
settings.APPLICATION_JWT_SECRET_KEY = "devKey"
|
||||
user = UserFactory(email="user@example.com")
|
||||
application = ApplicationFactory(
|
||||
active=True,
|
||||
scopes=[ApplicationScope.ROOMS_LIST, ApplicationScope.ROOMS_CREATE],
|
||||
)
|
||||
|
||||
plain_secret = "test-secret-123"
|
||||
application.client_secret = plain_secret
|
||||
application.save()
|
||||
|
||||
client = APIClient()
|
||||
response = client.post(
|
||||
"/external-api/v1.0/application/token/",
|
||||
{
|
||||
"client_id": application.client_id,
|
||||
"client_secret": plain_secret,
|
||||
"grant_type": "client_credentials",
|
||||
"scope": user.email,
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
# Decode token to verify payload
|
||||
token = response.data["access_token"]
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
settings.APPLICATION_JWT_SECRET_KEY,
|
||||
algorithms=[settings.APPLICATION_JWT_ALG],
|
||||
issuer=settings.APPLICATION_JWT_ISSUER,
|
||||
audience=settings.APPLICATION_JWT_AUDIENCE,
|
||||
)
|
||||
|
||||
assert payload == {
|
||||
"iss": settings.APPLICATION_JWT_ISSUER,
|
||||
"aud": settings.APPLICATION_JWT_AUDIENCE,
|
||||
"client_id": application.client_id,
|
||||
"exp": 1673787600,
|
||||
"iat": 1673784000,
|
||||
"user_id": str(user.id),
|
||||
"delegated": True,
|
||||
"scope": "rooms:list rooms:create",
|
||||
}
|
||||
@@ -1,331 +0,0 @@
|
||||
"""
|
||||
Unit tests for the Application and ApplicationDomain models
|
||||
"""
|
||||
|
||||
# pylint: disable=W0613
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth.hashers import check_password
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
import pytest
|
||||
|
||||
from core.factories import ApplicationDomainFactory, ApplicationFactory
|
||||
from core.models import Application, ApplicationDomain, ApplicationScope
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
# Application Model Tests
|
||||
|
||||
|
||||
def test_models_application_str():
|
||||
"""The str representation should be the name."""
|
||||
application = ApplicationFactory(name="My Integration")
|
||||
assert str(application) == "My Integration"
|
||||
|
||||
|
||||
def test_models_application_name_maxlength():
|
||||
"""The name field should be at most 255 characters."""
|
||||
ApplicationFactory(name="a" * 255)
|
||||
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
ApplicationFactory(name="a" * 256)
|
||||
|
||||
assert "Ensure this value has at most 255 characters (it has 256)." in str(
|
||||
excinfo.value
|
||||
)
|
||||
|
||||
|
||||
def test_models_application_active_default():
|
||||
"""An application should be active by default."""
|
||||
application = Application.objects.create(name="Test App")
|
||||
assert application.active is True
|
||||
|
||||
|
||||
def test_models_application_scopes_default():
|
||||
"""Scopes should default to empty list."""
|
||||
application = Application.objects.create(name="Test App")
|
||||
assert application.scopes == []
|
||||
|
||||
|
||||
def test_models_application_client_id_auto_generated():
|
||||
"""Client ID should be automatically generated on creation."""
|
||||
application = ApplicationFactory()
|
||||
assert application.client_id is not None
|
||||
assert len(application.client_id) > 0
|
||||
|
||||
|
||||
def test_models_application_client_id_unique():
|
||||
"""Client IDs should be unique."""
|
||||
app1 = ApplicationFactory()
|
||||
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
ApplicationFactory(client_id=app1.client_id)
|
||||
|
||||
assert "Application with this Client id already exists." in str(excinfo.value)
|
||||
|
||||
|
||||
def test_models_application_client_id_length(settings):
|
||||
"""Client ID should match configured length."""
|
||||
|
||||
app1 = ApplicationFactory()
|
||||
assert len(app1.client_id) == 40 # default value
|
||||
|
||||
settings.APPLICATION_CLIENT_ID_LENGTH = 20
|
||||
|
||||
app2 = ApplicationFactory()
|
||||
assert len(app2.client_id) == 20
|
||||
|
||||
|
||||
def test_models_application_client_secret_auto_generated():
|
||||
"""Client secret should be automatically generated and hashed on creation."""
|
||||
application = ApplicationFactory()
|
||||
|
||||
assert application.client_secret is not None
|
||||
assert len(application.client_secret) > 0
|
||||
|
||||
|
||||
def test_models_application_client_secret_hashed_on_save():
|
||||
"""Client secret should be hashed when saved."""
|
||||
plain_secret = "my-plain-secret"
|
||||
|
||||
with mock.patch(
|
||||
"core.models.utils.generate_client_secret", return_value=plain_secret
|
||||
):
|
||||
application = ApplicationFactory(client_secret=plain_secret)
|
||||
|
||||
# Secret should be hashed, not plain
|
||||
assert application.client_secret != plain_secret
|
||||
# Should verify with check_password
|
||||
assert check_password(plain_secret, application.client_secret) is True
|
||||
|
||||
|
||||
def test_models_application_client_secret_preserves_existing_hash():
|
||||
"""Re-saving should not re-hash an already hashed secret."""
|
||||
application = ApplicationFactory()
|
||||
original_hash = application.client_secret
|
||||
|
||||
# Update another field and save
|
||||
application.name = "Updated Name"
|
||||
application.save()
|
||||
|
||||
# Hash should remain unchanged
|
||||
assert application.client_secret == original_hash
|
||||
|
||||
|
||||
def test_models_application_updates_preserve_client_id():
|
||||
"""Application updates should preserve existing client_id."""
|
||||
application = ApplicationFactory()
|
||||
original_client_id = application.client_id
|
||||
|
||||
application.name = "Updated Name"
|
||||
application.save()
|
||||
|
||||
assert application.client_id == original_client_id
|
||||
|
||||
|
||||
def test_models_application_scopes_valid_choices():
|
||||
"""Only valid scope choices should be accepted."""
|
||||
application = ApplicationFactory(
|
||||
scopes=[
|
||||
ApplicationScope.ROOMS_LIST,
|
||||
ApplicationScope.ROOMS_CREATE,
|
||||
ApplicationScope.ROOMS_RETRIEVE,
|
||||
]
|
||||
)
|
||||
|
||||
assert len(application.scopes) == 3
|
||||
assert ApplicationScope.ROOMS_LIST in application.scopes
|
||||
|
||||
|
||||
def test_models_application_scopes_invalid_choice():
|
||||
"""Invalid scope choices should raise validation error."""
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
ApplicationFactory(scopes=["invalid:scope"])
|
||||
|
||||
assert "is not a valid choice" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_models_application_can_delegate_email_no_restrictions():
|
||||
"""Application with no domain restrictions can delegate any email."""
|
||||
application = ApplicationFactory()
|
||||
|
||||
assert application.can_delegate_email("user@example.com") is True
|
||||
assert application.can_delegate_email("admin@anotherdomain.org") is True
|
||||
|
||||
|
||||
def test_models_application_can_delegate_email_allowed_domain():
|
||||
"""Application can delegate email from allowed domain."""
|
||||
application = ApplicationFactory()
|
||||
ApplicationDomainFactory(application=application, domain="example.com")
|
||||
|
||||
assert application.can_delegate_email("user@example.com") is True
|
||||
|
||||
|
||||
def test_models_application_can_delegate_email_denied_domain():
|
||||
"""Application cannot delegate email from non-allowed domain."""
|
||||
application = ApplicationFactory()
|
||||
ApplicationDomainFactory(application=application, domain="example.com")
|
||||
|
||||
assert application.can_delegate_email("user@other.com") is False
|
||||
|
||||
|
||||
def test_models_application_can_delegate_email_case_insensitive():
|
||||
"""Domain matching should be case-insensitive."""
|
||||
application = ApplicationFactory()
|
||||
ApplicationDomainFactory(application=application, domain="example.com")
|
||||
|
||||
assert application.can_delegate_email("user@EXAMPLE.COM") is True
|
||||
assert application.can_delegate_email("user@Example.Com") is True
|
||||
|
||||
|
||||
def test_models_application_can_delegate_email_multiple_domains():
|
||||
"""Application with multiple allowed domains should check all."""
|
||||
application = ApplicationFactory()
|
||||
ApplicationDomainFactory(application=application, domain="example.com")
|
||||
ApplicationDomainFactory(application=application, domain="other.org")
|
||||
|
||||
assert application.can_delegate_email("user@example.com") is True
|
||||
assert application.can_delegate_email("admin@other.org") is True
|
||||
assert application.can_delegate_email("test@denied.com") is False
|
||||
|
||||
|
||||
# ApplicationDomain Model Tests
|
||||
|
||||
|
||||
def test_models_application_domain_str():
|
||||
"""The str representation should be the domain."""
|
||||
domain = ApplicationDomainFactory(domain="example.com")
|
||||
assert str(domain) == "example.com"
|
||||
|
||||
|
||||
def test_models_application_domain_ordering():
|
||||
"""Domains should be returned ordered by domain name."""
|
||||
application = ApplicationFactory()
|
||||
ApplicationDomainFactory(application=application, domain="zulu.com")
|
||||
ApplicationDomainFactory(application=application, domain="alpha.com")
|
||||
ApplicationDomainFactory(application=application, domain="beta.com")
|
||||
|
||||
domains = ApplicationDomain.objects.all()
|
||||
assert domains[0].domain == "alpha.com"
|
||||
assert domains[1].domain == "beta.com"
|
||||
assert domains[2].domain == "zulu.com"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"valid_domain",
|
||||
[
|
||||
"example.com",
|
||||
"sub.example.com",
|
||||
"deep.sub.example.com",
|
||||
"example-with-dash.com",
|
||||
"123.example.com",
|
||||
],
|
||||
)
|
||||
def test_models_application_domain_valid_domain(valid_domain):
|
||||
"""Valid domain names should be accepted."""
|
||||
ApplicationDomainFactory(domain=valid_domain)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"invalid_domain",
|
||||
[
|
||||
"not a domain",
|
||||
"example..com",
|
||||
"-example.com",
|
||||
"example-.com",
|
||||
"example.com-",
|
||||
],
|
||||
)
|
||||
def test_models_application_domain_invalid_domain(invalid_domain):
|
||||
"""Invalid domain names should raise validation error."""
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
ApplicationDomainFactory(domain=invalid_domain)
|
||||
|
||||
|
||||
def test_models_application_domain_lowercase_on_save():
|
||||
"""Domain should be normalized to lowercase on save."""
|
||||
domain = ApplicationDomainFactory(domain="EXAMPLE.COM")
|
||||
|
||||
assert domain.domain == "example.com"
|
||||
|
||||
|
||||
def test_models_application_domain_strip_whitespace_on_save():
|
||||
"""Domain should strip whitespace on save."""
|
||||
domain = ApplicationDomainFactory(domain=" example.com ")
|
||||
|
||||
assert domain.domain == "example.com"
|
||||
|
||||
|
||||
def test_models_application_domain_combined_normalization():
|
||||
"""Domain should strip and lowercase in one operation."""
|
||||
domain = ApplicationDomainFactory(domain=" EXAMPLE.COM ")
|
||||
|
||||
assert domain.domain == "example.com"
|
||||
|
||||
|
||||
def test_models_application_domain_unique_together():
|
||||
"""Same domain cannot be added twice to same application."""
|
||||
application = ApplicationFactory()
|
||||
ApplicationDomainFactory(application=application, domain="example.com")
|
||||
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
ApplicationDomainFactory(application=application, domain="example.com")
|
||||
|
||||
assert "Application domain with this Application and Domain already exists." in str(
|
||||
excinfo.value
|
||||
)
|
||||
|
||||
|
||||
def test_models_application_domain_same_domain_different_apps():
|
||||
"""Same domain can belong to different applications."""
|
||||
app1 = ApplicationFactory()
|
||||
app2 = ApplicationFactory()
|
||||
|
||||
ApplicationDomainFactory(application=app1, domain="example.com")
|
||||
ApplicationDomainFactory(application=app2, domain="example.com")
|
||||
|
||||
assert app1.allowed_domains.count() == 1
|
||||
assert app2.allowed_domains.count() == 1
|
||||
|
||||
|
||||
def test_models_application_domain_cascade_delete():
|
||||
"""Deleting application should delete its domains."""
|
||||
application = ApplicationFactory()
|
||||
ApplicationDomainFactory(application=application, domain="example.com")
|
||||
ApplicationDomainFactory(application=application, domain="other.com")
|
||||
|
||||
assert ApplicationDomain.objects.count() == 2
|
||||
|
||||
application.delete()
|
||||
|
||||
assert ApplicationDomain.objects.count() == 0
|
||||
|
||||
|
||||
def test_models_application_domain_related_name():
|
||||
"""Domains should be accessible via application.allowed_domains."""
|
||||
application = ApplicationFactory()
|
||||
domain1 = ApplicationDomainFactory(application=application, domain="example.com")
|
||||
domain2 = ApplicationDomainFactory(application=application, domain="other.com")
|
||||
|
||||
assert list(application.allowed_domains.all()) == [domain1, domain2]
|
||||
|
||||
|
||||
def test_models_application_domain_filters_delegation():
|
||||
"""Adding/removing domains should affect can_delegate_email."""
|
||||
application = ApplicationFactory()
|
||||
|
||||
# No restrictions initially
|
||||
assert application.can_delegate_email("user@example.com") is True
|
||||
|
||||
# Add domain restriction
|
||||
domain = ApplicationDomainFactory(application=application, domain="example.com")
|
||||
assert application.can_delegate_email("user@example.com") is True
|
||||
assert application.can_delegate_email("user@other.com") is False
|
||||
|
||||
# Remove domain restriction
|
||||
domain.delete()
|
||||
assert application.can_delegate_email("user@other.com") is True
|
||||
@@ -102,7 +102,7 @@ def test_models_rooms_access_rights_none(django_assert_num_queries):
|
||||
with django_assert_num_queries(0):
|
||||
assert room.get_role(None) is None
|
||||
with django_assert_num_queries(0):
|
||||
assert room.is_administrator_or_owner(None) is False
|
||||
assert room.is_administrator(None) is False
|
||||
with django_assert_num_queries(0):
|
||||
assert room.is_owner(None) is False
|
||||
|
||||
@@ -115,7 +115,7 @@ def test_models_rooms_access_rights_anonymous(django_assert_num_queries):
|
||||
with django_assert_num_queries(0):
|
||||
assert room.get_role(user) is None
|
||||
with django_assert_num_queries(0):
|
||||
assert room.is_administrator_or_owner(user) is False
|
||||
assert room.is_administrator(user) is False
|
||||
with django_assert_num_queries(0):
|
||||
assert room.is_owner(user) is False
|
||||
|
||||
@@ -128,7 +128,7 @@ def test_models_rooms_access_rights_authenticated(django_assert_num_queries):
|
||||
with django_assert_num_queries(1):
|
||||
assert room.get_role(user) is None
|
||||
with django_assert_num_queries(1):
|
||||
assert room.is_administrator_or_owner(user) is False
|
||||
assert room.is_administrator(user) is False
|
||||
with django_assert_num_queries(1):
|
||||
assert room.is_owner(user) is False
|
||||
|
||||
@@ -141,7 +141,7 @@ def test_models_rooms_access_rights_member_direct(django_assert_num_queries):
|
||||
with django_assert_num_queries(1):
|
||||
assert room.get_role(user) == "member"
|
||||
with django_assert_num_queries(1):
|
||||
assert room.is_administrator_or_owner(user) is False
|
||||
assert room.is_administrator(user) is False
|
||||
with django_assert_num_queries(1):
|
||||
assert room.is_owner(user) is False
|
||||
|
||||
@@ -154,7 +154,7 @@ def test_models_rooms_access_rights_administrator_direct(django_assert_num_queri
|
||||
with django_assert_num_queries(1):
|
||||
assert room.get_role(user) == "administrator"
|
||||
with django_assert_num_queries(1):
|
||||
assert room.is_administrator_or_owner(user) is True
|
||||
assert room.is_administrator(user) is True
|
||||
with django_assert_num_queries(1):
|
||||
assert room.is_owner(user) is False
|
||||
|
||||
@@ -167,7 +167,7 @@ def test_models_rooms_access_rights_owner_direct(django_assert_num_queries):
|
||||
with django_assert_num_queries(1):
|
||||
assert room.get_role(user) == "owner"
|
||||
with django_assert_num_queries(1):
|
||||
assert room.is_administrator_or_owner(user) is True
|
||||
assert room.is_administrator(user) is True
|
||||
with django_assert_num_queries(1):
|
||||
assert room.is_owner(user) is True
|
||||
|
||||
|
||||
@@ -2,13 +2,9 @@
|
||||
Test utils functions
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from livekit.api import TwirpError
|
||||
|
||||
from core.utils import NotificationError, create_livekit_client, notify_participants
|
||||
from core.utils import create_livekit_client
|
||||
|
||||
|
||||
@mock.patch("asyncio.get_running_loop")
|
||||
@@ -64,105 +60,3 @@ def test_create_livekit_client_custom_configuration(
|
||||
create_livekit_client(custom_configuration)
|
||||
|
||||
mock_livekit_api.assert_called_once_with(**custom_configuration, session=None)
|
||||
|
||||
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_notify_participants_error(mock_create_livekit_client):
|
||||
"""Test participant notification with API error."""
|
||||
|
||||
# Set up the mock LiveKitAPI and its behavior
|
||||
mock_api_instance = mock.Mock()
|
||||
mock_api_instance.room = mock.Mock()
|
||||
mock_api_instance.room.send_data = mock.AsyncMock(
|
||||
side_effect=TwirpError(msg="test error", code=123, status=123)
|
||||
)
|
||||
|
||||
class MockResponse:
|
||||
"""LiveKit API response mock with non-empty rooms list."""
|
||||
|
||||
rooms = ["room-1"]
|
||||
|
||||
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
|
||||
|
||||
mock_api_instance.aclose = mock.AsyncMock()
|
||||
mock_create_livekit_client.return_value = mock_api_instance
|
||||
|
||||
# Call the function and expect an exception
|
||||
with pytest.raises(NotificationError, match="Failed to notify room participants"):
|
||||
notify_participants(room_name="room-number-1", notification_data={"foo": "foo"})
|
||||
|
||||
# Verify that the service checked for existing rooms
|
||||
mock_api_instance.room.list_rooms.assert_called_once()
|
||||
|
||||
# Verify send_data was called
|
||||
mock_api_instance.room.send_data.assert_called_once()
|
||||
|
||||
# Verify aclose was still called after the exception
|
||||
mock_api_instance.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_notify_participants_success_no_room(mock_create_livekit_client):
|
||||
"""Test the notify_participants function when the LiveKit room doesn't exist."""
|
||||
|
||||
# Set up the mock LiveKitAPI and its behavior
|
||||
mock_api_instance = mock.Mock()
|
||||
mock_api_instance.room = mock.Mock()
|
||||
mock_api_instance.room.send_data = mock.AsyncMock()
|
||||
|
||||
# Create a proper response object with an empty rooms list
|
||||
class MockResponse:
|
||||
"""LiveKit API response mock with empty rooms list."""
|
||||
|
||||
rooms = []
|
||||
|
||||
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
|
||||
mock_api_instance.aclose = mock.AsyncMock()
|
||||
mock_create_livekit_client.return_value = mock_api_instance
|
||||
|
||||
notify_participants(room_name="room-number-1", notification_data={"foo": "foo"})
|
||||
|
||||
# Verify that the service checked for existing rooms
|
||||
mock_api_instance.room.list_rooms.assert_called_once()
|
||||
|
||||
# Verify the send_data method was not called since no room exists
|
||||
mock_api_instance.room.send_data.assert_not_called()
|
||||
|
||||
# Verify the connection was properly closed
|
||||
mock_api_instance.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_notify_participants_success(mock_create_livekit_client):
|
||||
"""Test successful participant notification."""
|
||||
|
||||
# Set up the mock LiveKitAPI and its behavior
|
||||
mock_api_instance = mock.Mock()
|
||||
mock_api_instance.room = mock.Mock()
|
||||
mock_api_instance.room.send_data = mock.AsyncMock()
|
||||
|
||||
class MockResponse:
|
||||
"""LiveKit API response mock with non-empty rooms list."""
|
||||
|
||||
rooms = ["room-1"]
|
||||
|
||||
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
|
||||
|
||||
mock_api_instance.aclose = mock.AsyncMock()
|
||||
mock_create_livekit_client.return_value = mock_api_instance
|
||||
|
||||
# Call the function
|
||||
notify_participants(room_name="room-number-1", notification_data={"foo": "foo"})
|
||||
|
||||
# Verify that the service checked for existing rooms
|
||||
mock_api_instance.room.list_rooms.assert_called_once()
|
||||
|
||||
# Verify the send_data method was called
|
||||
mock_api_instance.room.send_data.assert_called_once()
|
||||
send_data_request = mock_api_instance.room.send_data.call_args[0][0]
|
||||
assert send_data_request.room == "room-number-1"
|
||||
assert json.loads(send_data_request.data.decode("utf-8")) == {"foo": "foo"}
|
||||
assert send_data_request.kind == 0 # RELIABLE mode in Livekit protocol
|
||||
|
||||
# Verify aclose was called
|
||||
mock_api_instance.aclose.assert_called_once()
|
||||
|
||||
@@ -7,7 +7,6 @@ from lasuite.oidc_login.urls import urlpatterns as oidc_urls
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from core.api import get_frontend_configuration, viewsets
|
||||
from core.external_api import viewsets as external_viewsets
|
||||
|
||||
# - Main endpoints
|
||||
router = DefaultRouter()
|
||||
@@ -18,20 +17,6 @@ router.register(
|
||||
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
|
||||
)
|
||||
|
||||
# - External API
|
||||
external_router = DefaultRouter()
|
||||
external_router.register(
|
||||
"application",
|
||||
external_viewsets.ApplicationViewSet,
|
||||
basename="external_application",
|
||||
)
|
||||
|
||||
external_router.register(
|
||||
"rooms",
|
||||
external_viewsets.RoomViewSet,
|
||||
basename="external_room",
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
f"api/{settings.API_VERSION}/",
|
||||
@@ -44,15 +29,3 @@ urlpatterns = [
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
if settings.EXTERNAL_API_ENABLED:
|
||||
urlpatterns.append(
|
||||
path(
|
||||
f"external-api/{settings.EXTERNAL_API_VERSION}/",
|
||||
include(
|
||||
[
|
||||
*external_router.urls,
|
||||
]
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
+15
-149
@@ -2,15 +2,12 @@
|
||||
Utils functions used in the core app
|
||||
"""
|
||||
|
||||
# pylint: disable=R0913, R0917
|
||||
# ruff: noqa:S311, PLR0913
|
||||
# ruff: noqa:S311
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import random
|
||||
import secrets
|
||||
import string
|
||||
from typing import List, Optional
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
@@ -18,15 +15,7 @@ from django.core.files.storage import default_storage
|
||||
|
||||
import aiohttp
|
||||
import botocore
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit.api import ( # pylint: disable=E0611
|
||||
AccessToken,
|
||||
ListRoomsRequest,
|
||||
LiveKitAPI,
|
||||
SendDataRequest,
|
||||
TwirpError,
|
||||
VideoGrants,
|
||||
)
|
||||
from livekit.api import AccessToken, LiveKitAPI, VideoGrants
|
||||
|
||||
|
||||
def generate_color(identity: str) -> str:
|
||||
@@ -52,13 +41,7 @@ def generate_color(identity: str) -> str:
|
||||
|
||||
|
||||
def generate_token(
|
||||
room: str,
|
||||
user,
|
||||
username: Optional[str] = None,
|
||||
color: Optional[str] = None,
|
||||
sources: Optional[List[str]] = None,
|
||||
is_admin_or_owner: bool = False,
|
||||
participant_id: Optional[str] = None,
|
||||
room: str, user, username: Optional[str] = None, color: Optional[str] = None
|
||||
) -> str:
|
||||
"""Generate a LiveKit access token for a user in a specific room.
|
||||
|
||||
@@ -69,34 +52,25 @@ def generate_token(
|
||||
If none, a default value will be used.
|
||||
color (Optional[str]): The color to be displayed in the room.
|
||||
If none, a value will be generated
|
||||
sources: (Optional[List[str]]): List of media sources the user can publish
|
||||
If none, defaults to LIVEKIT_DEFAULT_SOURCES.
|
||||
is_admin_or_owner (bool): Whether user has admin privileges
|
||||
participant_id (Optional[str]): Stable identifier for anonymous users;
|
||||
used as identity when user.is_anonymous.
|
||||
|
||||
Returns:
|
||||
str: The LiveKit JWT access token.
|
||||
"""
|
||||
|
||||
if is_admin_or_owner:
|
||||
sources = settings.LIVEKIT_DEFAULT_SOURCES
|
||||
|
||||
if sources is None:
|
||||
sources = settings.LIVEKIT_DEFAULT_SOURCES
|
||||
|
||||
video_grants = VideoGrants(
|
||||
room=room,
|
||||
room_join=True,
|
||||
room_admin=is_admin_or_owner,
|
||||
room_admin=True,
|
||||
can_update_own_metadata=True,
|
||||
can_publish=bool(sources),
|
||||
can_publish_sources=sources,
|
||||
can_subscribe=True,
|
||||
can_publish_sources=[
|
||||
"camera",
|
||||
"microphone",
|
||||
"screen_share",
|
||||
"screen_share_audio",
|
||||
],
|
||||
)
|
||||
|
||||
if user.is_anonymous:
|
||||
identity = participant_id or str(uuid4())
|
||||
identity = str(uuid4())
|
||||
default_username = "Anonymous"
|
||||
else:
|
||||
identity = str(user.sub)
|
||||
@@ -113,22 +87,14 @@ def generate_token(
|
||||
.with_grants(video_grants)
|
||||
.with_identity(identity)
|
||||
.with_name(username or default_username)
|
||||
.with_attributes(
|
||||
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
|
||||
)
|
||||
.with_metadata(json.dumps({"color": color}))
|
||||
)
|
||||
|
||||
return token.to_jwt()
|
||||
|
||||
|
||||
def generate_livekit_config(
|
||||
room_id: str,
|
||||
user,
|
||||
username: str,
|
||||
is_admin_or_owner: bool,
|
||||
color: Optional[str] = None,
|
||||
configuration: Optional[dict] = None,
|
||||
participant_id: Optional[str] = None,
|
||||
room_id: str, user, username: str, color: Optional[str] = None
|
||||
) -> dict:
|
||||
"""Generate LiveKit configuration for room access.
|
||||
|
||||
@@ -136,31 +102,15 @@ def generate_livekit_config(
|
||||
room_id: Room identifier
|
||||
user: User instance requesting access
|
||||
username: Display name in room
|
||||
is_admin_or_owner (bool): Whether the user has admin/owner privileges for this room.
|
||||
color (Optional[str]): Optional color to associate with the participant.
|
||||
configuration (Optional[dict]): Room configuration dict that can override default settings.
|
||||
participant_id (Optional[str]): Stable identifier for anonymous users;
|
||||
used as identity when user.is_anonymous.
|
||||
|
||||
Returns:
|
||||
dict: LiveKit configuration with URL, room and access token
|
||||
"""
|
||||
|
||||
sources = None
|
||||
if configuration is not None:
|
||||
sources = configuration.get("can_publish_sources", None)
|
||||
|
||||
return {
|
||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||
"room": room_id,
|
||||
"token": generate_token(
|
||||
room=room_id,
|
||||
user=user,
|
||||
username=username,
|
||||
color=color,
|
||||
sources=sources,
|
||||
is_admin_or_owner=is_admin_or_owner,
|
||||
participant_id=participant_id,
|
||||
room=room_id, user=user, username=username, color=color
|
||||
),
|
||||
}
|
||||
|
||||
@@ -208,87 +158,3 @@ def create_livekit_client(custom_configuration=None):
|
||||
configuration = custom_configuration or settings.LIVEKIT_CONFIGURATION
|
||||
|
||||
return LiveKitAPI(session=custom_session, **configuration)
|
||||
|
||||
|
||||
class NotificationError(Exception):
|
||||
"""Notification delivery to room participants fails."""
|
||||
|
||||
|
||||
@async_to_sync
|
||||
async def notify_participants(room_name: str, notification_data: dict):
|
||||
"""Send notification data to all participants in a LiveKit room."""
|
||||
|
||||
lkapi = create_livekit_client()
|
||||
|
||||
try:
|
||||
room_response = await lkapi.room.list_rooms(
|
||||
ListRoomsRequest(
|
||||
names=[room_name],
|
||||
)
|
||||
)
|
||||
|
||||
# Check if the room exists
|
||||
if not room_response.rooms:
|
||||
return
|
||||
|
||||
await lkapi.room.send_data(
|
||||
SendDataRequest(
|
||||
room=room_name,
|
||||
data=json.dumps(notification_data).encode("utf-8"),
|
||||
kind="RELIABLE",
|
||||
)
|
||||
)
|
||||
except TwirpError as e:
|
||||
raise NotificationError("Failed to notify room participants") from e
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
|
||||
ALPHANUMERIC_CHARSET = string.ascii_letters + string.digits
|
||||
|
||||
|
||||
def generate_secure_token(length: int = 30, charset: str = ALPHANUMERIC_CHARSET) -> str:
|
||||
"""Generate a cryptographically secure random token.
|
||||
|
||||
Uses SystemRandom for proper entropy, suitable for OAuth tokens
|
||||
and API credentials that must be non-guessable.
|
||||
|
||||
Inspired by: https://github.com/oauthlib/oauthlib/blob/master/oauthlib/common.py
|
||||
|
||||
Args:
|
||||
length: Token length in characters (default: 30)
|
||||
charset: Character set to use for generation
|
||||
|
||||
Returns:
|
||||
Cryptographically secure random token
|
||||
"""
|
||||
return "".join(secrets.choice(charset) for _ in range(length))
|
||||
|
||||
|
||||
def generate_client_id() -> str:
|
||||
"""Generate a unique client ID for application authentication.
|
||||
|
||||
Returns:
|
||||
Random client ID string
|
||||
"""
|
||||
return generate_secure_token(settings.APPLICATION_CLIENT_ID_LENGTH)
|
||||
|
||||
|
||||
def generate_client_secret() -> str:
|
||||
"""Generate a secure client secret for application authentication.
|
||||
|
||||
Returns:
|
||||
Cryptographically secure client secret
|
||||
"""
|
||||
return generate_secure_token(settings.APPLICATION_CLIENT_SECRET_LENGTH)
|
||||
|
||||
|
||||
def generate_room_slug():
|
||||
"""Generate a random room slug in the format 'xxx-xxxx-xxx'."""
|
||||
|
||||
sizes = [3, 4, 3]
|
||||
parts = [
|
||||
"".join(secrets.choice(string.ascii_lowercase) for _ in range(size))
|
||||
for size in sizes
|
||||
]
|
||||
return "-".join(parts)
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-07-11 11:33+0000\n"
|
||||
"POT-Creation-Date: 2025-05-26 10:50+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -29,15 +29,15 @@ msgstr "Berechtigungen"
|
||||
msgid "Important dates"
|
||||
msgstr "Wichtige Daten"
|
||||
|
||||
#: core/admin.py:147
|
||||
#: core/admin.py:143
|
||||
msgid "No owner"
|
||||
msgstr "Kein Eigentümer"
|
||||
|
||||
#: core/admin.py:150
|
||||
#: core/admin.py:146
|
||||
msgid "Multiple owners"
|
||||
msgstr "Mehrere Eigentümer"
|
||||
|
||||
#: core/api/serializers.py:67
|
||||
#: core/api/serializers.py:63
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr ""
|
||||
"Sie müssen Administrator oder Eigentümer eines Raums sein, um Zugriffe "
|
||||
@@ -251,8 +251,7 @@ msgstr "PIN-Code für den Raum"
|
||||
|
||||
#: core/models.py:392
|
||||
msgid "Unique n-digit code that identifies this room in telephony mode."
|
||||
msgstr ""
|
||||
"Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
|
||||
msgstr "Eindeutiger n-stelliger Code, der diesen Raum im Telephonmodus identifiziert."
|
||||
|
||||
#: core/models.py:398 core/models.py:552
|
||||
msgid "Room"
|
||||
@@ -367,9 +366,9 @@ msgid "Make sure you have a stable internet connection"
|
||||
msgstr "Stellen Sie sicher, dass Sie eine stabile Internetverbindung haben"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:248
|
||||
#: core/templates/mail/html/screen_recording.html:245
|
||||
#: core/templates/mail/html/screen_recording.html:240
|
||||
#: core/templates/mail/text/invitation.txt:21
|
||||
#: core/templates/mail/text/screen_recording.txt:23
|
||||
#: core/templates/mail/text/screen_recording.txt:22
|
||||
#, python-format
|
||||
msgid " Thank you for using %(brandname)s. "
|
||||
msgstr " Vielen Dank für die Nutzung von %(brandname)s. "
|
||||
@@ -395,41 +394,33 @@ msgstr ""
|
||||
msgid " The recording will expire in %(days)s days. "
|
||||
msgstr " Die Aufzeichnung wird in %(days)s Tagen ablaufen. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:200
|
||||
#: core/templates/mail/text/screen_recording.txt:9
|
||||
msgid ""
|
||||
" Sharing the recording via link is not yet available. Only organizers can "
|
||||
"download it. "
|
||||
msgstr ""
|
||||
" Die Freigabe der Aufzeichnung per Link ist noch nicht verfügbar. Nur Organisatoren können sie herunterladen. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:206
|
||||
#: core/templates/mail/text/screen_recording.txt:11
|
||||
#: core/templates/mail/html/screen_recording.html:201
|
||||
#: core/templates/mail/text/screen_recording.txt:10
|
||||
msgid "To keep this recording permanently:"
|
||||
msgstr "So speichern Sie diese Aufzeichnung dauerhaft:"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:208
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
#: core/templates/mail/html/screen_recording.html:203
|
||||
#: core/templates/mail/text/screen_recording.txt:12
|
||||
msgid "Click the \"Open\" button below "
|
||||
msgstr "Klicken Sie auf den Button „Öffnen“ unten "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:209
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
#: core/templates/mail/html/screen_recording.html:204
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
msgid "Use the \"Download\" button in the interface "
|
||||
msgstr "Verwenden Sie den Button „Herunterladen“ in der Oberfläche "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:210
|
||||
#: core/templates/mail/text/screen_recording.txt:15
|
||||
#: core/templates/mail/html/screen_recording.html:205
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
msgid "Save the file to your preferred location"
|
||||
msgstr "Speichern Sie die Datei an einem gewünschten Ort"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:221
|
||||
#: core/templates/mail/text/screen_recording.txt:17
|
||||
#: core/templates/mail/html/screen_recording.html:216
|
||||
#: core/templates/mail/text/screen_recording.txt:16
|
||||
msgid "Open"
|
||||
msgstr "Öffnen"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:230
|
||||
#: core/templates/mail/text/screen_recording.txt:19
|
||||
#: core/templates/mail/html/screen_recording.html:225
|
||||
#: core/templates/mail/text/screen_recording.txt:18
|
||||
#, python-format
|
||||
msgid ""
|
||||
" If you have any questions or need assistance, please contact our support "
|
||||
@@ -438,18 +429,18 @@ msgstr ""
|
||||
" Wenn Sie Fragen haben oder Unterstützung benötigen, wenden Sie sich bitte "
|
||||
"an unser Support-Team unter %(support_email)s. "
|
||||
|
||||
#: meet/settings.py:163
|
||||
#: meet/settings.py:162
|
||||
msgid "English"
|
||||
msgstr "Englisch"
|
||||
|
||||
#: meet/settings.py:164
|
||||
#: meet/settings.py:163
|
||||
msgid "French"
|
||||
msgstr "Französisch"
|
||||
|
||||
#: meet/settings.py:165
|
||||
#: meet/settings.py:164
|
||||
msgid "Dutch"
|
||||
msgstr "Niederländisch"
|
||||
|
||||
#: meet/settings.py:166
|
||||
#: meet/settings.py:165
|
||||
msgid "German"
|
||||
msgstr "Deutsch"
|
||||
|
||||
Binary file not shown.
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2025-07-11 11:33+0000\n"
|
||||
"POT-Creation-Date: 2025-05-26 10:50+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -29,15 +29,15 @@ msgstr "Permissions"
|
||||
msgid "Important dates"
|
||||
msgstr "Important dates"
|
||||
|
||||
#: core/admin.py:147
|
||||
#: core/admin.py:143
|
||||
msgid "No owner"
|
||||
msgstr "No owner"
|
||||
|
||||
#: core/admin.py:150
|
||||
#: core/admin.py:146
|
||||
msgid "Multiple owners"
|
||||
msgstr "Multiple owners"
|
||||
|
||||
#: core/api/serializers.py:67
|
||||
#: core/api/serializers.py:63
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr "You must be administrator or owner of a room to add accesses to it."
|
||||
|
||||
@@ -361,9 +361,9 @@ msgid "Make sure you have a stable internet connection"
|
||||
msgstr "Make sure you have a stable internet connection"
|
||||
|
||||
#: core/templates/mail/html/invitation.html:248
|
||||
#: core/templates/mail/html/screen_recording.html:245
|
||||
#: core/templates/mail/html/screen_recording.html:240
|
||||
#: core/templates/mail/text/invitation.txt:21
|
||||
#: core/templates/mail/text/screen_recording.txt:23
|
||||
#: core/templates/mail/text/screen_recording.txt:22
|
||||
#, python-format
|
||||
msgid " Thank you for using %(brandname)s. "
|
||||
msgstr " Thank you for using %(brandname)s. "
|
||||
@@ -389,42 +389,33 @@ msgstr ""
|
||||
msgid " The recording will expire in %(days)s days. "
|
||||
msgstr " The recording will expire in %(days)s days. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:200
|
||||
#: core/templates/mail/text/screen_recording.txt:9
|
||||
msgid ""
|
||||
" Sharing the recording via link is not yet available. Only organizers can "
|
||||
"download it. "
|
||||
msgstr ""
|
||||
" Sharing the recording via link is not yet available. Only organizers can "
|
||||
"download it. "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:206
|
||||
#: core/templates/mail/text/screen_recording.txt:11
|
||||
#: core/templates/mail/html/screen_recording.html:201
|
||||
#: core/templates/mail/text/screen_recording.txt:10
|
||||
msgid "To keep this recording permanently:"
|
||||
msgstr "To keep this recording permanently:"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:208
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
#: core/templates/mail/html/screen_recording.html:203
|
||||
#: core/templates/mail/text/screen_recording.txt:12
|
||||
msgid "Click the \"Open\" button below "
|
||||
msgstr "Click the \"Open\" button below "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:209
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
#: core/templates/mail/html/screen_recording.html:204
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
msgid "Use the \"Download\" button in the interface "
|
||||
msgstr "Use the \"Download\" button in the interface "
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:210
|
||||
#: core/templates/mail/text/screen_recording.txt:15
|
||||
#: core/templates/mail/html/screen_recording.html:205
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
msgid "Save the file to your preferred location"
|
||||
msgstr "Save the file to your preferred location"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:221
|
||||
#: core/templates/mail/text/screen_recording.txt:17
|
||||
#: core/templates/mail/html/screen_recording.html:216
|
||||
#: core/templates/mail/text/screen_recording.txt:16
|
||||
msgid "Open"
|
||||
msgstr "Open"
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:230
|
||||
#: core/templates/mail/text/screen_recording.txt:19
|
||||
#: core/templates/mail/html/screen_recording.html:225
|
||||
#: core/templates/mail/text/screen_recording.txt:18
|
||||
#, python-format
|
||||
msgid ""
|
||||
" If you have any questions or need assistance, please contact our support "
|
||||
@@ -433,18 +424,18 @@ msgstr ""
|
||||
" If you have any questions or need assistance, please contact our support "
|
||||
"team at %(support_email)s. "
|
||||
|
||||
#: meet/settings.py:163
|
||||
#: meet/settings.py:162
|
||||
msgid "English"
|
||||
msgstr "English"
|
||||
|
||||
#: meet/settings.py:164
|
||||
#: meet/settings.py:163
|
||||
msgid "French"
|
||||
msgstr "French"
|
||||
|
||||
#: meet/settings.py:165
|
||||
#: meet/settings.py:164
|
||||
msgid "Dutch"
|
||||
msgstr "Dutch"
|
||||
|
||||
#: meet/settings.py:166
|
||||
#: meet/settings.py:165
|
||||
msgid "German"
|
||||
msgstr "German"
|
||||
|
||||
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user