mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-15 13:13:56 +00:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7fbcbc89ed | |||
| 89f8480e0b | |||
| d9bf6efa2a | |||
| 4cb7412194 | |||
| e8df597055 | |||
| 05dfcd11ca | |||
| b018832fcf | |||
| a269160f6f | |||
| c3afa84d9b | |||
| 8c6752a29f | |||
| 4c57432a03 | |||
| 3b7bfd999c | |||
| 7f386b2e2f | |||
| c55d8235fd | |||
| c7b23abd68 | |||
| 5a641a4366 | |||
| f043ad6f98 | |||
| 1141c1cecd | |||
| 33792b050a | |||
| f4569c64e5 | |||
| 6a00d3d087 | |||
| 2e975e2643 | |||
| 4c63aa827f | |||
| 67e9bf2fef | |||
| 7124167947 | |||
| 759388c72f | |||
| a663b4dc76 | |||
| 73aa162dc8 | |||
| a3851842e9 | |||
| 77964c6a74 | |||
| 72b863e794 | |||
| 0e3c978af2 |
@@ -0,0 +1,29 @@
|
|||||||
|
# /!\
|
||||||
|
# Security Note: This action is not hardened against prompt injection attacks and should only be used
|
||||||
|
# to review trusted PRs. Configure your repository with "Require approval for all external contributors"
|
||||||
|
# to ensure workflows only run after a maintainer has reviewed the PR.
|
||||||
|
name: Security Review
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pull-requests: write # Needed for leaving PR comments
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- 'main'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
security:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- uses: anthropics/claude-code-security-review@0c6a49f1fa56a1d472575da86a94dbc1edb78eda
|
||||||
|
with:
|
||||||
|
comment-pr: true
|
||||||
|
exclude-directories: docs,gitlint,LICENSES,bin
|
||||||
|
claude-api-key: ${{ secrets.CLAUDE_API_KEY }}
|
||||||
+2
-7
@@ -14,14 +14,11 @@ and this project adheres to
|
|||||||
- ✨(frontend) add configurable documentation menu item
|
- ✨(frontend) add configurable documentation menu item
|
||||||
- ✨(frontend) allow promoting authenticated participants
|
- ✨(frontend) allow promoting authenticated participants
|
||||||
- ✨(frontend) introduce an "unauthenticated" participant badge
|
- ✨(frontend) introduce an "unauthenticated" participant badge
|
||||||
- ✨(backend) add roomkit viewset to start a room without WebRTC join
|
|
||||||
- ✨(frontend) let users set default configuration for generated links
|
|
||||||
- ✨(frontend) expose media state to external gateways
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- ⬆️(frontend) upgrade @mediapipe/tasks-vision from 0.10.14 to 0.10.35
|
- ⬆️(frontend) upgrade @mediapipe/tasks-vision from 0.10.14 to 0.10.35
|
||||||
- ⬆️(frontend) upgrade i18next from 26.3.1 to 26.3.6
|
- ⬆️(frontend) upgrade i18next from 26.3.1 to 26.3.4
|
||||||
- ⬆️(frontend) upgrade posthog-js from 1.391.2 to 1.395.0
|
- ⬆️(frontend) upgrade posthog-js from 1.391.2 to 1.395.0
|
||||||
- ⬆️(frontend) upgrade @tanstack/react-query from 5.101.0 to 5.101.1
|
- ⬆️(frontend) upgrade @tanstack/react-query from 5.101.0 to 5.101.1
|
||||||
- ⬆️(frontend) upgrade livekit-client from 2.19.2 to 2.20.0
|
- ⬆️(frontend) upgrade livekit-client from 2.19.2 to 2.20.0
|
||||||
@@ -29,9 +26,8 @@ and this project adheres to
|
|||||||
- 📝(legal) update terms of service
|
- 📝(legal) update terms of service
|
||||||
- 💄(frontend) render Avatar initials in uppercase
|
- 💄(frontend) render Avatar initials in uppercase
|
||||||
- 💄(frontend) improve participant name rendering in the list
|
- 💄(frontend) improve participant name rendering in the list
|
||||||
- 🚚(backend) rename TelephonyService to SIPManagement
|
|
||||||
|
|
||||||
### Fixed
|
## Fixed
|
||||||
|
|
||||||
- 🐛(transcription) fix silent bug in speaker assignment
|
- 🐛(transcription) fix silent bug in speaker assignment
|
||||||
- 🐛(summary) extend tasks auto retry logic
|
- 🐛(summary) extend tasks auto retry logic
|
||||||
@@ -40,7 +36,6 @@ and this project adheres to
|
|||||||
- 🐛(backend) allow any string as sub in the API serializer
|
- 🐛(backend) allow any string as sub in the API serializer
|
||||||
- 🐛(frontend) fall back to user.full_name on request-entry
|
- 🐛(frontend) fall back to user.full_name on request-entry
|
||||||
- 🚸(frontend) show two initials in the Avatar when possible
|
- 🚸(frontend) show two initials in the Avatar when possible
|
||||||
- 🩹(all) clear the SonarCloud reliability finding and the lint debt
|
|
||||||
|
|
||||||
## [1.24.0] - 2026-07-21
|
## [1.24.0] - 2026-07-21
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ COMPOSE_EXEC = $(COMPOSE) exec
|
|||||||
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
|
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
|
||||||
COMPOSE_RUN = $(COMPOSE) run --rm
|
COMPOSE_RUN = $(COMPOSE) run --rm
|
||||||
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
|
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
|
||||||
COMPOSE_RUN_LINT = $(COMPOSE_RUN) --no-deps app-dev
|
|
||||||
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
|
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
|
||||||
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
|
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
|
||||||
|
|
||||||
@@ -52,14 +51,6 @@ WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT
|
|||||||
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
||||||
MAIL_NPM = $(COMPOSE_RUN) -w /app/src/mail node npm
|
MAIL_NPM = $(COMPOSE_RUN) -w /app/src/mail node npm
|
||||||
|
|
||||||
# -- Linters
|
|
||||||
LINT_RUFF_FORMAT = ruff format .
|
|
||||||
LINT_RUFF_CHECK = ruff check . --fix
|
|
||||||
LINT_PYLINT = pylint meet demo core
|
|
||||||
LINT_BACK = echo 'lint:ruff-format started…' && $(LINT_RUFF_FORMAT) \
|
|
||||||
&& echo 'lint:ruff-check started…' && $(LINT_RUFF_CHECK) \
|
|
||||||
&& echo 'lint:pylint started…' && $(LINT_PYLINT)
|
|
||||||
|
|
||||||
# -- Frontend
|
# -- Frontend
|
||||||
PATH_FRONT = ./src/frontend
|
PATH_FRONT = ./src/frontend
|
||||||
|
|
||||||
@@ -133,7 +124,6 @@ logs: ## display app-dev logs (follow mode)
|
|||||||
run-backend: ## start only the backend application and all needed services
|
run-backend: ## start only the backend application and all needed services
|
||||||
@$(COMPOSE) up --force-recreate -d celery-dev --remove-orphans
|
@$(COMPOSE) up --force-recreate -d celery-dev --remove-orphans
|
||||||
@$(COMPOSE) up --force-recreate -d nginx
|
@$(COMPOSE) up --force-recreate -d nginx
|
||||||
@$(COMPOSE) up -d livekit
|
|
||||||
@echo "Wait for postgresql to be up..."
|
@echo "Wait for postgresql to be up..."
|
||||||
@$(WAIT_DB)
|
@$(WAIT_DB)
|
||||||
.PHONY: run-backend
|
.PHONY: run-backend
|
||||||
@@ -198,23 +188,27 @@ demo: ## flush db then create a demo for load testing purpose
|
|||||||
@$(MANAGE) create_demo
|
@$(MANAGE) create_demo
|
||||||
.PHONY: demo
|
.PHONY: demo
|
||||||
|
|
||||||
|
# Nota bene: Black should come after isort just in case they don't agree...
|
||||||
lint: ## lint back-end python sources
|
lint: ## lint back-end python sources
|
||||||
@$(COMPOSE_RUN_LINT) sh -c "$(LINT_BACK)"
|
lint: \
|
||||||
|
lint-ruff-format \
|
||||||
|
lint-ruff-check \
|
||||||
|
lint-pylint
|
||||||
.PHONY: lint
|
.PHONY: lint
|
||||||
|
|
||||||
lint-ruff-format: ## format back-end python sources with ruff
|
lint-ruff-format: ## format back-end python sources with ruff
|
||||||
@echo 'lint:ruff-format started…'
|
@echo 'lint:ruff-format started…'
|
||||||
@$(COMPOSE_RUN_LINT) $(LINT_RUFF_FORMAT)
|
@$(COMPOSE_RUN_APP) ruff format .
|
||||||
.PHONY: lint-ruff-format
|
.PHONY: lint-ruff-format
|
||||||
|
|
||||||
lint-ruff-check: ## lint back-end python sources with ruff
|
lint-ruff-check: ## lint back-end python sources with ruff
|
||||||
@echo 'lint:ruff-check started…'
|
@echo 'lint:ruff-check started…'
|
||||||
@$(COMPOSE_RUN_LINT) $(LINT_RUFF_CHECK)
|
@$(COMPOSE_RUN_APP) ruff check . --fix
|
||||||
.PHONY: lint-ruff-check
|
.PHONY: lint-ruff-check
|
||||||
|
|
||||||
lint-pylint: ## lint back-end python sources with pylint only on changed files from main
|
lint-pylint: ## lint back-end python sources with pylint only on changed files from main
|
||||||
@echo 'lint:pylint started…'
|
@echo 'lint:pylint started…'
|
||||||
@$(COMPOSE_RUN_LINT) $(LINT_PYLINT)
|
@$(COMPOSE_RUN_APP) pylint meet demo core
|
||||||
.PHONY: lint-pylint
|
.PHONY: lint-pylint
|
||||||
|
|
||||||
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"`
|
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"`
|
||||||
@@ -395,6 +389,12 @@ build-k8s-cluster: \
|
|||||||
./bin/start-kind.sh
|
./bin/start-kind.sh
|
||||||
.PHONY: build-k8s-cluster
|
.PHONY: build-k8s-cluster
|
||||||
|
|
||||||
|
build-k8s-cluster-orbstack: ## setup the kubernetes environment on OrbStack's built-in cluster (macOS)
|
||||||
|
build-k8s-cluster-orbstack: \
|
||||||
|
env.d/development/kube-secret
|
||||||
|
./bin/start-orbstack.sh
|
||||||
|
.PHONY: build-k8s-cluster-orbstack
|
||||||
|
|
||||||
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
|
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
|
||||||
DEV_ENV=dev-keycloak tilt up --namespace=meet -f ./bin/Tiltfile
|
DEV_ENV=dev-keycloak tilt up --namespace=meet -f ./bin/Tiltfile
|
||||||
.PHONY: build-k8s-cluster
|
.PHONY: build-k8s-cluster
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
|
load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
|
||||||
load('ext://namespace', 'namespace_create', 'namespace_inject')
|
load('ext://namespace', 'namespace_create', 'namespace_inject')
|
||||||
|
|
||||||
|
# OrbStack's built-in cluster (macOS) is a supported alternative to kind.
|
||||||
|
# Recent Tilt versions (>= 0.33) detect it as a local dev cluster; this is
|
||||||
|
# a no-op for kind and a safety net for older Tilt versions.
|
||||||
|
allow_k8s_contexts('orbstack')
|
||||||
|
|
||||||
namespace_create('meet')
|
namespace_create('meet')
|
||||||
|
|
||||||
DEV_ENV = os.getenv('DEV_ENV', 'dev-keycloak')
|
DEV_ENV = os.getenv('DEV_ENV', 'dev-keycloak')
|
||||||
|
|||||||
Executable
+182
@@ -0,0 +1,182 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
#
|
||||||
|
# Bootstrap the local dev environment on OrbStack's built-in Kubernetes
|
||||||
|
# cluster (macOS) instead of kind.
|
||||||
|
#
|
||||||
|
# This replicates what bin/start-kind.sh (numerique-gouv/tools
|
||||||
|
# kind/create_cluster.sh) provides, minus what OrbStack makes unnecessary:
|
||||||
|
# - no kind cluster: OrbStack ships a lightweight single-node cluster
|
||||||
|
# - no local registry (kind-registry): OrbStack's cluster shares the
|
||||||
|
# Docker image store, so images built by Tilt are directly visible
|
||||||
|
# to pods. Tilt detects the "orbstack" context as a local cluster
|
||||||
|
# and skips pushing images entirely.
|
||||||
|
#
|
||||||
|
# Requirements: OrbStack (with Kubernetes enabled), kubectl, mkcert, curl.
|
||||||
|
set -o errexit
|
||||||
|
|
||||||
|
APPLICATION=${1:-meet}
|
||||||
|
CONTEXT="orbstack"
|
||||||
|
|
||||||
|
echo "0. Check OrbStack Kubernetes is available"
|
||||||
|
if ! command -v mkcert >/dev/null 2>&1; then
|
||||||
|
echo "❌ mkcert is not installed. Install it first: brew install mkcert"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! kubectl config get-contexts -o name | grep -qx "${CONTEXT}"; then
|
||||||
|
echo "Context '${CONTEXT}' not found. Trying to start OrbStack Kubernetes..."
|
||||||
|
if command -v orb >/dev/null 2>&1; then
|
||||||
|
orb start k8s
|
||||||
|
else
|
||||||
|
echo "❌ Enable Kubernetes in OrbStack (Settings > Kubernetes) and retry."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
kubectl config use-context "${CONTEXT}"
|
||||||
|
|
||||||
|
echo "0b. Check ports 80/443 are free on localhost"
|
||||||
|
# OrbStack forwards LoadBalancer service ports to 127.0.0.1. If the kind
|
||||||
|
# cluster is still running, its docker proxy already holds 80/443.
|
||||||
|
# Skip the check if ingress-nginx is already installed here: in that case
|
||||||
|
# the listener on 80/443 is our own LoadBalancer.
|
||||||
|
if ! kubectl -n ingress-nginx get deployment ingress-nginx-controller >/dev/null 2>&1; then
|
||||||
|
for port in 80 443; do
|
||||||
|
if lsof -nP -iTCP:"${port}" -sTCP:LISTEN >/dev/null 2>&1; then
|
||||||
|
echo "❌ Port ${port} is already in use on the host."
|
||||||
|
echo " If the kind cluster is running, delete it first:"
|
||||||
|
echo " kind delete cluster --name suite"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "1. Create ca"
|
||||||
|
CURRENT_DIR=$(pwd)
|
||||||
|
mkcert -install
|
||||||
|
cd /tmp
|
||||||
|
mkcert "127.0.0.1.nip.io" "*.127.0.0.1.nip.io"
|
||||||
|
cd "${CURRENT_DIR}"
|
||||||
|
|
||||||
|
echo "2. Install ingress-nginx (cloud provider: LoadBalancer service)"
|
||||||
|
# OrbStack exposes LoadBalancer services on 127.0.0.1, so the cloud
|
||||||
|
# manifest replaces kind's hostPort-based deploy. Every sub-step below is
|
||||||
|
# guarded individually so the script is safe to re-run after a partial
|
||||||
|
# failure (unlike the upstream kind script, which guards the whole block
|
||||||
|
# on namespace existence).
|
||||||
|
|
||||||
|
# Make sure no stale registry configmap tells Tilt to push to localhost:5001
|
||||||
|
# (there is no registry on OrbStack).
|
||||||
|
kubectl -n kube-public delete configmap local-registry-hosting --ignore-not-found
|
||||||
|
|
||||||
|
if ! kubectl -n ingress-nginx get deployment ingress-nginx-controller >/dev/null 2>&1; then
|
||||||
|
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/cloud/deploy.yaml
|
||||||
|
fi
|
||||||
|
if ! kubectl -n ingress-nginx get deployment nginx-errors >/dev/null 2>&1; then
|
||||||
|
kubectl apply -n ingress-nginx -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/refs/heads/main/docs/examples/customization/custom-errors/custom-default-backend.yaml
|
||||||
|
fi
|
||||||
|
kubectl -n ingress-nginx create secret tls mkcert --key /tmp/127.0.0.1.nip.io+1-key.pem --cert /tmp/127.0.0.1.nip.io+1.pem || echo ok
|
||||||
|
|
||||||
|
# The meet charts render Ingresses without ingressClassName. The kind
|
||||||
|
# provider manifest handles this via --watch-ingress-without-class=true;
|
||||||
|
# the cloud manifest does not, so add it here (otherwise: 404 everywhere).
|
||||||
|
if ! kubectl -n ingress-nginx get deployment ingress-nginx-controller -o jsonpath='{.spec.template.spec.containers[0].args}' | grep -q 'watch-ingress-without-class'; then
|
||||||
|
kubectl -n ingress-nginx patch deployments.apps ingress-nginx-controller --type 'json' -p '[{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--watch-ingress-without-class=true"},{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--default-ssl-certificate=ingress-nginx/mkcert"},{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--default-backend-service=ingress-nginx/nginx-errors"}
|
||||||
|
]'
|
||||||
|
fi
|
||||||
|
if ! kubectl -n ingress-nginx get deployment nginx-errors -o jsonpath='{.spec.template.spec.containers[0].image}' | grep -q 'error-pages'; then
|
||||||
|
kubectl -n ingress-nginx patch deployment nginx-errors --type=json -p='[
|
||||||
|
{"op": "replace", "path": "/spec/template/spec/containers/0/image", "value": "ghcr.io/tarampampam/error-pages:3.3.0"},
|
||||||
|
{"op": "add", "path": "/spec/template/spec/containers/0/env", "value": [{"name": "TEMPLATE_NAME", "value": "ghost"}, {"name": "SHOW_DETAILS", "value": "false"}, {"name": "SEND_SAME_HTTP_CODE", "value": "true"}]}
|
||||||
|
]'
|
||||||
|
fi
|
||||||
|
cat <<EOF | kubectl apply -n ingress-nginx -f -
|
||||||
|
apiVersion: v1
|
||||||
|
data:
|
||||||
|
allow-snippet-annotations: "true"
|
||||||
|
annotations-risk-level: Critical
|
||||||
|
custom-http-errors: 500,501,502,503,504
|
||||||
|
kind: ConfigMap
|
||||||
|
metadata:
|
||||||
|
name: ingress-nginx-controller
|
||||||
|
namespace: ingress-nginx
|
||||||
|
EOF
|
||||||
|
|
||||||
|
echo "2b. Wait for the ingress controller to be ready"
|
||||||
|
kubectl -n ingress-nginx rollout status deployment/ingress-nginx-controller --timeout=180s
|
||||||
|
|
||||||
|
echo "3. Patch CoreDNS so in-cluster pods resolve *.127.0.0.1.nip.io to the ingress"
|
||||||
|
# nip.io resolves to 127.0.0.1, which inside a pod is the pod itself.
|
||||||
|
# Rewrite these names to the ingress-nginx service, like the kind setup does.
|
||||||
|
# Unlike kind, we amend OrbStack's existing Corefile instead of replacing it.
|
||||||
|
if ! kubectl -n kube-system get configmap coredns -o jsonpath='{.data.Corefile}' | grep -q '127\.0\.0\.1\.nip\.io'; then
|
||||||
|
kubectl -n kube-system get configmap coredns -o jsonpath='{.data.Corefile}' \
|
||||||
|
| awk '/forward \./ && !done { print " rewrite stop {"; print " name regex (.*).127.0.0.1.nip.io ingress-nginx-controller.ingress-nginx.svc.cluster.local answer auto"; print " }"; done=1 } { print }' \
|
||||||
|
>/tmp/Corefile.orbstack
|
||||||
|
kubectl -n kube-system create configmap coredns --from-file=Corefile=/tmp/Corefile.orbstack --dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
kubectl -n kube-system rollout restart deployments/coredns
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! kubectl get ns "${APPLICATION}" >/dev/null 2>&1; then
|
||||||
|
echo "4. Setup namespace"
|
||||||
|
kubectl create ns "${APPLICATION}"
|
||||||
|
fi
|
||||||
|
kubectl config set-context --current --namespace="${APPLICATION}"
|
||||||
|
kubectl -n "${APPLICATION}" create secret generic mkcert --from-file=rootCA.pem="$(mkcert -CAROOT)/rootCA.pem" || echo ok
|
||||||
|
|
||||||
|
if ! kubectl get configmap certifi -n "${APPLICATION}" >/dev/null 2>&1; then
|
||||||
|
echo "5. Inject our custom CA in a configmap for certifi"
|
||||||
|
curl https://raw.githubusercontent.com/certifi/python-certifi/refs/heads/master/certifi/cacert.pem -o /tmp/cacert.pem
|
||||||
|
cat "$(mkcert -CAROOT)/rootCA.pem" >>/tmp/cacert.pem
|
||||||
|
kubectl -n "${APPLICATION}" create configmap certifi --from-file=cacert.pem=/tmp/cacert.pem
|
||||||
|
kubectl -n "${APPLICATION}" create secret generic certifi --from-file=/tmp/cacert.pem || echo ok
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "5b. Smoke test: the ingress chain answers on https://127.0.0.1"
|
||||||
|
# Before Tilt deploys the app this returns the styled 404 from the default
|
||||||
|
# backend — that still proves LB -> controller works. 000 means the
|
||||||
|
# LoadBalancer is not bound to localhost.
|
||||||
|
HTTP_CODE=$(curl -sk -o /dev/null -w '%{http_code}' --max-time 10 https://127.0.0.1/ || true)
|
||||||
|
if [ "${HTTP_CODE}" = "000" ]; then
|
||||||
|
echo "⚠️ Nothing answered on https://127.0.0.1 — check the LoadBalancer:"
|
||||||
|
echo " kubectl -n ingress-nginx get svc ingress-nginx-controller"
|
||||||
|
else
|
||||||
|
echo "✅ Ingress reachable (HTTP ${HTTP_CODE})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "6. Check pod readiness across all namespaces..."
|
||||||
|
|
||||||
|
sleep_interval=10
|
||||||
|
|
||||||
|
echo "Initial wait time: $((sleep_interval * 2)) seconds…"
|
||||||
|
sleep $((sleep_interval * 2))
|
||||||
|
|
||||||
|
check_pods_ready() {
|
||||||
|
local max_attempts=60 # Maximum number of attempts (10 minutes with 10s intervals)
|
||||||
|
local attempt=1
|
||||||
|
|
||||||
|
while [ $attempt -le $max_attempts ]; do
|
||||||
|
echo "Attempt $attempt/$max_attempts - Checking pod status..."
|
||||||
|
|
||||||
|
not_ready_count=$( kubectl get po -A --no-headers | grep -v -E "Running|Completed"| wc -l | tr -d ' ')
|
||||||
|
|
||||||
|
if [ "$not_ready_count" -eq 0 ]; then
|
||||||
|
echo "✅ All pods are ready!"
|
||||||
|
return 0
|
||||||
|
else
|
||||||
|
echo "⏳ $not_ready_count pod(s) still not ready. Waiting $sleep_interval seconds…"
|
||||||
|
sleep $sleep_interval
|
||||||
|
((attempt++))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "❌ Timeout: Some pods are still not ready after 10 minutes"
|
||||||
|
echo "Final pod status:"
|
||||||
|
kubectl get po -A
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if check_pods_ready; then
|
||||||
|
echo "🎉 Cluster is fully ready!"
|
||||||
|
else
|
||||||
|
echo "⚠️ Some pods may need manual intervention"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
+3
-2
@@ -85,6 +85,7 @@ services:
|
|||||||
- postgresql
|
- postgresql
|
||||||
- mailcatcher
|
- mailcatcher
|
||||||
- redis
|
- redis
|
||||||
|
- livekit
|
||||||
- createbuckets
|
- createbuckets
|
||||||
- createwebhook
|
- createwebhook
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
@@ -96,7 +97,7 @@ services:
|
|||||||
celery-dev:
|
celery-dev:
|
||||||
user: ${DOCKER_USER:-1000}
|
user: ${DOCKER_USER:-1000}
|
||||||
image: meet:backend-development
|
image: meet:backend-development
|
||||||
command: ["celery", "-A", "meet.celery_app", "worker", "-l", "DEBUG", "--pool=solo"]
|
command: ["celery", "-A", "meet.celery_app", "worker", "-l", "DEBUG"]
|
||||||
environment:
|
environment:
|
||||||
- DJANGO_CONFIGURATION=Development
|
- DJANGO_CONFIGURATION=Development
|
||||||
env_file:
|
env_file:
|
||||||
@@ -131,7 +132,7 @@ services:
|
|||||||
celery:
|
celery:
|
||||||
user: ${DOCKER_USER:-1000}
|
user: ${DOCKER_USER:-1000}
|
||||||
image: meet:backend-production
|
image: meet:backend-production
|
||||||
command: ["celery", "-A", "meet.celery_app", "worker", "-l", "INFO", "--pool=solo"]
|
command: ["celery", "-A", "meet.celery_app", "worker", "-l", "INFO"]
|
||||||
environment:
|
environment:
|
||||||
- DJANGO_CONFIGURATION=Demo
|
- DJANGO_CONFIGURATION=Demo
|
||||||
env_file:
|
env_file:
|
||||||
|
|||||||
@@ -143,3 +143,24 @@ $ 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://meet.127.0.0.1.nip.io/](https://meet.127.0.0.1.nip.io/).
|
||||||
|
|
||||||
|
### Alternative: OrbStack's built-in Kubernetes (macOS)
|
||||||
|
|
||||||
|
If you use [OrbStack](https://orbstack.dev/) on macOS, you can run the stack on its built-in Kubernetes cluster instead of kind. It uses noticeably less RAM (no nested kubeadm node container) and no local registry is needed: OrbStack's cluster shares the Docker image store, so Tilt uses images directly without pushing.
|
||||||
|
|
||||||
|
Enable Kubernetes in OrbStack (Settings > Kubernetes), then:
|
||||||
|
|
||||||
|
```shellscript
|
||||||
|
$ make build-k8s-cluster-orbstack
|
||||||
|
```
|
||||||
|
|
||||||
|
This installs ingress-nginx (exposed by OrbStack on `127.0.0.1:80/443`), the mkcert TLS certificates, and the CoreDNS rewrite for `*.127.0.0.1.nip.io`, then you start Tilt as usual:
|
||||||
|
|
||||||
|
```shellscript
|
||||||
|
$ make start-tilt-keycloak
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- Ports 80/443 must be free: delete the kind cluster first if you used it (`kind delete cluster --name suite`).
|
||||||
|
- If you "Reset Kubernetes" in OrbStack, re-run `make build-k8s-cluster-orbstack`.
|
||||||
|
- kind remains the reference setup (matches CI and lets you pin the Kubernetes version).
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ ALLOW_UNREGISTERED_ROOMS=False
|
|||||||
|
|
||||||
# Recording
|
# Recording
|
||||||
RECORDING_ENABLE=True
|
RECORDING_ENABLE=True
|
||||||
RECORDING_STORAGE_EVENT_ENABLE=False
|
RECORDING_STORAGE_EVENT_ENABLE=True
|
||||||
RECORDING_STORAGE_EVENT_TOKEN=password
|
RECORDING_STORAGE_EVENT_TOKEN=password
|
||||||
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe/
|
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe/
|
||||||
SUMMARY_SERVICE_API_TOKEN=password
|
SUMMARY_SERVICE_API_TOKEN=password
|
||||||
@@ -85,10 +85,6 @@ RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
|
|||||||
# Telephony
|
# Telephony
|
||||||
ROOM_TELEPHONY_ENABLED=True
|
ROOM_TELEPHONY_ENABLED=True
|
||||||
|
|
||||||
# RoomKit
|
|
||||||
# ROOMKIT_ENABLED = True
|
|
||||||
# ROOMKIT_SERVER_TO_SERVER_API_TOKEN = ThisIsAnExampleKeyForDevPurposeOnly
|
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
METADATA_COLLECTOR_ENABLED=True
|
METADATA_COLLECTOR_ENABLED=True
|
||||||
|
|
||||||
|
|||||||
Generated
+5
-5
@@ -10,7 +10,7 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "3.49.0",
|
"core-js": "3.49.0",
|
||||||
"i18next": "^26.3.6",
|
"i18next": "^26.3.4",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"regenerator-runtime": "0.14.1"
|
"regenerator-runtime": "0.14.1"
|
||||||
},
|
},
|
||||||
@@ -9364,9 +9364,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next": {
|
"node_modules/i18next": {
|
||||||
"version": "26.3.6",
|
"version": "26.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.4.tgz",
|
||||||
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
|
"integrity": "sha512-pa7m0d7pBDqGHZxljT+WPFeyFgQ7P7SciPPo1tTqYuO0z4sqADYhwnBESmmGp/wEof1inwdls/k8ZgTg8rxFHA==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -9383,7 +9383,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5 || ^6 || ^7"
|
"typescript": "^5 || ^6"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"typescript": {
|
"typescript": {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "3.49.0",
|
"core-js": "3.49.0",
|
||||||
"i18next": "26.3.6",
|
"i18next": "26.3.4",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"regenerator-runtime": "0.14.1"
|
"regenerator-runtime": "0.14.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,6 +8,3 @@ class AnalyticsEvent(StrEnum):
|
|||||||
|
|
||||||
# Rooms
|
# Rooms
|
||||||
ROOM_CREATED = "room_created"
|
ROOM_CREATED = "room_created"
|
||||||
|
|
||||||
# Roomkit (meeting-room SIP devices)
|
|
||||||
ROOMKIT_JOINED = "roomkit_joined"
|
|
||||||
|
|||||||
@@ -61,9 +61,6 @@ def get_frontend_configuration(request):
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
"telephony": build_telephony_config(),
|
"telephony": build_telephony_config(),
|
||||||
"resource": {
|
|
||||||
"default_access_level": settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
|
|
||||||
},
|
|
||||||
"subtitle": {"enabled": settings.ROOM_SUBTITLE_ENABLED},
|
"subtitle": {"enabled": settings.ROOM_SUBTITLE_ENABLED},
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ class FeatureFlag:
|
|||||||
"file_upload": "FILE_UPLOAD_ENABLED",
|
"file_upload": "FILE_UPLOAD_ENABLED",
|
||||||
"addons": "ADDONS_ENABLED",
|
"addons": "ADDONS_ENABLED",
|
||||||
"application": "APPLICATION_ENABLED",
|
"application": "APPLICATION_ENABLED",
|
||||||
"roomkit": "ROOMKIT_ENABLED",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -31,28 +31,9 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.User
|
model = models.User
|
||||||
fields = [
|
fields = ["id", "email", "full_name", "short_name", "timezone", "language"]
|
||||||
"id",
|
|
||||||
"email",
|
|
||||||
"full_name",
|
|
||||||
"short_name",
|
|
||||||
"timezone",
|
|
||||||
"language",
|
|
||||||
"default_room_access_level",
|
|
||||||
"default_room_configuration",
|
|
||||||
]
|
|
||||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
read_only_fields = ["id", "email", "full_name", "short_name"]
|
||||||
|
|
||||||
def validate_default_room_configuration(self, value):
|
|
||||||
"""Validate the default room configuration against the RoomConfiguration schema."""
|
|
||||||
if value is None or value == {}:
|
|
||||||
return value
|
|
||||||
try:
|
|
||||||
RoomConfiguration.model_validate(value)
|
|
||||||
except PydanticValidationError as e:
|
|
||||||
raise serializers.ValidationError(e.errors()) from e
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class UserLightSerializer(serializers.ModelSerializer):
|
class UserLightSerializer(serializers.ModelSerializer):
|
||||||
"""Serialize users with limited fields."""
|
"""Serialize users with limited fields."""
|
||||||
|
|||||||
@@ -73,15 +73,3 @@ class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
|
|||||||
"""Throttle Anonymous user requesting room generation callback"""
|
"""Throttle Anonymous user requesting room generation callback"""
|
||||||
|
|
||||||
scope = "creation_callback"
|
scope = "creation_callback"
|
||||||
|
|
||||||
|
|
||||||
class RoomKitJoinRateThrottle(MonitoredUserRateThrottle):
|
|
||||||
"""Throttle the LiveKit SIP module requesting roomkit joins.
|
|
||||||
|
|
||||||
The roomkit endpoints are authenticated as a machine user, so all requests
|
|
||||||
share a single throttle bucket. This is not a security measure against
|
|
||||||
brute-force attacks but a guard against accidental hammering from a buggy
|
|
||||||
SIP module.
|
|
||||||
"""
|
|
||||||
|
|
||||||
scope = "roomkit_join"
|
|
||||||
|
|||||||
@@ -308,27 +308,8 @@ class RoomViewSet(
|
|||||||
return drf_response.Response(serializer.data)
|
return drf_response.Response(serializer.data)
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
"""Set the current user as owner of the newly created room.
|
"""Set the current user as owner of the newly created room."""
|
||||||
|
room = serializer.save()
|
||||||
Apply the user's default room preferences (access level and configuration)
|
|
||||||
unless the request explicitly provides its own values.
|
|
||||||
"""
|
|
||||||
user = self.request.user
|
|
||||||
save_kwargs = {}
|
|
||||||
|
|
||||||
if (
|
|
||||||
"access_level" not in serializer.validated_data
|
|
||||||
and user.default_room_access_level not in (None, "")
|
|
||||||
):
|
|
||||||
save_kwargs["access_level"] = user.default_room_access_level
|
|
||||||
|
|
||||||
user_default_configuration = user.default_room_configuration
|
|
||||||
if not serializer.validated_data.get(
|
|
||||||
"configuration"
|
|
||||||
) and user_default_configuration not in (None, {}):
|
|
||||||
save_kwargs["configuration"] = user.default_room_configuration
|
|
||||||
|
|
||||||
room = serializer.save(**save_kwargs)
|
|
||||||
models.ResourceAccess.objects.create(
|
models.ResourceAccess.objects.create(
|
||||||
resource=room,
|
resource=room,
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
# Generated by Django 5.2.14 on 2026-08-03 13:40
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('core', '0021_recording_external_process_id_alter_recording_status'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='user',
|
|
||||||
name='default_room_access_level',
|
|
||||||
field=models.CharField(blank=True, choices=[('public', 'Public Access'), ('trusted', 'Trusted Access'), ('restricted', 'Restricted Access')], help_text='Access level applied by default to new rooms created by this user. When empty, the instance default is used.', max_length=50, null=True, verbose_name='default room access level'),
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='user',
|
|
||||||
name='default_room_configuration',
|
|
||||||
field=models.JSONField(blank=True, default=dict, help_text='Configurations applied by default to new rooms created by this user.', verbose_name='default room configuration'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -189,25 +189,6 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
|||||||
default=settings.TIME_ZONE,
|
default=settings.TIME_ZONE,
|
||||||
help_text=_("The timezone in which the user wants to see times."),
|
help_text=_("The timezone in which the user wants to see times."),
|
||||||
)
|
)
|
||||||
default_room_access_level = models.CharField(
|
|
||||||
max_length=50,
|
|
||||||
choices=RoomAccessLevel.choices,
|
|
||||||
blank=True,
|
|
||||||
null=True,
|
|
||||||
verbose_name=_("default room access level"),
|
|
||||||
help_text=_(
|
|
||||||
"Access level applied by default to new rooms created by this user. "
|
|
||||||
"When empty, the instance default is used."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
default_room_configuration = models.JSONField(
|
|
||||||
blank=True,
|
|
||||||
default=dict,
|
|
||||||
verbose_name=_("default room configuration"),
|
|
||||||
help_text=_(
|
|
||||||
"Configurations applied by default to new rooms created by this user."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
is_device = models.BooleanField(
|
is_device = models.BooleanField(
|
||||||
_("device"),
|
_("device"),
|
||||||
default=False,
|
default=False,
|
||||||
@@ -448,14 +429,7 @@ class Room(Resource):
|
|||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""Generate a unique n-digit pin code for new rooms."""
|
"""Generate a unique n-digit pin code for new rooms."""
|
||||||
|
if settings.ROOM_TELEPHONY_ENABLED and not self.pk and not self.pin_code:
|
||||||
# Roomkit devices also join by PIN, so a PIN is needed as soon as
|
|
||||||
# either integration is enabled.
|
|
||||||
if (
|
|
||||||
(settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED)
|
|
||||||
and not self.pk
|
|
||||||
and not self.pin_code
|
|
||||||
):
|
|
||||||
self.pin_code = self.generate_unique_pin_code(
|
self.pin_code = self.generate_unique_pin_code(
|
||||||
length=settings.ROOM_TELEPHONY_PIN_LENGTH
|
length=settings.ROOM_TELEPHONY_PIN_LENGTH
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
"""Meet core roomkit API endpoints for meeting-room (SIP) device integration."""
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
"""Authentication for the roomkit API of the Meet core app."""
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import secrets
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
from rest_framework.authentication import BaseAuthentication
|
|
||||||
from rest_framework.exceptions import AuthenticationFailed
|
|
||||||
|
|
||||||
from core.recording.event.authentication import MachineUser
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class ServerToServerAuthentication(BaseAuthentication):
|
|
||||||
"""Custom authentication class for roomkit server-to-server requests.
|
|
||||||
|
|
||||||
Validates the Authorization header against the roomkit server-to-server
|
|
||||||
token. A valid PIN code is intentionally not enough to authenticate: the
|
|
||||||
endpoints are restricted to the LiveKit SIP module's credentials.
|
|
||||||
"""
|
|
||||||
|
|
||||||
AUTH_HEADER = "Authorization"
|
|
||||||
TOKEN_TYPE = "Bearer" # noqa S105
|
|
||||||
|
|
||||||
def authenticate(self, request):
|
|
||||||
"""Validate the Bearer token from the Authorization header.
|
|
||||||
|
|
||||||
Returns a (MachineUser, token) pair on success, and raises
|
|
||||||
AuthenticationFailed if the header is missing, malformed, or contains
|
|
||||||
an invalid token.
|
|
||||||
"""
|
|
||||||
required_token = settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN
|
|
||||||
if not required_token:
|
|
||||||
raise AuthenticationFailed("Server-to-server token is not configured.")
|
|
||||||
|
|
||||||
auth_header = request.headers.get(self.AUTH_HEADER)
|
|
||||||
if not auth_header:
|
|
||||||
logger.warning(
|
|
||||||
"Roomkit authentication failed: missing Authorization header (ip: %s)",
|
|
||||||
request.META.get("REMOTE_ADDR"),
|
|
||||||
)
|
|
||||||
raise AuthenticationFailed("Authorization header is missing.")
|
|
||||||
|
|
||||||
# Validate token format and existence
|
|
||||||
auth_parts = auth_header.split(" ")
|
|
||||||
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
|
|
||||||
raise AuthenticationFailed("Invalid authorization header.")
|
|
||||||
|
|
||||||
token = auth_parts[1]
|
|
||||||
|
|
||||||
# Use constant-time comparison to prevent timing attacks
|
|
||||||
if not secrets.compare_digest(token.encode(), required_token.encode()):
|
|
||||||
logger.warning(
|
|
||||||
"Roomkit authentication failed: invalid token (ip: %s)",
|
|
||||||
request.META.get("REMOTE_ADDR"),
|
|
||||||
)
|
|
||||||
raise AuthenticationFailed("Invalid server-to-server token.")
|
|
||||||
|
|
||||||
return MachineUser(username="roomkit"), token
|
|
||||||
|
|
||||||
def authenticate_header(self, request):
|
|
||||||
"""Return the WWW-Authenticate header value."""
|
|
||||||
return f"{self.TOKEN_TYPE} realm='Roomkit server to server'"
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
"""Serializers for the roomkit API of the Meet core app."""
|
|
||||||
|
|
||||||
# pylint: disable=abstract-method
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
|
|
||||||
from rest_framework import serializers
|
|
||||||
|
|
||||||
from core.api.serializers import BaseValidationOnlySerializer
|
|
||||||
|
|
||||||
|
|
||||||
class RoomKitJoinSerializer(BaseValidationOnlySerializer):
|
|
||||||
"""Validate roomkit join requests from the LiveKit SIP module."""
|
|
||||||
|
|
||||||
pin_code = serializers.CharField(required=True)
|
|
||||||
|
|
||||||
def validate_pin_code(self, value):
|
|
||||||
"""Ensure the PIN code matches the configured length."""
|
|
||||||
if len(value) != settings.ROOM_TELEPHONY_PIN_LENGTH:
|
|
||||||
raise serializers.ValidationError("PIN code length is invalid.")
|
|
||||||
return value
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
"""Roomkit API endpoints for meeting-room (SIP) device integration."""
|
|
||||||
|
|
||||||
from logging import getLogger
|
|
||||||
|
|
||||||
from rest_framework import decorators, 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 analytics, models
|
|
||||||
from core.api import permissions, throttling
|
|
||||||
from core.api.feature_flag import FeatureFlag
|
|
||||||
from core.services.sip_management import SIPException, SIPManagement
|
|
||||||
|
|
||||||
from . import authentication, serializers
|
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
class RoomKitViewSet(viewsets.ViewSet):
|
|
||||||
"""Server-to-server API endpoints for the roomkit integration.
|
|
||||||
|
|
||||||
Groups all interactions between roomkit (SIP) devices and the backend,
|
|
||||||
brokered by the LiveKit SIP module. All endpoints are authenticated
|
|
||||||
with the roomkit server-to-server tokens.
|
|
||||||
"""
|
|
||||||
|
|
||||||
authentication_classes = [authentication.ServerToServerAuthentication]
|
|
||||||
permission_classes = [permissions.IsAuthenticated]
|
|
||||||
|
|
||||||
@decorators.action(
|
|
||||||
detail=False,
|
|
||||||
methods=["post"],
|
|
||||||
url_path="join",
|
|
||||||
throttle_classes=[throttling.RoomKitJoinRateThrottle],
|
|
||||||
)
|
|
||||||
@FeatureFlag.require("roomkit")
|
|
||||||
def join(self, request):
|
|
||||||
"""Prepare a room for a meeting-room (SIP) device joining by PIN code.
|
|
||||||
|
|
||||||
Called by the LiveKit SIP module when a meeting-room device dials in
|
|
||||||
with a PIN code before any WebRTC participant has joined. Resolves the
|
|
||||||
room by PIN and creates its SIP dispatch rule, so the device can enter
|
|
||||||
without waiting for a WebRTC user.
|
|
||||||
|
|
||||||
The webhook-based creation path is kept: both converge on the same rule
|
|
||||||
through the shared SIPManagement.
|
|
||||||
"""
|
|
||||||
|
|
||||||
serializer = serializers.RoomKitJoinSerializer(data=request.data)
|
|
||||||
serializer.is_valid(raise_exception=True)
|
|
||||||
|
|
||||||
try:
|
|
||||||
room = models.Room.objects.get(
|
|
||||||
pin_code=serializer.validated_data["pin_code"]
|
|
||||||
)
|
|
||||||
except models.Room.DoesNotExist as e:
|
|
||||||
raise drf_exceptions.NotFound("No room found for this PIN code.") from e
|
|
||||||
|
|
||||||
try:
|
|
||||||
created = SIPManagement().ensure_dispatch_rule(room)
|
|
||||||
except SIPException as e:
|
|
||||||
raise drf_exceptions.APIException("Could not create dispatch rule.") from e
|
|
||||||
|
|
||||||
analytics.capture(
|
|
||||||
request.user,
|
|
||||||
analytics.AnalyticsEvent.ROOMKIT_JOINED,
|
|
||||||
{
|
|
||||||
"room_id": str(room.pk),
|
|
||||||
"dispatch_rule_created": created,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
"Roomkit join requested: room_id=%s, dispatch_rule_created=%s",
|
|
||||||
room.id,
|
|
||||||
created,
|
|
||||||
)
|
|
||||||
|
|
||||||
return drf_response.Response(
|
|
||||||
{"status": "success"},
|
|
||||||
status=drf_status.HTTP_200_OK,
|
|
||||||
)
|
|
||||||
@@ -28,7 +28,7 @@ from .room_management import (
|
|||||||
RoomManagementException,
|
RoomManagementException,
|
||||||
RoomNotFoundException,
|
RoomNotFoundException,
|
||||||
)
|
)
|
||||||
from .sip_management import SIPException, SIPManagement
|
from .telephony import TelephonyException, TelephonyService
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ class LiveKitEventsService:
|
|||||||
)
|
)
|
||||||
self.webhook_receiver = api.WebhookReceiver(token_verifier)
|
self.webhook_receiver = api.WebhookReceiver(token_verifier)
|
||||||
self.lobby_service = LobbyService()
|
self.lobby_service = LobbyService()
|
||||||
self.sip_management = SIPManagement()
|
self.telephony_service = TelephonyService()
|
||||||
self.recording_events = RecordingEventsService()
|
self.recording_events = RecordingEventsService()
|
||||||
|
|
||||||
self._filter_regex = None
|
self._filter_regex = None
|
||||||
@@ -245,12 +245,12 @@ class LiveKitEventsService:
|
|||||||
except models.Room.DoesNotExist as err:
|
except models.Room.DoesNotExist as err:
|
||||||
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
|
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
|
||||||
|
|
||||||
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
|
if settings.ROOM_TELEPHONY_ENABLED:
|
||||||
try:
|
try:
|
||||||
self.sip_management.ensure_dispatch_rule(room)
|
self.telephony_service.create_dispatch_rule(room)
|
||||||
except SIPException as e:
|
except TelephonyException as e:
|
||||||
raise ActionFailedError(
|
raise ActionFailedError(
|
||||||
f"Failed to create sip dispatch rule for room {room_id}"
|
f"Failed to create telephony dispatch rule for room {room_id}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
def _handle_room_finished(self, data):
|
def _handle_room_finished(self, data):
|
||||||
@@ -265,12 +265,12 @@ class LiveKitEventsService:
|
|||||||
)
|
)
|
||||||
raise ActionFailedError("Failed to process room finished event") from e
|
raise ActionFailedError("Failed to process room finished event") from e
|
||||||
|
|
||||||
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
|
if settings.ROOM_TELEPHONY_ENABLED:
|
||||||
try:
|
try:
|
||||||
self.sip_management.delete_dispatch_rule(room_id)
|
self.telephony_service.delete_dispatch_rule(room_id)
|
||||||
except SIPException as e:
|
except TelephonyException as e:
|
||||||
raise ActionFailedError(
|
raise ActionFailedError(
|
||||||
f"Failed to delete sip dispatch rule for room {room_id}"
|
f"Failed to delete telephony dispatch rule for room {room_id}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
+10
-38
@@ -1,9 +1,9 @@
|
|||||||
"""SIP management service for managing SIP dispatch rules for room access."""
|
"""Telephony service for managing SIP dispatch rules for room access."""
|
||||||
|
|
||||||
from logging import getLogger
|
from logging import getLogger
|
||||||
|
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
from livekit.api import TwirpError, TwirpErrorCode
|
from livekit.api import TwirpError
|
||||||
from livekit.protocol.sip import (
|
from livekit.protocol.sip import (
|
||||||
CreateSIPDispatchRuleRequest,
|
CreateSIPDispatchRuleRequest,
|
||||||
DeleteSIPDispatchRuleRequest,
|
DeleteSIPDispatchRuleRequest,
|
||||||
@@ -17,16 +17,12 @@ from core import utils
|
|||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class SIPException(Exception):
|
class TelephonyException(Exception):
|
||||||
"""Exception raised when SIP operations fail."""
|
"""Exception raised when telephony operations fail."""
|
||||||
|
|
||||||
|
|
||||||
class DispatchRuleConflictError(SIPException):
|
class TelephonyService:
|
||||||
"""Raised when a dispatch rule already exists for the same routing criteria."""
|
"""Service for managing participant access through the telephony system (SIP)."""
|
||||||
|
|
||||||
|
|
||||||
class SIPManagement:
|
|
||||||
"""Service for managing SIP access through the telephony or roomkit system (SIP)."""
|
|
||||||
|
|
||||||
def _rule_name(self, room_id):
|
def _rule_name(self, room_id):
|
||||||
"""Generate the rule name for a room based on its ID."""
|
"""Generate the rule name for a room based on its ID."""
|
||||||
@@ -36,7 +32,7 @@ class SIPManagement:
|
|||||||
async def create_dispatch_rule(self, room):
|
async def create_dispatch_rule(self, room):
|
||||||
"""Create a SIP inbound dispatch rule for direct room routing.
|
"""Create a SIP inbound dispatch rule for direct room routing.
|
||||||
|
|
||||||
Configures livekit-sip to route incoming SIP calls directly to the specified room
|
Configures telephony to route incoming SIP calls directly to the specified room
|
||||||
using the room's ID and PIN code for authentication.
|
using the room's ID and PIN code for authentication.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -55,12 +51,10 @@ class SIPManagement:
|
|||||||
try:
|
try:
|
||||||
await lkapi.sip.create_sip_dispatch_rule(create=request)
|
await lkapi.sip.create_sip_dispatch_rule(create=request)
|
||||||
except TwirpError as e:
|
except TwirpError as e:
|
||||||
if e.code == TwirpErrorCode.ALREADY_EXISTS:
|
|
||||||
raise DispatchRuleConflictError("Dispatch rule already exists") from e
|
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Unexpected error creating dispatch rule for room %s", room.id
|
"Unexpected error creating dispatch rule for room %s", room.id
|
||||||
)
|
)
|
||||||
raise SIPException("Could not create dispatch rule") from e
|
raise TelephonyException("Could not create dispatch rule") from e
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
await lkapi.aclose()
|
await lkapi.aclose()
|
||||||
@@ -85,7 +79,7 @@ class SIPManagement:
|
|||||||
)
|
)
|
||||||
except TwirpError as e:
|
except TwirpError as e:
|
||||||
logger.exception("Failed to list dispatch rules for room %s", room_id)
|
logger.exception("Failed to list dispatch rules for room %s", room_id)
|
||||||
raise SIPException("Could not list dispatch rules") from e
|
raise TelephonyException("Could not list dispatch rules") from e
|
||||||
finally:
|
finally:
|
||||||
await lkapi.aclose()
|
await lkapi.aclose()
|
||||||
|
|
||||||
@@ -100,28 +94,6 @@ class SIPManagement:
|
|||||||
if existing_rule.name == rule_name
|
if existing_rule.name == rule_name
|
||||||
]
|
]
|
||||||
|
|
||||||
@async_to_sync
|
|
||||||
async def has_dispatch_rule(self, room_id):
|
|
||||||
"""Check whether at least one dispatch rule exists for a specific room."""
|
|
||||||
return bool(await self._list_dispatch_rules_ids(room_id))
|
|
||||||
|
|
||||||
def ensure_dispatch_rule(self, room):
|
|
||||||
"""Create the SIP dispatch rule for a room if it does not already exist.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if a rule was created, False if it already existed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if self.has_dispatch_rule(room.pk):
|
|
||||||
return False
|
|
||||||
|
|
||||||
try:
|
|
||||||
self.create_dispatch_rule(room)
|
|
||||||
except DispatchRuleConflictError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
return True
|
|
||||||
|
|
||||||
@async_to_sync
|
@async_to_sync
|
||||||
async def delete_dispatch_rule(self, room_id):
|
async def delete_dispatch_rule(self, room_id):
|
||||||
"""Delete all SIP inbound dispatch rules associated with a specific room."""
|
"""Delete all SIP inbound dispatch rules associated with a specific room."""
|
||||||
@@ -146,7 +118,7 @@ class SIPManagement:
|
|||||||
|
|
||||||
except TwirpError as e:
|
except TwirpError as e:
|
||||||
logger.exception("Failed to delete dispatch rules for room %s", room_id)
|
logger.exception("Failed to delete dispatch rules for room %s", room_id)
|
||||||
raise SIPException("Could not delete dispatch rules") from e
|
raise TelephonyException("Could not delete dispatch rules") from e
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
await lkapi.aclose()
|
await lkapi.aclose()
|
||||||
@@ -1,11 +1,4 @@
|
|||||||
"""
|
|
||||||
Celery task decorator that degrades to a synchronous call when Celery is off.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# The Celery app is imported lazily so that importing this module does not pull
|
|
||||||
# in Celery when CELERY_ENABLED is false.
|
|
||||||
# ruff: noqa: PLC0415
|
# ruff: noqa: PLC0415
|
||||||
# pylint: disable=import-outside-toplevel
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
"""Tests for the roomkit API of the Meet core app."""
|
|
||||||
@@ -1,305 +0,0 @@
|
|||||||
"""
|
|
||||||
Test the roomkit join server-to-server API endpoint.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# pylint: disable=redefined-outer-name,unused-argument
|
|
||||||
|
|
||||||
from unittest import mock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from ...factories import RoomFactory
|
|
||||||
from ...services.sip_management import SIPException
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
|
||||||
def test_join_anonymous(mock_sip_management, settings, client):
|
|
||||||
"""Requests without an Authorization header should be rejected."""
|
|
||||||
|
|
||||||
mock_sip_instance = mock_sip_management.return_value
|
|
||||||
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
|
||||||
|
|
||||||
room = RoomFactory(pin_code="1234567890")
|
|
||||||
|
|
||||||
response = client.post("/api/v1.0/roomkit/join/", {"pin_code": room.pin_code})
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
assert response.json() == {"detail": "Authorization header is missing."}
|
|
||||||
mock_sip_instance.ensure_dispatch_rule.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
|
||||||
def test_join_malformed_authorization_header(mock_sip_management, settings, client):
|
|
||||||
"""Requests with a malformed Authorization header should be rejected."""
|
|
||||||
|
|
||||||
mock_sip_instance = mock_sip_management.return_value
|
|
||||||
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
|
||||||
|
|
||||||
room = RoomFactory(pin_code="1234567890")
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/roomkit/join/",
|
|
||||||
{"pin_code": room.pin_code},
|
|
||||||
HTTP_AUTHORIZATION="testAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
assert response.json() == {"detail": "Invalid authorization header."}
|
|
||||||
mock_sip_instance.ensure_dispatch_rule.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
|
||||||
def test_join_wrong_bearer(mock_sip_management, settings, client):
|
|
||||||
"""Requests with an incorrect bearer token should be rejected."""
|
|
||||||
|
|
||||||
mock_sip_instance = mock_sip_management.return_value
|
|
||||||
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
|
||||||
|
|
||||||
room = RoomFactory(pin_code="1234567890")
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/roomkit/join/",
|
|
||||||
{"pin_code": room.pin_code},
|
|
||||||
HTTP_AUTHORIZATION="Bearer wrongAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
assert response.json() == {"detail": "Invalid server-to-server token."}
|
|
||||||
mock_sip_instance.ensure_dispatch_rule.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
|
||||||
def test_join_token_not_configured(mock_sip_management, settings, client):
|
|
||||||
"""Requests should be rejected when no server-to-server token is configured."""
|
|
||||||
|
|
||||||
mock_sip_instance = mock_sip_management.return_value
|
|
||||||
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = None
|
|
||||||
|
|
||||||
room = RoomFactory(pin_code="1234567890")
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/roomkit/join/",
|
|
||||||
{"pin_code": room.pin_code},
|
|
||||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 401
|
|
||||||
mock_sip_instance.ensure_dispatch_rule.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
|
||||||
def test_join_roomkit_disabled(mock_sip_management, settings, client):
|
|
||||||
"""The endpoint should not be exposed when the roomkit integration is disabled."""
|
|
||||||
|
|
||||||
mock_sip_instance = mock_sip_management.return_value
|
|
||||||
|
|
||||||
settings.ROOMKIT_ENABLED = False
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
|
||||||
|
|
||||||
room = RoomFactory(pin_code="1234567890")
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/roomkit/join/",
|
|
||||||
{"pin_code": room.pin_code},
|
|
||||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 404
|
|
||||||
mock_sip_instance.ensure_dispatch_rule.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
|
||||||
def test_join_missing_pin(mock_sip_management, settings, client):
|
|
||||||
"""Requests without a PIN code should be rejected."""
|
|
||||||
|
|
||||||
mock_sip_instance = mock_sip_management.return_value
|
|
||||||
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/roomkit/join/",
|
|
||||||
{},
|
|
||||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert response.json() == {"pin_code": ["This field is required."]}
|
|
||||||
mock_sip_instance.ensure_dispatch_rule.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
|
||||||
def test_join_blank_pin(mock_sip_management, settings, client):
|
|
||||||
"""Requests with a blank PIN code should be rejected."""
|
|
||||||
|
|
||||||
mock_sip_instance = mock_sip_management.return_value
|
|
||||||
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/roomkit/join/",
|
|
||||||
{"pin_code": ""},
|
|
||||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert response.json() == {"pin_code": ["This field may not be blank."]}
|
|
||||||
mock_sip_instance.ensure_dispatch_rule.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
|
||||||
def test_join_wrong_pin_length(mock_sip_management, settings, client):
|
|
||||||
"""Requests with a PIN code of unexpected length should be rejected."""
|
|
||||||
|
|
||||||
mock_sip_instance = mock_sip_management.return_value
|
|
||||||
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
|
||||||
settings.ROOM_TELEPHONY_PIN_LENGTH = 10
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/roomkit/join/",
|
|
||||||
{"pin_code": "123"},
|
|
||||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert response.json() == {"pin_code": ["PIN code length is invalid."]}
|
|
||||||
mock_sip_instance.ensure_dispatch_rule.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
|
||||||
def test_join_unknown_pin(mock_sip_management, settings, client):
|
|
||||||
"""Requests with a PIN matching no room should return 404 and create no rule."""
|
|
||||||
|
|
||||||
mock_sip_instance = mock_sip_management.return_value
|
|
||||||
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
|
||||||
|
|
||||||
RoomFactory(pin_code="1234567890")
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/roomkit/join/",
|
|
||||||
{"pin_code": "0987654321"},
|
|
||||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 404
|
|
||||||
assert response.json() == {"detail": "No room found for this PIN code."}
|
|
||||||
mock_sip_instance.ensure_dispatch_rule.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
|
||||||
def test_join_success(mock_sip_management, settings, client):
|
|
||||||
"""Requests with a valid PIN should create the dispatch rule."""
|
|
||||||
|
|
||||||
mock_sip_instance = mock_sip_management.return_value
|
|
||||||
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
|
||||||
|
|
||||||
room = RoomFactory(pin_code="1234567890")
|
|
||||||
mock_sip_instance.ensure_dispatch_rule.return_value = True
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/roomkit/join/",
|
|
||||||
{"pin_code": room.pin_code},
|
|
||||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {"status": "success"}
|
|
||||||
mock_sip_instance.ensure_dispatch_rule.assert_called_once_with(room)
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
|
||||||
def test_join_dispatch_rule_already_exists(mock_sip_management, settings, client):
|
|
||||||
"""Requests should succeed when the dispatch rule already exists (idempotency)."""
|
|
||||||
|
|
||||||
mock_sip_instance = mock_sip_management.return_value
|
|
||||||
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
|
||||||
|
|
||||||
room = RoomFactory(pin_code="1234567890")
|
|
||||||
mock_sip_instance.ensure_dispatch_rule.return_value = False
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/roomkit/join/",
|
|
||||||
{"pin_code": room.pin_code},
|
|
||||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == {"status": "success"}
|
|
||||||
mock_sip_instance.ensure_dispatch_rule.assert_called_once_with(room)
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.roomkit.viewsets.analytics.capture")
|
|
||||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
|
||||||
def test_join_tracks_analytics_event(
|
|
||||||
mock_sip_management, mock_capture, settings, client
|
|
||||||
):
|
|
||||||
"""Successful joins should be tracked with an analytics event."""
|
|
||||||
|
|
||||||
mock_sip_instance = mock_sip_management.return_value
|
|
||||||
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
|
||||||
|
|
||||||
room = RoomFactory(pin_code="1234567890")
|
|
||||||
mock_sip_instance.ensure_dispatch_rule.return_value = True
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/roomkit/join/",
|
|
||||||
{"pin_code": room.pin_code},
|
|
||||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
mock_capture.assert_called_once()
|
|
||||||
_user, event, properties = mock_capture.call_args[0]
|
|
||||||
assert str(event) == "roomkit_joined"
|
|
||||||
assert properties == {
|
|
||||||
"room_id": str(room.pk),
|
|
||||||
"dispatch_rule_created": True,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.roomkit.viewsets.analytics.capture")
|
|
||||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
|
||||||
def test_join_sip_failure(mock_sip_management, mock_capture, settings, client):
|
|
||||||
"""Requests should fail with a server error when the sip management service fails."""
|
|
||||||
|
|
||||||
mock_sip_instance = mock_sip_management.return_value
|
|
||||||
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
|
||||||
|
|
||||||
room = RoomFactory(pin_code="1234567890")
|
|
||||||
mock_sip_instance.ensure_dispatch_rule.side_effect = SIPException(
|
|
||||||
"Could not create dispatch rule"
|
|
||||||
)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/roomkit/join/",
|
|
||||||
{"pin_code": room.pin_code},
|
|
||||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
|
||||||
raise_request_exception=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 500
|
|
||||||
mock_sip_instance.ensure_dispatch_rule.assert_called_once_with(room)
|
|
||||||
mock_capture.assert_not_called()
|
|
||||||
@@ -3,14 +3,13 @@ Test rooms API endpoints in the Meet core app: create.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# pylint: disable=redefined-outer-name,unused-argument
|
# pylint: disable=redefined-outer-name,unused-argument
|
||||||
from django.conf import settings
|
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from ...factories import RoomFactory, UserFactory
|
from ...factories import RoomFactory, UserFactory
|
||||||
from ...models import Room, RoomAccessLevel
|
from ...models import Room
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -110,205 +109,3 @@ def test_api_rooms_create_authenticated_existing_slug():
|
|||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert response.json() == {"slug": ["Room with this Slug already exists."]}
|
assert response.json() == {"slug": ["Room with this Slug already exists."]}
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_authenticated_user_default_access_level():
|
|
||||||
"""
|
|
||||||
The user's default room access level should be applied to the new room
|
|
||||||
when the request does not provide one.
|
|
||||||
"""
|
|
||||||
user = UserFactory(default_room_access_level=RoomAccessLevel.RESTRICTED)
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/rooms/",
|
|
||||||
{
|
|
||||||
"name": "my room",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
room = Room.objects.get()
|
|
||||||
assert room.access_level == RoomAccessLevel.RESTRICTED
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_authenticated_explicit_access_level_overrides_default():
|
|
||||||
"""
|
|
||||||
An access level explicitly provided in the request should take precedence
|
|
||||||
over the user's default room access level.
|
|
||||||
"""
|
|
||||||
user = UserFactory(default_room_access_level=RoomAccessLevel.RESTRICTED)
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/rooms/",
|
|
||||||
{
|
|
||||||
"name": "my room",
|
|
||||||
"access_level": RoomAccessLevel.TRUSTED,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
room = Room.objects.get()
|
|
||||||
assert room.access_level == RoomAccessLevel.TRUSTED
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_authenticated_no_user_default_access_level():
|
|
||||||
"""
|
|
||||||
When the user has no default room access level, the instance default
|
|
||||||
should be applied to the new room.
|
|
||||||
"""
|
|
||||||
user = UserFactory(default_room_access_level=None)
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/rooms/",
|
|
||||||
{
|
|
||||||
"name": "my room",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
room = Room.objects.get()
|
|
||||||
assert room.access_level == settings.RESOURCE_DEFAULT_ACCESS_LEVEL
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_authenticated_user_default_configuration():
|
|
||||||
"""
|
|
||||||
The user's default room configuration should be applied to the new room
|
|
||||||
when the request does not provide one.
|
|
||||||
"""
|
|
||||||
user = UserFactory(default_room_configuration={"everyone_can_mute": False})
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/rooms/",
|
|
||||||
{
|
|
||||||
"name": "my room",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
room = Room.objects.get()
|
|
||||||
assert room.configuration == {"everyone_can_mute": False}
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_authenticated_explicit_configuration_overrides_default():
|
|
||||||
"""
|
|
||||||
A configuration explicitly provided in the request should take precedence
|
|
||||||
over the user's default room configuration.
|
|
||||||
"""
|
|
||||||
user = UserFactory(default_room_configuration={"everyone_can_mute": False})
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/rooms/",
|
|
||||||
{
|
|
||||||
"name": "my room",
|
|
||||||
"configuration": {"can_publish_sources": ["camera", "microphone"]},
|
|
||||||
},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
room = Room.objects.get()
|
|
||||||
assert room.configuration == {"can_publish_sources": ["camera", "microphone"]}
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_authenticated_empty_configuration_falls_back_to_default():
|
|
||||||
"""
|
|
||||||
An empty configuration in the request should not be considered an explicit
|
|
||||||
value: the user's default room configuration should still be applied.
|
|
||||||
"""
|
|
||||||
user = UserFactory(default_room_configuration={"everyone_can_mute": True})
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/rooms/",
|
|
||||||
{
|
|
||||||
"name": "my room",
|
|
||||||
"configuration": {},
|
|
||||||
},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
room = Room.objects.get()
|
|
||||||
assert room.configuration == {"everyone_can_mute": True}
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_authenticated_empty_user_default_configuration():
|
|
||||||
"""
|
|
||||||
When the user's default room configuration is empty, the new room should
|
|
||||||
keep its default empty configuration.
|
|
||||||
"""
|
|
||||||
user = UserFactory(default_room_configuration={})
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/rooms/",
|
|
||||||
{
|
|
||||||
"name": "my room",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
room = Room.objects.get()
|
|
||||||
assert room.configuration == {}
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_authenticated_request_precedence_over_user_empty():
|
|
||||||
"""
|
|
||||||
When the user's default room configuration is empty, the request should take precedence.
|
|
||||||
"""
|
|
||||||
user = UserFactory(default_room_configuration={})
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/rooms/",
|
|
||||||
{"name": "my room", "configuration": {"everyone_can_mute": True}},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
room = Room.objects.get()
|
|
||||||
assert room.configuration == {"everyone_can_mute": True}
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_rooms_create_authenticated_blank_user_default_access_level():
|
|
||||||
"""
|
|
||||||
A blank default room access level (stored as an empty string) should be
|
|
||||||
treated as unset: the instance default should be applied to the new room
|
|
||||||
instead of persisting an invalid empty access level.
|
|
||||||
"""
|
|
||||||
user = UserFactory(default_room_access_level="")
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.post(
|
|
||||||
"/api/v1.0/rooms/",
|
|
||||||
{
|
|
||||||
"name": "my room",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 201
|
|
||||||
room = Room.objects.get()
|
|
||||||
assert room.access_level == settings.RESOURCE_DEFAULT_ACCESS_LEVEL
|
|
||||||
|
|||||||
@@ -453,8 +453,6 @@ def test_api_rooms_retrieve_administrators(
|
|||||||
{
|
{
|
||||||
"id": str(other_user_access.id),
|
"id": str(other_user_access.id),
|
||||||
"user": {
|
"user": {
|
||||||
"default_room_access_level": None,
|
|
||||||
"default_room_configuration": {},
|
|
||||||
"id": str(other_user_access.user.id),
|
"id": str(other_user_access.user.id),
|
||||||
"email": other_user_access.user.email,
|
"email": other_user_access.user.email,
|
||||||
"full_name": other_user_access.user.full_name,
|
"full_name": other_user_access.user.full_name,
|
||||||
@@ -468,8 +466,6 @@ def test_api_rooms_retrieve_administrators(
|
|||||||
{
|
{
|
||||||
"id": str(user_access.id),
|
"id": str(user_access.id),
|
||||||
"user": {
|
"user": {
|
||||||
"default_room_access_level": None,
|
|
||||||
"default_room_configuration": {},
|
|
||||||
"id": str(user_access.user.id),
|
"id": str(user_access.user.id),
|
||||||
"email": user_access.user.email,
|
"email": user_access.user.email,
|
||||||
"full_name": user_access.user.full_name,
|
"full_name": user_access.user.full_name,
|
||||||
|
|||||||
@@ -21,10 +21,7 @@ from core.services.livekit_events import (
|
|||||||
)
|
)
|
||||||
from core.services.lobby import LobbyService
|
from core.services.lobby import LobbyService
|
||||||
from core.services.room_management import RoomManagementException
|
from core.services.room_management import RoomManagementException
|
||||||
from core.services.sip_management import (
|
from core.services.telephony import TelephonyException, TelephonyService
|
||||||
SIPException,
|
|
||||||
SIPManagement,
|
|
||||||
)
|
|
||||||
from core.utils import NotificationError
|
from core.utils import NotificationError
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
@@ -62,7 +59,7 @@ def test_initialization(
|
|||||||
mock_token_verifier.assert_called_once_with(api_key, api_secret)
|
mock_token_verifier.assert_called_once_with(api_key, api_secret)
|
||||||
mock_webhook_receiver.assert_called_once_with(mock_token_verifier.return_value)
|
mock_webhook_receiver.assert_called_once_with(mock_token_verifier.return_value)
|
||||||
assert isinstance(service.lobby_service, LobbyService)
|
assert isinstance(service.lobby_service, LobbyService)
|
||||||
assert isinstance(service.sip_management, SIPManagement)
|
assert isinstance(service.telephony_service, TelephonyService)
|
||||||
assert isinstance(service.recording_events, RecordingEventsService)
|
assert isinstance(service.recording_events, RecordingEventsService)
|
||||||
|
|
||||||
|
|
||||||
@@ -75,12 +72,11 @@ def test_initialization(
|
|||||||
)
|
)
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_success( # noqa: PLR0913 # pylint: disable=too-many-arguments, too-many-positional-arguments
|
def test_handle_egress_ended_success(
|
||||||
mock_update_metadata, mock_notify, mode, notification_type, service, settings
|
mock_update_metadata, mock_notify, mode, notification_type, service
|
||||||
):
|
):
|
||||||
"""Should successfully stop recording and notifies all participant."""
|
"""Should successfully stop recording and notifies all participant."""
|
||||||
|
|
||||||
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
|
||||||
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
mock_data.egress_info.egress_id = recording.worker_id
|
mock_data.egress_info.egress_id = recording.worker_id
|
||||||
@@ -159,12 +155,11 @@ def test_handle_egress_updated_non_handled(
|
|||||||
)
|
)
|
||||||
@mock.patch("core.utils.notify_participants")
|
@mock.patch("core.utils.notify_participants")
|
||||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_handle_egress_ended_metadata_update_fails( # noqa: PLR0913 # pylint: disable=too-many-arguments, too-many-positional-arguments
|
def test_handle_egress_ended_metadata_update_fails(
|
||||||
mock_update_metadata, mock_notify, mode, notification_type, service, settings
|
mock_update_metadata, mock_notify, mode, notification_type, service
|
||||||
):
|
):
|
||||||
"""Should successfully stop and save recording when metadata's update fails."""
|
"""Should successfully stop and save recording when metadata's update fails."""
|
||||||
|
|
||||||
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
|
||||||
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
mock_data.egress_info.egress_id = recording.worker_id
|
mock_data.egress_info.egress_id = recording.worker_id
|
||||||
@@ -474,11 +469,11 @@ def test_handle_egress_ended_ignores_non_savable_recording(
|
|||||||
|
|
||||||
|
|
||||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||||
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
|
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
||||||
def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
|
def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
|
||||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||||
):
|
):
|
||||||
"""Should clear lobby cache and delete SIP dispatch rule when room finishes."""
|
"""Should clear lobby cache and delete telephony dispatch rule when room finishes."""
|
||||||
settings.ROOM_TELEPHONY_ENABLED = True
|
settings.ROOM_TELEPHONY_ENABLED = True
|
||||||
mock_room_name = uuid.uuid4()
|
mock_room_name = uuid.uuid4()
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
@@ -491,31 +486,12 @@ def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
|
|||||||
|
|
||||||
|
|
||||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||||
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
|
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
||||||
def test_handle_room_finished_deletes_dispatch_rule_when_only_roomkit_enabled(
|
|
||||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
|
||||||
):
|
|
||||||
"""Should delete dispatch rule when only roomkit is enabled when room finishes."""
|
|
||||||
settings.ROOM_TELEPHONY_ENABLED = False
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
mock_room_name = uuid.uuid4()
|
|
||||||
mock_data = mock.MagicMock()
|
|
||||||
mock_data.room.name = str(mock_room_name)
|
|
||||||
|
|
||||||
service._handle_room_finished(mock_data)
|
|
||||||
|
|
||||||
mock_delete_dispatch_rule.assert_called_once_with(mock_room_name)
|
|
||||||
mock_clear_cache.assert_called_once_with(mock_room_name)
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
|
||||||
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
|
|
||||||
def test_handle_room_finished_skips_telephony_when_disabled(
|
def test_handle_room_finished_skips_telephony_when_disabled(
|
||||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||||
):
|
):
|
||||||
"""Should clear lobby cache but skip dispatch rule deletion when telephony is disabled."""
|
"""Should clear lobby cache but skip dispatch rule deletion when telephony is disabled."""
|
||||||
settings.ROOM_TELEPHONY_ENABLED = False
|
settings.ROOM_TELEPHONY_ENABLED = False
|
||||||
settings.ROOMKIT_ENABLED = False
|
|
||||||
mock_room_name = uuid.uuid4()
|
mock_room_name = uuid.uuid4()
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
mock_data.room.name = str(mock_room_name)
|
mock_data.room.name = str(mock_room_name)
|
||||||
@@ -529,7 +505,7 @@ def test_handle_room_finished_skips_telephony_when_disabled(
|
|||||||
@mock.patch.object(
|
@mock.patch.object(
|
||||||
LobbyService, "clear_room_cache", side_effect=Exception("Test error")
|
LobbyService, "clear_room_cache", side_effect=Exception("Test error")
|
||||||
)
|
)
|
||||||
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
|
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
||||||
def test_handle_room_finished_raises_error_when_cache_clearing_fails(
|
def test_handle_room_finished_raises_error_when_cache_clearing_fails(
|
||||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||||
):
|
):
|
||||||
@@ -552,9 +528,9 @@ def test_handle_room_finished_raises_error_when_cache_clearing_fails(
|
|||||||
|
|
||||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||||
@mock.patch.object(
|
@mock.patch.object(
|
||||||
SIPManagement,
|
TelephonyService,
|
||||||
"delete_dispatch_rule",
|
"delete_dispatch_rule",
|
||||||
side_effect=SIPException("Test error"),
|
side_effect=TelephonyException("Test error"),
|
||||||
)
|
)
|
||||||
def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
|
def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
|
||||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||||
@@ -565,7 +541,7 @@ def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
|
|||||||
mock_data.room.name = "00000000-0000-0000-0000-000000000000"
|
mock_data.room.name = "00000000-0000-0000-0000-000000000000"
|
||||||
|
|
||||||
expected_error = (
|
expected_error = (
|
||||||
"Failed to delete sip dispatch rule for room "
|
"Failed to delete telephony dispatch rule for room "
|
||||||
"00000000-0000-0000-0000-000000000000"
|
"00000000-0000-0000-0000-000000000000"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -586,11 +562,11 @@ def test_handle_room_finished_raises_error_for_invalid_room_name(service):
|
|||||||
service._handle_room_finished(mock_data)
|
service._handle_room_finished(mock_data)
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
|
@mock.patch.object(TelephonyService, "create_dispatch_rule")
|
||||||
def test_handle_room_started_creates_dispatch_rule_successfully(
|
def test_handle_room_started_creates_dispatch_rule_successfully(
|
||||||
mock_ensure_dispatch_rule, service, settings
|
mock_create_dispatch_rule, service, settings
|
||||||
):
|
):
|
||||||
"""Should ensure the SIP dispatch rule exists when room starts successfully."""
|
"""Should create telephony dispatch rule when room starts successfully."""
|
||||||
settings.ROOM_TELEPHONY_ENABLED = True
|
settings.ROOM_TELEPHONY_ENABLED = True
|
||||||
room = RoomFactory()
|
room = RoomFactory()
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
@@ -598,75 +574,22 @@ def test_handle_room_started_creates_dispatch_rule_successfully(
|
|||||||
|
|
||||||
service._handle_room_started(mock_data)
|
service._handle_room_started(mock_data)
|
||||||
|
|
||||||
mock_ensure_dispatch_rule.assert_called_once_with(room)
|
mock_create_dispatch_rule.assert_called_once_with(room)
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
|
@mock.patch.object(TelephonyService, "create_dispatch_rule")
|
||||||
def test_handle_room_started_creates_dispatch_rule_when_only_roomkit_enabled(
|
|
||||||
mock_ensure_dispatch_rule, service, settings
|
|
||||||
):
|
|
||||||
"""Should ensure the dispatch rule exists when only roomkit is enabled during room start."""
|
|
||||||
settings.ROOM_TELEPHONY_ENABLED = False
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
room = RoomFactory()
|
|
||||||
mock_data = mock.MagicMock()
|
|
||||||
mock_data.room.name = str(room.id)
|
|
||||||
|
|
||||||
service._handle_room_started(mock_data)
|
|
||||||
|
|
||||||
mock_ensure_dispatch_rule.assert_called_once_with(room)
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(SIPManagement, "ensure_dispatch_rule", return_value=False)
|
|
||||||
def test_handle_room_started_ignores_existing_dispatch_rule(
|
|
||||||
mock_ensure_dispatch_rule, service, settings
|
|
||||||
):
|
|
||||||
"""Should proceed silently when the dispatch rule already exists when room starts."""
|
|
||||||
settings.ROOM_TELEPHONY_ENABLED = True
|
|
||||||
room = RoomFactory()
|
|
||||||
mock_data = mock.MagicMock()
|
|
||||||
mock_data.room.name = str(room.id)
|
|
||||||
|
|
||||||
# ensure_dispatch_rule reports the rule as pre-existing: nothing to raise
|
|
||||||
service._handle_room_started(mock_data)
|
|
||||||
|
|
||||||
mock_ensure_dispatch_rule.assert_called_once_with(room)
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(
|
|
||||||
SIPManagement,
|
|
||||||
"ensure_dispatch_rule",
|
|
||||||
side_effect=SIPException("Test error"),
|
|
||||||
)
|
|
||||||
def test_handle_room_started_raises_error_when_dispatch_rule_creation_fails(
|
|
||||||
mock_ensure_dispatch_rule, service, settings
|
|
||||||
):
|
|
||||||
"""Should raise ActionFailedError when ensuring the dispatch rule fails when room starts."""
|
|
||||||
settings.ROOM_TELEPHONY_ENABLED = True
|
|
||||||
room = RoomFactory()
|
|
||||||
mock_data = mock.MagicMock()
|
|
||||||
mock_data.room.name = str(room.id)
|
|
||||||
|
|
||||||
expected_error = f"Failed to create sip dispatch rule for room {room.id}"
|
|
||||||
|
|
||||||
with pytest.raises(ActionFailedError, match=expected_error):
|
|
||||||
service._handle_room_started(mock_data)
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
|
|
||||||
def test_handle_room_started_skips_dispatch_rule_when_telephony_disabled(
|
def test_handle_room_started_skips_dispatch_rule_when_telephony_disabled(
|
||||||
mock_ensure_dispatch_rule, service, settings
|
mock_create_dispatch_rule, service, settings
|
||||||
):
|
):
|
||||||
"""Should skip ensuring the SIP dispatch rule when telephony is disabled during room start."""
|
"""Should skip creating telephony dispatch rule when telephony is disabled during room start."""
|
||||||
settings.ROOM_TELEPHONY_ENABLED = False
|
settings.ROOM_TELEPHONY_ENABLED = False
|
||||||
settings.ROOMKIT_ENABLED = False
|
|
||||||
room = RoomFactory()
|
room = RoomFactory()
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
mock_data.room.name = str(room.id)
|
mock_data.room.name = str(room.id)
|
||||||
|
|
||||||
service._handle_room_started(mock_data)
|
service._handle_room_started(mock_data)
|
||||||
|
|
||||||
mock_ensure_dispatch_rule.assert_not_called()
|
mock_create_dispatch_rule.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
def test_handle_room_started_raises_error_for_invalid_room_name(service):
|
def test_handle_room_started_raises_error_for_invalid_room_name(service):
|
||||||
|
|||||||
+35
-162
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Test SIP mamagement service.
|
Test telephony service.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# pylint: disable=W0212
|
# pylint: disable=W0212
|
||||||
@@ -20,11 +20,7 @@ from livekit.protocol.sip import (
|
|||||||
|
|
||||||
from core.factories import RoomFactory
|
from core.factories import RoomFactory
|
||||||
from core.models import RoomAccessLevel
|
from core.models import RoomAccessLevel
|
||||||
from core.services.sip_management import (
|
from core.services.telephony import TelephonyException, TelephonyService
|
||||||
DispatchRuleConflictError,
|
|
||||||
SIPException,
|
|
||||||
SIPManagement,
|
|
||||||
)
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -39,9 +35,9 @@ def create_mock_livekit_client():
|
|||||||
|
|
||||||
def test_rule_name():
|
def test_rule_name():
|
||||||
"""Test rule name generation."""
|
"""Test rule name generation."""
|
||||||
sip_management = SIPManagement()
|
telephony_service = TelephonyService()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
rule_name = sip_management._rule_name(room.id)
|
rule_name = telephony_service._rule_name(room.id)
|
||||||
|
|
||||||
assert rule_name == f"SIP_{str(room.id)}"
|
assert rule_name == f"SIP_{str(room.id)}"
|
||||||
|
|
||||||
@@ -49,14 +45,14 @@ def test_rule_name():
|
|||||||
@mock.patch("core.utils.create_livekit_client")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_create_dispatch_rule_success(mock_client_factory):
|
def test_create_dispatch_rule_success(mock_client_factory):
|
||||||
"""Test successful dispatch rule creation."""
|
"""Test successful dispatch rule creation."""
|
||||||
sip_management = SIPManagement()
|
telephony_service = TelephonyService()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_api = create_mock_livekit_client()
|
mock_api = create_mock_livekit_client()
|
||||||
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock()
|
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock()
|
||||||
mock_client_factory.return_value = mock_api
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
sip_management.create_dispatch_rule(room)
|
telephony_service.create_dispatch_rule(room)
|
||||||
|
|
||||||
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
|
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
|
||||||
create_request = mock_api.sip.create_sip_dispatch_rule.call_args[1]["create"]
|
create_request = mock_api.sip.create_sip_dispatch_rule.call_args[1]["create"]
|
||||||
@@ -71,7 +67,7 @@ def test_create_dispatch_rule_success(mock_client_factory):
|
|||||||
@mock.patch("core.utils.create_livekit_client")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_create_dispatch_rule_api_failure(mock_client_factory):
|
def test_create_dispatch_rule_api_failure(mock_client_factory):
|
||||||
"""Test dispatch rule creation when API fails."""
|
"""Test dispatch rule creation when API fails."""
|
||||||
sip_management = SIPManagement()
|
telephony_service = TelephonyService()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_api = create_mock_livekit_client()
|
mock_api = create_mock_livekit_client()
|
||||||
@@ -80,8 +76,8 @@ def test_create_dispatch_rule_api_failure(mock_client_factory):
|
|||||||
)
|
)
|
||||||
mock_client_factory.return_value = mock_api
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
with pytest.raises(SIPException, match="Could not create dispatch rule"):
|
with pytest.raises(TelephonyException, match="Could not create dispatch rule"):
|
||||||
sip_management.create_dispatch_rule(room)
|
telephony_service.create_dispatch_rule(room)
|
||||||
|
|
||||||
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
|
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
|
||||||
mock_api.aclose.assert_called_once()
|
mock_api.aclose.assert_called_once()
|
||||||
@@ -90,7 +86,7 @@ def test_create_dispatch_rule_api_failure(mock_client_factory):
|
|||||||
@mock.patch("core.utils.create_livekit_client")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_list_dispatch_rules_ids_success(mock_client_factory):
|
def test_list_dispatch_rules_ids_success(mock_client_factory):
|
||||||
"""Test successful listing of dispatch rule IDs."""
|
"""Test successful listing of dispatch rule IDs."""
|
||||||
sip_management = SIPManagement()
|
telephony_service = TelephonyService()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_rules = [
|
mock_rules = [
|
||||||
@@ -115,7 +111,7 @@ def test_list_dispatch_rules_ids_success(mock_client_factory):
|
|||||||
)
|
)
|
||||||
mock_client_factory.return_value = mock_api
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
result = async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
|
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
|
||||||
|
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
assert "rule-1" in result
|
assert "rule-1" in result
|
||||||
@@ -131,7 +127,7 @@ def test_list_dispatch_rules_ids_success(mock_client_factory):
|
|||||||
@mock.patch("core.utils.create_livekit_client")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
|
def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
|
||||||
"""Test listing dispatch rule IDs when no rules exist."""
|
"""Test listing dispatch rule IDs when no rules exist."""
|
||||||
sip_management = SIPManagement()
|
telephony_service = TelephonyService()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_api = create_mock_livekit_client()
|
mock_api = create_mock_livekit_client()
|
||||||
@@ -140,7 +136,7 @@ def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
|
|||||||
)
|
)
|
||||||
mock_client_factory.return_value = mock_api
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
result = async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
|
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
|
||||||
|
|
||||||
assert result == []
|
assert result == []
|
||||||
mock_api.aclose.assert_called_once()
|
mock_api.aclose.assert_called_once()
|
||||||
@@ -149,7 +145,7 @@ def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
|
|||||||
@mock.patch("core.utils.create_livekit_client")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
|
def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
|
||||||
"""Test listing dispatch rule IDs when no rules match the room."""
|
"""Test listing dispatch rule IDs when no rules match the room."""
|
||||||
sip_management = SIPManagement()
|
telephony_service = TelephonyService()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_rules = [
|
mock_rules = [
|
||||||
@@ -167,7 +163,7 @@ def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
|
|||||||
)
|
)
|
||||||
mock_client_factory.return_value = mock_api
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
result = async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
|
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
|
||||||
|
|
||||||
assert result == []
|
assert result == []
|
||||||
mock_api.aclose.assert_called_once()
|
mock_api.aclose.assert_called_once()
|
||||||
@@ -176,7 +172,7 @@ def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
|
|||||||
@mock.patch("core.utils.create_livekit_client")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_list_dispatch_rules_ids_api_failure(mock_client_factory):
|
def test_list_dispatch_rules_ids_api_failure(mock_client_factory):
|
||||||
"""Test listing dispatch rule IDs when API fails."""
|
"""Test listing dispatch rule IDs when API fails."""
|
||||||
sip_management = SIPManagement()
|
telephony_service = TelephonyService()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_api = create_mock_livekit_client()
|
mock_api = create_mock_livekit_client()
|
||||||
@@ -185,34 +181,34 @@ def test_list_dispatch_rules_ids_api_failure(mock_client_factory):
|
|||||||
)
|
)
|
||||||
mock_client_factory.return_value = mock_api
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
with pytest.raises(SIPException, match="Could not list dispatch rules"):
|
with pytest.raises(TelephonyException, match="Could not list dispatch rules"):
|
||||||
async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
|
async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
|
||||||
|
|
||||||
mock_api.sip.list_sip_dispatch_rule.assert_called_once()
|
mock_api.sip.list_sip_dispatch_rule.assert_called_once()
|
||||||
mock_api.aclose.assert_called_once()
|
mock_api.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
|
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||||
@mock.patch("core.utils.create_livekit_client")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_delete_dispatch_rule_no_rules(mock_client_factory, mock_list_rules):
|
def test_delete_dispatch_rule_no_rules(mock_client_factory, mock_list_rules):
|
||||||
"""Test deleting dispatch rules when no rules exist."""
|
"""Test deleting dispatch rules when no rules exist."""
|
||||||
sip_management = SIPManagement()
|
telephony_service = TelephonyService()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_list_rules.return_value = []
|
mock_list_rules.return_value = []
|
||||||
|
|
||||||
result = sip_management.delete_dispatch_rule(room.id)
|
result = telephony_service.delete_dispatch_rule(room.id)
|
||||||
|
|
||||||
assert result is False
|
assert result is False
|
||||||
mock_list_rules.assert_called_once_with(room.id)
|
mock_list_rules.assert_called_once_with(room.id)
|
||||||
mock_client_factory.assert_not_called()
|
mock_client_factory.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
|
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||||
@mock.patch("core.utils.create_livekit_client")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
|
def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
|
||||||
"""Test deleting a single dispatch rule."""
|
"""Test deleting a single dispatch rule."""
|
||||||
sip_management = SIPManagement()
|
telephony_service = TelephonyService()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_list_rules.return_value = ["rule-1"]
|
mock_list_rules.return_value = ["rule-1"]
|
||||||
@@ -220,7 +216,7 @@ def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
|
|||||||
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
|
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
|
||||||
mock_client_factory.return_value = mock_api
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
result = sip_management.delete_dispatch_rule(room.id)
|
result = telephony_service.delete_dispatch_rule(room.id)
|
||||||
|
|
||||||
assert result is True
|
assert result is True
|
||||||
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
|
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
|
||||||
@@ -230,11 +226,11 @@ def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
|
|||||||
mock_api.aclose.assert_called_once()
|
mock_api.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
|
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||||
@mock.patch("core.utils.create_livekit_client")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rules):
|
def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rules):
|
||||||
"""Test deleting multiple dispatch rules."""
|
"""Test deleting multiple dispatch rules."""
|
||||||
sip_management = SIPManagement()
|
telephony_service = TelephonyService()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
|
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
|
||||||
@@ -242,7 +238,7 @@ def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rule
|
|||||||
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
|
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
|
||||||
mock_client_factory.return_value = mock_api
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
result = sip_management.delete_dispatch_rule(room.id)
|
result = telephony_service.delete_dispatch_rule(room.id)
|
||||||
|
|
||||||
assert result is True
|
assert result is True
|
||||||
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 3
|
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 3
|
||||||
@@ -257,11 +253,11 @@ def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rule
|
|||||||
mock_api.aclose.assert_called_once()
|
mock_api.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
|
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||||
@mock.patch("core.utils.create_livekit_client")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rules):
|
def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rules):
|
||||||
"""Test deleting multiple dispatch rules when one deletion fails."""
|
"""Test deleting multiple dispatch rules when one deletion fails."""
|
||||||
sip_management = SIPManagement()
|
telephony_service = TelephonyService()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
|
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
|
||||||
@@ -281,18 +277,18 @@ def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rul
|
|||||||
)
|
)
|
||||||
mock_client_factory.return_value = mock_api
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
with pytest.raises(SIPException, match="Could not delete dispatch rules"):
|
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"):
|
||||||
sip_management.delete_dispatch_rule(room.id)
|
telephony_service.delete_dispatch_rule(room.id)
|
||||||
|
|
||||||
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 2
|
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 2
|
||||||
mock_api.aclose.assert_called_once()
|
mock_api.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
|
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||||
@mock.patch("core.utils.create_livekit_client")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules):
|
def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules):
|
||||||
"""Test deleting dispatch rules when API fails immediately."""
|
"""Test deleting dispatch rules when API fails immediately."""
|
||||||
sip_management = SIPManagement()
|
telephony_service = TelephonyService()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_list_rules.return_value = ["rule-1"]
|
mock_list_rules.return_value = ["rule-1"]
|
||||||
@@ -302,131 +298,8 @@ def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules):
|
|||||||
)
|
)
|
||||||
mock_client_factory.return_value = mock_api
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
with pytest.raises(SIPException, match="Could not delete dispatch rules"):
|
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"):
|
||||||
sip_management.delete_dispatch_rule(room.id)
|
telephony_service.delete_dispatch_rule(room.id)
|
||||||
|
|
||||||
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
|
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
|
||||||
mock_api.aclose.assert_called_once()
|
mock_api.aclose.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.create_livekit_client")
|
|
||||||
def test_create_dispatch_rule_conflict_raises_dedicated_error(mock_client_factory):
|
|
||||||
"""Test that a LiveKit conflict error raises DispatchRuleConflictError."""
|
|
||||||
sip_management = SIPManagement()
|
|
||||||
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=(
|
|
||||||
"Dispatch rule for the same trunk, inbound number, number, and "
|
|
||||||
"PIN combination already exists in dispatch rule"
|
|
||||||
),
|
|
||||||
code="already_exists",
|
|
||||||
status=409,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
mock_client_factory.return_value = mock_api
|
|
||||||
|
|
||||||
with pytest.raises(DispatchRuleConflictError):
|
|
||||||
sip_management.create_dispatch_rule(room)
|
|
||||||
|
|
||||||
mock_api.aclose.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.create_livekit_client")
|
|
||||||
def test_ensure_dispatch_rule_creates_when_missing(mock_client_factory):
|
|
||||||
"""Test that ensure_dispatch_rule creates the rule when none exists."""
|
|
||||||
sip_management = SIPManagement()
|
|
||||||
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_api.sip.create_sip_dispatch_rule = mock.AsyncMock()
|
|
||||||
mock_client_factory.return_value = mock_api
|
|
||||||
|
|
||||||
created = sip_management.ensure_dispatch_rule(room)
|
|
||||||
|
|
||||||
assert created is True
|
|
||||||
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.patch("core.utils.create_livekit_client")
|
|
||||||
def test_ensure_dispatch_rule_skips_when_existing(mock_client_factory):
|
|
||||||
"""Test that ensure_dispatch_rule is idempotent when the rule already exists."""
|
|
||||||
sip_management = SIPManagement()
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
|
||||||
|
|
||||||
existing_rule = SIPDispatchRuleInfo(
|
|
||||||
sip_dispatch_rule_id="rule-1", name=f"SIP_{str(room.id)}"
|
|
||||||
)
|
|
||||||
mock_api = create_mock_livekit_client()
|
|
||||||
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
|
|
||||||
return_value=ListSIPDispatchRuleResponse(items=[existing_rule])
|
|
||||||
)
|
|
||||||
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock()
|
|
||||||
mock_client_factory.return_value = mock_api
|
|
||||||
|
|
||||||
created = sip_management.ensure_dispatch_rule(room)
|
|
||||||
|
|
||||||
assert created is False
|
|
||||||
mock_api.sip.create_sip_dispatch_rule.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.create_livekit_client")
|
|
||||||
def test_ensure_dispatch_rule_returns_false_on_conflict(mock_client_factory):
|
|
||||||
"""Test that ensure_dispatch_rule tolerates a concurrent rule creation.
|
|
||||||
|
|
||||||
If the rule is created by a concurrent caller (e.g. the LiveKit webhook)
|
|
||||||
between the existence check and the creation, LiveKit rejects the
|
|
||||||
duplicate and ensure_dispatch_rule reports the rule as already existing.
|
|
||||||
"""
|
|
||||||
sip_management = SIPManagement()
|
|
||||||
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_api.sip.create_sip_dispatch_rule = mock.AsyncMock(
|
|
||||||
side_effect=TwirpError(
|
|
||||||
msg=(
|
|
||||||
"Dispatch rule for the same trunk, inbound number, number, and "
|
|
||||||
"PIN combination already exists in dispatch rule"
|
|
||||||
),
|
|
||||||
code="already_exists",
|
|
||||||
status=409,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
mock_client_factory.return_value = mock_api
|
|
||||||
|
|
||||||
created = sip_management.ensure_dispatch_rule(room)
|
|
||||||
|
|
||||||
assert created is False
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.create_livekit_client")
|
|
||||||
def test_ensure_dispatch_rule_raises_on_other_failures(mock_client_factory):
|
|
||||||
"""Test that ensure_dispatch_rule propagates unexpected LiveKit failures."""
|
|
||||||
sip_management = SIPManagement()
|
|
||||||
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_api.sip.create_sip_dispatch_rule = mock.AsyncMock(
|
|
||||||
side_effect=TwirpError(msg="Internal server error", code="unknown", status=500)
|
|
||||||
)
|
|
||||||
mock_client_factory.return_value = mock_api
|
|
||||||
|
|
||||||
with pytest.raises(SIPException, match="Could not create dispatch rule"):
|
|
||||||
sip_management.ensure_dispatch_rule(room)
|
|
||||||
@@ -119,8 +119,6 @@ def test_api_users_retrieve_me_authenticated(settings):
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
"default_room_access_level": None,
|
|
||||||
"default_room_configuration": {},
|
|
||||||
"id": str(user.id),
|
"id": str(user.id),
|
||||||
"email": user.email,
|
"email": user.email,
|
||||||
"full_name": user.full_name,
|
"full_name": user.full_name,
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
"""
|
|
||||||
Test the default room preferences exposed on the users API.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from rest_framework.test import APIClient
|
|
||||||
|
|
||||||
from core import factories
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_users_me_includes_default_room_preferences():
|
|
||||||
"""The "me" endpoint should expose the user's default room preferences."""
|
|
||||||
user = factories.UserFactory(
|
|
||||||
default_room_access_level="restricted",
|
|
||||||
default_room_configuration={"everyone_can_mute": False},
|
|
||||||
)
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.get("/api/v1.0/users/me/")
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
content = response.json()
|
|
||||||
assert content["default_room_access_level"] == "restricted"
|
|
||||||
assert content["default_room_configuration"] == {"everyone_can_mute": False}
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_users_update_default_room_preferences():
|
|
||||||
"""Users should be able to update their own default room preferences."""
|
|
||||||
user = factories.UserFactory()
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.patch(
|
|
||||||
f"/api/v1.0/users/{user.id!s}/",
|
|
||||||
{
|
|
||||||
"default_room_access_level": "trusted",
|
|
||||||
"default_room_configuration": {
|
|
||||||
"can_publish_sources": ["microphone", "camera"],
|
|
||||||
"everyone_can_mute": False,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
user.refresh_from_db()
|
|
||||||
assert user.default_room_access_level == "trusted"
|
|
||||||
assert user.default_room_configuration == {
|
|
||||||
"can_publish_sources": ["microphone", "camera"],
|
|
||||||
"everyone_can_mute": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_users_update_default_room_access_level_invalid():
|
|
||||||
"""An invalid access level should be rejected."""
|
|
||||||
user = factories.UserFactory()
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.patch(
|
|
||||||
f"/api/v1.0/users/{user.id!s}/",
|
|
||||||
{"default_room_access_level": "invalid"},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
user.refresh_from_db()
|
|
||||||
assert user.default_room_access_level is None
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_users_update_default_room_configuration_invalid():
|
|
||||||
"""An invalid room configuration should be rejected."""
|
|
||||||
user = factories.UserFactory()
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.patch(
|
|
||||||
f"/api/v1.0/users/{user.id!s}/",
|
|
||||||
{"default_room_configuration": {"unknown_field": True}},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
user.refresh_from_db()
|
|
||||||
assert user.default_room_configuration == {}
|
|
||||||
|
|
||||||
|
|
||||||
def test_api_users_update_other_user_default_room_preferences_forbidden():
|
|
||||||
"""Users should not be able to update someone else's preferences."""
|
|
||||||
user = factories.UserFactory()
|
|
||||||
other_user = factories.UserFactory()
|
|
||||||
|
|
||||||
client = APIClient()
|
|
||||||
client.force_login(user)
|
|
||||||
|
|
||||||
response = client.patch(
|
|
||||||
f"/api/v1.0/users/{other_user.id!s}/",
|
|
||||||
{"default_room_access_level": "restricted"},
|
|
||||||
format="json",
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 403
|
|
||||||
other_user.refresh_from_db()
|
|
||||||
assert other_user.default_room_access_level is None
|
|
||||||
@@ -184,13 +184,12 @@ def test_models_rooms_is_public_property():
|
|||||||
|
|
||||||
|
|
||||||
@mock.patch.object(Room, "generate_unique_pin_code")
|
@mock.patch.object(Room, "generate_unique_pin_code")
|
||||||
def test_telephony_and_roomkit_disabled_skips_pin_generation(
|
def test_telephony_disabled_skips_pin_generation(
|
||||||
mock_generate_unique_pin_code, settings
|
mock_generate_unique_pin_code, settings
|
||||||
):
|
):
|
||||||
"""Telephony and roomkit both disabled should not generate pin codes."""
|
"""Telephony disabled should not generate pin codes."""
|
||||||
|
|
||||||
settings.ROOM_TELEPHONY_ENABLED = False
|
settings.ROOM_TELEPHONY_ENABLED = False
|
||||||
settings.ROOMKIT_ENABLED = False
|
|
||||||
|
|
||||||
room = RoomFactory()
|
room = RoomFactory()
|
||||||
|
|
||||||
@@ -198,18 +197,6 @@ def test_telephony_and_roomkit_disabled_skips_pin_generation(
|
|||||||
assert room.pin_code is None
|
assert room.pin_code is None
|
||||||
|
|
||||||
|
|
||||||
def test_roomkit_enabled_generates_pin_code(settings):
|
|
||||||
"""Roomkit enabled alone should generate pin codes, even without telephony."""
|
|
||||||
|
|
||||||
settings.ROOM_TELEPHONY_ENABLED = False
|
|
||||||
settings.ROOMKIT_ENABLED = True
|
|
||||||
|
|
||||||
room = RoomFactory()
|
|
||||||
|
|
||||||
assert room.pin_code is not None
|
|
||||||
assert len(room.pin_code) == settings.ROOM_TELEPHONY_PIN_LENGTH
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_and_custom_pin_length(settings):
|
def test_default_and_custom_pin_length(settings):
|
||||||
"""Pin codes should be created with correct configured length."""
|
"""Pin codes should be created with correct configured length."""
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ from rest_framework.routers import DefaultRouter, SimpleRouter
|
|||||||
from core.addons import viewsets as addons_viewsets
|
from core.addons import viewsets as addons_viewsets
|
||||||
from core.api import get_frontend_configuration, viewsets
|
from core.api import get_frontend_configuration, viewsets
|
||||||
from core.external_api import viewsets as external_viewsets
|
from core.external_api import viewsets as external_viewsets
|
||||||
from core.roomkit import viewsets as roomkit_viewsets
|
|
||||||
|
|
||||||
# - Main endpoints
|
# - Main endpoints
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
@@ -20,11 +19,6 @@ router.register("files", viewsets.FileViewSet, basename="files")
|
|||||||
router.register(
|
router.register(
|
||||||
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
|
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
|
||||||
)
|
)
|
||||||
router.register(
|
|
||||||
"roomkit",
|
|
||||||
roomkit_viewsets.RoomKitViewSet,
|
|
||||||
basename="roomkit",
|
|
||||||
)
|
|
||||||
router.register(
|
router.register(
|
||||||
"addons/sessions",
|
"addons/sessions",
|
||||||
addons_viewsets.SessionViewSet,
|
addons_viewsets.SessionViewSet,
|
||||||
|
|||||||
@@ -349,11 +349,6 @@ class Base(Configuration):
|
|||||||
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
|
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
),
|
),
|
||||||
"roomkit_join": values.Value(
|
|
||||||
default="300/minute",
|
|
||||||
environ_name="ROOMKIT_JOIN_THROTTLE_RATES",
|
|
||||||
environ_prefix=None,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
MONITORED_THROTTLE_FAILURE_CALLBACK = (
|
MONITORED_THROTTLE_FAILURE_CALLBACK = (
|
||||||
@@ -886,21 +881,6 @@ class Base(Configuration):
|
|||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Roomkit (meeting-room SIP devices) integration
|
|
||||||
ROOMKIT_ENABLED = values.BooleanValue(
|
|
||||||
False,
|
|
||||||
environ_name="ROOMKIT_ENABLED",
|
|
||||||
environ_prefix=None,
|
|
||||||
)
|
|
||||||
# Server-to-server API token allowing the LiveKit SIP module to call the
|
|
||||||
# roomkit endpoints (e.g. join a room on behalf of a meeting-room device
|
|
||||||
# dialing in before any WebRTC participant).
|
|
||||||
ROOMKIT_SERVER_TO_SERVER_API_TOKEN = SecretFileValue(
|
|
||||||
None,
|
|
||||||
environ_name="ROOMKIT_SERVER_TO_SERVER_API_TOKEN",
|
|
||||||
environ_prefix=None,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Subtitles settings
|
# Subtitles settings
|
||||||
ROOM_SUBTITLE_ENABLED = values.BooleanValue(
|
ROOM_SUBTITLE_ENABLED = values.BooleanValue(
|
||||||
False, environ_name="ROOM_SUBTITLE_ENABLED", environ_prefix=None
|
False, environ_name="ROOM_SUBTITLE_ENABLED", environ_prefix=None
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ dependencies = [
|
|||||||
"django-storages[s3]==1.14.6",
|
"django-storages[s3]==1.14.6",
|
||||||
"django-timezone-field>=5.1",
|
"django-timezone-field>=5.1",
|
||||||
"django-pydantic-field==0.5.4",
|
"django-pydantic-field==0.5.4",
|
||||||
"django==5.2.16",
|
"django==5.2.14",
|
||||||
"djangorestframework==3.17.1",
|
"djangorestframework==3.17.1",
|
||||||
"drf_spectacular==0.29.0",
|
"drf_spectacular==0.29.0",
|
||||||
"dockerflow==2026.3.4",
|
"dockerflow==2026.3.4",
|
||||||
|
|||||||
Generated
+4
-4
@@ -586,16 +586,16 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django"
|
name = "django"
|
||||||
version = "5.2.16"
|
version = "5.2.14"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "asgiref" },
|
{ name = "asgiref" },
|
||||||
{ name = "sqlparse" },
|
{ name = "sqlparse" },
|
||||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/26/889449d521ae508b26de715954faecd8bcf3f740affb81b2d146a83b42a5/django-5.2.16.tar.gz", hash = "sha256:59ea02020c3136fce14bef0bbece21a10a4febef5eed1c51c22ae468efa22200", size = 10890894, upload-time = "2026-07-07T13:52:17.005Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/65/95/95f7faa0950867afaa0bef2460c6263afd6a2c78cc9434046ed28160b015/django-5.2.14.tar.gz", hash = "sha256:58a63ba841662e5c686b57ba1fec52ddd68c0b93bd96ac3029d55728f00bf8a2", size = 10895118, upload-time = "2026-05-05T13:57:31.104Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/4e/13/1e5e3e4c15dcecb04281b3cb2a46a4670e1cef131068e202f6040df19224/django-5.2.16-py3-none-any.whl", hash = "sha256:04f354bf9d807a86ad1a8392fe3808d362358a8eafc322848e0e43e59b24371d", size = 8311943, upload-time = "2026-07-07T13:52:11.223Z" },
|
{ url = "https://files.pythonhosted.org/packages/14/44/f172870cf87aa25afef48fb72adba89ee8b77fcab6f3b23d240b923f1528/django-5.2.14-py3-none-any.whl", hash = "sha256:6f712143bd3064310d1f50fac859c3e9a274bdcfc9595339853be7779297fc76", size = 8311320, upload-time = "2026-05-05T13:57:25.795Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1260,7 +1260,7 @@ requires-dist = [
|
|||||||
{ name = "brotli", specifier = "==1.2.0" },
|
{ name = "brotli", specifier = "==1.2.0" },
|
||||||
{ name = "celery", extras = ["redis"], specifier = "==5.6.3" },
|
{ name = "celery", extras = ["redis"], specifier = "==5.6.3" },
|
||||||
{ name = "dj-database-url", specifier = "==3.1.2" },
|
{ name = "dj-database-url", specifier = "==3.1.2" },
|
||||||
{ name = "django", specifier = "==5.2.16" },
|
{ name = "django", specifier = "==5.2.14" },
|
||||||
{ name = "django-configurations", specifier = "==2.5.1" },
|
{ name = "django-configurations", specifier = "==2.5.1" },
|
||||||
{ name = "django-cors-headers", specifier = "==4.9.0" },
|
{ name = "django-cors-headers", specifier = "==4.9.0" },
|
||||||
{ name = "django-countries", specifier = "==9.0.0" },
|
{ name = "django-countries", specifier = "==9.0.0" },
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { fetchApi } from './fetchApi'
|
|||||||
import { keys } from './queryKeys'
|
import { keys } from './queryKeys'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { RecordingMode } from '@/features/recording'
|
import { RecordingMode } from '@/features/recording'
|
||||||
import type { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
|
||||||
import type { Track } from 'livekit-client'
|
import type { Track } from 'livekit-client'
|
||||||
type Source = Track.Source
|
type Source = Track.Source
|
||||||
|
|
||||||
@@ -50,9 +49,6 @@ export interface ApiConfig {
|
|||||||
international_phone_number?: string
|
international_phone_number?: string
|
||||||
default_country?: string
|
default_country?: string
|
||||||
}
|
}
|
||||||
resource?: {
|
|
||||||
default_access_level?: ApiAccessLevel
|
|
||||||
}
|
|
||||||
manifest_link?: string
|
manifest_link?: string
|
||||||
livekit: {
|
livekit: {
|
||||||
url: string
|
url: string
|
||||||
|
|||||||
@@ -59,10 +59,9 @@ export const Avatar = React.memo(
|
|||||||
<text
|
<text
|
||||||
x="50"
|
x="50"
|
||||||
y="50"
|
y="50"
|
||||||
dy="-0.08em"
|
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
dominantBaseline="central"
|
dominantBaseline="central"
|
||||||
fontSize="52"
|
fontSize={initials.length > 1 ? 48 : 52}
|
||||||
fontWeight="500"
|
fontWeight="500"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
import { BackendLanguage } from '@/utils/languages'
|
import { BackendLanguage } from '@/utils/languages'
|
||||||
import type {
|
|
||||||
ApiAccessLevel,
|
|
||||||
RoomConfiguration,
|
|
||||||
} from '@/features/rooms/api/ApiRoom'
|
|
||||||
|
|
||||||
export type ApiUser = {
|
export type ApiUser = {
|
||||||
id: string
|
id: string
|
||||||
@@ -11,6 +7,4 @@ export type ApiUser = {
|
|||||||
last_name: string
|
last_name: string
|
||||||
language: BackendLanguage
|
language: BackendLanguage
|
||||||
timezone: string
|
timezone: string
|
||||||
default_room_access_level?: ApiAccessLevel | null
|
|
||||||
default_room_configuration?: RoomConfiguration | null
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import { useMutation, type UseMutationOptions } from '@tanstack/react-query'
|
|
||||||
import { fetchApi } from '@/api/fetchApi'
|
|
||||||
import type { ApiError } from '@/api/ApiError'
|
|
||||||
import { type ApiUser } from './ApiUser'
|
|
||||||
|
|
||||||
export type PatchUserParams = {
|
|
||||||
userId: string
|
|
||||||
user: Partial<
|
|
||||||
Pick<
|
|
||||||
ApiUser,
|
|
||||||
| 'timezone'
|
|
||||||
| 'language'
|
|
||||||
| 'default_room_access_level'
|
|
||||||
| 'default_room_configuration'
|
|
||||||
>
|
|
||||||
>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const patchUser = ({ userId, user }: PatchUserParams) => {
|
|
||||||
return fetchApi<ApiUser>(`/users/${userId}/`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: JSON.stringify(user),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export const patchUserMutationKey = ['patchUser']
|
|
||||||
|
|
||||||
export function usePatchUser(
|
|
||||||
options?: UseMutationOptions<ApiUser, ApiError, PatchUserParams>
|
|
||||||
) {
|
|
||||||
return useMutation<ApiUser, ApiError, PatchUserParams>({
|
|
||||||
mutationKey: patchUserMutationKey,
|
|
||||||
mutationFn: patchUser,
|
|
||||||
...options,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,7 @@ import { getScrollBarWidth } from '@livekit/components-core'
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
|
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
|
||||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||||
|
import { useCallback, useEffect, useLayoutEffect } from 'react'
|
||||||
|
|
||||||
const MIN_HEIGHT = 130
|
const MIN_HEIGHT = 130
|
||||||
const MIN_WIDTH = 140
|
const MIN_WIDTH = 140
|
||||||
@@ -10,6 +11,72 @@ const MIN_VISIBLE_TILES = 1
|
|||||||
const ASPECT_RATIO = 16 / 10
|
const ASPECT_RATIO = 16 / 10
|
||||||
const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1
|
const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1
|
||||||
|
|
||||||
|
type CarouselOrientation = 'vertical' | 'horizontal'
|
||||||
|
|
||||||
|
interface CarouselLayoutState {
|
||||||
|
orientation: CarouselOrientation
|
||||||
|
maxVisibleTiles: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CarouselLayoutObserverProps {
|
||||||
|
asideEl: React.RefObject<HTMLDivElement>
|
||||||
|
orientation?: CarouselOrientation
|
||||||
|
onLayoutChange: (layout: CarouselLayoutState) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const CarouselLayoutObserver = ({
|
||||||
|
orientation,
|
||||||
|
asideEl,
|
||||||
|
onLayoutChange,
|
||||||
|
}: CarouselLayoutObserverProps) => {
|
||||||
|
const { width, height } = useSize(asideEl)
|
||||||
|
|
||||||
|
// Hysteresis memory: avoids flapping between N and N+1 tiles when the
|
||||||
|
// container size hovers around a breakpoint. A ref (not state) because
|
||||||
|
// updating it must not trigger a re-render.
|
||||||
|
const prevTilesRef = React.useRef(0)
|
||||||
|
|
||||||
|
const carouselOrientation: CarouselOrientation =
|
||||||
|
orientation ?? (height >= width ? 'vertical' : 'horizontal')
|
||||||
|
|
||||||
|
const tileSpan =
|
||||||
|
carouselOrientation === 'vertical'
|
||||||
|
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
|
||||||
|
: Math.max(height * ASPECT_RATIO, MIN_WIDTH)
|
||||||
|
|
||||||
|
const scrollBarWidth = getScrollBarWidth()
|
||||||
|
|
||||||
|
const availableSpan =
|
||||||
|
(carouselOrientation === 'vertical' ? height : width) - scrollBarWidth
|
||||||
|
|
||||||
|
const tilesThatFit = Math.max(availableSpan / tileSpan, MIN_VISIBLE_TILES)
|
||||||
|
|
||||||
|
let maxVisibleTiles: number
|
||||||
|
if (Math.abs(tilesThatFit - prevTilesRef.current) < 0.5) {
|
||||||
|
// Within the dead zone: keep the previous count.
|
||||||
|
maxVisibleTiles = Math.round(prevTilesRef.current)
|
||||||
|
} else {
|
||||||
|
maxVisibleTiles = Math.round(tilesThatFit)
|
||||||
|
prevTilesRef.current = tilesThatFit
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply cosmetic layout output straight to the DOM.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const el = asideEl.current
|
||||||
|
if (!el) return
|
||||||
|
el.dataset.lkOrientation = carouselOrientation
|
||||||
|
el.style.setProperty('--lk-max-visible-tiles', maxVisibleTiles.toString())
|
||||||
|
}, [asideEl, carouselOrientation, maxVisibleTiles])
|
||||||
|
|
||||||
|
// Report upward only what the parent actually needs for
|
||||||
|
// `useVisualStableUpdate` (and only when it changes — see parent handler).
|
||||||
|
useEffect(() => {
|
||||||
|
onLayoutChange({ orientation: carouselOrientation, maxVisibleTiles })
|
||||||
|
}, [carouselOrientation, maxVisibleTiles, onLayoutChange])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export interface CarouselLayoutProps extends React.HTMLAttributes<HTMLMediaElement> {
|
export interface CarouselLayoutProps extends React.HTMLAttributes<HTMLMediaElement> {
|
||||||
tracks: TrackReferenceOrPlaceholder[]
|
tracks: TrackReferenceOrPlaceholder[]
|
||||||
@@ -40,52 +107,42 @@ export function CarouselLayout({
|
|||||||
...props
|
...props
|
||||||
}: CarouselLayoutProps) {
|
}: CarouselLayoutProps) {
|
||||||
const asideEl = React.useRef<HTMLDivElement>(null)
|
const asideEl = React.useRef<HTMLDivElement>(null)
|
||||||
const [prevTiles, setPrevTiles] = React.useState(0)
|
|
||||||
const { width, height } = useSize(asideEl)
|
|
||||||
const carouselOrientation = orientation
|
|
||||||
? orientation
|
|
||||||
: height >= width
|
|
||||||
? 'vertical'
|
|
||||||
: 'horizontal'
|
|
||||||
|
|
||||||
const tileSpan =
|
const [layout, setLayout] = React.useState<CarouselLayoutState>({
|
||||||
carouselOrientation === 'vertical'
|
orientation: orientation ?? 'vertical',
|
||||||
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
|
maxVisibleTiles: MIN_VISIBLE_TILES,
|
||||||
: Math.max(height * ASPECT_RATIO, MIN_WIDTH)
|
})
|
||||||
const scrollBarWidth = getScrollBarWidth()
|
|
||||||
|
|
||||||
const tilesThatFit =
|
// Stable callback + identity check: the parent only re-renders when the
|
||||||
carouselOrientation === 'vertical'
|
// derived layout genuinely changed, not on every resize tick.
|
||||||
? Math.max((height - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
|
const handleLayoutChange = useCallback((next: CarouselLayoutState) => {
|
||||||
: Math.max((width - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
|
setLayout((prev) =>
|
||||||
|
prev.orientation === next.orientation &&
|
||||||
|
prev.maxVisibleTiles === next.maxVisibleTiles
|
||||||
|
? prev
|
||||||
|
: next
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
let maxVisibleTiles = Math.round(tilesThatFit)
|
const sortedTiles = useVisualStableUpdate(tracks, layout.maxVisibleTiles)
|
||||||
if (Math.abs(tilesThatFit - prevTiles) < 0.5) {
|
|
||||||
maxVisibleTiles = Math.round(prevTiles)
|
|
||||||
} else if (prevTiles !== tilesThatFit) {
|
|
||||||
setPrevTiles(tilesThatFit)
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortedTiles = useVisualStableUpdate(tracks, maxVisibleTiles)
|
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
|
||||||
if (asideEl.current) {
|
|
||||||
asideEl.current.dataset.lkOrientation = carouselOrientation
|
|
||||||
asideEl.current.style.setProperty(
|
|
||||||
'--lk-max-visible-tiles',
|
|
||||||
maxVisibleTiles.toString()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}, [maxVisibleTiles, carouselOrientation])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<>
|
||||||
key={carouselOrientation}
|
<CarouselLayoutObserver
|
||||||
className="lk-carousel"
|
asideEl={asideEl}
|
||||||
ref={asideEl}
|
orientation={orientation}
|
||||||
{...props}
|
onLayoutChange={handleLayoutChange}
|
||||||
>
|
/>
|
||||||
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
|
{/* `key` intentionally remounts the container when orientation flips, */}
|
||||||
</aside>
|
{/* which resets scroll position and re-runs the observer measurement. */}
|
||||||
|
<aside
|
||||||
|
key={layout.orientation}
|
||||||
|
className="lk-carousel"
|
||||||
|
ref={asideEl}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
|
||||||
|
</aside>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,37 @@ import { mergeProps } from '@/utils/mergeProps'
|
|||||||
import { PaginationIndicator } from './PaginationIndicator'
|
import { PaginationIndicator } from './PaginationIndicator'
|
||||||
import { useGridLayout } from '../hooks/useGridLayout'
|
import { useGridLayout } from '../hooks/useGridLayout'
|
||||||
import { PaginationControl } from './PaginationControl'
|
import { PaginationControl } from './PaginationControl'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useSpeakerPromotionTrigger } from '../hooks/useSpeakerPromotionTrigger'
|
import { useSpeakerPromotionTrigger } from '../hooks/useSpeakerPromotionTrigger'
|
||||||
|
|
||||||
|
interface GridLayoutObserverProps {
|
||||||
|
gridEl: React.RefObject<HTMLDivElement>
|
||||||
|
trackCount: number
|
||||||
|
onMaxTilesChange: (maxTiles: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Headless component that runs the layout calculation in isolation and
|
||||||
|
* reports the resulting tile capacity upward.
|
||||||
|
*
|
||||||
|
* `useGridLayout` re-renders its host on every layout recalculation
|
||||||
|
* (e.g. container resizes). Rendering it in a null child means only this
|
||||||
|
* component churns; the parent `GridLayout` re-renders solely when
|
||||||
|
* `maxTiles` actually changes, since `setState` bails out on equal values.
|
||||||
|
*/
|
||||||
|
const GridLayoutObserver = ({
|
||||||
|
gridEl,
|
||||||
|
trackCount,
|
||||||
|
onMaxTilesChange,
|
||||||
|
}: GridLayoutObserverProps) => {
|
||||||
|
const { layout } = useGridLayout(gridEl, trackCount)
|
||||||
|
useEffect(() => {
|
||||||
|
onMaxTilesChange(layout.maxTiles)
|
||||||
|
}, [onMaxTilesChange, layout.maxTiles])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export interface GridLayoutProps
|
export interface GridLayoutProps
|
||||||
extends
|
extends
|
||||||
@@ -38,15 +67,14 @@ export interface GridLayoutProps
|
|||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
||||||
const gridEl = React.createRef<HTMLDivElement>()
|
const gridEl = useRef<HTMLDivElement>(null)
|
||||||
|
const [maxTiles, setMaxTiles] = useState(1)
|
||||||
|
|
||||||
const elementProps = React.useMemo(
|
const elementProps = React.useMemo(
|
||||||
() => mergeProps(props, { className: 'lk-grid-layout' }),
|
() => mergeProps(props, { className: 'lk-grid-layout' }),
|
||||||
[props]
|
[props]
|
||||||
)
|
)
|
||||||
|
const pagination = usePagination(maxTiles, tracks)
|
||||||
const { layout } = useGridLayout(gridEl, tracks.length)
|
|
||||||
const pagination = usePagination(layout.maxTiles, tracks)
|
|
||||||
useSpeakerPromotionTrigger(pagination.tracks)
|
useSpeakerPromotionTrigger(pagination.tracks)
|
||||||
|
|
||||||
useSwipe(gridEl, {
|
useSwipe(gridEl, {
|
||||||
@@ -60,8 +88,13 @@ export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
|||||||
data-lk-pagination={pagination.totalPageCount > 1}
|
data-lk-pagination={pagination.totalPageCount > 1}
|
||||||
{...elementProps}
|
{...elementProps}
|
||||||
>
|
>
|
||||||
|
<GridLayoutObserver
|
||||||
|
gridEl={gridEl}
|
||||||
|
trackCount={tracks.length}
|
||||||
|
onMaxTilesChange={setMaxTiles}
|
||||||
|
/>
|
||||||
<TrackLoop tracks={pagination.tracks}>{props.children}</TrackLoop>
|
<TrackLoop tracks={pagination.tracks}>{props.children}</TrackLoop>
|
||||||
{tracks.length > layout.maxTiles && (
|
{tracks.length > maxTiles && (
|
||||||
<>
|
<>
|
||||||
<PaginationIndicator
|
<PaginationIndicator
|
||||||
totalPageCount={pagination.totalPageCount}
|
totalPageCount={pagination.totalPageCount}
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
import { useLocalParticipant } from '@livekit/components-react'
|
|
||||||
import { useEffect } from 'react'
|
|
||||||
|
|
||||||
export const MEDIA_STATE_ELEMENT_ID = 'media-state'
|
|
||||||
export const MEDIA_STATE_CHANGED_EVENT = 'media-state-changed'
|
|
||||||
|
|
||||||
export type MediaStateChangedDetail = {
|
|
||||||
microphoneEnabled: boolean
|
|
||||||
cameraEnabled: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Exposes the local participant's media state in the DOM so external tools
|
|
||||||
* (e.g. bots automating the frontend) can reliably read the microphone and
|
|
||||||
* camera state, and watch for changes with a MutationObserver:
|
|
||||||
*
|
|
||||||
* const el = document.getElementById('media-state')
|
|
||||||
* new MutationObserver(...).observe(el, { attributes: true })
|
|
||||||
*/
|
|
||||||
export const MediaStateObserver = () => {
|
|
||||||
const { isMicrophoneEnabled, isCameraEnabled } = useLocalParticipant()
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent<MediaStateChangedDetail>(MEDIA_STATE_CHANGED_EVENT, {
|
|
||||||
detail: {
|
|
||||||
microphoneEnabled: isMicrophoneEnabled,
|
|
||||||
cameraEnabled: isCameraEnabled,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}, [isMicrophoneEnabled, isCameraEnabled])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
id={MEDIA_STATE_ELEMENT_ID}
|
|
||||||
style={{ display: 'none' }}
|
|
||||||
data-microphone-enabled={isMicrophoneEnabled ? 'true' : 'false'}
|
|
||||||
data-camera-enabled={isCameraEnabled ? 'true' : 'false'}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,6 @@ import { SidePanel } from '../components/SidePanel'
|
|||||||
import { RecordingProvider } from '@/features/recording'
|
import { RecordingProvider } from '@/features/recording'
|
||||||
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
||||||
import { ConnectionObserver } from '../components/ConnectionObserver'
|
import { ConnectionObserver } from '../components/ConnectionObserver'
|
||||||
import { MediaStateObserver } from '../components/MediaStateObserver'
|
|
||||||
import { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer'
|
import { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer'
|
||||||
import { useRoomPageTitle } from '../hooks/useRoomPageTitle'
|
import { useRoomPageTitle } from '../hooks/useRoomPageTitle'
|
||||||
import { useNoiseReduction } from '../hooks/useNoiseReduction'
|
import { useNoiseReduction } from '../hooks/useNoiseReduction'
|
||||||
@@ -64,7 +63,6 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
<>
|
<>
|
||||||
<RoomMetadataSynchronizer />
|
<RoomMetadataSynchronizer />
|
||||||
<ConnectionObserver />
|
<ConnectionObserver />
|
||||||
<MediaStateObserver />
|
|
||||||
<ChatProvider />
|
<ChatProvider />
|
||||||
<VideoResolutionSubscription />
|
<VideoResolutionSubscription />
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -2,12 +2,9 @@ export class CallbackIdHandler {
|
|||||||
private readonly storageKey = 'popup_callback_id'
|
private readonly storageKey = 'popup_callback_id'
|
||||||
|
|
||||||
private generateId(): string {
|
private generateId(): string {
|
||||||
// The id is the only thing guarding /rooms/creation-callback/, which is
|
return (
|
||||||
// unauthenticated, so it comes from the CSPRNG rather than Math.random.
|
Math.random().toString(36).substring(2, 15) +
|
||||||
const bytes = new Uint8Array(16)
|
Math.random().toString(36).substring(2, 15)
|
||||||
crypto.getRandomValues(bytes)
|
|
||||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(
|
|
||||||
''
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,79 +1,22 @@
|
|||||||
import { Trans, useTranslation } from 'react-i18next'
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
import { useRef } from 'react'
|
|
||||||
import { Heading } from 'react-aria-components'
|
|
||||||
import { RiSettings3Line, RiDoorOpenLine } from '@remixicon/react'
|
|
||||||
import { useLanguageLabels } from '@/i18n/useLanguageLabels'
|
import { useLanguageLabels } from '@/i18n/useLanguageLabels'
|
||||||
import { A, Badge, Dialog, type DialogProps, Field, H, P } from '@/primitives'
|
import { A, Badge, Dialog, type DialogProps, Field, H, P } from '@/primitives'
|
||||||
import { Tab, TabList, TabPanel, Tabs } from '@/primitives/Tabs'
|
|
||||||
import { text } from '@/primitives/Text.tsx'
|
|
||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { useUser } from '@/features/auth/api/useUser'
|
import { useUser } from '@/features/auth/api/useUser'
|
||||||
import { LoginButton } from '@/components/LoginButton'
|
import { LoginButton } from '@/components/LoginButton'
|
||||||
import { logout } from '@/features/auth/utils/logout'
|
import { logout } from '@/features/auth/utils/logout'
|
||||||
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
|
|
||||||
import { RoomsTab } from './tabs/RoomsTab'
|
|
||||||
|
|
||||||
export type SettingsDialogProps = Pick<DialogProps, 'isOpen' | 'onOpenChange'>
|
export type SettingsDialogProps = Pick<DialogProps, 'isOpen' | 'onOpenChange'>
|
||||||
|
|
||||||
enum SettingsDialogTabKey {
|
|
||||||
GENERAL = 'general',
|
|
||||||
ROOMS = 'rooms',
|
|
||||||
}
|
|
||||||
|
|
||||||
const tabsStyle = css({
|
|
||||||
maxHeight: '40.625rem', // fixme size copied from meet settings modal
|
|
||||||
width: '50rem', // fixme size copied from meet settings modal
|
|
||||||
marginY: '-1rem', // fixme hacky solution to cancel modal padding
|
|
||||||
maxWidth: '100%',
|
|
||||||
overflow: 'hidden',
|
|
||||||
height: 'calc(100vh - 2rem)',
|
|
||||||
})
|
|
||||||
|
|
||||||
const tabListContainerStyle = css({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
borderRight: '1px solid lightGray', // fixme poor color management
|
|
||||||
paddingY: '1rem',
|
|
||||||
paddingLeft: '0.2rem',
|
|
||||||
paddingRight: '1.5rem',
|
|
||||||
})
|
|
||||||
|
|
||||||
const tabPanelContainerStyle = css({
|
|
||||||
display: 'flex',
|
|
||||||
flexGrow: '1',
|
|
||||||
marginTop: '3.5rem',
|
|
||||||
minWidth: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
const tabPanelStyle = css({
|
|
||||||
flexGrow: '1',
|
|
||||||
minWidth: 0,
|
|
||||||
overflowY: 'auto',
|
|
||||||
paddingRight: '1.5rem',
|
|
||||||
paddingBottom: '1rem',
|
|
||||||
})
|
|
||||||
|
|
||||||
export const SettingsDialog = (props: SettingsDialogProps) => {
|
export const SettingsDialog = (props: SettingsDialogProps) => {
|
||||||
const { t, i18n } = useTranslation('settings')
|
const { t, i18n } = useTranslation('settings')
|
||||||
const { user, isLoggedIn } = useUser()
|
const { user, isLoggedIn } = useUser()
|
||||||
const { languagesList, currentLanguage } = useLanguageLabels()
|
const { languagesList, currentLanguage } = useLanguageLabels()
|
||||||
|
|
||||||
const dialogEl = useRef<HTMLDivElement>(null)
|
|
||||||
const isWideScreen = useMediaQuery('(min-width: 800px)') // fixme - hardcoded 50rem in pixel
|
|
||||||
|
|
||||||
const userDisplay =
|
const userDisplay =
|
||||||
user?.full_name && user?.email
|
user?.full_name && user?.email
|
||||||
? `${user.full_name} (${user.email})`
|
? `${user.full_name} (${user.email})`
|
||||||
: user?.email
|
: user?.email
|
||||||
|
return (
|
||||||
const generalContent = (
|
<Dialog title={t('dialog.heading')} {...props}>
|
||||||
<div
|
|
||||||
className={css({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
minWidth: '360px',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<H lvl={2}>{t('account.heading')}</H>
|
<H lvl={2}>{t('account.heading')}</H>
|
||||||
{isLoggedIn ? (
|
{isLoggedIn ? (
|
||||||
<>
|
<>
|
||||||
@@ -104,56 +47,6 @@ export const SettingsDialog = (props: SettingsDialogProps) => {
|
|||||||
i18n.changeLanguage(lang as string)
|
i18n.changeLanguage(lang as string)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
// Without tabs there is no rail to host the heading, so keep the plain dialog.
|
|
||||||
if (!isLoggedIn) {
|
|
||||||
return (
|
|
||||||
<Dialog title={t('dialog.heading')} {...props} role="dialog" type="flex">
|
|
||||||
{generalContent}
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog innerRef={dialogEl} {...props} role="dialog" type="flex">
|
|
||||||
<Tabs
|
|
||||||
orientation="vertical"
|
|
||||||
className={tabsStyle}
|
|
||||||
defaultSelectedKey={SettingsDialogTabKey.GENERAL}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={tabListContainerStyle}
|
|
||||||
style={{
|
|
||||||
flex: isWideScreen ? '0 0 16rem' : undefined,
|
|
||||||
paddingTop: !isWideScreen ? '64px' : undefined,
|
|
||||||
paddingRight: !isWideScreen ? '1rem' : undefined,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isWideScreen && (
|
|
||||||
<Heading slot="title" level={1} className={text({ variant: 'h1' })}>
|
|
||||||
{t('dialog.heading')}
|
|
||||||
</Heading>
|
|
||||||
)}
|
|
||||||
<TabList border={false}>
|
|
||||||
<Tab icon highlight id={SettingsDialogTabKey.GENERAL}>
|
|
||||||
<RiSettings3Line />
|
|
||||||
{isWideScreen && t(`tabs.${SettingsDialogTabKey.GENERAL}`)}
|
|
||||||
</Tab>
|
|
||||||
<Tab icon highlight id={SettingsDialogTabKey.ROOMS}>
|
|
||||||
<RiDoorOpenLine />
|
|
||||||
{isWideScreen && t(`tabs.${SettingsDialogTabKey.ROOMS}`)}
|
|
||||||
</Tab>
|
|
||||||
</TabList>
|
|
||||||
</div>
|
|
||||||
<div className={tabPanelContainerStyle}>
|
|
||||||
<TabPanel id={SettingsDialogTabKey.GENERAL} className={tabPanelStyle}>
|
|
||||||
{generalContent}
|
|
||||||
</TabPanel>
|
|
||||||
<RoomsTab id={SettingsDialogTabKey.ROOMS} />
|
|
||||||
</div>
|
|
||||||
</Tabs>
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,226 +0,0 @@
|
|||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { useUser } from '@/features/auth/api/useUser'
|
|
||||||
import { useConfig } from '@/api/useConfig'
|
|
||||||
import {
|
|
||||||
usePatchUser,
|
|
||||||
patchUserMutationKey,
|
|
||||||
} from '@/features/auth/api/patchUser'
|
|
||||||
import { type ApiUser } from '@/features/auth/api/ApiUser'
|
|
||||||
import { ApiAccessLevel, RoomConfiguration } from '@/features/rooms/api/ApiRoom'
|
|
||||||
import { useMemo } from 'react'
|
|
||||||
import { queryClient } from '@/api/queryClient'
|
|
||||||
import { keys } from '@/api/queryKeys'
|
|
||||||
import { Track } from 'livekit-client'
|
|
||||||
import Source = Track.Source
|
|
||||||
import { isSubsetOf } from '@/features/rooms/utils/isSubsetOf'
|
|
||||||
import { updatePublishSources } from '@/features/rooms/livekit/hooks/usePublishSourcesManager'
|
|
||||||
import { Field, H, Text } from '@/primitives'
|
|
||||||
import { TabPanel } from '@/primitives/Tabs'
|
|
||||||
import { css } from '@/styled-system/css'
|
|
||||||
import { Separator as RACSeparator } from 'react-aria-components'
|
|
||||||
|
|
||||||
type RoomsTabProps = {
|
|
||||||
id: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const RoomsTab = ({ id }: RoomsTabProps) => {
|
|
||||||
const { t } = useTranslation('settings', { keyPrefix: 'roomDefaults' })
|
|
||||||
const { t: tAdmin } = useTranslation('rooms', {
|
|
||||||
keyPrefix: 'admin',
|
|
||||||
useSuspense: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
const { user } = useUser()
|
|
||||||
const { data: configData } = useConfig()
|
|
||||||
|
|
||||||
// Optimistic updates: patch the cache immediately so the UI updates
|
|
||||||
// instantly and concurrent saves always build on the latest local state.
|
|
||||||
// Since each PATCH replaces the full JSON config, this avoids overwriting
|
|
||||||
// earlier changes with a stale snapshot.
|
|
||||||
//
|
|
||||||
// No per-request rollback: later requests already include earlier changes.
|
|
||||||
// Once the last in-flight save completes, re-fetch the server state once to
|
|
||||||
// restore the UI if all saves failed.
|
|
||||||
const { mutate: patchUser } = usePatchUser({
|
|
||||||
onMutate: async ({ user: partialUser }) => {
|
|
||||||
await queryClient.cancelQueries({ queryKey: [keys.user] })
|
|
||||||
queryClient.setQueryData<ApiUser | false>([keys.user], (previous) =>
|
|
||||||
previous ? { ...previous, ...partialUser } : previous
|
|
||||||
)
|
|
||||||
},
|
|
||||||
onSettled: () => {
|
|
||||||
if (queryClient.isMutating({ mutationKey: patchUserMutationKey }) === 1) {
|
|
||||||
queryClient.invalidateQueries({ queryKey: [keys.user] })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
const configuration: RoomConfiguration = useMemo(
|
|
||||||
() => user?.default_room_configuration ?? {},
|
|
||||||
[user?.default_room_configuration]
|
|
||||||
)
|
|
||||||
|
|
||||||
const currentSources: Source[] = useMemo(() => {
|
|
||||||
const defaultSources = configData?.livekit?.default_sources ?? []
|
|
||||||
if (!Array.isArray(configuration?.can_publish_sources)) {
|
|
||||||
return defaultSources
|
|
||||||
}
|
|
||||||
return configuration.can_publish_sources
|
|
||||||
}, [configData, configuration])
|
|
||||||
|
|
||||||
const accessLevel =
|
|
||||||
user?.default_room_access_level ??
|
|
||||||
configData?.resource?.default_access_level ??
|
|
||||||
ApiAccessLevel.PUBLIC
|
|
||||||
|
|
||||||
// Every change saves immediately; the optimistic onMutate above keeps the
|
|
||||||
// cached user (and therefore `configuration`) in sync right away.
|
|
||||||
const saveConfiguration = (newConfiguration: RoomConfiguration) => {
|
|
||||||
if (!user) return
|
|
||||||
patchUser({
|
|
||||||
userId: user.id,
|
|
||||||
user: { default_room_configuration: newConfiguration },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateSource = (sources: Source[], enabled: boolean) =>
|
|
||||||
saveConfiguration({
|
|
||||||
...configuration,
|
|
||||||
can_publish_sources: updatePublishSources(
|
|
||||||
currentSources,
|
|
||||||
sources,
|
|
||||||
enabled
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
const isMicrophoneEnabled = isSubsetOf([Source.Microphone], currentSources)
|
|
||||||
const isCameraEnabled = isSubsetOf([Source.Camera], currentSources)
|
|
||||||
const isScreenShareEnabled = isSubsetOf(
|
|
||||||
[Source.ScreenShare, Source.ScreenShareAudio],
|
|
||||||
currentSources
|
|
||||||
)
|
|
||||||
const isMutingEnabled = configuration?.everyone_can_mute ?? true
|
|
||||||
|
|
||||||
const saveAccessLevel = (newAccessLevel: ApiAccessLevel) => {
|
|
||||||
if (!user) return
|
|
||||||
patchUser({
|
|
||||||
userId: user.id,
|
|
||||||
user: { default_room_access_level: newAccessLevel },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TabPanel padding={'md'} flex id={id}>
|
|
||||||
<H lvl={2}>{t('heading')}</H>
|
|
||||||
<Text variant="note" margin={'md'}>
|
|
||||||
{t('description')}
|
|
||||||
</Text>
|
|
||||||
<RACSeparator
|
|
||||||
className={css({
|
|
||||||
border: 'none',
|
|
||||||
height: '1px',
|
|
||||||
width: '100%',
|
|
||||||
flexShrink: 0,
|
|
||||||
background: 'greyscale.250',
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<H
|
|
||||||
lvl={3}
|
|
||||||
variant={'h2'}
|
|
||||||
className={css({
|
|
||||||
fontWeight: 500,
|
|
||||||
})}
|
|
||||||
margin="sm"
|
|
||||||
>
|
|
||||||
{tAdmin('moderation.title')}
|
|
||||||
</H>
|
|
||||||
<Text
|
|
||||||
variant="note"
|
|
||||||
wrap="balance"
|
|
||||||
className={css({
|
|
||||||
textStyle: 'sm',
|
|
||||||
})}
|
|
||||||
margin={'md'}
|
|
||||||
>
|
|
||||||
{tAdmin('moderation.description')}
|
|
||||||
</Text>
|
|
||||||
<Field
|
|
||||||
type="switch"
|
|
||||||
label={tAdmin('moderation.microphone.label')}
|
|
||||||
isSelected={isMicrophoneEnabled}
|
|
||||||
onChange={(enabled) => updateSource([Source.Microphone], enabled)}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
type="switch"
|
|
||||||
label={tAdmin('moderation.camera.label')}
|
|
||||||
isSelected={isCameraEnabled}
|
|
||||||
onChange={(enabled) => updateSource([Source.Camera], enabled)}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
type="switch"
|
|
||||||
label={tAdmin('moderation.screenshare.label')}
|
|
||||||
isSelected={isScreenShareEnabled}
|
|
||||||
onChange={(enabled) =>
|
|
||||||
updateSource([Source.ScreenShare, Source.ScreenShareAudio], enabled)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Field
|
|
||||||
type="switch"
|
|
||||||
label={tAdmin('moderation.mute.label')}
|
|
||||||
isSelected={isMutingEnabled}
|
|
||||||
onChange={(enabled) =>
|
|
||||||
saveConfiguration({ ...configuration, everyone_can_mute: enabled })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<RACSeparator
|
|
||||||
className={css({
|
|
||||||
border: 'none',
|
|
||||||
height: '1px',
|
|
||||||
width: '100%',
|
|
||||||
flexShrink: 0,
|
|
||||||
marginY: '1rem',
|
|
||||||
background: 'greyscale.250',
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<H
|
|
||||||
lvl={3}
|
|
||||||
variant={'h2'}
|
|
||||||
className={css({
|
|
||||||
fontWeight: 500,
|
|
||||||
})}
|
|
||||||
margin="sm"
|
|
||||||
>
|
|
||||||
{tAdmin('access.title')}
|
|
||||||
</H>
|
|
||||||
<Field
|
|
||||||
type="radioGroup"
|
|
||||||
label={tAdmin('access.type')}
|
|
||||||
value={accessLevel}
|
|
||||||
labelProps={{
|
|
||||||
className: css({
|
|
||||||
fontSize: '1rem',
|
|
||||||
paddingBottom: '1rem',
|
|
||||||
}),
|
|
||||||
}}
|
|
||||||
onChange={(value) => saveAccessLevel(value as ApiAccessLevel)}
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
value: ApiAccessLevel.PUBLIC,
|
|
||||||
label: tAdmin('access.levels.public.label'),
|
|
||||||
description: tAdmin('access.levels.public.description'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: ApiAccessLevel.TRUSTED,
|
|
||||||
label: tAdmin('access.levels.trusted.label'),
|
|
||||||
description: tAdmin('access.levels.trusted.description'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: ApiAccessLevel.RESTRICTED,
|
|
||||||
label: tAdmin('access.levels.restricted.label'),
|
|
||||||
description: tAdmin('access.levels.restricted.description'),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</TabPanel>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -179,11 +179,6 @@
|
|||||||
"notifications": "Benachrichtigungen",
|
"notifications": "Benachrichtigungen",
|
||||||
"accessibility": "Barrierefreiheit",
|
"accessibility": "Barrierefreiheit",
|
||||||
"transcription": "Transkription",
|
"transcription": "Transkription",
|
||||||
"shortcuts": "Tastenkürzel",
|
"shortcuts": "Tastenkürzel"
|
||||||
"rooms": "Räume"
|
|
||||||
},
|
|
||||||
"roomDefaults": {
|
|
||||||
"heading": "Standardeinstellungen für Räume",
|
|
||||||
"description": "Wählen Sie die Einstellungen, die standardmäßig auf neue von Ihnen erstellte Räume angewendet werden. Sie können sie für jedes Meeting weiterhin in den Moderationseinstellungen ändern."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,11 +179,6 @@
|
|||||||
"notifications": "Notifications",
|
"notifications": "Notifications",
|
||||||
"accessibility": "Accessibility",
|
"accessibility": "Accessibility",
|
||||||
"transcription": "Transcription",
|
"transcription": "Transcription",
|
||||||
"shortcuts": "Shortcuts",
|
"shortcuts": "Shortcuts"
|
||||||
"rooms": "Rooms"
|
|
||||||
},
|
|
||||||
"roomDefaults": {
|
|
||||||
"heading": "Default room settings",
|
|
||||||
"description": "Choose the settings applied by default to the new rooms you create. You can still change them for each meeting from the host settings."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,11 +179,6 @@
|
|||||||
"notifications": "Notifications",
|
"notifications": "Notifications",
|
||||||
"accessibility": "Accessibilité",
|
"accessibility": "Accessibilité",
|
||||||
"transcription": "Transcription",
|
"transcription": "Transcription",
|
||||||
"shortcuts": "Raccourcis",
|
"shortcuts": "Raccourcis"
|
||||||
"rooms": "Réunions"
|
|
||||||
},
|
|
||||||
"roomDefaults": {
|
|
||||||
"heading": "Paramètres par défaut des réunions",
|
|
||||||
"description": "Choisissez les paramètres appliqués par défaut aux nouvelles réunions que vous créez. Vous pourrez toujours les modifier pour chaque réunion depuis les paramètres d’administration."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,11 +179,6 @@
|
|||||||
"notifications": "Meldingen",
|
"notifications": "Meldingen",
|
||||||
"accessibility": "Toegankelijkheid",
|
"accessibility": "Toegankelijkheid",
|
||||||
"transcription": "Transcriptie",
|
"transcription": "Transcriptie",
|
||||||
"shortcuts": "Sneltoetsen",
|
"shortcuts": "Sneltoetsen"
|
||||||
"rooms": "Vergaderingen"
|
|
||||||
},
|
|
||||||
"roomDefaults": {
|
|
||||||
"heading": "Standaardinstellingen voor vergaderingen",
|
|
||||||
"description": "Kies de instellingen die standaard worden toegepast op nieuwe vergaderingen die u aanmaakt. U kunt ze voor elke vergadering nog steeds wijzigen via de hostinstellingen."
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -143,9 +143,13 @@ def test_media_info_ignores_empty_stream_entry(monkeypatch: pytest.MonkeyPatch)
|
|||||||
|
|
||||||
def test_extract_audio_from_video():
|
def test_extract_audio_from_video():
|
||||||
"""Test that extract_audio_from_video can extract audio from a video file."""
|
"""Test that extract_audio_from_video can extract audio from a video file."""
|
||||||
path = extract_audio_from_media(MEDIA_INFO_SAMPLE_VISIO)
|
path = None
|
||||||
# A bit of cleanup logic since this is not a generator
|
# A bit of cleanup logic since this is not a generator
|
||||||
try:
|
try:
|
||||||
|
path = extract_audio_from_media(MEDIA_INFO_SAMPLE_VISIO)
|
||||||
assert path.name.endswith(".m4a")
|
assert path.name.endswith(".m4a")
|
||||||
|
except Exception as e:
|
||||||
|
pytest.fail(f"Failed to extract audio from video: {e}")
|
||||||
finally:
|
finally:
|
||||||
path.unlink(missing_ok=True)
|
if path and path.exists():
|
||||||
|
path.unlink()
|
||||||
|
|||||||
Reference in New Issue
Block a user