mirror of
https://github.com/suitenumerique/meet.git
synced 2026-07-30 21:52:16 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5790bb1e47 | |||
| ca80540f7e | |||
| 80f3af0690 | |||
| 399617e247 |
@@ -14,6 +14,7 @@ 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
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -389,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
|
||||||
@@ -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).
|
||||||
|
|||||||
@@ -8,3 +8,6 @@ class AnalyticsEvent(StrEnum):
|
|||||||
|
|
||||||
# Rooms
|
# Rooms
|
||||||
ROOM_CREATED = "room_created"
|
ROOM_CREATED = "room_created"
|
||||||
|
|
||||||
|
# Roomkit (meeting-room SIP devices)
|
||||||
|
ROOMKIT_JOINED = "roomkit_joined"
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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
|
||||||
|
|||||||
@@ -73,3 +73,15 @@ 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"
|
||||||
|
|||||||
@@ -429,7 +429,14 @@ 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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Meet core roomkit API endpoints for meeting-room (SIP) device integration."""
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""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'"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
"""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 .telephony import TelephonyException, TelephonyService
|
from .sip_management import SIPException, SIPManagement
|
||||||
|
|
||||||
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.telephony_service = TelephonyService()
|
self.sip_management = SIPManagement()
|
||||||
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:
|
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
|
||||||
try:
|
try:
|
||||||
self.telephony_service.create_dispatch_rule(room)
|
self.sip_management.ensure_dispatch_rule(room)
|
||||||
except TelephonyException as e:
|
except SIPException as e:
|
||||||
raise ActionFailedError(
|
raise ActionFailedError(
|
||||||
f"Failed to create telephony dispatch rule for room {room_id}"
|
f"Failed to create sip 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:
|
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
|
||||||
try:
|
try:
|
||||||
self.telephony_service.delete_dispatch_rule(room_id)
|
self.sip_management.delete_dispatch_rule(room_id)
|
||||||
except TelephonyException as e:
|
except SIPException as e:
|
||||||
raise ActionFailedError(
|
raise ActionFailedError(
|
||||||
f"Failed to delete telephony dispatch rule for room {room_id}"
|
f"Failed to delete sip dispatch rule for room {room_id}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
+38
-10
@@ -1,9 +1,9 @@
|
|||||||
"""Telephony service for managing SIP dispatch rules for room access."""
|
"""SIP management 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
|
from livekit.api import TwirpError, TwirpErrorCode
|
||||||
from livekit.protocol.sip import (
|
from livekit.protocol.sip import (
|
||||||
CreateSIPDispatchRuleRequest,
|
CreateSIPDispatchRuleRequest,
|
||||||
DeleteSIPDispatchRuleRequest,
|
DeleteSIPDispatchRuleRequest,
|
||||||
@@ -17,12 +17,16 @@ from core import utils
|
|||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class TelephonyException(Exception):
|
class SIPException(Exception):
|
||||||
"""Exception raised when telephony operations fail."""
|
"""Exception raised when SIP operations fail."""
|
||||||
|
|
||||||
|
|
||||||
class TelephonyService:
|
class DispatchRuleConflictError(SIPException):
|
||||||
"""Service for managing participant access through the telephony system (SIP)."""
|
"""Raised when a dispatch rule already exists for the same routing criteria."""
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
@@ -32,7 +36,7 @@ class TelephonyService:
|
|||||||
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 telephony to route incoming SIP calls directly to the specified room
|
Configures livekit-sip 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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -51,10 +55,12 @@ class TelephonyService:
|
|||||||
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 TelephonyException("Could not create dispatch rule") from e
|
raise SIPException("Could not create dispatch rule") from e
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
await lkapi.aclose()
|
await lkapi.aclose()
|
||||||
@@ -79,7 +85,7 @@ class TelephonyService:
|
|||||||
)
|
)
|
||||||
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 TelephonyException("Could not list dispatch rules") from e
|
raise SIPException("Could not list dispatch rules") from e
|
||||||
finally:
|
finally:
|
||||||
await lkapi.aclose()
|
await lkapi.aclose()
|
||||||
|
|
||||||
@@ -94,6 +100,28 @@ class TelephonyService:
|
|||||||
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."""
|
||||||
@@ -118,7 +146,7 @@ class TelephonyService:
|
|||||||
|
|
||||||
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 TelephonyException("Could not delete dispatch rules") from e
|
raise SIPException("Could not delete dispatch rules") from e
|
||||||
|
|
||||||
finally:
|
finally:
|
||||||
await lkapi.aclose()
|
await lkapi.aclose()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Tests for the roomkit API of the Meet core app."""
|
||||||
@@ -0,0 +1,266 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_sip_management():
|
||||||
|
"""Mock the SIPManagement used by the roomkit viewset."""
|
||||||
|
with mock.patch("core.roomkit.viewsets.SIPManagement") as mock_service_class:
|
||||||
|
yield mock_service_class.return_value
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_anonymous(settings, mock_sip_management, client):
|
||||||
|
"""Requests without an Authorization header should be rejected."""
|
||||||
|
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_management.ensure_dispatch_rule.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_malformed_authorization_header(settings, mock_sip_management, client):
|
||||||
|
"""Requests with a malformed Authorization header should be rejected."""
|
||||||
|
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_management.ensure_dispatch_rule.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_wrong_bearer(settings, mock_sip_management, client):
|
||||||
|
"""Requests with an incorrect bearer token should be rejected."""
|
||||||
|
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_management.ensure_dispatch_rule.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_token_not_configured(settings, mock_sip_management, client):
|
||||||
|
"""Requests should be rejected when no server-to-server token is configured."""
|
||||||
|
|
||||||
|
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_management.ensure_dispatch_rule.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_roomkit_disabled(settings, mock_sip_management, client):
|
||||||
|
"""The endpoint should not be exposed when the roomkit integration is disabled."""
|
||||||
|
|
||||||
|
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_management.ensure_dispatch_rule.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_missing_pin(settings, mock_sip_management, client):
|
||||||
|
"""Requests without a PIN code should be rejected."""
|
||||||
|
|
||||||
|
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_management.ensure_dispatch_rule.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_blank_pin(settings, mock_sip_management, client):
|
||||||
|
"""Requests with a blank PIN code should be rejected."""
|
||||||
|
|
||||||
|
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_management.ensure_dispatch_rule.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_wrong_pin_length(settings, mock_sip_management, client):
|
||||||
|
"""Requests with a PIN code of unexpected length should be rejected."""
|
||||||
|
|
||||||
|
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_management.ensure_dispatch_rule.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_unknown_pin(settings, mock_sip_management, client):
|
||||||
|
"""Requests with a PIN matching no room should return 404 and create no rule."""
|
||||||
|
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_management.ensure_dispatch_rule.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_success(settings, mock_sip_management, client):
|
||||||
|
"""Requests with a valid PIN should create the dispatch rule."""
|
||||||
|
settings.ROOMKIT_ENABLED = True
|
||||||
|
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||||
|
|
||||||
|
room = RoomFactory(pin_code="1234567890")
|
||||||
|
mock_sip_management.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_management.ensure_dispatch_rule.assert_called_once_with(room)
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_dispatch_rule_already_exists(settings, mock_sip_management, client):
|
||||||
|
"""Requests should succeed when the dispatch rule already exists (idempotency)."""
|
||||||
|
|
||||||
|
settings.ROOMKIT_ENABLED = True
|
||||||
|
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||||
|
|
||||||
|
room = RoomFactory(pin_code="1234567890")
|
||||||
|
mock_sip_management.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_management.ensure_dispatch_rule.assert_called_once_with(room)
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_tracks_analytics_event(settings, mock_sip_management, client):
|
||||||
|
"""Successful joins should be tracked with an analytics event."""
|
||||||
|
|
||||||
|
settings.ROOMKIT_ENABLED = True
|
||||||
|
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||||
|
|
||||||
|
room = RoomFactory(pin_code="1234567890")
|
||||||
|
mock_sip_management.ensure_dispatch_rule.return_value = True
|
||||||
|
|
||||||
|
with mock.patch("core.roomkit.viewsets.analytics.capture") as mock_capture:
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_join_sip_failure(settings, mock_sip_management, client):
|
||||||
|
"""Requests should fail with a server error when the sip management service fails."""
|
||||||
|
|
||||||
|
settings.ROOMKIT_ENABLED = True
|
||||||
|
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||||
|
|
||||||
|
room = RoomFactory(pin_code="1234567890")
|
||||||
|
mock_sip_management.ensure_dispatch_rule.side_effect = SIPException(
|
||||||
|
"Could not create dispatch rule"
|
||||||
|
)
|
||||||
|
|
||||||
|
with mock.patch("core.roomkit.viewsets.analytics.capture") as mock_capture:
|
||||||
|
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_management.ensure_dispatch_rule.assert_called_once_with(room)
|
||||||
|
mock_capture.assert_not_called()
|
||||||
@@ -21,7 +21,10 @@ 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.telephony import TelephonyException, TelephonyService
|
from core.services.sip_management import (
|
||||||
|
SIPException,
|
||||||
|
SIPManagement,
|
||||||
|
)
|
||||||
from core.utils import NotificationError
|
from core.utils import NotificationError
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
@@ -59,7 +62,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.telephony_service, TelephonyService)
|
assert isinstance(service.sip_management, SIPManagement)
|
||||||
assert isinstance(service.recording_events, RecordingEventsService)
|
assert isinstance(service.recording_events, RecordingEventsService)
|
||||||
|
|
||||||
|
|
||||||
@@ -469,11 +472,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(TelephonyService, "delete_dispatch_rule")
|
@mock.patch.object(SIPManagement, "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 telephony dispatch rule when room finishes."""
|
"""Should clear lobby cache and delete SIP 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()
|
||||||
@@ -486,12 +489,31 @@ 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(TelephonyService, "delete_dispatch_rule")
|
@mock.patch.object(SIPManagement, "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)
|
||||||
@@ -505,7 +527,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(TelephonyService, "delete_dispatch_rule")
|
@mock.patch.object(SIPManagement, "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
|
||||||
):
|
):
|
||||||
@@ -528,9 +550,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(
|
||||||
TelephonyService,
|
SIPManagement,
|
||||||
"delete_dispatch_rule",
|
"delete_dispatch_rule",
|
||||||
side_effect=TelephonyException("Test error"),
|
side_effect=SIPException("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
|
||||||
@@ -541,7 +563,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 telephony dispatch rule for room "
|
"Failed to delete sip dispatch rule for room "
|
||||||
"00000000-0000-0000-0000-000000000000"
|
"00000000-0000-0000-0000-000000000000"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -562,11 +584,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(TelephonyService, "create_dispatch_rule")
|
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
|
||||||
def test_handle_room_started_creates_dispatch_rule_successfully(
|
def test_handle_room_started_creates_dispatch_rule_successfully(
|
||||||
mock_create_dispatch_rule, service, settings
|
mock_ensure_dispatch_rule, service, settings
|
||||||
):
|
):
|
||||||
"""Should create telephony dispatch rule when room starts successfully."""
|
"""Should ensure the SIP dispatch rule exists 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()
|
||||||
@@ -574,22 +596,75 @@ def test_handle_room_started_creates_dispatch_rule_successfully(
|
|||||||
|
|
||||||
service._handle_room_started(mock_data)
|
service._handle_room_started(mock_data)
|
||||||
|
|
||||||
mock_create_dispatch_rule.assert_called_once_with(room)
|
mock_ensure_dispatch_rule.assert_called_once_with(room)
|
||||||
|
|
||||||
|
|
||||||
@mock.patch.object(TelephonyService, "create_dispatch_rule")
|
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
|
||||||
def test_handle_room_started_skips_dispatch_rule_when_telephony_disabled(
|
def test_handle_room_started_creates_dispatch_rule_when_only_roomkit_enabled(
|
||||||
mock_create_dispatch_rule, service, settings
|
mock_ensure_dispatch_rule, service, settings
|
||||||
):
|
):
|
||||||
"""Should skip creating telephony dispatch rule when telephony is disabled during room start."""
|
"""Should ensure the dispatch rule exists when only roomkit is enabled during room start."""
|
||||||
settings.ROOM_TELEPHONY_ENABLED = False
|
settings.ROOM_TELEPHONY_ENABLED = False
|
||||||
|
settings.ROOMKIT_ENABLED = True
|
||||||
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_create_dispatch_rule.assert_not_called()
|
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(
|
||||||
|
mock_ensure_dispatch_rule, service, settings
|
||||||
|
):
|
||||||
|
"""Should skip ensuring the SIP dispatch rule when telephony is disabled during room start."""
|
||||||
|
settings.ROOM_TELEPHONY_ENABLED = False
|
||||||
|
settings.ROOMKIT_ENABLED = False
|
||||||
|
room = RoomFactory()
|
||||||
|
mock_data = mock.MagicMock()
|
||||||
|
mock_data.room.name = str(room.id)
|
||||||
|
|
||||||
|
service._handle_room_started(mock_data)
|
||||||
|
|
||||||
|
mock_ensure_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):
|
||||||
|
|||||||
+162
-35
@@ -1,5 +1,5 @@
|
|||||||
"""
|
"""
|
||||||
Test telephony service.
|
Test SIP mamagement service.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# pylint: disable=W0212
|
# pylint: disable=W0212
|
||||||
@@ -20,7 +20,11 @@ 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.telephony import TelephonyException, TelephonyService
|
from core.services.sip_management import (
|
||||||
|
DispatchRuleConflictError,
|
||||||
|
SIPException,
|
||||||
|
SIPManagement,
|
||||||
|
)
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -35,9 +39,9 @@ def create_mock_livekit_client():
|
|||||||
|
|
||||||
def test_rule_name():
|
def test_rule_name():
|
||||||
"""Test rule name generation."""
|
"""Test rule name generation."""
|
||||||
telephony_service = TelephonyService()
|
sip_management = SIPManagement()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
rule_name = telephony_service._rule_name(room.id)
|
rule_name = sip_management._rule_name(room.id)
|
||||||
|
|
||||||
assert rule_name == f"SIP_{str(room.id)}"
|
assert rule_name == f"SIP_{str(room.id)}"
|
||||||
|
|
||||||
@@ -45,14 +49,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."""
|
||||||
telephony_service = TelephonyService()
|
sip_management = SIPManagement()
|
||||||
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
|
||||||
|
|
||||||
telephony_service.create_dispatch_rule(room)
|
sip_management.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"]
|
||||||
@@ -67,7 +71,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."""
|
||||||
telephony_service = TelephonyService()
|
sip_management = SIPManagement()
|
||||||
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()
|
||||||
@@ -76,8 +80,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(TelephonyException, match="Could not create dispatch rule"):
|
with pytest.raises(SIPException, match="Could not create dispatch rule"):
|
||||||
telephony_service.create_dispatch_rule(room)
|
sip_management.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()
|
||||||
@@ -86,7 +90,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."""
|
||||||
telephony_service = TelephonyService()
|
sip_management = SIPManagement()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_rules = [
|
mock_rules = [
|
||||||
@@ -111,7 +115,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(telephony_service._list_dispatch_rules_ids)(room.id)
|
result = async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
|
||||||
|
|
||||||
assert len(result) == 2
|
assert len(result) == 2
|
||||||
assert "rule-1" in result
|
assert "rule-1" in result
|
||||||
@@ -127,7 +131,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."""
|
||||||
telephony_service = TelephonyService()
|
sip_management = SIPManagement()
|
||||||
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()
|
||||||
@@ -136,7 +140,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(telephony_service._list_dispatch_rules_ids)(room.id)
|
result = async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
|
||||||
|
|
||||||
assert result == []
|
assert result == []
|
||||||
mock_api.aclose.assert_called_once()
|
mock_api.aclose.assert_called_once()
|
||||||
@@ -145,7 +149,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."""
|
||||||
telephony_service = TelephonyService()
|
sip_management = SIPManagement()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_rules = [
|
mock_rules = [
|
||||||
@@ -163,7 +167,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(telephony_service._list_dispatch_rules_ids)(room.id)
|
result = async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
|
||||||
|
|
||||||
assert result == []
|
assert result == []
|
||||||
mock_api.aclose.assert_called_once()
|
mock_api.aclose.assert_called_once()
|
||||||
@@ -172,7 +176,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."""
|
||||||
telephony_service = TelephonyService()
|
sip_management = SIPManagement()
|
||||||
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()
|
||||||
@@ -181,34 +185,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(TelephonyException, match="Could not list dispatch rules"):
|
with pytest.raises(SIPException, match="Could not list dispatch rules"):
|
||||||
async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
|
async_to_sync(sip_management._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.telephony.TelephonyService._list_dispatch_rules_ids")
|
@mock.patch("core.services.sip_management.SIPManagement._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."""
|
||||||
telephony_service = TelephonyService()
|
sip_management = SIPManagement()
|
||||||
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 = telephony_service.delete_dispatch_rule(room.id)
|
result = sip_management.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.telephony.TelephonyService._list_dispatch_rules_ids")
|
@mock.patch("core.services.sip_management.SIPManagement._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."""
|
||||||
telephony_service = TelephonyService()
|
sip_management = SIPManagement()
|
||||||
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"]
|
||||||
@@ -216,7 +220,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 = telephony_service.delete_dispatch_rule(room.id)
|
result = sip_management.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()
|
||||||
@@ -226,11 +230,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.telephony.TelephonyService._list_dispatch_rules_ids")
|
@mock.patch("core.services.sip_management.SIPManagement._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."""
|
||||||
telephony_service = TelephonyService()
|
sip_management = SIPManagement()
|
||||||
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"]
|
||||||
@@ -238,7 +242,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 = telephony_service.delete_dispatch_rule(room.id)
|
result = sip_management.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
|
||||||
@@ -253,11 +257,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.telephony.TelephonyService._list_dispatch_rules_ids")
|
@mock.patch("core.services.sip_management.SIPManagement._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."""
|
||||||
telephony_service = TelephonyService()
|
sip_management = SIPManagement()
|
||||||
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"]
|
||||||
@@ -277,18 +281,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(TelephonyException, match="Could not delete dispatch rules"):
|
with pytest.raises(SIPException, match="Could not delete dispatch rules"):
|
||||||
telephony_service.delete_dispatch_rule(room.id)
|
sip_management.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.telephony.TelephonyService._list_dispatch_rules_ids")
|
@mock.patch("core.services.sip_management.SIPManagement._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."""
|
||||||
telephony_service = TelephonyService()
|
sip_management = SIPManagement()
|
||||||
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"]
|
||||||
@@ -298,8 +302,131 @@ 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(TelephonyException, match="Could not delete dispatch rules"):
|
with pytest.raises(SIPException, match="Could not delete dispatch rules"):
|
||||||
telephony_service.delete_dispatch_rule(room.id)
|
sip_management.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)
|
||||||
@@ -184,12 +184,13 @@ 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_disabled_skips_pin_generation(
|
def test_telephony_and_roomkit_disabled_skips_pin_generation(
|
||||||
mock_generate_unique_pin_code, settings
|
mock_generate_unique_pin_code, settings
|
||||||
):
|
):
|
||||||
"""Telephony disabled should not generate pin codes."""
|
"""Telephony and roomkit both disabled should not generate pin codes."""
|
||||||
|
|
||||||
settings.ROOM_TELEPHONY_ENABLED = False
|
settings.ROOM_TELEPHONY_ENABLED = False
|
||||||
|
settings.ROOMKIT_ENABLED = False
|
||||||
|
|
||||||
room = RoomFactory()
|
room = RoomFactory()
|
||||||
|
|
||||||
@@ -197,6 +198,18 @@ def test_telephony_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,6 +9,7 @@ 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()
|
||||||
@@ -19,6 +20,11 @@ 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,6 +349,11 @@ 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 = (
|
||||||
@@ -881,6 +886,21 @@ 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
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user