Compare commits
471 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5e6e266b0 | |||
| 8f05a987df | |||
| cef3e98ec1 | |||
| 6b31719b1b | |||
| 175a64001a | |||
| d46d70e73b | |||
| 95e4b77b7e | |||
| 8044e3d6d8 | |||
| 58722cab00 | |||
| 5f75b085ec | |||
| afd7139d39 | |||
| 67d61b8cf7 | |||
| 47cc88c820 | |||
| 0bf7755855 | |||
| 4e75a17916 | |||
| 73a9fb3a72 | |||
| e86dc12bf9 | |||
| ef2b0b64bb | |||
| 1f71bfc5d2 | |||
| 888fbbcd5f | |||
| 9b6b32c3d5 | |||
| 00a3c0a37c | |||
| ff9487eb2f | |||
| 242e7cb148 | |||
| 86a04ed718 | |||
| 3d3242e148 | |||
| 7d1f15ef91 | |||
| 00b58288cf | |||
| aeb5c3790c | |||
| 3e69a2380f | |||
| 57f63bf891 | |||
| da86b30455 | |||
| 6a76041a70 | |||
| 26bd947541 | |||
| a85a62eb1a | |||
| 4793f2fa8f | |||
| 1268346405 | |||
| f28da45b4c | |||
| 740355d494 | |||
| 34fde10854 | |||
| 84c820bc60 | |||
| 1539613bf8 | |||
| 5f70840398 | |||
| 84e62246b7 | |||
| 6c633b1ecb | |||
| 0f76517957 | |||
| fd7a78e80e | |||
| 206babb20f | |||
| 081c860e04 | |||
| 83192f69e3 | |||
| 2ceb94a966 | |||
| 3c13e287e6 | |||
| eee17b6b58 | |||
| 185d5c2c60 | |||
| 31ac4cd767 | |||
| a6aa77cb97 | |||
| 42b9a34c7a | |||
| 9ff4b23ea7 | |||
| f48dd5cea1 | |||
| 49ee46438b | |||
| ea2e5e8609 | |||
| 6ca73c665b | |||
| cfa68d0eb4 | |||
| fac0c53123 | |||
| a8f1ee0530 | |||
| 651d299068 | |||
| 51ed277941 | |||
| f46a5dc157 | |||
| 4f4b4d3231 | |||
| 25a39a1fb6 | |||
| a5003b55e3 | |||
| bcf551a25c | |||
| c1c2d0260d | |||
| e80b9c2485 | |||
| 04f7412307 | |||
| ba422110f8 | |||
| a83afdbb0c | |||
| a5d8aae293 | |||
| 8a3a0d5759 | |||
| aa8362c470 | |||
| c2586a392c | |||
| ea3f0cc59e | |||
| 9aa9342054 | |||
| ca38c4851f | |||
| a97895c383 | |||
| e88aeedbf1 | |||
| 5f32a9e6a3 | |||
| aa09c15ecc | |||
| 7ec3e481ff | |||
| 42107f4698 | |||
| a49893696b | |||
| 6f3339fbdc | |||
| f17e0a3ba0 | |||
| 2367750395 | |||
| 5eb70384e3 | |||
| 22c68da2af | |||
| ebf676529f | |||
| 40cedba8ae | |||
| 59e0643dde | |||
| 7b89395f42 | |||
| 3d047b5124 | |||
| 89b1190bb4 | |||
| 8f28a46a5f | |||
| ac88c046dc | |||
| b7f55ac35d | |||
| 329a729bdc | |||
| 2215b621f4 | |||
| c330ec6ff4 | |||
| 8245270f28 | |||
| 9728603f72 | |||
| 803c94a80c | |||
| fd90d0b830 | |||
| f380d0342d | |||
| 4cdf257b6a | |||
| 338f8d8a69 | |||
| ab4f415d23 | |||
| e926b407b1 | |||
| 4e655a0a64 | |||
| d2bde299be | |||
| 9b44ed0974 | |||
| aeaa9b7ffd | |||
| da73424f72 | |||
| 2b9b977f57 | |||
| 355db6ef9a | |||
| 890e043d29 | |||
| 9eb412758a | |||
| fa27afdfdf | |||
| cb8b415ef9 | |||
| 7c6182cc4e | |||
| 2d47e90a1a | |||
| 56ec2dd8cb | |||
| 361de29780 | |||
| e4d5ca64b9 | |||
| bd139a1ef9 | |||
| f682f8feb3 | |||
| 5f1d59c753 | |||
| c45b91dc58 | |||
| 0e72f61650 | |||
| 4fae3c6c47 | |||
| 120bcdc720 | |||
| f1b20d7981 | |||
| 95190ec690 | |||
| adb99cc5d9 | |||
| 201069aa4c | |||
| b6a5b1a805 | |||
| f3af637fd6 | |||
| de3a5aa404 | |||
| 5e9d20e685 | |||
| b54445739a | |||
| 7c67bacd94 | |||
| 1fd1b184ee | |||
| adb517a336 | |||
| eec9ff9f26 | |||
| e0258a1765 | |||
| 872ce1ecc6 | |||
| e2c3b745ca | |||
| 965d823d08 | |||
| 1db189ace2 | |||
| 199e0908e9 | |||
| 8518f83211 | |||
| 0240d85837 | |||
| 162896c93c | |||
| 483a219ac4 | |||
| 1b26dea178 | |||
| bdaf4245da | |||
| be63993ba2 | |||
| 3d245c3bd4 | |||
| 66a36eff73 | |||
| 387bc2e1f4 | |||
| d44b45b6aa | |||
| 224b98fd9a | |||
| 031852d0b1 | |||
| 24915b0485 | |||
| 0862203d5d | |||
| ee604abe00 | |||
| 26d668b478 | |||
| 04081f04fc | |||
| f7268c507b | |||
| cadb20793a | |||
| 8a417806e4 | |||
| 223c744e3f | |||
| f67335f306 | |||
| 4eb7f29f8e | |||
| 912bac8756 | |||
| 3c97418a70 | |||
| 26a90456f7 | |||
| d068558c8f | |||
| dac6bfe142 | |||
| 6b3e5d747a | |||
| 3066e3a83c | |||
| 5e05b6b2a5 | |||
| d075d60d19 | |||
| 7c631bb76f | |||
| 59cd1f766a | |||
| f0a17b1ce1 | |||
| 17c486f7bf | |||
| 85bde9633f | |||
| 0eb715b0cd | |||
| b0617dcfed | |||
| 6c4c44e933 | |||
| 16dde229cc | |||
| d01d6dd9d1 | |||
| 2a39978245 | |||
| d85ed031a9 | |||
| 5c8c81f97b | |||
| 3368a9b6af | |||
| e87d0519ff | |||
| 731f0471aa | |||
| e458272745 | |||
| d71e417d58 | |||
| d91f343ba9 | |||
| 4fc0744433 | |||
| 93e3f05348 | |||
| cbabcb877b | |||
| 30cd6573ef | |||
| 921d69031e | |||
| 1716e11900 | |||
| 6e55013b15 | |||
| 21bed40484 | |||
| 5959e82bf0 | |||
| b5113580b3 | |||
| 053e4bc7b9 | |||
| 9e57c25a25 | |||
| dfbcbed485 | |||
| 9c28e1b266 | |||
| 2a586445b5 | |||
| 75ffb7f5f7 | |||
| 6a1c85809d | |||
| 70d250cc9c | |||
| 988e5aa256 | |||
| d3178eff5d | |||
| 9c840a4e06 | |||
| e6754b49e0 | |||
| ec586eaab4 | |||
| e4e4fcbbfc | |||
| 8929ec5714 | |||
| 26045bbffa | |||
| 641a311bca | |||
| 0d84b1c9ad | |||
| 4255308010 | |||
| e20619d9a2 | |||
| 4a6b84ed50 | |||
| b058e6add9 | |||
| b265c3c7cb | |||
| 459280b384 | |||
| 9d01dde9e4 | |||
| de92d7d5ac | |||
| 077d38f5e3 | |||
| 3e315e92fa | |||
| 5ef38fcba6 | |||
| ea754e96c9 | |||
| e692a0cf8a | |||
| 46eee4ce9d | |||
| 5c2305d710 | |||
| fb6b6f2b03 | |||
| 77e7e9cdd4 | |||
| ac2451e327 | |||
| e356dcb4c3 | |||
| 2699edeaad | |||
| d417c21d77 | |||
| 830da365c4 | |||
| b33df66158 | |||
| 211e05a0cb | |||
| 018eec8a46 | |||
| e01b1713c4 | |||
| 1b9af5bf6d | |||
| 260eab23be | |||
| 98de4f9145 | |||
| c82168b6c0 | |||
| ba286724f3 | |||
| bbd700270f | |||
| 15330ad4e1 | |||
| 4db3e205d2 | |||
| dd0b570fea | |||
| 3088299c0b | |||
| d1bb414cb4 | |||
| 513f3c588a | |||
| b407bfda07 | |||
| 3de4cc01dc | |||
| 1fe1a557ad | |||
| fb92e5b79f | |||
| e71bc093bd | |||
| 43df855461 | |||
| 12fc33d30a | |||
| 892a98193d | |||
| c5379f29e7 | |||
| 2fddc82333 | |||
| c9ba6cbc05 | |||
| 76807a54f2 | |||
| 15aff4db8e | |||
| 27a0128b2a | |||
| 4a18e188e4 | |||
| 1cd8fd2fc6 | |||
| 64eadadaef | |||
| 6e48f8f222 | |||
| 866a2cea20 | |||
| 17b1dde050 | |||
| 0b25374cef | |||
| fb8b2d752b | |||
| 3c0a712f45 | |||
| 17795c69d6 | |||
| d1b0378a45 | |||
| ef4dcf57b0 | |||
| bbb6e4f317 | |||
| 77d2365a61 | |||
| e6caa0a2fd | |||
| f9614fc108 | |||
| 9d516bf638 | |||
| 5bac9a1d59 | |||
| 61aa3c79c5 | |||
| 0c6cd8223d | |||
| b692314646 | |||
| e816475981 | |||
| 8d1f01645a | |||
| 29a46a413e | |||
| b618b2347f | |||
| 7038f2a85d | |||
| b7dfafaf47 | |||
| 2a7d963f50 | |||
| 980117132f | |||
| 7a8b50b5f0 | |||
| 409e403581 | |||
| 0c0eed6f59 | |||
| 750b7f86b4 | |||
| 9569c58315 | |||
| 922a968418 | |||
| 48e6cef763 | |||
| ac4ec6c752 | |||
| 7454d44329 | |||
| 1e3e7de753 | |||
| 4c2eb31a6a | |||
| a2a44a6546 | |||
| f1fa99f918 | |||
| 4a6e65d4be | |||
| c8772bb1ad | |||
| d54925bd97 | |||
| e9f3e27058 | |||
| 0abbe4a26f | |||
| 3e93f5924c | |||
| d70dc41643 | |||
| 6e81b55403 | |||
| b8cc21debc | |||
| 36ddb84982 | |||
| 496ae12fa9 | |||
| ae4ef48d05 | |||
| 952104fd82 | |||
| 60dc8bf174 | |||
| d54a61cbcc | |||
| f90a1e3549 | |||
| 572b80d3fe | |||
| 82d840a15f | |||
| 500c690fa0 | |||
| 577111d864 | |||
| 563f1e4c0f | |||
| 2e8407ac7c | |||
| 2af9ec0d85 | |||
| b70799c2db | |||
| 0facfc11be | |||
| 8023e44f71 | |||
| b27c5e9b92 | |||
| 3e5c4c32e9 | |||
| 551207ab86 | |||
| 0aa47fcd1e | |||
| c289f79d8e | |||
| b79fa14919 | |||
| c7c0df5b6d | |||
| cb00347be6 | |||
| bcb004ab4b | |||
| 422f838899 | |||
| 462c6c50e5 | |||
| 63565b38c3 | |||
| 10d759bdbb | |||
| 51f1f0ebbf | |||
| 9e27d0f345 | |||
| 978d931bd7 | |||
| 0c811222d4 | |||
| 94171dcb82 | |||
| 56c1cd98fa | |||
| f2e6edb90d | |||
| bc76c44fe9 | |||
| e519f00342 | |||
| 2246bb7782 | |||
| e210f26f9c | |||
| 888dfe76c7 | |||
| ae17fbdaa8 | |||
| 2ef95aa835 | |||
| a83e5c4b1c | |||
| 9cc79ba159 | |||
| c63adf9c8c | |||
| 9366c8c4dd | |||
| 3636bfa703 | |||
| 34c14cc516 | |||
| 1a0051a90b | |||
| af21478143 | |||
| 986b75ba39 | |||
| df55fb2424 | |||
| ec114808b2 | |||
| 7f0e866eac | |||
| 8e0e286bc4 | |||
| ee9148fe9f | |||
| c863bc7456 | |||
| eab361326f | |||
| 86c82f584c | |||
| af6ac954e9 | |||
| 35cc6cd902 | |||
| 9cb7b0f154 | |||
| d105603a9b | |||
| d2da1e37b9 | |||
| 0206762e6d | |||
| c4fe341a74 | |||
| bc53916c68 | |||
| 886919c23d | |||
| 4afbd9ba7f | |||
| a5fb3b910f | |||
| d1a17d2aa9 | |||
| e5af74685e | |||
| 06462a55b0 | |||
| 67967f00b5 | |||
| a8053b46cd | |||
| b927be9f16 | |||
| 2092d586ab | |||
| 132a1fbac7 | |||
| 90b4449040 | |||
| 205bb3aac1 | |||
| d5c9ee79f4 | |||
| 1ef2bd99d8 | |||
| acbc5dba73 | |||
| 3438b4608c | |||
| b4484540f7 | |||
| 7021272075 | |||
| d92adb7435 | |||
| d92c13d85a | |||
| 46d60661f1 | |||
| ba20fbe3a5 | |||
| f1b45af7d7 | |||
| 0b6869a4dc | |||
| 7278061a80 | |||
| e5eb1e9916 | |||
| 4060e891f2 | |||
| 314468c68d | |||
| 52a7a6efab | |||
| 83d04c499b | |||
| 41c1f41ed2 | |||
| dc06b55693 | |||
| 20aceb1932 | |||
| 3671f2a0dd | |||
| d74dd967af | |||
| 3a4f4e7016 | |||
| b7d964db56 | |||
| 88b7a7dc58 | |||
| baca9fc001 | |||
| c432524f2a | |||
| a079ceef71 | |||
| f0742a0978 | |||
| fc1b4d7fa7 | |||
| 46f26eb493 | |||
| ff09c3d969 | |||
| ba9d22f6c8 | |||
| 94e71ba15d | |||
| 9a1384b188 | |||
| 695ac47014 | |||
| b3c1deeb9c | |||
| c08c3efdbb | |||
| 468d09dc3b | |||
| a22d052f46 | |||
| 9734df9d5d | |||
| f596aae1e8 | |||
| 32956f495f | |||
| 69381a6c4b | |||
| d537a4449a | |||
| 50c9304afe |
@@ -1,35 +0,0 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'preprod'
|
||||
- 'production'
|
||||
|
||||
|
||||
jobs:
|
||||
notify-argocd:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Call argocd github webhook
|
||||
run: |
|
||||
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
|
||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${{ secrets.ARGOCD_WEBHOOK_SECRET }}'' | awk '{print "X-Hub-Signature: sha1="$2}')
|
||||
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ vars.ARGOCD_WEBHOOK_URL }}
|
||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${{ secrets.ARGOCD_PRODUCTION_WEBHOOK_SECRET }}'' | awk '{print "X-Hub-Signature: sha1="$2}')
|
||||
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ secrets.ARGOCD_PRODUCTION_WEBHOOK_URL }}
|
||||
|
||||
start-test-on-preprod:
|
||||
needs:
|
||||
- notify-argocd
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.event.ref, 'refs/tags/preprod')
|
||||
steps:
|
||||
-
|
||||
name: Debug
|
||||
run: |
|
||||
echo "Start test when preprod is ready"
|
||||
@@ -31,7 +31,10 @@ jobs:
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
@@ -49,7 +52,7 @@ jobs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
build-and-push-frontend:
|
||||
build-and-push-frontend-generic:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
@@ -64,7 +67,10 @@ jobs:
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
@@ -83,6 +89,43 @@ jobs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
build-and-push-frontend-dinum:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: lasuite/meet-frontend-dinum
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
docker-build-args: '-f docker/dinum-frontend/Dockerfile --target frontend-production'
|
||||
docker-image-name: 'docker.io/lasuite/meet-frontend-dinum:${{ github.sha }}'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/dinum-frontend/Dockerfile
|
||||
target: frontend-production
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
build-and-push-summary:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -98,7 +141,10 @@ jobs:
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -111,11 +157,44 @@ jobs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
build-and-push-agents:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: lasuite/meet-agents
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USER }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./src/agents
|
||||
file: ./src/agents/Dockerfile
|
||||
target: production
|
||||
build-args: DOCKER_USER=${{ env.DOCKER_USER }}:-1000
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
notify-argocd:
|
||||
needs:
|
||||
- build-and-push-frontend
|
||||
- build-and-push-frontend-generic
|
||||
- build-and-push-frontend-dinum
|
||||
- build-and-push-backend
|
||||
- build-and-push-summary
|
||||
- build-and-push-agents
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
|
||||
@@ -20,14 +20,18 @@ jobs:
|
||||
- name: show
|
||||
run: git log
|
||||
- name: Enforce absence of print statements in code
|
||||
if: always()
|
||||
run: |
|
||||
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/meet.yml' | grep "print("
|
||||
- name: Check absence of fixup commits
|
||||
if: always()
|
||||
run: |
|
||||
! git log | grep 'fixup!'
|
||||
- name: Install gitlint
|
||||
if: always()
|
||||
run: pip install --user requests gitlint
|
||||
- name: Lint commit messages added to main
|
||||
if: always()
|
||||
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
|
||||
|
||||
build-mails:
|
||||
@@ -81,7 +85,8 @@ jobs:
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.13"
|
||||
cache: "pip"
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
- name: Check code formatting with ruff
|
||||
@@ -91,6 +96,46 @@ jobs:
|
||||
- name: Lint code with pylint
|
||||
run: ~/.local/bin/pylint meet demo core
|
||||
|
||||
lint-agents:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/agents
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
cache: "pip"
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
- name: Check code formatting with ruff
|
||||
run: ~/.local/bin/ruff format . --diff
|
||||
- name: Lint code with ruff
|
||||
run: ~/.local/bin/ruff check .
|
||||
|
||||
lint-summary:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/summary
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.13"
|
||||
cache: "pip"
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
- name: Check code formatting with ruff
|
||||
run: ~/.local/bin/ruff format . --diff
|
||||
- name: Lint code with ruff
|
||||
run: ~/.local/bin/ruff check .
|
||||
|
||||
test-back:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-mails
|
||||
@@ -135,6 +180,9 @@ jobs:
|
||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
LIVEKIT_API_SECRET: secret
|
||||
LIVEKIT_API_KEY: devkey
|
||||
AWS_S3_ENDPOINT_URL: http://localhost:9000
|
||||
AWS_S3_ACCESS_KEY_ID: meet
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -152,10 +200,38 @@ jobs:
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
|
||||
- name: Start MinIO
|
||||
run: |
|
||||
docker pull minio/minio
|
||||
docker run -d --name minio \
|
||||
-p 9000:9000 \
|
||||
-e "MINIO_ACCESS_KEY=meet" \
|
||||
-e "MINIO_SECRET_KEY=password" \
|
||||
-v /data/media:/data \
|
||||
minio/minio server --console-address :9001 /data
|
||||
|
||||
# Tool to wait for a service to be ready
|
||||
- name: Install Dockerize
|
||||
run: |
|
||||
curl -sSL https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz | sudo tar -C /usr/local/bin -xzv
|
||||
|
||||
- name: Wait for MinIO to be ready
|
||||
run: |
|
||||
dockerize -wait tcp://localhost:9000 -timeout 10s
|
||||
|
||||
- name: Configure MinIO
|
||||
run: |
|
||||
MINIO=$(docker ps | grep minio/minio | sed -E 's/.*\s+([a-zA-Z0-9_-]+)$/\1/')
|
||||
docker exec ${MINIO} sh -c \
|
||||
"mc alias set meet http://localhost:9000 meet password && \
|
||||
mc alias ls && \
|
||||
mc mb meet/meet-media-storage"
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.13"
|
||||
cache: "pip"
|
||||
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
@@ -219,67 +295,3 @@ jobs:
|
||||
|
||||
- name: Build SDK
|
||||
run: npm run build
|
||||
|
||||
i18n-crowdin:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "infrastructure,secrets"
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
- name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
|
||||
- name: Install gettext (required to make messages)
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y gettext
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install development dependencies
|
||||
working-directory: src/backend
|
||||
run: pip install --user .[dev]
|
||||
|
||||
- name: Generate the translation base file
|
||||
run: ~/.local/bin/django-admin makemessages --keep-pot --all
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18.x"
|
||||
cache: "npm"
|
||||
cache-dependency-path: src/frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd src/frontend/ && npm ci
|
||||
|
||||
- name: Extract the frontend translation
|
||||
run: make frontend-i18n-extract
|
||||
|
||||
- name: Upload files to Crowdin
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
config: crowdin/config.yml
|
||||
upload_sources: true
|
||||
upload_translations: true
|
||||
download_translations: false
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_BASE_PATH: ${{ github.workspace }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
|
||||
@@ -8,3 +8,5 @@ and this project adheres to
|
||||
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
- 🔧(backend) support `_FILE` for secret environment variables #566
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Django Meet
|
||||
|
||||
# ---- base image to inherit from ----
|
||||
FROM python:3.12.6-alpine3.20 AS base
|
||||
FROM python:3.13.5-alpine3.21 AS base
|
||||
|
||||
# Upgrade pip to its latest release to speed up dependencies installation
|
||||
RUN python -m pip install --upgrade pip setuptools
|
||||
@@ -62,11 +62,11 @@ FROM base AS core
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
RUN apk add \
|
||||
gettext \
|
||||
RUN apk --no-cache add \
|
||||
cairo \
|
||||
libffi-dev \
|
||||
gdk-pixbuf \
|
||||
gettext \
|
||||
libffi-dev \
|
||||
pango \
|
||||
shared-mime-info
|
||||
|
||||
|
||||
@@ -347,3 +347,7 @@ start-tilt: ## start the kubernetes cluster using kind
|
||||
start-tilt-keycloak: ## start the kubernetes cluster using kind, without Pro Connect for authentication, use keycloak
|
||||
DEV_ENV=dev-keycloak tilt up -f ./bin/Tiltfile
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
start-tilt-dinum: ## start the kubernetes cluster using kind, without Pro Connect for authentication, but with DINUM styles
|
||||
DEV_ENV=dev-dinum tilt up -f ./bin/Tiltfile
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://livekit.io/">LiveKit</a> - <a href="https://go.crisp.chat/chat/embed/?website_id=58ea6697-8eba-4492-bc59-ad6562585041">Chat with us</a> - <a href="https://github.com/orgs/suitenumerique/projects/3/views/2">Roadmap</a> - <a href="https://github.com/suitenumerique/meet/blob/main/CHANGELOG.md">Changelog</a> - <a href="https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md">Bug reports</a>
|
||||
<a href="https://livekit.io/">LiveKit</a> - <a href="https://matrix.to/#/#meet-official:matrix.org">Chat with us</a> - <a href="https://github.com/orgs/suitenumerique/projects/3/views/2">Roadmap</a> - <a href="https://github.com/suitenumerique/meet/blob/main/CHANGELOG.md">Changelog</a> - <a href="https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md">Bug reports</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -33,9 +33,9 @@ Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level perfo
|
||||
- Support for multiple screen sharing streams
|
||||
- Non-persistent, secure chat
|
||||
- End-to-end encryption (coming soon)
|
||||
- Meeting recording (coming soon)
|
||||
- Meeting recording
|
||||
- Meeting transcription (currently in beta)
|
||||
- Telephony integration (in development)
|
||||
- Telephony integration
|
||||
- Secure participation with robust authentication and access control
|
||||
- LiveKit Advances features including :
|
||||
- speaker detection
|
||||
@@ -111,6 +111,7 @@ Come help us make La Suite Meet even better. We're growing fast and [would love
|
||||
## Credits
|
||||
|
||||
We're using the awesome [LiveKit](https://livekit.io/) implementation. We're also thankful to the teams behind [Django Rest Framework](https://www.django-rest-framework.org/), [Vite.js](https://vite.dev/), and [React Aria](https://github.com/adobe/react-spectrum) — Thanks for your amazing work!
|
||||
This project is tested with BrowserStack.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -2,6 +2,15 @@ load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
|
||||
load('ext://namespace', 'namespace_create', 'namespace_inject')
|
||||
namespace_create('meet')
|
||||
|
||||
DEV_ENV = os.getenv('DEV_ENV', 'dev')
|
||||
|
||||
if DEV_ENV == 'dev-dinum':
|
||||
update_settings(suppress_unused_image_warnings=["localhost:5001/meet-frontend-generic:latest"])
|
||||
|
||||
if DEV_ENV == 'dev-keycloak':
|
||||
update_settings(suppress_unused_image_warnings=["localhost:5001/meet-frontend-dinum:latest"])
|
||||
|
||||
|
||||
def clean_old_images(image_name):
|
||||
local('docker images -q %s | tail -n +2 | xargs -r docker rmi' % image_name)
|
||||
|
||||
@@ -22,7 +31,19 @@ docker_build(
|
||||
clean_old_images('localhost:5001/meet-backend')
|
||||
|
||||
docker_build(
|
||||
'localhost:5001/meet-frontend:latest',
|
||||
'localhost:5001/meet-frontend-dinum:latest',
|
||||
context='..',
|
||||
dockerfile='../docker/dinum-frontend/Dockerfile',
|
||||
only=['./src/frontend', './docker', './.dockerignore'],
|
||||
target = 'frontend-production',
|
||||
live_update=[
|
||||
sync('../src/frontend', '/home/frontend'),
|
||||
]
|
||||
)
|
||||
clean_old_images('localhost:5001/meet-frontend-dinum')
|
||||
|
||||
docker_build(
|
||||
'localhost:5001/meet-frontend-generic:latest',
|
||||
context='..',
|
||||
dockerfile='../src/frontend/Dockerfile',
|
||||
only=['./src/frontend', './docker', './.dockerignore'],
|
||||
@@ -31,25 +52,37 @@ docker_build(
|
||||
sync('../src/frontend', '/home/frontend'),
|
||||
]
|
||||
)
|
||||
clean_old_images('localhost:5001/meet-frontend')
|
||||
clean_old_images('localhost:5001/meet-frontend-generic')
|
||||
|
||||
docker_build(
|
||||
'localhost:5001/meet-summary:latest',
|
||||
context='../src/summary',
|
||||
dockerfile='../src/summary/Dockerfile',
|
||||
only=['.', '../../docker', '../../.dockerignore'],
|
||||
only=['.'],
|
||||
target = 'production',
|
||||
live_update=[
|
||||
sync('../src/summary', '/home/summary'),
|
||||
sync('../src/summary', '/app'),
|
||||
]
|
||||
)
|
||||
clean_old_images('localhost:5001/meet-summary')
|
||||
|
||||
docker_build(
|
||||
'localhost:5001/meet-agents:latest',
|
||||
context='../src/agents',
|
||||
dockerfile='../src/agents/Dockerfile',
|
||||
only=['.'],
|
||||
target = 'production',
|
||||
live_update=[
|
||||
sync('../src/agents', '/app'),
|
||||
]
|
||||
)
|
||||
clean_old_images('localhost:5001/meet-agents')
|
||||
|
||||
# Copy the mkcert root CA certificate to our Docker build context
|
||||
# This is necessary because we need to inject the certificate into our LiveKit container
|
||||
local_resource(
|
||||
'copy-root-ca',
|
||||
cmd='cp "$(mkcert -CAROOT)/rootCA.pem" ../docker/livekit/rootCA.pem',
|
||||
cmd='cp -f "$(mkcert -CAROOT)/rootCA.pem" ../docker/livekit/rootCA.pem',
|
||||
deps=[], # No dependencies needed
|
||||
)
|
||||
# Build a custom LiveKit Docker image that includes our root CA certificate
|
||||
@@ -64,6 +97,13 @@ clean_old_images('localhost:5001/meet-livekit')
|
||||
|
||||
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .'))
|
||||
|
||||
k8s_resource('minio-bucket', resource_deps=['minio'])
|
||||
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
|
||||
k8s_resource('meet-backend-migrate', resource_deps=['meet-backend'])
|
||||
k8s_resource('livekit-livekit-server-test-connection', resource_deps=['livekit-livekit-server'])
|
||||
k8s_resource('keycloak', resource_deps=['kc-postgresql'])
|
||||
k8s_resource('meet-backend-createsuperuser', resource_deps=['meet-backend-migrate'])
|
||||
|
||||
migration = '''
|
||||
set -eu
|
||||
# get k8s pod name from tilt resource name
|
||||
|
||||
@@ -15,6 +15,37 @@ services:
|
||||
ports:
|
||||
- "1081:1080"
|
||||
|
||||
minio:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: minio/minio
|
||||
environment:
|
||||
- MINIO_ROOT_USER=meet
|
||||
- MINIO_ROOT_PASSWORD=password
|
||||
ports:
|
||||
- '9000:9000'
|
||||
- '9001:9001'
|
||||
healthcheck:
|
||||
test: [ "CMD", "mc", "ready", "local" ]
|
||||
interval: 1s
|
||||
timeout: 20s
|
||||
retries: 300
|
||||
entrypoint: ""
|
||||
command: minio server --console-address :9001 /data
|
||||
volumes:
|
||||
- ./data/media:/data
|
||||
|
||||
createbuckets:
|
||||
image: minio/mc
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
entrypoint: >
|
||||
sh -c "
|
||||
/usr/bin/mc alias set meet http://minio:9000 meet password && \
|
||||
/usr/bin/mc mb meet/meet-media-storage && \
|
||||
exit 0;"
|
||||
|
||||
app-dev:
|
||||
build:
|
||||
context: .
|
||||
@@ -40,6 +71,9 @@ services:
|
||||
- redis
|
||||
- nginx
|
||||
- livekit
|
||||
- createbuckets
|
||||
extra_hosts:
|
||||
- "127.0.0.1.nip.io:host-gateway"
|
||||
|
||||
celery-dev:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
@@ -73,6 +107,7 @@ services:
|
||||
- postgresql
|
||||
- redis
|
||||
- livekit
|
||||
- minio
|
||||
|
||||
celery:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
@@ -103,6 +138,7 @@ services:
|
||||
target: frontend-production
|
||||
args:
|
||||
VITE_API_BASE_URL: "http://localhost:8071"
|
||||
VITE_APP_TITLE: "LaSuite Meet"
|
||||
image: meet:frontend-development
|
||||
ports:
|
||||
- "3000:8080"
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# ---- Front-end image ----
|
||||
FROM node:20-alpine AS frontend-deps
|
||||
|
||||
WORKDIR /home/frontend/
|
||||
|
||||
COPY ./src/frontend/package.json ./package.json
|
||||
COPY ./src/frontend/package-lock.json ./package-lock.json
|
||||
|
||||
RUN npm ci
|
||||
|
||||
COPY .dockerignore ./.dockerignore
|
||||
COPY ./src/frontend/ .
|
||||
|
||||
# ---- Front-end builder image ----
|
||||
FROM frontend-deps AS meet-builder
|
||||
|
||||
WORKDIR /home/frontend
|
||||
|
||||
ENV VITE_APP_TITLE="Visio"
|
||||
ENV VITE_BUILD_SOURCEMAP="true"
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Inject PostHog sourcemap metadata into the built assets
|
||||
# This metadata is essential for correctly mapping errors to source maps in production
|
||||
RUN set -e && \
|
||||
npx @posthog/cli sourcemap inject --directory ./dist/assets
|
||||
|
||||
COPY ./docker/dinum-frontend/dinum-styles.css \
|
||||
./dist/assets/
|
||||
|
||||
COPY ./docker/dinum-frontend/logo.svg \
|
||||
./dist/assets/logo.svg
|
||||
|
||||
COPY ./docker/dinum-frontend/assets/ \
|
||||
./dist/assets/
|
||||
|
||||
COPY ./docker/dinum-frontend/fonts/ \
|
||||
./dist/assets/fonts/
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
|
||||
|
||||
USER root
|
||||
RUN apk update && apk upgrade libssl3 libcrypto3 libxml2>=2.12.7-r2 libxslt>=1.1.39-r2
|
||||
|
||||
USER nginx
|
||||
|
||||
# Un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
USER ${DOCKER_USER}
|
||||
|
||||
COPY --from=meet-builder \
|
||||
/home/frontend/dist \
|
||||
/usr/share/nginx/html
|
||||
|
||||
COPY ./src/frontend/default.conf /etc/nginx/conf.d
|
||||
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||
|
||||
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,61 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="38.194309mm"
|
||||
height="3.4877367mm"
|
||||
viewBox="0 0 38.194309 3.4877367"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
sodipodi:docname="gouvernement.svg"
|
||||
inkscape:version="1.4.1 (93de688d07, 2025-03-30)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview1"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:document-units="mm"
|
||||
inkscape:zoom="6.1918409"
|
||||
inkscape:cx="79.620909"
|
||||
inkscape:cy="44.332534"
|
||||
inkscape:window-width="1901"
|
||||
inkscape:window-height="1037"
|
||||
inkscape:window-x="5"
|
||||
inkscape:window-y="5"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="layer1" />
|
||||
<defs
|
||||
id="defs1">
|
||||
<rect
|
||||
x="111.07374"
|
||||
y="333.22122"
|
||||
width="541.4845"
|
||||
height="297.51895"
|
||||
id="rect2" />
|
||||
<rect
|
||||
x="158.10136"
|
||||
y="358.3631"
|
||||
width="425.55618"
|
||||
height="204.21426"
|
||||
id="rect1" />
|
||||
</defs>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-53.656101,-100.08408)">
|
||||
<path
|
||||
d="m 55.435527,103.08144 c -0.72898,0 -1.249045,-0.56007 -1.249045,-1.25349 0,-0.69342 0.502285,-1.25349 1.204595,-1.25349 0.41783,0 0.760095,0.20447 0.96901,0.50673 l 0.333375,-0.25781 c -0.28448,-0.38227 -0.742315,-0.64008 -1.302385,-0.64008 -0.973455,0 -1.63576,0.75565 -1.63576,1.64465 0,0.889 0.680085,1.64465 1.68021,1.64465 0.582295,0 1.05791,-0.24892 1.346835,-0.64008 v -1.21349 h -1.31572 v 0.36005 h 0.89789 v 0.70231 c -0.217805,0.24447 -0.54229,0.40005 -0.929005,0.40005 z m 3.569334,-2.89814 c -0.973455,0 -1.63576,0.75565 -1.63576,1.64465 0,0.889 0.662305,1.64465 1.63576,1.64465 0.96901,0 1.631315,-0.75565 1.631315,-1.64465 0,-0.889 -0.662305,-1.64465 -1.631315,-1.64465 z m 0,2.89814 c -0.70231,0 -1.204595,-0.56007 -1.204595,-1.25349 0,-0.69342 0.502285,-1.25349 1.204595,-1.25349 0.697865,0 1.20015,0.56007 1.20015,1.25349 0,0.69342 -0.502285,1.25349 -1.20015,1.25349 z m 4.276088,-0.84455 c 0,0.53784 -0.31115,0.84455 -0.786765,0.84455 -0.48006,0 -0.79121,-0.30671 -0.79121,-0.84455 v -1.96469 h -0.41783 v 1.93802 c 0,0.8001 0.48006,1.26238 1.20904,1.26238 0.72898,0 1.204595,-0.46228 1.204595,-1.26238 v -1.93802 h -0.41783 z m 0.942341,-1.96469 1.2446,3.1115 h 0.55118 l 1.2446,-3.1115 h -0.4445 l -1.07569,2.68922 -1.07569,-2.68922 z m 3.653795,3.1115 h 1.67132 v -0.3556 h -1.25349 v -1.05347 h 1.07569 v -0.35115 h -1.07569 v -0.99568 h 1.25349 v -0.3556 h -1.67132 z m 2.502533,0 h 0.41783 v -1.36462 h 0.40894 c 0.03111,0 0.06667,0 0.09779,-0.004 l 0.902335,1.36906 h 0.493395 l -1.00457,-1.44907 c 0.333375,-0.13335 0.52451,-0.41339 0.52451,-0.78677 0,-0.5334 -0.386715,-0.87566 -1.01346,-0.87566 h -0.82677 z m 0.84455,-2.7559 c 0.3556,0 0.564515,0.19558 0.564515,0.51117 0,0.33338 -0.208915,0.52451 -0.564515,0.52451 h -0.42672 v -1.03568 z m 2.004699,2.7559 h 0.41783 v -2.60033 l 1.76022,2.60033 h 0.546735 v -3.1115 h -0.41783 v 2.60032 l -1.76022,-2.60032 h -0.546735 z m 3.71158,0 h 1.67132 v -0.3556 h -1.25349 v -1.05347 h 1.07569 v -0.35115 h -1.07569 v -0.99568 h 1.25349 v -0.3556 h -1.67132 z m 2.502533,0 h 0.41783 v -2.55143 l 0.90678,1.48463 h 0.32004 l 0.90678,-1.48463 v 2.55143 h 0.41783 v -3.1115 h -0.5334 l -0.95123,1.56908 -0.95123,-1.56908 h -0.5334 z m 3.951605,0 h 1.67132 v -0.3556 h -1.25349 v -1.05347 h 1.07569 v -0.35115 h -1.07569 v -0.99568 h 1.25349 v -0.3556 h -1.67132 z m 2.502535,0 h 0.41783 v -2.60033 l 1.76022,2.60033 h 0.546735 v -3.1115 h -0.41783 v 2.60032 l -1.76022,-2.60032 H 85.89712 Z m 3.373758,-2.72923 h 1.03124 v 2.72923 h 0.41783 v -2.72923 h 1.03124 v -0.38227 h -2.48031 z"
|
||||
id="text3"
|
||||
style="font-size:4.445px;line-height:0.661464px;font-family:Marianne;-inkscape-font-specification:'Marianne, Normal';letter-spacing:0px;word-spacing:0px;display:inline;stroke:#000000;stroke-width:0.198437"
|
||||
aria-label="GOUVERNEMENT" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
@@ -0,0 +1,70 @@
|
||||
:root {
|
||||
--fonts-sans: 'Marianne', ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
.Header-beforeLogo {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.Header-beforeLogo::before {
|
||||
content: '';
|
||||
display: block;
|
||||
background-image: url(/assets/marianne.svg);
|
||||
background-position: 0 -0.046875rem;
|
||||
background-size: 2.0625rem 0.84375rem;
|
||||
height: 0.75rem;
|
||||
margin-bottom: 0.1rem;
|
||||
width: 2.0625rem;
|
||||
}
|
||||
|
||||
.Header-beforeLogo::after {
|
||||
content: '';
|
||||
display: block;
|
||||
background-image: url(/assets/gouvernement.svg), url(/assets/devise.svg);
|
||||
background-repeat: no-repeat, no-repeat;
|
||||
background-size: 108.8px 10px, 40px 29px;
|
||||
background-position: 0 3px, 0 18.9px;
|
||||
width: 108.8px;
|
||||
height: 48px;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Marianne';
|
||||
src: url('/assets/fonts/Marianne-Regular-subset.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Marianne';
|
||||
src: url('/assets/fonts/Marianne-Regular_Italic-subset.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Marianne';
|
||||
src: url('/assets/fonts/Marianne-Medium-subset.woff2') format('woff2');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Marianne';
|
||||
src: url('/assets/fonts/Marianne-Bold-subset.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Marianne';
|
||||
src: url('/assets/fonts/Marianne-ExtraBold-subset.woff2') format('woff2');
|
||||
font-weight: 800;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
@@ -35,9 +35,6 @@ backend:
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
DB_PORT: 5432
|
||||
POSTGRES_DB: meet
|
||||
POSTGRES_USER: dinum
|
||||
POSTGRES_PASSWORD: pass
|
||||
REDIS_URL: redis://default:pass@redis-master:6379/1
|
||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
LIVEKIT_API_SECRET: secret
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Installation on a k8s cluster
|
||||
|
||||
This document is a step-by-step guide that describes how to install Visio on a k8s cluster without AI features.
|
||||
This document is a step-by-step guide that describes how to install LaSuite Meet on a k8s cluster without AI features.
|
||||
|
||||
## Prerequisites
|
||||
## Prerequisites for a kubernetes setup
|
||||
|
||||
- k8s cluster with an nginx-ingress controller
|
||||
- an OIDC provider (if you don't have one, we will provide an example)
|
||||
@@ -12,14 +12,25 @@ This document is a step-by-step guide that describes how to install Visio on a k
|
||||
|
||||
### Test cluster
|
||||
|
||||
If you do not have a test cluster, you can install everything on a local kind cluster. In this case, the simplest way is to use our script **bin/start-kind.sh**.
|
||||
If you do not have a kubernetes test cluster, you can install everything on a local kind cluster. In this case, the simplest way is to use our script located in this repo under **bin/start-kind.sh**.
|
||||
|
||||
To be able to use the script, you will need to install:
|
||||
IMPORTANT: The kind method will only deploy meet as a local instance(127.0.0.1) that can only be accessed from the device where it has been deployed.
|
||||
|
||||
To be able to use the script, you will need to install the following components:
|
||||
|
||||
- Docker (https://docs.docker.com/desktop/)
|
||||
- Kind (https://kind.sigs.k8s.io/docs/user/quick-start/#installation)
|
||||
- Mkcert (https://github.com/FiloSottile/mkcert#installation)
|
||||
- Helm (https://helm.sh/docs/intro/quickstart/#install-helm)
|
||||
- kubectl (https://kubernetes.io/docs/tasks/tools/)
|
||||
|
||||
In order to initiate the local kind installation via **start-kind.sh** do the following:
|
||||
1) Make sure administrator/root user context is able to execute mkcert, docker, kind etc. commands or the script might fail
|
||||
2) Download the script to the device where the above components are installed
|
||||
3) Make the script executable
|
||||
4) Run the script with proper permissions (administrator/sudo etc.)
|
||||
|
||||
The output of the script will resemble the below example:
|
||||
|
||||
```
|
||||
$ ./bin/start-kind.sh
|
||||
@@ -99,11 +110,11 @@ When your k8s cluster is ready, you can start the deployment. This cluster is sp
|
||||
|
||||
Please remember that \*.127.0.0.1.nip.io will always resolve to 127.0.0.1, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP.
|
||||
|
||||
## Preparation
|
||||
## Preparation of components
|
||||
|
||||
### What will you use to authenticate your users ?
|
||||
|
||||
Visio uses OIDC, so if you already have an OIDC provider, obtain the necessary information to use it. In the next step, we will see how to configure Django (and thus Visio) to use it. If you do not have a provider, we will show you how to deploy a local Keycloak instance (this is not a production deployment, just a demo).
|
||||
LaSuite Meet uses OIDC, so if you already have an OIDC provider, obtain the necessary information to use it. In the next step, we will see how to configure Django (and thus LaSuite Meet) to use it. If you do not have a provider, we will show you how to deploy a local Keycloak instance (this is not a production deployment, just a demo).
|
||||
|
||||
If you haven't run the script **bin/start-kind.sh**, you'll need to manually create the namespace by running the following command:
|
||||
|
||||
@@ -111,7 +122,7 @@ If you haven't run the script **bin/start-kind.sh**, you'll need to manually cre
|
||||
$ kubectl create namespace meet
|
||||
```
|
||||
|
||||
If you have already run the script, you can skip this step and proceed to the next instruction.
|
||||
If you have already run the script, you can skip this step and proceed to the next instruction. NOTE: Before you proceed, and is using the kind method, make sure you download this repo examples/ directory and its contents to the location where you will be executing the helm command. Helm will look for "examples/<name>values.yaml" from based on the path it is being executed.
|
||||
|
||||
```
|
||||
$ kubectl config set-context --current --namespace=meet
|
||||
@@ -123,6 +134,8 @@ keycloak-0 1/1 Running 0 6m48s
|
||||
keycloak-postgresql-0 1/1 Running 0 6m48s
|
||||
```
|
||||
|
||||
In your OIDC provider, set LaSuite Meet's redirect URI to `https://.../api/v1.0/callback/` where `...` should be replaced with the domain name LaSuite Meet is hosted on.
|
||||
|
||||
From here the important information you will need are :
|
||||
|
||||
```
|
||||
@@ -141,7 +154,7 @@ You can find these values in **examples/keycloak.values.yaml**
|
||||
|
||||
### Find livekit server connexion values
|
||||
|
||||
Visio use livekit for streaming part so if you have a livekit provider, obtain the necessary information to use it. If you do not have a provider, you can install a livekit testing environment as follows:
|
||||
LaSuite Meet use livekit for streaming part so if you have a livekit provider, obtain the necessary information to use it. If you do not have a provider, you can install a livekit testing environment as follows:
|
||||
|
||||
Livekit need a redis (and meet too) so we will start by deploying a redis :
|
||||
|
||||
@@ -183,7 +196,7 @@ CELERY_RESULT_BACKEND: redis://default:pass@redis-master:6379/1
|
||||
|
||||
### Find postgresql connexion values
|
||||
|
||||
Visio uses a postgresql db as backend so if you have a provider, obtain the necessary information to use it. If you do not have, you can install a postgresql testing environment as follows:
|
||||
LaSuite Meet uses a postgresql db as backend so if you have a provider, obtain the necessary information to use it. If you do not have, you can install a postgresql testing environment as follows:
|
||||
|
||||
```
|
||||
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml
|
||||
@@ -204,14 +217,11 @@ DB_NAME: meet
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
DB_PORT: 5432
|
||||
POSTGRES_DB: meet
|
||||
POSTGRES_USER: dinum
|
||||
POSTGRES_PASSWORD: pass
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
Now you are ready to deploy Visio without AI. AI required more dependencies (Openai-compliant API, LiveKit Egress, Cold storage and a docs deployment to push resumes). To deploy meet you need to provide all previous information to the helm chart.
|
||||
Now you are ready to deploy LaSuite Meet without AI. AI required more dependencies (Openai-compliant API, LiveKit Egress, Cold storage and a docs deployment to push resumes). To deploy meet you need to provide all previous information to the helm chart.
|
||||
|
||||
```
|
||||
$ helm repo add meet https://suitenumerique.github.io/meet/
|
||||
@@ -232,99 +242,129 @@ meet <none> meet.127.0.0.1.nip.io localhost 80, 44
|
||||
meet-admin <none> meet.127.0.0.1.nip.io localhost 80, 443 52m
|
||||
```
|
||||
|
||||
You can use Visio on https://meet.127.0.0.1.nip.io. The provisioning user in keycloak is meet/meet.
|
||||
You can use LaSuite Meet on https://meet.127.0.0.1.nip.io from the local device. The provisioning user in keycloak is meet/meet.
|
||||
|
||||
## All options
|
||||
|
||||
These are the environmental options available on meet backend.
|
||||
|
||||
| Option | Description | default |
|
||||
| ----------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| DJANGO_ALLOWED_HOSTS | Host that are allowed | [] |
|
||||
| DJANGO_SECRET_KEY | Secret key used | |
|
||||
| DJANGO_SILENCED_SYSTEM_CHECKS | Silence system checks | [] |
|
||||
| DJANGO_ALLOW_UNSECURE_USER_LISTING | | false |
|
||||
| DB_ENGINE | Database engine used | django.db.backends.postgresql_psycopg2 |
|
||||
| DB_NAME | name of the database | meet |
|
||||
| DB_USER | user used to connect to database | dinum |
|
||||
| DB_PASSWORD | password used to connect to the database | pass |
|
||||
| DB_HOST | hostname of the database | localhost |
|
||||
| DB_PORT | port to connect to database | 5432 |
|
||||
| STORAGES_STATICFILES_BACKEND | Static file serving engine | whitenoise.storage.CompressedManifestStaticFilesStorage |
|
||||
| AWS_S3_ENDPOINT_URL | S3 host endpoint | |
|
||||
| AWS_S3_ACCESS_KEY_ID | s3 access key | |
|
||||
| AWS_S3_SECRET_ACCESS_KEY | s3 secret key | |
|
||||
| AWS_S3_REGION_NAME | s3 region | |
|
||||
| AWS_STORAGE_BUCKET_NAME | s3 bucket name | |
|
||||
| DJANGO_LANGUAGE_CODE | Default language | en-us |
|
||||
| REDIS_URL | redis endpoint | redis://redis:6379/1 |
|
||||
| REQUEST_ENTRY_THROTTLE_RATES | throttle rates | 150/minute |
|
||||
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | deploy check | False |
|
||||
| FRONTEND_ANALYTICS | analytics information | {} |
|
||||
| FRONTEND_SUPPORT | crisp frontend support | {} |
|
||||
| FRONTEND_SILENCE_LIVEKIT_DEBUG | silence livekit debug | false |
|
||||
| DJANGO_EMAIL_BACKEND | email backend library | django.core.mail.backends.smtp.EmailBackend |
|
||||
| DJANGO_EMAIL_HOST | host of the email server | |
|
||||
| DJANGO_EMAIL_HOST_USER | user to connect to the email server | |
|
||||
| DJANGO_EMAIL_HOST_PASSWORD | password to connect tto the email server | |
|
||||
| DJANGO_EMAIL_PORT | por tot connect to the email server | |
|
||||
| DJANGO_EMAIL_USE_TLS | enable tls on email connection | false |
|
||||
| DJANGO_EMAIL_USE_SSL | enable tls on email connection | false |
|
||||
| DJANGO_EMAIL_FROM | email from account | from@example.com |
|
||||
| DJANGO_CORS_ALLOW_ALL_ORIGINS | allow all cors origins | true |
|
||||
| DJANGO_CORS_ALLOWED_ORIGINS | origins to allow in string | [] |
|
||||
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | origins to allow in regex | [] |
|
||||
| SENTRY_DSN | sentry server | |
|
||||
| DJANGO_CELERY_BROKER_URL | celery broker host | redis://redis:6379/0 |
|
||||
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | celery broker options | {} |
|
||||
| OIDC_CREATE_USER | create oidc user if not exists | false |
|
||||
| OIDC_VERIFY_SSL | verify ssl for oidc | true |
|
||||
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | fallback to email for identification | false |
|
||||
| OIDC_RP_SIGN_ALGO | token verification algoritm used by oidc | RS256 |
|
||||
| OIDC_RP_CLIENT_ID | oidc client | meet |
|
||||
| OIDC_RP_CLIENT_SECRET | oidc client secret | |
|
||||
| OIDC_OP_JWKS_ENDPOINT | oidc endpoint for JWKS | |
|
||||
| OIDC_OP_AUTHORIZATION_ENDPOINT | oidc endpoint for authorization | |
|
||||
| OIDC_OP_TOKEN_ENDPOINT | oidc endpoint for token | |
|
||||
| OIDC_OP_USER_ENDPOINT | oidc endpoint for user | |
|
||||
| OIDC_OP_LOGOUT_ENDPOINT | oidc endpoint for logout | |
|
||||
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | extra parameters for oidc request | |
|
||||
| OIDC_RP_SCOPES | oidc scopes | openid email |
|
||||
| LOGIN_REDIRECT_URL | login redirect url | |
|
||||
| LOGIN_REDIRECT_URL_FAILURE | login redurect url for failure | |
|
||||
| LOGOUT_REDIRECT_URL | url to redirect to on logout | |
|
||||
| OIDC_USE_NONCE | use nonce for oidc | true |
|
||||
| OIDC_REDIRECT_REQUIRE_HTTPS | require https for oidc | false |
|
||||
| OIDC_REDIRECT_ALLOWED_HOSTS | allowed redirect hosts for oidc | [] |
|
||||
| OIDC_STORE_ID_TOKEN | store oidc ID token | true |
|
||||
| ALLOW_LOGOUT_GET_METHOD | allow logout through get method | true |
|
||||
| OIDC_REDIRECT_FIELD_NAME | direct field for oidc | returnTo |
|
||||
| OIDC_USERINFO_FULLNAME_FIELDS | full name claim from OIDC token | ["given_name", "usual_name"] |
|
||||
| OIDC_USERINFO_SHORTNAME_FIELD | shortname claim from OIDC token | given_name |
|
||||
| LIVEKIT_API_KEY | livekit api key | |
|
||||
| LIVEKIT_API_SECRET | livekit api secret | |
|
||||
| LIVEKIT_API_URL | livekit api url | |
|
||||
| RESOURCE_DEFAULT_ACCESS_LEVEL | default resource access level for rooms | public |
|
||||
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
|
||||
| RECORDING_ENABLE | record meeting option | false |
|
||||
| RECORDING_OUTPUT_FOLDER | folder to store meetings | recordings |
|
||||
| RECORDING_VERIFY_SSL | verify ssl for recording storage | true |
|
||||
| RECORDING_WORKER_CLASSES | worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
|
||||
| RECORDING_EVENT_PARSER_CLASS | storage event engine for recording | core.recording.event.parsers.MinioParser |
|
||||
| RECORDING_ENABLE_STORAGE_EVENT_AUTH | enable storage event authorization | true |
|
||||
| RECORDING_STORAGE_EVENT_ENABLE | enable recording storage events | false |
|
||||
| RECORDING_STORAGE_EVENT_TOKEN | recording storage event token | |
|
||||
| SUMMARY_SERVICE_ENDPOINT | summary service endpoint | |
|
||||
| SUMMARY_SERVICE_API_TOKEN | api token voor summary service | |
|
||||
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | signup users to marketing emails | false |
|
||||
| MARKETING_SERVICE_CLASS | markering class | core.services.marketing.BrevoMarketingService |
|
||||
| BREVO_API_KEY | breva api key for marketing emails | |
|
||||
| BREVO_API_CONTACT_LIST_IDS | brevo api contact list ids | [] |
|
||||
| DJANGO_BREVO_API_CONTACT_ATTRIBUTES | brevo contact attributes | {"VISIO_USER": True} |
|
||||
| BREVO_API_TIMEOUT | brevo timeout | 1 |
|
||||
| LOBBY_KEY_PREFIX | lobby prefix | room_lobby |
|
||||
| LOBBY_WAITING_TIMEOUT | lobby waiting tumeout | 3 |
|
||||
| LOBBY_DENIED_TIMEOUT | lobby deny timeout | 5 |
|
||||
| LOBBY_ACCEPTED_TIMEOUT | lobby accept timeout | 21600 |
|
||||
| LOBBY_NOTIFICATION_TYPE | lobby notification types | participantWaiting |
|
||||
| LOBBY_COOKIE_NAME | lobby cookie name | lobbyParticipantId |
|
||||
| Option | Description | default |
|
||||
|-------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| DATA_DIR | Data directory location | /data |
|
||||
| DJANGO_ALLOWED_HOSTS | Hosts that are allowed | [] |
|
||||
| DJANGO_SECRET_KEY | Secret key used for Django security | |
|
||||
| DJANGO_SILENCED_SYSTEM_CHECKS | Silence Django system checks | [] |
|
||||
| DJANGO_ALLOW_UNSECURE_USER_LISTING | Allow unsecure user listing | false |
|
||||
| DB_ENGINE | Database engine used | django.db.backends.postgresql_psycopg2 |
|
||||
| DB_NAME | Name of the database | meet |
|
||||
| DB_USER | User used to connect to database | dinum |
|
||||
| DB_PASSWORD | Password used to connect to the database | pass |
|
||||
| DB_HOST | Hostname of the database | localhost |
|
||||
| DB_PORT | Port to connect to database | 5432 |
|
||||
| STORAGES_STATICFILES_BACKEND | Static file serving engine | whitenoise.storage.CompressedManifestStaticFilesStorage |
|
||||
| AWS_S3_ENDPOINT_URL | S3 host endpoint | |
|
||||
| AWS_S3_ACCESS_KEY_ID | S3 access key | |
|
||||
| AWS_S3_SECRET_ACCESS_KEY | S3 secret key | |
|
||||
| AWS_S3_REGION_NAME | S3 region | |
|
||||
| AWS_STORAGE_BUCKET_NAME | S3 bucket name | meet-media-storage |
|
||||
| DJANGO_LANGUAGE_CODE | Default language | en-us |
|
||||
| REDIS_URL | Redis endpoint | redis://redis:6379/1 |
|
||||
| SESSION_COOKIE_AGE | Session cookie expiration in seconds | 43200 (12 hours) |
|
||||
| REQUEST_ENTRY_THROTTLE_RATES | Entry request throttle rates | 150/minute |
|
||||
| CREATION_CALLBACK_THROTTLE_RATES | Creation callback throttle rates | 600/minute |
|
||||
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | Enable Django deploy check | false |
|
||||
| CSRF_TRUSTED_ORIGINS | CSRF trusted origins list | [] |
|
||||
| FRONTEND_CUSTOM_CSS_URL | URL of an additional CSS file to load in the frontend app. If set, a `<link>` tag with this URL as href is added to the `<head>` of the frontend app | |
|
||||
| FRONTEND_ANALYTICS | Analytics information | {} |
|
||||
| FRONTEND_SUPPORT | Crisp frontend support configuration, also you can pass help articles, with `help_article_transcript`, `help_article_recording`, `help_article_more_tools` | {} |
|
||||
| FRONTEND_TRANSCRIPT | Frontend transcription configuration, you can pass a beta form, with `form_beta_users` | {} |
|
||||
| FRONTEND_MANIFEST_LINK | Link to the "Learn more" button on the homepage | {} |
|
||||
| FRONTEND_SILENCE_LIVEKIT_DEBUG | Silence LiveKit debug logs | false |
|
||||
| FRONTEND_IS_SILENT_LOGIN_ENABLED | Enable silent login feature | true |
|
||||
| FRONTEND_FEEDBACK | Frontend feedback configuration | {} |
|
||||
| FRONTEND_USE_FRENCH_GOV_FOOTER | Show the French government footer in the homepage | false |
|
||||
| FRONTEND_USE_PROCONNECT_BUTTON | Show a "Login with ProConnect" button in the homepage instead of a "Login" button | false |
|
||||
| DJANGO_EMAIL_BACKEND | Email backend library | django.core.mail.backends.smtp.EmailBackend |
|
||||
| DJANGO_EMAIL_HOST | Host of the email server | |
|
||||
| DJANGO_EMAIL_HOST_USER | User to connect to the email server | |
|
||||
| DJANGO_EMAIL_HOST_PASSWORD | Password to connect to the email server | |
|
||||
| DJANGO_EMAIL_PORT | Port to connect to the email server | |
|
||||
| DJANGO_EMAIL_USE_TLS | Enable TLS on email connection | false |
|
||||
| DJANGO_EMAIL_USE_SSL | Enable SSL on email connection | false |
|
||||
| DJANGO_EMAIL_FROM | Email from account | from@example.com |
|
||||
| EMAIL_BRAND_NAME | Email branding name | |
|
||||
| EMAIL_SUPPORT_EMAIL | Support email address | |
|
||||
| EMAIL_LOGO_IMG | Email logo image | |
|
||||
| EMAIL_DOMAIN | Email domain | |
|
||||
| EMAIL_APP_BASE_URL | Email app base URL | |
|
||||
| DJANGO_CORS_ALLOW_ALL_ORIGINS | Allow all CORS origins | false |
|
||||
| DJANGO_CORS_ALLOWED_ORIGINS | Origins to allow (string list) | [] |
|
||||
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | Origins to allow (regex patterns) | [] |
|
||||
| SENTRY_DSN | Sentry server DSN | |
|
||||
| DJANGO_CELERY_BROKER_URL | Celery broker host | redis://redis:6379/0 |
|
||||
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | Celery broker options | {} |
|
||||
| OIDC_CREATE_USER | Create OIDC user if not exists | true |
|
||||
| OIDC_VERIFY_SSL | Verify SSL for OIDC | true |
|
||||
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | Fallback to email for identification | false |
|
||||
| OIDC_RP_SIGN_ALGO | Token verification algorithm used by OIDC | RS256 |
|
||||
| OIDC_RP_CLIENT_ID | OIDC client ID | meet |
|
||||
| OIDC_RP_CLIENT_SECRET | OIDC client secret | |
|
||||
| OIDC_OP_JWKS_ENDPOINT | OIDC endpoint for JWKS | |
|
||||
| OIDC_OP_AUTHORIZATION_ENDPOINT | OIDC endpoint for authorization | |
|
||||
| OIDC_OP_TOKEN_ENDPOINT | OIDC endpoint for token | |
|
||||
| OIDC_OP_USER_ENDPOINT | OIDC endpoint for user | |
|
||||
| OIDC_OP_USER_ENDPOINT_FORMAT | OIDC endpoint format (AUTO, JWT, JSON) | AUTO |
|
||||
| OIDC_OP_LOGOUT_ENDPOINT | OIDC endpoint for logout | |
|
||||
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | Extra parameters for OIDC request | {} |
|
||||
| OIDC_RP_SCOPES | OIDC scopes | openid email |
|
||||
| OIDC_USE_NONCE | Use nonce for OIDC | true |
|
||||
| OIDC_REDIRECT_REQUIRE_HTTPS | Require HTTPS for OIDC | false |
|
||||
| OIDC_REDIRECT_ALLOWED_HOSTS | Allowed redirect hosts for OIDC | [] |
|
||||
| OIDC_STORE_ID_TOKEN | Store OIDC ID token | true |
|
||||
| OIDC_REDIRECT_FIELD_NAME | Redirect field for OIDC | returnTo |
|
||||
| OIDC_USERINFO_FULLNAME_FIELDS | Full name claim from OIDC token | ["given_name", "usual_name"] |
|
||||
| OIDC_USERINFO_SHORTNAME_FIELD | Short name claim from OIDC token | given_name |
|
||||
| OIDC_USERINFO_ESSENTIAL_CLAIMS | Required claims from OIDC token | [] |
|
||||
| OIDC_USE_PKCE | Enable the use of PKCE (Proof Key for Code Exchange) during the OAuth 2.0 authorization code flow. Recommended for enhanced security. | False |
|
||||
| OIDC_PKCE_CODE_CHALLENGE_METHOD | Method used to generate the PKCE code challenge. Common values include S256 and plain. Refer to the mozilla-django-oidc documentation for supported options. | S256 |
|
||||
| OIDC_PKCE_CODE_VERIFIER_SIZE | Length of the random string used as the PKCE code verifier. Must be an integer between 43 and 128, inclusive. | 64 |
|
||||
| LOGIN_REDIRECT_URL | Login redirect URL | |
|
||||
| LOGIN_REDIRECT_URL_FAILURE | Login redirect URL for failure | |
|
||||
| LOGOUT_REDIRECT_URL | URL to redirect to on logout | |
|
||||
| ALLOW_LOGOUT_GET_METHOD | Allow logout through GET method | true |
|
||||
| LIVEKIT_API_KEY | LiveKit API key | |
|
||||
| LIVEKIT_API_SECRET | LiveKit API secret | |
|
||||
| LIVEKIT_API_URL | LiveKit API URL | |
|
||||
| LIVEKIT_VERIFY_SSL | Verify SSL for LiveKit connections | true |
|
||||
| LIVEKIT_FORCE_WSS_PROTOCOL | Enables WSS protocol conversion for legacy browser compatibility (Firefox <124, Chrome <125, Edge <125) where HTTPS URLs fail in WebSocket() constructor. | false |
|
||||
| LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND | Firefox-only connection warmup: pre-calls WebSocket endpoint (expecting 401) to initialize cache, resolving proxy/network connectivity issues. | false |
|
||||
| RESOURCE_DEFAULT_ACCESS_LEVEL | Default resource access level for rooms | public |
|
||||
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
|
||||
| RECORDING_ENABLE | Record meeting option | false |
|
||||
| RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings |
|
||||
| RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
|
||||
| RECORDING_EVENT_PARSER_CLASS | Storage event engine for recording | core.recording.event.parsers.MinioParser |
|
||||
| RECORDING_ENABLE_STORAGE_EVENT_AUTH | Enable storage event authorization | true |
|
||||
| RECORDING_STORAGE_EVENT_ENABLE | Enable recording storage events | false |
|
||||
| RECORDING_STORAGE_EVENT_TOKEN | Recording storage event token | |
|
||||
| RECORDING_EXPIRATION_DAYS | Recording expiration in days | |
|
||||
| RECORDING_MAX_DURATION | Maximum recording duration in milliseconds. Must match LiveKit Egress configuration exactly. | |
|
||||
| SCREEN_RECORDING_BASE_URL | Screen recording base URL | |
|
||||
| SUMMARY_SERVICE_ENDPOINT | Summary service endpoint | |
|
||||
| SUMMARY_SERVICE_API_TOKEN | API token for summary service | |
|
||||
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | Signup users to marketing emails | false |
|
||||
| MARKETING_SERVICE_CLASS | Marketing service class | core.services.marketing.BrevoMarketingService |
|
||||
| BREVO_API_KEY | Brevo API key for marketing emails | |
|
||||
| BREVO_API_CONTACT_LIST_IDS | Brevo API contact list IDs | [] |
|
||||
| DJANGO_BREVO_API_CONTACT_ATTRIBUTES | Brevo contact attributes | {"VISIO_USER": true} |
|
||||
| BREVO_API_TIMEOUT | Brevo timeout in seconds | 1 |
|
||||
| LOBBY_KEY_PREFIX | Lobby key prefix | room_lobby |
|
||||
| LOBBY_WAITING_TIMEOUT | Lobby waiting timeout in seconds | 3 |
|
||||
| LOBBY_DENIED_TIMEOUT | Lobby deny timeout in seconds | 5 |
|
||||
| LOBBY_ACCEPTED_TIMEOUT | Lobby accept timeout in seconds | 21600 (6 hours) |
|
||||
| LOBBY_NOTIFICATION_TYPE | Lobby notification types | participantWaiting |
|
||||
| LOBBY_COOKIE_NAME | Lobby cookie name | lobbyParticipantId |
|
||||
| ROOM_CREATION_CALLBACK_CACHE_TIMEOUT | Room creation callback cache timeout | 600 (10 minutes) |
|
||||
| ROOM_TELEPHONY_ENABLED | Enable SIP telephony feature | false |
|
||||
| ROOM_TELEPHONY_PIN_LENGTH | Telephony PIN length | 10 |
|
||||
| ROOM_TELEPHONY_PIN_MAX_RETRIES | Telephony PIN maximum retries | 5 |
|
||||
|
||||
@@ -12,12 +12,20 @@ PYTHONPATH=/app
|
||||
# Mail
|
||||
DJANGO_EMAIL_HOST="mailcatcher"
|
||||
DJANGO_EMAIL_PORT=1025
|
||||
DJANGO_EMAIL_BRAND_NAME=La Suite Numérique
|
||||
DJANGO_EMAIL_SUPPORT_EMAIL=test@yopmail.com
|
||||
DJANGO_EMAIL_LOGO_IMG=http://localhost:3000/assets/logo-suite-numerique.png
|
||||
DJANGO_EMAIL_DOMAIN=localhost:3000
|
||||
DJANGO_EMAIL_APP_BASE_URL=http://localhost:3000
|
||||
|
||||
# Backend url
|
||||
MEET_BASE_URL="http://localhost:8072"
|
||||
|
||||
# Media
|
||||
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
AWS_S3_ENDPOINT_URL=http://minio:9000
|
||||
AWS_S3_ACCESS_KEY_ID=meet
|
||||
AWS_S3_SECRET_ACCESS_KEY=password
|
||||
|
||||
# OIDC
|
||||
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs
|
||||
@@ -34,11 +42,21 @@ LOGIN_REDIRECT_URL=http://localhost:3000
|
||||
LOGIN_REDIRECT_URL_FAILURE=http://localhost:3000
|
||||
LOGOUT_REDIRECT_URL=http://localhost:3000
|
||||
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS=["http://localhost:8083", "http://localhost:3000"]
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS=localhost:8083,localhost:3000
|
||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||
|
||||
# Livekit Token settings
|
||||
LIVEKIT_API_SECRET=secret
|
||||
LIVEKIT_API_KEY=devkey
|
||||
LIVEKIT_API_URL=http://localhost:7880
|
||||
LIVEKIT_API_URL=http://127.0.0.1.nip.io:7880
|
||||
LIVEKIT_VERIFY_SSL=False
|
||||
ALLOW_UNREGISTERED_ROOMS=False
|
||||
|
||||
# Recording
|
||||
SCREEN_RECORDING_BASE_URL=http://localhost:3000/recordings
|
||||
|
||||
# Telephony
|
||||
ROOM_TELEPHONY_ENABLED=True
|
||||
|
||||
FRONTEND_USE_FRENCH_GOV_FOOTER=False
|
||||
FRONTEND_USE_PROCONNECT_BUTTON=False
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"enabled": false,
|
||||
"groupName": "ignored python dependencies",
|
||||
"matchManagers": ["pep621"],
|
||||
"matchPackageNames": []
|
||||
"matchPackageNames": ["redis"]
|
||||
},
|
||||
{
|
||||
"enabled": false,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
FROM python:3.13-slim AS base
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
WORKDIR /builder
|
||||
|
||||
COPY pyproject.toml .
|
||||
|
||||
RUN mkdir /install && \
|
||||
pip install --prefix=/install .
|
||||
|
||||
FROM base AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG DOCKER_USER
|
||||
USER ${DOCKER_USER}
|
||||
|
||||
# Un-privileged user running the application
|
||||
COPY --from=builder /install /usr/local
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["python", "multi-user-transcriber.py", "start"]
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Multi user transcription agent."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from livekit import api, rtc
|
||||
from livekit.agents import (
|
||||
Agent,
|
||||
AgentSession,
|
||||
AutoSubscribe,
|
||||
JobContext,
|
||||
JobProcess,
|
||||
JobRequest,
|
||||
RoomInputOptions,
|
||||
RoomIO,
|
||||
RoomOutputOptions,
|
||||
WorkerOptions,
|
||||
WorkerPermissions,
|
||||
cli,
|
||||
utils,
|
||||
)
|
||||
from livekit.plugins import deepgram, silero
|
||||
|
||||
load_dotenv()
|
||||
|
||||
logger = logging.getLogger("transcriber")
|
||||
|
||||
TRANSCRIBER_AGENT_NAME = os.getenv("TRANSCRIBER_AGENT_NAME", "multi-user-transcriber")
|
||||
|
||||
|
||||
class Transcriber(Agent):
|
||||
"""Create a transcription agent for a specific participant."""
|
||||
|
||||
def __init__(self, *, participant_identity: str):
|
||||
"""Init transcription agent."""
|
||||
super().__init__(
|
||||
instructions="not-needed",
|
||||
stt=deepgram.STT(),
|
||||
)
|
||||
self.participant_identity = participant_identity
|
||||
|
||||
|
||||
class MultiUserTranscriber:
|
||||
"""Manage transcription sessions for multiple room participants."""
|
||||
|
||||
def __init__(self, ctx: JobContext):
|
||||
"""Init multi user transcription agent."""
|
||||
self.ctx = ctx
|
||||
self._sessions: dict[str, AgentSession] = {}
|
||||
self._tasks: set[asyncio.Task] = set()
|
||||
|
||||
def start(self):
|
||||
"""Start listening for participant connection events."""
|
||||
self.ctx.room.on("participant_connected", self.on_participant_connected)
|
||||
self.ctx.room.on("participant_disconnected", self.on_participant_disconnected)
|
||||
|
||||
async def aclose(self):
|
||||
"""Close all sessions and cleanup resources."""
|
||||
await utils.aio.cancel_and_wait(*self._tasks)
|
||||
|
||||
await asyncio.gather(
|
||||
*[self._close_session(session) for session in self._sessions.values()]
|
||||
)
|
||||
|
||||
self.ctx.room.off("participant_connected", self.on_participant_connected)
|
||||
self.ctx.room.off("participant_disconnected", self.on_participant_disconnected)
|
||||
|
||||
def on_participant_connected(self, participant: rtc.RemoteParticipant):
|
||||
"""Handle new participant connection by starting transcription session."""
|
||||
if participant.identity in self._sessions:
|
||||
return
|
||||
|
||||
logger.info(f"starting session for {participant.identity}")
|
||||
task = asyncio.create_task(self._start_session(participant))
|
||||
self._tasks.add(task)
|
||||
|
||||
def on_task_done(task: asyncio.Task):
|
||||
try:
|
||||
self._sessions[participant.identity] = task.result()
|
||||
finally:
|
||||
self._tasks.discard(task)
|
||||
|
||||
task.add_done_callback(on_task_done)
|
||||
|
||||
def on_participant_disconnected(self, participant: rtc.RemoteParticipant):
|
||||
"""Handle participant disconnection by closing transcription session."""
|
||||
if (session := self._sessions.pop(participant.identity)) is None:
|
||||
return
|
||||
|
||||
logger.info(f"closing session for {participant.identity}")
|
||||
task = asyncio.create_task(self._close_session(session))
|
||||
self._tasks.add(task)
|
||||
task.add_done_callback(lambda _: self._tasks.discard(task))
|
||||
|
||||
async def _start_session(self, participant: rtc.RemoteParticipant) -> AgentSession:
|
||||
"""Create and start transcription session for participant."""
|
||||
if participant.identity in self._sessions:
|
||||
return self._sessions[participant.identity]
|
||||
|
||||
session = AgentSession(
|
||||
vad=self.ctx.proc.userdata["vad"],
|
||||
)
|
||||
room_io = RoomIO(
|
||||
agent_session=session,
|
||||
room=self.ctx.room,
|
||||
participant=participant,
|
||||
input_options=RoomInputOptions(
|
||||
text_enabled=False,
|
||||
),
|
||||
output_options=RoomOutputOptions(
|
||||
transcription_enabled=True,
|
||||
audio_enabled=False,
|
||||
),
|
||||
)
|
||||
await room_io.start()
|
||||
await session.start(
|
||||
agent=Transcriber(
|
||||
participant_identity=participant.identity,
|
||||
)
|
||||
)
|
||||
return session
|
||||
|
||||
async def _close_session(self, sess: AgentSession) -> None:
|
||||
"""Close and cleanup transcription session."""
|
||||
await sess.drain()
|
||||
await sess.aclose()
|
||||
|
||||
|
||||
async def entrypoint(ctx: JobContext):
|
||||
"""Initialize and run the multi-user transcriber."""
|
||||
transcriber = MultiUserTranscriber(ctx)
|
||||
transcriber.start()
|
||||
|
||||
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
|
||||
for participant in ctx.room.remote_participants.values():
|
||||
transcriber.on_participant_connected(participant)
|
||||
|
||||
async def cleanup():
|
||||
await transcriber.aclose()
|
||||
|
||||
ctx.add_shutdown_callback(cleanup)
|
||||
|
||||
|
||||
async def handle_transcriber_job_request(job_req: JobRequest) -> None:
|
||||
"""Accept job if no transcriber exists in room, otherwise reject."""
|
||||
room_name = job_req.room.name
|
||||
transcriber_id = f"{TRANSCRIBER_AGENT_NAME}-{room_name}"
|
||||
|
||||
async with api.LiveKitAPI() as lkapi:
|
||||
try:
|
||||
response = await lkapi.room.list_participants(
|
||||
list=api.ListParticipantsRequest(room=room_name)
|
||||
)
|
||||
|
||||
transcriber_exists = any(
|
||||
p.kind == rtc.ParticipantKind.PARTICIPANT_KIND_AGENT
|
||||
and p.identity == transcriber_id
|
||||
for p in response.participants
|
||||
)
|
||||
|
||||
if transcriber_exists:
|
||||
logger.info(f"Transcriber exists in {room_name} - rejecting")
|
||||
await job_req.reject()
|
||||
else:
|
||||
logger.info(f"Accepting job for {room_name}")
|
||||
await job_req.accept(identity=transcriber_id)
|
||||
|
||||
except Exception:
|
||||
logger.exception(f"Error processing job for {room_name}")
|
||||
await job_req.reject()
|
||||
|
||||
|
||||
def prewarm(proc: JobProcess):
|
||||
"""Preload voice activity detection model."""
|
||||
proc.userdata["vad"] = silero.VAD.load()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli.run_app(
|
||||
WorkerOptions(
|
||||
entrypoint_fnc=entrypoint,
|
||||
request_fnc=handle_transcriber_job_request,
|
||||
prewarm_fnc=prewarm,
|
||||
agent_name=TRANSCRIBER_AGENT_NAME,
|
||||
permissions=WorkerPermissions(hidden=True),
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "0.1.23"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.2.6",
|
||||
"livekit-plugins-deepgram==1.2.6",
|
||||
"livekit-plugins-silero==1.2.6",
|
||||
"python-dotenv==1.1.1"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"ruff==0.12.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py313"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"B", # flake8-bugbear
|
||||
"C4", # flake8-comprehensions
|
||||
"D", # pydocstyle
|
||||
"E", # pycodestyle error
|
||||
"F", # Pyflakes
|
||||
"I", # Isort
|
||||
"ISC", # flake8-implicit-str-concat
|
||||
"PLC", # Pylint Convention
|
||||
"PLE", # Pylint Error
|
||||
"PLR", # Pylint Refactor
|
||||
"PLW", # Pylint Warning
|
||||
"RUF100", # Ruff unused-noqa
|
||||
"S", # flake8-bandit
|
||||
"T20", # flake8-print
|
||||
"W", # pycodestyle warning
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/*" = [
|
||||
"S101", # use of assert
|
||||
]
|
||||
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
# Use Google-style docstrings.
|
||||
convention = "google"
|
||||
@@ -99,6 +99,7 @@ class ResourceAccessInline(admin.TabularInline):
|
||||
|
||||
model = models.ResourceAccess
|
||||
extra = 0
|
||||
autocomplete_fields = ["user"]
|
||||
|
||||
|
||||
@admin.register(models.Room)
|
||||
@@ -106,6 +107,10 @@ class RoomAdmin(admin.ModelAdmin):
|
||||
"""Room admin interface declaration."""
|
||||
|
||||
inlines = (ResourceAccessInline,)
|
||||
search_fields = ["name", "slug", "=id"]
|
||||
list_display = ["name", "slug", "access_level", "created_at"]
|
||||
list_filter = ["access_level", "created_at"]
|
||||
readonly_fields = ["id", "created_at", "updated_at"]
|
||||
|
||||
|
||||
class RecordingAccessInline(admin.TabularInline):
|
||||
@@ -120,4 +125,28 @@ class RecordingAdmin(admin.ModelAdmin):
|
||||
"""Recording admin interface declaration."""
|
||||
|
||||
inlines = (RecordingAccessInline,)
|
||||
list_display = ("id", "status", "room", "created_at", "worker_id")
|
||||
search_fields = ["status", "=id", "worker_id", "room__slug", "=room__id"]
|
||||
list_display = ("id", "status", "room", "get_owner", "created_at", "worker_id")
|
||||
list_filter = ["status", "room", "created_at"]
|
||||
readonly_fields = ["id", "created_at", "updated_at"]
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
|
||||
return super().get_queryset(request).prefetch_related("accesses__user")
|
||||
|
||||
def get_owner(self, obj):
|
||||
"""Return the owner of the recording for display in the admin list."""
|
||||
|
||||
owners = [
|
||||
access
|
||||
for access in obj.accesses.all()
|
||||
if access.role == models.RoleChoices.OWNER
|
||||
]
|
||||
|
||||
if not owners:
|
||||
return _("No owner")
|
||||
|
||||
if len(owners) > 1:
|
||||
return _("Multiple owners")
|
||||
|
||||
return str(owners[0].user)
|
||||
|
||||
@@ -40,6 +40,22 @@ def get_frontend_configuration(request):
|
||||
"recording": {
|
||||
"is_enabled": settings.RECORDING_ENABLE,
|
||||
"available_modes": settings.RECORDING_WORKER_CLASSES.keys(),
|
||||
"expiration_days": settings.RECORDING_EXPIRATION_DAYS,
|
||||
"max_duration": settings.RECORDING_MAX_DURATION,
|
||||
},
|
||||
"telephony": {
|
||||
"enabled": settings.ROOM_TELEPHONY_ENABLED,
|
||||
"phone_number": settings.ROOM_TELEPHONY_PHONE_NUMBER
|
||||
if settings.ROOM_TELEPHONY_ENABLED
|
||||
else None,
|
||||
"default_country": settings.ROOM_TELEPHONY_DEFAULT_COUNTRY,
|
||||
},
|
||||
"subtitle": {"enabled": settings.ROOM_SUBTITLE_ENABLED},
|
||||
"livekit": {
|
||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||
"force_wss_protocol": settings.LIVEKIT_FORCE_WSS_PROTOCOL,
|
||||
"enable_firefox_proxy_workaround": settings.LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND,
|
||||
"default_sources": settings.LIVEKIT_DEFAULT_SOURCES,
|
||||
},
|
||||
}
|
||||
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Feature flag handler for the Meet core app."""
|
||||
|
||||
from functools import wraps
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import Http404
|
||||
|
||||
|
||||
class FeatureFlag:
|
||||
"""Check if features are enabled and return error responses."""
|
||||
|
||||
FLAGS = {
|
||||
"recording": "RECORDING_ENABLE",
|
||||
"storage_event": "RECORDING_STORAGE_EVENT_ENABLE",
|
||||
"subtitle": "ROOM_SUBTITLE_ENABLED",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def flag_is_active(cls, flag_name):
|
||||
"""Check if a feature flag is active."""
|
||||
|
||||
setting_name = cls.FLAGS.get(flag_name)
|
||||
|
||||
if setting_name is None:
|
||||
return False
|
||||
|
||||
return getattr(settings, setting_name, False)
|
||||
|
||||
@classmethod
|
||||
def require(cls, flag_name):
|
||||
"""Decorator to check feature at the beginning of endpoint methods."""
|
||||
|
||||
if flag_name not in cls.FLAGS:
|
||||
raise ValueError(f"Unknown feature flag: {flag_name}")
|
||||
|
||||
def decorator(view_func):
|
||||
@wraps(view_func)
|
||||
def wrapper(self, request, *args, **kwargs):
|
||||
if not cls.flag_is_active(flag_name):
|
||||
raise Http404
|
||||
return view_func(self, request, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
@@ -1,7 +1,5 @@
|
||||
"""Permission handlers for the Meet core app."""
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from rest_framework import permissions
|
||||
|
||||
from ..models import RoleChoices
|
||||
@@ -64,7 +62,7 @@ class RoomPermissions(permissions.BasePermission):
|
||||
if request.method == "DELETE":
|
||||
return obj.is_owner(user)
|
||||
|
||||
return obj.is_administrator(user)
|
||||
return obj.is_administrator_or_owner(user)
|
||||
|
||||
|
||||
class ResourceAccessPermission(IsAuthenticated):
|
||||
@@ -80,7 +78,7 @@ class ResourceAccessPermission(IsAuthenticated):
|
||||
if request.method == "DELETE" and obj.role == RoleChoices.OWNER:
|
||||
return obj.user == user
|
||||
|
||||
return obj.resource.is_administrator(user)
|
||||
return obj.resource.is_administrator_or_owner(user)
|
||||
|
||||
|
||||
class HasAbilityPermission(IsAuthenticated):
|
||||
@@ -94,28 +92,17 @@ class HasAbilityPermission(IsAuthenticated):
|
||||
class HasPrivilegesOnRoom(IsAuthenticated):
|
||||
"""Check if user has privileges on a given room."""
|
||||
|
||||
message = "You must have privileges to start a recording."
|
||||
message = "You must have privileges on room to perform this action."
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Determine if user has privileges on room."""
|
||||
return obj.is_owner(request.user) or obj.is_administrator(request.user)
|
||||
return obj.is_administrator_or_owner(request.user)
|
||||
|
||||
|
||||
class IsRecordingEnabled(permissions.BasePermission):
|
||||
"""Check if the recording feature is enabled."""
|
||||
class HasLiveKitRoomAccess(permissions.BasePermission):
|
||||
"""Check if authenticated user's LiveKit token is for the specific room."""
|
||||
|
||||
message = "Access denied, recording is disabled."
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Determine if access is allowed based on settings."""
|
||||
return settings.RECORDING_ENABLE
|
||||
|
||||
|
||||
class IsStorageEventEnabled(permissions.BasePermission):
|
||||
"""Check if the storage event feature is enabled."""
|
||||
|
||||
message = "Access denied, storage event is disabled."
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Determine if access is allowed based on settings."""
|
||||
return settings.RECORDING_STORAGE_EVENT_ENABLE
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if not request.auth or not hasattr(request.auth, "video"):
|
||||
return False
|
||||
return request.auth.video.room == str(obj.id)
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
"""Client serializers for the Meet core app."""
|
||||
|
||||
# pylint: disable=abstract-method,no-name-in-module
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from livekit.api import ParticipantPermission
|
||||
from rest_framework import serializers
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from timezone_field.rest_framework import TimeZoneSerializerField
|
||||
|
||||
from core import models, utils
|
||||
|
||||
@@ -11,9 +15,11 @@ from core import models, utils
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
"""Serialize users."""
|
||||
|
||||
timezone = TimeZoneSerializerField()
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["id", "email", "full_name", "short_name"]
|
||||
fields = ["id", "email", "full_name", "short_name", "timezone", "language"]
|
||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
||||
|
||||
|
||||
@@ -34,10 +40,10 @@ class ResourceAccessSerializerMixin:
|
||||
# Update
|
||||
self.instance
|
||||
and (
|
||||
data["role"] == models.RoleChoices.OWNER
|
||||
data.get("role") == models.RoleChoices.OWNER
|
||||
and not self.instance.resource.is_owner(user)
|
||||
or self.instance.role == models.RoleChoices.OWNER
|
||||
and not self.instance.user == user
|
||||
and self.instance.user != user
|
||||
)
|
||||
) or (
|
||||
# Create
|
||||
@@ -55,7 +61,9 @@ class ResourceAccessSerializerMixin:
|
||||
request = self.context.get("request", None)
|
||||
user = getattr(request, "user", None)
|
||||
|
||||
if not (user and user.is_authenticated and resource.is_administrator(user)):
|
||||
if not (
|
||||
user and user.is_authenticated and resource.is_administrator_or_owner(user)
|
||||
):
|
||||
raise PermissionDenied(
|
||||
_("You must be administrator or owner of a room to add accesses to it.")
|
||||
)
|
||||
@@ -100,8 +108,8 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.Room
|
||||
fields = ["id", "name", "slug", "configuration", "access_level"]
|
||||
read_only_fields = ["id", "slug"]
|
||||
fields = ["id", "name", "slug", "configuration", "access_level", "pin_code"]
|
||||
read_only_fields = ["id", "slug", "pin_code"]
|
||||
|
||||
def to_representation(self, instance):
|
||||
"""
|
||||
@@ -115,9 +123,11 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
return output
|
||||
|
||||
role = instance.get_role(request.user)
|
||||
is_admin = models.RoleChoices.check_administrator_role(role)
|
||||
is_admin_or_owner = models.RoleChoices.check_administrator_role(
|
||||
role
|
||||
) or models.RoleChoices.check_owner_role(role)
|
||||
|
||||
if role is not None:
|
||||
if is_admin_or_owner:
|
||||
access_serializer = NestedResourceAccessSerializer(
|
||||
instance.accesses.select_related("resource", "user").all(),
|
||||
context=self.context,
|
||||
@@ -125,7 +135,9 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
)
|
||||
output["accesses"] = access_serializer.data
|
||||
|
||||
if not is_admin:
|
||||
configuration = output["configuration"]
|
||||
|
||||
if not is_admin_or_owner:
|
||||
del output["configuration"]
|
||||
|
||||
should_access_room = (
|
||||
@@ -141,10 +153,14 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
room_id = f"{instance.id!s}"
|
||||
username = request.query_params.get("username", None)
|
||||
output["livekit"] = utils.generate_livekit_config(
|
||||
room_id=room_id, user=request.user, username=username
|
||||
room_id=room_id,
|
||||
user=request.user,
|
||||
username=username,
|
||||
configuration=configuration,
|
||||
is_admin_or_owner=is_admin_or_owner,
|
||||
)
|
||||
|
||||
output["is_administrable"] = is_admin
|
||||
output["is_administrable"] = is_admin_or_owner
|
||||
|
||||
return output
|
||||
|
||||
@@ -156,11 +172,33 @@ class RecordingSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.Recording
|
||||
fields = ["id", "room", "created_at", "updated_at", "status"]
|
||||
fields = [
|
||||
"id",
|
||||
"room",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"status",
|
||||
"mode",
|
||||
"key",
|
||||
"is_expired",
|
||||
"expired_at",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class StartRecordingSerializer(serializers.Serializer):
|
||||
class BaseValidationOnlySerializer(serializers.Serializer):
|
||||
"""Base serializer for validation-only operations."""
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError(f"{self.__class__.__name__} is validation-only")
|
||||
|
||||
|
||||
class StartRecordingSerializer(BaseValidationOnlySerializer):
|
||||
"""Validate start recording requests."""
|
||||
|
||||
mode = serializers.ChoiceField(
|
||||
@@ -173,53 +211,93 @@ class StartRecordingSerializer(serializers.Serializer):
|
||||
},
|
||||
)
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("StartRecordingSerializer is validation-only")
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("StartRecordingSerializer is validation-only")
|
||||
|
||||
|
||||
class RequestEntrySerializer(serializers.Serializer):
|
||||
class RequestEntrySerializer(BaseValidationOnlySerializer):
|
||||
"""Validate request entry data."""
|
||||
|
||||
username = serializers.CharField(required=True)
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("StartRecordingSerializer is validation-only")
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("StartRecordingSerializer is validation-only")
|
||||
|
||||
|
||||
class ParticipantEntrySerializer(serializers.Serializer):
|
||||
class ParticipantEntrySerializer(BaseValidationOnlySerializer):
|
||||
"""Validate participant entry decision data."""
|
||||
|
||||
participant_id = serializers.CharField(required=True)
|
||||
participant_id = serializers.UUIDField(required=True)
|
||||
allow_entry = serializers.BooleanField(required=True)
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("StartRecordingSerializer is validation-only")
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("StartRecordingSerializer is validation-only")
|
||||
|
||||
|
||||
class CreationCallbackSerializer(serializers.Serializer):
|
||||
class CreationCallbackSerializer(BaseValidationOnlySerializer):
|
||||
"""Validate room creation callback data."""
|
||||
|
||||
callback_id = serializers.CharField(required=True)
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("StartRecordingSerializer is validation-only")
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("StartRecordingSerializer is validation-only")
|
||||
class RoomInviteSerializer(serializers.Serializer):
|
||||
"""Validate room invite creation data."""
|
||||
|
||||
emails = serializers.ListField(child=serializers.EmailField(), allow_empty=False)
|
||||
|
||||
|
||||
class BaseParticipantsManagementSerializer(BaseValidationOnlySerializer):
|
||||
"""Base serializer for participant management operations."""
|
||||
|
||||
participant_identity = serializers.UUIDField(
|
||||
help_text="LiveKit participant identity (UUID format)"
|
||||
)
|
||||
|
||||
|
||||
class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
|
||||
"""Validate participant muting data."""
|
||||
|
||||
track_sid = serializers.CharField(
|
||||
max_length=255, help_text="LiveKit track SID to mute"
|
||||
)
|
||||
|
||||
|
||||
class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
|
||||
"""Validate participant update data."""
|
||||
|
||||
metadata = serializers.DictField(
|
||||
required=False, allow_null=True, help_text="Participant metadata as JSON object"
|
||||
)
|
||||
attributes = serializers.DictField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Participant attributes as JSON object",
|
||||
)
|
||||
permission = serializers.DictField(
|
||||
required=False,
|
||||
allow_null=True,
|
||||
help_text="Participant permission as JSON object",
|
||||
)
|
||||
name = serializers.CharField(
|
||||
max_length=255,
|
||||
required=False,
|
||||
allow_blank=True,
|
||||
allow_null=True,
|
||||
help_text="Display name for the participant",
|
||||
)
|
||||
|
||||
def validate(self, attrs):
|
||||
"""Ensure at least one update field is provided."""
|
||||
update_fields = ["metadata", "attributes", "permission", "name"]
|
||||
|
||||
has_update = any(
|
||||
field in attrs and attrs[field] is not None and attrs[field] != ""
|
||||
for field in update_fields
|
||||
)
|
||||
|
||||
if not has_update:
|
||||
raise serializers.ValidationError(
|
||||
f"At least one of the following fields must be provided: "
|
||||
f"{', '.join(update_fields)}."
|
||||
)
|
||||
|
||||
if "permission" in attrs:
|
||||
try:
|
||||
ParticipantPermission(**attrs["permission"])
|
||||
except ValueError as e:
|
||||
raise serializers.ValidationError(
|
||||
{"permission": f"Invalid permission: {str(e)}"}
|
||||
) from e
|
||||
|
||||
return attrs
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import uuid
|
||||
from logging import getLogger
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
@@ -20,7 +21,8 @@ from rest_framework import (
|
||||
status as drf_status,
|
||||
)
|
||||
|
||||
from core import models, utils
|
||||
from core import enums, models, utils
|
||||
from core.recording.enums import FileExtension
|
||||
from core.recording.event.authentication import StorageEventAuthentication
|
||||
from core.recording.event.exceptions import (
|
||||
InvalidBucketError,
|
||||
@@ -39,6 +41,7 @@ from core.recording.worker.factories import (
|
||||
from core.recording.worker.mediator import (
|
||||
WorkerServiceMediator,
|
||||
)
|
||||
from core.services.invitation import InvitationService
|
||||
from core.services.livekit_events import (
|
||||
LiveKitEventsService,
|
||||
LiveKitWebhookError,
|
||||
@@ -47,9 +50,16 @@ from core.services.lobby import (
|
||||
LobbyParticipantNotFound,
|
||||
LobbyService,
|
||||
)
|
||||
from core.services.participants_management import (
|
||||
ParticipantsManagement,
|
||||
ParticipantsManagementException,
|
||||
)
|
||||
from core.services.room_creation import RoomCreation
|
||||
from core.services.subtitle import SubtitleException, SubtitleService
|
||||
|
||||
from ..authentication.livekit import LiveKitTokenAuthentication
|
||||
from . import permissions, serializers
|
||||
from .feature_flag import FeatureFlag
|
||||
|
||||
# pylint: disable=too-many-ancestors
|
||||
|
||||
@@ -284,9 +294,9 @@ class RoomViewSet(
|
||||
url_path="start-recording",
|
||||
permission_classes=[
|
||||
permissions.HasPrivilegesOnRoom,
|
||||
permissions.IsRecordingEnabled,
|
||||
],
|
||||
)
|
||||
@FeatureFlag.require("recording")
|
||||
def start_room_recording(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Start recording a room."""
|
||||
|
||||
@@ -329,9 +339,9 @@ class RoomViewSet(
|
||||
url_path="stop-recording",
|
||||
permission_classes=[
|
||||
permissions.HasPrivilegesOnRoom,
|
||||
permissions.IsRecordingEnabled,
|
||||
],
|
||||
)
|
||||
@FeatureFlag.require("recording")
|
||||
def stop_room_recording(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Stop room recording."""
|
||||
|
||||
@@ -363,7 +373,7 @@ class RoomViewSet(
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["POST"],
|
||||
methods=["post"],
|
||||
url_path="request-entry",
|
||||
permission_classes=[],
|
||||
throttle_classes=[RequestEntryAnonRateThrottle],
|
||||
@@ -414,7 +424,8 @@ class RoomViewSet(
|
||||
try:
|
||||
lobby_service.handle_participant_entry(
|
||||
room_id=room.id,
|
||||
**serializer.validated_data,
|
||||
participant_id=str(serializer.validated_data.get("participant_id")),
|
||||
allow_entry=serializer.validated_data.get("allow_entry"),
|
||||
)
|
||||
return drf_response.Response({"message": "Participant was updated."})
|
||||
|
||||
@@ -446,7 +457,7 @@ class RoomViewSet(
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["POST"],
|
||||
methods=["post"],
|
||||
url_path="webhooks-livekit",
|
||||
permission_classes=[],
|
||||
)
|
||||
@@ -472,7 +483,7 @@ class RoomViewSet(
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["POST"],
|
||||
methods=["post"],
|
||||
url_path="creation-callback",
|
||||
permission_classes=[],
|
||||
throttle_classes=[CreationCallbackAnonRateThrottle],
|
||||
@@ -495,41 +506,174 @@ class RoomViewSet(
|
||||
{"status": "success", "room": room}, status=drf_status.HTTP_200_OK
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
url_path="invite",
|
||||
permission_classes=[
|
||||
permissions.HasPrivilegesOnRoom,
|
||||
],
|
||||
)
|
||||
def invite(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Send email invitations to join a room.
|
||||
|
||||
class ResourceAccessListModelMixin:
|
||||
"""List mixin for resource access API."""
|
||||
This API endpoint allows a user with appropriate privileges to send email invitations
|
||||
to one or more recipients, inviting them to join the specified room.
|
||||
"""
|
||||
|
||||
def get_permissions(self):
|
||||
"""User only needs to be authenticated to list rooms access"""
|
||||
if self.action == "list":
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
else:
|
||||
return super().get_permissions()
|
||||
room = self.get_object()
|
||||
|
||||
return [permission() for permission in permission_classes]
|
||||
serializer = serializers.RoomInviteSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return the queryset according to the action."""
|
||||
queryset = super().get_queryset()
|
||||
if self.action == "list":
|
||||
user = self.request.user
|
||||
queryset = queryset.filter(
|
||||
Q(resource__accesses__user=user),
|
||||
resource__accesses__role__in=[
|
||||
models.RoleChoices.ADMIN,
|
||||
models.RoleChoices.OWNER,
|
||||
],
|
||||
).distinct()
|
||||
return queryset
|
||||
emails = serializer.validated_data.get("emails")
|
||||
emails = list(set(emails))
|
||||
|
||||
InvitationService().invite_to_room(
|
||||
room=room, sender=request.user, emails=emails
|
||||
)
|
||||
|
||||
return drf_response.Response(
|
||||
{"status": "success", "message": "invitations sent"},
|
||||
status=drf_status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
url_path="start-subtitle",
|
||||
permission_classes=[
|
||||
permissions.HasLiveKitRoomAccess,
|
||||
],
|
||||
authentication_classes=[LiveKitTokenAuthentication],
|
||||
)
|
||||
@FeatureFlag.require("subtitle")
|
||||
def start_subtitle(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Start realtime transcription for the room.
|
||||
|
||||
Requires valid LiveKit token for room authorization.
|
||||
Anonymous users can start subtitles if they have room access tokens.
|
||||
"""
|
||||
|
||||
room = self.get_object()
|
||||
|
||||
try:
|
||||
SubtitleService().start_subtitle(room)
|
||||
except SubtitleException:
|
||||
return drf_response.Response(
|
||||
{"error": f"Subtitles failed to start for room {room.slug}"},
|
||||
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
return drf_response.Response(
|
||||
{"status": "success"}, status=drf_status.HTTP_200_OK
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
url_path="mute-participant",
|
||||
url_name="mute-participant",
|
||||
permission_classes=[permissions.HasPrivilegesOnRoom],
|
||||
)
|
||||
def mute_participant(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Mute a specific track for a participant in the room."""
|
||||
room = self.get_object()
|
||||
|
||||
serializer = serializers.MuteParticipantSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
try:
|
||||
ParticipantsManagement().mute(
|
||||
room_name=str(room.pk),
|
||||
identity=str(serializer.validated_data["participant_identity"]),
|
||||
track_sid=serializer.validated_data["track_sid"],
|
||||
)
|
||||
except ParticipantsManagementException:
|
||||
return drf_response.Response(
|
||||
{"error": "Failed to mute participant"},
|
||||
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
return drf_response.Response(
|
||||
{
|
||||
"status": "success",
|
||||
},
|
||||
status=drf_status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
url_path="update-participant",
|
||||
url_name="update-participant",
|
||||
permission_classes=[permissions.HasPrivilegesOnRoom],
|
||||
)
|
||||
def update_participant(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Update participant attributes, permissions, or metadata."""
|
||||
room = self.get_object()
|
||||
|
||||
serializer = serializers.UpdateParticipantSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
try:
|
||||
ParticipantsManagement().update(
|
||||
room_name=str(room.pk),
|
||||
identity=str(serializer.validated_data["participant_identity"]),
|
||||
metadata=serializer.validated_data.get("metadata"),
|
||||
attributes=serializer.validated_data.get("attributes"),
|
||||
permission=serializer.validated_data.get("permission"),
|
||||
name=serializer.validated_data.get("name"),
|
||||
)
|
||||
except ParticipantsManagementException:
|
||||
return drf_response.Response(
|
||||
{"error": "Failed to update participant"},
|
||||
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
return drf_response.Response(
|
||||
{
|
||||
"status": "success",
|
||||
},
|
||||
status=drf_status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
url_path="remove-participant",
|
||||
url_name="remove-participant",
|
||||
permission_classes=[permissions.HasPrivilegesOnRoom],
|
||||
)
|
||||
def remove_participant(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Remove a participant from the room."""
|
||||
room = self.get_object()
|
||||
|
||||
serializer = serializers.BaseParticipantsManagementSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
try:
|
||||
ParticipantsManagement().remove(
|
||||
room_name=str(room.pk),
|
||||
identity=str(serializer.validated_data["participant_identity"]),
|
||||
)
|
||||
except ParticipantsManagementException:
|
||||
return drf_response.Response(
|
||||
{"error": "Failed to remove participant"},
|
||||
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
return drf_response.Response(
|
||||
{"status": "success"}, status=drf_status.HTTP_200_OK
|
||||
)
|
||||
|
||||
|
||||
class ResourceAccessViewSet(
|
||||
ResourceAccessListModelMixin,
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
@@ -540,10 +684,30 @@ class ResourceAccessViewSet(
|
||||
queryset = models.ResourceAccess.objects.all()
|
||||
serializer_class = serializers.ResourceAccessSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
"""Return the queryset according to the action."""
|
||||
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# Restrict access to resources the user either has explicit
|
||||
# permissions for or administrative privileges over.
|
||||
if self.action == "list":
|
||||
user = self.request.user
|
||||
queryset = queryset.filter(
|
||||
Q(resource__accesses__user=user),
|
||||
resource__accesses__role__in=[
|
||||
models.RoleChoices.ADMIN,
|
||||
models.RoleChoices.OWNER,
|
||||
],
|
||||
).distinct()
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
class RecordingViewSet(
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
@@ -569,8 +733,8 @@ class RecordingViewSet(
|
||||
methods=["post"],
|
||||
url_path="storage-hook",
|
||||
authentication_classes=[StorageEventAuthentication],
|
||||
permission_classes=[permissions.IsStorageEventEnabled],
|
||||
)
|
||||
@FeatureFlag.require("storage_event")
|
||||
def on_storage_event_received(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Handle incoming storage hook events for recordings."""
|
||||
|
||||
@@ -617,3 +781,85 @@ class RecordingViewSet(
|
||||
return drf_response.Response(
|
||||
{"message": "Event processed."},
|
||||
)
|
||||
|
||||
def _auth_get_original_url(self, request):
|
||||
"""
|
||||
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
|
||||
Raises PermissionDenied if the header is missing.
|
||||
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
|
||||
See corresponding ingress configuration in Helm chart and read about the
|
||||
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
|
||||
is configured to do this.
|
||||
Based on the original url and the logged-in user, we must decide if we authorize Nginx
|
||||
to let this request go through (by returning a 200 code) or if we block it (by returning
|
||||
a 403 error). Note that we return 403 errors without any further details for security
|
||||
reasons.
|
||||
"""
|
||||
# Extract the original URL from the request header
|
||||
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
|
||||
if not original_url:
|
||||
logger.debug("Missing HTTP_X_ORIGINAL_URL header in subrequest")
|
||||
raise drf_exceptions.PermissionDenied()
|
||||
|
||||
logger.debug("Original url: '%s'", original_url)
|
||||
return urlparse(original_url)
|
||||
|
||||
def _auth_get_url_params(self, pattern, fragment):
|
||||
"""
|
||||
Extracts URL parameters from the given fragment using the specified regex pattern.
|
||||
Raises PermissionDenied if parameters cannot be extracted.
|
||||
"""
|
||||
|
||||
match = pattern.search(fragment)
|
||||
|
||||
try:
|
||||
return match.groupdict()
|
||||
except (ValueError, AttributeError) as exc:
|
||||
logger.debug("Failed to extract parameters from subrequest URL: %s", exc)
|
||||
raise drf_exceptions.PermissionDenied() from exc
|
||||
|
||||
@decorators.action(detail=False, methods=["get"], url_path="media-auth")
|
||||
def media_auth(self, request, *args, **kwargs):
|
||||
"""
|
||||
This view is used by an Nginx subrequest to control access to a recording's
|
||||
media file.
|
||||
When we let the request go through, we compute authorization headers that will be added to
|
||||
the request going through thanks to the nginx.ingress.kubernetes.io/auth-response-headers
|
||||
annotation. The request will then be proxied to the object storage backend who will
|
||||
respond with the file after checking the signature included in headers.
|
||||
"""
|
||||
|
||||
parsed_url = self._auth_get_original_url(request)
|
||||
|
||||
url_params = self._auth_get_url_params(
|
||||
enums.RECORDING_STORAGE_URL_PATTERN, parsed_url.path
|
||||
)
|
||||
|
||||
user = request.user
|
||||
recording_id = url_params["recording_id"]
|
||||
|
||||
extension = url_params["extension"]
|
||||
if extension not in [item.value for item in FileExtension]:
|
||||
raise drf_exceptions.ValidationError({"detail": "Unsupported extension."})
|
||||
|
||||
try:
|
||||
recording = models.Recording.objects.get(id=recording_id)
|
||||
except models.Recording.DoesNotExist as e:
|
||||
raise drf_exceptions.NotFound("No recording found for this event.") from e
|
||||
|
||||
if extension != recording.extension:
|
||||
raise drf_exceptions.NotFound("No recording found with this extension.")
|
||||
|
||||
abilities = recording.get_abilities(user)
|
||||
|
||||
if not abilities["retrieve"]:
|
||||
logger.debug("User '%s' lacks permission for attachment", user.id)
|
||||
raise drf_exceptions.PermissionDenied()
|
||||
|
||||
if not recording.is_saved:
|
||||
logger.debug("Recording '%s' has not been saved", recording)
|
||||
raise drf_exceptions.PermissionDenied()
|
||||
|
||||
request = utils.generate_s3_authorization_headers(recording.key)
|
||||
|
||||
return drf_response.Response("authorized", headers=request.headers, status=200)
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
"""Meet Core application"""
|
||||
# from django.apps import AppConfig
|
||||
# from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
# class CoreConfig(AppConfig):
|
||||
# """Configuration class for the Meet core app."""
|
||||
|
||||
# name = "core"
|
||||
# app_label = "core"
|
||||
# verbose_name = _("meet core application")
|
||||
@@ -1,12 +1,13 @@
|
||||
"""Authentication Backends for the Meet core app."""
|
||||
|
||||
import contextlib
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import requests
|
||||
from mozilla_django_oidc.auth import (
|
||||
OIDCAuthenticationBackend as MozillaOIDCAuthenticationBackend,
|
||||
from lasuite.oidc_login.backends import (
|
||||
OIDCAuthenticationBackend as LaSuiteOIDCAuthenticationBackend,
|
||||
)
|
||||
|
||||
from core.models import User
|
||||
@@ -17,93 +18,46 @@ from core.services.marketing import (
|
||||
)
|
||||
|
||||
|
||||
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
|
||||
"""Custom OpenID Connect (OIDC) Authentication Backend.
|
||||
|
||||
This class overrides the default OIDC Authentication Backend to accommodate differences
|
||||
in the User and Identity models, and handles signed and/or encrypted UserInfo response.
|
||||
"""
|
||||
|
||||
def get_userinfo(self, access_token, id_token, payload):
|
||||
"""Return user details dictionary.
|
||||
def get_extra_claims(self, user_info):
|
||||
"""
|
||||
Return extra claims from user_info.
|
||||
|
||||
Parameters:
|
||||
- access_token (str): The access token.
|
||||
- id_token (str): The id token (unused).
|
||||
- payload (dict): The token payload (unused).
|
||||
|
||||
Note: The id_token and payload parameters are unused in this implementation,
|
||||
but were kept to preserve base method signature.
|
||||
|
||||
Note: It handles signed and/or encrypted UserInfo Response. It is required by
|
||||
Agent Connect, which follows the OIDC standard. It forces us to override the
|
||||
base method, which deal with 'application/json' response.
|
||||
Args:
|
||||
user_info (dict): The user information dictionary.
|
||||
|
||||
Returns:
|
||||
- dict: User details dictionary obtained from the OpenID Connect user endpoint.
|
||||
dict: A dictionary of extra claims.
|
||||
|
||||
"""
|
||||
|
||||
user_response = requests.get(
|
||||
self.OIDC_OP_USER_ENDPOINT,
|
||||
headers={"Authorization": f"Bearer {access_token}"},
|
||||
verify=self.get_settings("OIDC_VERIFY_SSL", True),
|
||||
timeout=self.get_settings("OIDC_TIMEOUT", None),
|
||||
proxies=self.get_settings("OIDC_PROXY", None),
|
||||
)
|
||||
user_response.raise_for_status()
|
||||
userinfo = self.verify_token(user_response.text)
|
||||
return userinfo
|
||||
|
||||
def get_or_create_user(self, access_token, id_token, payload):
|
||||
"""Return a User based on userinfo. Get or create a new user if no user matches the Sub.
|
||||
|
||||
Parameters:
|
||||
- access_token (str): The access token.
|
||||
- id_token (str): The ID token.
|
||||
- payload (dict): The user payload.
|
||||
|
||||
Returns:
|
||||
- User: An existing or newly created User instance.
|
||||
|
||||
Raises:
|
||||
- Exception: Raised when user creation is not allowed and no existing user is found.
|
||||
"""
|
||||
|
||||
user_info = self.get_userinfo(access_token, id_token, payload)
|
||||
sub = user_info.get("sub")
|
||||
|
||||
if not sub:
|
||||
raise SuspiciousOperation(
|
||||
_("User info contained no recognizable user identification")
|
||||
)
|
||||
|
||||
email = user_info.get("email")
|
||||
user = self.get_existing_user(sub, email)
|
||||
|
||||
claims = {
|
||||
"email": email,
|
||||
return {
|
||||
# Get user's full name from OIDC fields defined in settings
|
||||
"full_name": self.compute_full_name(user_info),
|
||||
"short_name": user_info.get(settings.OIDC_USERINFO_SHORTNAME_FIELD),
|
||||
}
|
||||
if not user and self.get_settings("OIDC_CREATE_USER", True):
|
||||
user = User.objects.create(
|
||||
sub=sub,
|
||||
password="!", # noqa: S106
|
||||
**claims,
|
||||
)
|
||||
|
||||
if settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
|
||||
self.signup_to_marketing_email(email)
|
||||
def post_get_or_create_user(self, user, claims, is_new_user):
|
||||
"""
|
||||
Post-processing after user creation or retrieval.
|
||||
|
||||
elif not user:
|
||||
return None
|
||||
Args:
|
||||
user (User): The user instance.
|
||||
claims (dict): The claims dictionary.
|
||||
is_new_user (bool): Indicates if the user was newly created.
|
||||
|
||||
if not user.is_active:
|
||||
raise SuspiciousOperation(_("User account is disabled"))
|
||||
Returns:
|
||||
- None
|
||||
|
||||
self.update_user_if_needed(user, claims)
|
||||
|
||||
return user
|
||||
"""
|
||||
email = claims["email"]
|
||||
if is_new_user and email and settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
|
||||
self.signup_to_marketing_email(email)
|
||||
|
||||
@staticmethod
|
||||
def signup_to_marketing_email(email):
|
||||
@@ -116,7 +70,9 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
|
||||
Note: For a more robust solution, consider using Async task processing (Celery/Django-Q)
|
||||
"""
|
||||
try:
|
||||
with contextlib.suppress(
|
||||
ContactCreationError, ImproperlyConfigured, ImportError
|
||||
):
|
||||
marketing_service = get_marketing_service()
|
||||
contact_data = ContactData(
|
||||
email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
|
||||
@@ -124,8 +80,6 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
marketing_service.create_contact(
|
||||
contact_data, timeout=settings.BREVO_API_TIMEOUT
|
||||
)
|
||||
except (ContactCreationError, ImproperlyConfigured, ImportError):
|
||||
pass
|
||||
|
||||
def get_existing_user(self, sub, email):
|
||||
"""Fetch existing user by sub or email."""
|
||||
@@ -139,35 +93,6 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
pass
|
||||
except User.MultipleObjectsReturned as e:
|
||||
raise SuspiciousOperation(
|
||||
_("Multiple user accounts share a common email.")
|
||||
"Multiple user accounts share a common email."
|
||||
) from e
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def compute_full_name(user_info):
|
||||
"""Compute user's full name based on OIDC fields in settings."""
|
||||
full_name = " ".join(
|
||||
filter(
|
||||
None,
|
||||
(
|
||||
user_info.get(field)
|
||||
for field in settings.OIDC_USERINFO_FULLNAME_FIELDS
|
||||
),
|
||||
)
|
||||
)
|
||||
return full_name or None
|
||||
|
||||
@staticmethod
|
||||
def update_user_if_needed(user, claims):
|
||||
"""Update user claims if they have changed."""
|
||||
user_fields = vars(user.__class__) # Get available model fields
|
||||
updated_claims = {
|
||||
key: value
|
||||
for key, value in claims.items()
|
||||
if value and key in user_fields and value != getattr(user, key)
|
||||
}
|
||||
|
||||
if not updated_claims:
|
||||
return
|
||||
|
||||
User.objects.filter(sub=user.sub).update(**updated_claims)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Authentication using LiveKit token for the Meet core app."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
|
||||
from livekit.api import TokenVerifier
|
||||
from rest_framework import authentication, exceptions
|
||||
|
||||
UserModel = get_user_model()
|
||||
|
||||
|
||||
class LiveKitTokenAuthentication(authentication.BaseAuthentication):
|
||||
"""Authenticate using LiveKit token and load the associated Django user."""
|
||||
|
||||
def authenticate(self, request):
|
||||
token = request.data.get("token")
|
||||
if not token:
|
||||
return None # No authentication attempted
|
||||
|
||||
try:
|
||||
verifier = TokenVerifier(
|
||||
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
||||
api_secret=settings.LIVEKIT_CONFIGURATION["api_secret"],
|
||||
)
|
||||
claims = verifier.verify(token)
|
||||
|
||||
user_id = claims.identity
|
||||
if not user_id:
|
||||
raise exceptions.AuthenticationFailed("Token missing user identity")
|
||||
|
||||
try:
|
||||
user = UserModel.objects.get(id=user_id)
|
||||
except UserModel.DoesNotExist:
|
||||
user = AnonymousUser()
|
||||
|
||||
return (user, claims)
|
||||
|
||||
except Exception as e:
|
||||
raise exceptions.AuthenticationFailed(
|
||||
f"Invalid LiveKit token: {str(e)}"
|
||||
) from e
|
||||
@@ -1,18 +0,0 @@
|
||||
"""Authentication URLs for the People core app."""
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from mozilla_django_oidc.urls import urlpatterns as mozzila_oidc_urls
|
||||
|
||||
from .views import OIDCLogoutCallbackView, OIDCLogoutView
|
||||
|
||||
urlpatterns = [
|
||||
# Override the default 'logout/' path from Mozilla Django OIDC with our custom view.
|
||||
path("logout/", OIDCLogoutView.as_view(), name="oidc_logout_custom"),
|
||||
path(
|
||||
"logout-callback/",
|
||||
OIDCLogoutCallbackView.as_view(),
|
||||
name="oidc_logout_callback",
|
||||
),
|
||||
*mozzila_oidc_urls,
|
||||
]
|
||||
@@ -1,181 +0,0 @@
|
||||
"""Authentication Views for the People core app."""
|
||||
|
||||
import copy
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.contrib import auth
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
from django.utils import crypto
|
||||
|
||||
from mozilla_django_oidc.utils import (
|
||||
absolutify,
|
||||
)
|
||||
from mozilla_django_oidc.views import (
|
||||
OIDCAuthenticationCallbackView as MozillaOIDCAuthenticationCallbackView,
|
||||
)
|
||||
from mozilla_django_oidc.views import (
|
||||
OIDCAuthenticationRequestView as MozillaOIDCAuthenticationRequestView,
|
||||
)
|
||||
from mozilla_django_oidc.views import (
|
||||
OIDCLogoutView as MozillaOIDCOIDCLogoutView,
|
||||
)
|
||||
|
||||
|
||||
class OIDCLogoutView(MozillaOIDCOIDCLogoutView):
|
||||
"""Custom logout view for handling OpenID Connect (OIDC) logout flow.
|
||||
|
||||
Adds support for handling logout callbacks from the identity provider (OP)
|
||||
by initiating the logout flow if the user has an active session.
|
||||
|
||||
The Django session is retained during the logout process to persist the 'state' OIDC parameter.
|
||||
This parameter is crucial for maintaining the integrity of the logout flow between this call
|
||||
and the subsequent callback.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def persist_state(request, state):
|
||||
"""Persist the given 'state' parameter in the session's 'oidc_states' dictionary
|
||||
|
||||
This method is used to store the OIDC state parameter in the session, according to the
|
||||
structure expected by Mozilla Django OIDC's 'add_state_and_verifier_and_nonce_to_session'
|
||||
utility function.
|
||||
"""
|
||||
|
||||
if "oidc_states" not in request.session or not isinstance(
|
||||
request.session["oidc_states"], dict
|
||||
):
|
||||
request.session["oidc_states"] = {}
|
||||
|
||||
request.session["oidc_states"][state] = {}
|
||||
request.session.save()
|
||||
|
||||
def construct_oidc_logout_url(self, request):
|
||||
"""Create the redirect URL for interfacing with the OIDC provider.
|
||||
|
||||
Retrieves the necessary parameters from the session and constructs the URL
|
||||
required to initiate logout with the OpenID Connect provider.
|
||||
|
||||
If no ID token is found in the session, the logout flow will not be initiated,
|
||||
and the method will return the default redirect URL.
|
||||
|
||||
The 'state' parameter is generated randomly and persisted in the session to ensure
|
||||
its integrity during the subsequent callback.
|
||||
"""
|
||||
|
||||
oidc_logout_endpoint = self.get_settings("OIDC_OP_LOGOUT_ENDPOINT")
|
||||
|
||||
if not oidc_logout_endpoint:
|
||||
return self.redirect_url
|
||||
|
||||
reverse_url = reverse("oidc_logout_callback")
|
||||
id_token = request.session.get("oidc_id_token", None)
|
||||
|
||||
if not id_token:
|
||||
return self.redirect_url
|
||||
|
||||
query = {
|
||||
"id_token_hint": id_token,
|
||||
"state": crypto.get_random_string(self.get_settings("OIDC_STATE_SIZE", 32)),
|
||||
"post_logout_redirect_uri": absolutify(request, reverse_url),
|
||||
}
|
||||
|
||||
self.persist_state(request, query["state"])
|
||||
|
||||
return f"{oidc_logout_endpoint}?{urlencode(query)}"
|
||||
|
||||
def post(self, request):
|
||||
"""Handle user logout.
|
||||
|
||||
If the user is not authenticated, redirects to the default logout URL.
|
||||
Otherwise, constructs the OIDC logout URL and redirects the user to start
|
||||
the logout process.
|
||||
|
||||
If the user is redirected to the default logout URL, ensure her Django session
|
||||
is terminated.
|
||||
"""
|
||||
|
||||
logout_url = self.redirect_url
|
||||
|
||||
if request.user.is_authenticated:
|
||||
logout_url = self.construct_oidc_logout_url(request)
|
||||
|
||||
# If the user is not redirected to the OIDC provider, ensure logout
|
||||
if logout_url == self.redirect_url:
|
||||
auth.logout(request)
|
||||
|
||||
return HttpResponseRedirect(logout_url)
|
||||
|
||||
|
||||
class OIDCLogoutCallbackView(MozillaOIDCOIDCLogoutView):
|
||||
"""Custom view for handling the logout callback from the OpenID Connect (OIDC) provider.
|
||||
|
||||
Handles the callback after logout from the identity provider (OP).
|
||||
Verifies the state parameter and performs necessary logout actions.
|
||||
|
||||
The Django session is maintained during the logout process to ensure the integrity
|
||||
of the logout flow initiated in the previous step.
|
||||
"""
|
||||
|
||||
http_method_names = ["get"]
|
||||
|
||||
def get(self, request):
|
||||
"""Handle the logout callback.
|
||||
|
||||
If the user is not authenticated, redirects to the default logout URL.
|
||||
Otherwise, verifies the state parameter and performs necessary logout actions.
|
||||
"""
|
||||
|
||||
if not request.user.is_authenticated:
|
||||
return HttpResponseRedirect(self.redirect_url)
|
||||
|
||||
state = request.GET.get("state")
|
||||
|
||||
if state not in request.session.get("oidc_states", {}):
|
||||
msg = "OIDC callback state not found in session `oidc_states`!"
|
||||
raise SuspiciousOperation(msg)
|
||||
|
||||
del request.session["oidc_states"][state]
|
||||
request.session.save()
|
||||
|
||||
auth.logout(request)
|
||||
|
||||
return HttpResponseRedirect(self.redirect_url)
|
||||
|
||||
|
||||
class OIDCAuthenticationCallbackView(MozillaOIDCAuthenticationCallbackView):
|
||||
"""Custom callback view for handling the silent login flow."""
|
||||
|
||||
@property
|
||||
def failure_url(self):
|
||||
"""Override the failure URL property to handle silent login flow
|
||||
|
||||
A silent login failure (e.g., no active user session) should not be
|
||||
considered as an authentication failure.
|
||||
"""
|
||||
if self.request.session.get("silent", None):
|
||||
del self.request.session["silent"]
|
||||
self.request.session.save()
|
||||
return self.success_url
|
||||
return super().failure_url
|
||||
|
||||
|
||||
class OIDCAuthenticationRequestView(MozillaOIDCAuthenticationRequestView):
|
||||
"""Custom authentication view for handling the silent login flow."""
|
||||
|
||||
def get_extra_params(self, request):
|
||||
"""Handle 'prompt' extra parameter for the silent login flow
|
||||
|
||||
This extra parameter is necessary to distinguish between a standard
|
||||
authentication flow and the silent login flow.
|
||||
"""
|
||||
extra_params = self.get_settings("OIDC_AUTH_REQUEST_EXTRA_PARAMS", None)
|
||||
if extra_params is None:
|
||||
extra_params = {}
|
||||
if request.GET.get("silent") == "true":
|
||||
extra_params = copy.deepcopy(extra_params)
|
||||
extra_params.update({"prompt": "none"})
|
||||
request.session["silent"] = True
|
||||
request.session.save()
|
||||
return extra_params
|
||||
@@ -2,9 +2,21 @@
|
||||
Core application enums declaration
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from django.conf import global_settings, settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
UUID_REGEX = (
|
||||
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
|
||||
)
|
||||
FILE_EXT_REGEX = r"[a-zA-Z0-9]{1,10}"
|
||||
|
||||
# pylint: disable=line-too-long
|
||||
RECORDING_STORAGE_URL_PATTERN = re.compile(
|
||||
f"/media/{settings.RECORDING_OUTPUT_FOLDER}/(?P<recording_id>{UUID_REGEX:s}).(?P<extension>{FILE_EXT_REGEX:s})"
|
||||
)
|
||||
|
||||
# Django sets `LANGUAGES` by default with all supported languages. We can use it for
|
||||
# the choice of languages which should not be limited to the few languages active in
|
||||
# the app.
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# ruff: noqa: S311
|
||||
"""
|
||||
Core application factories
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.8 on 2025-04-22 14:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0012_alter_room_access_level'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'), ('nl-nl', 'Dutch'))", default='en-us', help_text='The language in which the user wants to see the interface.', max_length=10, verbose_name='language'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,45 @@
|
||||
# Generated by Django 5.1.9 on 2025-05-13 08:22
|
||||
|
||||
import secrets
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def generate_pin_for_rooms(apps, schema_editor):
|
||||
"""Generate unique 10-digit PIN codes for existing rooms.
|
||||
The PIN code is required for SIP telephony features.
|
||||
"""
|
||||
Room = apps.get_model('core', 'Room')
|
||||
rooms_without_pin_code = Room.objects.filter(pin_code__isnull=True)
|
||||
existing_pins = set(Room.objects.values_list('pin_code', flat=True))
|
||||
|
||||
length = 10
|
||||
|
||||
def generate_pin_code():
|
||||
while True:
|
||||
pin_code = str(secrets.randbelow(10 ** length)).zfill(length)
|
||||
if pin_code not in existing_pins:
|
||||
return pin_code
|
||||
|
||||
for room in rooms_without_pin_code:
|
||||
room.pin_code = generate_pin_code()
|
||||
room.save()
|
||||
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0013_alter_user_language'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='room',
|
||||
name='pin_code',
|
||||
field=models.CharField(blank=True, help_text='Unique n-digit code that identifies this room in telephony mode.', max_length=None, null=True, unique=True, verbose_name='Room PIN code'),
|
||||
),
|
||||
migrations.RunPython(
|
||||
generate_pin_for_rooms,
|
||||
reverse_code=migrations.RunPython.noop
|
||||
),
|
||||
]
|
||||
@@ -2,9 +2,11 @@
|
||||
Declare and configure the models for the Meet core application
|
||||
"""
|
||||
|
||||
import secrets
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from logging import getLogger
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import models as auth_models
|
||||
@@ -12,12 +14,14 @@ from django.contrib.auth.base_user import AbstractBaseUser
|
||||
from django.core import mail, validators
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.db import models
|
||||
from django.utils.functional import lazy
|
||||
from django.utils import timezone
|
||||
from django.utils.text import capfirst, slugify
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from timezone_field import TimeZoneField
|
||||
|
||||
from .recording.enums import FileExtension
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
@@ -31,7 +35,7 @@ class RoleChoices(models.TextChoices):
|
||||
@classmethod
|
||||
def check_administrator_role(cls, role):
|
||||
"""Check if a role is administrator."""
|
||||
return role in [cls.ADMIN, cls.OWNER]
|
||||
return role == cls.ADMIN
|
||||
|
||||
@classmethod
|
||||
def check_owner_role(cls, role):
|
||||
@@ -160,7 +164,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
)
|
||||
language = models.CharField(
|
||||
max_length=10,
|
||||
choices=lazy(lambda: settings.LANGUAGES, tuple)(),
|
||||
choices=settings.LANGUAGES,
|
||||
default=settings.LANGUAGE_CODE,
|
||||
verbose_name=_("language"),
|
||||
help_text=_("The language in which the user wants to see the interface."),
|
||||
@@ -284,13 +288,13 @@ class Resource(BaseModel):
|
||||
role = RoleChoices.MEMBER
|
||||
return role
|
||||
|
||||
def is_administrator(self, user):
|
||||
def is_administrator_or_owner(self, user):
|
||||
"""
|
||||
Check if a user is administrator of the resource.
|
||||
|
||||
Users carrying the "owner" role are considered as administrators a fortiori.
|
||||
"""
|
||||
return RoleChoices.check_administrator_role(self.get_role(user))
|
||||
Check if a user is administrator or owner of the resource."""
|
||||
role = self.get_role(user)
|
||||
return RoleChoices.check_administrator_role(
|
||||
role
|
||||
) or RoleChoices.check_owner_role(role)
|
||||
|
||||
def is_owner(self, user):
|
||||
"""Check if a user is owner of the resource."""
|
||||
@@ -379,6 +383,14 @@ class Room(Resource):
|
||||
verbose_name=_("Visio room configuration"),
|
||||
help_text=_("Values for Visio parameters to configure the room."),
|
||||
)
|
||||
pin_code = models.CharField(
|
||||
max_length=None,
|
||||
unique=True,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_("Room PIN code"),
|
||||
help_text=_("Unique n-digit code that identifies this room in telephony mode."),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "meet_room"
|
||||
@@ -389,6 +401,14 @@ class Room(Resource):
|
||||
def __str__(self):
|
||||
return capfirst(self.name)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Generate a unique n-digit pin code for new rooms."""
|
||||
if settings.ROOM_TELEPHONY_ENABLED and not self.pk and not self.pin_code:
|
||||
self.pin_code = self.generate_unique_pin_code(
|
||||
length=settings.ROOM_TELEPHONY_PIN_LENGTH
|
||||
)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def clean_fields(self, exclude=None):
|
||||
"""
|
||||
Automatically generate the slug from the name and make sure it does not look like a UUID.
|
||||
@@ -403,6 +423,7 @@ class Room(Resource):
|
||||
pass
|
||||
else:
|
||||
raise ValidationError({"name": f'Room name "{self.name:s}" is reserved.'})
|
||||
|
||||
super().clean_fields(exclude=exclude)
|
||||
|
||||
@property
|
||||
@@ -410,6 +431,31 @@ class Room(Resource):
|
||||
"""Check if a room is public"""
|
||||
return self.access_level == RoomAccessLevel.PUBLIC
|
||||
|
||||
@staticmethod
|
||||
def generate_unique_pin_code(length):
|
||||
"""Generate a unique n-digit PIN code"""
|
||||
|
||||
if length < 4:
|
||||
raise ValueError(
|
||||
"PIN code length must be at least 4 digits for minimal security"
|
||||
)
|
||||
|
||||
max_value = 10**length
|
||||
|
||||
for _ in range(settings.ROOM_TELEPHONY_PIN_MAX_RETRIES):
|
||||
pin_code = str(secrets.randbelow(max_value)).zfill(length)
|
||||
if not Room.objects.filter(pin_code=pin_code).exists():
|
||||
return pin_code
|
||||
|
||||
# Log a warning as a temporary measure until backend observability is implemented.
|
||||
logger.warning(
|
||||
"Failed to generate unique PIN code of length %s after %s attempts",
|
||||
length,
|
||||
settings.ROOM_TELEPHONY_PIN_MAX_RETRIES,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class BaseAccessManager(models.Manager):
|
||||
"""Base manager for handling resource access control."""
|
||||
@@ -576,6 +622,57 @@ class Recording(BaseModel):
|
||||
RecordingStatusChoices.STOPPED,
|
||||
}
|
||||
|
||||
@property
|
||||
def is_saved(self) -> bool:
|
||||
"""Check if the recording is in a saved state."""
|
||||
return self.status in {
|
||||
RecordingStatusChoices.NOTIFICATION_SUCCEEDED,
|
||||
RecordingStatusChoices.SAVED,
|
||||
}
|
||||
|
||||
@property
|
||||
def extension(self):
|
||||
"""Get recording extension based on its mode."""
|
||||
extensions = {
|
||||
RecordingModeChoices.TRANSCRIPT: FileExtension.OGG.value,
|
||||
RecordingModeChoices.SCREEN_RECORDING: FileExtension.MP4.value,
|
||||
}
|
||||
return extensions.get(self.mode, FileExtension.MP4.value)
|
||||
|
||||
@property
|
||||
def key(self):
|
||||
"""Generate the file key based on recording mode."""
|
||||
|
||||
return f"{settings.RECORDING_OUTPUT_FOLDER}/{self.id}.{self.extension}"
|
||||
|
||||
@property
|
||||
def expired_at(self) -> Optional[datetime]:
|
||||
"""
|
||||
Calculate the expiration date based on created_at and RECORDING_EXPIRATION_DAYS.
|
||||
Returns None if no expiration is configured.
|
||||
|
||||
Note: This is a naive and imperfect implementation since recordings are actually
|
||||
saved to the bucket after created_at timestamp is set. The actual expiration
|
||||
will be determined by the bucket lifecycle policy which operates on the object's
|
||||
timestamp in the storage system, not this value.
|
||||
"""
|
||||
|
||||
if not settings.RECORDING_EXPIRATION_DAYS:
|
||||
return None
|
||||
|
||||
return self.created_at + timedelta(days=settings.RECORDING_EXPIRATION_DAYS)
|
||||
|
||||
@property
|
||||
def is_expired(self) -> bool:
|
||||
"""
|
||||
Determine if the recording has expired by comparing expired_at with current UTC time.
|
||||
Returns False if no expiration is configured or if expiration date is in the future.
|
||||
"""
|
||||
if not self.expired_at:
|
||||
return False
|
||||
|
||||
return self.expired_at < timezone.now()
|
||||
|
||||
|
||||
class RecordingAccess(BaseAccess):
|
||||
"""Relation model to give access to a recording for a user or a team with a role."""
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Enums related to recordings."""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class FileExtension(Enum):
|
||||
"""Enum for file extensions used in recordings."""
|
||||
|
||||
OGG = "ogg"
|
||||
MP4 = "mp4"
|
||||
@@ -4,7 +4,6 @@ import logging
|
||||
import secrets
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from rest_framework.authentication import BaseAuthentication
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
@@ -55,7 +54,7 @@ class StorageEventAuthentication(BaseAuthentication):
|
||||
if not required_token:
|
||||
if settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
|
||||
raise AuthenticationFailed(
|
||||
_("Authentication is enabled but token is not configured.")
|
||||
"Authentication is enabled but token is not configured."
|
||||
)
|
||||
|
||||
return MachineUser(), None
|
||||
@@ -67,7 +66,7 @@ class StorageEventAuthentication(BaseAuthentication):
|
||||
"Authentication failed: Missing Authorization header (ip: %s)",
|
||||
request.META.get("REMOTE_ADDR"),
|
||||
)
|
||||
raise AuthenticationFailed(_("Authorization header is required"))
|
||||
raise AuthenticationFailed("Authorization header is required")
|
||||
|
||||
auth_parts = auth_header.split(" ")
|
||||
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
|
||||
@@ -75,7 +74,7 @@ class StorageEventAuthentication(BaseAuthentication):
|
||||
"Authentication failed: Invalid authorization header (ip: %s)",
|
||||
request.META.get("REMOTE_ADDR"),
|
||||
)
|
||||
raise AuthenticationFailed(_("Invalid authorization header."))
|
||||
raise AuthenticationFailed("Invalid authorization header.")
|
||||
|
||||
token = auth_parts[1]
|
||||
|
||||
@@ -85,7 +84,7 @@ class StorageEventAuthentication(BaseAuthentication):
|
||||
"Authentication failed: Invalid token (ip: %s)",
|
||||
request.META.get("REMOTE_ADDR"),
|
||||
)
|
||||
raise AuthenticationFailed(_("Invalid token"))
|
||||
raise AuthenticationFailed("Invalid token")
|
||||
|
||||
return MachineUser(), token
|
||||
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
"""Service to notify external services when a new recording is ready."""
|
||||
|
||||
import logging
|
||||
import smtplib
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail import send_mail
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import get_language, override
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import requests
|
||||
|
||||
@@ -21,10 +26,7 @@ class NotificationService:
|
||||
return self._notify_summary_service(recording)
|
||||
|
||||
if recording.mode == models.RecordingModeChoices.SCREEN_RECORDING:
|
||||
logger.warning(
|
||||
"Screen recording mode not implemented for recording %s", recording.id
|
||||
)
|
||||
return False
|
||||
return self._notify_user_by_email(recording)
|
||||
|
||||
logger.error(
|
||||
"Unknown recording mode %s for recording %s",
|
||||
@@ -33,6 +35,79 @@ class NotificationService:
|
||||
)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _notify_user_by_email(recording) -> bool:
|
||||
"""
|
||||
Send an email notification to recording owners when their recording is ready.
|
||||
|
||||
The email includes a direct link that redirects owners to a dedicated download
|
||||
page in the frontend where they can access their specific recording.
|
||||
"""
|
||||
|
||||
owner_accesses = (
|
||||
models.RecordingAccess.objects.select_related("user")
|
||||
.filter(
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording_id=recording.id,
|
||||
)
|
||||
.order_by("created_at")
|
||||
)
|
||||
|
||||
if not owner_accesses:
|
||||
logger.error("No owner found for recording %s", recording.id)
|
||||
return False
|
||||
|
||||
context = {
|
||||
"brandname": settings.EMAIL_BRAND_NAME,
|
||||
"support_email": settings.EMAIL_SUPPORT_EMAIL,
|
||||
"logo_img": settings.EMAIL_LOGO_IMG,
|
||||
"domain": settings.EMAIL_DOMAIN,
|
||||
"room_name": recording.room.name,
|
||||
"recording_expiration_days": settings.RECORDING_EXPIRATION_DAYS,
|
||||
"link": f"{settings.SCREEN_RECORDING_BASE_URL}/{recording.id}",
|
||||
}
|
||||
|
||||
has_failures = False
|
||||
|
||||
# We process emails individually rather than in batch because:
|
||||
# 1. Each email requires personalization (timezone, language)
|
||||
# 2. The number of recipients per recording is typically small (not thousands)
|
||||
for access in owner_accesses:
|
||||
user = access.user
|
||||
language = user.language or get_language()
|
||||
with override(language):
|
||||
personalized_context = {
|
||||
"recording_date": recording.created_at.astimezone(
|
||||
user.timezone
|
||||
).strftime("%Y-%m-%d"),
|
||||
"recording_time": recording.created_at.astimezone(
|
||||
user.timezone
|
||||
).strftime("%H:%M"),
|
||||
**context,
|
||||
}
|
||||
msg_html = render_to_string(
|
||||
"mail/html/screen_recording.html", personalized_context
|
||||
)
|
||||
msg_plain = render_to_string(
|
||||
"mail/text/screen_recording.txt", personalized_context
|
||||
)
|
||||
subject = str(_("Your recording is ready")) # Force translation
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
subject.capitalize(),
|
||||
msg_plain,
|
||||
settings.EMAIL_FROM,
|
||||
[user.email],
|
||||
html_message=msg_html,
|
||||
fail_silently=False,
|
||||
)
|
||||
except smtplib.SMTPException as exception:
|
||||
logger.error("notification could not be sent: %s", exception)
|
||||
has_failures = True
|
||||
|
||||
return not has_failures
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service(recording):
|
||||
"""Notify summary service about a new recording."""
|
||||
@@ -57,12 +132,17 @@ class NotificationService:
|
||||
logger.error("No owner found for recording %s", recording.id)
|
||||
return False
|
||||
|
||||
key = f"{settings.RECORDING_OUTPUT_FOLDER}/{recording.id}.ogg"
|
||||
|
||||
payload = {
|
||||
"filename": key,
|
||||
"filename": recording.key,
|
||||
"email": owner_access.user.email,
|
||||
"sub": owner_access.user.sub,
|
||||
"room": recording.room.name,
|
||||
"recording_date": recording.created_at.astimezone(
|
||||
owner_access.user.timezone
|
||||
).strftime("%Y-%m-%d"),
|
||||
"recording_time": recording.created_at.astimezone(
|
||||
owner_access.user.timezone
|
||||
).strftime("%H:%M"),
|
||||
}
|
||||
|
||||
headers = {
|
||||
|
||||
@@ -86,7 +86,7 @@ class MinioParser:
|
||||
|
||||
# pylint: disable=line-too-long
|
||||
self._filepath_regex = re.compile(
|
||||
r"(?P<url_encoded_folder_path>(?:[^%]+%2F)*)?(?P<recording_id>[0-9a-fA-F\-]{36})\.(?P<extension>[a-zA-Z0-9]+)"
|
||||
r"(?P<url_encoded_folder_path>(?:[^%]+%2F)+)?(?P<recording_id>[0-9a-fA-F\-]{36})\.(?P<extension>[a-zA-Z0-9]+)"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -123,7 +123,7 @@ class MinioParser:
|
||||
f"Invalid bucket: expected {self._bucket_name}, got {event_data.bucket_name}"
|
||||
)
|
||||
|
||||
if not event_data.filetype in self._allowed_filetypes:
|
||||
if event_data.filetype not in self._allowed_filetypes:
|
||||
raise InvalidFileTypeError(
|
||||
f"Invalid file type, expected {self._allowed_filetypes},"
|
||||
f"got '{event_data.filetype}'"
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Recording-related LiveKit Events Service"""
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
from core import models, utils
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class RecordingEventsError(Exception):
|
||||
"""Recording event handling fails."""
|
||||
|
||||
|
||||
class RecordingEventsService:
|
||||
"""Handles recording-related Livekit webhook events."""
|
||||
|
||||
@staticmethod
|
||||
def handle_limit_reached(recording):
|
||||
"""Stop recording and notify participants when limit is reached."""
|
||||
|
||||
recording.status = models.RecordingStatusChoices.STOPPED
|
||||
recording.save()
|
||||
|
||||
notification_mapping = {
|
||||
models.RecordingModeChoices.SCREEN_RECORDING: "screenRecordingLimitReached",
|
||||
models.RecordingModeChoices.TRANSCRIPT: "transcriptionLimitReached",
|
||||
}
|
||||
|
||||
notification_type = notification_mapping.get(recording.mode)
|
||||
if not notification_type:
|
||||
return
|
||||
|
||||
try:
|
||||
utils.notify_participants(
|
||||
room_name=str(recording.room.id),
|
||||
notification_data={"type": notification_type},
|
||||
)
|
||||
except utils.NotificationError as e:
|
||||
logger.exception(
|
||||
"Failed to notify participants about recording limit reached: "
|
||||
"room=%s, recording_id=%s, mode=%s",
|
||||
recording.room.id,
|
||||
recording.id,
|
||||
recording.mode,
|
||||
)
|
||||
raise RecordingEventsError(
|
||||
f"Failed to notify participants in room '{recording.room.id}' about "
|
||||
f"recording limit reached (recording_id={recording.id})"
|
||||
) from e
|
||||
@@ -17,7 +17,6 @@ class WorkerServiceConfig:
|
||||
|
||||
output_folder: str
|
||||
server_configurations: Dict[str, Any]
|
||||
verify_ssl: Optional[bool]
|
||||
bucket_args: Optional[dict]
|
||||
|
||||
@classmethod
|
||||
@@ -29,7 +28,6 @@ class WorkerServiceConfig:
|
||||
return cls(
|
||||
output_folder=settings.RECORDING_OUTPUT_FOLDER,
|
||||
server_configurations=settings.LIVEKIT_CONFIGURATION,
|
||||
verify_ssl=settings.RECORDING_VERIFY_SSL,
|
||||
bucket_args={
|
||||
"endpoint": settings.AWS_S3_ENDPOINT_URL,
|
||||
"access_key": settings.AWS_S3_ACCESS_KEY_ID,
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
# pylint: disable=no-member
|
||||
|
||||
import aiohttp
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit import api as livekit_api
|
||||
from livekit.api.egress_service import EgressService
|
||||
|
||||
from ... import utils
|
||||
from ..enums import FileExtension
|
||||
from .exceptions import WorkerConnectionError, WorkerResponseError
|
||||
from .factories import WorkerServiceConfig
|
||||
|
||||
@@ -28,21 +28,26 @@ class BaseEgressService:
|
||||
async def _handle_request(self, request, method_name: str):
|
||||
"""Handle making a request to the LiveKit API and returns the response."""
|
||||
|
||||
# Use HTTP connector for local development with Tilt,
|
||||
# where cluster communications are unsecure
|
||||
connector = aiohttp.TCPConnector(ssl=self._config.verify_ssl)
|
||||
lkapi = utils.create_livekit_client(self._config.server_configurations)
|
||||
|
||||
async with aiohttp.ClientSession(connector=connector) as session:
|
||||
client = EgressService(session, **self._config.server_configurations)
|
||||
method = getattr(client, method_name)
|
||||
try:
|
||||
response = await method(request)
|
||||
except livekit_api.TwirpError as e:
|
||||
raise WorkerConnectionError(
|
||||
f"LiveKit client connection error, {e.message}."
|
||||
) from e
|
||||
# ruff: noqa: SLF001
|
||||
# pylint: disable=protected-access
|
||||
method = getattr(lkapi._egress, method_name)
|
||||
|
||||
try:
|
||||
response = await method(request)
|
||||
return response
|
||||
except livekit_api.TwirpError as e:
|
||||
raise WorkerConnectionError(
|
||||
f"LiveKit client connection error, {e.message}."
|
||||
) from e
|
||||
except Exception as e:
|
||||
raise WorkerConnectionError(
|
||||
f"Unexpected error during LiveKit client connection: {str(e)}"
|
||||
) from e
|
||||
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
def stop(self, worker_id: str) -> str:
|
||||
"""Stop an ongoing egress worker.
|
||||
@@ -89,7 +94,9 @@ class VideoCompositeEgressService(BaseEgressService):
|
||||
|
||||
# Save room's recording as a mp4 video file.
|
||||
file_type = livekit_api.EncodedFileType.MP4
|
||||
filepath = self._get_filepath(filename=recording_id, extension="mp4")
|
||||
filepath = self._get_filepath(
|
||||
filename=recording_id, extension=FileExtension.MP4.value
|
||||
)
|
||||
|
||||
file_output = livekit_api.EncodedFileOutput(
|
||||
file_type=file_type,
|
||||
@@ -98,8 +105,7 @@ class VideoCompositeEgressService(BaseEgressService):
|
||||
)
|
||||
|
||||
request = livekit_api.RoomCompositeEgressRequest(
|
||||
room_name=room_name,
|
||||
file_outputs=[file_output],
|
||||
room_name=room_name, file_outputs=[file_output], layout="speaker-light"
|
||||
)
|
||||
|
||||
response = self._handle_request(request, "start_room_composite_egress")
|
||||
@@ -120,7 +126,9 @@ class AudioCompositeEgressService(BaseEgressService):
|
||||
|
||||
# Save room's recording as an ogg audio file.
|
||||
file_type = livekit_api.EncodedFileType.OGG
|
||||
filepath = self._get_filepath(filename=recording_id, extension="ogg")
|
||||
filepath = self._get_filepath(
|
||||
filename=recording_id, extension=FileExtension.OGG.value
|
||||
)
|
||||
|
||||
file_output = livekit_api.EncodedFileOutput(
|
||||
file_type=file_type,
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Invitation Service."""
|
||||
|
||||
import smtplib
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail import send_mail
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import get_language, override
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class InvitationError(Exception):
|
||||
"""Exception raised when invitation emails cannot be sent."""
|
||||
|
||||
status_code = 500
|
||||
|
||||
|
||||
class InvitationService:
|
||||
"""Service for invitations to users."""
|
||||
|
||||
@staticmethod
|
||||
def invite_to_room(room, sender, emails):
|
||||
"""Send invitation emails to join a room."""
|
||||
|
||||
language = get_language()
|
||||
|
||||
context = {
|
||||
"brandname": settings.EMAIL_BRAND_NAME,
|
||||
"logo_img": settings.EMAIL_LOGO_IMG,
|
||||
"domain": settings.EMAIL_DOMAIN,
|
||||
"room_url": f"{settings.EMAIL_APP_BASE_URL}/{room.slug}",
|
||||
"room_link": f"{settings.EMAIL_DOMAIN}/{room.slug}",
|
||||
"sender_email": sender.email,
|
||||
}
|
||||
|
||||
with override(language):
|
||||
msg_html = render_to_string("mail/html/invitation.html", context)
|
||||
msg_plain = render_to_string("mail/text/invitation.txt", context)
|
||||
subject = str(
|
||||
_(
|
||||
f"Video call in progress: {sender.email} is waiting for you to connect"
|
||||
)
|
||||
) # Force translation
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
subject,
|
||||
msg_plain,
|
||||
settings.EMAIL_FROM,
|
||||
emails,
|
||||
html_message=msg_html,
|
||||
fail_silently=False,
|
||||
)
|
||||
except smtplib.SMTPException as e:
|
||||
logger.error("invitation to %s was not sent: %s", emails, e)
|
||||
raise InvitationError("Could not send invitation") from e
|
||||
@@ -1,13 +1,25 @@
|
||||
"""LiveKit Events Service"""
|
||||
|
||||
# pylint: disable=no-member
|
||||
|
||||
import uuid
|
||||
from enum import Enum
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from livekit import api
|
||||
|
||||
from core import models
|
||||
from core.recording.services.recording_events import (
|
||||
RecordingEventsError,
|
||||
RecordingEventsService,
|
||||
)
|
||||
|
||||
from .lobby import LobbyService
|
||||
from .telephony import TelephonyException, TelephonyService
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class LiveKitWebhookError(Exception):
|
||||
@@ -77,6 +89,8 @@ class LiveKitEventsService:
|
||||
)
|
||||
self.webhook_receiver = api.WebhookReceiver(token_verifier)
|
||||
self.lobby_service = LobbyService()
|
||||
self.telephony_service = TelephonyService()
|
||||
self.recording_events = RecordingEventsService()
|
||||
|
||||
def receive(self, request):
|
||||
"""Process webhook and route to appropriate handler."""
|
||||
@@ -108,10 +122,77 @@ class LiveKitEventsService:
|
||||
# pylint: disable=not-callable
|
||||
handler(data)
|
||||
|
||||
def _handle_room_finished(self, data):
|
||||
"""Handle 'room_finished' event."""
|
||||
def _handle_egress_ended(self, data):
|
||||
"""Handle 'egress_ended' event."""
|
||||
|
||||
try:
|
||||
recording = models.Recording.objects.get(
|
||||
worker_id=data.egress_info.egress_id
|
||||
)
|
||||
except models.Recording.DoesNotExist as err:
|
||||
raise ActionFailedError(
|
||||
f"Recording with worker ID {data.egress_info.egress_id} does not exist"
|
||||
) from err
|
||||
|
||||
if (
|
||||
data.egress_info.status == api.EgressStatus.EGRESS_LIMIT_REACHED
|
||||
and recording.status == models.RecordingStatusChoices.ACTIVE
|
||||
):
|
||||
try:
|
||||
self.recording_events.handle_limit_reached(recording)
|
||||
except RecordingEventsError as e:
|
||||
raise ActionFailedError(
|
||||
f"Failed to process limit reached event for recording {recording}"
|
||||
) from e
|
||||
|
||||
def _handle_room_started(self, data):
|
||||
"""Handle 'room_started' event."""
|
||||
|
||||
try:
|
||||
room_id = uuid.UUID(data.room.name)
|
||||
except ValueError as e:
|
||||
logger.warning(
|
||||
"Ignoring room event: room name '%s' is not a valid UUID format.",
|
||||
data.room.name,
|
||||
)
|
||||
raise ActionFailedError("Failed to process room started event") from e
|
||||
|
||||
try:
|
||||
room = models.Room.objects.get(id=room_id)
|
||||
except models.Room.DoesNotExist as err:
|
||||
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
|
||||
|
||||
if settings.ROOM_TELEPHONY_ENABLED:
|
||||
try:
|
||||
self.telephony_service.create_dispatch_rule(room)
|
||||
except TelephonyException as e:
|
||||
raise ActionFailedError(
|
||||
f"Failed to create telephony dispatch rule for room {room_id}"
|
||||
) from e
|
||||
|
||||
def _handle_room_finished(self, data):
|
||||
"""Handle 'room_finished' event."""
|
||||
|
||||
try:
|
||||
room_id = uuid.UUID(data.room.name)
|
||||
except ValueError as e:
|
||||
logger.warning(
|
||||
"Ignoring room event: room name '%s' is not a valid UUID format.",
|
||||
data.room.name,
|
||||
)
|
||||
raise ActionFailedError("Failed to process room finished event") from e
|
||||
|
||||
if settings.ROOM_TELEPHONY_ENABLED:
|
||||
try:
|
||||
self.telephony_service.delete_dispatch_rule(room_id)
|
||||
except TelephonyException as e:
|
||||
raise ActionFailedError(
|
||||
f"Failed to delete telephony dispatch rule for room {room_id}"
|
||||
) from e
|
||||
|
||||
try:
|
||||
self.lobby_service.clear_room_cache(room_id)
|
||||
except Exception as e:
|
||||
raise ActionFailedError("Failed to process room finished event") from e
|
||||
raise ActionFailedError(
|
||||
f"Failed to clear room cache for room {room_id}"
|
||||
) from e
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Lobby Service"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
@@ -11,14 +10,6 @@ from uuid import UUID
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit.api import ( # pylint: disable=E0611
|
||||
ListRoomsRequest,
|
||||
LiveKitAPI,
|
||||
SendDataRequest,
|
||||
TwirpError,
|
||||
)
|
||||
|
||||
from core import models, utils
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -47,10 +38,6 @@ class LobbyParticipantNotFound(LobbyError):
|
||||
"""Raised when participant is not found."""
|
||||
|
||||
|
||||
class LobbyNotificationError(LobbyError):
|
||||
"""Raised when LiveKit notification fails."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class LobbyParticipant:
|
||||
"""Participant in a lobby system."""
|
||||
@@ -102,7 +89,7 @@ class LobbyService:
|
||||
@staticmethod
|
||||
def _get_or_create_participant_id(request) -> str:
|
||||
"""Extract unique participant identifier from the request."""
|
||||
return request.COOKIES.get(settings.LOBBY_COOKIE_NAME, uuid.uuid4().hex)
|
||||
return request.COOKIES.get(settings.LOBBY_COOKIE_NAME, str(uuid.uuid4()))
|
||||
|
||||
@staticmethod
|
||||
def prepare_response(response, participant_id):
|
||||
@@ -156,6 +143,8 @@ class LobbyService:
|
||||
participant_id = self._get_or_create_participant_id(request)
|
||||
participant = self._get_participant(room.id, participant_id)
|
||||
|
||||
room_id = str(room.id)
|
||||
|
||||
if self.can_bypass_lobby(room=room, user=request.user):
|
||||
if participant is None:
|
||||
participant = LobbyParticipant(
|
||||
@@ -168,10 +157,13 @@ class LobbyService:
|
||||
participant.status = LobbyParticipantStatus.ACCEPTED
|
||||
|
||||
livekit_config = utils.generate_livekit_config(
|
||||
room_id=str(room.id),
|
||||
room_id=room_id,
|
||||
user=request.user,
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id=participant_id,
|
||||
)
|
||||
return participant, livekit_config
|
||||
|
||||
@@ -186,10 +178,13 @@ class LobbyService:
|
||||
elif participant.status == LobbyParticipantStatus.ACCEPTED:
|
||||
# wrongly named, contains access token to join a room
|
||||
livekit_config = utils.generate_livekit_config(
|
||||
room_id=str(room.id),
|
||||
room_id=room_id,
|
||||
user=request.user,
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id=participant_id,
|
||||
)
|
||||
|
||||
return participant, livekit_config
|
||||
@@ -212,9 +207,6 @@ class LobbyService:
|
||||
|
||||
Create a new participant entry in waiting status and notify room
|
||||
participants of the new entry request.
|
||||
|
||||
Raises:
|
||||
LobbyNotificationError: If room notification fails
|
||||
"""
|
||||
|
||||
color = utils.generate_color(participant_id)
|
||||
@@ -227,10 +219,15 @@ class LobbyService:
|
||||
)
|
||||
|
||||
try:
|
||||
self.notify_participants(room_id=room_id)
|
||||
except LobbyNotificationError:
|
||||
utils.notify_participants(
|
||||
room_name=str(room_id),
|
||||
notification_data={
|
||||
"type": settings.LOBBY_NOTIFICATION_TYPE,
|
||||
},
|
||||
)
|
||||
except utils.NotificationError:
|
||||
# If room not created yet, there is no participants to notify
|
||||
pass
|
||||
logger.exception("Failed to notify room participants")
|
||||
|
||||
cache_key = self._get_cache_key(room_id, participant_id)
|
||||
cache.set(
|
||||
@@ -335,44 +332,6 @@ class LobbyService:
|
||||
participant.status = status
|
||||
cache.set(cache_key, participant.to_dict(), timeout=timeout)
|
||||
|
||||
@async_to_sync
|
||||
async def notify_participants(self, room_id: UUID):
|
||||
"""Notify room participants about a new waiting participant using LiveKit.
|
||||
|
||||
Raises:
|
||||
LobbyNotificationError: If notification fails to send
|
||||
"""
|
||||
|
||||
notification_data = {
|
||||
"type": settings.LOBBY_NOTIFICATION_TYPE,
|
||||
}
|
||||
|
||||
lkapi = LiveKitAPI(**settings.LIVEKIT_CONFIGURATION)
|
||||
|
||||
try:
|
||||
room_response = await lkapi.room.list_rooms(
|
||||
ListRoomsRequest(
|
||||
names=[str(room_id)],
|
||||
)
|
||||
)
|
||||
|
||||
# Check if the room exists
|
||||
if not room_response.rooms:
|
||||
return
|
||||
|
||||
await lkapi.room.send_data(
|
||||
SendDataRequest(
|
||||
room=str(room_id),
|
||||
data=json.dumps(notification_data).encode("utf-8"),
|
||||
kind="RELIABLE",
|
||||
)
|
||||
)
|
||||
except TwirpError as e:
|
||||
logger.exception("Failed to notify room participants")
|
||||
raise LobbyNotificationError("Failed to notify room participants") from e
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
def clear_room_cache(self, room_id: UUID) -> None:
|
||||
"""Clear all participant entries from the cache for a specific room."""
|
||||
|
||||
@@ -383,3 +342,9 @@ class LobbyService:
|
||||
return
|
||||
|
||||
cache.delete_many(keys)
|
||||
|
||||
def clear_participant_cache(self, room_id: UUID, participant_id: str) -> None:
|
||||
"""Clear a given participant entry from the cache for a specific room."""
|
||||
|
||||
cache_key = self._get_cache_key(room_id, participant_id)
|
||||
cache.delete(cache_key)
|
||||
|
||||
@@ -10,6 +10,7 @@ from django.core.exceptions import ImproperlyConfigured
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
import brevo_python
|
||||
import urllib3
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -120,8 +121,11 @@ class BrevoMarketingService:
|
||||
|
||||
try:
|
||||
response = contact_api.create_contact(contact, **api_configurations)
|
||||
except brevo_python.rest.ApiException as err:
|
||||
logger.exception("Failed to create contact in Brevo")
|
||||
except (
|
||||
brevo_python.rest.ApiException,
|
||||
urllib3.exceptions.ReadTimeoutError,
|
||||
) as err:
|
||||
logger.warning("Failed to create contact in Brevo", exc_info=True)
|
||||
raise ContactCreationError("Failed to create contact in Brevo") from err
|
||||
|
||||
return response
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Participants management service for LiveKit rooms."""
|
||||
|
||||
# pylint: disable=too-many-arguments,no-name-in-module,too-many-positional-arguments
|
||||
# ruff: noqa:PLR0913
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from logging import getLogger
|
||||
from typing import Dict, Optional
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit.api import (
|
||||
MuteRoomTrackRequest,
|
||||
RoomParticipantIdentity,
|
||||
TwirpError,
|
||||
UpdateParticipantRequest,
|
||||
)
|
||||
|
||||
from core import utils
|
||||
|
||||
from .lobby import LobbyService
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class ParticipantsManagementException(Exception):
|
||||
"""Exception raised when a participant management operations fail."""
|
||||
|
||||
|
||||
class ParticipantsManagement:
|
||||
"""Service for managing participants."""
|
||||
|
||||
@async_to_sync
|
||||
async def mute(self, room_name: str, identity: str, track_sid: str):
|
||||
"""Mute a specific audio or video track for a participant in a room."""
|
||||
|
||||
lkapi = utils.create_livekit_client()
|
||||
|
||||
try:
|
||||
await lkapi.room.mute_published_track(
|
||||
MuteRoomTrackRequest(
|
||||
room=room_name,
|
||||
identity=identity,
|
||||
track_sid=track_sid,
|
||||
muted=True,
|
||||
)
|
||||
)
|
||||
|
||||
except TwirpError as e:
|
||||
logger.exception(
|
||||
"Unexpected error muting participant %s for room %s",
|
||||
identity,
|
||||
room_name,
|
||||
)
|
||||
raise ParticipantsManagementException("Could not mute participant") from e
|
||||
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
@async_to_sync
|
||||
async def remove(self, room_name: str, identity: str):
|
||||
"""Remove a participant from a room and clear their lobby cache."""
|
||||
|
||||
try:
|
||||
LobbyService().clear_participant_cache(
|
||||
room_id=uuid.UUID(room_name), participant_id=identity
|
||||
)
|
||||
except (ValueError, TypeError) as exc:
|
||||
logger.warning(
|
||||
"participants_management.remove: room_name '%s' is not a UUID; "
|
||||
"skipping lobby cache clear",
|
||||
room_name,
|
||||
exc_info=exc,
|
||||
)
|
||||
|
||||
lkapi = utils.create_livekit_client()
|
||||
|
||||
try:
|
||||
await lkapi.room.remove_participant(
|
||||
RoomParticipantIdentity(room=room_name, identity=identity)
|
||||
)
|
||||
except TwirpError as e:
|
||||
logger.exception(
|
||||
"Unexpected error removing participant %s for room %s",
|
||||
identity,
|
||||
room_name,
|
||||
)
|
||||
raise ParticipantsManagementException("Could not remove participant") from e
|
||||
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
@async_to_sync
|
||||
async def update(
|
||||
self,
|
||||
room_name: str,
|
||||
identity: str,
|
||||
metadata: Optional[Dict] = None,
|
||||
attributes: Optional[Dict] = None,
|
||||
permission: Optional[Dict] = None,
|
||||
name: Optional[str] = None,
|
||||
):
|
||||
"""Update participant properties such as metadata, attributes, permissions, or name."""
|
||||
|
||||
lkapi = utils.create_livekit_client()
|
||||
|
||||
try:
|
||||
await lkapi.room.update_participant(
|
||||
UpdateParticipantRequest(
|
||||
room=room_name,
|
||||
identity=identity,
|
||||
metadata=json.dumps(metadata),
|
||||
permission=permission,
|
||||
attributes=attributes,
|
||||
name=name,
|
||||
)
|
||||
)
|
||||
|
||||
except TwirpError as e:
|
||||
logger.exception(
|
||||
"Unexpected error updating participant %s for room %s",
|
||||
identity,
|
||||
room_name,
|
||||
)
|
||||
raise ParticipantsManagementException("Could not update participant") from e
|
||||
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Service for managing subtitle agents in LiveKit rooms."""
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit.protocol.agent_dispatch import CreateAgentDispatchRequest
|
||||
|
||||
from core import utils
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class SubtitleException(Exception):
|
||||
"""Exception raised when subtitle operations fail."""
|
||||
|
||||
|
||||
class SubtitleService:
|
||||
"""Service for managing subtitle agents in LiveKit rooms."""
|
||||
|
||||
@async_to_sync
|
||||
async def start_subtitle(self, room):
|
||||
"""Start subtitle agent for the specified room."""
|
||||
|
||||
lkapi = utils.create_livekit_client()
|
||||
|
||||
try:
|
||||
# Transcriber agent prevents duplicate subtitle agents per room
|
||||
# No error is raised if agent already exists
|
||||
await lkapi.agent_dispatch.create_dispatch(
|
||||
CreateAgentDispatchRequest(
|
||||
agent_name=settings.ROOM_SUBTITLE_AGENT_NAME, room=str(room.id)
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Failed to create agent dispatch for room %s", room.id)
|
||||
raise SubtitleException("Failed to create subtitle agent") from e
|
||||
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
@async_to_sync
|
||||
async def stop_subtitle(self, room) -> None:
|
||||
"""Stop subtitle agent for the specified room."""
|
||||
|
||||
raise NotImplementedError("Subtitle agent stopping not yet implemented")
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Telephony service for managing SIP dispatch rules for room access."""
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit.api import TwirpError
|
||||
from livekit.protocol.sip import (
|
||||
CreateSIPDispatchRuleRequest,
|
||||
DeleteSIPDispatchRuleRequest,
|
||||
ListSIPDispatchRuleRequest,
|
||||
SIPDispatchRule,
|
||||
SIPDispatchRuleDirect,
|
||||
)
|
||||
|
||||
from core import utils
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class TelephonyException(Exception):
|
||||
"""Exception raised when telephony operations fail."""
|
||||
|
||||
|
||||
class TelephonyService:
|
||||
"""Service for managing participant access through the telephony system (SIP)."""
|
||||
|
||||
def _rule_name(self, room_id):
|
||||
"""Generate the rule name for a room based on its ID."""
|
||||
return f"SIP_{str(room_id)}"
|
||||
|
||||
@async_to_sync
|
||||
async def create_dispatch_rule(self, room):
|
||||
"""Create a SIP inbound dispatch rule for direct room routing.
|
||||
|
||||
Configures telephony to route incoming SIP calls directly to the specified room
|
||||
using the room's ID and PIN code for authentication.
|
||||
"""
|
||||
|
||||
direct_rule = SIPDispatchRule(
|
||||
dispatch_rule_direct=SIPDispatchRuleDirect(
|
||||
room_name=str(room.pk), pin=str(room.pin_code)
|
||||
)
|
||||
)
|
||||
|
||||
request = CreateSIPDispatchRuleRequest(
|
||||
rule=direct_rule, name=self._rule_name(room.pk)
|
||||
)
|
||||
|
||||
lkapi = utils.create_livekit_client()
|
||||
|
||||
try:
|
||||
await lkapi.sip.create_sip_dispatch_rule(create=request)
|
||||
except TwirpError as e:
|
||||
logger.exception(
|
||||
"Unexpected error creating dispatch rule for room %s", room.id
|
||||
)
|
||||
raise TelephonyException("Could not create dispatch rule") from e
|
||||
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
async def _list_dispatch_rules_ids(self, room_id):
|
||||
"""List SIP dispatch rule IDs for a specific room.
|
||||
|
||||
Fetches all existing SIP dispatch rules and filters them by room name
|
||||
since LiveKit API doesn't support server-side filtering by 'room_name'.
|
||||
This approach is acceptable for moderate scale but may need refactoring
|
||||
for high-volume scenarios.
|
||||
|
||||
Note:
|
||||
Feature request for server-side filtering: livekit/sip#405
|
||||
"""
|
||||
|
||||
lkapi = utils.create_livekit_client()
|
||||
|
||||
try:
|
||||
existing_rules = await lkapi.sip.list_sip_dispatch_rule(
|
||||
list=ListSIPDispatchRuleRequest()
|
||||
)
|
||||
except TwirpError as e:
|
||||
logger.exception("Failed to list dispatch rules for room %s", room_id)
|
||||
raise TelephonyException("Could not list dispatch rules") from e
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
if not existing_rules or not existing_rules.items:
|
||||
return []
|
||||
|
||||
rule_name = self._rule_name(room_id)
|
||||
|
||||
return [
|
||||
existing_rule.sip_dispatch_rule_id
|
||||
for existing_rule in existing_rules.items
|
||||
if existing_rule.name == rule_name
|
||||
]
|
||||
|
||||
@async_to_sync
|
||||
async def delete_dispatch_rule(self, room_id):
|
||||
"""Delete all SIP inbound dispatch rules associated with a specific room."""
|
||||
|
||||
rules_ids = await self._list_dispatch_rules_ids(room_id)
|
||||
|
||||
if not rules_ids:
|
||||
logger.info("No dispatch rules found for room %s", room_id)
|
||||
return False
|
||||
|
||||
if len(rules_ids) > 1:
|
||||
logger.error("Multiple dispatch rules found for room %s", room_id)
|
||||
|
||||
lkapi = utils.create_livekit_client()
|
||||
try:
|
||||
for rule_id in rules_ids:
|
||||
await lkapi.sip.delete_sip_dispatch_rule(
|
||||
delete=DeleteSIPDispatchRuleRequest(sip_dispatch_rule_id=rule_id)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
except TwirpError as e:
|
||||
logger.exception("Failed to delete dispatch rules for room %s", room_id)
|
||||
raise TelephonyException("Could not delete dispatch rules") from e
|
||||
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
@@ -58,7 +58,7 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
|
||||
|
||||
assert user.sub == "123"
|
||||
assert user.email is None
|
||||
assert user.password == "!"
|
||||
assert user.has_usable_password() is False
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
|
||||
assert user.email == email
|
||||
assert user.full_name is None
|
||||
assert user.short_name is None
|
||||
assert user.password == "!"
|
||||
assert user.has_usable_password() is False
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
@@ -110,7 +110,7 @@ def test_authentication_getter_new_user_with_names(monkeypatch, email):
|
||||
assert user.email == email
|
||||
assert user.full_name == "John Doe"
|
||||
assert user.short_name == "John"
|
||||
assert user.password == "!"
|
||||
assert user.has_usable_password() is False
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkey
|
||||
django_assert_num_queries(0),
|
||||
pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match="User info contained no recognizable user identification",
|
||||
match="Claims verification failed",
|
||||
),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
@@ -287,9 +287,7 @@ def test_finds_user_whitespace_email(django_assert_num_queries, settings):
|
||||
[
|
||||
"john.doe@example.com", # Fullwidth character in domain
|
||||
"john.doe@еxample.com", # Cyrillic 'е' in domain
|
||||
"JOHN.DOe@exam𝔭le.com", # Mixed Gothic '𝔭' in domain
|
||||
"john.doe@exаmple.com", # Cyrillic 'а' (a) in domain
|
||||
"john.doe@e𝓧𝓪𝓶𝓹𝓵𝓮.com", # Mixed fullwidth and cursive in domain
|
||||
],
|
||||
)
|
||||
def test_authentication_getter_existing_user_email_tricky(email, monkeypatch, settings):
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
"""Unit tests for the Authentication URLs."""
|
||||
|
||||
from core.authentication.urls import urlpatterns
|
||||
|
||||
|
||||
def test_urls_override_default_mozilla_django_oidc():
|
||||
"""Custom URL patterns should override default ones from Mozilla Django OIDC."""
|
||||
|
||||
url_names = [u.name for u in urlpatterns]
|
||||
assert url_names.index("oidc_logout_custom") < url_names.index("oidc_logout")
|
||||
@@ -1,359 +0,0 @@
|
||||
"""Unit tests for the Authentication Views."""
|
||||
|
||||
from unittest import mock
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.contrib.sessions.middleware import SessionMiddleware
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.test import RequestFactory
|
||||
from django.test.utils import override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils import crypto
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.authentication.views import (
|
||||
MozillaOIDCAuthenticationCallbackView,
|
||||
OIDCAuthenticationCallbackView,
|
||||
OIDCAuthenticationRequestView,
|
||||
OIDCLogoutCallbackView,
|
||||
OIDCLogoutView,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
|
||||
def test_view_logout_anonymous():
|
||||
"""Anonymous users calling the logout url,
|
||||
should be redirected to the specified LOGOUT_REDIRECT_URL."""
|
||||
|
||||
url = reverse("oidc_logout_custom")
|
||||
response = APIClient().get(url)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/example-logout"
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
OIDCLogoutView, "construct_oidc_logout_url", return_value="/example-logout"
|
||||
)
|
||||
def test_view_logout(mocked_oidc_logout_url):
|
||||
"""Authenticated users should be redirected to OIDC provider for logout."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
url = reverse("oidc_logout_custom")
|
||||
response = client.get(url)
|
||||
|
||||
mocked_oidc_logout_url.assert_called_once()
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/example-logout"
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/default-redirect-logout")
|
||||
@mock.patch.object(
|
||||
OIDCLogoutView, "construct_oidc_logout_url", return_value="/default-redirect-logout"
|
||||
)
|
||||
def test_view_logout_no_oidc_provider(mocked_oidc_logout_url):
|
||||
"""Authenticated users should be logged out when no OIDC provider is available."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
url = reverse("oidc_logout_custom")
|
||||
|
||||
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
|
||||
response = client.get(url)
|
||||
mocked_oidc_logout_url.assert_called_once()
|
||||
mock_logout.assert_called_once()
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/default-redirect-logout"
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
|
||||
def test_view_logout_callback_anonymous():
|
||||
"""Anonymous users calling the logout callback url,
|
||||
should be redirected to the specified LOGOUT_REDIRECT_URL."""
|
||||
|
||||
url = reverse("oidc_logout_callback")
|
||||
response = APIClient().get(url)
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/example-logout"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"initial_oidc_states",
|
||||
[{}, {"other_state": "foo"}],
|
||||
)
|
||||
def test_view_logout_persist_state(initial_oidc_states):
|
||||
"""State value should be persisted in session's data."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
if initial_oidc_states:
|
||||
request.session["oidc_states"] = initial_oidc_states
|
||||
request.session.save()
|
||||
|
||||
mocked_state = "mock_state"
|
||||
|
||||
OIDCLogoutView().persist_state(request, mocked_state)
|
||||
|
||||
assert "oidc_states" in request.session
|
||||
assert request.session["oidc_states"] == {
|
||||
"mock_state": {},
|
||||
**initial_oidc_states,
|
||||
}
|
||||
|
||||
|
||||
@override_settings(OIDC_OP_LOGOUT_ENDPOINT="/example-logout")
|
||||
@mock.patch.object(OIDCLogoutView, "persist_state")
|
||||
@mock.patch.object(crypto, "get_random_string", return_value="mocked_state")
|
||||
def test_view_logout_construct_oidc_logout_url(
|
||||
mocked_get_random_string, mocked_persist_state
|
||||
):
|
||||
"""Should construct the logout URL to initiate the logout flow with the OIDC provider."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
request.session["oidc_id_token"] = "mocked_oidc_id_token"
|
||||
request.session.save()
|
||||
|
||||
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
|
||||
|
||||
mocked_persist_state.assert_called_once()
|
||||
mocked_get_random_string.assert_called_once()
|
||||
|
||||
params = parse_qs(urlparse(redirect_url).query)
|
||||
|
||||
assert params["id_token_hint"][0] == "mocked_oidc_id_token"
|
||||
assert params["state"][0] == "mocked_state"
|
||||
|
||||
url = reverse("oidc_logout_callback")
|
||||
assert url in params["post_logout_redirect_uri"][0]
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/")
|
||||
def test_view_logout_construct_oidc_logout_url_none_id_token():
|
||||
"""If no ID token is available in the session,
|
||||
the user should be redirected to the final URL."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
redirect_url = OIDCLogoutView().construct_oidc_logout_url(request)
|
||||
|
||||
assert redirect_url == "/"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"initial_state",
|
||||
[None, {"other_state": "foo"}],
|
||||
)
|
||||
def test_view_logout_callback_wrong_state(initial_state):
|
||||
"""Should raise an error if OIDC state doesn't match session data."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
if initial_state:
|
||||
request.session["oidc_states"] = initial_state
|
||||
request.session.save()
|
||||
|
||||
callback_view = OIDCLogoutCallbackView.as_view()
|
||||
|
||||
with pytest.raises(SuspiciousOperation) as excinfo:
|
||||
callback_view(request)
|
||||
|
||||
assert (
|
||||
str(excinfo.value) == "OIDC callback state not found in session `oidc_states`!"
|
||||
)
|
||||
|
||||
|
||||
@override_settings(LOGOUT_REDIRECT_URL="/example-logout")
|
||||
def test_view_logout_callback():
|
||||
"""If state matches, callback should clear OIDC state and redirects."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().get("/logout-callback/", data={"state": "mocked_state"})
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
mocked_state = "mocked_state"
|
||||
|
||||
request.session["oidc_states"] = {mocked_state: {}}
|
||||
request.session.save()
|
||||
|
||||
callback_view = OIDCLogoutCallbackView.as_view()
|
||||
|
||||
with mock.patch("mozilla_django_oidc.views.auth.logout") as mock_logout:
|
||||
|
||||
def clear_user(request):
|
||||
# Assert state is cleared prior to logout
|
||||
assert request.session["oidc_states"] == {}
|
||||
request.user = AnonymousUser()
|
||||
|
||||
mock_logout.side_effect = clear_user
|
||||
response = callback_view(request)
|
||||
mock_logout.assert_called_once()
|
||||
|
||||
assert response.status_code == 302
|
||||
assert response.url == "/example-logout"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
|
||||
def test_view_authentication_default(settings, mocked_extra_params_setting):
|
||||
"""By default, authentication request should not trigger silent login."""
|
||||
|
||||
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
request.GET = {}
|
||||
|
||||
view = OIDCAuthenticationRequestView()
|
||||
extra_params = view.get_extra_params(request)
|
||||
|
||||
assert extra_params == (mocked_extra_params_setting or {})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
|
||||
def test_view_authentication_silent_false(settings, mocked_extra_params_setting):
|
||||
"""Ensure setting 'silent' parameter to a random value doesn't trigger the silent login flow."""
|
||||
|
||||
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
request.GET = {"silent": "foo"}
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
view = OIDCAuthenticationRequestView()
|
||||
extra_params = view.get_extra_params(request)
|
||||
|
||||
assert extra_params == (mocked_extra_params_setting or {})
|
||||
assert not request.session.get("silent")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mocked_extra_params_setting", [{"foo": 123}, {}, None])
|
||||
def test_view_authentication_silent_true(settings, mocked_extra_params_setting):
|
||||
"""If 'silent' parameter is set to True, the silent login should be triggered."""
|
||||
settings.OIDC_AUTH_REQUEST_EXTRA_PARAMS = mocked_extra_params_setting
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
request.GET = {"silent": "true"}
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
view = OIDCAuthenticationRequestView()
|
||||
extra_params = view.get_extra_params(request)
|
||||
expected_params = {"prompt": "none"}
|
||||
|
||||
assert (
|
||||
extra_params == {**mocked_extra_params_setting, **expected_params}
|
||||
if mocked_extra_params_setting
|
||||
else expected_params
|
||||
)
|
||||
assert request.session.get("silent") is True
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
MozillaOIDCAuthenticationCallbackView,
|
||||
"failure_url",
|
||||
new_callable=mock.PropertyMock,
|
||||
return_value="foo",
|
||||
)
|
||||
def test_view_callback_failure_url(mocked_failure_url):
|
||||
"""Test default behavior of the 'failure_url' property"""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
view = OIDCAuthenticationCallbackView()
|
||||
view.request = request
|
||||
|
||||
returned_url = view.failure_url
|
||||
|
||||
mocked_failure_url.assert_called_once()
|
||||
assert returned_url == "foo"
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
OIDCAuthenticationCallbackView,
|
||||
"success_url",
|
||||
new_callable=mock.PropertyMock,
|
||||
return_value="foo",
|
||||
)
|
||||
def test_view_callback_failure_url_silent_login(mocked_success_url):
|
||||
"""If a silent login was initiated and failed, it should not be treated as a failure."""
|
||||
|
||||
user = factories.UserFactory()
|
||||
|
||||
request = RequestFactory().request()
|
||||
request.user = user
|
||||
|
||||
middleware = SessionMiddleware(get_response=lambda x: x)
|
||||
middleware.process_request(request)
|
||||
|
||||
request.session["silent"] = True
|
||||
request.session.save()
|
||||
|
||||
view = OIDCAuthenticationCallbackView()
|
||||
view.request = request
|
||||
|
||||
returned_url = view.failure_url
|
||||
|
||||
mocked_success_url.assert_called_once()
|
||||
assert returned_url == "foo"
|
||||
assert not request.session.get("silent")
|
||||
@@ -2,7 +2,7 @@
|
||||
Test event authentication.
|
||||
"""
|
||||
|
||||
# pylint: disable=E1128
|
||||
# pylint: disable=assignment-from-no-return
|
||||
|
||||
from django.test import RequestFactory
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
"""
|
||||
Test event notification.
|
||||
"""
|
||||
|
||||
# pylint: disable=assignment-from-no-return,redefined-outer-name,unused-argument,protected-access
|
||||
|
||||
import datetime
|
||||
import smtplib
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.sites.models import Site
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories, models
|
||||
from core.recording.event.notification import NotificationService, notification_service
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mocked_current_site():
|
||||
"""Mocks the Site.objects.get_current()to return a controlled predefined domain."""
|
||||
|
||||
site_mock = mock.Mock()
|
||||
site_mock.domain = "test-domain.com"
|
||||
|
||||
with mock.patch.object(
|
||||
Site.objects, "get_current", return_value=site_mock
|
||||
) as patched:
|
||||
yield patched
|
||||
|
||||
|
||||
@mock.patch.object(NotificationService, "_notify_summary_service", return_value=True)
|
||||
def test_notify_external_services_transcript_mode(mock_notify_summary):
|
||||
"""Test notification routing for transcript mode recordings."""
|
||||
|
||||
service = NotificationService()
|
||||
|
||||
recording = factories.RecordingFactory(mode=models.RecordingModeChoices.TRANSCRIPT)
|
||||
result = service.notify_external_services(recording)
|
||||
|
||||
assert result is True
|
||||
mock_notify_summary.assert_called_once_with(recording)
|
||||
|
||||
|
||||
@mock.patch.object(NotificationService, "_notify_user_by_email", return_value=True)
|
||||
def test_notify_external_services_screen_recording_mode(mock_notify_email):
|
||||
"""Test notification routing for screen recording mode."""
|
||||
|
||||
service = NotificationService()
|
||||
|
||||
recording = factories.RecordingFactory(
|
||||
mode=models.RecordingModeChoices.SCREEN_RECORDING
|
||||
)
|
||||
|
||||
result = service.notify_external_services(recording)
|
||||
|
||||
assert result is True
|
||||
mock_notify_email.assert_called_once_with(recording)
|
||||
|
||||
|
||||
def test_notify_external_services_unknown_mode(caplog):
|
||||
"""Test notification for unknown recording mode."""
|
||||
recording = factories.RecordingFactory()
|
||||
|
||||
# Bypass validation
|
||||
recording.mode = "unknown"
|
||||
|
||||
service = NotificationService()
|
||||
|
||||
result = service.notify_external_services(recording)
|
||||
|
||||
assert result is False
|
||||
assert f"Unknown recording mode unknown for recording {recording.id}" in caplog.text
|
||||
|
||||
|
||||
# pylint: disable=too-many-locals
|
||||
def test_notify_user_by_email_success(mocked_current_site, settings):
|
||||
"""Test successful email notification to recording owners."""
|
||||
settings.EMAIL_BRAND_NAME = "ACME"
|
||||
settings.EMAIL_SUPPORT_EMAIL = "support@acme.com"
|
||||
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
|
||||
settings.SCREEN_RECORDING_BASE_URL = "https://acme.com/recordings"
|
||||
settings.EMAIL_FROM = "notifications@acme.com"
|
||||
|
||||
recording = factories.RecordingFactory(room__name="Conference Room A")
|
||||
|
||||
# Fix recording test to avoid flaky tests
|
||||
recording.created_at = datetime.datetime(2023, 5, 15, 14, 30, 0)
|
||||
|
||||
french_user = factories.UserFactory(
|
||||
email="franc@test.com", language="fr-fr", timezone="Europe/Paris"
|
||||
)
|
||||
dutch_user = factories.UserFactory(
|
||||
email="berry@test.com", language="nl-nl", timezone="Europe/Amsterdam"
|
||||
)
|
||||
english_user = factories.UserFactory(
|
||||
email="john@test.com", language="en-us", timezone="America/Phoenix"
|
||||
)
|
||||
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER, user=french_user
|
||||
)
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER, user=dutch_user
|
||||
)
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER, user=english_user
|
||||
)
|
||||
|
||||
# Create non-owner users to verify they don't receive emails
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.MEMBER
|
||||
)
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.ADMIN
|
||||
)
|
||||
|
||||
notification_service = NotificationService()
|
||||
|
||||
with mock.patch("core.recording.event.notification.send_mail") as mock_send_mail:
|
||||
result = notification_service._notify_user_by_email(recording)
|
||||
|
||||
assert result is True
|
||||
assert mock_send_mail.call_count == 3
|
||||
|
||||
call_args_list = mock_send_mail.call_args_list
|
||||
|
||||
base_content = [
|
||||
"ACME", # Brand name
|
||||
"support@acme.com", # Support email
|
||||
"https://acme.com/logo", # Logo URL
|
||||
f"https://acme.com/recordings/{recording.id}", # Recording link
|
||||
"Conference Room A", # Room name
|
||||
]
|
||||
|
||||
# First call verification
|
||||
subject1, body1, sender1, recipients1 = call_args_list[0][0]
|
||||
assert subject1 == "Votre enregistrement est prêt"
|
||||
|
||||
# Verify email contains expected content
|
||||
personalized_content1 = [
|
||||
*base_content,
|
||||
"2023-05-15", # Formatted date
|
||||
"16:30", # Formatted time
|
||||
"Votre enregistrement est prêt !", # Intro
|
||||
]
|
||||
for content in personalized_content1:
|
||||
assert content in body1
|
||||
|
||||
assert recipients1 == ["franc@test.com"]
|
||||
assert sender1 == "notifications@acme.com"
|
||||
|
||||
# Second call verification
|
||||
subject2, body2, sender2, recipients2 = call_args_list[1][0]
|
||||
assert subject2 == "Je opname is klaar"
|
||||
|
||||
# Verify second email content (if needed)
|
||||
personalized_content2 = [
|
||||
*base_content,
|
||||
"2023-05-15", # Formatted date
|
||||
"16:30", # Formatted time
|
||||
"Je opname is klaar!", # Intro
|
||||
]
|
||||
for content in personalized_content2:
|
||||
assert content in body2
|
||||
|
||||
assert recipients2 == ["berry@test.com"]
|
||||
assert sender2 == "notifications@acme.com"
|
||||
|
||||
# Third call verification
|
||||
subject3, body3, sender3, recipients3 = call_args_list[2][0]
|
||||
assert subject3 == "Your recording is ready"
|
||||
|
||||
# Verify second email content (if needed)
|
||||
personalized_content3 = [
|
||||
*base_content,
|
||||
"Conference Room A", # Room name
|
||||
"2023-05-15", # Formatted date
|
||||
"07:30", # Formatted time
|
||||
"Your recording is ready!", # Intro
|
||||
]
|
||||
for content in personalized_content3:
|
||||
assert content in body3
|
||||
|
||||
assert recipients3 == ["john@test.com"]
|
||||
assert sender3 == "notifications@acme.com"
|
||||
|
||||
|
||||
def test_notify_user_by_email_no_owners(mocked_current_site, caplog):
|
||||
"""Test email notification when no owners are found."""
|
||||
|
||||
# Recording with no access
|
||||
recording = factories.RecordingFactory()
|
||||
|
||||
result = notification_service._notify_user_by_email(recording)
|
||||
|
||||
assert result is False
|
||||
assert f"No owner found for recording {recording.id}" in caplog.text
|
||||
|
||||
|
||||
def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
|
||||
"""Test email notification when an exception occurs."""
|
||||
|
||||
recording = factories.RecordingFactory(room__name="Conference Room A")
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER
|
||||
)
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
notification_service = NotificationService()
|
||||
|
||||
with mock.patch(
|
||||
"core.recording.event.notification.send_mail",
|
||||
side_effect=smtplib.SMTPException("SMTP Error"),
|
||||
) as mock_send_mail:
|
||||
result = notification_service._notify_user_by_email(recording)
|
||||
|
||||
assert result is False
|
||||
assert mock_send_mail.call_count == 2
|
||||
assert "notification could not be sent:" in caplog.text
|
||||
@@ -2,7 +2,7 @@
|
||||
Test event parsers.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0212,W0621,W0613
|
||||
# pylint: disable=protected-access,redefined-outer-name,unused-argument
|
||||
|
||||
from unittest import mock
|
||||
|
||||
@@ -129,8 +129,11 @@ def test_validate_invalid_filetype(minio_parser):
|
||||
@pytest.mark.parametrize(
|
||||
"invalid_filepath",
|
||||
[
|
||||
"invalid_filepath",
|
||||
"invalid_filepath", # totally invalid string
|
||||
"recording/46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||
"recording/46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing extension
|
||||
"46d1a121-2426-484d-8fb3-09b5d886f7a8", # missing url_encoded_folder_path and extension
|
||||
"", # empty string
|
||||
"recording%2F46d1a1212426484d8fb309b5d886f7a8.ogg",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""
|
||||
Test RecordingEventsService service.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from core.factories import RecordingFactory
|
||||
from core.recording.services.recording_events import (
|
||||
RecordingEventsError,
|
||||
RecordingEventsService,
|
||||
)
|
||||
from core.utils import NotificationError
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service():
|
||||
"""Initialize RecordingEventsService."""
|
||||
return RecordingEventsService()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "notification_type"),
|
||||
(
|
||||
("screen_recording", "screenRecordingLimitReached"),
|
||||
("transcript", "transcriptionLimitReached"),
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_limit_reached_success(mock_notify, mode, notification_type, service):
|
||||
"""Test handle_limit_reached stops recording and notifies participants."""
|
||||
|
||||
recording = RecordingFactory(status="active", mode=mode)
|
||||
service.handle_limit_reached(recording)
|
||||
|
||||
assert recording.status == "stopped"
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "notification_type"),
|
||||
(
|
||||
("screen_recording", "screenRecordingLimitReached"),
|
||||
("transcript", "transcriptionLimitReached"),
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_limit_reached_error(mock_notify, mode, notification_type, service):
|
||||
"""Test handle_limit_reached raises RecordingEventsError when notification fails."""
|
||||
|
||||
mock_notify.side_effect = NotificationError("Error notifying")
|
||||
|
||||
recording = RecordingFactory(status="active", mode=mode)
|
||||
|
||||
with pytest.raises(
|
||||
RecordingEventsError,
|
||||
match=r"Failed to notify participants in room '.+' "
|
||||
r"about recording limit reached \(recording_id=.+\)",
|
||||
):
|
||||
service.handle_limit_reached(recording)
|
||||
|
||||
assert recording.status == "stopped"
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||
)
|
||||
@@ -22,15 +22,37 @@ def test_api_recordings_list_anonymous():
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_recordings_list_authenticated_no_recording():
|
||||
"""
|
||||
Authenticated users listing recordings should only
|
||||
see recordings to which they have access.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
factories.UserRecordingAccessFactory(user=other_user)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert results == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
["administrator", "member", "owner"],
|
||||
)
|
||||
def test_api_recordings_list_authenticated_direct(role):
|
||||
def test_api_recordings_list_authenticated_direct(role, settings):
|
||||
"""
|
||||
Authenticated users listing recordings, should only see the recordings
|
||||
to which they are related.
|
||||
"""
|
||||
settings.RECORDING_EXPIRATIONS_DAYS = None
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
@@ -57,7 +79,9 @@ def test_api_recordings_list_authenticated_direct(role):
|
||||
assert expected_ids == result_ids
|
||||
assert results[0] == {
|
||||
"id": str(recording.id),
|
||||
"key": recording.key,
|
||||
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"mode": recording.mode,
|
||||
"room": {
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
@@ -66,6 +90,8 @@ def test_api_recordings_list_authenticated_direct(role):
|
||||
},
|
||||
"status": "initiated",
|
||||
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"expired_at": None,
|
||||
"is_expired": False,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"""
|
||||
Test media-auth authorization API endpoint in docs core app.
|
||||
"""
|
||||
|
||||
from io import BytesIO
|
||||
from urllib.parse import urlparse
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.storage import default_storage
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import models
|
||||
from core.factories import RecordingFactory, UserFactory, UserRecordingAccessFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_recordings_media_auth_unauthenticated():
|
||||
"""
|
||||
Test that unauthenticated requests to download media are rejected
|
||||
"""
|
||||
original_url = f"http://localhost/media/recordings/{uuid4()!s}.mp4"
|
||||
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_recordings_media_auth_wrong_path():
|
||||
"""
|
||||
Test that media URLs with incorrect path structures are rejected
|
||||
"""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
original_url = f"http://localhost/media/wrong-path/{uuid4()!s}.mp4"
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_api_recordings_media_auth_unknown_recording():
|
||||
"""
|
||||
Test that requests for non-existent recordings are properly handled
|
||||
"""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
original_url = f"http://localhost/media/recordings/{uuid4()!s}.mp4"
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_api_recordings_media_auth_no_abilities():
|
||||
"""
|
||||
Test that users without any access permissions cannot download recordings
|
||||
"""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED)
|
||||
original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4"
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_api_recordings_media_auth_wrong_abilities():
|
||||
"""
|
||||
Test that users with insufficient role permissions cannot download recordings
|
||||
"""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED)
|
||||
|
||||
UserRecordingAccessFactory(user=user, recording=recording, role="member")
|
||||
|
||||
original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4"
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize("wrong_status", ["initiated", "active", "failed_to_stop"])
|
||||
def test_api_recordings_media_auth_unsaved(wrong_status):
|
||||
"""
|
||||
Test that recordings that aren't in 'saved' status cannot be downloaded
|
||||
"""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
recording = RecordingFactory(status=wrong_status)
|
||||
UserRecordingAccessFactory(user=user, recording=recording, role="owner")
|
||||
|
||||
original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4"
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_api_recordings_media_auth_mismatched_extension():
|
||||
"""
|
||||
Test that requests with mismatched file extensions are rejected
|
||||
"""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
recording = RecordingFactory(
|
||||
status=models.RecordingStatusChoices.SAVED,
|
||||
mode=models.RecordingModeChoices.TRANSCRIPT,
|
||||
)
|
||||
UserRecordingAccessFactory(user=user, recording=recording, role="owner")
|
||||
|
||||
original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4"
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "No recording found with this extension."}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"wrong_extension", ["jpg", "txt", "mp3"], ids=["image", "text", "audio"]
|
||||
)
|
||||
def test_api_recordings_media_auth_wrong_extension(wrong_extension):
|
||||
"""
|
||||
Trying to download a recording with an unsupported extension should return
|
||||
a validation error (400) with details about allowed extensions.
|
||||
"""
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED)
|
||||
UserRecordingAccessFactory(user=user, recording=recording, role="owner")
|
||||
|
||||
original_url = (
|
||||
f"http://localhost/media/recordings/{recording.id!s}.{wrong_extension}"
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "Unsupported extension."}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["screen_recording", "transcript"])
|
||||
def test_api_recordings_media_auth_success_owner(mode):
|
||||
"""
|
||||
Test downloading a recording when logged in and authorized.
|
||||
Verifies S3 authentication headers and successful file retrieval.
|
||||
"""
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED, mode=mode)
|
||||
UserRecordingAccessFactory(user=user, recording=recording, role="owner")
|
||||
|
||||
default_storage.connection.meta.client.put_object(
|
||||
Bucket=default_storage.bucket_name,
|
||||
Key=recording.key,
|
||||
Body=BytesIO(b"my prose"),
|
||||
ContentType="text/plain",
|
||||
)
|
||||
|
||||
original_url = f"http://localhost/media/{recording.key:s}"
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
authorization = response["Authorization"]
|
||||
assert "AWS4-HMAC-SHA256 Credential=" in authorization
|
||||
assert (
|
||||
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
||||
in authorization
|
||||
)
|
||||
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
||||
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/meet-media-storage/{recording.key:s}"
|
||||
response = requests.get(
|
||||
file_url,
|
||||
headers={
|
||||
"authorization": authorization,
|
||||
"x-amz-date": response["x-amz-date"],
|
||||
"x-amz-content-sha256": response["x-amz-content-sha256"],
|
||||
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
|
||||
},
|
||||
timeout=1,
|
||||
)
|
||||
assert response.content.decode("utf-8") == "my prose"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["screen_recording", "transcript"])
|
||||
def test_api_recordings_media_auth_success_administrator(mode):
|
||||
"""
|
||||
Test downloading a recording when logged in and authorized.
|
||||
Verifies S3 authentication headers and successful file retrieval.
|
||||
"""
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED, mode=mode)
|
||||
UserRecordingAccessFactory(user=user, recording=recording, role="administrator")
|
||||
|
||||
default_storage.connection.meta.client.put_object(
|
||||
Bucket=default_storage.bucket_name,
|
||||
Key=recording.key,
|
||||
Body=BytesIO(b"my prose"),
|
||||
ContentType="text/plain",
|
||||
)
|
||||
|
||||
original_url = f"http://localhost/media/{recording.key:s}"
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
authorization = response["Authorization"]
|
||||
assert "AWS4-HMAC-SHA256 Credential=" in authorization
|
||||
assert (
|
||||
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
||||
in authorization
|
||||
)
|
||||
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
||||
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/meet-media-storage/{recording.key:s}"
|
||||
response = requests.get(
|
||||
file_url,
|
||||
headers={
|
||||
"authorization": authorization,
|
||||
"x-amz-date": response["x-amz-date"],
|
||||
"x-amz-content-sha256": response["x-amz-content-sha256"],
|
||||
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
|
||||
},
|
||||
timeout=1,
|
||||
)
|
||||
assert response.content.decode("utf-8") == "my prose"
|
||||
@@ -0,0 +1,243 @@
|
||||
"""
|
||||
Test recordings API endpoints in the Meet core app: retrieve.
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
from freezegun import freeze_time
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RecordingFactory, UserFactory, UserRecordingAccessFactory
|
||||
from ...models import RecordingStatusChoices
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_recording_retrieve_anonymous():
|
||||
"""Anonymous users should not be able to retrieve recordings."""
|
||||
recording = RecordingFactory()
|
||||
client = APIClient()
|
||||
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_recording_retrieve_authenticated():
|
||||
"""Authenticated users without access receive 404 when requesting recordings.
|
||||
|
||||
The API returns 404 instead of 403 to avoid revealing the existence of
|
||||
resources the user doesn't have permission to access.
|
||||
"""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
other_user = UserFactory()
|
||||
recording = UserRecordingAccessFactory(user=other_user).recording
|
||||
|
||||
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "No Recording matches the given query."}
|
||||
|
||||
|
||||
def test_api_recording_retrieve_members():
|
||||
"""
|
||||
A user who is a member of a recording should not be able to retrieve it.
|
||||
"""
|
||||
user = UserFactory()
|
||||
recording = RecordingFactory()
|
||||
|
||||
UserRecordingAccessFactory(recording=recording, user=user, role="member")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
def test_api_recording_retrieve_administrators(settings):
|
||||
"""A user who is an administrator of a recording should be able to retrieve it."""
|
||||
settings.RECORDING_EXPIRATION_DAYS = None
|
||||
|
||||
user = UserFactory()
|
||||
recording = RecordingFactory()
|
||||
|
||||
UserRecordingAccessFactory(recording=recording, user=user, role="administrator")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
room = recording.room
|
||||
|
||||
assert content == {
|
||||
"id": str(recording.id),
|
||||
"key": recording.key,
|
||||
"room": {
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
},
|
||||
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"status": str(recording.status),
|
||||
"mode": str(recording.mode),
|
||||
"expired_at": None,
|
||||
"is_expired": False,
|
||||
}
|
||||
|
||||
|
||||
def test_api_recording_retrieve_owners(settings):
|
||||
"""A user who is an owner of a recording should be able to retrieve it."""
|
||||
settings.RECORDING_EXPIRATION_DAYS = None
|
||||
user = UserFactory()
|
||||
recording = RecordingFactory()
|
||||
|
||||
UserRecordingAccessFactory(recording=recording, user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
room = recording.room
|
||||
|
||||
assert content == {
|
||||
"id": str(recording.id),
|
||||
"key": recording.key,
|
||||
"room": {
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
},
|
||||
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"status": str(recording.status),
|
||||
"mode": str(recording.mode),
|
||||
"expired_at": None,
|
||||
"is_expired": False,
|
||||
}
|
||||
|
||||
|
||||
@freeze_time("2023-01-15 12:00:00")
|
||||
def test_api_recording_retrieve_compute_expiration_date_correctly(settings):
|
||||
"""Test that the API returns the correct expiration date for a non-expired recording."""
|
||||
settings.RECORDING_EXPIRATION_DAYS = 1
|
||||
|
||||
user = UserFactory()
|
||||
recording = RecordingFactory()
|
||||
|
||||
UserRecordingAccessFactory(
|
||||
recording=recording, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
room = recording.room
|
||||
|
||||
assert content == {
|
||||
"id": str(recording.id),
|
||||
"key": recording.key,
|
||||
"room": {
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
},
|
||||
"created_at": "2023-01-15T12:00:00Z",
|
||||
"updated_at": "2023-01-15T12:00:00Z",
|
||||
"status": str(recording.status),
|
||||
"mode": str(recording.mode),
|
||||
"expired_at": "2023-01-16T12:00:00Z",
|
||||
"is_expired": False, # Ensure the recording is still valid and hasn't expired
|
||||
}
|
||||
|
||||
|
||||
def test_api_recording_retrieve_expired(settings):
|
||||
"""Test that the API returns the correct expiration date and flag for an expired recording."""
|
||||
settings.RECORDING_EXPIRATION_DAYS = 2
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
with freeze_time("2023-01-15 12:00:00"):
|
||||
recording = RecordingFactory()
|
||||
|
||||
UserRecordingAccessFactory(
|
||||
recording=recording, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
room = recording.room
|
||||
|
||||
assert content == {
|
||||
"id": str(recording.id),
|
||||
"key": recording.key,
|
||||
"room": {
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
},
|
||||
"created_at": "2023-01-15T12:00:00Z",
|
||||
"updated_at": "2023-01-15T12:00:00Z",
|
||||
"status": str(recording.status),
|
||||
"mode": str(recording.mode),
|
||||
"expired_at": "2023-01-17T12:00:00Z",
|
||||
"is_expired": True, # Ensure the recording has expired
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status",
|
||||
[
|
||||
RecordingStatusChoices.INITIATED,
|
||||
RecordingStatusChoices.ACTIVE,
|
||||
RecordingStatusChoices.SAVED,
|
||||
RecordingStatusChoices.FAILED_TO_START,
|
||||
RecordingStatusChoices.FAILED_TO_STOP,
|
||||
RecordingStatusChoices.ABORTED,
|
||||
],
|
||||
)
|
||||
def test_api_recording_retrieve_any_status(status):
|
||||
"""Test that recordings with any status can be retrieved."""
|
||||
user = UserFactory()
|
||||
recording = RecordingFactory(status=status)
|
||||
|
||||
UserRecordingAccessFactory(recording=recording, user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert content["id"] == str(recording.id)
|
||||
assert content["status"] == status
|
||||
@@ -2,7 +2,7 @@
|
||||
Test recordings API endpoints in the Meet core app: save recording.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621,W0613
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
|
||||
import uuid
|
||||
from unittest import mock
|
||||
@@ -77,7 +77,8 @@ def test_save_recording_permission_needed(settings, client):
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "Not found."}
|
||||
|
||||
|
||||
def test_save_recording_parsing_error(recording_settings, mock_get_parser, client):
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Test worker service factories.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0212,W0621,W0613
|
||||
# pylint: disable=protected-access,redefined-outer-name,unused-argument
|
||||
|
||||
from dataclasses import FrozenInstanceError
|
||||
from unittest.mock import Mock
|
||||
@@ -32,7 +32,6 @@ def test_settings():
|
||||
mocked_settings = {
|
||||
"RECORDING_OUTPUT_FOLDER": "/test/output",
|
||||
"LIVEKIT_CONFIGURATION": {"server": "test.example.com"},
|
||||
"RECORDING_VERIFY_SSL": True,
|
||||
"AWS_S3_ENDPOINT_URL": "https://s3.test.com",
|
||||
"AWS_S3_ACCESS_KEY_ID": "test_key",
|
||||
"AWS_S3_SECRET_ACCESS_KEY": "test_secret",
|
||||
@@ -56,7 +55,6 @@ def test_config_initialization(default_config):
|
||||
"""Test that WorkerServiceConfig is properly initialized from settings"""
|
||||
assert default_config.output_folder == "/test/output"
|
||||
assert default_config.server_configurations == {"server": "test.example.com"}
|
||||
assert default_config.verify_ssl is True
|
||||
assert default_config.bucket_args == {
|
||||
"endpoint": "https://s3.test.com",
|
||||
"access_key": "test_key",
|
||||
@@ -76,7 +74,6 @@ def test_config_immutability(default_config):
|
||||
@override_settings(
|
||||
RECORDING_OUTPUT_FOLDER="/test/output",
|
||||
LIVEKIT_CONFIGURATION={"server": "test.example.com"},
|
||||
RECORDING_VERIFY_SSL=True,
|
||||
AWS_S3_ENDPOINT_URL="https://s3.test.com",
|
||||
AWS_S3_ACCESS_KEY_ID="test_key",
|
||||
AWS_S3_SECRET_ACCESS_KEY="test_secret",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Test WorkerServiceMediator class."""
|
||||
|
||||
# pylint: disable=W0621,W0613
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
Test worker service classes.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0212,W0621,W0613,E1101
|
||||
# pylint: disable=protected-access,redefined-outer-name,unused-argument,no-member
|
||||
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import aiohttp
|
||||
import pytest
|
||||
|
||||
from core.recording.worker.exceptions import WorkerConnectionError, WorkerResponseError
|
||||
from core.recording.worker.exceptions import WorkerResponseError
|
||||
from core.recording.worker.factories import WorkerServiceConfig
|
||||
from core.recording.worker.services import (
|
||||
AudioCompositeEgressService,
|
||||
@@ -25,11 +24,10 @@ def config():
|
||||
return WorkerServiceConfig(
|
||||
output_folder="/test/output",
|
||||
server_configurations={
|
||||
"host": "test.livekit.io",
|
||||
"url": "test.livekit.io",
|
||||
"api_key": "test_key",
|
||||
"api_secret": "test_secret",
|
||||
},
|
||||
verify_ssl=True,
|
||||
bucket_args={
|
||||
"endpoint": "https://s3.test.com",
|
||||
"access_key": "test_key",
|
||||
@@ -127,58 +125,6 @@ def test_base_egress_filepath_construction(service, filename, extension, expecte
|
||||
assert result.endswith(f"{filename}.{extension}")
|
||||
|
||||
|
||||
def test_base_egress_handle_request_success(
|
||||
config, service, mock_client_session, mock_egress_service, mock_tcp_connector
|
||||
):
|
||||
"""Test successful request handling"""
|
||||
# Setup mock response
|
||||
mock_response = Mock()
|
||||
mock_method = AsyncMock(return_value=mock_response)
|
||||
mock_egress_instance = Mock()
|
||||
mock_egress_instance.test_method = mock_method
|
||||
mock_egress_service.return_value = mock_egress_instance
|
||||
|
||||
# Create test request
|
||||
test_request = Mock()
|
||||
|
||||
response = service._handle_request(test_request, "test_method")
|
||||
|
||||
mock_client_session.assert_called_once_with(
|
||||
connector=mock_tcp_connector.return_value
|
||||
)
|
||||
|
||||
# Verify EgressService initialization
|
||||
mock_egress_service.assert_called_once_with(
|
||||
mock_client_session.return_value.__aenter__.return_value,
|
||||
**service._config.server_configurations,
|
||||
)
|
||||
|
||||
# Verify method call and response
|
||||
mock_method.assert_called_once_with(test_request)
|
||||
assert response == mock_response
|
||||
|
||||
|
||||
def test_base_egress_handle_request_connection_error(service, mock_egress_service):
|
||||
"""Test handling of connection errors"""
|
||||
# Setup mock error
|
||||
mock_method = AsyncMock(
|
||||
side_effect=livekit_api.TwirpError(msg="Connection failed", code=500)
|
||||
)
|
||||
mock_egress_instance = Mock()
|
||||
mock_egress_instance.test_method = mock_method
|
||||
mock_egress_service.return_value = mock_egress_instance
|
||||
|
||||
# Create test request
|
||||
test_request = Mock()
|
||||
|
||||
# Verify error handling
|
||||
with pytest.raises(WorkerConnectionError) as exc:
|
||||
service._handle_request(test_request, "test_method")
|
||||
|
||||
assert "LiveKit client connection error" in str(exc.value)
|
||||
assert "Connection failed" in str(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"response_status,expected_result",
|
||||
[
|
||||
@@ -224,43 +170,6 @@ def test_base_egress_start_not_implemented(service):
|
||||
assert "Subclass must implement this method" in str(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("verify_ssl", [True, False])
|
||||
def test_base_egress_ssl_verification_config(verify_ssl):
|
||||
"""Test SSL verification configuration"""
|
||||
config = WorkerServiceConfig(
|
||||
output_folder="/test/output",
|
||||
server_configurations={
|
||||
"host": "test.livekit.io",
|
||||
"api_key": "test_key",
|
||||
"api_secret": "test_secret",
|
||||
},
|
||||
verify_ssl=verify_ssl,
|
||||
bucket_args={
|
||||
"endpoint": "https://s3.test.com",
|
||||
"access_key": "test_key",
|
||||
"secret": "test_secret",
|
||||
"region": "test-region",
|
||||
"bucket": "test-bucket",
|
||||
"force_path_style": True,
|
||||
},
|
||||
)
|
||||
|
||||
service = BaseEgressService(config)
|
||||
|
||||
# Mock ClientSession to capture connector configuration
|
||||
with patch("aiohttp.ClientSession") as mock_session:
|
||||
mock_session.return_value.__aenter__ = AsyncMock()
|
||||
mock_session.return_value.__aexit__ = AsyncMock()
|
||||
|
||||
# Trigger request to verify connector configuration
|
||||
service._handle_request(Mock(), "test_method")
|
||||
|
||||
# Verify SSL configuration
|
||||
connector = mock_session.call_args[1]["connector"]
|
||||
assert isinstance(connector, aiohttp.TCPConnector)
|
||||
assert connector._ssl == verify_ssl
|
||||
|
||||
|
||||
def test_video_composite_egress_hrid(video_service):
|
||||
"""Test HRID is correct"""
|
||||
assert video_service.hrid == "video-recording-composite-livekit-egress"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Test rooms API endpoints in the Meet core app: create.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621,W0613
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
from django.core.cache import cache
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Test rooms API endpoints in the Meet core app: creation callback functionality.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621,W0613
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
from django.core.cache import cache
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
"""
|
||||
Test rooms API endpoints in the Meet core app: invite.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
|
||||
import json
|
||||
import random
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
from ...services.invitation import InvitationError, InvitationService
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_rooms_invite_anonymous():
|
||||
"""Test anonymous users should not be allowed to invite people to rooms."""
|
||||
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
|
||||
data = {"emails": ["toto@yopmail.com"]}
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/invite/",
|
||||
json.dumps(data),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_rooms_invite_no_access():
|
||||
"""Test non-privileged users should not be allowed to invite people to rooms."""
|
||||
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
|
||||
user = UserFactory()
|
||||
client.force_login(user)
|
||||
|
||||
data = {"emails": ["toto@yopmail.com"]}
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/invite/",
|
||||
json.dumps(data),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You must have privileges on room to perform this action.",
|
||||
}
|
||||
|
||||
|
||||
def test_api_rooms_invite_member():
|
||||
"""Test member users should not be allowed to invite people to rooms."""
|
||||
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
|
||||
user = UserFactory()
|
||||
client.force_login(user)
|
||||
|
||||
room.accesses.create(user=user, role="member")
|
||||
|
||||
data = {"emails": ["toto@yopmail.com"]}
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/invite/",
|
||||
json.dumps(data),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You must have privileges on room to perform this action.",
|
||||
}
|
||||
|
||||
|
||||
def test_api_rooms_invite_missing_emails():
|
||||
"""Test missing email list should return validation error."""
|
||||
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
|
||||
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
data = {"foo": []}
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/invite/",
|
||||
json.dumps(data),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"emails": [
|
||||
"This field is required.",
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_api_rooms_invite_empty_emails():
|
||||
"""Test empty email list should return validation error."""
|
||||
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
|
||||
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
data = {"emails": []}
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/invite/",
|
||||
json.dumps(data),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"emails": [
|
||||
"This list may not be empty.",
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_api_rooms_invite_invalid_emails():
|
||||
"""Test invalid email addresses should return validation errors."""
|
||||
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
|
||||
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
data = {"emails": ["abdc", "efg"]}
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/invite/",
|
||||
json.dumps(data),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"emails": {
|
||||
"0": ["Enter a valid email address."],
|
||||
"1": ["Enter a valid email address."],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_api_rooms_invite_partially_invalid_emails():
|
||||
"""Test partially invalid email addresses should return validation errors."""
|
||||
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
|
||||
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
data = {"emails": ["fabrice@yopmail.com", "efg"]}
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/invite/",
|
||||
json.dumps(data),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {
|
||||
"emails": {
|
||||
"1": ["Enter a valid email address."],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@mock.patch.object(InvitationService, "invite_to_room")
|
||||
def test_api_rooms_invite_duplicates(mock_invite_to_room):
|
||||
"""Test duplicate emails should be deduplicated before processing."""
|
||||
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
|
||||
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
data = {"emails": ["toto@yopmail.com", "toto@yopmail.com", "Toto@yopmail.com"]}
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/invite/",
|
||||
json.dumps(data),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_invite_to_room.assert_called_once()
|
||||
|
||||
_, kwargs = mock_invite_to_room.call_args
|
||||
|
||||
assert kwargs["room"] == room
|
||||
assert kwargs["sender"] == user
|
||||
assert sorted(kwargs["emails"]) == sorted(["Toto@yopmail.com", "toto@yopmail.com"])
|
||||
|
||||
|
||||
@mock.patch.object(InvitationService, "invite_to_room", side_effect=InvitationError())
|
||||
def test_api_rooms_invite_error(mock_invite_to_room):
|
||||
"""Test invitation service error should return appropriate error response."""
|
||||
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
|
||||
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
data = {"emails": ["toto@yopmail.com", "toto@yopmail.com"]}
|
||||
|
||||
with pytest.raises(InvitationError):
|
||||
client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/invite/",
|
||||
json.dumps(data),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
mock_invite_to_room.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.invitation.send_mail")
|
||||
def test_api_rooms_invite_success(mock_send_mail, settings):
|
||||
"""Test privileged users should successfully send invitation emails."""
|
||||
|
||||
settings.EMAIL_BRAND_NAME = "ACME"
|
||||
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
|
||||
settings.EMAIL_APP_BASE_URL = "https://acme.com"
|
||||
settings.EMAIL_FROM = "notifications@acme.com"
|
||||
settings.EMAIL_DOMAIN = "acme.com"
|
||||
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
|
||||
room.accesses.create(user=user, role=random.choice(["administrator", "owner"]))
|
||||
|
||||
client.force_login(user)
|
||||
|
||||
data = {"emails": ["fabien@yopmail.com", "gerald@yopmail.com"]}
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/invite/",
|
||||
json.dumps(data),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "success", "message": "invitations sent"}
|
||||
|
||||
mock_send_mail.assert_called_once()
|
||||
|
||||
subject, body, sender, recipients = mock_send_mail.call_args[0]
|
||||
|
||||
assert (
|
||||
subject == f"Video call in progress: {user.email} is waiting for you to connect"
|
||||
)
|
||||
|
||||
# Verify email contains expected content
|
||||
required_content = [
|
||||
"ACME", # Brand name
|
||||
"https://acme.com/logo", # Logo URL
|
||||
f"https://acme.com/{room.slug}", # Room url
|
||||
f"acme.com/{room.slug}", # Room link
|
||||
]
|
||||
|
||||
for content in required_content:
|
||||
assert content in body
|
||||
|
||||
assert sender == "notifications@acme.com"
|
||||
|
||||
# Verify all owners received the email (order-independent comparison)
|
||||
assert sorted(recipients) == sorted(["fabien@yopmail.com", "gerald@yopmail.com"])
|
||||
@@ -37,7 +37,7 @@ def test_request_entry_anonymous(settings):
|
||||
assert not lobby_keys
|
||||
|
||||
with (
|
||||
mock.patch.object(LobbyService, "notify_participants", return_value=None),
|
||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||
):
|
||||
response = client.post(
|
||||
@@ -86,7 +86,7 @@ def test_request_entry_authenticated_user(settings):
|
||||
assert not lobby_keys
|
||||
|
||||
with (
|
||||
mock.patch.object(LobbyService, "notify_participants", return_value=None),
|
||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||
):
|
||||
response = client.post(
|
||||
@@ -132,18 +132,18 @@ def test_request_entry_with_existing_participants(settings):
|
||||
|
||||
# Add two participants already waiting in the lobby
|
||||
cache.set(
|
||||
f"mocked-cache-prefix_{room.id}_participant1",
|
||||
f"mocked-cache-prefix_{room.id}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
{
|
||||
"id": "participant1",
|
||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
},
|
||||
)
|
||||
cache.set(
|
||||
f"mocked-cache-prefix_{room.id}_participant2",
|
||||
f"mocked-cache-prefix_{room.id}_f4ca3ab8a6c04ad88097b8da33f60f10",
|
||||
{
|
||||
"id": "participant2",
|
||||
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
|
||||
"username": "user2",
|
||||
"status": "accepted",
|
||||
"color": "#654321",
|
||||
@@ -156,7 +156,7 @@ def test_request_entry_with_existing_participants(settings):
|
||||
|
||||
# Mock external service calls to isolate the test
|
||||
with (
|
||||
mock.patch.object(LobbyService, "notify_participants", return_value=None),
|
||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||
):
|
||||
# Make request as a new anonymous user
|
||||
@@ -205,7 +205,7 @@ def test_request_entry_public_room(settings):
|
||||
assert not lobby_keys
|
||||
|
||||
with (
|
||||
mock.patch.object(LobbyService, "notify_participants", return_value=None),
|
||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||
mock.patch.object(
|
||||
LobbyService, "_get_or_create_participant_id", return_value="123"
|
||||
),
|
||||
@@ -255,9 +255,11 @@ def test_request_entry_authenticated_user_public_room(settings):
|
||||
assert not lobby_keys
|
||||
|
||||
with (
|
||||
mock.patch.object(LobbyService, "notify_participants", return_value=None),
|
||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||
mock.patch.object(
|
||||
LobbyService, "_get_or_create_participant_id", return_value="123"
|
||||
LobbyService,
|
||||
"_get_or_create_participant_id",
|
||||
return_value="2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
),
|
||||
mock.patch.object(
|
||||
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
||||
@@ -274,11 +276,11 @@ def test_request_entry_authenticated_user_public_room(settings):
|
||||
# Verify the lobby cookie was set
|
||||
cookie = response.cookies.get("mocked-cookie")
|
||||
assert cookie is not None
|
||||
assert cookie.value == "123"
|
||||
assert cookie.value == "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
|
||||
|
||||
# Verify response content matches expected structure and values
|
||||
assert response.json() == {
|
||||
"id": "123",
|
||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
"username": "test_user",
|
||||
"status": "accepted",
|
||||
"color": "mocked-color",
|
||||
@@ -300,9 +302,9 @@ def test_request_entry_waiting_participant_public_room(settings):
|
||||
|
||||
# Add a waiting participant to the room's lobby cache
|
||||
cache.set(
|
||||
f"mocked-cache-prefix_{room.id}_participant1",
|
||||
f"mocked-cache-prefix_{room.id}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
{
|
||||
"id": "participant1",
|
||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
@@ -310,10 +312,10 @@ def test_request_entry_waiting_participant_public_room(settings):
|
||||
)
|
||||
|
||||
# Simulate a browser with existing participant cookie
|
||||
client.cookies.load({"mocked-cookie": "participant1"})
|
||||
client.cookies.load({"mocked-cookie": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"})
|
||||
|
||||
with (
|
||||
mock.patch.object(LobbyService, "notify_participants", return_value=None),
|
||||
mock.patch.object(utils, "notify_participants", return_value=None),
|
||||
mock.patch.object(
|
||||
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
||||
),
|
||||
@@ -328,11 +330,11 @@ def test_request_entry_waiting_participant_public_room(settings):
|
||||
# Verify the lobby cookie was set
|
||||
cookie = response.cookies.get("mocked-cookie")
|
||||
assert cookie is not None
|
||||
assert cookie.value == "participant1"
|
||||
assert cookie.value == "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
|
||||
|
||||
# Verify response content matches expected structure and values
|
||||
assert response.json() == {
|
||||
"id": "participant1",
|
||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
"username": "user1",
|
||||
"status": "accepted",
|
||||
"color": "#123456",
|
||||
@@ -379,7 +381,7 @@ def test_allow_participant_to_enter_anonymous():
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/enter/",
|
||||
{"participant_id": "test-id", "allow_entry": True},
|
||||
{"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
@@ -394,7 +396,7 @@ def test_allow_participant_to_enter_non_owner():
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/enter/",
|
||||
{"participant_id": "test-id", "allow_entry": True},
|
||||
{"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
@@ -412,7 +414,7 @@ def test_allow_participant_to_enter_public_room():
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/enter/",
|
||||
{"participant_id": "test-id", "allow_entry": True},
|
||||
{"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
@@ -435,9 +437,9 @@ def test_allow_participant_to_enter_success(settings, allow_entry, updated_statu
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
cache.set(
|
||||
f"mocked-cache-prefix_{room.id!s}_participant1",
|
||||
f"mocked-cache-prefix_{room.id!s}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
{
|
||||
"id": "test-id",
|
||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
"status": "waiting",
|
||||
"username": "foo",
|
||||
"color": "123",
|
||||
@@ -446,13 +448,18 @@ def test_allow_participant_to_enter_success(settings, allow_entry, updated_statu
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/enter/",
|
||||
{"participant_id": "participant1", "allow_entry": allow_entry},
|
||||
{
|
||||
"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
"allow_entry": allow_entry,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "Participant was updated."}
|
||||
|
||||
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_participant1")
|
||||
participant_data = cache.get(
|
||||
f"mocked-cache-prefix_{room.id!s}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
|
||||
)
|
||||
assert participant_data.get("status") == updated_status
|
||||
|
||||
|
||||
@@ -468,12 +475,14 @@ def test_allow_participant_to_enter_participant_not_found(settings):
|
||||
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_test-id")
|
||||
participant_data = cache.get(
|
||||
f"mocked-cache-prefix_{room.id!s}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def"
|
||||
)
|
||||
assert participant_data is None
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/enter/",
|
||||
{"participant_id": "test-id", "allow_entry": True},
|
||||
{"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
@@ -563,18 +572,18 @@ def test_list_waiting_participants_success(settings):
|
||||
|
||||
# Add participants in the lobby
|
||||
cache.set(
|
||||
f"mocked-cache-prefix_{room.id}_participant1",
|
||||
f"mocked-cache-prefix_{room.id}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
{
|
||||
"id": "participant1",
|
||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
},
|
||||
)
|
||||
cache.set(
|
||||
f"mocked-cache-prefix_{room.id}_participant2",
|
||||
f"mocked-cache-prefix_{room.id}_f4ca3ab8a6c04ad88097b8da33f60f10",
|
||||
{
|
||||
"id": "participant2",
|
||||
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
|
||||
"username": "user2",
|
||||
"status": "waiting",
|
||||
"color": "#654321",
|
||||
@@ -588,13 +597,13 @@ def test_list_waiting_participants_success(settings):
|
||||
participants = response.json().get("participants")
|
||||
assert sorted(participants, key=lambda p: p["id"]) == [
|
||||
{
|
||||
"id": "participant1",
|
||||
"id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def",
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
},
|
||||
{
|
||||
"id": "participant2",
|
||||
"id": "f4ca3ab8a6c04ad88097b8da33f60f10",
|
||||
"username": "user2",
|
||||
"status": "waiting",
|
||||
"color": "#654321",
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
"""
|
||||
Test rooms API endpoints in the Meet core app: participants management.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument,protected-access
|
||||
|
||||
import random
|
||||
from unittest import mock
|
||||
from uuid import uuid4
|
||||
|
||||
from django.urls import reverse
|
||||
|
||||
import pytest
|
||||
from livekit.api import TwirpError
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||
from core.services.lobby import LobbyService
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_livekit_client():
|
||||
"""Mock LiveKit API client."""
|
||||
with mock.patch("core.utils.create_livekit_client") as mock_create:
|
||||
mock_client = mock.AsyncMock()
|
||||
mock_create.return_value = mock_client
|
||||
yield mock_client
|
||||
|
||||
|
||||
def test_mute_participant_success(mock_livekit_client):
|
||||
"""Test successful participant muting."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {"status": "success"}
|
||||
|
||||
mock_livekit_client.room.mute_published_track.assert_called_once()
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
def test_mute_participant_forbidden_without_access():
|
||||
"""Test mute participant returns 403 when user lacks room privileges."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory() # User without UserResourceAccess
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_mute_participant_invalid_payload():
|
||||
"""Test mute participant with invalid payload."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {"participant_identity": "invalid-uuid", "track_sid": ""}
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
def test_mute_participant_unexpected_twirp_error(mock_livekit_client):
|
||||
"""Test mute participant when LiveKit API raises TwirpError."""
|
||||
client = APIClient()
|
||||
|
||||
mock_livekit_client.room.mute_published_track.side_effect = TwirpError(
|
||||
msg="Internal server error", code=500, status=500
|
||||
)
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {"participant_identity": str(uuid4()), "track_sid": "test-track-sid"}
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
assert response.data == {"error": "Failed to mute participant"}
|
||||
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
def test_update_participant_success(mock_livekit_client):
|
||||
"""Test successful participant update."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {
|
||||
"participant_identity": str(uuid4()),
|
||||
"metadata": {"role": "presenter"},
|
||||
"permission": {
|
||||
"can_subscribe": True,
|
||||
"can_publish": True,
|
||||
"can_publish_data": True,
|
||||
"can_publish_sources": [
|
||||
1,
|
||||
2,
|
||||
], # [TrackSource.CAMERA, TrackSource.MICROPHONE]
|
||||
"hidden": False,
|
||||
"recorder": False,
|
||||
"can_update_metadata": True,
|
||||
"agent": False,
|
||||
"can_subscribe_metrics": False,
|
||||
},
|
||||
"name": "John Doe",
|
||||
}
|
||||
|
||||
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {"status": "success"}
|
||||
|
||||
mock_livekit_client.room.update_participant.assert_called_once()
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
def test_update_participant_forbidden_without_access():
|
||||
"""Test update participant returns 403 when user lacks room privileges."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory() # User without UserResourceAccess
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {"participant_identity": str(uuid4()), "name": "Test User"}
|
||||
|
||||
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_update_participant_invalid_payload():
|
||||
"""Test update participant with invalid payload."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {"participant_identity": "invalid-uuid"}
|
||||
|
||||
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "Must be a valid UUID." in str(response.data)
|
||||
|
||||
|
||||
def test_update_participant_no_update_fields():
|
||||
"""Test update participant with no update fields provided."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {
|
||||
"participant_identity": str(uuid4())
|
||||
# No metadata, attributes, permission, or name
|
||||
}
|
||||
|
||||
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "At least one of the following fields must be provided" in str(response.data)
|
||||
|
||||
|
||||
def test_update_participant_invalid_permission():
|
||||
"""Test update participant with wrong permission object."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {
|
||||
"participant_identity": str(uuid4()),
|
||||
"permission": {"invalid-attributes": True},
|
||||
}
|
||||
|
||||
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "Invalid permission" in str(response.data)
|
||||
|
||||
|
||||
def test_update_participant_wrong_metadata_attributes():
|
||||
"""Test update participant with wrong metadata or attributes provided."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {
|
||||
"participant_identity": str(uuid4()),
|
||||
"metadata": "wrong string",
|
||||
"attributes": "wrong string",
|
||||
}
|
||||
|
||||
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "metadata" in response.data or "attributes" in response.data
|
||||
|
||||
|
||||
def test_update_participant_unexpected_twirp_error(mock_livekit_client):
|
||||
"""Test update participant when LiveKit API raises TwirpError."""
|
||||
client = APIClient()
|
||||
|
||||
mock_livekit_client.room.update_participant.side_effect = TwirpError(
|
||||
msg="Internal server error", code=500, status=500
|
||||
)
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {"participant_identity": str(uuid4()), "name": "Test User"}
|
||||
|
||||
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
assert response.data == {"error": "Failed to update participant"}
|
||||
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
def test_remove_participant_success_lobby_cache(mock_livekit_client):
|
||||
"""Test successful participant removal.
|
||||
|
||||
The lobby cache cleanup is crucial for security - without it, removed
|
||||
participants could potentially re-enter the room using their cached
|
||||
lobby session.
|
||||
"""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
participant_identity = str(uuid4())
|
||||
|
||||
# Create participant in lobby cache first
|
||||
LobbyService().enter(room.id, participant_identity, "John doe")
|
||||
|
||||
# Accept participant
|
||||
LobbyService().handle_participant_entry(room.id, participant_identity, True)
|
||||
|
||||
payload = {"participant_identity": participant_identity}
|
||||
|
||||
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {"status": "success"}
|
||||
|
||||
mock_livekit_client.room.remove_participant.assert_called_once()
|
||||
# called twice: once for Lobby, once for ParticipantManagement
|
||||
mock_livekit_client.aclose.assert_called()
|
||||
|
||||
# Verify lobby cache was cleared - participant should no longer exist
|
||||
participant = LobbyService()._get_participant(room.id, participant_identity)
|
||||
assert participant is None
|
||||
|
||||
|
||||
def test_remove_participant_success(mock_livekit_client):
|
||||
"""Test successful participant removal."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {"participant_identity": str(uuid4())}
|
||||
|
||||
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == {"status": "success"}
|
||||
|
||||
mock_livekit_client.room.remove_participant.assert_called_once()
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
|
||||
|
||||
def test_remove_participant_forbidden_without_access():
|
||||
"""Test remove participant returns 403 when user lacks room privileges."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory() # User without UserResourceAccess
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {"participant_identity": str(uuid4())}
|
||||
|
||||
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
|
||||
def test_remove_participant_invalid_payload():
|
||||
"""Test remove participant with invalid payload."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {"participant_identity": "invalid-uuid"}
|
||||
|
||||
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
def test_remove_participant_missing_identity():
|
||||
"""Test remove participant with missing participant_identity."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {} # Missing participant_identity
|
||||
|
||||
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
|
||||
|
||||
def test_remove_participant_unexpected_twirp_error(mock_livekit_client):
|
||||
"""Test remove participant when LiveKit API raises TwirpError."""
|
||||
client = APIClient()
|
||||
|
||||
mock_livekit_client.room.remove_participant.side_effect = TwirpError(
|
||||
msg="Internal server error", code=500, status=500
|
||||
)
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
UserResourceAccessFactory(
|
||||
resource=room, user=user, role=random.choice(["administrator", "owner"])
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {"participant_identity": str(uuid4())}
|
||||
|
||||
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
assert response.data == {"error": "Failed to remove participant"}
|
||||
|
||||
mock_livekit_client.aclose.assert_called_once()
|
||||
@@ -32,6 +32,7 @@ def test_api_rooms_retrieve_anonymous_private_pk():
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
@@ -51,6 +52,7 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
@@ -69,6 +71,7 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
@@ -85,6 +88,7 @@ def test_api_rooms_retrieve_anonymous_private_slug():
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
@@ -101,6 +105,7 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
@@ -209,6 +214,7 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
|
||||
"token": "foo",
|
||||
},
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
@@ -229,7 +235,10 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
which they are not related, provided the room is public.
|
||||
They should not see related users.
|
||||
"""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
room = RoomFactory(
|
||||
access_level=RoomAccessLevel.PUBLIC,
|
||||
configuration={"can_publish_sources": ["mock-source"]},
|
||||
)
|
||||
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
@@ -251,11 +260,18 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
"token": "foo",
|
||||
},
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(
|
||||
room=expected_name, user=user, username=None, color=None
|
||||
room=expected_name,
|
||||
user=user,
|
||||
username=None,
|
||||
color=None,
|
||||
sources=["mock-source"],
|
||||
is_admin_or_owner=False,
|
||||
participant_id=None,
|
||||
)
|
||||
|
||||
|
||||
@@ -295,11 +311,18 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
||||
"token": "foo",
|
||||
},
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(
|
||||
room=expected_name, user=user, username=None, color=None
|
||||
room=expected_name,
|
||||
user=user,
|
||||
username=None,
|
||||
color=None,
|
||||
sources=None,
|
||||
is_admin_or_owner=False,
|
||||
participant_id=None,
|
||||
)
|
||||
|
||||
|
||||
@@ -324,6 +347,7 @@ def test_api_rooms_retrieve_authenticated():
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
@@ -336,23 +360,24 @@ def test_api_rooms_retrieve_authenticated():
|
||||
"url": "test_url_value",
|
||||
}
|
||||
)
|
||||
def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
|
||||
def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, settings):
|
||||
"""
|
||||
Users who are members of a room should be allowed to see related users.
|
||||
Users who are members of a room should not be allowed to see related users.
|
||||
"""
|
||||
settings.TIME_ZONE = "UTC"
|
||||
user = UserFactory()
|
||||
other_user = UserFactory()
|
||||
|
||||
room = RoomFactory()
|
||||
user_access = UserResourceAccessFactory(resource=room, user=user, role="member")
|
||||
other_user_access = UserResourceAccessFactory(
|
||||
resource=room, user=other_user, role="member"
|
||||
room = RoomFactory(
|
||||
configuration={"can_publish_sources": ["mock-source"]},
|
||||
)
|
||||
UserResourceAccessFactory(resource=room, user=user, role="member")
|
||||
UserResourceAccessFactory(resource=room, user=other_user, role="member")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
with django_assert_num_queries(4):
|
||||
with django_assert_num_queries(3):
|
||||
response = client.get(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
)
|
||||
@@ -360,33 +385,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
|
||||
assert response.status_code == 200
|
||||
content_dict = response.json()
|
||||
|
||||
assert sorted(content_dict.pop("accesses"), key=lambda x: x["id"]) == sorted(
|
||||
[
|
||||
{
|
||||
"id": str(user_access.id),
|
||||
"user": {
|
||||
"id": str(user_access.user.id),
|
||||
"email": user_access.user.email,
|
||||
"full_name": user_access.user.full_name,
|
||||
"short_name": user_access.user.short_name,
|
||||
},
|
||||
"resource": str(room.id),
|
||||
"role": user_access.role,
|
||||
},
|
||||
{
|
||||
"id": str(other_user_access.id),
|
||||
"user": {
|
||||
"id": str(other_user_access.user.id),
|
||||
"email": other_user_access.user.email,
|
||||
"full_name": other_user_access.user.full_name,
|
||||
"short_name": other_user_access.user.short_name,
|
||||
},
|
||||
"resource": str(room.id),
|
||||
"role": other_user_access.role,
|
||||
},
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
assert "accesses" not in content_dict
|
||||
|
||||
expected_name = str(room.id)
|
||||
assert content_dict == {
|
||||
@@ -399,11 +398,18 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
|
||||
"token": "foo",
|
||||
},
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(
|
||||
room=expected_name, user=user, username=None, color=None
|
||||
room=expected_name,
|
||||
user=user,
|
||||
username=None,
|
||||
color=None,
|
||||
sources=["mock-source"],
|
||||
is_admin_or_owner=False,
|
||||
participant_id=None,
|
||||
)
|
||||
|
||||
|
||||
@@ -415,11 +421,14 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
|
||||
"url": "test_url_value",
|
||||
}
|
||||
)
|
||||
def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries):
|
||||
def test_api_rooms_retrieve_administrators(
|
||||
mock_token, django_assert_num_queries, settings
|
||||
):
|
||||
"""
|
||||
A user who is an administrator or owner of a room should be allowed
|
||||
to see related users.
|
||||
"""
|
||||
settings.TIME_ZONE = "UTC"
|
||||
user = UserFactory()
|
||||
other_user = UserFactory()
|
||||
room = RoomFactory()
|
||||
@@ -448,6 +457,8 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
|
||||
"email": other_user_access.user.email,
|
||||
"full_name": other_user_access.user.full_name,
|
||||
"short_name": other_user_access.user.short_name,
|
||||
"timezone": "UTC",
|
||||
"language": other_user_access.user.language,
|
||||
},
|
||||
"resource": str(room.id),
|
||||
"role": other_user_access.role,
|
||||
@@ -459,6 +470,8 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
|
||||
"email": user_access.user.email,
|
||||
"full_name": user_access.user.full_name,
|
||||
"short_name": user_access.user.short_name,
|
||||
"timezone": "UTC",
|
||||
"language": user_access.user.language,
|
||||
},
|
||||
"resource": str(room.id),
|
||||
"role": user_access.role,
|
||||
@@ -478,9 +491,16 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
|
||||
"token": "foo",
|
||||
},
|
||||
"name": room.name,
|
||||
"pin_code": room.pin_code,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(
|
||||
room=expected_name, user=user, username=None, color=None
|
||||
room=expected_name,
|
||||
user=user,
|
||||
username=None,
|
||||
color=None,
|
||||
sources=None,
|
||||
is_admin_or_owner=True,
|
||||
participant_id=None,
|
||||
)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Test rooms API endpoints in the Meet core app: start recording.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621,W0613
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
|
||||
from unittest import mock
|
||||
|
||||
@@ -55,8 +55,9 @@ def test_start_recording_anonymous():
|
||||
assert Recording.objects.count() == 0
|
||||
|
||||
|
||||
def test_start_recording_non_owner_and_non_administrator():
|
||||
def test_start_recording_non_owner_and_non_administrator(settings):
|
||||
"""Non-owner and Non-Administrator users should not be allowed to start room recordings."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
@@ -88,8 +89,8 @@ def test_start_recording_recording_disabled(settings):
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {"detail": "Access denied, recording is disabled."}
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "Not found."}
|
||||
assert Recording.objects.count() == 0
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Test rooms API endpoints in the Meet core app: stop recording.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621,W0613
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
|
||||
from unittest import mock
|
||||
|
||||
@@ -54,8 +54,9 @@ def test_stop_recording_anonymous():
|
||||
assert Recording.objects.filter(status=RecordingStatusChoices.ACTIVE).count() == 1
|
||||
|
||||
|
||||
def test_stop_recording_non_owner_and_non_administrator():
|
||||
def test_stop_recording_non_owner_and_non_administrator(settings):
|
||||
"""Non-owner and Non-Administrator users should not be allowed to stop room recordings."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
||||
@@ -84,8 +85,8 @@ def test_stop_recording_recording_disabled(settings):
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {"detail": "Access denied, recording is disabled."}
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "Not found."}
|
||||
# Verify no recording exists
|
||||
assert Recording.objects.count() == 0
|
||||
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
"""
|
||||
Test rooms API endpoints in the Meet core app: start subtitle.
|
||||
"""
|
||||
# pylint: disable=W0621
|
||||
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import pytest
|
||||
from livekit.api import AccessToken, TwirpError, VideoGrants
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_room_id() -> str:
|
||||
"""Mock room's id."""
|
||||
return "d2aeb774-1ecd-4d73-a3ac-3d3530cad7ff"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_livekit_token(mock_room_id):
|
||||
"""Mock LiveKit JWT token."""
|
||||
|
||||
video_grants = VideoGrants(
|
||||
room=mock_room_id,
|
||||
room_join=True,
|
||||
room_admin=True,
|
||||
can_update_own_metadata=True,
|
||||
can_publish_sources=[
|
||||
"camera",
|
||||
"microphone",
|
||||
"screen_share",
|
||||
"screen_share_audio",
|
||||
],
|
||||
)
|
||||
|
||||
token = (
|
||||
AccessToken(
|
||||
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
||||
api_secret=settings.LIVEKIT_CONFIGURATION["api_secret"],
|
||||
)
|
||||
.with_grants(video_grants)
|
||||
.with_identity(str(uuid.uuid4()))
|
||||
)
|
||||
|
||||
return token.to_jwt()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_livekit_client():
|
||||
"""Mock LiveKit API client."""
|
||||
with mock.patch("core.utils.create_livekit_client") as mock_create:
|
||||
mock_client = mock.AsyncMock()
|
||||
mock_create.return_value = mock_client
|
||||
yield mock_client
|
||||
|
||||
|
||||
def test_start_subtitle_missing_token_anonymous(settings):
|
||||
"""Test that anonymous users cannot start subtitles without a valid LiveKit token."""
|
||||
|
||||
settings.ROOM_SUBTITLE_ENABLED = True
|
||||
|
||||
room = RoomFactory()
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_start_subtitle_missing_token_authenticated(settings):
|
||||
"""Test that authenticated users still need a valid LiveKit token to start subtitles."""
|
||||
|
||||
settings.ROOM_SUBTITLE_ENABLED = True
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_start_subtitle_invalid_token():
|
||||
"""Test that malformed or invalid LiveKit tokens are rejected."""
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/", {"token": "invalid-token"}
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {"detail": "Invalid LiveKit token: Not enough segments"}
|
||||
|
||||
|
||||
def test_start_subtitle_disabled_by_default(mock_livekit_token):
|
||||
"""Test that subtitle functionality is disabled when feature flag is off."""
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||
{"token": mock_livekit_token},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "Not found."}
|
||||
|
||||
|
||||
def test_start_subtitle_valid_token(
|
||||
settings, mock_livekit_client, mock_livekit_token, mock_room_id
|
||||
):
|
||||
"""Test successful subtitle initiation with valid token and enabled feature."""
|
||||
|
||||
settings.ROOM_SUBTITLE_ENABLED = True
|
||||
|
||||
room = RoomFactory(id=mock_room_id)
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||
{"token": mock_livekit_token},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "success"}
|
||||
|
||||
mock_livekit_client.agent_dispatch.create_dispatch.assert_called_once()
|
||||
|
||||
call_args = mock_livekit_client.agent_dispatch.create_dispatch.call_args[0][0]
|
||||
assert call_args.agent_name == "multi-user-transcriber"
|
||||
assert call_args.room == "d2aeb774-1ecd-4d73-a3ac-3d3530cad7ff"
|
||||
|
||||
|
||||
def test_start_subtitle_twirp_error(
|
||||
settings, mock_livekit_client, mock_livekit_token, mock_room_id
|
||||
):
|
||||
"""Test handling of LiveKit service errors during subtitle initiation."""
|
||||
|
||||
settings.ROOM_SUBTITLE_ENABLED = True
|
||||
|
||||
room = RoomFactory(id=mock_room_id)
|
||||
client = APIClient()
|
||||
|
||||
mock_livekit_client.agent_dispatch.create_dispatch.side_effect = TwirpError(
|
||||
msg="Internal server error", code=500, status=500
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||
{"token": mock_livekit_token},
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.json() == {
|
||||
"error": f"Subtitles failed to start for room {room.slug}"
|
||||
}
|
||||
|
||||
|
||||
def test_start_subtitle_wrong_room(settings, mock_livekit_token):
|
||||
"""Test that tokens are validated against the correct room ID."""
|
||||
|
||||
settings.ROOM_SUBTITLE_ENABLED = True
|
||||
|
||||
room = RoomFactory()
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||
{"token": mock_livekit_token},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
def test_start_subtitle_wrong_signature(settings, mock_livekit_token):
|
||||
"""Test that tokens signed with incorrect signature are rejected."""
|
||||
|
||||
settings.ROOM_SUBTITLE_ENABLED = True
|
||||
settings.LIVEKIT_CONFIGURATION["api_secret"] = "wrong-secret"
|
||||
|
||||
room = RoomFactory()
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-subtitle/",
|
||||
{"token": mock_livekit_token},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "Invalid LiveKit token: Signature verification failed"
|
||||
}
|
||||
@@ -2,8 +2,7 @@
|
||||
Test LiveKit webhook endpoint on the rooms API.
|
||||
"""
|
||||
|
||||
# ruff: noqa: PLR0913
|
||||
# pylint: disable=R0913,W0621,R0917,W0613
|
||||
# pylint: disable=too-many-arguments,redefined-outer-name,too-many-positional-arguments,unused-argument
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
@@ -25,7 +24,7 @@ def webhook_event_data():
|
||||
"name": "00000000-0000-0000-0000-000000000000",
|
||||
"emptyTimeout": 300,
|
||||
"creationTime": "1692627281",
|
||||
"turnPassword": "2Pvdj+/WV1xV4EkB8klJ9xkXDWY=",
|
||||
"turnPassword": "fake-turn-password",
|
||||
"enabledCodecs": [
|
||||
{"mime": "audio/opus"},
|
||||
{"mime": "video/H264"},
|
||||
@@ -144,7 +143,7 @@ def test_handled_event_type(
|
||||
|
||||
def test_unhandled_event_type(client, mock_livekit_config):
|
||||
"""Should return 200 for event types that have no handler."""
|
||||
event_data = json.dumps({"event": "room_started"})
|
||||
event_data = json.dumps({"event": "participant_joined"})
|
||||
|
||||
hash64 = base64.b64encode(hashlib.sha256(event_data.encode()).digest()).decode()
|
||||
token = api.AccessToken(
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
"""
|
||||
Test LiveKitEvents service.
|
||||
"""
|
||||
# pylint: disable=W0621,W0613, W0212
|
||||
# pylint: disable=W0621,W0613, W0212, E0611
|
||||
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from livekit.api import EgressStatus
|
||||
|
||||
from core.factories import RecordingFactory, RoomFactory
|
||||
from core.recording.services.recording_events import RecordingEventsService
|
||||
from core.services.livekit_events import (
|
||||
ActionFailedError,
|
||||
AuthenticationError,
|
||||
@@ -17,6 +20,8 @@ from core.services.livekit_events import (
|
||||
api,
|
||||
)
|
||||
from core.services.lobby import LobbyService
|
||||
from core.services.telephony import TelephonyException, TelephonyService
|
||||
from core.utils import NotificationError
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -53,45 +58,253 @@ def test_initialization(
|
||||
mock_token_verifier.assert_called_once_with(api_key, api_secret)
|
||||
mock_webhook_receiver.assert_called_once_with(mock_token_verifier.return_value)
|
||||
assert isinstance(service.lobby_service, LobbyService)
|
||||
assert isinstance(service.telephony_service, TelephonyService)
|
||||
assert isinstance(service.recording_events, RecordingEventsService)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "notification_type"),
|
||||
(
|
||||
("screen_recording", "screenRecordingLimitReached"),
|
||||
("transcript", "transcriptionLimitReached"),
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_egress_ended_success(mock_notify, mode, notification_type, service):
|
||||
"""Should successfully stop recording and notifies all participant."""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
|
||||
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||
)
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == "stopped"
|
||||
|
||||
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_egress_ended_notification_fails(mock_notify, service):
|
||||
"""Should raise ActionFailedError when notification fails but still stop recording."""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
|
||||
|
||||
mock_notify.side_effect = NotificationError("Error notifying")
|
||||
|
||||
with pytest.raises(
|
||||
ActionFailedError,
|
||||
match=r"Failed to process limit reached event for recording .+",
|
||||
):
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == "stopped"
|
||||
|
||||
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_egress_ended_recording_not_found(mock_notify, service):
|
||||
"""Should raise ActionFailedError when recording doesn't exist."""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = "worker-2"
|
||||
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
|
||||
|
||||
with pytest.raises(
|
||||
ActionFailedError, match=r"Recording with worker ID .+ does not exist"
|
||||
):
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_notify.assert_not_called()
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == "active"
|
||||
|
||||
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_egress_ended_recording_not_active(mock_notify, service):
|
||||
"""Should ignore non-active recordings."""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="failed_to_stop")
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = "worker-1"
|
||||
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
|
||||
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_notify.assert_not_called()
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == "failed_to_stop"
|
||||
|
||||
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_egress_ended_recording_not_limit_reached(mock_notify, service):
|
||||
"""Should ignore egress non-limit-reached statuses."""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="stopped")
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = "worker-1"
|
||||
mock_data.egress_info.status = EgressStatus.EGRESS_COMPLETE
|
||||
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_notify.assert_not_called()
|
||||
assert recording.status == "stopped"
|
||||
|
||||
|
||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||
def test_handle_room_finished(mock_clear_cache, service):
|
||||
"""Should clear lobby cache when room is finished."""
|
||||
|
||||
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
||||
def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
|
||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||
):
|
||||
"""Should clear lobby cache and delete telephony dispatch rule when room finishes."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
mock_room_name = uuid.uuid4()
|
||||
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(mock_room_name)
|
||||
|
||||
service._handle_room_finished(mock_data)
|
||||
|
||||
mock_delete_dispatch_rule.assert_called_once_with(mock_room_name)
|
||||
mock_clear_cache.assert_called_once_with(mock_room_name)
|
||||
|
||||
|
||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
||||
def test_handle_room_finished_skips_telephony_when_disabled(
|
||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||
):
|
||||
"""Should clear lobby cache but skip dispatch rule deletion when telephony is disabled."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = False
|
||||
mock_room_name = uuid.uuid4()
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(mock_room_name)
|
||||
|
||||
service._handle_room_finished(mock_data)
|
||||
|
||||
mock_delete_dispatch_rule.assert_not_called()
|
||||
mock_clear_cache.assert_called_once_with(mock_room_name)
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
LobbyService, "clear_room_cache", side_effect=Exception("Test error")
|
||||
)
|
||||
def test_handle_room_finished_error(mock_clear_cache, service):
|
||||
"""Should raise ActionFailedError when processing fails."""
|
||||
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
||||
def test_handle_room_finished_raises_error_when_cache_clearing_fails(
|
||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||
):
|
||||
"""Should raise ActionFailedError when lobby cache clearing fails when room finishes."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = "00000000-0000-0000-0000-000000000000"
|
||||
with pytest.raises(
|
||||
ActionFailedError, match="Failed to process room finished event"
|
||||
):
|
||||
|
||||
expected_error = (
|
||||
"Failed to clear room cache for room 00000000-0000-0000-0000-000000000000"
|
||||
)
|
||||
|
||||
with pytest.raises(ActionFailedError, match=expected_error):
|
||||
service._handle_room_finished(mock_data)
|
||||
|
||||
mock_delete_dispatch_rule.assert_called_once_with(
|
||||
uuid.UUID("00000000-0000-0000-0000-000000000000")
|
||||
)
|
||||
|
||||
def test_handle_room_finished_invalid_room_name(service):
|
||||
"""Should raise ActionFailedError when processing fails."""
|
||||
|
||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||
@mock.patch.object(
|
||||
TelephonyService,
|
||||
"delete_dispatch_rule",
|
||||
side_effect=TelephonyException("Test error"),
|
||||
)
|
||||
def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
|
||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||
):
|
||||
"""Should raise ActionFailedError when dispatch rule deletion fails when room finishes."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = "00000000-0000-0000-0000-000000000000"
|
||||
|
||||
expected_error = (
|
||||
"Failed to delete telephony dispatch rule for room "
|
||||
"00000000-0000-0000-0000-000000000000"
|
||||
)
|
||||
|
||||
with pytest.raises(ActionFailedError, match=expected_error):
|
||||
service._handle_room_finished(mock_data)
|
||||
|
||||
mock_clear_cache.assert_not_called()
|
||||
|
||||
|
||||
def test_handle_room_finished_raises_error_for_invalid_room_name(service):
|
||||
"""Should raise ActionFailedError when room name format is invalid when room finishes."""
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = "invalid"
|
||||
|
||||
with pytest.raises(
|
||||
ActionFailedError, match="Failed to process room finished event"
|
||||
):
|
||||
service._handle_room_finished(mock_data)
|
||||
|
||||
|
||||
@mock.patch.object(TelephonyService, "create_dispatch_rule")
|
||||
def test_handle_room_started_creates_dispatch_rule_successfully(
|
||||
mock_create_dispatch_rule, service, settings
|
||||
):
|
||||
"""Should create telephony dispatch rule when room starts successfully."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
room = RoomFactory()
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(room.id)
|
||||
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
mock_create_dispatch_rule.assert_called_once_with(room)
|
||||
|
||||
|
||||
@mock.patch.object(TelephonyService, "create_dispatch_rule")
|
||||
def test_handle_room_started_skips_dispatch_rule_when_telephony_disabled(
|
||||
mock_create_dispatch_rule, service, settings
|
||||
):
|
||||
"""Should skip creating telephony dispatch rule when telephony is disabled during room start."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = False
|
||||
room = RoomFactory()
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(room.id)
|
||||
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
mock_create_dispatch_rule.assert_not_called()
|
||||
|
||||
|
||||
def test_handle_room_started_raises_error_for_invalid_room_name(service):
|
||||
"""Should raise ActionFailedError when room name format is invalid when room starts."""
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = "invalid"
|
||||
|
||||
with pytest.raises(ActionFailedError, match="Failed to process room started event"):
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
|
||||
def test_handle_room_started_raises_error_for_nonexistent_room(service):
|
||||
"""Should raise ActionFailedError when a room starts that doesn't exist in the database."""
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(uuid.uuid4())
|
||||
|
||||
expected_error = f"Room with ID {mock_data.room.name} does not exist"
|
||||
|
||||
with pytest.raises(ActionFailedError, match=expected_error):
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
api.WebhookReceiver, "receive", side_effect=Exception("Invalid payload")
|
||||
)
|
||||
|
||||
@@ -5,7 +5,6 @@ Test lobby service.
|
||||
# pylint: disable=W0621,W0613, W0212, R0913
|
||||
# ruff: noqa: PLR0913
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
@@ -14,18 +13,17 @@ from django.core.cache import cache
|
||||
from django.http import HttpResponse
|
||||
|
||||
import pytest
|
||||
from livekit.api import TwirpError
|
||||
|
||||
from core.factories import RoomFactory
|
||||
from core.models import RoomAccessLevel
|
||||
from core.services.lobby import (
|
||||
LobbyNotificationError,
|
||||
LobbyParticipant,
|
||||
LobbyParticipantNotFound,
|
||||
LobbyParticipantParsingError,
|
||||
LobbyParticipantStatus,
|
||||
LobbyService,
|
||||
)
|
||||
from core.utils import NotificationError
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -146,10 +144,9 @@ def test_get_or_create_participant_id_from_cookie(lobby_service):
|
||||
assert participant_id == "existing-id"
|
||||
|
||||
|
||||
@mock.patch("uuid.uuid4")
|
||||
@mock.patch.object(uuid, "uuid4", return_value="generated-id")
|
||||
def test_get_or_create_participant_id_new(mock_uuid4, lobby_service):
|
||||
"""Test creating new participant ID when cookie is missing."""
|
||||
mock_uuid4.return_value = mock.Mock(hex="generated-id")
|
||||
request = mock.Mock()
|
||||
request.COOKIES = {}
|
||||
|
||||
@@ -268,6 +265,9 @@ def test_request_entry_public_room(
|
||||
user=request.user,
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id="test-participant-id",
|
||||
)
|
||||
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
@@ -304,6 +304,9 @@ def test_request_entry_trusted_room(
|
||||
user=request.user,
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id="test-participant-id",
|
||||
)
|
||||
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
@@ -396,6 +399,9 @@ def test_request_entry_accepted_participant(
|
||||
user=request.user,
|
||||
username=username,
|
||||
color="#123456",
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id="test-participant-id",
|
||||
)
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
@@ -414,7 +420,7 @@ def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
|
||||
# pylint: disable=R0917
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
@mock.patch("core.utils.generate_color")
|
||||
@mock.patch("core.services.lobby.LobbyService.notify_participants")
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_enter_success(
|
||||
mock_notify,
|
||||
mock_generate_color,
|
||||
@@ -443,13 +449,15 @@ def test_enter_success(
|
||||
participant.to_dict(),
|
||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||
)
|
||||
mock_notify.assert_called_once_with(room_id=room.id)
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(room.pk), notification_data={"type": "participantWaiting"}
|
||||
)
|
||||
|
||||
|
||||
# pylint: disable=R0917
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
@mock.patch("core.utils.generate_color")
|
||||
@mock.patch("core.services.lobby.LobbyService.notify_participants")
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_enter_with_notification_error(
|
||||
mock_notify,
|
||||
mock_generate_color,
|
||||
@@ -460,7 +468,7 @@ def test_enter_with_notification_error(
|
||||
):
|
||||
"""Test participant entry with notification error."""
|
||||
mock_generate_color.return_value = "#123456"
|
||||
mock_notify.side_effect = LobbyNotificationError("Error notifying")
|
||||
mock_notify.side_effect = NotificationError("Error notifying")
|
||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
@@ -776,125 +784,6 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
|
||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.LiveKitAPI")
|
||||
def test_notify_participants_success_no_room(mock_livekit_api, lobby_service):
|
||||
"""Test the notify_participants method when the LiveKit room doesn't exist yet."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
# Set up the mock LiveKitAPI and its behavior
|
||||
mock_api_instance = mock.Mock()
|
||||
mock_api_instance.room = mock.Mock()
|
||||
mock_api_instance.room.send_data = mock.AsyncMock()
|
||||
|
||||
# Create a proper response object with an empty rooms list
|
||||
class MockResponse:
|
||||
"""LiveKit API response mock with empty rooms list."""
|
||||
|
||||
rooms = []
|
||||
|
||||
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
|
||||
mock_api_instance.aclose = mock.AsyncMock()
|
||||
mock_livekit_api.return_value = mock_api_instance
|
||||
|
||||
# Act
|
||||
lobby_service.notify_participants(room.id)
|
||||
|
||||
# Assert
|
||||
# Verify the API was initialized with correct configuration
|
||||
mock_livekit_api.assert_called_once_with(**settings.LIVEKIT_CONFIGURATION)
|
||||
|
||||
# Verify that the service checked for existing rooms
|
||||
mock_api_instance.room.list_rooms.assert_called_once()
|
||||
|
||||
# Verify the send_data method was not called since no room exists
|
||||
mock_api_instance.room.send_data.assert_not_called()
|
||||
|
||||
# Verify the connection was properly closed
|
||||
mock_api_instance.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.LiveKitAPI")
|
||||
def test_notify_participants_success(mock_livekit_api, lobby_service):
|
||||
"""Test successful participant notification."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
# Set up the mock LiveKitAPI and its behavior
|
||||
mock_api_instance = mock.Mock()
|
||||
mock_api_instance.room = mock.Mock()
|
||||
mock_api_instance.room.send_data = mock.AsyncMock()
|
||||
|
||||
class MockResponse:
|
||||
"""LiveKit API response mock with non-empty rooms list."""
|
||||
|
||||
rooms = ["room-1"]
|
||||
|
||||
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
|
||||
|
||||
mock_api_instance.aclose = mock.AsyncMock()
|
||||
mock_livekit_api.return_value = mock_api_instance
|
||||
|
||||
# Call the function
|
||||
lobby_service.notify_participants(room.id)
|
||||
|
||||
# Verify the API was called correctly
|
||||
mock_livekit_api.assert_called_once_with(**settings.LIVEKIT_CONFIGURATION)
|
||||
|
||||
# Verify that the service checked for existing rooms
|
||||
mock_api_instance.room.list_rooms.assert_called_once()
|
||||
|
||||
# Verify the send_data method was called
|
||||
mock_api_instance.room.send_data.assert_called_once()
|
||||
send_data_request = mock_api_instance.room.send_data.call_args[0][0]
|
||||
assert send_data_request.room == str(room.id)
|
||||
assert (
|
||||
json.loads(send_data_request.data.decode("utf-8"))["type"]
|
||||
== settings.LOBBY_NOTIFICATION_TYPE
|
||||
)
|
||||
assert send_data_request.kind == 0 # RELIABLE mode in Livekit protocol
|
||||
|
||||
# Verify aclose was called
|
||||
mock_api_instance.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.LiveKitAPI")
|
||||
def test_notify_participants_error(mock_livekit_api, lobby_service):
|
||||
"""Test participant notification with API error."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
# Set up the mock LiveKitAPI and its behavior
|
||||
mock_api_instance = mock.Mock()
|
||||
mock_api_instance.room = mock.Mock()
|
||||
mock_api_instance.room.send_data = mock.AsyncMock(
|
||||
side_effect=TwirpError(msg="test error", code=123)
|
||||
)
|
||||
|
||||
class MockResponse:
|
||||
"""LiveKit API response mock with non-empty rooms list."""
|
||||
|
||||
rooms = ["room-1"]
|
||||
|
||||
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
|
||||
|
||||
mock_api_instance.aclose = mock.AsyncMock()
|
||||
mock_livekit_api.return_value = mock_api_instance
|
||||
|
||||
# Call the function and expect an exception
|
||||
with pytest.raises(
|
||||
LobbyNotificationError, match="Failed to notify room participants"
|
||||
):
|
||||
lobby_service.notify_participants(room.id)
|
||||
|
||||
# Verify the API was called correctly
|
||||
mock_livekit_api.assert_called_once_with(**settings.LIVEKIT_CONFIGURATION)
|
||||
|
||||
# Verify that the service checked for existing rooms
|
||||
mock_api_instance.room.list_rooms.assert_called_once()
|
||||
|
||||
# Verify send_data was called
|
||||
mock_api_instance.room.send_data.assert_called_once()
|
||||
|
||||
# Verify aclose was still called after the exception
|
||||
mock_api_instance.aclose.assert_called_once()
|
||||
|
||||
|
||||
def test_clear_room_cache(settings, lobby_service):
|
||||
"""Test clearing room cache actually removes entries from cache."""
|
||||
|
||||
@@ -950,3 +839,35 @@ def test_clear_room_empty(settings, lobby_service):
|
||||
assert cache.keys(f"test-lobby_{room_id!s}_*") == []
|
||||
lobby_service.clear_room_cache(room_id)
|
||||
assert cache.keys(f"test-lobby_{room_id!s}_*") == []
|
||||
|
||||
|
||||
def test_clear_participant_cache(lobby_service):
|
||||
"""Test clearing a specific participant entry from cache."""
|
||||
room_id = uuid.uuid4()
|
||||
participant_id = "test-participant-id"
|
||||
|
||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
|
||||
participant_data = {
|
||||
"status": "waiting",
|
||||
"username": "test-username",
|
||||
"id": participant_id,
|
||||
"color": "#123456",
|
||||
}
|
||||
cache.set(cache_key, participant_data, timeout=settings.LOBBY_WAITING_TIMEOUT)
|
||||
assert cache.get(cache_key) is not None
|
||||
|
||||
lobby_service.clear_participant_cache(room_id, participant_id)
|
||||
assert cache.get(cache_key) is None
|
||||
|
||||
|
||||
def test_clear_participant_cache_nonexistent(lobby_service):
|
||||
"""Test clearing a participant that doesn't exist in cache."""
|
||||
room_id = uuid.uuid4()
|
||||
participant_id = "nonexistent-participant"
|
||||
|
||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
|
||||
assert cache.get(cache_key) is None
|
||||
|
||||
lobby_service.clear_participant_cache(room_id, participant_id)
|
||||
|
||||
assert cache.get(cache_key) is None
|
||||
|
||||
@@ -11,6 +11,7 @@ from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
import brevo_python
|
||||
import pytest
|
||||
import urllib3
|
||||
|
||||
from core.services.marketing import (
|
||||
BrevoMarketingService,
|
||||
@@ -133,6 +134,34 @@ def test_create_contact_api_error(mock_contact_api):
|
||||
brevo_service.create_contact(valid_contact_data)
|
||||
|
||||
|
||||
@mock.patch("brevo_python.ContactsApi")
|
||||
def test_create_contact_timeout_error(mock_contact_api):
|
||||
"""Test contact creation timeout error handling."""
|
||||
|
||||
mock_api = mock_contact_api.return_value
|
||||
settings.BREVO_API_KEY = "test-api-key"
|
||||
settings.BREVO_API_CONTACT_LIST_IDS = [1, 2, 3, 4]
|
||||
settings.BREVO_API_CONTACT_ATTRIBUTES = {"source": "test"}
|
||||
|
||||
valid_contact_data = ContactData(
|
||||
email="test@example.com",
|
||||
attributes={"first_name": "Test"},
|
||||
list_ids=[1, 2],
|
||||
update_enabled=True,
|
||||
)
|
||||
|
||||
brevo_service = BrevoMarketingService()
|
||||
|
||||
mock_api.create_contact.side_effect = urllib3.exceptions.ReadTimeoutError(
|
||||
pool=mock.Mock(),
|
||||
url="https://api.brevo.com/v3/endpoint",
|
||||
message="HTTPSConnectionPool(host='api.brevo.com', port=443): Read timed out.",
|
||||
)
|
||||
|
||||
with pytest.raises(ContactCreationError, match="Failed to create contact in Brevo"):
|
||||
brevo_service.create_contact(valid_contact_data)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clear_marketing_cache():
|
||||
"""Clear marketing service cache between tests."""
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
Test subtitle service.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from core.factories import RoomFactory
|
||||
from core.services.subtitle import SubtitleService
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_livekit_client():
|
||||
"""Mock LiveKit API client."""
|
||||
with mock.patch("core.utils.create_livekit_client") as mock_create:
|
||||
mock_client = mock.AsyncMock()
|
||||
mock_create.return_value = mock_client
|
||||
yield mock_client
|
||||
|
||||
|
||||
def test_start_subtitle_settings(mock_livekit_client, settings):
|
||||
"""Test that start_subtitle uses the configured agent name from Django settings."""
|
||||
|
||||
settings.ROOM_SUBTITLE_AGENT_NAME = "fake-subtitle-agent-name"
|
||||
|
||||
room = RoomFactory(name="my room")
|
||||
SubtitleService().start_subtitle(room)
|
||||
|
||||
mock_livekit_client.agent_dispatch.create_dispatch.assert_called_once()
|
||||
|
||||
call_args = mock_livekit_client.agent_dispatch.create_dispatch.call_args[0][0]
|
||||
assert call_args.agent_name == "fake-subtitle-agent-name"
|
||||
assert call_args.room == str(room.id)
|
||||
|
||||
|
||||
def test_stop_subtitle_not_implemented():
|
||||
"""Test that stop_subtitle raises NotImplementedError."""
|
||||
|
||||
room = RoomFactory(name="my room")
|
||||
|
||||
with pytest.raises(
|
||||
NotImplementedError, match="Subtitle agent stopping not yet implemented"
|
||||
):
|
||||
SubtitleService().stop_subtitle(room)
|
||||
@@ -0,0 +1,305 @@
|
||||
"""
|
||||
Test telephony service.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0212
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit.api import TwirpError
|
||||
from livekit.protocol.sip import (
|
||||
CreateSIPDispatchRuleRequest,
|
||||
DeleteSIPDispatchRuleRequest,
|
||||
ListSIPDispatchRuleRequest,
|
||||
ListSIPDispatchRuleResponse,
|
||||
SIPDispatchRule,
|
||||
SIPDispatchRuleInfo,
|
||||
)
|
||||
|
||||
from core.factories import RoomFactory
|
||||
from core.models import RoomAccessLevel
|
||||
from core.services.telephony import TelephonyException, TelephonyService
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def create_mock_livekit_client():
|
||||
"""Factory for creating LiveKit client mock."""
|
||||
mock_api = mock.Mock()
|
||||
mock_api.sip = mock.Mock()
|
||||
mock_api.aclose = mock.AsyncMock()
|
||||
return mock_api
|
||||
|
||||
|
||||
def test_rule_name():
|
||||
"""Test rule name generation."""
|
||||
telephony_service = TelephonyService()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
rule_name = telephony_service._rule_name(room.id)
|
||||
|
||||
assert rule_name == f"SIP_{str(room.id)}"
|
||||
|
||||
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_create_dispatch_rule_success(mock_client_factory):
|
||||
"""Test successful dispatch rule creation."""
|
||||
telephony_service = TelephonyService()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_api = create_mock_livekit_client()
|
||||
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock()
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
telephony_service.create_dispatch_rule(room)
|
||||
|
||||
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
|
||||
create_request = mock_api.sip.create_sip_dispatch_rule.call_args[1]["create"]
|
||||
|
||||
assert isinstance(create_request, CreateSIPDispatchRuleRequest)
|
||||
assert create_request.name == f"SIP_{str(room.id)}"
|
||||
assert create_request.rule.dispatch_rule_direct.room_name == str(room.id)
|
||||
assert create_request.rule.dispatch_rule_direct.pin == str(room.pin_code)
|
||||
mock_api.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_create_dispatch_rule_api_failure(mock_client_factory):
|
||||
"""Test dispatch rule creation when API fails."""
|
||||
telephony_service = TelephonyService()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_api = create_mock_livekit_client()
|
||||
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock(
|
||||
side_effect=TwirpError(msg="Internal server error", code=500, status=500)
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
with pytest.raises(TelephonyException, match="Could not create dispatch rule"):
|
||||
telephony_service.create_dispatch_rule(room)
|
||||
|
||||
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
|
||||
mock_api.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_list_dispatch_rules_ids_success(mock_client_factory):
|
||||
"""Test successful listing of dispatch rule IDs."""
|
||||
telephony_service = TelephonyService()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_rules = [
|
||||
SIPDispatchRuleInfo(
|
||||
sip_dispatch_rule_id="rule-1",
|
||||
name=f"SIP_{str(room.id)}",
|
||||
rule=SIPDispatchRule(),
|
||||
),
|
||||
SIPDispatchRuleInfo(
|
||||
sip_dispatch_rule_id="rule-2", name="OTHER_RULE", rule=SIPDispatchRule()
|
||||
),
|
||||
SIPDispatchRuleInfo(
|
||||
sip_dispatch_rule_id="rule-3",
|
||||
name=f"SIP_{str(room.id)}",
|
||||
rule=SIPDispatchRule(),
|
||||
),
|
||||
]
|
||||
|
||||
mock_api = create_mock_livekit_client()
|
||||
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
|
||||
return_value=ListSIPDispatchRuleResponse(items=mock_rules)
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
|
||||
|
||||
assert len(result) == 2
|
||||
assert "rule-1" in result
|
||||
assert "rule-3" in result
|
||||
assert "rule-2" not in result
|
||||
|
||||
mock_api.sip.list_sip_dispatch_rule.assert_called_once()
|
||||
list_request = mock_api.sip.list_sip_dispatch_rule.call_args[1]["list"]
|
||||
assert isinstance(list_request, ListSIPDispatchRuleRequest)
|
||||
mock_api.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
|
||||
"""Test listing dispatch rule IDs when no rules exist."""
|
||||
telephony_service = TelephonyService()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_api = create_mock_livekit_client()
|
||||
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
|
||||
return_value=ListSIPDispatchRuleResponse(items=[])
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
|
||||
|
||||
assert result == []
|
||||
mock_api.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
|
||||
"""Test listing dispatch rule IDs when no rules match the room."""
|
||||
telephony_service = TelephonyService()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_rules = [
|
||||
SIPDispatchRuleInfo(
|
||||
sip_dispatch_rule_id="rule-1", name="OTHER_RULE_1", rule=SIPDispatchRule()
|
||||
),
|
||||
SIPDispatchRuleInfo(
|
||||
sip_dispatch_rule_id="rule-2", name="OTHER_RULE_2", rule=SIPDispatchRule()
|
||||
),
|
||||
]
|
||||
|
||||
mock_api = create_mock_livekit_client()
|
||||
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
|
||||
return_value=ListSIPDispatchRuleResponse(items=mock_rules)
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
|
||||
|
||||
assert result == []
|
||||
mock_api.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_list_dispatch_rules_ids_api_failure(mock_client_factory):
|
||||
"""Test listing dispatch rule IDs when API fails."""
|
||||
telephony_service = TelephonyService()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_api = create_mock_livekit_client()
|
||||
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
|
||||
side_effect=TwirpError(msg="Internal server error", code=500, status=500)
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
with pytest.raises(TelephonyException, match="Could not list dispatch rules"):
|
||||
async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
|
||||
|
||||
mock_api.sip.list_sip_dispatch_rule.assert_called_once()
|
||||
mock_api.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_delete_dispatch_rule_no_rules(mock_client_factory, mock_list_rules):
|
||||
"""Test deleting dispatch rules when no rules exist."""
|
||||
telephony_service = TelephonyService()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_list_rules.return_value = []
|
||||
|
||||
result = telephony_service.delete_dispatch_rule(room.id)
|
||||
|
||||
assert result is False
|
||||
mock_list_rules.assert_called_once_with(room.id)
|
||||
mock_client_factory.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
|
||||
"""Test deleting a single dispatch rule."""
|
||||
telephony_service = TelephonyService()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_list_rules.return_value = ["rule-1"]
|
||||
mock_api = create_mock_livekit_client()
|
||||
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
result = telephony_service.delete_dispatch_rule(room.id)
|
||||
|
||||
assert result is True
|
||||
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
|
||||
delete_request = mock_api.sip.delete_sip_dispatch_rule.call_args[1]["delete"]
|
||||
assert isinstance(delete_request, DeleteSIPDispatchRuleRequest)
|
||||
assert delete_request.sip_dispatch_rule_id == "rule-1"
|
||||
mock_api.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rules):
|
||||
"""Test deleting multiple dispatch rules."""
|
||||
telephony_service = TelephonyService()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
|
||||
mock_api = create_mock_livekit_client()
|
||||
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
result = telephony_service.delete_dispatch_rule(room.id)
|
||||
|
||||
assert result is True
|
||||
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 3
|
||||
|
||||
deleted_rule_ids = [
|
||||
call_args[1]["delete"].sip_dispatch_rule_id
|
||||
for call_args in mock_api.sip.delete_sip_dispatch_rule.call_args_list
|
||||
]
|
||||
assert all(
|
||||
rule_id in deleted_rule_ids for rule_id in ["rule-1", "rule-2", "rule-3"]
|
||||
)
|
||||
mock_api.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rules):
|
||||
"""Test deleting multiple dispatch rules when one deletion fails."""
|
||||
telephony_service = TelephonyService()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
|
||||
mock_api = create_mock_livekit_client()
|
||||
|
||||
call_count = 0
|
||||
|
||||
def delete_side_effect(*args, **kwargs):
|
||||
nonlocal call_count
|
||||
if call_count == 0:
|
||||
call_count += 1
|
||||
return None
|
||||
raise TwirpError(msg="Deletion failed", code=500, status=500)
|
||||
|
||||
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock(
|
||||
side_effect=delete_side_effect
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"):
|
||||
telephony_service.delete_dispatch_rule(room.id)
|
||||
|
||||
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 2
|
||||
mock_api.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules):
|
||||
"""Test deleting dispatch rules when API fails immediately."""
|
||||
telephony_service = TelephonyService()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_list_rules.return_value = ["rule-1"]
|
||||
mock_api = create_mock_livekit_client()
|
||||
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock(
|
||||
side_effect=TwirpError(msg="Internal server error", code=500, status=500)
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"):
|
||||
telephony_service.delete_dispatch_rule(room.id)
|
||||
|
||||
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
|
||||
mock_api.aclose.assert_called_once()
|
||||
@@ -103,8 +103,10 @@ def test_api_users_retrieve_me_anonymous():
|
||||
}
|
||||
|
||||
|
||||
def test_api_users_retrieve_me_authenticated():
|
||||
def test_api_users_retrieve_me_authenticated(settings):
|
||||
"""Authenticated users should be able to retrieve their own user via the "/users/me" path."""
|
||||
|
||||
settings.TIME_ZONE = "UTC"
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
@@ -121,6 +123,8 @@ def test_api_users_retrieve_me_authenticated():
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
"short_name": user.short_name,
|
||||
"language": user.language,
|
||||
"timezone": "UTC",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ from core.factories import (
|
||||
UserFactory,
|
||||
UserRecordingAccessFactory,
|
||||
)
|
||||
from core.models import Recording, RecordingStatusChoices
|
||||
from core.models import Recording, RecordingModeChoices, RecordingStatusChoices
|
||||
from core.recording.enums import FileExtension
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -216,3 +217,74 @@ def test_models_recording_worker_id_very_long():
|
||||
too_long_id = "w" * 256
|
||||
with pytest.raises(ValidationError):
|
||||
RecordingFactory(worker_id=too_long_id)
|
||||
|
||||
|
||||
# Test key property method
|
||||
|
||||
|
||||
def test_models_recording_key_for_transcript(settings):
|
||||
"""Test key property returns correct path for transcript mode."""
|
||||
|
||||
settings.RECORDING_OUTPUT_FOLDER = "/custom/path"
|
||||
|
||||
recording = RecordingFactory(mode=RecordingModeChoices.TRANSCRIPT)
|
||||
expected_path = f"/custom/path/{recording.id}.{FileExtension.OGG.value}"
|
||||
assert recording.key == expected_path
|
||||
|
||||
|
||||
def test_models_recording_key_for_screen_recording(settings):
|
||||
"""Test key property returns correct path for screen recording mode."""
|
||||
settings.RECORDING_OUTPUT_FOLDER = "/custom/path"
|
||||
|
||||
recording = RecordingFactory(mode=RecordingModeChoices.SCREEN_RECORDING)
|
||||
expected_path = f"/custom/path/{recording.id}.{FileExtension.MP4.value}"
|
||||
assert recording.key == expected_path
|
||||
|
||||
|
||||
def test_models_recording_key_for_unknown_mode(settings):
|
||||
"""Test key property uses default extension for unknown mode."""
|
||||
|
||||
settings.RECORDING_OUTPUT_FOLDER = "/custom/path"
|
||||
recording = RecordingFactory()
|
||||
# Directly set an invalid mode (bypassing validation)
|
||||
recording.mode = "unknown_mode"
|
||||
expected_path = f"/custom/path/{recording.id}.{FileExtension.MP4.value}"
|
||||
assert recording.key == expected_path
|
||||
|
||||
|
||||
# Test is_saved method
|
||||
|
||||
|
||||
def test_models_recording_is_saved_true():
|
||||
"""Test is_saved property returns True for SAVED and NOTIFICATION_SUCCEEDED status."""
|
||||
recording = RecordingFactory(status=RecordingStatusChoices.SAVED)
|
||||
assert recording.is_saved is True
|
||||
|
||||
recording = RecordingFactory(status=RecordingStatusChoices.NOTIFICATION_SUCCEEDED)
|
||||
assert recording.is_saved is True
|
||||
|
||||
|
||||
def test_models_recording_is_saved_false_active():
|
||||
"""Test is_saved property returns False for ACTIVE status."""
|
||||
recording = RecordingFactory(status=RecordingStatusChoices.ACTIVE)
|
||||
assert recording.is_saved is False
|
||||
|
||||
|
||||
def test_models_recording_is_saved_false_initiated():
|
||||
"""Test is_saved property returns False for INITIATED status."""
|
||||
recording = RecordingFactory(status=RecordingStatusChoices.INITIATED)
|
||||
assert recording.is_saved is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status",
|
||||
[
|
||||
RecordingStatusChoices.FAILED_TO_STOP,
|
||||
RecordingStatusChoices.FAILED_TO_START,
|
||||
RecordingStatusChoices.ABORTED,
|
||||
],
|
||||
)
|
||||
def test_models_recording_is_saved_false_error_states(status):
|
||||
"""Test is_saved property returns False for error statuses."""
|
||||
recording = RecordingFactory(status=status)
|
||||
assert recording.is_saved is False
|
||||
|
||||
@@ -2,6 +2,12 @@
|
||||
Unit tests for the Room model
|
||||
"""
|
||||
|
||||
# pylint: disable=W0613
|
||||
|
||||
import secrets
|
||||
from logging import Logger
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
@@ -96,7 +102,7 @@ def test_models_rooms_access_rights_none(django_assert_num_queries):
|
||||
with django_assert_num_queries(0):
|
||||
assert room.get_role(None) is None
|
||||
with django_assert_num_queries(0):
|
||||
assert room.is_administrator(None) is False
|
||||
assert room.is_administrator_or_owner(None) is False
|
||||
with django_assert_num_queries(0):
|
||||
assert room.is_owner(None) is False
|
||||
|
||||
@@ -109,7 +115,7 @@ def test_models_rooms_access_rights_anonymous(django_assert_num_queries):
|
||||
with django_assert_num_queries(0):
|
||||
assert room.get_role(user) is None
|
||||
with django_assert_num_queries(0):
|
||||
assert room.is_administrator(user) is False
|
||||
assert room.is_administrator_or_owner(user) is False
|
||||
with django_assert_num_queries(0):
|
||||
assert room.is_owner(user) is False
|
||||
|
||||
@@ -122,7 +128,7 @@ def test_models_rooms_access_rights_authenticated(django_assert_num_queries):
|
||||
with django_assert_num_queries(1):
|
||||
assert room.get_role(user) is None
|
||||
with django_assert_num_queries(1):
|
||||
assert room.is_administrator(user) is False
|
||||
assert room.is_administrator_or_owner(user) is False
|
||||
with django_assert_num_queries(1):
|
||||
assert room.is_owner(user) is False
|
||||
|
||||
@@ -135,7 +141,7 @@ def test_models_rooms_access_rights_member_direct(django_assert_num_queries):
|
||||
with django_assert_num_queries(1):
|
||||
assert room.get_role(user) == "member"
|
||||
with django_assert_num_queries(1):
|
||||
assert room.is_administrator(user) is False
|
||||
assert room.is_administrator_or_owner(user) is False
|
||||
with django_assert_num_queries(1):
|
||||
assert room.is_owner(user) is False
|
||||
|
||||
@@ -148,7 +154,7 @@ def test_models_rooms_access_rights_administrator_direct(django_assert_num_queri
|
||||
with django_assert_num_queries(1):
|
||||
assert room.get_role(user) == "administrator"
|
||||
with django_assert_num_queries(1):
|
||||
assert room.is_administrator(user) is True
|
||||
assert room.is_administrator_or_owner(user) is True
|
||||
with django_assert_num_queries(1):
|
||||
assert room.is_owner(user) is False
|
||||
|
||||
@@ -161,7 +167,7 @@ def test_models_rooms_access_rights_owner_direct(django_assert_num_queries):
|
||||
with django_assert_num_queries(1):
|
||||
assert room.get_role(user) == "owner"
|
||||
with django_assert_num_queries(1):
|
||||
assert room.is_administrator(user) is True
|
||||
assert room.is_administrator_or_owner(user) is True
|
||||
with django_assert_num_queries(1):
|
||||
assert room.is_owner(user) is True
|
||||
|
||||
@@ -175,3 +181,169 @@ def test_models_rooms_is_public_property():
|
||||
# Test non-public room
|
||||
private_room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
assert private_room.is_public is False
|
||||
|
||||
|
||||
@mock.patch.object(Room, "generate_unique_pin_code")
|
||||
def test_telephony_disabled_skips_pin_generation(
|
||||
mock_generate_unique_pin_code, settings
|
||||
):
|
||||
"""Telephony disabled should not generate pin codes."""
|
||||
|
||||
settings.ROOM_TELEPHONY_ENABLED = False
|
||||
|
||||
room = RoomFactory()
|
||||
|
||||
mock_generate_unique_pin_code.assert_not_called()
|
||||
assert room.pin_code is None
|
||||
|
||||
|
||||
def test_default_and_custom_pin_length(settings):
|
||||
"""Pin codes should be created with correct configured length."""
|
||||
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
|
||||
room = RoomFactory()
|
||||
|
||||
# Assert default value is 10 for collision reasons
|
||||
assert len(room.pin_code) == 10
|
||||
|
||||
settings.ROOM_TELEPHONY_PIN_LENGTH = 5
|
||||
|
||||
room = RoomFactory()
|
||||
|
||||
# Assert custom size
|
||||
assert len(room.pin_code) == 5
|
||||
|
||||
|
||||
def test_room_updates_preserve_pin_code(settings):
|
||||
"""Room updates should preserve existing pin code."""
|
||||
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
|
||||
room = RoomFactory()
|
||||
|
||||
# Store the original pin code to compare after updates
|
||||
previous_pin_code = room.pin_code
|
||||
|
||||
# If this method is called, it would indicate the pin is being regenerated unnecessarily
|
||||
with mock.patch.object(
|
||||
Room, "generate_unique_pin_code"
|
||||
) as mock_generate_unique_pin_code:
|
||||
# Explicitly call save to persist the changes to the room
|
||||
room.slug = "aaa-aaaa-aaa"
|
||||
room.save()
|
||||
assert room.pin_code == previous_pin_code
|
||||
mock_generate_unique_pin_code.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("is_telephony_enabled", [True, False])
|
||||
def test_manual_pin_code_updates(is_telephony_enabled, settings):
|
||||
"""Manual pin code changes should persist regardless of telephony setting."""
|
||||
|
||||
settings.ROOM_TELEPHONY_ENABLED = is_telephony_enabled
|
||||
settings.ROOM_TELEPHONY_PIN_LENGTH = 5
|
||||
|
||||
room = RoomFactory()
|
||||
assert room.pin_code != "12345"
|
||||
|
||||
room.pin_code = "12345"
|
||||
room.save()
|
||||
|
||||
assert room.pin_code == "12345"
|
||||
|
||||
# No data validation when manual updates are made
|
||||
room.pin_code = "123"
|
||||
room.save()
|
||||
|
||||
assert room.pin_code == "123"
|
||||
|
||||
|
||||
def test_pin_code_uniqueness(settings):
|
||||
"""Duplicate pin codes should raise validation error."""
|
||||
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
settings.ROOM_TELEPHONY_PIN_LENGTH = 5
|
||||
|
||||
RoomFactory(pin_code="12345")
|
||||
|
||||
with pytest.raises(ValidationError) as excinfo:
|
||||
RoomFactory(pin_code="12345")
|
||||
|
||||
assert "Room with this Room PIN code already exists." in str(excinfo.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalid_length", [0, 1, 2, 3])
|
||||
def test_pin_code_minimum_length(invalid_length, settings):
|
||||
"""Pin codes should enforce minimum length for security."""
|
||||
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
settings.ROOM_TELEPHONY_PIN_LENGTH = 4
|
||||
|
||||
# Assert no exception is raised with a valid length
|
||||
RoomFactory()
|
||||
|
||||
settings.ROOM_TELEPHONY_PIN_LENGTH = invalid_length
|
||||
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
RoomFactory()
|
||||
|
||||
assert "PIN code length must be at least 4 digits for minimal security" in str(
|
||||
excinfo.value
|
||||
)
|
||||
|
||||
|
||||
@mock.patch.object(Logger, "warning")
|
||||
@mock.patch.object(secrets, "randbelow", return_value=12345)
|
||||
def test_pin_generation_max_retries(mock_randbelow, mock_logger, settings):
|
||||
"""Pin generation should give up after max retries."""
|
||||
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
settings.ROOM_TELEPHONY_PIN_LENGTH = 5
|
||||
|
||||
RoomFactory(pin_code="12345")
|
||||
|
||||
# Assert default max retries is low, 5
|
||||
room1 = RoomFactory()
|
||||
assert mock_randbelow.call_count == 5
|
||||
assert room1.pin_code is None
|
||||
|
||||
mock_logger.assert_called_once_with(
|
||||
"Failed to generate unique PIN code of length %s after %s attempts", 5, 5
|
||||
)
|
||||
|
||||
mock_logger.reset_mock()
|
||||
mock_randbelow.reset_mock()
|
||||
settings.ROOM_TELEPHONY_PIN_MAX_RETRIES = 3
|
||||
|
||||
room2 = RoomFactory()
|
||||
assert mock_randbelow.call_count == 3
|
||||
assert room2.pin_code is None
|
||||
|
||||
mock_logger.assert_called_once_with(
|
||||
"Failed to generate unique PIN code of length %s after %s attempts", 5, 3
|
||||
)
|
||||
|
||||
|
||||
@mock.patch.object(secrets, "randbelow", return_value=12345)
|
||||
def test_pin_code_zero_padding(mock_randbelow, settings):
|
||||
"""Pin codes should be zero-padded to meet required length."""
|
||||
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
settings.ROOM_TELEPHONY_PIN_LENGTH = 10
|
||||
|
||||
room = RoomFactory()
|
||||
assert room.pin_code == "0000012345"
|
||||
|
||||
|
||||
@mock.patch.object(secrets, "randbelow", return_value=12345)
|
||||
def test_pin_generation_upper_bound(mock_randbelow, settings):
|
||||
"""Random number generator should use correct upper bound based on pin length."""
|
||||
|
||||
settings.ROOM_TELEPHONY_ENABLED = False
|
||||
|
||||
room = RoomFactory()
|
||||
|
||||
room.generate_unique_pin_code(length=5)
|
||||
|
||||
# Assert called with the right exclusive upper bound, 10^5
|
||||
mock_randbelow.assert_called_with(100000)
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
"""
|
||||
Test utils functions
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from livekit.api import TwirpError
|
||||
|
||||
from core.utils import NotificationError, create_livekit_client, notify_participants
|
||||
|
||||
|
||||
@mock.patch("asyncio.get_running_loop")
|
||||
@mock.patch("core.utils.LiveKitAPI")
|
||||
def test_create_livekit_client_ssl_enabled(
|
||||
mock_livekit_api, mock_get_running_loop, settings
|
||||
):
|
||||
"""Test LiveKitAPI client creation with SSL verification enabled."""
|
||||
mock_get_running_loop.return_value = mock.MagicMock()
|
||||
settings.LIVEKIT_VERIFY_SSL = True
|
||||
|
||||
create_livekit_client()
|
||||
|
||||
mock_livekit_api.assert_called_once_with(
|
||||
**settings.LIVEKIT_CONFIGURATION, session=None
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.utils.aiohttp.ClientSession")
|
||||
@mock.patch("asyncio.get_running_loop")
|
||||
@mock.patch("core.utils.LiveKitAPI")
|
||||
def test_create_livekit_client_ssl_disabled(
|
||||
mock_livekit_api, mock_get_running_loop, mock_client_session, settings
|
||||
):
|
||||
"""Test LiveKitAPI client creation with SSL verification disabled."""
|
||||
mock_get_running_loop.return_value = mock.MagicMock()
|
||||
mock_session_instance = mock.MagicMock()
|
||||
mock_client_session.return_value = mock_session_instance
|
||||
settings.LIVEKIT_VERIFY_SSL = False
|
||||
|
||||
create_livekit_client()
|
||||
|
||||
mock_livekit_api.assert_called_once_with(
|
||||
**settings.LIVEKIT_CONFIGURATION, session=mock_session_instance
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("asyncio.get_running_loop")
|
||||
@mock.patch("core.utils.LiveKitAPI")
|
||||
def test_create_livekit_client_custom_configuration(
|
||||
mock_livekit_api, mock_get_running_loop, settings
|
||||
):
|
||||
"""Test LiveKitAPI client creation with custom configuration."""
|
||||
settings.LIVEKIT_VERIFY_SSL = True
|
||||
|
||||
mock_get_running_loop.return_value = mock.MagicMock()
|
||||
custom_configuration = {
|
||||
"api_key": "mock_key",
|
||||
"api_secret": "mock_secret",
|
||||
"url": "http://mock-url.com",
|
||||
}
|
||||
|
||||
create_livekit_client(custom_configuration)
|
||||
|
||||
mock_livekit_api.assert_called_once_with(**custom_configuration, session=None)
|
||||
|
||||
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_notify_participants_error(mock_create_livekit_client):
|
||||
"""Test participant notification with API error."""
|
||||
|
||||
# Set up the mock LiveKitAPI and its behavior
|
||||
mock_api_instance = mock.Mock()
|
||||
mock_api_instance.room = mock.Mock()
|
||||
mock_api_instance.room.send_data = mock.AsyncMock(
|
||||
side_effect=TwirpError(msg="test error", code=123, status=123)
|
||||
)
|
||||
|
||||
class MockResponse:
|
||||
"""LiveKit API response mock with non-empty rooms list."""
|
||||
|
||||
rooms = ["room-1"]
|
||||
|
||||
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
|
||||
|
||||
mock_api_instance.aclose = mock.AsyncMock()
|
||||
mock_create_livekit_client.return_value = mock_api_instance
|
||||
|
||||
# Call the function and expect an exception
|
||||
with pytest.raises(NotificationError, match="Failed to notify room participants"):
|
||||
notify_participants(room_name="room-number-1", notification_data={"foo": "foo"})
|
||||
|
||||
# Verify that the service checked for existing rooms
|
||||
mock_api_instance.room.list_rooms.assert_called_once()
|
||||
|
||||
# Verify send_data was called
|
||||
mock_api_instance.room.send_data.assert_called_once()
|
||||
|
||||
# Verify aclose was still called after the exception
|
||||
mock_api_instance.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_notify_participants_success_no_room(mock_create_livekit_client):
|
||||
"""Test the notify_participants function when the LiveKit room doesn't exist."""
|
||||
|
||||
# Set up the mock LiveKitAPI and its behavior
|
||||
mock_api_instance = mock.Mock()
|
||||
mock_api_instance.room = mock.Mock()
|
||||
mock_api_instance.room.send_data = mock.AsyncMock()
|
||||
|
||||
# Create a proper response object with an empty rooms list
|
||||
class MockResponse:
|
||||
"""LiveKit API response mock with empty rooms list."""
|
||||
|
||||
rooms = []
|
||||
|
||||
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
|
||||
mock_api_instance.aclose = mock.AsyncMock()
|
||||
mock_create_livekit_client.return_value = mock_api_instance
|
||||
|
||||
notify_participants(room_name="room-number-1", notification_data={"foo": "foo"})
|
||||
|
||||
# Verify that the service checked for existing rooms
|
||||
mock_api_instance.room.list_rooms.assert_called_once()
|
||||
|
||||
# Verify the send_data method was not called since no room exists
|
||||
mock_api_instance.room.send_data.assert_not_called()
|
||||
|
||||
# Verify the connection was properly closed
|
||||
mock_api_instance.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_notify_participants_success(mock_create_livekit_client):
|
||||
"""Test successful participant notification."""
|
||||
|
||||
# Set up the mock LiveKitAPI and its behavior
|
||||
mock_api_instance = mock.Mock()
|
||||
mock_api_instance.room = mock.Mock()
|
||||
mock_api_instance.room.send_data = mock.AsyncMock()
|
||||
|
||||
class MockResponse:
|
||||
"""LiveKit API response mock with non-empty rooms list."""
|
||||
|
||||
rooms = ["room-1"]
|
||||
|
||||
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
|
||||
|
||||
mock_api_instance.aclose = mock.AsyncMock()
|
||||
mock_create_livekit_client.return_value = mock_api_instance
|
||||
|
||||
# Call the function
|
||||
notify_participants(room_name="room-number-1", notification_data={"foo": "foo"})
|
||||
|
||||
# Verify that the service checked for existing rooms
|
||||
mock_api_instance.room.list_rooms.assert_called_once()
|
||||
|
||||
# Verify the send_data method was called
|
||||
mock_api_instance.room.send_data.assert_called_once()
|
||||
send_data_request = mock_api_instance.room.send_data.call_args[0][0]
|
||||
assert send_data_request.room == "room-number-1"
|
||||
assert json.loads(send_data_request.data.decode("utf-8")) == {"foo": "foo"}
|
||||
assert send_data_request.kind == 0 # RELIABLE mode in Livekit protocol
|
||||
|
||||
# Verify aclose was called
|
||||
mock_api_instance.aclose.assert_called_once()
|
||||
@@ -3,10 +3,10 @@
|
||||
from django.conf import settings
|
||||
from django.urls import include, path
|
||||
|
||||
from lasuite.oidc_login.urls import urlpatterns as oidc_urls
|
||||
from rest_framework.routers import DefaultRouter
|
||||
|
||||
from core.api import get_frontend_configuration, viewsets
|
||||
from core.authentication.urls import urlpatterns as oidc_urls
|
||||
|
||||
# - Main endpoints
|
||||
router = DefaultRouter()
|
||||
|
||||