Compare commits
526 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 090bae6028 | |||
| 3e4a7058d2 | |||
| 25a4e2dfc6 | |||
| 14e83ecaff | |||
| 8bd90bd2ff | |||
| b51f127872 | |||
| 4c0230d537 | |||
| 7309df4115 | |||
| 18b2dfc497 | |||
| 3282da7c56 | |||
| 7f8a6e8685 | |||
| 4232c0a303 | |||
| a5454e48b7 | |||
| 591706f363 | |||
| 1ea84b6e6c | |||
| c0d101a326 | |||
| 64cfcb6c0f | |||
| cdd69b741a | |||
| f5a87cc210 | |||
| 4d4ddb9ee8 | |||
| 70dbf94f7b | |||
| 0ad37ee6de | |||
| ed4f7dcf6c | |||
| c54773008c | |||
| 6848321bcc | |||
| f161a5cf6a | |||
| 51270571bc | |||
| c6fdeaf1e9 | |||
| f1959cbb3a | |||
| 84cea2f658 | |||
| db1fdb9871 | |||
| a8618239d1 | |||
| 0eb283b75a | |||
| 0104cabc5e | |||
| dbfba564c5 | |||
| 4830206bb2 | |||
| e4f30f926c | |||
| fc61f58596 | |||
| e0021dbb80 | |||
| 83914f8307 | |||
| be54709598 | |||
| 5b76ea492b | |||
| 46934a84d1 | |||
| deb9ab1a1d | |||
| d0fd16d7d2 | |||
| 0b8181e5ce | |||
| 8b2365d5f9 | |||
| 2dd16d1f40 | |||
| fa9484b630 | |||
| 1e0e495cd8 | |||
| c270299179 | |||
| d9a84e5f0f | |||
| 0f64d3cf3a | |||
| d1e008a844 | |||
| 3be5a5afc6 | |||
| 31468a5e7c | |||
| b342b9d526 | |||
| f7e7c3ba22 | |||
| 3902b02691 | |||
| 7ce4390740 | |||
| 94d18cffe4 | |||
| ad0c3eea66 | |||
| 459bbf65a8 | |||
| 4b4f16e93f | |||
| 3e3f964ac5 | |||
| bcb111defb | |||
| 91a3aa4033 | |||
| 05b74365fa | |||
| 4eea4d01cd | |||
| 8caa6b7820 | |||
| e7e7bb0d09 | |||
| 819d3784f7 | |||
| d83ccb3d9d | |||
| a083c8cc3a | |||
| a299cae8e8 | |||
| 60b7a35ed1 | |||
| 39fdffd9a8 | |||
| e3779d9a6e | |||
| b74d6e72f9 | |||
| 9be826fd14 | |||
| 4fe01ae2bf | |||
| 300756b323 | |||
| 0af79912c5 | |||
| 446270c153 | |||
| e6377ce182 | |||
| a6b6cb7787 | |||
| 50146e95f4 | |||
| 35741a1bc1 | |||
| b12b14b277 | |||
| bb30f25953 | |||
| ebbdebf444 | |||
| fde10e7556 | |||
| 0dbb256e9f | |||
| 33c8a3a9e4 | |||
| dd0cb61ebc | |||
| e92f084afb | |||
| 4a53005ae3 | |||
| d7d82130d9 | |||
| 16bee00eb4 | |||
| 0f70b544ed | |||
| 2903442a67 | |||
| be884a8ca1 | |||
| fe70165076 | |||
| 290ba4274a | |||
| d2f239e6a2 | |||
| c61fc40671 | |||
| 10705ca3ac | |||
| 6b03ebb393 | |||
| 0627510f10 | |||
| f9233c8fb3 | |||
| 0b672ef8da | |||
| 0b0efa5bab | |||
| 0958206057 | |||
| 4e13ace815 | |||
| 30ee0fdce3 | |||
| 17453a6cee | |||
| c1e8ebc129 | |||
| a1dbf02f9c | |||
| c4206b4b24 | |||
| 732df0768c | |||
| 8a96b6939a | |||
| e78b8c69fe | |||
| 6e49ab3230 | |||
| 200e2d3c2f | |||
| 4ffef3f94a | |||
| 8e62f2ecd3 | |||
| d024fb1b95 | |||
| 7e49f0f661 | |||
| fb8c0fd1b5 | |||
| b07e4c58b4 | |||
| e6bbf2d3c8 | |||
| 28899a2aaf | |||
| a5bc66a921 | |||
| adb09410da | |||
| 061f12e7e2 | |||
| 24e819a533 | |||
| 5c48ac100a | |||
| 4434f0265d | |||
| 0522696842 | |||
| 8516782d79 | |||
| b06880be15 | |||
| 5de9cec688 | |||
| 7dfd86873c | |||
| 7cee409d22 | |||
| 7ddc448c88 | |||
| 6fcb69bd3c | |||
| 6aed4cb751 | |||
| f433f59d3f | |||
| fba9910705 | |||
| dcba3330f7 | |||
| 82bb5f0f8b | |||
| 0fd06ef6c0 | |||
| bd4dec6f27 | |||
| a987830fb3 | |||
| 7f09636791 | |||
| 3460ec8808 | |||
| 86cda75b39 | |||
| 7afa165013 | |||
| e11bdc6d28 | |||
| 8309545ec6 | |||
| 840033fcbc | |||
| 28ca2d6c37 | |||
| 4e77458116 | |||
| d4532eeb64 | |||
| b84628ee95 | |||
| f6f1222f47 | |||
| 7278613b20 | |||
| d370a4db10 | |||
| c1bc379744 | |||
| 5ef6359b7c | |||
| 04d76acce5 | |||
| eeb71f90bc | |||
| 5db4b09106 | |||
| 11cd85d4eb | |||
| ccbeeba68f | |||
| f3ea0fca71 | |||
| cb4acc32e5 | |||
| 60f5c8486b | |||
| ed3a26d449 | |||
| cb4c058c5d | |||
| c504b5262b | |||
| 3b3816b333 | |||
| 15e922f9df | |||
| b69f777e5a | |||
| 67d004fbda | |||
| 4449b578bd | |||
| 1f0d2ce335 | |||
| 0d1d0662ee | |||
| 0056b4cc29 | |||
| d7892cde9f | |||
| 372db49e94 | |||
| f7ed70dc9c | |||
| 427b23ca80 | |||
| 99a1efc538 | |||
| ec22abf82b | |||
| cde4b10794 | |||
| a47f1f92c4 | |||
| 0db36c788b | |||
| a84b76170d | |||
| 998382020d | |||
| 2a12715673 | |||
| 54d4330a97 | |||
| 5dcbe56e56 | |||
| 1a52221ef2 | |||
| 2f3e64b389 | |||
| 2dcaf814e1 | |||
| 583f5b8e70 | |||
| fe8fd36467 | |||
| 0370d9cad0 | |||
| 0da8aa846a | |||
| 70ed99b6c9 | |||
| 1875a394c6 | |||
| 211ba332dc | |||
| 7333c21632 | |||
| 78ebd1a8fd | |||
| 682b69fc11 | |||
| 7a73bf8fc2 | |||
| 1e934957f5 | |||
| 5a7584a3ad | |||
| fb9bf6b08e | |||
| 11b8541005 | |||
| 64607ae8d0 | |||
| c403bbc347 | |||
| 62855fe12c | |||
| f27d451eb6 | |||
| 68792d8019 | |||
| ebb8c14eeb | |||
| 15d43b8d5e | |||
| 1faae96ccd | |||
| 651cc0e5bd | |||
| 0dadd472ff | |||
| be35ee0258 | |||
| bece79f47b | |||
| a499331960 | |||
| d50d167d0a | |||
| e4c7bc0826 | |||
| b5244a5ec0 | |||
| 8cc2cc83c6 | |||
| e591d09b00 | |||
| eeb4dae12d | |||
| 271389d459 | |||
| fe6eefa1f0 | |||
| a276517278 | |||
| 9e9b9015f4 | |||
| 3f0378aada | |||
| 90c88a8bd3 | |||
| 1103902c12 | |||
| 812016d09c | |||
| cadc5c0034 | |||
| b083d837f8 | |||
| db8445f4ab | |||
| 0e5c2be445 | |||
| 0131a65509 | |||
| fb3727e73e | |||
| 3391165e4b | |||
| 0be94aa572 | |||
| b309f91095 | |||
| c50a749293 | |||
| cd9e7c8a1f | |||
| 9d0767ccfe | |||
| 021e52d9eb | |||
| 607a5fc99d | |||
| 756be17cc7 | |||
| 00fa7beebd | |||
| b9d13de591 | |||
| 7896890ffc | |||
| a2043abb51 | |||
| 64bb1b3bb5 | |||
| a1a56402d1 | |||
| 582d3774a4 | |||
| fab08cfaf8 | |||
| 550c48f29e | |||
| 0c64580abf | |||
| 0ad9b7e233 | |||
| b9bcc45cce | |||
| bcd285e368 | |||
| c0ad98eb34 | |||
| 178278235e | |||
| 11664956c7 | |||
| f020979188 | |||
| da5c8d6773 | |||
| 31051cd6c4 | |||
| c51058e6ac | |||
| 3ba913bb21 | |||
| 95116f70e8 | |||
| 5b282b62e7 | |||
| 5e74fce6e2 | |||
| 96b279b350 | |||
| fc4ad562ae | |||
| 480a8df31a | |||
| f76e7f0e51 | |||
| b9ffbd179c | |||
| a3ef1d4a26 | |||
| d11bcc5de9 | |||
| 412914cf01 | |||
| d511aedd39 | |||
| 7edf7d194b | |||
| 6324608a4a | |||
| a402c2f46f | |||
| e21858febe | |||
| 3d615fa582 | |||
| 4293444d3e | |||
| a55a4d5b5d | |||
| dd9a87a33b | |||
| 24242ef01a | |||
| b537cdfd93 | |||
| 80cc7c723f | |||
| e9210213b1 | |||
| 634f1924be | |||
| 4e175a8361 | |||
| c5ce32ef79 | |||
| aaf6b03a25 | |||
| 66bc739411 | |||
| 1b48fa256e | |||
| 053c4a40e9 | |||
| 53d732d802 | |||
| 26ca81db40 | |||
| e6e6a3bde7 | |||
| 26fdaac589 | |||
| 1fd1cb71ba | |||
| c8023573c6 | |||
| 584be7e65b | |||
| 59ec88e84a | |||
| 1b2e0ad431 | |||
| 20464a2845 | |||
| ece6284de2 | |||
| 82e994e5b1 | |||
| 85aa7a7251 | |||
| c4ececd03a | |||
| 57bba04cf3 | |||
| 11e162dbd4 | |||
| e06e9d1496 | |||
| c8cc9909ba | |||
| c218a1f7a2 | |||
| 490aaba30a | |||
| 790d37569c | |||
| 1e140a01b5 | |||
| 4ecb7202ec | |||
| 4b7419fe4a | |||
| 44d3c738d7 | |||
| 888c66a218 | |||
| 86641bd160 | |||
| 3d91af23cc | |||
| 2d0d30ce01 | |||
| 98ae9af84a | |||
| 3dda12ada6 | |||
| 8b2750c413 | |||
| c16c27fa50 | |||
| b3ef57e1b6 | |||
| b554a6a542 | |||
| 574fd6dc89 | |||
| 8d77332a0a | |||
| 0e2c805b41 | |||
| 9782eb8c7c | |||
| f6bc57ba91 | |||
| 4d5aec9a49 | |||
| 74b296aa37 | |||
| 88fadd1d61 | |||
| cac58f49d3 | |||
| 12c27eedac | |||
| 6a7ec95493 | |||
| e41656a760 | |||
| eb90c0f28c | |||
| f3c4b0ac40 | |||
| 038e6368e4 | |||
| 028f20375f | |||
| ccc23ff46a | |||
| e3b7a1f77b | |||
| 69a8eea1ce | |||
| 20493edd07 | |||
| 79f7fcab6e | |||
| ce3a6dab12 | |||
| 1b57dd0ecf | |||
| c6318910c2 | |||
| 03b3630611 | |||
| 5b8c8d493a | |||
| b12fb6bf48 | |||
| a8f64f5a36 | |||
| 6ab5b3300a | |||
| 6189e6454d | |||
| a8b2c56f4b | |||
| b472220151 | |||
| 223cee3828 | |||
| 101be503bf | |||
| 47e2d85471 | |||
| 16321b3cb0 | |||
| 144cb56cda | |||
| e6a15e36b4 | |||
| 3541af5992 | |||
| 68f0ea3639 | |||
| 925eb92c60 | |||
| d965ee7060 | |||
| aef85bb1ab | |||
| 8f59b08088 | |||
| 366998b3d6 | |||
| c8ad0ab24f | |||
| d2bbcb0f51 | |||
| 40457bd68a | |||
| c21c5f604b | |||
| 66350b8fb5 | |||
| 378cc3a651 | |||
| 28f43fb2c0 | |||
| 37eea16a50 | |||
| fe8ed43aae | |||
| 671aa68804 | |||
| 6449aaf7ea | |||
| f8fff3dbdf | |||
| 1715ec10dd | |||
| 8c7aed4b00 | |||
| 217b19e42a | |||
| bd26a0abc1 | |||
| abb708aa49 | |||
| 5d35161ae3 | |||
| 0b6f58bf9c | |||
| 79519fef26 | |||
| d7b1fbaf28 | |||
| 26bc67d1b4 | |||
| b783a8bac6 | |||
| e7dc54d6c5 | |||
| 9a07fba991 | |||
| ca3b1f0297 | |||
| 01390b12fb | |||
| df1eca7c34 | |||
| b529e9c848 | |||
| 23a2d3bcac | |||
| 55749a9565 | |||
| 8115a39538 | |||
| 2416ca1127 | |||
| 1971f594cf | |||
| fb0ee9e8f6 | |||
| b261f2ee5b | |||
| aa54075e6b | |||
| 271b598cee | |||
| fc232759fb | |||
| a992aa8898 | |||
| 1b8b91a44d | |||
| 63c6f5a8a1 | |||
| 1970a4d6b1 | |||
| d406f31bd8 | |||
| 4a011024dd | |||
| 59b23ad1b9 | |||
| 93be2881d2 | |||
| daa125edf3 | |||
| c93e770704 | |||
| 1f57adc4da | |||
| 952e6970f0 | |||
| af0746eac1 | |||
| 62492d1411 | |||
| 34e21b77b5 | |||
| 9edb9c2f57 | |||
| ee1abbed04 | |||
| ed52123733 | |||
| a7739efb70 | |||
| 72ef3c8bb0 | |||
| e9ef2bc4ae | |||
| 8587574fcd | |||
| c3617fc005 | |||
| 8f81318ecf | |||
| afd2f9a299 | |||
| fff5740b14 | |||
| 7545f94bbd | |||
| edb90327e5 | |||
| fdff5092b0 | |||
| 234116163a | |||
| 2a8027deb0 | |||
| d167490c09 | |||
| e7ea700c3d | |||
| edf19c8f1e | |||
| e6feed2086 | |||
| dbd9ac6eea | |||
| 86b03a3d47 | |||
| 2c2b4edc84 | |||
| 4be2d16483 | |||
| ccd0cb4641 | |||
| 561ea346db | |||
| 13bd195b22 | |||
| a35a4ffbec | |||
| 668523aa8b | |||
| d15fb0a19b | |||
| d6502f0739 | |||
| 504b851731 | |||
| cb6a81715a | |||
| 47c133cc64 | |||
| c0d490f549 | |||
| 41ad15e20b | |||
| 0707ac2cd4 | |||
| 5ce63d937d | |||
| 50791c945d | |||
| 1fb37f2f8e | |||
| eab64b7197 | |||
| 786cd3e4c7 | |||
| c8f96b1f22 | |||
| df5f0dbf9f | |||
| dc67d7c190 | |||
| 82208c220a | |||
| 2e081e5e1e | |||
| 57b8a15642 | |||
| b03bfe94a4 | |||
| a2774eb888 | |||
| 195e701fc4 | |||
| 88a5717022 | |||
| e17d42ebe3 | |||
| ae95a00301 | |||
| faff1c1228 | |||
| e04b8d081f | |||
| efb5ac5834 | |||
| 0cf4960969 | |||
| f11bcea3a2 | |||
| d8c8ac0811 | |||
| 9fd1af2302 | |||
| e6c292ecd7 | |||
| d6b5e9a50c | |||
| 789bce5092 | |||
| f888fc1717 | |||
| 545877febb | |||
| d2dba511e2 | |||
| 84c2986c01 | |||
| 44e5cd6ef3 | |||
| 7510d0fc2b | |||
| f50426b11a | |||
| b604235c35 | |||
| 6e20d5385f | |||
| 1c046abf5f | |||
| 3718851435 | |||
| c390499394 | |||
| 980d3c19d8 |
@@ -0,0 +1,33 @@
|
||||
name: Download Crowdin translations
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
types: [file-fully-translated]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
crowdin:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download Crowdin files
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
upload_sources: false
|
||||
upload_translations: false
|
||||
download_translations: true
|
||||
localization_branch_name: l10n_crowdin_translations
|
||||
create_pull_request: true
|
||||
pull_request_title: "New Crowdin translations"
|
||||
pull_request_body: "New Crowdin pull request with translations"
|
||||
pull_request_base_branch_name: "main"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
|
||||
CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
|
||||
CROWDIN_BASE_PATH: ${{ github.workspace }}
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
repositories: "meet,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
name: Docker Hub Workflow
|
||||
run-name: Docker Hub Workflow
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
@@ -28,7 +29,7 @@ jobs:
|
||||
repositories: "meet,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
@@ -48,9 +49,15 @@ jobs:
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
docker-build-args: '--target backend-production -f Dockerfile'
|
||||
docker-image-name: 'docker.io/lasuite/meet-backend:${{ github.sha }}'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
target: backend-production
|
||||
@@ -72,7 +79,7 @@ jobs:
|
||||
repositories: "meet,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
@@ -92,9 +99,15 @@ jobs:
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
with:
|
||||
docker-build-args: '-f src/frontend/Dockerfile --target frontend-production'
|
||||
docker-image-name: 'docker.io/lasuite/meet-frontend:${{ github.sha }}'
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./src/frontend/Dockerfile
|
||||
@@ -104,10 +117,57 @@ jobs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
build-and-push-summary:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "meet,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
-
|
||||
name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: lasuite/meet-summary
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./src/summary
|
||||
file: ./src/summary/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-backend
|
||||
- build-and-push-summary
|
||||
runs-on: ubuntu-latest
|
||||
if: |
|
||||
github.event_name != 'pull_request'
|
||||
@@ -122,7 +182,7 @@ jobs:
|
||||
repositories: "meet,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
+36
-22
@@ -14,7 +14,7 @@ jobs:
|
||||
if: github.event_name == 'pull_request' # Makes sense only for pull requests
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: show
|
||||
@@ -77,9 +77,9 @@ jobs:
|
||||
working-directory: src/backend
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v3
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
- name: Install development dependencies
|
||||
@@ -89,7 +89,7 @@ jobs:
|
||||
- name: Lint code with ruff
|
||||
run: ~/.local/bin/ruff check .
|
||||
- name: Lint code with pylint
|
||||
run: ~/.local/bin/pylint .
|
||||
run: ~/.local/bin/pylint meet demo core
|
||||
|
||||
test-back:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -122,9 +122,6 @@ jobs:
|
||||
DB_PASSWORD: pass
|
||||
DB_PORT: 5432
|
||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
AWS_S3_ENDPOINT_URL: http://localhost:9000
|
||||
AWS_S3_ACCESS_KEY_ID: impress
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
LIVEKIT_API_SECRET: secret
|
||||
LIVEKIT_API_KEY: devkey
|
||||
|
||||
@@ -145,7 +142,7 @@ jobs:
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v3
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
@@ -163,6 +160,21 @@ jobs:
|
||||
- name: Run tests
|
||||
run: ~/.local/bin/pytest -n 2
|
||||
|
||||
lint-front:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd src/frontend/ && npm ci
|
||||
|
||||
- name: Check linting
|
||||
run: cd src/frontend/ && npm run lint
|
||||
|
||||
- name: Check format
|
||||
run: cd src/frontend/ && npm run check
|
||||
|
||||
i18n-crowdin:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -176,7 +188,7 @@ jobs:
|
||||
repositories: "infrastructure,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
@@ -193,7 +205,7 @@ jobs:
|
||||
sudo apt-get install -y gettext
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v3
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
@@ -208,22 +220,24 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "18.x"
|
||||
cache: "yarn"
|
||||
cache-dependency-path: src/frontend/yarn.lock
|
||||
cache: "npm"
|
||||
cache-dependency-path: src/frontend/package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: cd src/frontend/ && yarn install --frozen-lockfile
|
||||
run: cd src/frontend/ && npm ci
|
||||
|
||||
- name: Extract the frontend translation
|
||||
run: make frontend-i18n-extract
|
||||
|
||||
- name: Upload files to Crowdin
|
||||
run: |
|
||||
docker run \
|
||||
--rm \
|
||||
-e CROWDIN_API_TOKEN=$CROWDIN_API_TOKEN \
|
||||
-e CROWDIN_PROJECT_ID=$CROWDIN_PROJECT_ID \
|
||||
-e CROWDIN_BASE_PATH=$CROWDIN_BASE_PATH \
|
||||
-v "${{ github.workspace }}:/app" \
|
||||
crowdin/cli:3.16.0 \
|
||||
crowdin upload sources -c /app/crowdin/config.yml
|
||||
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 }}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Release Chart
|
||||
run-name: Release Chart
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
push:
|
||||
paths:
|
||||
- ./src/helm/meet/**
|
||||
|
||||
jobs:
|
||||
release:
|
||||
if: github.event_name == 'push'
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Cleanup
|
||||
run: rm -rf ./src/helm/extra
|
||||
|
||||
- name: Install Helm
|
||||
uses: azure/setup-helm@v4
|
||||
env:
|
||||
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||
|
||||
- name: Publish Helm charts
|
||||
uses: numerique-gouv/helm-gh-pages@add-overwrite-option
|
||||
with:
|
||||
charts_dir: ./src/helm
|
||||
linting: on
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -50,9 +50,6 @@ node_modules
|
||||
# Mails
|
||||
src/backend/core/templates/mail/
|
||||
|
||||
# Typescript client
|
||||
src/frontend/tsclient
|
||||
|
||||
# Swagger
|
||||
**/swagger.json
|
||||
|
||||
|
||||
@@ -62,12 +62,17 @@ words=wip
|
||||
# For example, use the following regex if you only want to allow email addresses from foo.com
|
||||
# regex=[^@]+@foo.com
|
||||
|
||||
[ignore-by-title]
|
||||
[ignore-by-title:bots]
|
||||
# Allow empty body & wrong title pattern only when bots (pyup/greenkeeper)
|
||||
# upgrade dependencies
|
||||
regex=^(⬆️.*|Update (.*) from (.*) to (.*)|(chore|fix)\(package\): update .*)$
|
||||
ignore=B6,UC1
|
||||
|
||||
[ignore-by-title:releases]
|
||||
# Allow empty body for release commits
|
||||
regex=^🔖.*$
|
||||
ignore=B6
|
||||
|
||||
# [ignore-by-body]
|
||||
# Ignore certain rules for commits of which the body has a line that matches a regex
|
||||
# E.g. Match bodies that have a line that that contain "release"
|
||||
|
||||
@@ -8,3 +8,5 @@ creation_rules:
|
||||
- age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa #marie
|
||||
- age1qy04neuzwpasmvljqrcvhwnf0kz5cpyteze38c8avp0czewskasszv9pyw #argocd
|
||||
- age18fgn6j2vwwswqcpv9xpcehq8mrf9zs2sglwkamp3tzwx8d9jq9jsrskrk9 #manuuu
|
||||
- age1hm2hsfgjezpsc3k0y5w5feq9t8vl3seq04qjhgt6ztd6403wfvpsgxu09m # github-repo
|
||||
- age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r # Samuel Paccoud
|
||||
|
||||
+22
-31
@@ -1,18 +1,17 @@
|
||||
# Django Meet
|
||||
|
||||
# ---- base image to inherit from ----
|
||||
FROM python:3.10-slim-bullseye as base
|
||||
FROM python:3.12.6-alpine3.20 AS base
|
||||
|
||||
# Upgrade pip to its latest release to speed up dependencies installation
|
||||
RUN python -m pip install --upgrade pip
|
||||
RUN python -m pip install --upgrade pip setuptools
|
||||
|
||||
# Upgrade system packages to install security updates
|
||||
RUN apt-get update && \
|
||||
apt-get -y upgrade && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
RUN apk update && \
|
||||
apk upgrade
|
||||
|
||||
# ---- Back-end builder image ----
|
||||
FROM base as back-builder
|
||||
FROM base AS back-builder
|
||||
|
||||
WORKDIR /builder
|
||||
|
||||
@@ -24,7 +23,7 @@ RUN mkdir /install && \
|
||||
|
||||
|
||||
# ---- mails ----
|
||||
FROM node:20 as mail-builder
|
||||
FROM node:20 AS mail-builder
|
||||
|
||||
COPY ./src/mail /mail/app
|
||||
|
||||
@@ -35,15 +34,12 @@ RUN yarn install --frozen-lockfile && \
|
||||
|
||||
|
||||
# ---- static link collector ----
|
||||
FROM base as link-collector
|
||||
FROM base AS link-collector
|
||||
ARG MEET_STATIC_ROOT=/data/static
|
||||
|
||||
# Install libpangocairo & rdfind
|
||||
RUN apt-get update && \
|
||||
apt-get install -y \
|
||||
libpangocairo-1.0-0 \
|
||||
rdfind && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
RUN apk add \
|
||||
pango \
|
||||
rdfind
|
||||
|
||||
# Copy installed python dependencies
|
||||
COPY --from=back-builder /install /usr/local
|
||||
@@ -62,21 +58,18 @@ RUN DJANGO_CONFIGURATION=Build DJANGO_JWT_PRIVATE_SIGNING_KEY=Dummy \
|
||||
RUN rdfind -makesymlinks true -followsymlinks true -makeresultsfile false ${MEET_STATIC_ROOT}
|
||||
|
||||
# ---- Core application image ----
|
||||
FROM base as core
|
||||
FROM base AS core
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
# Install required system libs
|
||||
RUN apt-get update && \
|
||||
apt-get install -y \
|
||||
gettext \
|
||||
libcairo2 \
|
||||
libffi-dev \
|
||||
libgdk-pixbuf2.0-0 \
|
||||
libpango-1.0-0 \
|
||||
libpangocairo-1.0-0 \
|
||||
shared-mime-info && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
RUN apk add \
|
||||
gettext \
|
||||
cairo \
|
||||
libffi-dev \
|
||||
gdk-pixbuf \
|
||||
pango \
|
||||
shared-mime-info
|
||||
|
||||
|
||||
# Copy entrypoint
|
||||
COPY ./docker/files/usr/local/bin/entrypoint /usr/local/bin/entrypoint
|
||||
@@ -100,15 +93,13 @@ WORKDIR /app
|
||||
ENTRYPOINT [ "/usr/local/bin/entrypoint" ]
|
||||
|
||||
# ---- Development image ----
|
||||
FROM core as backend-development
|
||||
FROM core AS backend-development
|
||||
|
||||
# Switch back to the root user to install development dependencies
|
||||
USER root:root
|
||||
|
||||
# Install psql
|
||||
RUN apt-get update && \
|
||||
apt-get install -y postgresql-client && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
RUN apk add postgresql-client
|
||||
|
||||
# Uninstall Meet and re-install it in editable mode along with development
|
||||
# dependencies
|
||||
@@ -128,7 +119,7 @@ ENV DB_HOST=postgresql \
|
||||
CMD ["python", "manage.py", "runserver", "0.0.0.0:8000"]
|
||||
|
||||
# ---- Production image ----
|
||||
FROM core as backend-production
|
||||
FROM core AS backend-production
|
||||
|
||||
ARG MEET_STATIC_ROOT=/data/static
|
||||
|
||||
|
||||
@@ -48,8 +48,7 @@ WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT
|
||||
|
||||
# -- Backend
|
||||
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
||||
MAIL_YARN = $(COMPOSE_RUN) -w /app/src/mail node yarn # FIXME : use npm
|
||||
TSCLIENT_YARN = $(COMPOSE_RUN) -w /app/src/tsclient node yarn # FIXME : use npm
|
||||
MAIL_NPM = $(COMPOSE_RUN) -w /app/src/mail node npm
|
||||
|
||||
# -- Frontend
|
||||
PATH_FRONT = ./src/frontend
|
||||
@@ -142,7 +141,7 @@ lint-ruff-check: ## lint back-end python sources with ruff
|
||||
|
||||
lint-pylint: ## lint back-end python sources with pylint only on changed files from main
|
||||
@echo 'lint:pylint started…'
|
||||
bin/pylint --diff-only=origin/main
|
||||
@$(COMPOSE_RUN_APP) pylint meet demo core
|
||||
.PHONY: lint-pylint
|
||||
|
||||
test: ## run project tests
|
||||
@@ -217,7 +216,7 @@ env.d/development/kc_postgresql:
|
||||
env.d/development/crowdin:
|
||||
cp -n env.d/development/crowdin.dist env.d/development/crowdin
|
||||
|
||||
crowdin-download: ## Download translated message from crowdin
|
||||
crowdin-download: ## Download translated message from Crowdin
|
||||
@$(COMPOSE_RUN_CROWDIN) download -c crowdin/config.yml
|
||||
.PHONY: crowdin-download
|
||||
|
||||
@@ -225,14 +224,17 @@ crowdin-download-sources: ## Download sources from Crowdin
|
||||
@$(COMPOSE_RUN_CROWDIN) download sources -c crowdin/config.yml
|
||||
.PHONY: crowdin-download-sources
|
||||
|
||||
crowdin-upload: ## Upload source translations to crowdin
|
||||
crowdin-upload: ## Upload source translations to Crowdin
|
||||
@$(COMPOSE_RUN_CROWDIN) upload sources -c crowdin/config.yml
|
||||
.PHONY: crowdin-upload
|
||||
|
||||
crowdin-upload-translations: ## Upload translations to Crowdin
|
||||
@$(COMPOSE_RUN_CROWDIN) upload translations -c crowdin/config.yml
|
||||
.PHONY: crowdin-upload-translations
|
||||
|
||||
i18n-compile: ## compile all translations
|
||||
i18n-compile: \
|
||||
back-i18n-compile \
|
||||
frontend-i18n-compile
|
||||
back-i18n-compile
|
||||
.PHONY: i18n-compile
|
||||
|
||||
i18n-generate: ## create the .pot files and extract frontend messages
|
||||
@@ -257,32 +259,21 @@ i18n-generate-and-upload: \
|
||||
# -- Mail generator
|
||||
|
||||
mails-build: ## Convert mjml files to html and text
|
||||
@$(MAIL_YARN) build
|
||||
@$(MAIL_NPM) run build
|
||||
.PHONY: mails-build
|
||||
|
||||
mails-build-html-to-plain-text: ## Convert html files to text
|
||||
@$(MAIL_YARN) build-html-to-plain-text
|
||||
@$(MAIL_NPM) run build-html-to-plain-text
|
||||
.PHONY: mails-build-html-to-plain-text
|
||||
|
||||
mails-build-mjml-to-html: ## Convert mjml files to html and text
|
||||
@$(MAIL_YARN) build-mjml-to-html
|
||||
@$(MAIL_NPM) run build-mjml-to-html
|
||||
.PHONY: mails-build-mjml-to-html
|
||||
|
||||
mails-install: ## install the mail generator
|
||||
@$(MAIL_YARN) install
|
||||
@$(MAIL_NPM) install
|
||||
.PHONY: mails-install
|
||||
|
||||
# -- TS client generator
|
||||
|
||||
# FIXME : adapt this command
|
||||
tsclient-install: ## Install the Typescript API client generator
|
||||
@$(TSCLIENT_YARN) install
|
||||
.PHONY: tsclient-install
|
||||
|
||||
# FIXME : adapt this command
|
||||
tsclient: tsclient-install ## Generate a Typescript API client
|
||||
@$(TSCLIENT_YARN) generate:api:client:local ../frontend/tsclient
|
||||
.PHONY: tsclient-install
|
||||
|
||||
# -- Misc
|
||||
clean: ## restore repository state as it was freshly cloned
|
||||
@@ -295,29 +286,55 @@ help:
|
||||
@grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(firstword $(MAKEFILE_LIST)) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "$(GREEN)%-30s$(RESET) %s\n", $$1, $$2}'
|
||||
.PHONY: help
|
||||
|
||||
# FIXME : adapt this command
|
||||
frontend-i18n-extract: ## Extract the frontend translation inside a json to be used for crowdin
|
||||
cd $(PATH_FRONT) && yarn i18n:extract
|
||||
frontend-i18n-extract: ## Check the frontend code and generate missing translations keys in translation files
|
||||
cd $(PATH_FRONT) && npm run i18n:extract
|
||||
.PHONY: frontend-i18n-extract
|
||||
|
||||
# FIXME : adapt this command
|
||||
frontend-i18n-generate: ## Generate the frontend json files used for crowdin
|
||||
frontend-i18n-generate: ## Generate the frontend json files used for Crowdin
|
||||
frontend-i18n-generate: \
|
||||
crowdin-download-sources \
|
||||
frontend-i18n-extract
|
||||
.PHONY: frontend-i18n-generate
|
||||
|
||||
# FIXME : adapt this command
|
||||
frontend-i18n-compile: ## Format the crowin json files used deploy to the apps
|
||||
cd $(PATH_FRONT) && yarn i18n:deploy
|
||||
.PHONY: frontend-i18n-compile
|
||||
|
||||
# -- K8S
|
||||
build-k8s-cluster: ## build the kubernetes cluster using kind
|
||||
./bin/start-kind.sh
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
install-secret: ## install the kubernetes secrets from Vaultwarden
|
||||
if kubectl -n meet get secrets bitwarden-cli-visio; then \
|
||||
echo "Secret already present"; \
|
||||
else \
|
||||
echo "Please provide the following information:"; \
|
||||
read -p "Enter your vaultwarden email login: " LOGIN; \
|
||||
read -p "Enter your vaultwarden password: " PASSWORD; \
|
||||
read -p "Enter your vaultwarden server url: " URL; \
|
||||
echo "\nCreate vaultwarden secret"; \
|
||||
echo "apiVersion: v1" > /tmp/secret.yaml; \
|
||||
echo "kind: Secret" >> /tmp/secret.yaml; \
|
||||
echo "metadata:" >> /tmp/secret.yaml; \
|
||||
echo " name: bitwarden-cli-visio" >> /tmp/secret.yaml; \
|
||||
echo " namespace: meet" >> /tmp/secret.yaml; \
|
||||
echo "type: Opaque" >> /tmp/secret.yaml; \
|
||||
echo "stringData:" >> /tmp/secret.yaml; \
|
||||
echo " BW_HOST: $$URL" >> /tmp/secret.yaml; \
|
||||
echo " BW_PASSWORD: $$PASSWORD" >> /tmp/secret.yaml; \
|
||||
echo " BW_USERNAME: $$LOGIN" >> /tmp/secret.yaml; \
|
||||
kubectl -n meet apply -f /tmp/secret.yaml;\
|
||||
rm -f /tmp/secret.yaml; \
|
||||
helm repo add external-secrets https://charts.external-secrets.io; \
|
||||
helm upgrade --install external-secrets \
|
||||
external-secrets/external-secrets \
|
||||
-n meet \
|
||||
--create-namespace \
|
||||
--set installCRDs=true; \
|
||||
fi
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
start-tilt: ## start the kubernetes cluster using kind
|
||||
tilt up -f ./bin/Tiltfile
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
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
|
||||
|
||||
@@ -94,6 +94,58 @@ You first need to create a superuser account:
|
||||
$ make superuser
|
||||
```
|
||||
|
||||
### Run application on local Kubernetes
|
||||
|
||||
The application is deployed across staging, preprod, and production environments using Kubernetes (K8s).
|
||||
Reproducing environment conditions locally is crucial for developing new features or debugging issues.
|
||||
|
||||
This is facilitated by [Tilt](https://tilt.dev/) ("Kubernetes for Prod, Tilt for Dev"). Tilt enables smart rebuilds and live updates for services running locally in Kubernetes. We defined our services in a Tiltfile located at `bin/Tiltfile`.
|
||||
|
||||
|
||||
#### Getting Started
|
||||
|
||||
Make sure you have installed:
|
||||
- kubectl
|
||||
- helm
|
||||
- helmfile
|
||||
- tilt
|
||||
|
||||
To build and start the Kubernetes cluster using Kind:
|
||||
```shell
|
||||
$ make build-k8s-cluster
|
||||
```
|
||||
|
||||
Once the Kubernetes cluster is ready, start the application stack locally:
|
||||
```shell
|
||||
$ make start-tilt
|
||||
or
|
||||
$ make start-tilt-keycloak # start stack without Pro Connect, use keycloak
|
||||
```
|
||||
These commands set up and run your application environment using Tilt for local Kubernetes development.
|
||||
|
||||
You can monitor Tilt's at `http://localhost:10350/`. After Tilt actions finish, you can access the app at `https://meet.127.0.0.1.nip.io/`.
|
||||
|
||||
#### Debugging frontend
|
||||
|
||||
Tilt deploys the `meet-dev` for the frontend by default, to benefit from Vite.js hot reloading while developing.
|
||||
To troubleshoot production issues, please modify the Tiltfile, switch frontend's target to `frontend-production`:
|
||||
|
||||
```yaml
|
||||
...
|
||||
|
||||
docker_build(
|
||||
'localhost:5001/meet-frontend:latest',
|
||||
context='..',
|
||||
dockerfile='../src/frontend/Dockerfile',
|
||||
only=['./src/frontend', './docker', './.dockerignore'],
|
||||
target='frontend-production', # Update this line when needed
|
||||
live_update=[
|
||||
sync('../src/frontend', '/home/frontend'),
|
||||
]
|
||||
)
|
||||
...
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
This project is intended to be community-driven, so please, do not hesitate to
|
||||
|
||||
+12
-2
@@ -1,7 +1,6 @@
|
||||
load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
|
||||
load('ext://namespace', 'namespace_create', 'namespace_inject')
|
||||
namespace_create('meet')
|
||||
|
||||
docker_build(
|
||||
'localhost:5001/meet-backend:latest',
|
||||
context='..',
|
||||
@@ -28,7 +27,18 @@ docker_build(
|
||||
]
|
||||
)
|
||||
|
||||
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e dev template .'))
|
||||
docker_build(
|
||||
'localhost:5001/meet-summary:latest',
|
||||
context='../src/summary',
|
||||
dockerfile='../src/summary/Dockerfile',
|
||||
only=['.', '../../docker', '../../.dockerignore'],
|
||||
target = 'production',
|
||||
live_update=[
|
||||
sync('../src/summary', '/home/summary'),
|
||||
]
|
||||
)
|
||||
|
||||
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .'))
|
||||
|
||||
migration = '''
|
||||
set -eu
|
||||
|
||||
+1
-65
@@ -5,8 +5,7 @@ set -eo pipefail
|
||||
REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
|
||||
UNSET_USER=0
|
||||
|
||||
TERRAFORM_DIRECTORY="./env.d/terraform"
|
||||
COMPOSE_FILE="${REPO_DIR}/docker-compose.yml"
|
||||
COMPOSE_FILE="${REPO_DIR}/compose.yml"
|
||||
COMPOSE_PROJECT="meet"
|
||||
|
||||
|
||||
@@ -92,66 +91,3 @@ function _dc_exec() {
|
||||
function _django_manage() {
|
||||
_dc_run "app-dev" python manage.py "$@"
|
||||
}
|
||||
|
||||
# _set_openstack_project: select an OpenStack project from the openrc files defined in the
|
||||
# terraform directory.
|
||||
#
|
||||
# usage: _set_openstack_project
|
||||
#
|
||||
# If necessary the script will prompt the user to choose a project from those available
|
||||
function _set_openstack_project() {
|
||||
|
||||
declare prompt
|
||||
declare -a projects
|
||||
declare -i default=1
|
||||
declare -i choice=0
|
||||
declare -i n_projects
|
||||
|
||||
# List projects by looking in the "./env.d/terraform" directory
|
||||
# and store them in an array
|
||||
read -r -a projects <<< "$(
|
||||
find "${TERRAFORM_DIRECTORY}" -maxdepth 1 -mindepth 1 -type d |
|
||||
sed 's|'"${TERRAFORM_DIRECTORY}\/"'||' |
|
||||
xargs
|
||||
)"
|
||||
nb_projects=${#projects[@]}
|
||||
|
||||
if [[ ${nb_projects} -le 0 ]]; then
|
||||
echo "There are no OpenStack projects defined..." >&2
|
||||
echo "To add one, create a subdirectory in \"${TERRAFORM_DIRECTORY}\" with the name" \
|
||||
"of your project and copy your \"openrc.sh\" file into it." >&2
|
||||
exit 10
|
||||
fi
|
||||
|
||||
if [[ ${nb_projects} -gt 1 ]]; then
|
||||
prompt="Select an OpenStack project to target:\\n"
|
||||
for (( i=0; i<nb_projects; i++ )); do
|
||||
prompt+="[$((i+1))] ${projects[$i]}"
|
||||
if [[ $((i+1)) -eq ${default} ]]; then
|
||||
prompt+=" (default)"
|
||||
fi
|
||||
prompt+="\\n"
|
||||
done
|
||||
prompt+="If your OpenStack project is not listed, add it to the \"env.d/terraform\" directory.\\n"
|
||||
prompt+="Your choice: "
|
||||
read -r -p "$(echo -e "${prompt}")" choice
|
||||
|
||||
if [[ ${choice} -gt nb_projects ]]; then
|
||||
(>&2 echo "Invalid choice ${choice} (should be <= ${nb_projects})")
|
||||
exit 11
|
||||
fi
|
||||
|
||||
if [[ ${choice} -le 0 ]]; then
|
||||
choice=${default}
|
||||
fi
|
||||
fi
|
||||
|
||||
project=${projects[$((choice-1))]}
|
||||
# Check that the openrc.sh file exists for this project
|
||||
if [ ! -f "${TERRAFORM_DIRECTORY}/${project}/openrc.sh" ]; then
|
||||
(>&2 echo "Missing \"openrc.sh\" file in \"${TERRAFORM_DIRECTORY}/${project}\". Check documentation.")
|
||||
exit 12
|
||||
fi
|
||||
|
||||
echo "${project}"
|
||||
}
|
||||
|
||||
-38
@@ -1,38 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# shellcheck source=bin/_config.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
declare diff_from
|
||||
declare -a paths
|
||||
declare -a args
|
||||
|
||||
# Parse options
|
||||
for arg in "$@"
|
||||
do
|
||||
case $arg in
|
||||
--diff-only=*)
|
||||
diff_from="${arg#*=}"
|
||||
shift
|
||||
;;
|
||||
-*)
|
||||
args+=("$arg")
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
paths+=("$arg")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -n "${diff_from}" ]]; then
|
||||
# Run pylint only on modified files located in src/backend
|
||||
# (excluding deleted files and migration files)
|
||||
# shellcheck disable=SC2207
|
||||
paths=($(git diff "${diff_from}" --name-only --diff-filter=d -- src/backend ':!**/migrations/*.py' | grep -E '^src/backend/.*\.py$'))
|
||||
fi
|
||||
|
||||
# Fix docker vs local path when project sources are mounted as a volume
|
||||
read -ra paths <<< "$(echo "${paths[@]}" | sed "s|src/backend/||g")"
|
||||
_dc_run app-dev pylint "${paths[@]}" "${args[@]}"
|
||||
+2
-102
@@ -1,103 +1,3 @@
|
||||
#!/bin/sh
|
||||
set -o errexit
|
||||
#!/bin/bash
|
||||
|
||||
CURRENT_DIR=$(pwd)
|
||||
|
||||
echo "0. Create ca"
|
||||
# 0. Create ca
|
||||
mkcert -install
|
||||
cd /tmp
|
||||
mkcert "127.0.0.1.nip.io" "*.127.0.0.1.nip.io"
|
||||
cd $CURRENT_DIR
|
||||
|
||||
echo "1. Create registry container unless it already exists"
|
||||
# 1. Create registry container unless it already exists
|
||||
reg_name='kind-registry'
|
||||
reg_port='5001'
|
||||
if [ "$(docker inspect -f '{{.State.Running}}' "${reg_name}" 2>/dev/null || true)" != 'true' ]; then
|
||||
docker run \
|
||||
-d --restart=always -p "127.0.0.1:${reg_port}:5000" --network bridge --name "${reg_name}" \
|
||||
registry:2
|
||||
fi
|
||||
|
||||
echo "2. Create kind cluster with containerd registry config dir enabled"
|
||||
# 2. Create kind cluster with containerd registry config dir enabled
|
||||
# TODO: kind will eventually enable this by default and this patch will
|
||||
# be unnecessary.
|
||||
#
|
||||
# See:
|
||||
# https://github.com/kubernetes-sigs/kind/issues/2875
|
||||
# https://github.com/containerd/containerd/blob/main/docs/cri/config.md#registry-configuration
|
||||
# See: https://github.com/containerd/containerd/blob/main/docs/hosts.md
|
||||
cat <<EOF | kind create cluster --config=-
|
||||
kind: Cluster
|
||||
apiVersion: kind.x-k8s.io/v1alpha4
|
||||
containerdConfigPatches:
|
||||
- |-
|
||||
[plugins."io.containerd.grpc.v1.cri".registry]
|
||||
config_path = "/etc/containerd/certs.d"
|
||||
nodes:
|
||||
- role: control-plane
|
||||
image: kindest/node:v1.27.3
|
||||
kubeadmConfigPatches:
|
||||
- |
|
||||
kind: InitConfiguration
|
||||
nodeRegistration:
|
||||
kubeletExtraArgs:
|
||||
node-labels: "ingress-ready=true"
|
||||
extraPortMappings:
|
||||
- containerPort: 80
|
||||
hostPort: 80
|
||||
protocol: TCP
|
||||
- containerPort: 443
|
||||
hostPort: 443
|
||||
protocol: TCP
|
||||
- role: worker
|
||||
image: kindest/node:v1.27.3
|
||||
- role: worker
|
||||
image: kindest/node:v1.27.3
|
||||
EOF
|
||||
|
||||
echo "3. Add the registry config to the nodes"
|
||||
# 3. Add the registry config to the nodes
|
||||
#
|
||||
# This is necessary because localhost resolves to loopback addresses that are
|
||||
# network-namespace local.
|
||||
# In other words: localhost in the container is not localhost on the host.
|
||||
#
|
||||
# We want a consistent name that works from both ends, so we tell containerd to
|
||||
# alias localhost:${reg_port} to the registry container when pulling images
|
||||
REGISTRY_DIR="/etc/containerd/certs.d/localhost:${reg_port}"
|
||||
for node in $(kind get nodes); do
|
||||
docker exec "${node}" mkdir -p "${REGISTRY_DIR}"
|
||||
cat <<EOF | docker exec -i "${node}" cp /dev/stdin "${REGISTRY_DIR}/hosts.toml"
|
||||
[host."http://${reg_name}:5000"]
|
||||
EOF
|
||||
done
|
||||
|
||||
echo "4. Connect the registry to the cluster network if not already connected"
|
||||
# 4. Connect the registry to the cluster network if not already connected
|
||||
# This allows kind to bootstrap the network but ensures they're on the same network
|
||||
if [ "$(docker inspect -f='{{json .NetworkSettings.Networks.kind}}' "${reg_name}")" = 'null' ]; then
|
||||
docker network connect "kind" "${reg_name}"
|
||||
fi
|
||||
|
||||
echo "5. Document the local registry"
|
||||
# 5. Document the local registry
|
||||
# https://github.com/kubernetes/enhancements/tree/master/keps/sig-cluster-lifecycle/generic/1755-communicating-a-local-registry
|
||||
cat <<EOF | kubectl apply -f -
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: local-registry-hosting
|
||||
namespace: kube-public
|
||||
data:
|
||||
localRegistryHosting.v1: |
|
||||
host: "localhost:${reg_port}"
|
||||
help: "https://kind.sigs.k8s.io/docs/user/local-registry/"
|
||||
EOF
|
||||
|
||||
echo "6. Install ingress-nginx"
|
||||
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
|
||||
kubectl -n ingress-nginx create secret tls mkcert --key /tmp/127.0.0.1.nip.io+1-key.pem --cert /tmp/127.0.0.1.nip.io+1.pem
|
||||
kubectl -n ingress-nginx patch deployments.apps ingress-nginx-controller --type 'json' -p '[{"op": "add", "path": "/spec/template/spec/containers/0/args/-", "value":"--default-ssl-certificate=ingress-nginx/mkcert"}]'
|
||||
curl https://raw.githubusercontent.com/numerique-gouv/tools/refs/heads/main/kind/create_cluster.sh | bash -s -- meet
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
project=$(_set_openstack_project)
|
||||
echo "Using \"${project}\" project..."
|
||||
|
||||
source "${TERRAFORM_DIRECTORY}/${project}/openrc.sh"
|
||||
|
||||
# Run Terraform commands in the Hashicorp docker container via docker compose
|
||||
# shellcheck disable=SC2068
|
||||
DOCKER_USER="$(id -u):$(id -g)" \
|
||||
PROJECT="${project}" \
|
||||
docker compose run --rm \
|
||||
-e OS_AUTH_URL \
|
||||
-e OS_IDENTITY_API_VERSION \
|
||||
-e OS_USER_DOMAIN_NAME \
|
||||
-e OS_PROJECT_DOMAIN_NAME \
|
||||
-e OS_TENANT_ID \
|
||||
-e OS_TENANT_NAME \
|
||||
-e OS_USERNAME \
|
||||
-e OS_PASSWORD \
|
||||
-e OS_REGION_NAME \
|
||||
terraform-state "$@"
|
||||
@@ -1,26 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -eo pipefail
|
||||
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
project=$(_set_openstack_project)
|
||||
echo "Using \"${project}\" project..."
|
||||
|
||||
source "${TERRAFORM_DIRECTORY}/${project}/openrc.sh"
|
||||
|
||||
# Run Terraform commands in the Hashicorp docker container via docker compose
|
||||
# shellcheck disable=SC2068
|
||||
DOCKER_USER="$(id -u):$(id -g)" \
|
||||
PROJECT="${project}" \
|
||||
docker compose run --rm \
|
||||
-e OS_AUTH_URL \
|
||||
-e OS_IDENTITY_API_VERSION \
|
||||
-e OS_USER_DOMAIN_NAME \
|
||||
-e OS_PROJECT_DOMAIN_NAME \
|
||||
-e OS_TENANT_ID \
|
||||
-e OS_TENANT_NAME \
|
||||
-e OS_USERNAME \
|
||||
-e OS_PASSWORD \
|
||||
-e OS_REGION_NAME \
|
||||
-e TF_VAR_user_name \
|
||||
terraform "$@"
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
HELMFILE=src/helm/helmfile.yaml
|
||||
|
||||
environments=$(awk '/environments:/ {flag=1; next} flag && NF {print} !NF {flag=0}' "$HELMFILE" | grep -E '^[[:space:]]{2}[a-zA-Z]+' | sed 's/^[[:space:]]*//;s/:.*//')
|
||||
|
||||
for env in $environments; do
|
||||
echo "################### $env lint ###################"
|
||||
helmfile -e $env -f src/helm/helmfile.yaml lint || exit 1
|
||||
echo -e "\n"
|
||||
done
|
||||
@@ -1,4 +1,3 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
postgresql:
|
||||
@@ -100,7 +99,7 @@ services:
|
||||
image: jwilder/dockerize
|
||||
|
||||
crowdin:
|
||||
image: crowdin/cli:3.16.0
|
||||
image: crowdin/cli:4.0.0
|
||||
volumes:
|
||||
- ".:/app"
|
||||
env_file:
|
||||
+15
-14
@@ -1,7 +1,7 @@
|
||||
#
|
||||
# Your crowdin's credentials
|
||||
#
|
||||
api_token_env: CROWDIN_API_TOKEN
|
||||
api_token_env: CROWDIN_PERSONAL_TOKEN
|
||||
project_id_env: CROWDIN_PROJECT_ID
|
||||
base_path_env: CROWDIN_BASE_PATH
|
||||
|
||||
@@ -14,16 +14,17 @@ preserve_hierarchy: true
|
||||
#
|
||||
# Files configuration
|
||||
#
|
||||
files: [
|
||||
{
|
||||
source : "/backend/locale/django.pot",
|
||||
dest: "/backend-meet.pot",
|
||||
translation : "/backend/locale/%locale_with_underscore%/LC_MESSAGES/django.po"
|
||||
},
|
||||
{
|
||||
source: "/frontend/packages/i18n/locales/impress/translations-crowdin.json",
|
||||
dest: "/frontend-impress.json",
|
||||
translation: "/frontend/packages/i18n/locales/impress/%two_letters_code%/translations.json",
|
||||
skip_untranslated_strings: true,
|
||||
},
|
||||
]
|
||||
files:
|
||||
[
|
||||
{
|
||||
source: "src/backend/locale/django.pot",
|
||||
dest: "/backend-meet.pot",
|
||||
translation: "src/backend/locale/%locale_with_underscore%/LC_MESSAGES/django.po",
|
||||
},
|
||||
{
|
||||
source: "src/frontend/src/locales/fr/**/*",
|
||||
translation: "src/frontend/src/locales/%two_letters_code%/**/%original_file_name%",
|
||||
dest: "/%original_file_name%",
|
||||
skip_untranslated_strings: true,
|
||||
},
|
||||
]
|
||||
|
||||
+405
-405
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,40 @@
|
||||
replicaCount: 1
|
||||
terminationGracePeriodSeconds: 18000
|
||||
|
||||
livekit:
|
||||
keys:
|
||||
devkey: secret
|
||||
log_level: debug
|
||||
rtc:
|
||||
use_external_ip: false
|
||||
port_range_start: 50000
|
||||
port_range_end: 60000
|
||||
tcp_port: 7881
|
||||
redis:
|
||||
address: redis-master:6379
|
||||
password: pass
|
||||
turn:
|
||||
enabled: true
|
||||
udp_port: 443
|
||||
domain: livekit.127.0.0.1.nip.io
|
||||
loadBalancerAnnotations: {}
|
||||
|
||||
|
||||
loadBalancer:
|
||||
type: nginx
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/rewrite-target: /
|
||||
nginx.ingress.kubernetes.io/ssl-redirect: "true"
|
||||
tls:
|
||||
- hosts:
|
||||
- livekit.127.0.0.1.nip.io
|
||||
secretName: livekit-dinum-cert
|
||||
|
||||
autoscaling:
|
||||
enabled: false
|
||||
minReplicas: 1
|
||||
maxReplicas: 5
|
||||
targetCPUUtilizationPercentage: 60
|
||||
|
||||
nodeSelector: {}
|
||||
resources: {}
|
||||
@@ -0,0 +1,120 @@
|
||||
image:
|
||||
repository: lasuite/meet-backend
|
||||
pullPolicy: Always
|
||||
tag: "latest"
|
||||
|
||||
backend:
|
||||
replicas: 1
|
||||
envVars:
|
||||
DJANGO_CSRF_TRUSTED_ORIGINS: https://meet.127.0.0.1.nip.io,http://meet.127.0.0.1.nip.io
|
||||
DJANGO_CONFIGURATION: Production
|
||||
DJANGO_ALLOWED_HOSTS: meet.127.0.0.1.nip.io
|
||||
DJANGO_SECRET_KEY: ThisCouldBeAReallyGoodOrPerhapsABadKeyToUseSometimes
|
||||
DJANGO_SETTINGS_MODULE: meet.settings
|
||||
DJANGO_SILENCED_SYSTEM_CHECKS: security.W004, security.W008
|
||||
DJANGO_SUPERUSER_PASSWORD: admin
|
||||
DJANGO_EMAIL_HOST: "mailcatcher"
|
||||
DJANGO_EMAIL_PORT: 1025
|
||||
DJANGO_EMAIL_USE_SSL: False
|
||||
OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/certs
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/auth
|
||||
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/token
|
||||
OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/userinfo
|
||||
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/session/end
|
||||
OIDC_RP_CLIENT_ID: meet
|
||||
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_RP_SIGN_ALGO: RS256
|
||||
OIDC_RP_SCOPES: "openid email"
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS: https://meet.127.0.0.1.nip.io
|
||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS: "{'acr_values': 'eidas1'}"
|
||||
LOGIN_REDIRECT_URL: https://meet.127.0.0.1.nip.io
|
||||
LOGIN_REDIRECT_URL_FAILURE: https://meet.127.0.0.1.nip.io
|
||||
LOGOUT_REDIRECT_URL: https://meet.127.0.0.1.nip.io
|
||||
DB_HOST: postgresql
|
||||
DB_NAME: meet
|
||||
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
|
||||
LIVEKIT_API_KEY: devkey
|
||||
LIVEKIT_API_URL: https://livekit.127.0.0.1.nip.io/
|
||||
ALLOW_UNREGISTERED_ROOMS: False
|
||||
FRONTEND_SILENCE_LIVEKIT_DEBUG: False
|
||||
|
||||
|
||||
migrate:
|
||||
command:
|
||||
- "/bin/sh"
|
||||
- "-c"
|
||||
- |
|
||||
python manage.py migrate --no-input &&
|
||||
python manage.py create_demo --force
|
||||
restartPolicy: Never
|
||||
|
||||
command:
|
||||
- "gunicorn"
|
||||
- "-c"
|
||||
- "/usr/local/etc/gunicorn/meet.py"
|
||||
- "meet.wsgi:application"
|
||||
- "--reload"
|
||||
|
||||
createsuperuser:
|
||||
command:
|
||||
- "/bin/sh"
|
||||
- "-c"
|
||||
- |
|
||||
python manage.py createsuperuser --email admin@example.com --password admin
|
||||
restartPolicy: Never
|
||||
|
||||
# Exra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||
extraVolumeMounts:
|
||||
- name: certs
|
||||
mountPath: /usr/local/lib/python3.12/site-packages/certifi/cacert.pem
|
||||
subPath: cacert.pem
|
||||
|
||||
# Exra volume to manage our local custom CA and avoid to set ssl_verify: false
|
||||
extraVolumes:
|
||||
- name: certs
|
||||
configMap:
|
||||
name: certifi
|
||||
items:
|
||||
- key: cacert.pem
|
||||
path: cacert.pem
|
||||
|
||||
frontend:
|
||||
envVars:
|
||||
VITE_PORT: 8080
|
||||
VITE_HOST: 0.0.0.0
|
||||
VITE_API_BASE_URL: https://meet.127.0.0.1.nip.io/
|
||||
|
||||
replicas: 1
|
||||
|
||||
image:
|
||||
repository: lasuite/meet-frontend
|
||||
pullPolicy: Always
|
||||
tag: "latest"
|
||||
|
||||
ingress:
|
||||
enabled: true
|
||||
host: meet.127.0.0.1.nip.io
|
||||
|
||||
ingressAdmin:
|
||||
enabled: true
|
||||
host: meet.127.0.0.1.nip.io
|
||||
|
||||
posthog:
|
||||
ingress:
|
||||
enabled: false
|
||||
ingressAssets:
|
||||
enabled: false
|
||||
|
||||
summary:
|
||||
replicas: 0
|
||||
|
||||
celery:
|
||||
replicas: 0
|
||||
@@ -0,0 +1,7 @@
|
||||
auth:
|
||||
username: dinum
|
||||
password: pass
|
||||
database: meet
|
||||
tls:
|
||||
enabled: true
|
||||
autoGenerated: true
|
||||
@@ -0,0 +1,3 @@
|
||||
auth:
|
||||
password: pass
|
||||
architecture: standalone
|
||||
@@ -0,0 +1,233 @@
|
||||
# Installation on a k8s cluster
|
||||
|
||||
This document is a step-by-step guide that describes how to install Visio on a k8s cluster without AI features.
|
||||
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- k8s cluster with an nginx-ingress controller
|
||||
- an OIDC provider (if you don't have one, we will provide an example)
|
||||
- a LiveKit server (if you don't have one, we will provide an example)
|
||||
- a PostgreSQL server (if you don't have one, we will provide an example)
|
||||
- a Memcached server (if you don't have one, we will provide an example)
|
||||
|
||||
### Test cluster
|
||||
|
||||
If you do not have a 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**.
|
||||
|
||||
To be able to use the script, you will need to install:
|
||||
|
||||
- Docker (https://docs.docker.com/desktop/)
|
||||
- Kind (https://kind.sigs.k8s.io/docs/user/quick-start/#installation)
|
||||
- Mkcert (https://github.com/FiloSottile/mkcert#installation)
|
||||
- Helm (https://helm.sh/docs/intro/quickstart/#install-helm)
|
||||
|
||||
```
|
||||
$ ./bin/start-kind.sh
|
||||
0. Create ca
|
||||
The local CA is already installed in the system trust store! 👍
|
||||
The local CA is already installed in the Firefox and/or Chrome/Chromium trust store! 👍
|
||||
|
||||
|
||||
Created a new certificate valid for the following names 📜
|
||||
- "127.0.0.1.nip.io"
|
||||
- "*.127.0.0.1.nip.io"
|
||||
|
||||
Reminder: X.509 wildcards only go one level deep, so this won't match a.b.127.0.0.1.nip.io ℹ️
|
||||
|
||||
The certificate is at "./127.0.0.1.nip.io+1.pem" and the key at "./127.0.0.1.nip.io+1-key.pem" ✅
|
||||
|
||||
It will expire on 23 March 2027 🗓
|
||||
|
||||
1. Create registry container unless it already exists
|
||||
2. Create kind cluster with containerd registry config dir enabled
|
||||
Creating cluster "visio" ...
|
||||
✓ Ensuring node image (kindest/node:v1.27.3) 🖼
|
||||
✓ Preparing nodes 📦
|
||||
✓ Writing configuration 📜
|
||||
✓ Starting control-plane 🕹️
|
||||
✓ Installing CNI 🔌
|
||||
✓ Installing StorageClass 💾
|
||||
Set kubectl context to "kind-visio"
|
||||
You can now use your cluster with:
|
||||
|
||||
kubectl cluster-info --context kind-visio
|
||||
|
||||
Thanks for using kind! 😊
|
||||
3. Add the registry config to the nodes
|
||||
4. Connect the registry to the cluster network if not already connected
|
||||
5. Document the local registry
|
||||
configmap/local-registry-hosting created
|
||||
Warning: resource configmaps/coredns is missing the kubectl.kubernetes.io/last-applied-configuration annotation which is required by kubectl apply. kubectl apply should only be used on resources created declaratively by either kubectl create --save-config or kubectl apply. The missing annotation will be patched automatically.
|
||||
configmap/coredns configured
|
||||
deployment.apps/coredns restarted
|
||||
6. Install ingress-nginx
|
||||
namespace/ingress-nginx created
|
||||
serviceaccount/ingress-nginx created
|
||||
serviceaccount/ingress-nginx-admission created
|
||||
role.rbac.authorization.k8s.io/ingress-nginx created
|
||||
role.rbac.authorization.k8s.io/ingress-nginx-admission created
|
||||
clusterrole.rbac.authorization.k8s.io/ingress-nginx created
|
||||
clusterrole.rbac.authorization.k8s.io/ingress-nginx-admission created
|
||||
rolebinding.rbac.authorization.k8s.io/ingress-nginx created
|
||||
rolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created
|
||||
clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx created
|
||||
clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created
|
||||
configmap/ingress-nginx-controller created
|
||||
service/ingress-nginx-controller created
|
||||
service/ingress-nginx-controller-admission created
|
||||
deployment.apps/ingress-nginx-controller created
|
||||
job.batch/ingress-nginx-admission-create created
|
||||
job.batch/ingress-nginx-admission-patch created
|
||||
ingressclass.networking.k8s.io/nginx created
|
||||
validatingwebhookconfiguration.admissionregistration.k8s.io/ingress-nginx-admission created
|
||||
secret/mkcert created
|
||||
deployment.apps/ingress-nginx-controller patched
|
||||
7. Setup namespace
|
||||
namespace/meet created
|
||||
Context "kind-visio" modified.
|
||||
secret/mkcert created
|
||||
$ kind get clusters
|
||||
visio
|
||||
$ kubectl -n ingress-nginx get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
ingress-nginx-admission-create-jgnc9 0/1 Completed 0 2m44s
|
||||
ingress-nginx-admission-patch-wrt47 0/1 Completed 0 2m44s
|
||||
ingress-nginx-controller-57c548c4cd-9xwt6 1/1 Running 0 2m44s
|
||||
```
|
||||
When your k8s cluster is ready, you can start the deployment. This cluster is special because it uses the *.127.0.0.1.nip.io domain and mkcert certificates to have full HTTPS support and easy domain name management.
|
||||
|
||||
Please remember that *.127.0.0.1.nip.io will always resolve to 127.0.0.1, except in the k8s cluster where we configure CoreDNS to answer with the ingress-nginx service IP.
|
||||
|
||||
## Preparation
|
||||
|
||||
### What will you use to authenticate your users ?
|
||||
|
||||
Visio uses OIDC, so if you already have an OIDC provider, obtain the necessary information to use it. In the next step, we will see how to configure Django (and thus Visio) to use it. If you do not have a provider, we will show you how to deploy a local Keycloak instance (this is not a production deployment, just a demo).
|
||||
|
||||
If you haven't run the script **bin/start-kind.sh**, you'll need to manually create the namespace by running the following command:
|
||||
|
||||
```
|
||||
$ kubectl create namespace meet
|
||||
```
|
||||
|
||||
If you have already run the script, you can skip this step and proceed to the next instruction.
|
||||
|
||||
```
|
||||
$ kubectl config set-context --current --namespace=meet
|
||||
$ helm install keycloak oci://registry-1.docker.io/bitnamicharts/keycloak -f examples/keycloak.values.yaml
|
||||
$ #wait until
|
||||
$ kubectl get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
keycloak-0 1/1 Running 0 6m48s
|
||||
keycloak-postgresql-0 1/1 Running 0 6m48s
|
||||
```
|
||||
|
||||
From here the important information you will need are :
|
||||
|
||||
```
|
||||
OIDC_OP_JWKS_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/certs
|
||||
OIDC_OP_AUTHORIZATION_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/auth
|
||||
OIDC_OP_TOKEN_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/token
|
||||
OIDC_OP_USER_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/userinfo
|
||||
OIDC_OP_LOGOUT_ENDPOINT: https://keycloak.127.0.0.1.nip.io/realms/meet/protocol/openid-connect/session/end
|
||||
OIDC_RP_CLIENT_ID: meet
|
||||
OIDC_RP_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_RP_SIGN_ALGO: RS256
|
||||
OIDC_RP_SCOPES: "openid email"
|
||||
```
|
||||
|
||||
You can find these values in **examples/keycloak.values.yaml**
|
||||
|
||||
### Find livekit server connexion values
|
||||
|
||||
Visio use livekit for streaming part so if you have a livekit provider, obtain the necessary information to use it. If you do not have a provider, you can install a livekit testing environment as follows:
|
||||
|
||||
Livekit need a redis (and meet too) so we will start by deploying a redis :
|
||||
|
||||
```
|
||||
$ helm install redis oci://registry-1.docker.io/bitnamicharts/redis -f examples/redis.values.yaml
|
||||
$ kubectl get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
keycloak-0 1/1 Running 0 26m
|
||||
keycloak-postgresql-0 1/1 Running 0 26m
|
||||
redis-master-0 1/1 Running 0 35s
|
||||
```
|
||||
When the redis is ready we can deploy livekit-server.
|
||||
|
||||
```
|
||||
$ helm repo add livekit https://helm.livekit.io
|
||||
$ helm repo update
|
||||
$ helm install livekit livekit/livekit-server -f examples/livekit.values.yaml
|
||||
$ kubectl get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
keycloak-0 1/1 Running 0 30m
|
||||
keycloak-postgresql-0 1/1 Running 0 30m
|
||||
livekit-livekit-server-5c5fb87f7f-ct6x5 1/1 Running 0 7s
|
||||
redis-master-0 1/1 Running 0 4m30s
|
||||
$ curl https://livekit.127.0.0.1.nip.io
|
||||
OK
|
||||
```
|
||||
|
||||
From here important information you will need are :
|
||||
|
||||
```
|
||||
LIVEKIT_API_SECRET: secret
|
||||
LIVEKIT_API_KEY: devkey
|
||||
LIVEKIT_API_URL: https://livekit.127.0.0.1.nip.io/
|
||||
REDIS_URL: redis://default:pass@redis-master:6379/1
|
||||
CELERY_BROKER_URL: redis://default:pass@redis-master:6379/1
|
||||
CELERY_RESULT_BACKEND: redis://default:pass@redis-master:6379/1
|
||||
```
|
||||
|
||||
### Find postgresql connexion values
|
||||
|
||||
Visio uses a postgresql db as backend so if you have a provider, obtain the necessary information to use it. If you do not have, you can install a postgresql testing environment as follows:
|
||||
|
||||
```
|
||||
$ helm install postgresql oci://registry-1.docker.io/bitnamicharts/postgresql -f examples/postgresql.values.yaml
|
||||
$ kubectl get po
|
||||
NAME READY STATUS RESTARTS AGE
|
||||
keycloak-0 1/1 Running 0 45m
|
||||
keycloak-postgresql-0 1/1 Running 0 45m
|
||||
livekit-livekit-server-5c5fb87f7f-ct6x5 1/1 Running 0 15m
|
||||
postgresql-0 1/1 Running 0 50s
|
||||
redis-master-0 1/1 Running 0 19
|
||||
```
|
||||
From here important information you will need are :
|
||||
|
||||
```
|
||||
DB_HOST: postgres-postgresql
|
||||
DB_NAME: meet
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
DB_PORT: 5432
|
||||
POSTGRES_DB: meet
|
||||
POSTGRES_USER: dinum
|
||||
POSTGRES_PASSWORD: pass
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
Now you are ready to deploy Visio without AI. AI required more dependencies (Openai-compliant API, LiveKit Egress, Cold storage and a docs deployment to push resumes). To deploy meet you need to provide all previous information to the helm chart.
|
||||
|
||||
```
|
||||
$ helm repo add meet https://numerique-gouv.github.io/meet/
|
||||
$ helm repo update
|
||||
$ helm install meet meet/meet -f examples/meet.values.yaml
|
||||
```
|
||||
|
||||
## Test your deployment
|
||||
|
||||
In order to test your deployment you have to log in to your instance. If you use exclusively our examples you can do:
|
||||
|
||||
```
|
||||
$ kubectl get ingress
|
||||
NAME CLASS HOSTS ADDRESS PORTS AGE
|
||||
keycloak <none> keycloak.127.0.0.1.nip.io localhost 80 58m
|
||||
livekit-livekit-server <none> livekit.127.0.0.1.nip.io localhost 80, 443 106m
|
||||
meet <none> meet.127.0.0.1.nip.io localhost 80, 443 52m
|
||||
meet-admin <none> meet.127.0.0.1.nip.io localhost 80, 443 52m
|
||||
```
|
||||
|
||||
You can use Visio on https://meet.127.0.0.1.nip.io. The provisioning user in keycloak is meet/meet.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Releasing a new version
|
||||
|
||||
Whenever we are cooking a new release (e.g. `4.18.1`) we should follow a standard procedure described below:
|
||||
|
||||
1. Create a new branch named: `release/4.18.1`.
|
||||
2. Bump the release number for backend project, frontend projects, and Helm files:
|
||||
|
||||
- for backend, update the version number by hand in `pyproject.toml`,
|
||||
- for each frontend projects (`src/frontend`, `src/mail`), run `npm version 4.18.1` in their directory. This will update both their `package.json` and `package-lock.json` for you,
|
||||
- for Helm, update Docker image tag in files located at `src/helm/env.d` for both `preprod` and `production` environments:
|
||||
|
||||
```yaml
|
||||
image:
|
||||
repository: lasuite/meet-backend
|
||||
pullPolicy: Always
|
||||
tag: "v4.18.1" # Replace with your new version number, without forgetting the "v" prefix
|
||||
|
||||
...
|
||||
|
||||
frontend:
|
||||
image:
|
||||
repository: lasuite/meet-frontend
|
||||
pullPolicy: Always
|
||||
tag: "v4.18.1" # Replace with your new version number, without forgetting the "v" prefix
|
||||
```
|
||||
|
||||
The new images don't exist _yet_: they will be created automatically later in the process.
|
||||
|
||||
3. ~~Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations~~ _we don't keep a changelog yet for now as the project is still in its infancy. Soon™!_
|
||||
4. Commit your changes with the following format: the 🔖 release emoji, the type of release (patch/minor/patch) and the release version:
|
||||
|
||||
```text
|
||||
🔖(minor) bump release to 4.18.0
|
||||
```
|
||||
|
||||
5. Open a pull request, wait for an approval from your peers and merge it.
|
||||
6. Checkout and pull changes from the `main` branch to ensure you have the latest updates.
|
||||
7. Tag and push your commit:
|
||||
|
||||
```bash
|
||||
git tag v4.18.1 && git push origin --tags
|
||||
```
|
||||
|
||||
Doing this triggers the CI and tells it to build the new Docker image versions that you targeted earlier in the Helm files.
|
||||
|
||||
8. Ensure the new [backend](https://hub.docker.com/r/lasuite/meet-frontend/tags) and [frontend](https://hub.docker.com/r/lasuite/meet-frontend/tags) image tags are on Docker Hub.
|
||||
9. The release is now done!
|
||||
|
||||
# Deploying
|
||||
|
||||
> [!TIP]
|
||||
> The `staging` platform is deployed automatically with every update of the `main` branch.
|
||||
|
||||
Making a new release doesn't publish it automatically in production.
|
||||
|
||||
Deployment is done by ArgoCD. ArgoCD checks for the `production` tag and automatically deploys the production platform with the targeted commit.
|
||||
|
||||
To publish, we mark the commit we want with the `production` tag. ArgoCD is then notified that the tag has changed. It then deploys the Docker image tags specified in the Helm files of the targeted commit.
|
||||
|
||||
To publish the release you just made:
|
||||
|
||||
```bash
|
||||
git tag --force production v4.18.1
|
||||
git push --force origin production
|
||||
```
|
||||
@@ -1,25 +0,0 @@
|
||||
# Api client TypeScript
|
||||
|
||||
The backend application can automatically create a TypeScript client to be used in frontend
|
||||
applications. It is used in the Meet front application itself.
|
||||
|
||||
This client is made with [openapi-typescript-codegen](https://github.com/ferdikoomen/openapi-typescript-codegen)
|
||||
and Meet's backend OpenAPI schema (available [here](http://localhost:8071/v1.0/swagger/) if you have the backend running).
|
||||
|
||||
## Requirements
|
||||
|
||||
We'll need the online OpenAPI schema generated by swagger. Therefore you will first need to
|
||||
install the backend application.
|
||||
|
||||
## Install openApiClientJs
|
||||
|
||||
```sh
|
||||
$ cd src/tsclient
|
||||
$ yarn install
|
||||
```
|
||||
|
||||
## Generate the client
|
||||
|
||||
```sh
|
||||
yarn generate:api:client:local <output_path_for_generated_client>
|
||||
```
|
||||
@@ -18,9 +18,6 @@ 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
|
||||
@@ -44,3 +41,4 @@ OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||
LIVEKIT_API_SECRET=secret
|
||||
LIVEKIT_API_KEY=devkey
|
||||
LIVEKIT_API_URL=http://localhost:7880
|
||||
ALLOW_UNREGISTERED_ROOMS=False
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
CROWDIN_API_TOKEN=Your-Api-Token
|
||||
CROWDIN_PERSONAL_TOKEN=Your-Api-Token
|
||||
CROWDIN_PROJECT_ID=Your-Project-Id
|
||||
CROWDIN_BASE_PATH=/app/src
|
||||
CROWDIN_BASE_PATH=/app
|
||||
|
||||
+3
-1
@@ -13,7 +13,9 @@
|
||||
"enabled": false,
|
||||
"groupName": "ignored js dependencies",
|
||||
"matchManagers": ["npm"],
|
||||
"matchPackageNames": []
|
||||
"matchPackageNames": [
|
||||
"eslint"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
find . -name "*.enc.*" -exec sops updatekeys -y {} \;
|
||||
+1
-1
Submodule secrets updated: 35ec1ef9fe...2ba12db71d
@@ -447,10 +447,10 @@ max-bool-expr=5
|
||||
max-branches=12
|
||||
|
||||
# Maximum number of locals for function / method body
|
||||
max-locals=15
|
||||
max-locals=20
|
||||
|
||||
# Maximum number of parents for a class (see R0901).
|
||||
max-parents=7
|
||||
max-parents=10
|
||||
|
||||
# Maximum number of public methods for a class (see R0904).
|
||||
max-public-methods=20
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Admin classes and registrations for core app."""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth import admin as auth_admin
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -21,7 +22,19 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
)
|
||||
},
|
||||
),
|
||||
(_("Personal info"), {"fields": ("sub", "email", "language", "timezone")}),
|
||||
(
|
||||
_("Personal info"),
|
||||
{
|
||||
"fields": (
|
||||
"sub",
|
||||
"email",
|
||||
"full_name",
|
||||
"short_name",
|
||||
"language",
|
||||
"timezone",
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
_("Permissions"),
|
||||
{
|
||||
@@ -51,6 +64,8 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
"sub",
|
||||
"admin_email",
|
||||
"email",
|
||||
"full_name",
|
||||
"short_name",
|
||||
"is_active",
|
||||
"is_staff",
|
||||
"is_superuser",
|
||||
@@ -59,9 +74,24 @@ class UserAdmin(auth_admin.UserAdmin):
|
||||
"updated_at",
|
||||
)
|
||||
list_filter = ("is_staff", "is_superuser", "is_device", "is_active")
|
||||
ordering = ("is_active", "-is_superuser", "-is_staff", "-is_device", "-updated_at")
|
||||
readonly_fields = ("id", "sub", "email", "created_at", "updated_at")
|
||||
search_fields = ("id", "sub", "admin_email", "email")
|
||||
ordering = (
|
||||
"is_active",
|
||||
"-is_superuser",
|
||||
"-is_staff",
|
||||
"-is_device",
|
||||
"-updated_at",
|
||||
"full_name",
|
||||
)
|
||||
readonly_fields = (
|
||||
"id",
|
||||
"sub",
|
||||
"email",
|
||||
"full_name",
|
||||
"short_name",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
search_fields = ("id", "sub", "admin_email", "email", "full_name")
|
||||
|
||||
|
||||
class ResourceAccessInline(admin.TabularInline):
|
||||
@@ -76,3 +106,18 @@ class RoomAdmin(admin.ModelAdmin):
|
||||
"""Room admin interface declaration."""
|
||||
|
||||
inlines = (ResourceAccessInline,)
|
||||
|
||||
|
||||
class RecordingAccessInline(admin.TabularInline):
|
||||
"""Inline admin class for recording accesses."""
|
||||
|
||||
model = models.RecordingAccess
|
||||
extra = 0
|
||||
|
||||
|
||||
@admin.register(models.Recording)
|
||||
class RecordingAdmin(admin.ModelAdmin):
|
||||
"""Recording admin interface declaration."""
|
||||
|
||||
inlines = (RecordingAccessInline,)
|
||||
list_display = ("id", "status", "room", "created_at", "worker_id")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Meet core API endpoints"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
@@ -22,6 +23,8 @@ def exception_handler(exc, context):
|
||||
detail = exc.message
|
||||
elif hasattr(exc, "messages"):
|
||||
detail = exc.messages
|
||||
else:
|
||||
detail = ""
|
||||
|
||||
exc = drf_exceptions.ValidationError(detail=detail)
|
||||
|
||||
@@ -34,6 +37,10 @@ def get_frontend_configuration(request):
|
||||
"""Returns the frontend configuration dict as configured in settings."""
|
||||
frontend_configuration = {
|
||||
"LANGUAGE_CODE": settings.LANGUAGE_CODE,
|
||||
"recording": {
|
||||
"is_enabled": settings.RECORDING_ENABLE,
|
||||
"available_modes": settings.RECORDING_WORKER_CLASSES.keys(),
|
||||
},
|
||||
}
|
||||
frontend_configuration.update(settings.FRONTEND_CONFIGURATION)
|
||||
return Response(frontend_configuration)
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
"""Permission handlers for the Meet core app."""
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from rest_framework import permissions
|
||||
|
||||
from ..models import RoleChoices
|
||||
@@ -64,15 +67,11 @@ class RoomPermissions(permissions.BasePermission):
|
||||
return obj.is_administrator(user)
|
||||
|
||||
|
||||
class ResourceAccessPermission(permissions.BasePermission):
|
||||
class ResourceAccessPermission(IsAuthenticated):
|
||||
"""
|
||||
Permissions for a room that can only be updated by room administrators.
|
||||
"""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Only allow authenticated users."""
|
||||
return request.user.is_authenticated
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""
|
||||
Check that the logged-in user is administrator of the linked room.
|
||||
@@ -82,3 +81,41 @@ class ResourceAccessPermission(permissions.BasePermission):
|
||||
return obj.user == user
|
||||
|
||||
return obj.resource.is_administrator(user)
|
||||
|
||||
|
||||
class HasAbilityPermission(IsAuthenticated):
|
||||
"""Permission class for access objects."""
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Check permission for a given object."""
|
||||
return obj.get_abilities(request.user).get(view.action, False)
|
||||
|
||||
|
||||
class HasPrivilegesOnRoom(IsAuthenticated):
|
||||
"""Check if user has privileges on a given room."""
|
||||
|
||||
message = "You must have privileges to start a recording."
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Determine if user has privileges on room."""
|
||||
return obj.is_owner(request.user) or obj.is_administrator(request.user)
|
||||
|
||||
|
||||
class IsRecordingEnabled(permissions.BasePermission):
|
||||
"""Check if the recording feature is enabled."""
|
||||
|
||||
message = "Access denied, recording is disabled."
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Determine if access is allowed based on settings."""
|
||||
return settings.RECORDING_ENABLE
|
||||
|
||||
|
||||
class IsStorageEventEnabled(permissions.BasePermission):
|
||||
"""Check if the storage event feature is enabled."""
|
||||
|
||||
message = "Access denied, storage event is disabled."
|
||||
|
||||
def has_permission(self, request, view):
|
||||
"""Determine if access is allowed based on settings."""
|
||||
return settings.RECORDING_STORAGE_EVENT_ENABLE
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Client serializers for the Meet core app."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
@@ -13,8 +14,8 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["id", "email"]
|
||||
read_only_fields = ["id", "email"]
|
||||
fields = ["id", "email", "full_name", "short_name"]
|
||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
||||
|
||||
|
||||
class ResourceAccessSerializerMixin:
|
||||
@@ -86,6 +87,15 @@ class NestedResourceAccessSerializer(ResourceAccessSerializer):
|
||||
user = UserSerializer(read_only=True)
|
||||
|
||||
|
||||
class ListRoomSerializer(serializers.ModelSerializer):
|
||||
"""Serialize Room model for a list API endpoint."""
|
||||
|
||||
class Meta:
|
||||
model = models.Room
|
||||
fields = ["id", "name", "slug", "is_public"]
|
||||
read_only_fields = ["id", "slug"]
|
||||
|
||||
|
||||
class RoomSerializer(serializers.ModelSerializer):
|
||||
"""Serialize Room model for the API."""
|
||||
|
||||
@@ -120,16 +130,50 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
del output["configuration"]
|
||||
|
||||
if role is not None or instance.is_public:
|
||||
slug = f"{instance.id!s}".replace("-", "")
|
||||
slug = f"{instance.id!s}"
|
||||
username = request.query_params.get("username", None)
|
||||
|
||||
output["livekit"] = {
|
||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||
"room": slug,
|
||||
"token": utils.generate_token(room=slug, user=request.user),
|
||||
"token": utils.generate_token(
|
||||
room=slug, user=request.user, username=username
|
||||
),
|
||||
}
|
||||
|
||||
output["is_administrable"] = is_admin
|
||||
|
||||
# todo - pass properly livekit configuration
|
||||
|
||||
return output
|
||||
|
||||
|
||||
class RecordingSerializer(serializers.ModelSerializer):
|
||||
"""Serialize Recording for the API."""
|
||||
|
||||
room = ListRoomSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = models.Recording
|
||||
fields = ["id", "room", "created_at", "updated_at", "status"]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class StartRecordingSerializer(serializers.Serializer):
|
||||
"""Validate start recording requests."""
|
||||
|
||||
mode = serializers.ChoiceField(
|
||||
choices=models.RecordingModeChoices.choices,
|
||||
required=True,
|
||||
error_messages={
|
||||
"required": "Recording mode is required.",
|
||||
"invalid_choice": "Invalid recording mode. Choose between "
|
||||
"screen_recording or transcript.",
|
||||
},
|
||||
)
|
||||
|
||||
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")
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""API endpoints"""
|
||||
|
||||
import uuid
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
@@ -13,16 +15,42 @@ from rest_framework import (
|
||||
pagination,
|
||||
viewsets,
|
||||
)
|
||||
from rest_framework import (
|
||||
exceptions as drf_exceptions,
|
||||
)
|
||||
from rest_framework import (
|
||||
response as drf_response,
|
||||
)
|
||||
from rest_framework import (
|
||||
status as drf_status,
|
||||
)
|
||||
|
||||
from core import models, utils
|
||||
from core.recording.event.authentication import StorageEventAuthentication
|
||||
from core.recording.event.exceptions import (
|
||||
InvalidBucketError,
|
||||
InvalidFileTypeError,
|
||||
ParsingEventDataError,
|
||||
)
|
||||
from core.recording.event.notification import notification_service
|
||||
from core.recording.event.parsers import get_parser
|
||||
from core.recording.worker.exceptions import (
|
||||
RecordingStartError,
|
||||
RecordingStopError,
|
||||
)
|
||||
from core.recording.worker.factories import (
|
||||
get_worker_service,
|
||||
)
|
||||
from core.recording.worker.mediator import (
|
||||
WorkerServiceMediator,
|
||||
)
|
||||
|
||||
from . import permissions, serializers
|
||||
|
||||
# pylint: disable=too-many-ancestors
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class NestedGenericViewSet(viewsets.GenericViewSet):
|
||||
"""
|
||||
@@ -188,12 +216,15 @@ class RoomViewSet(
|
||||
if not settings.ALLOW_UNREGISTERED_ROOMS:
|
||||
raise
|
||||
slug = slugify(self.kwargs["pk"])
|
||||
username = request.query_params.get("username", None)
|
||||
data = {
|
||||
"id": None,
|
||||
"livekit": {
|
||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||
"room": slug,
|
||||
"token": utils.generate_token(room=slug, user=request.user),
|
||||
"token": utils.generate_token(
|
||||
room=slug, user=request.user, username=username
|
||||
),
|
||||
},
|
||||
}
|
||||
else:
|
||||
@@ -206,11 +237,8 @@ class RoomViewSet(
|
||||
user = self.request.user
|
||||
|
||||
if user.is_authenticated:
|
||||
# todo - simplify this queryset
|
||||
queryset = (
|
||||
self.filter_queryset(self.get_queryset())
|
||||
.filter(Q(users=user))
|
||||
.distinct()
|
||||
self.filter_queryset(self.get_queryset()).filter(users=user).distinct()
|
||||
)
|
||||
else:
|
||||
queryset = self.get_queryset().none()
|
||||
@@ -232,6 +260,89 @@ class RoomViewSet(
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
url_path="start-recording",
|
||||
permission_classes=[
|
||||
permissions.HasPrivilegesOnRoom,
|
||||
permissions.IsRecordingEnabled,
|
||||
],
|
||||
)
|
||||
def start_room_recording(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Start recording a room."""
|
||||
|
||||
serializer = serializers.StartRecordingSerializer(data=request.data)
|
||||
|
||||
if not serializer.is_valid():
|
||||
return drf_response.Response(
|
||||
{"detail": "Invalid request."}, status=drf_status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
mode = serializer.validated_data["mode"]
|
||||
room = self.get_object()
|
||||
|
||||
# May raise exception if an active or initiated recording already exist for the room
|
||||
recording = models.Recording.objects.create(room=room, mode=mode)
|
||||
|
||||
models.RecordingAccess.objects.create(
|
||||
user=self.request.user, role=models.RoleChoices.OWNER, recording=recording
|
||||
)
|
||||
|
||||
worker_service = get_worker_service(mode=recording.mode)
|
||||
worker_manager = WorkerServiceMediator(worker_service=worker_service)
|
||||
|
||||
try:
|
||||
worker_manager.start(recording)
|
||||
except RecordingStartError:
|
||||
return drf_response.Response(
|
||||
{"error": f"Recording failed to start for room {room.slug}"},
|
||||
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
return drf_response.Response(
|
||||
{"message": f"Recording successfully started for room {room.slug}"},
|
||||
status=drf_status.HTTP_201_CREATED,
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
url_path="stop-recording",
|
||||
permission_classes=[
|
||||
permissions.HasPrivilegesOnRoom,
|
||||
permissions.IsRecordingEnabled,
|
||||
],
|
||||
)
|
||||
def stop_room_recording(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Stop room recording."""
|
||||
|
||||
room = self.get_object()
|
||||
|
||||
try:
|
||||
recording = models.Recording.objects.get(
|
||||
room=room, status=models.RecordingStatusChoices.ACTIVE
|
||||
)
|
||||
except models.Recording.DoesNotExist as e:
|
||||
raise drf_exceptions.NotFound(
|
||||
"No active recording found for this room."
|
||||
) from e
|
||||
|
||||
worker_service = get_worker_service(mode=recording.mode)
|
||||
worker_manager = WorkerServiceMediator(worker_service=worker_service)
|
||||
|
||||
try:
|
||||
worker_manager.stop(recording)
|
||||
except RecordingStopError:
|
||||
return drf_response.Response(
|
||||
{"error": f"Recording failed to stop for room {room.slug}"},
|
||||
status=drf_status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
)
|
||||
|
||||
return drf_response.Response(
|
||||
{"message": f"Recording stopped for room {room.slug}."}
|
||||
)
|
||||
|
||||
|
||||
class ResourceAccessListModelMixin:
|
||||
"""List mixin for resource access API."""
|
||||
@@ -276,3 +387,81 @@ class ResourceAccessViewSet(
|
||||
permission_classes = [permissions.ResourceAccessPermission]
|
||||
queryset = models.ResourceAccess.objects.all()
|
||||
serializer_class = serializers.ResourceAccessSerializer
|
||||
|
||||
|
||||
class RecordingViewSet(
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
API endpoints to access and perform actions on recordings.
|
||||
"""
|
||||
|
||||
pagination_class = Pagination
|
||||
permission_classes = [permissions.HasAbilityPermission]
|
||||
queryset = models.Recording.objects.all()
|
||||
serializer_class = serializers.RecordingSerializer
|
||||
|
||||
def get_queryset(self):
|
||||
"""Restrict recordings to the user's ones."""
|
||||
user = self.request.user
|
||||
return (
|
||||
super()
|
||||
.get_queryset()
|
||||
.filter(Q(accesses__user=user) | Q(accesses__team__in=user.get_teams()))
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["post"],
|
||||
url_path="storage-hook",
|
||||
authentication_classes=[StorageEventAuthentication],
|
||||
permission_classes=[permissions.IsStorageEventEnabled],
|
||||
)
|
||||
def on_storage_event_received(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Handle incoming storage hook events for recordings."""
|
||||
|
||||
parser = get_parser()
|
||||
|
||||
try:
|
||||
recording_id = parser.get_recording_id(request.data)
|
||||
|
||||
except ParsingEventDataError as e:
|
||||
raise drf_exceptions.PermissionDenied(f"Invalid request data: {e}") from e
|
||||
|
||||
except InvalidBucketError as e:
|
||||
raise drf_exceptions.PermissionDenied("Invalid bucket specified") from e
|
||||
|
||||
except InvalidFileTypeError as e:
|
||||
return drf_response.Response(
|
||||
{"message": f"Ignore this file type, {e}"},
|
||||
)
|
||||
|
||||
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 not recording.is_savable():
|
||||
raise drf_exceptions.PermissionDenied(
|
||||
f"Recording with ID {recording_id} cannot be saved because it is either,"
|
||||
" in an error state or has already been saved."
|
||||
)
|
||||
|
||||
# Attempt to notify external services about the recording
|
||||
# This is a non-blocking operation - failures are logged but don't interrupt the flow
|
||||
notification_succeeded = notification_service.notify_external_services(
|
||||
recording
|
||||
)
|
||||
|
||||
recording.status = (
|
||||
models.RecordingStatusChoices.NOTIFICATION_SUCCEEDED
|
||||
if notification_succeeded
|
||||
else models.RecordingStatusChoices.SAVED
|
||||
)
|
||||
recording.save()
|
||||
|
||||
return drf_response.Response(
|
||||
{"message": "Event processed."},
|
||||
)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Authentication Backends for the Meet core app."""
|
||||
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import requests
|
||||
@@ -9,6 +10,11 @@ from mozilla_django_oidc.auth import (
|
||||
)
|
||||
|
||||
from core.models import User
|
||||
from core.services.marketing_service import (
|
||||
ContactCreationError,
|
||||
ContactData,
|
||||
get_marketing_service,
|
||||
)
|
||||
|
||||
|
||||
class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
@@ -66,35 +72,100 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
user_info = self.get_userinfo(access_token, id_token, payload)
|
||||
sub = user_info.get("sub")
|
||||
|
||||
if sub is None:
|
||||
if not sub:
|
||||
raise SuspiciousOperation(
|
||||
_("User info contained no recognizable user identification")
|
||||
)
|
||||
|
||||
try:
|
||||
user = User.objects.get(sub=sub)
|
||||
except User.DoesNotExist:
|
||||
if self.get_settings("OIDC_CREATE_USER", True):
|
||||
user = self.create_user(user_info)
|
||||
else:
|
||||
user = None
|
||||
email = user_info.get("email")
|
||||
user = self.get_existing_user(sub, email)
|
||||
|
||||
return user
|
||||
|
||||
def create_user(self, claims):
|
||||
"""Return a newly created User instance."""
|
||||
|
||||
sub = claims.get("sub")
|
||||
|
||||
if sub is None:
|
||||
raise SuspiciousOperation(
|
||||
_("Claims contained no recognizable user identification")
|
||||
claims = {
|
||||
"email": email,
|
||||
"full_name": self.compute_full_name(user_info),
|
||||
"short_name": user_info.get(settings.OIDC_USERINFO_SHORTNAME_FIELD),
|
||||
}
|
||||
if not user and self.get_settings("OIDC_CREATE_USER", True):
|
||||
user = User.objects.create(
|
||||
sub=sub,
|
||||
password="!", # noqa: S106
|
||||
**claims,
|
||||
)
|
||||
|
||||
user = User.objects.create(
|
||||
sub=sub,
|
||||
email=claims.get("email"),
|
||||
password="!", # noqa: S106
|
||||
)
|
||||
if settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL:
|
||||
self.signup_to_marketing_email(email)
|
||||
|
||||
elif not user:
|
||||
return None
|
||||
|
||||
if not user.is_active:
|
||||
raise SuspiciousOperation(_("User account is disabled"))
|
||||
|
||||
self.update_user_if_needed(user, claims)
|
||||
|
||||
return user
|
||||
|
||||
@staticmethod
|
||||
def signup_to_marketing_email(email):
|
||||
"""Pragmatic approach to newsletter signup during authentication flow.
|
||||
|
||||
Details:
|
||||
1. Uses a very short timeout (1s) to prevent blocking the auth process
|
||||
2. Silently fails if the marketing service is down/slow to prioritize user experience
|
||||
3. Trade-off: May miss some signups but ensures auth flow remains fast
|
||||
|
||||
Note: For a more robust solution, consider using Async task processing (Celery/Django-Q)
|
||||
"""
|
||||
try:
|
||||
marketing_service = get_marketing_service()
|
||||
contact_data = ContactData(
|
||||
email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
|
||||
)
|
||||
marketing_service.create_contact(contact_data, timeout=1)
|
||||
except (ContactCreationError, ImproperlyConfigured, ImportError):
|
||||
pass
|
||||
|
||||
def get_existing_user(self, sub, email):
|
||||
"""Fetch existing user by sub or email."""
|
||||
try:
|
||||
return User.objects.get(sub=sub)
|
||||
except User.DoesNotExist:
|
||||
if email and settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION:
|
||||
try:
|
||||
return User.objects.get(email__iexact=email)
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
except User.MultipleObjectsReturned as e:
|
||||
raise SuspiciousOperation(
|
||||
_("Multiple user accounts share a common email.")
|
||||
) from e
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def compute_full_name(user_info):
|
||||
"""Compute user's full name based on OIDC fields in settings."""
|
||||
full_name = " ".join(
|
||||
filter(
|
||||
None,
|
||||
(
|
||||
user_info.get(field)
|
||||
for field in settings.OIDC_USERINFO_FULLNAME_FIELDS
|
||||
),
|
||||
)
|
||||
)
|
||||
return full_name or None
|
||||
|
||||
@staticmethod
|
||||
def update_user_if_needed(user, claims):
|
||||
"""Update user claims if they have changed."""
|
||||
user_fields = vars(user.__class__) # Get available model fields
|
||||
updated_claims = {
|
||||
key: value
|
||||
for key, value in claims.items()
|
||||
if value and key in user_fields and value != getattr(user, key)
|
||||
}
|
||||
|
||||
if not updated_claims:
|
||||
return
|
||||
|
||||
User.objects.filter(sub=user.sub).update(**updated_claims)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Authentication Views for the People core app."""
|
||||
|
||||
import copy
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.contrib import auth
|
||||
@@ -11,6 +12,12 @@ 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,
|
||||
)
|
||||
@@ -135,3 +142,40 @@ class OIDCLogoutCallbackView(MozillaOIDCOIDCLogoutView):
|
||||
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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Core application enums declaration
|
||||
"""
|
||||
|
||||
from django.conf import global_settings, settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"""
|
||||
Core application factories
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.utils.text import slugify
|
||||
@@ -22,6 +23,8 @@ class UserFactory(factory.django.DjangoModelFactory):
|
||||
|
||||
sub = factory.Sequence(lambda n: f"user{n!s}")
|
||||
email = factory.Faker("email")
|
||||
full_name = factory.Faker("name")
|
||||
short_name = factory.Faker("first_name")
|
||||
language = factory.fuzzy.FuzzyChoice([lang[0] for lang in settings.LANGUAGES])
|
||||
password = make_password("password")
|
||||
|
||||
@@ -31,6 +34,7 @@ class ResourceFactory(factory.django.DjangoModelFactory):
|
||||
|
||||
class Meta:
|
||||
model = models.Resource
|
||||
skip_postgeneration_save = True
|
||||
|
||||
is_public = factory.Faker("boolean", chance_of_getting_true=50)
|
||||
|
||||
@@ -44,6 +48,8 @@ class ResourceFactory(factory.django.DjangoModelFactory):
|
||||
else:
|
||||
UserResourceAccessFactory(resource=self, user=item[0], role=item[1])
|
||||
|
||||
self.save()
|
||||
|
||||
|
||||
class UserResourceAccessFactory(factory.django.DjangoModelFactory):
|
||||
"""Create fake resource user accesses for testing."""
|
||||
@@ -64,3 +70,52 @@ class RoomFactory(ResourceFactory):
|
||||
|
||||
name = factory.Faker("catch_phrase")
|
||||
slug = factory.LazyAttribute(lambda o: slugify(o.name))
|
||||
|
||||
|
||||
class RecordingFactory(factory.django.DjangoModelFactory):
|
||||
"""Create fake recording for testing."""
|
||||
|
||||
class Meta:
|
||||
model = models.Recording
|
||||
skip_postgeneration_save = True
|
||||
|
||||
room = factory.SubFactory(RoomFactory)
|
||||
status = models.RecordingStatusChoices.INITIATED
|
||||
mode = models.RecordingModeChoices.SCREEN_RECORDING
|
||||
worker_id = None
|
||||
|
||||
@factory.post_generation
|
||||
def users(self, create, extracted, **kwargs):
|
||||
"""Add users to recording from a given list of users with or without roles."""
|
||||
if create and extracted:
|
||||
for item in extracted:
|
||||
if isinstance(item, models.User):
|
||||
UserRecordingAccessFactory(recording=self, user=item)
|
||||
else:
|
||||
UserRecordingAccessFactory(
|
||||
recording=self, user=item[0], role=item[1]
|
||||
)
|
||||
|
||||
self.save()
|
||||
|
||||
|
||||
class UserRecordingAccessFactory(factory.django.DjangoModelFactory):
|
||||
"""Create fake recording user accesses for testing."""
|
||||
|
||||
class Meta:
|
||||
model = models.RecordingAccess
|
||||
|
||||
recording = factory.SubFactory(RecordingFactory)
|
||||
user = factory.SubFactory(UserFactory)
|
||||
role = factory.fuzzy.FuzzyChoice(models.RoleChoices.values)
|
||||
|
||||
|
||||
class TeamRecordingAccessFactory(factory.django.DjangoModelFactory):
|
||||
"""Create fake recording team accesses for testing."""
|
||||
|
||||
class Meta:
|
||||
model = models.RecordingAccess
|
||||
|
||||
recording = factory.SubFactory(RecordingFactory)
|
||||
team = factory.Sequence(lambda n: f"team{n}")
|
||||
role = factory.fuzzy.FuzzyChoice(models.RoleChoices.values)
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.7 on 2024-08-07 14:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0002_create_pg_trgm_extension'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='room',
|
||||
name='configuration',
|
||||
field=models.JSONField(blank=True, default=dict, help_text='Values for Visio parameters to configure the room.', verbose_name='Visio room configuration'),
|
||||
)
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.7 on 2024-08-07 14:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0003_alter_room_configuration'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='user',
|
||||
name='language',
|
||||
field=models.CharField(choices="(('en-us', 'English'), ('fr-fr', 'French'))", 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,67 @@
|
||||
# Generated by Django 5.1.1 on 2024-11-06 14:31
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0004_alter_user_language'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Recording',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('status', models.CharField(choices=[('initiated', 'Initiated'), ('active', 'Active'), ('stopped', 'Stopped'), ('saved', 'Saved'), ('aborted', 'Aborted'), ('failed_to_start', 'Failed to Start'), ('failed_to_stop', 'Failed to Stop')], default='initiated', max_length=20)),
|
||||
('worker_id', models.CharField(blank=True, help_text='Enter an identifier for the worker recording.This ID is retained even when the worker stops, allowing for easy tracking.', max_length=255, null=True, verbose_name='Worker ID')),
|
||||
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recordings', to='core.room', verbose_name='Room')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Recording',
|
||||
'verbose_name_plural': 'Recordings',
|
||||
'db_table': 'meet_recording',
|
||||
'ordering': ('-created_at',),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RecordingAccess',
|
||||
fields=[
|
||||
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created on')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated on')),
|
||||
('team', models.CharField(blank=True, max_length=100)),
|
||||
('role', models.CharField(choices=[('member', 'Member'), ('administrator', 'Administrator'), ('owner', 'Owner')], default='member', max_length=20)),
|
||||
('recording', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='accesses', to='core.recording')),
|
||||
('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Recording/user relation',
|
||||
'verbose_name_plural': 'Recording/user relations',
|
||||
'db_table': 'meet_recording_access',
|
||||
'ordering': ('-created_at',),
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='recording',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('status__in', ['active', 'initiated'])), fields=('room',), name='unique_initiated_or_active_recording_per_room'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='recordingaccess',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('user__isnull', False)), fields=('user', 'recording'), name='unique_recording_user', violation_error_message='This user is already in this recording.'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='recordingaccess',
|
||||
constraint=models.UniqueConstraint(condition=models.Q(('team__gt', '')), fields=('team', 'recording'), name='unique_recording_team', violation_error_message='This team is already in this recording.'),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='recordingaccess',
|
||||
constraint=models.CheckConstraint(condition=models.Q(models.Q(('team', ''), ('user__isnull', False)), models.Q(('team__gt', ''), ('user__isnull', True)), _connector='OR'), name='check_recording_access_either_user_or_team', violation_error_message='Either user or team must be set, not both.'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,89 @@
|
||||
|
||||
from django.db import migrations
|
||||
from django.db.models import Count
|
||||
from core.models import RoleChoices
|
||||
|
||||
def merge_duplicate_user_accounts(apps, schema_editor):
|
||||
"""Merge user accounts that share the same email address.
|
||||
|
||||
Historical Context:
|
||||
Previously, ProConnect authentication could return users with the same email
|
||||
but different sub, leading to duplicate user accounts. While the application
|
||||
now prevents this scenario, this migration is needed to clean up existing
|
||||
duplicate accounts to ensure users can continue to connect without being blocked
|
||||
by unique email constraints.
|
||||
|
||||
Performance of this migration is poor, this implementation prioritizes readability
|
||||
and maintainability. Consider refactoring this code to avoid individual db queries
|
||||
on each iteration.
|
||||
"""
|
||||
|
||||
User = apps.get_model('core', 'User')
|
||||
ResourceAccess = apps.get_model('core', 'ResourceAccess')
|
||||
|
||||
emails_with_duplicates = (
|
||||
User.objects.values('email')
|
||||
.annotate(count=Count('id'))
|
||||
.filter(count__gt=1)
|
||||
.values_list('email', flat=True)
|
||||
)
|
||||
|
||||
for email in emails_with_duplicates:
|
||||
# Keep the oldest user
|
||||
primary_user = User.objects.filter(email=email).order_by('created_at').first()
|
||||
duplicate_user_accounts = User.objects.filter(email=email).exclude(id=primary_user.id)
|
||||
|
||||
# Get IDs of duplicate accounts to be merged
|
||||
duplicate_account_ids = list(duplicate_user_accounts.values_list('id', flat=True))
|
||||
resource_accesses_to_transfer = ResourceAccess.objects.filter(user_id__in=duplicate_account_ids)
|
||||
|
||||
# Transfer resource access permissions to primary user
|
||||
# This process handles role hierarchy where:
|
||||
# OWNER > ADMIN > MEMBER
|
||||
for resource_access in resource_accesses_to_transfer:
|
||||
|
||||
# Determine if primary user already has access to this resource
|
||||
existing_primary_access = ResourceAccess.objects.filter(
|
||||
user_id=primary_user.id,
|
||||
resource_id=resource_access.resource.id
|
||||
).first()
|
||||
|
||||
if existing_primary_access:
|
||||
# Skip if primary user is already OWNER as it's the highest privilege level
|
||||
# No need to modify or downgrade owner access
|
||||
if existing_primary_access.role == RoleChoices.OWNER:
|
||||
continue
|
||||
|
||||
# Skip if primary user already has the exact same role
|
||||
# No need to update when roles match
|
||||
elif existing_primary_access.role == resource_access.role:
|
||||
continue
|
||||
|
||||
# Skip if new role is MEMBER since user already has base access
|
||||
# All existing access includes at least MEMBER privileges
|
||||
elif resource_access.role == RoleChoices.MEMBER:
|
||||
continue
|
||||
|
||||
# Update the role only if it represents a higher privilege level
|
||||
# Preserves existing access record while updating the role
|
||||
existing_primary_access.role = resource_access.role
|
||||
existing_primary_access.save()
|
||||
else:
|
||||
# Transfer access to primary user
|
||||
resource_access.user_id = primary_user.id
|
||||
resource_access.save()
|
||||
|
||||
# Delete duplicate accounts - CASCADE will automatically remove any untransferred
|
||||
# ResourceAccess records and other related data for these users
|
||||
duplicate_user_accounts.delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0005_recording_recordingaccess_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(merge_duplicate_user_accounts, reverse_code=migrations.RunPython.noop),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.1 on 2024-11-12 10:59
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0006_merge_duplicate_users'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='recording',
|
||||
name='mode',
|
||||
field=models.CharField(choices=[('screen_recording', 'SCREEN_RECORDING'), ('transcript', 'TRANSCRIPT')], default='screen_recording', help_text='Defines the mode of recording being called.', max_length=20, verbose_name='Recording mode'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.1.1 on 2024-11-13 09:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0007_recording_mode'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='full_name',
|
||||
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='full name'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='short_name',
|
||||
field=models.CharField(blank=True, max_length=100, null=True, verbose_name='short name'),
|
||||
)
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.3 on 2024-12-02 13:23
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0008_user_full_name_user_short_name'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='recording',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('initiated', 'Initiated'), ('active', 'Active'), ('stopped', 'Stopped'), ('saved', 'Saved'), ('aborted', 'Aborted'), ('failed_to_start', 'Failed to Start'), ('failed_to_stop', 'Failed to Stop'), ('notification_succeeded', 'Notification succeeded')], default='initiated', max_length=50),
|
||||
),
|
||||
]
|
||||
+291
-4
@@ -1,8 +1,10 @@
|
||||
"""
|
||||
Declare and configure the models for the Meet core application
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from logging import getLogger
|
||||
from typing import List
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import models as auth_models
|
||||
@@ -37,6 +39,47 @@ class RoleChoices(models.TextChoices):
|
||||
return role == cls.OWNER
|
||||
|
||||
|
||||
class RecordingStatusChoices(models.TextChoices):
|
||||
"""Enumeration of possible states for a recording operation."""
|
||||
|
||||
INITIATED = "initiated", _("Initiated")
|
||||
ACTIVE = "active", _("Active")
|
||||
STOPPED = "stopped", _("Stopped")
|
||||
SAVED = "saved", _("Saved")
|
||||
ABORTED = "aborted", _("Aborted")
|
||||
FAILED_TO_START = "failed_to_start", _("Failed to Start")
|
||||
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
|
||||
NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded")
|
||||
|
||||
@classmethod
|
||||
def is_final(cls, status):
|
||||
"""Determine if the recording status represents a final state.
|
||||
|
||||
A final status indicates the recording flow has completed, either
|
||||
successfully or unsuccessfully.
|
||||
"""
|
||||
|
||||
return status in {
|
||||
cls.STOPPED,
|
||||
cls.SAVED,
|
||||
cls.ABORTED,
|
||||
cls.FAILED_TO_START,
|
||||
cls.FAILED_TO_STOP,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def is_unsuccessful(cls, status):
|
||||
"""Determine if the recording status represents an unsuccessful state."""
|
||||
return status in {cls.ABORTED, cls.FAILED_TO_START, cls.FAILED_TO_STOP}
|
||||
|
||||
|
||||
class RecordingModeChoices(models.TextChoices):
|
||||
"""Recording mode choices."""
|
||||
|
||||
SCREEN_RECORDING = "screen_recording", _("SCREEN_RECORDING")
|
||||
TRANSCRIPT = "transcript", _("TRANSCRIPT")
|
||||
|
||||
|
||||
class BaseModel(models.Model):
|
||||
"""
|
||||
Serves as an abstract base model for other models, ensuring that records are validated
|
||||
@@ -99,11 +142,14 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
email = models.EmailField(_("identity email address"), blank=True, null=True)
|
||||
|
||||
# Unlike the "email" field which stores the email coming from the OIDC token, this field
|
||||
# stores the email used by staff users to login to the admin site
|
||||
# stores the email used by staff users to log in to the admin site
|
||||
admin_email = models.EmailField(
|
||||
_("admin email address"), unique=True, blank=True, null=True
|
||||
)
|
||||
|
||||
full_name = models.CharField(_("full name"), max_length=100, null=True, blank=True)
|
||||
short_name = models.CharField(
|
||||
_("short name"), max_length=100, null=True, blank=True
|
||||
)
|
||||
language = models.CharField(
|
||||
max_length=10,
|
||||
choices=lazy(lambda: settings.LANGUAGES, tuple)(),
|
||||
@@ -143,6 +189,7 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
|
||||
class Meta:
|
||||
db_table = "meet_user"
|
||||
ordering = ("-created_at",)
|
||||
verbose_name = _("user")
|
||||
verbose_name_plural = _("users")
|
||||
|
||||
@@ -163,10 +210,38 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
return []
|
||||
|
||||
|
||||
def get_resource_roles(resource: models.Model, user: User) -> List[str]:
|
||||
"""
|
||||
Get all roles assigned to a user for a specific resource, including team-based roles.
|
||||
|
||||
Args:
|
||||
resource: The resource to check permissions for
|
||||
user: The user to get roles for
|
||||
|
||||
Returns:
|
||||
List of role strings assigned to the user
|
||||
"""
|
||||
if not user.is_authenticated:
|
||||
return []
|
||||
|
||||
# Use pre-annotated roles if available from viewset optimization
|
||||
if hasattr(resource, "user_roles"):
|
||||
return resource.user_roles or []
|
||||
|
||||
try:
|
||||
return list(
|
||||
resource.accesses.filter_user(user)
|
||||
.values_list("role", flat=True)
|
||||
.distinct()
|
||||
)
|
||||
except (IndexError, models.ObjectDoesNotExist):
|
||||
return []
|
||||
|
||||
|
||||
class Resource(BaseModel):
|
||||
"""Model to define access control"""
|
||||
|
||||
is_public = models.BooleanField(default=settings.DEFAULT_ROOM_IS_PUBLIC)
|
||||
is_public = models.BooleanField(default=settings.RESOURCE_DEFAULT_IS_PUBLIC)
|
||||
users = models.ManyToManyField(
|
||||
User,
|
||||
through="ResourceAccess",
|
||||
@@ -230,6 +305,7 @@ class ResourceAccess(BaseModel):
|
||||
|
||||
class Meta:
|
||||
db_table = "meet_resource_access"
|
||||
ordering = ("-created_at",)
|
||||
verbose_name = _("Resource access")
|
||||
verbose_name_plural = _("Resource accesses")
|
||||
constraints = [
|
||||
@@ -288,7 +364,7 @@ class Room(Resource):
|
||||
|
||||
configuration = models.JSONField(
|
||||
blank=True,
|
||||
default={},
|
||||
default=dict,
|
||||
verbose_name=_("Visio room configuration"),
|
||||
help_text=_("Values for Visio parameters to configure the room."),
|
||||
)
|
||||
@@ -317,3 +393,214 @@ class Room(Resource):
|
||||
else:
|
||||
raise ValidationError({"name": f'Room name "{self.name:s}" is reserved.'})
|
||||
super().clean_fields(exclude=exclude)
|
||||
|
||||
|
||||
class BaseAccessManager(models.Manager):
|
||||
"""Base manager for handling resource access control."""
|
||||
|
||||
def filter_user(self, user):
|
||||
"""Filter accesses for a given user, including both direct and team-based access."""
|
||||
return self.filter(models.Q(user=user) | models.Q(team__in=user.get_teams()))
|
||||
|
||||
|
||||
class BaseAccess(BaseModel):
|
||||
"""Base model for accesses to handle resources."""
|
||||
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
team = models.CharField(max_length=100, blank=True)
|
||||
role = models.CharField(
|
||||
max_length=20, choices=RoleChoices.choices, default=RoleChoices.MEMBER
|
||||
)
|
||||
|
||||
objects = BaseAccessManager()
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def _get_abilities(self, resource, user):
|
||||
"""
|
||||
Compute and return abilities for a given user taking into account
|
||||
the current state of the object.
|
||||
"""
|
||||
|
||||
roles = get_resource_roles(resource, user)
|
||||
|
||||
is_owner = RoleChoices.OWNER in roles
|
||||
has_privileges = is_owner or RoleChoices.ADMIN in roles
|
||||
|
||||
# Default values for unprivileged users
|
||||
set_role_to = set()
|
||||
can_delete = False
|
||||
|
||||
# Special handling when modifying an owner's access
|
||||
if self.role == RoleChoices.OWNER:
|
||||
# Prevent orphaning the resource
|
||||
can_delete = (
|
||||
is_owner
|
||||
and resource.accesses.filter(role=RoleChoices.OWNER).count() > 1
|
||||
)
|
||||
if can_delete:
|
||||
set_role_to = {RoleChoices.ADMIN, RoleChoices.OWNER, RoleChoices.MEMBER}
|
||||
elif has_privileges:
|
||||
can_delete = True
|
||||
set_role_to = {RoleChoices.ADMIN, RoleChoices.MEMBER}
|
||||
if is_owner:
|
||||
set_role_to.add(RoleChoices.OWNER)
|
||||
|
||||
# Remove the current role as we don't want to propose it as an option
|
||||
set_role_to.discard(self.role)
|
||||
|
||||
return {
|
||||
"destroy": can_delete,
|
||||
"update": bool(set_role_to),
|
||||
"partial_update": bool(set_role_to),
|
||||
"retrieve": bool(roles),
|
||||
"set_role_to": sorted(r.value for r in set_role_to),
|
||||
}
|
||||
|
||||
|
||||
class Recording(BaseModel):
|
||||
"""Model for recordings that take place in a room.
|
||||
|
||||
Recording Status Flow:
|
||||
1. INITIATED: Initial state when recording is requested
|
||||
2. ACTIVE: Recording is currently in progress
|
||||
3. STOPPED: Recording has been stopped by user/system
|
||||
4. SAVED: Recording has been successfully processed and stored
|
||||
4. NOTIFICATION_SUCCEEDED: External service has been notified of this recording
|
||||
|
||||
Error States:
|
||||
- FAILED_TO_START: Worker failed to initialize recording
|
||||
- FAILED_TO_STOP: Worker failed during stop operation
|
||||
- ABORTED: Recording was terminated before completion
|
||||
|
||||
Warning: Worker failures may lead to database inconsistency between the actual
|
||||
recording state and its status in the database.
|
||||
"""
|
||||
|
||||
room = models.ForeignKey(
|
||||
Room,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="recordings",
|
||||
verbose_name=_("Room"),
|
||||
)
|
||||
status = models.CharField(
|
||||
max_length=50,
|
||||
choices=RecordingStatusChoices.choices,
|
||||
default=RecordingStatusChoices.INITIATED,
|
||||
)
|
||||
worker_id = models.CharField(
|
||||
max_length=255,
|
||||
null=True,
|
||||
blank=True,
|
||||
verbose_name=_("Worker ID"),
|
||||
help_text=_(
|
||||
"Enter an identifier for the worker recording."
|
||||
"This ID is retained even when the worker stops, allowing for easy tracking."
|
||||
),
|
||||
)
|
||||
mode = models.CharField(
|
||||
max_length=20,
|
||||
choices=RecordingModeChoices.choices,
|
||||
default=RecordingModeChoices.SCREEN_RECORDING,
|
||||
verbose_name=_("Recording mode"),
|
||||
help_text=_("Defines the mode of recording being called."),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "meet_recording"
|
||||
ordering = ("-created_at",)
|
||||
verbose_name = _("Recording")
|
||||
verbose_name_plural = _("Recordings")
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["room"],
|
||||
condition=models.Q(
|
||||
status__in=[
|
||||
RecordingStatusChoices.ACTIVE,
|
||||
RecordingStatusChoices.INITIATED,
|
||||
]
|
||||
),
|
||||
name="unique_initiated_or_active_recording_per_room",
|
||||
)
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"Recording {self.id} ({self.status})"
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""Compute and return abilities for a given user on the recording."""
|
||||
|
||||
roles = set(get_resource_roles(self, user))
|
||||
|
||||
is_owner_or_admin = bool(
|
||||
roles.intersection({RoleChoices.OWNER, RoleChoices.ADMIN})
|
||||
)
|
||||
|
||||
is_final_status = RecordingStatusChoices.is_final(self.status)
|
||||
|
||||
return {
|
||||
"destroy": is_owner_or_admin and is_final_status,
|
||||
"partial_update": False,
|
||||
"retrieve": is_owner_or_admin,
|
||||
"stop": is_owner_or_admin and not is_final_status,
|
||||
"update": False,
|
||||
}
|
||||
|
||||
def is_savable(self) -> bool:
|
||||
"""Determine if the recording can be saved based on its current status."""
|
||||
|
||||
return self.status in {
|
||||
RecordingStatusChoices.ACTIVE,
|
||||
RecordingStatusChoices.STOPPED,
|
||||
}
|
||||
|
||||
|
||||
class RecordingAccess(BaseAccess):
|
||||
"""Relation model to give access to a recording for a user or a team with a role."""
|
||||
|
||||
recording = models.ForeignKey(
|
||||
Recording,
|
||||
on_delete=models.CASCADE,
|
||||
related_name="accesses",
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "meet_recording_access"
|
||||
ordering = ("-created_at",)
|
||||
verbose_name = _("Recording/user relation")
|
||||
verbose_name_plural = _("Recording/user relations")
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["user", "recording"],
|
||||
condition=models.Q(user__isnull=False), # Exclude null users
|
||||
name="unique_recording_user",
|
||||
violation_error_message=_("This user is already in this recording."),
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=["team", "recording"],
|
||||
condition=models.Q(team__gt=""), # Exclude empty string teams
|
||||
name="unique_recording_team",
|
||||
violation_error_message=_("This team is already in this recording."),
|
||||
),
|
||||
models.CheckConstraint(
|
||||
condition=models.Q(user__isnull=False, team="")
|
||||
| models.Q(user__isnull=True, team__gt=""),
|
||||
name="check_recording_access_either_user_or_team",
|
||||
violation_error_message=_("Either user or team must be set, not both."),
|
||||
),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user!s} is {self.role:s} in {self.recording!s}"
|
||||
|
||||
def get_abilities(self, user):
|
||||
"""
|
||||
Compute and return abilities for a given user on the recording access.
|
||||
"""
|
||||
return self._get_abilities(self.recording, user)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Meet event parser classes, authentication and exceptions."""
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Authentication class for storage event token validation."""
|
||||
|
||||
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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MachineUser:
|
||||
"""Represent a non-interactive system user for automated storage operations."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.pk = None
|
||||
self.username = "storage_event_user"
|
||||
self.is_active = True
|
||||
|
||||
@property
|
||||
def is_authenticated(self):
|
||||
"""Indicate if this machine user is authenticated."""
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_anonymous(self) -> bool:
|
||||
"""Indicate if this is an anonymous user."""
|
||||
return False
|
||||
|
||||
def get_username(self) -> str:
|
||||
"""Return the machine user identifier."""
|
||||
return self.username
|
||||
|
||||
|
||||
class StorageEventAuthentication(BaseAuthentication):
|
||||
"""Authenticate requests using a Bearer token for storage event integration.
|
||||
This class validates Bearer tokens for storage events that don't map to database users.
|
||||
It's designed for S3-compatible storage integrations and similar use cases.
|
||||
Events are submitted when a webhook is configured on some bucket's events.
|
||||
"""
|
||||
|
||||
AUTH_HEADER = "Authorization"
|
||||
TOKEN_TYPE = "Bearer" # noqa S105
|
||||
|
||||
def authenticate(self, request):
|
||||
"""Validate the Bearer token from the Authorization header."""
|
||||
|
||||
if not settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
|
||||
return MachineUser(), None
|
||||
|
||||
required_token = settings.RECORDING_STORAGE_EVENT_TOKEN
|
||||
if not required_token:
|
||||
if settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH:
|
||||
raise AuthenticationFailed(
|
||||
_("Authentication is enabled but token is not configured.")
|
||||
)
|
||||
|
||||
return MachineUser(), None
|
||||
|
||||
auth_header = request.headers.get(self.AUTH_HEADER)
|
||||
|
||||
if not auth_header:
|
||||
logger.warning(
|
||||
"Authentication failed: Missing Authorization header (ip: %s)",
|
||||
request.META.get("REMOTE_ADDR"),
|
||||
)
|
||||
raise AuthenticationFailed(_("Authorization header is required"))
|
||||
|
||||
auth_parts = auth_header.split(" ")
|
||||
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
|
||||
logger.warning(
|
||||
"Authentication failed: Invalid authorization header (ip: %s)",
|
||||
request.META.get("REMOTE_ADDR"),
|
||||
)
|
||||
raise AuthenticationFailed(_("Invalid authorization header."))
|
||||
|
||||
token = auth_parts[1]
|
||||
|
||||
# Use constant-time comparison to prevent timing attacks
|
||||
if not secrets.compare_digest(token.encode(), required_token.encode()):
|
||||
logger.warning(
|
||||
"Authentication failed: Invalid token (ip: %s)",
|
||||
request.META.get("REMOTE_ADDR"),
|
||||
)
|
||||
raise AuthenticationFailed(_("Invalid token"))
|
||||
|
||||
return MachineUser(), token
|
||||
|
||||
def authenticate_header(self, request):
|
||||
"""Return the WWW-Authenticate header value."""
|
||||
return f"{self.TOKEN_TYPE} realm='Storage event API'"
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Storage parsers specific exceptions."""
|
||||
|
||||
|
||||
class ParsingEventDataError(Exception):
|
||||
"""Raised when the request data is malformed, incomplete, or missing."""
|
||||
|
||||
|
||||
class InvalidBucketError(Exception):
|
||||
"""Raised when the bucket name in the request does not match the expected one."""
|
||||
|
||||
|
||||
class InvalidFileTypeError(Exception):
|
||||
"""Raised when the file type in the request is not supported."""
|
||||
|
||||
|
||||
class InvalidFilepathError(Exception):
|
||||
"""Raised when the filepath in the request is invalid."""
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Service to notify external services when a new recording is ready."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import requests
|
||||
|
||||
from core import models
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotificationService:
|
||||
"""Service for processing recordings and notifying external services."""
|
||||
|
||||
def notify_external_services(self, recording):
|
||||
"""Process a recording based on its mode."""
|
||||
|
||||
if recording.mode == models.RecordingModeChoices.TRANSCRIPT:
|
||||
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
|
||||
|
||||
logger.error(
|
||||
"Unknown recording mode %s for recording %s",
|
||||
recording.mode,
|
||||
recording.id,
|
||||
)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service(recording):
|
||||
"""Notify summary service about a new recording."""
|
||||
|
||||
if (
|
||||
not settings.SUMMARY_SERVICE_ENDPOINT
|
||||
or not settings.SUMMARY_SERVICE_API_TOKEN
|
||||
):
|
||||
logger.error("Summary service not configured")
|
||||
return False
|
||||
|
||||
owner_access = (
|
||||
models.RecordingAccess.objects.select_related("user")
|
||||
.filter(
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording_id=recording.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
if not owner_access:
|
||||
logger.error("No owner found for recording %s", recording.id)
|
||||
return False
|
||||
|
||||
key = f"{settings.RECORDING_OUTPUT_FOLDER}/{recording.id}.ogg"
|
||||
|
||||
payload = {
|
||||
"filename": key,
|
||||
"email": owner_access.user.email,
|
||||
"sub": owner_access.user.sub,
|
||||
}
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {settings.SUMMARY_SERVICE_API_TOKEN}",
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
settings.SUMMARY_SERVICE_ENDPOINT,
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as exc:
|
||||
logger.exception(
|
||||
"Summary service HTTP error for recording %s. URL: %s. Exception: %s",
|
||||
recording.id,
|
||||
settings.SUMMARY_SERVICE_ENDPOINT,
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
notification_service = NotificationService()
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Meet storage event parser classes."""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import Any, Dict, Optional, Protocol
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
from .exceptions import (
|
||||
InvalidBucketError,
|
||||
InvalidFilepathError,
|
||||
InvalidFileTypeError,
|
||||
ParsingEventDataError,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StorageEvent:
|
||||
"""Represents a storage event with relevant metadata.
|
||||
Attributes:
|
||||
filepath: Identifier for the affected recording
|
||||
filetype: Type of storage event
|
||||
bucket_name: When the event occurred
|
||||
metadata: Additional event data
|
||||
"""
|
||||
|
||||
filepath: str
|
||||
filetype: str
|
||||
bucket_name: str
|
||||
metadata: Optional[Dict[str, Any]]
|
||||
|
||||
def __post_init__(self):
|
||||
if self.filepath is None:
|
||||
raise TypeError("filepath cannot be None")
|
||||
if self.filetype is None:
|
||||
raise TypeError("filetype cannot be None")
|
||||
if self.bucket_name is None:
|
||||
raise TypeError("bucket_name cannot be None")
|
||||
|
||||
|
||||
class EventParser(Protocol):
|
||||
"""Interface for parsing storage events."""
|
||||
|
||||
def __init__(self, bucket_name, allowed_filetypes=None):
|
||||
"""Initialize parser with bucket name and optional allowed filetypes."""
|
||||
|
||||
def parse(self, data: Dict) -> StorageEvent:
|
||||
"""Extract storage event data from raw dictionary input."""
|
||||
|
||||
def validate(self, data: StorageEvent) -> None:
|
||||
"""Verify storage event data meets all requirements."""
|
||||
|
||||
def get_recording_id(self, data: Dict) -> str:
|
||||
"""Extract recording ID from event dictionary."""
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_parser() -> EventParser:
|
||||
"""Return cached instance of configured event parser.
|
||||
Uses function memoization instead of a factory class since the only
|
||||
varying parameter is the parser class from settings. A factory class
|
||||
would add unnecessary complexity when a cached function provides the
|
||||
same singleton behavior with simpler code.
|
||||
"""
|
||||
|
||||
event_parser_cls = import_string(settings.RECORDING_EVENT_PARSER_CLASS)
|
||||
return event_parser_cls(bucket_name=settings.AWS_STORAGE_BUCKET_NAME)
|
||||
|
||||
|
||||
class MinioParser:
|
||||
"""Handle parsing and validation of Minio storage events."""
|
||||
|
||||
def __init__(self, bucket_name: str, allowed_filetypes=None):
|
||||
"""Initialize parser with target bucket name and accepted filetypes."""
|
||||
|
||||
if not bucket_name:
|
||||
raise ValueError("Bucket name cannot be None or empty")
|
||||
|
||||
self._bucket_name = bucket_name
|
||||
self._allowed_filetypes = allowed_filetypes or {"audio/ogg", "video/mp4"}
|
||||
|
||||
# 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]+)"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def parse(data):
|
||||
"""Convert raw Minio event dictionary to StorageEvent object."""
|
||||
|
||||
if not data:
|
||||
raise ParsingEventDataError("Received empty data.")
|
||||
|
||||
try:
|
||||
record = data["Records"][0]
|
||||
s3 = record["s3"]
|
||||
bucket_name = s3["bucket"]["name"]
|
||||
file_object = s3["object"]
|
||||
filepath = file_object["key"]
|
||||
filetype = file_object["contentType"]
|
||||
except (KeyError, IndexError) as e:
|
||||
raise ParsingEventDataError(f"Missing or malformed key: {e}.") from e
|
||||
try:
|
||||
return StorageEvent(
|
||||
filepath=filepath,
|
||||
filetype=filetype,
|
||||
bucket_name=bucket_name,
|
||||
metadata=None,
|
||||
)
|
||||
except TypeError as e:
|
||||
raise ParsingEventDataError(f"Missing essential data fields: {e}") from e
|
||||
|
||||
def validate(self, event_data: StorageEvent) -> str:
|
||||
"""Verify StorageEvent matches bucket, filetype and filepath requirements."""
|
||||
|
||||
if event_data.bucket_name != self._bucket_name:
|
||||
raise InvalidBucketError(
|
||||
f"Invalid bucket: expected {self._bucket_name}, got {event_data.bucket_name}"
|
||||
)
|
||||
|
||||
if not event_data.filetype in self._allowed_filetypes:
|
||||
raise InvalidFileTypeError(
|
||||
f"Invalid file type, expected {self._allowed_filetypes},"
|
||||
f"got '{event_data.filetype}'"
|
||||
)
|
||||
|
||||
match = self._filepath_regex.match(event_data.filepath)
|
||||
if not match:
|
||||
raise InvalidFilepathError(
|
||||
f"Invalid filepath structure: {event_data.filepath}"
|
||||
)
|
||||
|
||||
recording_id = match.group("recording_id")
|
||||
return recording_id
|
||||
|
||||
def get_recording_id(self, data):
|
||||
"""Extract recording ID from Minio event through parsing and validation."""
|
||||
|
||||
event_data = self.parse(data)
|
||||
recording_id = self.validate(event_data)
|
||||
|
||||
return recording_id
|
||||
@@ -0,0 +1 @@
|
||||
"""Meet worker services classes and exceptions."""
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Recording and worker services specific exceptions."""
|
||||
|
||||
|
||||
class WorkerRequestError(Exception):
|
||||
"""Raised when there is an issue with the worker request"""
|
||||
|
||||
|
||||
class WorkerConnectionError(Exception):
|
||||
"""Raised when there is an issue connecting to the worker."""
|
||||
|
||||
|
||||
class WorkerResponseError(Exception):
|
||||
"""Raised when the worker's response is not as expected."""
|
||||
|
||||
|
||||
class RecordingStartError(Exception):
|
||||
"""Raised when there is an error starting the recording."""
|
||||
|
||||
|
||||
class RecordingStopError(Exception):
|
||||
"""Raised when there is an error stopping the recording."""
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Factory, configurations and Protocol to create worker services"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import Any, ClassVar, Dict, Optional, Protocol, Type
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkerServiceConfig:
|
||||
"""Declare Worker Service common configurations"""
|
||||
|
||||
output_folder: str
|
||||
server_configurations: Dict[str, Any]
|
||||
verify_ssl: Optional[bool]
|
||||
bucket_args: Optional[dict]
|
||||
|
||||
@classmethod
|
||||
@lru_cache
|
||||
def from_settings(cls) -> "WorkerServiceConfig":
|
||||
"""Load configuration from Django settings with caching for efficiency."""
|
||||
|
||||
logger.debug("Loading WorkerServiceConfig from settings.")
|
||||
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,
|
||||
"secret": settings.AWS_S3_SECRET_ACCESS_KEY,
|
||||
"region": settings.AWS_S3_REGION_NAME,
|
||||
"bucket": settings.AWS_STORAGE_BUCKET_NAME,
|
||||
"force_path_style": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class WorkerService(Protocol):
|
||||
"""Define the interface for interacting with a worker service."""
|
||||
|
||||
hrid: ClassVar[str]
|
||||
|
||||
def __init__(self, config: WorkerServiceConfig):
|
||||
"""Initialize the service with the given configuration."""
|
||||
|
||||
def start(self, room_id: str, recording_id: str) -> str:
|
||||
"""Start a recording for a specified room."""
|
||||
|
||||
def stop(self, worker_id: str) -> str:
|
||||
"""Stop recording for a specified worker."""
|
||||
|
||||
|
||||
def get_worker_service(mode: str) -> WorkerService:
|
||||
"""Instantiate a worker service by its mode."""
|
||||
|
||||
worker_registry: Dict[str, str] = settings.RECORDING_WORKER_CLASSES
|
||||
|
||||
try:
|
||||
worker_class_path = worker_registry[mode]
|
||||
except KeyError as e:
|
||||
raise ValueError(
|
||||
f"Recording mode '{mode}' not found in RECORDING_WORKER_CLASSES. "
|
||||
f"Available modes: {list(worker_registry.keys())}"
|
||||
) from e
|
||||
|
||||
worker_class: Type[WorkerService] = import_string(worker_class_path)
|
||||
|
||||
config = WorkerServiceConfig.from_settings()
|
||||
return worker_class(config=config)
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Mediator between the worker service and recording instances in the Django ORM."""
|
||||
|
||||
import logging
|
||||
|
||||
from core.models import Recording, RecordingStatusChoices
|
||||
|
||||
from .exceptions import (
|
||||
RecordingStartError,
|
||||
RecordingStopError,
|
||||
WorkerConnectionError,
|
||||
WorkerRequestError,
|
||||
WorkerResponseError,
|
||||
)
|
||||
from .factories import WorkerService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WorkerServiceMediator:
|
||||
"""Mediate interactions between a worker service and a recording instance.
|
||||
|
||||
A mediator class that decouples the worker from Django ORM, handles recording updates
|
||||
based on worker status, and transforms worker errors into user-friendly exceptions.
|
||||
Implements Mediator pattern.
|
||||
"""
|
||||
|
||||
def __init__(self, worker_service: WorkerService):
|
||||
"""Initialize the WorkerServiceMediator with the provided worker service."""
|
||||
|
||||
self._worker_service = worker_service
|
||||
|
||||
def start(self, recording: Recording):
|
||||
"""Start the recording process using the worker service.
|
||||
|
||||
If the operation is successful, the recording's status will
|
||||
transition from INITIATED to ACTIVE, else to FAILED_TO_START to keep track of errors.
|
||||
|
||||
Args:
|
||||
recording (Recording): The recording instance to start.
|
||||
Raises:
|
||||
RecordingStartError: If there is an error starting the recording.
|
||||
"""
|
||||
|
||||
if recording.status != RecordingStatusChoices.INITIATED:
|
||||
logger.error("Cannot start recording in %s status.", recording.status)
|
||||
raise RecordingStartError()
|
||||
|
||||
room_name = str(recording.room.id)
|
||||
try:
|
||||
worker_id = self._worker_service.start(room_name, recording.id)
|
||||
except (WorkerRequestError, WorkerConnectionError, WorkerResponseError) as e:
|
||||
logger.exception(
|
||||
"Failed to start recording for room %s: %s", recording.room.slug, e
|
||||
)
|
||||
recording.status = RecordingStatusChoices.FAILED_TO_START
|
||||
raise RecordingStartError() from e
|
||||
else:
|
||||
recording.worker_id = worker_id
|
||||
recording.status = RecordingStatusChoices.ACTIVE
|
||||
finally:
|
||||
recording.save()
|
||||
|
||||
logger.info(
|
||||
"Worker started for room %s (worker ID: %s)",
|
||||
recording.room,
|
||||
recording.worker_id,
|
||||
)
|
||||
|
||||
def stop(self, recording: Recording):
|
||||
"""Stop the recording process using the worker service.
|
||||
|
||||
If the operation is successful, the recording's status will transition
|
||||
from ACTIVE to STOPPED, else to FAILED_TO_STOP to keep track of errors.
|
||||
|
||||
Args:
|
||||
recording (Recording): The recording instance to stop.
|
||||
Raises:
|
||||
RecordingStopError: If there is an error stopping the recording.
|
||||
"""
|
||||
|
||||
if recording.status != RecordingStatusChoices.ACTIVE:
|
||||
logger.error("Cannot stop recording in %s status.", recording.status)
|
||||
raise RecordingStopError()
|
||||
|
||||
try:
|
||||
response = self._worker_service.stop(worker_id=recording.worker_id)
|
||||
except (WorkerConnectionError, WorkerResponseError) as e:
|
||||
logger.exception(
|
||||
"Failed to stop recording for room %s: %s", recording.room.slug, e
|
||||
)
|
||||
recording.status = RecordingStatusChoices.FAILED_TO_STOP
|
||||
raise RecordingStopError() from e
|
||||
else:
|
||||
recording.status = RecordingStatusChoices[response]
|
||||
finally:
|
||||
recording.save()
|
||||
|
||||
logger.info("Worker stopped for room %s", recording.room)
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Worker services in charge of recording a room."""
|
||||
|
||||
# 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 .exceptions import WorkerConnectionError, WorkerResponseError
|
||||
from .factories import WorkerServiceConfig
|
||||
|
||||
|
||||
class BaseEgressService:
|
||||
"""Base egress defining common methods to manage and interact with LiveKit egress processes."""
|
||||
|
||||
def __init__(self, config: WorkerServiceConfig):
|
||||
self._config = config
|
||||
self._s3 = livekit_api.S3Upload(**config.bucket_args)
|
||||
|
||||
def _get_filepath(self, filename: str, extension: str) -> str:
|
||||
"""Construct the file path for a given filename and extension.
|
||||
Unsecure method, doesn't handle paths robustly and securely.
|
||||
"""
|
||||
return f"{self._config.output_folder}/{filename}.{extension}"
|
||||
|
||||
@async_to_sync
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
return response
|
||||
|
||||
def stop(self, worker_id: str) -> str:
|
||||
"""Stop an ongoing egress worker.
|
||||
The StopEgressRequest is shared among all types of egress,
|
||||
so a single implementation in the base class should be sufficient.
|
||||
"""
|
||||
|
||||
request = livekit_api.StopEgressRequest(
|
||||
egress_id=worker_id,
|
||||
)
|
||||
|
||||
response = self._handle_request(request, "stop_egress")
|
||||
|
||||
if not response.status:
|
||||
raise WorkerResponseError(
|
||||
"LiveKit response is missing the recording status."
|
||||
)
|
||||
|
||||
# To avoid exposing EgressStatus values and coupling with LiveKit outside of this class,
|
||||
# the response status is mapped to simpler "ABORTED", "STOPPED" or "FAILED_TO_STOP" strings.
|
||||
if response.status == livekit_api.EgressStatus.EGRESS_ABORTED:
|
||||
return "ABORTED"
|
||||
|
||||
if response.status == livekit_api.EgressStatus.EGRESS_ENDING:
|
||||
return "STOPPED"
|
||||
|
||||
return "FAILED_TO_STOP"
|
||||
|
||||
def start(self, room_name, recording_id):
|
||||
"""Start the egress process for a recording (not implemented in the base class).
|
||||
Each derived class must implement this method, providing the necessary parameters for
|
||||
its specific egress type (e.g. audio_only, streaming output).
|
||||
"""
|
||||
raise NotImplementedError("Subclass must implement this method.")
|
||||
|
||||
|
||||
class VideoCompositeEgressService(BaseEgressService):
|
||||
"""Record multiple participant video and audio tracks into a single output '.mp4' file."""
|
||||
|
||||
hrid = "video-recording-composite-livekit-egress"
|
||||
|
||||
def start(self, room_name, recording_id):
|
||||
"""Start the video composite egress process for a recording."""
|
||||
|
||||
# Save room's recording as a mp4 video file.
|
||||
file_type = livekit_api.EncodedFileType.MP4
|
||||
filepath = self._get_filepath(filename=recording_id, extension="mp4")
|
||||
|
||||
file_output = livekit_api.EncodedFileOutput(
|
||||
file_type=file_type,
|
||||
filepath=filepath,
|
||||
s3=self._s3,
|
||||
)
|
||||
|
||||
request = livekit_api.RoomCompositeEgressRequest(
|
||||
room_name=room_name,
|
||||
file_outputs=[file_output],
|
||||
)
|
||||
|
||||
response = self._handle_request(request, "start_room_composite_egress")
|
||||
|
||||
if not response.egress_id:
|
||||
raise WorkerResponseError("Egress ID not found in the response.")
|
||||
|
||||
return response.egress_id
|
||||
|
||||
|
||||
class AudioCompositeEgressService(BaseEgressService):
|
||||
"""Record multiple participant audio tracks into a single output '.ogg' file."""
|
||||
|
||||
hrid = "audio-recording-composite-livekit-egress"
|
||||
|
||||
def start(self, room_name, recording_id):
|
||||
"""Start the audio composite egress process for a recording."""
|
||||
|
||||
# Save room's recording as an ogg audio file.
|
||||
file_type = livekit_api.EncodedFileType.OGG
|
||||
filepath = self._get_filepath(filename=recording_id, extension="ogg")
|
||||
|
||||
file_output = livekit_api.EncodedFileOutput(
|
||||
file_type=file_type,
|
||||
filepath=filepath,
|
||||
s3=self._s3,
|
||||
)
|
||||
|
||||
request = livekit_api.RoomCompositeEgressRequest(
|
||||
room_name=room_name, file_outputs=[file_output], audio_only=True
|
||||
)
|
||||
|
||||
response = self._handle_request(request, "start_room_composite_egress")
|
||||
|
||||
if not response.egress_id:
|
||||
raise WorkerResponseError("Egress ID not found in the response.")
|
||||
|
||||
return response.egress_id
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Marketing service in charge of pushing data for marketing automation."""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import Dict, List, Optional, Protocol
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.utils.module_loading import import_string
|
||||
|
||||
import brevo_python
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ContactCreationError(Exception):
|
||||
"""Raised when the contact creation fails."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ContactData:
|
||||
"""Contact data for marketing service integration."""
|
||||
|
||||
email: str
|
||||
attributes: Optional[Dict[str, str]] = None
|
||||
list_ids: Optional[List[int]] = None
|
||||
update_enabled: bool = True
|
||||
|
||||
|
||||
class MarketingServiceProtocol(Protocol):
|
||||
"""Interface for marketing automation service integrations."""
|
||||
|
||||
def create_contact(
|
||||
self, contact_data: ContactData, timeout: Optional[int] = None
|
||||
) -> dict:
|
||||
"""Create or update a contact.
|
||||
|
||||
Args:
|
||||
contact_data: Contact information and attributes
|
||||
timeout: API request timeout in seconds
|
||||
|
||||
Returns:
|
||||
dict: Service response
|
||||
|
||||
Raises:
|
||||
ContactCreationError: If contact creation fails
|
||||
"""
|
||||
|
||||
|
||||
class BrevoMarketingService:
|
||||
"""Brevo marketing automation integration.
|
||||
|
||||
Handles:
|
||||
- Contact management and segmentation
|
||||
- Marketing campaigns and automation
|
||||
- Email communications
|
||||
|
||||
Configuration via Django settings:
|
||||
- BREVO_API_KEY: API authentication
|
||||
- BREVO_API_CONTACT_LIST_IDS: Default contact lists
|
||||
- BREVO_API_CONTACT_ATTRIBUTES: Default contact attributes
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize Brevo (ex-sendinblue) marketing service."""
|
||||
|
||||
if not settings.BREVO_API_KEY:
|
||||
raise ImproperlyConfigured("Brevo API key is required")
|
||||
|
||||
configuration = brevo_python.Configuration()
|
||||
configuration.api_key["api-key"] = settings.BREVO_API_KEY
|
||||
|
||||
self._api_client = brevo_python.ApiClient(configuration)
|
||||
|
||||
def create_contact(self, contact_data: ContactData, timeout=None) -> dict:
|
||||
"""Create or update a Brevo contact.
|
||||
|
||||
Args:
|
||||
contact_data: Contact information and attributes
|
||||
timeout: API request timeout in seconds
|
||||
|
||||
Returns:
|
||||
dict: Brevo API response
|
||||
|
||||
Raises:
|
||||
ContactCreationError: If contact creation fails
|
||||
ImproperlyConfigured: If required settings are missing
|
||||
|
||||
Note:
|
||||
Contact attributes must be pre-configured in Brevo.
|
||||
Changes to attributes can impact existing workflows.
|
||||
"""
|
||||
|
||||
if not settings.BREVO_API_CONTACT_LIST_IDS:
|
||||
raise ImproperlyConfigured(
|
||||
"Default Brevo List IDs must be configured in settings."
|
||||
)
|
||||
|
||||
contact_api = brevo_python.ContactsApi(self._api_client)
|
||||
|
||||
attributes = {
|
||||
**settings.BREVO_API_CONTACT_ATTRIBUTES,
|
||||
**(contact_data.attributes or {}),
|
||||
}
|
||||
|
||||
list_ids = (contact_data.list_ids or []) + settings.BREVO_API_CONTACT_LIST_IDS
|
||||
|
||||
contact = brevo_python.CreateContact(
|
||||
email=contact_data.email,
|
||||
attributes=attributes,
|
||||
list_ids=list_ids,
|
||||
update_enabled=contact_data.update_enabled,
|
||||
)
|
||||
|
||||
api_configurations = {}
|
||||
|
||||
if timeout is not None:
|
||||
api_configurations["_request_timeout"] = timeout
|
||||
|
||||
try:
|
||||
response = contact_api.create_contact(contact, **api_configurations)
|
||||
except brevo_python.rest.ApiException as err:
|
||||
logger.exception("Failed to create contact in Brevo")
|
||||
raise ContactCreationError("Failed to create contact in Brevo") from err
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_marketing_service() -> MarketingServiceProtocol:
|
||||
"""Return cached instance of configured marketing service."""
|
||||
marketing_service_cls = import_string(settings.MARKETING_SERVICE_CLASS)
|
||||
return marketing_service_cls()
|
||||
@@ -1,42 +1,48 @@
|
||||
"""Unit tests for the Authentication Backends."""
|
||||
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from unittest import mock
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
|
||||
|
||||
import pytest
|
||||
|
||||
from core import models
|
||||
from core.authentication.backends import OIDCAuthenticationBackend
|
||||
from core.factories import UserFactory
|
||||
from core.services import marketing_service
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_authentication_getter_existing_user_no_email(
|
||||
django_assert_num_queries, monkeypatch
|
||||
):
|
||||
def test_authentication_getter_existing_user(monkeypatch):
|
||||
"""
|
||||
If an existing user matches the user's info sub, the user should be returned.
|
||||
If an existing user matches, the user should be returned.
|
||||
"""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory()
|
||||
db_user = UserFactory(email="foo@mail.com")
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": db_user.sub}
|
||||
return {"sub": db_user.sub, "email": "some@mail.com"}
|
||||
|
||||
def get_existing_user(*args):
|
||||
return db_user
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
monkeypatch.setattr(
|
||||
OIDCAuthenticationBackend, "get_existing_user", get_existing_user
|
||||
)
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user == db_user
|
||||
|
||||
|
||||
def test_authentication_getter_new_user_no_email(monkeypatch):
|
||||
"""
|
||||
If no user matches the user's info sub, a user should be created.
|
||||
If no user matches, a user should be created.
|
||||
User's info doesn't contain an email, created user's email should be empty.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
@@ -58,16 +64,15 @@ def test_authentication_getter_new_user_no_email(monkeypatch):
|
||||
|
||||
def test_authentication_getter_new_user_with_email(monkeypatch):
|
||||
"""
|
||||
If no user matches the user's info sub, a user should be created.
|
||||
User's email and name should be set on the identity.
|
||||
The "email" field on the User model should not be set as it is reserved for staff users.
|
||||
If no user matches, a user should be created.
|
||||
User's info contains an email, created user's email should be filled.
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
email = "meet@example.com"
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": email, "first_name": "John", "last_name": "Doe"}
|
||||
return {"sub": "123", "email": email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
@@ -77,6 +82,34 @@ def test_authentication_getter_new_user_with_email(monkeypatch):
|
||||
|
||||
assert user.sub == "123"
|
||||
assert user.email == email
|
||||
assert user.full_name is None
|
||||
assert user.short_name is None
|
||||
assert user.password == "!"
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize("email", [None, "johndoe@foo.com"])
|
||||
def test_authentication_getter_new_user_with_names(monkeypatch, email):
|
||||
"""
|
||||
If no user matches, a user should be created.
|
||||
User's info contains name-related field, created user's full and short names should be filled,
|
||||
whether the email is filled
|
||||
"""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "given_name": "John", "usual_name": "Doe", "email": email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user.sub == "123"
|
||||
assert user.email == email
|
||||
assert user.full_name == "John Doe"
|
||||
assert user.short_name == "John"
|
||||
assert user.password == "!"
|
||||
assert models.User.objects.count() == 1
|
||||
|
||||
@@ -92,10 +125,429 @@ def test_models_oidc_user_getter_invalid_token(django_assert_num_queries, monkey
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with django_assert_num_queries(0), pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match="User info contained no recognizable user identification",
|
||||
with (
|
||||
django_assert_num_queries(0),
|
||||
pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match="User info contained no recognizable user identification",
|
||||
),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
assert models.User.objects.exists() is False
|
||||
|
||||
|
||||
def test_models_oidc_user_getter_empty_sub(django_assert_num_queries, monkeypatch):
|
||||
"""The user's info contains a sub, but it's an empty string."""
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"test": "123", "sub": ""}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with (
|
||||
django_assert_num_queries(0),
|
||||
pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match="User info contained no recognizable user identification",
|
||||
),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
assert models.User.objects.exists() is False
|
||||
|
||||
|
||||
def test_authentication_get_inactive_user(monkeypatch):
|
||||
"""Test an exception is raised when attempting to authenticate inactive user."""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(is_active=False)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": db_user.sub}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
with (
|
||||
pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match="User account is disabled",
|
||||
),
|
||||
):
|
||||
klass.get_or_create_user(access_token="test-token", id_token=None, payload=None)
|
||||
|
||||
|
||||
def test_finds_user_by_sub(django_assert_num_queries):
|
||||
"""Should return user when found by sub, and email is matching."""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(email="foo@mail.com")
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
user = klass.get_existing_user(db_user.sub, db_user.email)
|
||||
|
||||
assert user == db_user
|
||||
|
||||
|
||||
def test_finds_user_when_email_fallback_disabled(django_assert_num_queries, settings):
|
||||
"""Should not return a user when not found by sub, and email fallback is disabled."""
|
||||
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = False
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(email="foo@mail.com")
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
user = klass.get_existing_user("wrong-sub", db_user.email)
|
||||
|
||||
assert user is None
|
||||
|
||||
|
||||
def test_finds_user_when_email_is_none(django_assert_num_queries, settings):
|
||||
"""Should not return a user when not found by sub, and email is empty."""
|
||||
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
UserFactory(email="foo@mail.com")
|
||||
|
||||
empty_email = ""
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
user = klass.get_existing_user("wrong-sub", empty_email)
|
||||
|
||||
assert user is None
|
||||
|
||||
|
||||
def test_finds_user_by_email(django_assert_num_queries, settings):
|
||||
"""Should return user when found by email, and sub is not matching."""
|
||||
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(email="foo@mail.com")
|
||||
|
||||
with django_assert_num_queries(2):
|
||||
user = klass.get_existing_user("wrong-sub", db_user.email)
|
||||
|
||||
assert user == db_user
|
||||
|
||||
|
||||
def test_finds_user_case_insensitive_email(django_assert_num_queries, settings):
|
||||
"""Should match email case-insensitively when falling back to email."""
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(email="foo@mail.com")
|
||||
|
||||
with django_assert_num_queries(2):
|
||||
user = klass.get_existing_user("wrong-sub", "FOO@MAIL.COM")
|
||||
|
||||
assert user == db_user
|
||||
|
||||
|
||||
def test_finds_user_multiple_users_same_email(django_assert_num_queries, settings):
|
||||
"""Should handle multiple users with same email appropriately."""
|
||||
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
email = "foo@mail.com"
|
||||
UserFactory(email=email)
|
||||
UserFactory(email=email) # Second user with same email
|
||||
|
||||
with (
|
||||
django_assert_num_queries(2),
|
||||
pytest.raises(
|
||||
SuspiciousOperation,
|
||||
match="Multiple user accounts share a common email.",
|
||||
),
|
||||
):
|
||||
klass.get_existing_user("wrong-sub", email)
|
||||
|
||||
|
||||
def test_finds_user_whitespace_email(django_assert_num_queries, settings):
|
||||
"""Should not match emails with whitespace."""
|
||||
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
settings.OIDC_CREATE_USER = False
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
UserFactory(email="foo@mail.com")
|
||||
|
||||
with django_assert_num_queries(2):
|
||||
user = klass.get_existing_user("wrong-sub", " foo@mail.com ")
|
||||
|
||||
assert user is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"email",
|
||||
[
|
||||
"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):
|
||||
"""Test email matching security against visually similar but non-ASCII domains.
|
||||
|
||||
Validates that emails with Unicode characters that visually resemble ASCII
|
||||
(homoglyphs) are treated as distinct from their ASCII counterparts for security,
|
||||
per RFC compliance requirements for hostnames.
|
||||
"""
|
||||
|
||||
settings.OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION = True
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(email="john.doe@example.com")
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user != db_user
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"given_name, usual_name, email",
|
||||
[
|
||||
("Jack", "Doe", "john.doe@example.com"),
|
||||
("John", "Duy", "john.doe@example.com"),
|
||||
("John", "Doe", "jack.duy@example.com"),
|
||||
("Jack", "Duy", "jack.duy@example.com"),
|
||||
],
|
||||
)
|
||||
def test_authentication_getter_existing_user_change_fields(
|
||||
given_name, usual_name, email, django_assert_num_queries, monkeypatch
|
||||
):
|
||||
"""It should update the email or name fields on the user when they change."""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
user = UserFactory(
|
||||
full_name="John Doe", short_name="John", email="john.doe@example.com"
|
||||
)
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {
|
||||
"sub": user.sub,
|
||||
"email": email,
|
||||
"given_name": given_name,
|
||||
"usual_name": usual_name,
|
||||
}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
# One and only one additional update query when a field has changed
|
||||
with django_assert_num_queries(2):
|
||||
authenticated_user = klass.get_or_create_user(
|
||||
access_token="test-token", id_token=None, payload=None
|
||||
)
|
||||
|
||||
assert user == authenticated_user
|
||||
user.refresh_from_db()
|
||||
assert user.email == email
|
||||
assert user.full_name == f"{given_name:s} {usual_name:s}"
|
||||
assert user.short_name == given_name
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"user_info, expected_name",
|
||||
[
|
||||
({"given_name": "John", "family_name": "Doe"}, "John Doe"),
|
||||
(
|
||||
{"given_name": "John", "middle_name": "M", "family_name": "Doe"},
|
||||
"John M Doe",
|
||||
),
|
||||
({"family_name": "Doe"}, "Doe"),
|
||||
({"given_name": "", "family_name": ""}, None),
|
||||
({}, None),
|
||||
],
|
||||
)
|
||||
def test_compute_full_name(user_info, expected_name, settings):
|
||||
"""Test full name computation from OIDC user info fields."""
|
||||
settings.OIDC_USERINFO_FULLNAME_FIELDS = [
|
||||
"given_name",
|
||||
"middle_name",
|
||||
"family_name",
|
||||
]
|
||||
klass = OIDCAuthenticationBackend()
|
||||
assert klass.compute_full_name(user_info) == expected_name
|
||||
|
||||
|
||||
def test_compute_full_name_no_fields(settings):
|
||||
"""Test full name computation with empty field configuration."""
|
||||
settings.OIDC_USERINFO_FULLNAME_FIELDS = []
|
||||
klass = OIDCAuthenticationBackend()
|
||||
assert klass.compute_full_name({"given_name": "John"}) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"claims",
|
||||
[
|
||||
{"email": "john.doe@example.com"}, # Same data - no change needed
|
||||
{"email": ""}, # Empty strings should not override
|
||||
{"non_related_field": "foo"}, # Unrelated fields should be ignored
|
||||
{}, # Empty claims should not affect user
|
||||
{"email": None}, # None values should be ignored
|
||||
],
|
||||
)
|
||||
def test_update_user_when_no_update_needed(django_assert_num_queries, claims):
|
||||
"""Test that user attributes remain unchanged when claims don't require updates."""
|
||||
|
||||
user = UserFactory(
|
||||
full_name="John Doe", short_name="John", email="john.doe@example.com"
|
||||
)
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
|
||||
with django_assert_num_queries(0):
|
||||
klass.update_user_if_needed(user, claims)
|
||||
|
||||
user.refresh_from_db()
|
||||
|
||||
assert user.email == "john.doe@example.com"
|
||||
|
||||
|
||||
@mock.patch.object(OIDCAuthenticationBackend, "signup_to_marketing_email")
|
||||
def test_marketing_signup_new_user_enabled(mock_signup, monkeypatch, settings):
|
||||
"""Test marketing signup for new user with settings enabled."""
|
||||
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
email = "test@example.com"
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user("test-token", None, None)
|
||||
|
||||
assert user.email == email
|
||||
mock_signup.assert_called_once_with(email)
|
||||
|
||||
|
||||
@mock.patch.object(OIDCAuthenticationBackend, "signup_to_marketing_email")
|
||||
def test_marketing_signup_new_user_disabled(mock_signup, monkeypatch, settings):
|
||||
"""Test no marketing signup for new user with settings disabled."""
|
||||
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = False
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
email = "test@example.com"
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user("test-token", None, None)
|
||||
|
||||
assert user.email == email
|
||||
mock_signup.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch.object(OIDCAuthenticationBackend, "signup_to_marketing_email")
|
||||
def test_marketing_signup_new_user_default_disabled(mock_signup, monkeypatch):
|
||||
"""Test no marketing signup for new user with settings by default disabled."""
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
email = "test@example.com"
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": "123", "email": email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user("test-token", None, None)
|
||||
|
||||
assert user.email == email
|
||||
mock_signup.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"is_signup_enabled",
|
||||
[True, False],
|
||||
)
|
||||
@mock.patch.object(OIDCAuthenticationBackend, "signup_to_marketing_email")
|
||||
def test_marketing_signup_existing_user(
|
||||
mock_signup, monkeypatch, settings, is_signup_enabled
|
||||
):
|
||||
"""Test no marketing signup for existing user regardless of settings."""
|
||||
|
||||
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = is_signup_enabled
|
||||
|
||||
klass = OIDCAuthenticationBackend()
|
||||
db_user = UserFactory(email="test@example.com")
|
||||
|
||||
def get_userinfo_mocked(*args):
|
||||
return {"sub": db_user.sub, "email": db_user.email}
|
||||
|
||||
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
|
||||
|
||||
user = klass.get_or_create_user("test-token", None, None)
|
||||
assert user == db_user
|
||||
mock_signup.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.authentication.backends.get_marketing_service")
|
||||
def test_signup_to_marketing_email_success(mock_marketing):
|
||||
"""Test successful marketing signup."""
|
||||
|
||||
email = "test@example.com"
|
||||
|
||||
# Call the method
|
||||
OIDCAuthenticationBackend.signup_to_marketing_email(email)
|
||||
|
||||
# Verify service interaction
|
||||
mock_service = mock_marketing.return_value
|
||||
mock_service.create_contact.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
ImportError,
|
||||
ImproperlyConfigured,
|
||||
],
|
||||
)
|
||||
@mock.patch("core.authentication.backends.get_marketing_service")
|
||||
def test_marketing_signup_handles_service_initialization_errors(
|
||||
mock_marketing, error, settings
|
||||
):
|
||||
"""Tests errors that occur when trying to get/initialize the marketing service."""
|
||||
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
|
||||
|
||||
mock_marketing.side_effect = error
|
||||
|
||||
# Should not raise any exception
|
||||
OIDCAuthenticationBackend.signup_to_marketing_email("test@example.com")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
marketing_service.ContactCreationError,
|
||||
ImproperlyConfigured,
|
||||
ImportError,
|
||||
],
|
||||
)
|
||||
@mock.patch("core.authentication.backends.get_marketing_service")
|
||||
def test_marketing_signup_handles_contact_creation_errors(
|
||||
mock_marketing, error, settings
|
||||
):
|
||||
"""Tests errors that occur during the contact creation process."""
|
||||
|
||||
settings.SIGNUP_NEW_USER_TO_MARKETING_EMAIL = True
|
||||
mock_marketing.return_value.create_contact.side_effect = error
|
||||
|
||||
# Should not raise any exception
|
||||
OIDCAuthenticationBackend.signup_to_marketing_email("test@example.com")
|
||||
|
||||
@@ -15,7 +15,13 @@ import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
from core.authentication.views import OIDCLogoutCallbackView, OIDCLogoutView
|
||||
from core.authentication.views import (
|
||||
MozillaOIDCAuthenticationCallbackView,
|
||||
OIDCAuthenticationCallbackView,
|
||||
OIDCAuthenticationRequestView,
|
||||
OIDCLogoutCallbackView,
|
||||
OIDCLogoutView,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -229,3 +235,125 @@ def test_view_logout_callback():
|
||||
|
||||
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")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Fixtures for tests in the Meet core application"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
Test event authentication.
|
||||
"""
|
||||
|
||||
# pylint: disable=E1128
|
||||
|
||||
from django.test import RequestFactory
|
||||
|
||||
import pytest
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
from core.recording.event.authentication import (
|
||||
MachineUser,
|
||||
StorageEventAuthentication,
|
||||
)
|
||||
|
||||
|
||||
def test_successful_authentication(settings):
|
||||
"""Test successful authentication with valid token."""
|
||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
||||
request = RequestFactory().get("/")
|
||||
request.headers = {"Authorization": "Bearer valid-test-token"}
|
||||
|
||||
user, token = StorageEventAuthentication().authenticate(request)
|
||||
assert token == "valid-test-token"
|
||||
assert isinstance(user, MachineUser)
|
||||
|
||||
|
||||
def test_disabled_authentication_with_header(settings):
|
||||
"""Authentication should pass when no auth is configured, and header is present."""
|
||||
settings.RECORDING_STORAGE_EVENT_TOKEN = None
|
||||
settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH = False
|
||||
|
||||
request = RequestFactory().get("/")
|
||||
request.headers = {"Authorization": "Bearer some-token"}
|
||||
|
||||
user, token = StorageEventAuthentication().authenticate(request)
|
||||
assert token is None
|
||||
assert isinstance(user, MachineUser)
|
||||
|
||||
|
||||
def test_disabled_authentication_without_header(settings):
|
||||
"""Authentication should pass when no auth is configured, and no header is present."""
|
||||
settings.RECORDING_STORAGE_EVENT_TOKEN = None
|
||||
settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH = False
|
||||
|
||||
request = RequestFactory().get("/")
|
||||
|
||||
user, token = StorageEventAuthentication().authenticate(request)
|
||||
assert token is None
|
||||
assert isinstance(user, MachineUser)
|
||||
|
||||
|
||||
def test_authentication_when_disabled(settings):
|
||||
"""Authentication should pass when disabled, regardless of token configuration."""
|
||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "some-token"
|
||||
settings.RECORDING_ENABLE_STORAGE_EVENT_AUTH = False
|
||||
|
||||
request = RequestFactory().get("/")
|
||||
|
||||
user, token = StorageEventAuthentication().authenticate(request)
|
||||
assert token is None
|
||||
assert isinstance(user, MachineUser)
|
||||
|
||||
|
||||
def test_authentication_fails_when_token_not_configured(settings):
|
||||
"""Authentication should fail when authentication is enabled but no token is configured."""
|
||||
|
||||
# By default RECORDING_ENABLE_STORAGE_EVENT_AUTH should be True
|
||||
settings.RECORDING_STORAGE_EVENT_TOKEN = None
|
||||
|
||||
request = RequestFactory().get("/")
|
||||
|
||||
with pytest.raises(
|
||||
AuthenticationFailed,
|
||||
match="Authentication is enabled but token is not configured",
|
||||
):
|
||||
StorageEventAuthentication().authenticate(request)
|
||||
|
||||
|
||||
def test_missing_auth_header(settings):
|
||||
"""Test failure when Authorization header is missing."""
|
||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
||||
request = RequestFactory().get("/")
|
||||
request.headers = {}
|
||||
|
||||
with pytest.raises(AuthenticationFailed, match="Authorization header is required"):
|
||||
StorageEventAuthentication().authenticate(request)
|
||||
|
||||
|
||||
def test_invalid_auth_header_format(settings):
|
||||
"""Test failure when Authorization header has invalid format."""
|
||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
||||
request = RequestFactory().get("/")
|
||||
request.headers = {"Authorization": "InvalidFormat"}
|
||||
|
||||
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
|
||||
StorageEventAuthentication().authenticate(request)
|
||||
|
||||
|
||||
def test_invalid_token_type(settings):
|
||||
"""Test failure when token type is not Bearer."""
|
||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
||||
request = RequestFactory().get("/")
|
||||
request.headers = {"Authorization": "Basic some-token"}
|
||||
|
||||
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
|
||||
StorageEventAuthentication().authenticate(request)
|
||||
|
||||
|
||||
def test_invalid_token(settings):
|
||||
"""Test failure when token is invalid."""
|
||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
||||
request = RequestFactory().get("/")
|
||||
request.headers = {"Authorization": "Bearer wrong-token"}
|
||||
|
||||
with pytest.raises(AuthenticationFailed, match="Invalid token"):
|
||||
StorageEventAuthentication().authenticate(request)
|
||||
|
||||
|
||||
def test_malformed_auth_header(settings):
|
||||
"""Test failure when Authorization header is malformed."""
|
||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
||||
request = RequestFactory().get("/")
|
||||
request.headers = {"Authorization": "Bearer"} # Missing token part
|
||||
|
||||
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
|
||||
StorageEventAuthentication().authenticate(request)
|
||||
|
||||
|
||||
def test_authenticate_header():
|
||||
"""Test the WWW-Authenticate header value."""
|
||||
request = RequestFactory().get("/")
|
||||
header = StorageEventAuthentication().authenticate_header(request)
|
||||
assert header == "Bearer realm='Storage event API'"
|
||||
|
||||
|
||||
def test_multiple_spaces_in_auth_header(settings):
|
||||
"""Test failure when Authorization header contains multiple spaces."""
|
||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "valid-test-token"
|
||||
request = RequestFactory().get("/")
|
||||
request.headers = {"Authorization": "Bearer extra-spaces-token"}
|
||||
|
||||
with pytest.raises(AuthenticationFailed, match="Invalid authorization header"):
|
||||
StorageEventAuthentication().authenticate(request)
|
||||
@@ -0,0 +1,310 @@
|
||||
"""
|
||||
Test event parsers.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0212,W0621,W0613
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import pytest
|
||||
|
||||
from core.recording.event.exceptions import (
|
||||
InvalidBucketError,
|
||||
InvalidFilepathError,
|
||||
InvalidFileTypeError,
|
||||
ParsingEventDataError,
|
||||
)
|
||||
from core.recording.event.parsers import (
|
||||
MinioParser,
|
||||
StorageEvent,
|
||||
get_parser,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def valid_minio_event():
|
||||
"""Mock a valid Minio event."""
|
||||
return {
|
||||
"Records": [
|
||||
{
|
||||
"s3": {
|
||||
"bucket": {"name": "test-bucket"},
|
||||
"object": {
|
||||
"key": "recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||
"contentType": "audio/ogg",
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def minio_parser():
|
||||
"""Mock a Minio parser."""
|
||||
return MinioParser(bucket_name="test-bucket")
|
||||
|
||||
|
||||
def test_parse_valid_event(minio_parser, valid_minio_event):
|
||||
"""Test parsing a valid Minio event."""
|
||||
event = minio_parser.parse(valid_minio_event)
|
||||
assert isinstance(event, StorageEvent)
|
||||
assert event.filepath == "recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg"
|
||||
assert event.filetype == "audio/ogg"
|
||||
assert event.bucket_name == "test-bucket"
|
||||
assert event.metadata is None
|
||||
|
||||
|
||||
def test_parse_empty_data(minio_parser):
|
||||
"""Test parsing empty event data raises error."""
|
||||
with pytest.raises(ParsingEventDataError, match="Received empty data."):
|
||||
minio_parser.parse({})
|
||||
|
||||
|
||||
def test_parse_missing_keys(minio_parser):
|
||||
"""Test parsing event with missing key."""
|
||||
|
||||
invalid_minio_event = {
|
||||
"Records": [
|
||||
{
|
||||
"s3": {
|
||||
"bucket": {"name": None},
|
||||
# Missing 'object' key
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with pytest.raises(ParsingEventDataError, match="Missing or malformed key"):
|
||||
minio_parser.parse(invalid_minio_event)
|
||||
|
||||
|
||||
def test_parse_none_key(minio_parser):
|
||||
"""Test parsing event with None field."""
|
||||
|
||||
invalid_minio_event = {
|
||||
"Records": [
|
||||
{
|
||||
"s3": {
|
||||
"bucket": {"name": "test-bucket"},
|
||||
"object": {
|
||||
"key": "recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||
"contentType": None, # 'contentType' should not be None
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
with pytest.raises(ParsingEventDataError, match="Missing essential data fields"):
|
||||
minio_parser.parse(invalid_minio_event)
|
||||
|
||||
|
||||
def test_validate_invalid_bucket(minio_parser):
|
||||
"""Test validation with wrong bucket name."""
|
||||
event = StorageEvent(
|
||||
filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||
filetype="audio/ogg",
|
||||
bucket_name="wrong-bucket",
|
||||
metadata=None,
|
||||
)
|
||||
with pytest.raises(InvalidBucketError):
|
||||
minio_parser.validate(event)
|
||||
|
||||
|
||||
def test_validate_invalid_filetype(minio_parser):
|
||||
"""Test validation with unsupported file type."""
|
||||
event = StorageEvent(
|
||||
filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.txt",
|
||||
filetype="text/plain", # Not included in the default allowed filetypes
|
||||
bucket_name="test-bucket",
|
||||
metadata=None,
|
||||
)
|
||||
with pytest.raises(InvalidFileTypeError):
|
||||
minio_parser.validate(event)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"invalid_filepath",
|
||||
[
|
||||
"invalid_filepath",
|
||||
"recording/46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||
"recording%2F46d1a1212426484d8fb309b5d886f7a8.ogg",
|
||||
],
|
||||
)
|
||||
def test_validate_invalid_filepath(invalid_filepath, minio_parser):
|
||||
"""Test validation with malformed filepath."""
|
||||
event = StorageEvent(
|
||||
filepath=invalid_filepath,
|
||||
filetype="audio/ogg",
|
||||
bucket_name="test-bucket",
|
||||
metadata=None,
|
||||
)
|
||||
with pytest.raises(InvalidFilepathError):
|
||||
minio_parser.validate(event)
|
||||
|
||||
|
||||
def test_validate_valid_event(minio_parser):
|
||||
"""Test validation with valid event data."""
|
||||
event = StorageEvent(
|
||||
filepath="recording%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||
filetype="audio/ogg",
|
||||
bucket_name="test-bucket",
|
||||
metadata=None,
|
||||
)
|
||||
recording_id = minio_parser.validate(event)
|
||||
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
|
||||
|
||||
|
||||
def test_get_recording_id_success(minio_parser, valid_minio_event):
|
||||
"""Test successful extraction of recording ID."""
|
||||
recording_id = minio_parser.get_recording_id(valid_minio_event)
|
||||
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
|
||||
|
||||
|
||||
def test_validate_filepath_with_folder(minio_parser):
|
||||
"""Test validation of filepath with folder structure."""
|
||||
event = StorageEvent(
|
||||
filepath="parent_folder%2Ffolder%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||
filetype="audio/ogg",
|
||||
bucket_name="test-bucket",
|
||||
metadata=None,
|
||||
)
|
||||
recording_id = minio_parser.validate(event)
|
||||
assert recording_id == "46d1a121-2426-484d-8fb3-09b5d886f7a8"
|
||||
|
||||
|
||||
def test_parse_with_video_type(minio_parser):
|
||||
"""Test parsing event with video file type."""
|
||||
video_event = {
|
||||
"Records": [
|
||||
{
|
||||
"s3": {
|
||||
"bucket": {"name": "test-bucket"},
|
||||
"object": {
|
||||
"key": "46d1a121-2426-484d-8fb3-09b5d886f7a8.mp4",
|
||||
"contentType": "video/mp4",
|
||||
},
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
event = minio_parser.parse(video_event)
|
||||
assert event.filetype == "video/mp4"
|
||||
assert event.filepath.endswith(".mp4")
|
||||
|
||||
|
||||
def test_empty_allowed_filetypes():
|
||||
"""Test MinioParser with empty allowed_filetypes."""
|
||||
empty_types = set()
|
||||
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes=empty_types)
|
||||
assert parser._allowed_filetypes == {"audio/ogg", "video/mp4"}
|
||||
|
||||
|
||||
def test_custom_allowed_filetypes():
|
||||
"""Test MinioParser with empty allowed_filetypes."""
|
||||
custom_types = {"audio/mp3", "video/mov"}
|
||||
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes=custom_types)
|
||||
assert parser._allowed_filetypes == {"audio/mp3", "video/mov"}
|
||||
|
||||
|
||||
def test_validate_custom_filetypes():
|
||||
"""Test validation of filepath with folder structure."""
|
||||
|
||||
parser = MinioParser(bucket_name="test-bucket", allowed_filetypes={"audio/mp3"})
|
||||
|
||||
event = StorageEvent(
|
||||
filepath="parent_folder%2Ffolder%2F46d1a121-2426-484d-8fb3-09b5d886f7a8.ogg",
|
||||
filetype="audio/mp3",
|
||||
bucket_name="test-bucket",
|
||||
metadata=None,
|
||||
)
|
||||
parser.validate(event)
|
||||
|
||||
|
||||
def test_constructor_none_bucket():
|
||||
"""Test MinioParser constructor with None bucket name."""
|
||||
with pytest.raises(ValueError, match="Bucket name cannot be None or empty"):
|
||||
MinioParser(bucket_name=None)
|
||||
|
||||
|
||||
def test_constructor_empty_bucket():
|
||||
"""Test MinioParser constructor with empty bucket name."""
|
||||
with pytest.raises(ValueError, match="Bucket name cannot be None or empty"):
|
||||
MinioParser(bucket_name="")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clear_lru_cache():
|
||||
"""Fixture to clear the LRU cache between tests."""
|
||||
get_parser.cache_clear()
|
||||
yield
|
||||
get_parser.cache_clear()
|
||||
|
||||
|
||||
def test_returns_correct_instance(clear_lru_cache):
|
||||
"""Test if get_parser returns the correct parser instance."""
|
||||
settings.AWS_STORAGE_BUCKET_NAME = "test-bucket"
|
||||
parser = get_parser()
|
||||
assert isinstance(parser, MinioParser)
|
||||
assert parser._bucket_name == "test-bucket"
|
||||
|
||||
|
||||
def test_caching_behavior(clear_lru_cache):
|
||||
"""Test if the function properly caches the parser instance."""
|
||||
settings.AWS_STORAGE_BUCKET_NAME = "test-bucket"
|
||||
parser1 = get_parser()
|
||||
parser2 = get_parser()
|
||||
assert parser1 is parser2 # Check object identity
|
||||
|
||||
|
||||
def test_different_settings_new_instance():
|
||||
"""Test if changing settings creates a new instance."""
|
||||
settings.AWS_STORAGE_BUCKET_NAME = "different-bucket"
|
||||
parser = get_parser()
|
||||
assert parser._bucket_name == "different-bucket"
|
||||
|
||||
|
||||
def test_import_error_handling(clear_lru_cache):
|
||||
"""Test handling of import errors for invalid parser class."""
|
||||
settings.RECORDING_EVENT_PARSER_CLASS = "invalid.parser.path"
|
||||
with pytest.raises(ImportError):
|
||||
get_parser()
|
||||
|
||||
|
||||
@mock.patch("core.recording.event.parsers.import_string")
|
||||
def test_parser_instantiation_called_once(mock_import_string, clear_lru_cache):
|
||||
"""Test that parser class is instantiated only once due to caching."""
|
||||
mock_parser_cls = type(
|
||||
"MockParser",
|
||||
(),
|
||||
{
|
||||
"__init__": lambda self, bucket_name: setattr(
|
||||
self, "_bucket_name", bucket_name
|
||||
)
|
||||
},
|
||||
)
|
||||
mock_import_string.return_value = mock_parser_cls
|
||||
|
||||
# First call
|
||||
parser1 = get_parser()
|
||||
# Second call
|
||||
parser2 = get_parser()
|
||||
|
||||
# Verify import_string was called only once
|
||||
mock_import_string.assert_called_once_with(settings.RECORDING_EVENT_PARSER_CLASS)
|
||||
assert parser1 is parser2
|
||||
|
||||
|
||||
def test_cache_clear_behavior(clear_lru_cache, settings):
|
||||
"""Test that cache clearing creates new instance."""
|
||||
|
||||
settings.RECORDING_EVENT_PARSER_CLASS = "core.recording.event.parsers.MinioParser"
|
||||
|
||||
parser1 = get_parser()
|
||||
get_parser.cache_clear()
|
||||
parser2 = get_parser()
|
||||
|
||||
assert parser1 is not parser2 # Should be different instances after cache clear
|
||||
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
Test recordings API endpoints in the Meet core app: delete.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RecordingFactory, UserFactory, UserRecordingAccessFactory
|
||||
from ...models import Recording
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_recordings_delete_anonymous():
|
||||
"""Anonymous users should not be allowed to destroy a recording."""
|
||||
recording = RecordingFactory()
|
||||
client = APIClient()
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/recordings/{recording.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert Recording.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_recordings_delete_authenticated():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a recording
|
||||
from which they are not related.
|
||||
"""
|
||||
recording = RecordingFactory()
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/recordings/{recording.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert Recording.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_recordings_delete_members():
|
||||
"""
|
||||
Authenticated users should not be allowed to delete a recording
|
||||
from which they are only a member.
|
||||
"""
|
||||
|
||||
user = UserFactory()
|
||||
access = UserRecordingAccessFactory(role="member", user=user)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/recordings/{access.recording.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert Recording.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
["owner", "administrator"],
|
||||
)
|
||||
def test_api_recordings_delete_active(role):
|
||||
"""
|
||||
Authenticated users cannot delete active recordings, even with deletion privileges.
|
||||
"""
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
recording = RecordingFactory(status="active")
|
||||
access = UserRecordingAccessFactory(role=role, user=user, recording=recording)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/recordings/{access.recording.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert Recording.objects.count() == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
["owner", "administrator"],
|
||||
)
|
||||
def test_api_recordings_delete_final(role):
|
||||
"""
|
||||
Authenticated users should not be allowed to delete an active recording
|
||||
from which they are an admin or owner.
|
||||
"""
|
||||
|
||||
user = UserFactory()
|
||||
|
||||
recording = RecordingFactory(status="saved")
|
||||
access = UserRecordingAccessFactory(role=role, user=user, recording=recording)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/recordings/{access.recording.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert Recording.objects.count() == 0
|
||||
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
Test recordings API endpoints in the Meet core app: list.
|
||||
"""
|
||||
|
||||
import operator
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_recordings_list_anonymous():
|
||||
"""Anonymous users should not be able to list recordings."""
|
||||
factories.RecordingFactory()
|
||||
response = APIClient().get("/api/v1.0/recordings/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
["administrator", "member", "owner"],
|
||||
)
|
||||
def test_api_recordings_list_authenticated_direct(role):
|
||||
"""
|
||||
Authenticated users listing recordings, should only see the recordings
|
||||
to which they are related.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
access = factories.UserRecordingAccessFactory(role=role, user=user)
|
||||
factories.UserRecordingAccessFactory(user=other_user)
|
||||
|
||||
recording = access.recording
|
||||
room = recording.room
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 1
|
||||
expected_ids = {
|
||||
str(recording.id),
|
||||
}
|
||||
result_ids = {result["id"] for result in results}
|
||||
assert expected_ids == result_ids
|
||||
assert results[0] == {
|
||||
"id": str(recording.id),
|
||||
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"room": {
|
||||
"id": str(room.id),
|
||||
"is_public": room.is_public,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
},
|
||||
"status": "initiated",
|
||||
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
}
|
||||
|
||||
|
||||
def test_api_recording_list_authenticated_via_team(mock_user_get_teams):
|
||||
"""
|
||||
Authenticated users should be able to list recordings they are a
|
||||
owner/administrator/member of via a team.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
mock_user_get_teams.return_value = ["team1", "team2", "unknown"]
|
||||
|
||||
recordings_team1 = [
|
||||
access.recording
|
||||
for access in factories.TeamRecordingAccessFactory.create_batch(2, team="team1")
|
||||
]
|
||||
recordings_team2 = [
|
||||
access.recording
|
||||
for access in factories.TeamRecordingAccessFactory.create_batch(3, team="team2")
|
||||
]
|
||||
|
||||
expected_ids = {
|
||||
str(recording.id) for recording in recordings_team1 + recordings_team2
|
||||
}
|
||||
|
||||
response = client.get("/api/v1.0/recordings/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 5
|
||||
results_id = {result["id"] for result in results}
|
||||
assert expected_ids == results_id
|
||||
|
||||
|
||||
@mock.patch.object(PageNumberPagination, "get_page_size", return_value=2)
|
||||
def test_api_recordings_list_pagination(_mock_page_size):
|
||||
"""Pagination should work as expected."""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
recording_ids = [
|
||||
str(access.recording_id)
|
||||
for access in factories.UserRecordingAccessFactory.create_batch(3, user=user)
|
||||
]
|
||||
|
||||
response = client.get("/api/v1.0/recordings/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert content["count"] == 3
|
||||
assert content["next"] == "http://testserver/api/v1.0/recordings/?page=2"
|
||||
assert content["previous"] is None
|
||||
|
||||
assert len(content["results"]) == 2
|
||||
for item in content["results"]:
|
||||
recording_ids.remove(item["id"])
|
||||
|
||||
# Get page 2
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/?page=2",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
|
||||
assert content["count"] == 3
|
||||
assert content["next"] is None
|
||||
assert content["previous"], "http://testserver/api/v1.0/recordings/"
|
||||
|
||||
assert len(content["results"]) == 1
|
||||
recording_ids.remove(content["results"][0]["id"])
|
||||
assert recording_ids == []
|
||||
|
||||
|
||||
def test_api_recordings_list_authenticated_distinct():
|
||||
"""A recording for a room with several related users should only be listed once."""
|
||||
user = factories.UserFactory()
|
||||
other_user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
recording = factories.RecordingFactory(users=[user, other_user])
|
||||
|
||||
response = client.get("/api/v1.0/recordings/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert len(content["results"]) == 1
|
||||
assert content["results"][0]["id"] == str(recording.id)
|
||||
|
||||
|
||||
def test_api_recordings_list_ordering_default():
|
||||
"""Recordings should be ordered by descending "updated_at" by default"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.RecordingFactory.create_batch(5, users=[user])
|
||||
|
||||
response = client.get("/api/v1.0/recordings/")
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) == 5
|
||||
|
||||
# Check that results are sorted by descending "updated_at" as expected
|
||||
for i in range(4):
|
||||
assert operator.ge(results[i]["updated_at"], results[i + 1]["updated_at"])
|
||||
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
Test recordings API endpoints in the Meet core app: save recording.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621,W0613
|
||||
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RecordingFactory
|
||||
from ...models import Recording, RecordingStatusChoices
|
||||
from ...recording.event.exceptions import (
|
||||
InvalidBucketError,
|
||||
InvalidFileTypeError,
|
||||
ParsingEventDataError,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def recording_settings(settings):
|
||||
"""Configure recording-related and storage event Django settings."""
|
||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
|
||||
settings.RECORDING_STORAGE_EVENT_ENABLE = True
|
||||
return settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_get_parser():
|
||||
"""Mock 'get_parser' factory function."""
|
||||
with mock.patch("core.api.viewsets.get_parser") as mock_parser:
|
||||
yield mock_parser
|
||||
|
||||
|
||||
def test_save_recording_anonymous(settings, client):
|
||||
"""Anonymous users should not be allowed to save room recordings."""
|
||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
|
||||
|
||||
RecordingFactory(status="active")
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/storage-hook/",
|
||||
{"recording_data": "valid-data"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert Recording.objects.count() == 1
|
||||
|
||||
|
||||
def test_save_recording_wrong_bearer(settings, client):
|
||||
"""Requests with incorrect bearer token should be rejected when auth is required."""
|
||||
|
||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/storage-hook/",
|
||||
{"recording_data": "valid-data"},
|
||||
HTTP_AUTHORIZATION="Bearer wrongAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_save_recording_permission_needed(settings, client):
|
||||
"""Recordings should not be saved when feature is disabled."""
|
||||
|
||||
settings.RECORDING_STORAGE_EVENT_TOKEN = "testAuthToken"
|
||||
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/storage-hook/",
|
||||
{"recording_data": "valid-data"},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_save_recording_parsing_error(recording_settings, mock_get_parser, client):
|
||||
"""Test handling of parsing errors in recording event data."""
|
||||
mock_parser = mock.Mock()
|
||||
mock_parser.get_recording_id.side_effect = ParsingEventDataError("Error message")
|
||||
mock_get_parser.return_value = mock_parser
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/storage-hook/",
|
||||
{"recording_data": "valid-data"},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {"detail": "Invalid request data: Error message"}
|
||||
|
||||
|
||||
def test_save_recording_bucket_error(recording_settings, mock_get_parser, client):
|
||||
"""Test handling of invalid storage bucket errors in recording event data."""
|
||||
|
||||
mock_parser = mock.Mock()
|
||||
mock_parser.get_recording_id.side_effect = InvalidBucketError("Error message")
|
||||
mock_get_parser.return_value = mock_parser
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/storage-hook/",
|
||||
{"recording_data": "valid-data"},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {"detail": "Invalid bucket specified"}
|
||||
|
||||
|
||||
def test_save_recording_filetype_error(recording_settings, mock_get_parser):
|
||||
"""Test handling of unsupported file types in recording event data."""
|
||||
|
||||
mock_parser = mock.Mock()
|
||||
mock_parser.get_recording_id.side_effect = InvalidFileTypeError(
|
||||
"unsupported '.json'"
|
||||
)
|
||||
mock_get_parser.return_value = mock_parser
|
||||
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/storage-hook/",
|
||||
{"recording_data": "valid-data"},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "Ignore this file type, unsupported '.json'"}
|
||||
|
||||
|
||||
def test_save_recording_unknown_recording(recording_settings, mock_get_parser, client):
|
||||
"""Test handling of events for non-existent recordings."""
|
||||
|
||||
RecordingFactory(status="active")
|
||||
|
||||
mock_parser = mock.Mock()
|
||||
mock_parser.get_recording_id.return_value = uuid.uuid4()
|
||||
mock_get_parser.return_value = mock_parser
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/storage-hook/",
|
||||
{"recording_data": "valid-data"},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "No recording found for this event."}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
|
||||
)
|
||||
def test_save_recording_non_savable_recording(
|
||||
recording_settings, mock_get_parser, client, status
|
||||
):
|
||||
"""Test that recordings in non-savable states cannot be saved."""
|
||||
|
||||
recording = RecordingFactory(status=status)
|
||||
|
||||
mock_parser = mock.Mock()
|
||||
mock_parser.get_recording_id.return_value = recording.id
|
||||
mock_get_parser.return_value = mock_parser
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/storage-hook/",
|
||||
{"recording_data": "valid-data"},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": f"Recording with ID {recording.id} cannot be saved because it is either,"
|
||||
" in an error state or has already been saved."
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["active", "stopped"])
|
||||
def test_save_recording_success(recording_settings, mock_get_parser, client, status):
|
||||
"""Test successful saving of recordings in valid states."""
|
||||
|
||||
recording = RecordingFactory(status=status)
|
||||
|
||||
mock_parser = mock.Mock()
|
||||
mock_parser.get_recording_id.return_value = recording.id
|
||||
mock_get_parser.return_value = mock_parser
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/recordings/storage-hook/",
|
||||
{"recording_data": "valid-data"},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "Event processed."}
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == RecordingStatusChoices.SAVED
|
||||
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
Test worker service factories.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0212,W0621,W0613
|
||||
|
||||
from dataclasses import FrozenInstanceError
|
||||
from unittest.mock import Mock
|
||||
|
||||
from django.test import override_settings
|
||||
|
||||
import pytest
|
||||
|
||||
from core.recording.worker.factories import (
|
||||
WorkerService,
|
||||
WorkerServiceConfig,
|
||||
get_worker_service,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_lru_cache():
|
||||
"""Clear the lru_cache before and after each test"""
|
||||
WorkerServiceConfig.from_settings.cache_clear()
|
||||
yield
|
||||
WorkerServiceConfig.from_settings.cache_clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_settings():
|
||||
"""Fixture to provide test Django 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",
|
||||
"AWS_S3_REGION_NAME": "test-region",
|
||||
"AWS_STORAGE_BUCKET_NAME": "test-bucket",
|
||||
}
|
||||
|
||||
# Use override_settings to properly patch Django settings
|
||||
with override_settings(**mocked_settings):
|
||||
yield test_settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def default_config(test_settings):
|
||||
"""Fixture to provide a WorkerServiceConfig instance"""
|
||||
return WorkerServiceConfig.from_settings()
|
||||
|
||||
|
||||
# Tests
|
||||
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",
|
||||
"secret": "test_secret",
|
||||
"region": "test-region",
|
||||
"bucket": "test-bucket",
|
||||
"force_path_style": True,
|
||||
}
|
||||
|
||||
|
||||
def test_config_immutability(default_config):
|
||||
"""Test that config instances are immutable after creation"""
|
||||
with pytest.raises(FrozenInstanceError):
|
||||
default_config.output_folder = "new/path"
|
||||
|
||||
|
||||
@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",
|
||||
AWS_S3_REGION_NAME="test-region",
|
||||
AWS_STORAGE_BUCKET_NAME="test-bucket",
|
||||
)
|
||||
def test_config_caching():
|
||||
"""Test that from_settings method caches its result"""
|
||||
# Clear cache before testing caching behavior
|
||||
WorkerServiceConfig.from_settings.cache_clear()
|
||||
|
||||
config1 = WorkerServiceConfig.from_settings()
|
||||
config2 = WorkerServiceConfig.from_settings()
|
||||
assert config1 is config2
|
||||
|
||||
|
||||
class MockWorkerService(WorkerService):
|
||||
"""Mock worker service for testing."""
|
||||
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_import_string(monkeypatch):
|
||||
"""Fixture to mock import_string function."""
|
||||
mock = Mock(return_value=MockWorkerService)
|
||||
monkeypatch.setattr("core.recording.worker.factories.import_string", mock)
|
||||
return mock
|
||||
|
||||
|
||||
def test_factory_valid_mode(mock_import_string, settings, default_config):
|
||||
"""Test getting worker service with valid mode."""
|
||||
|
||||
settings.RECORDING_WORKER_CLASSES = {
|
||||
"test_mode": "path.to.MockWorkerService",
|
||||
"another_mode": "path.to.AnotherWorkerService",
|
||||
}
|
||||
|
||||
worker = get_worker_service("test_mode")
|
||||
|
||||
mock_import_string.assert_called_once_with("path.to.MockWorkerService")
|
||||
assert isinstance(worker, MockWorkerService)
|
||||
assert worker.config == default_config
|
||||
|
||||
|
||||
def test_factory_invalid_mode(settings, mock_import_string, default_config):
|
||||
"""Test getting worker service with invalid mode raises ValueError."""
|
||||
|
||||
settings.RECORDING_WORKER_CLASSES = {
|
||||
"test_mode": "path.to.MockWorkerService",
|
||||
"another_mode": "path.to.AnotherWorkerService",
|
||||
}
|
||||
|
||||
worker = get_worker_service("test_mode")
|
||||
|
||||
mock_import_string.assert_called_once_with("path.to.MockWorkerService")
|
||||
assert isinstance(worker, MockWorkerService)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
get_worker_service("invalid_mode")
|
||||
mock_import_string.assert_not_called()
|
||||
|
||||
assert "Recording mode 'invalid_mode' not found" in str(exc_info.value)
|
||||
assert "Available modes: ['test_mode', 'another_mode']" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_factory_import_error(mock_import_string, settings):
|
||||
"""Test handling of import errors."""
|
||||
|
||||
mock_import_string.side_effect = ImportError("Module not found")
|
||||
|
||||
settings.RECORDING_WORKER_CLASSES = {
|
||||
"test_mode": "path.to.MockWorkerService",
|
||||
"another_mode": "path.to.AnotherWorkerService",
|
||||
}
|
||||
|
||||
with pytest.raises(ImportError) as exc_info:
|
||||
get_worker_service("test_mode")
|
||||
|
||||
assert "Module not found" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_factory_empty_registry(settings):
|
||||
"""Test behavior when worker registry is empty."""
|
||||
|
||||
settings.RECORDING_WORKER_CLASSES = {}
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
get_worker_service("any_mode")
|
||||
|
||||
assert "Available modes: []" in str(exc_info.value)
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Test WorkerServiceMediator class."""
|
||||
|
||||
# pylint: disable=W0621,W0613
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from core.factories import RecordingFactory
|
||||
from core.models import RecordingStatusChoices
|
||||
from core.recording.worker.exceptions import (
|
||||
RecordingStartError,
|
||||
RecordingStopError,
|
||||
WorkerConnectionError,
|
||||
WorkerRequestError,
|
||||
WorkerResponseError,
|
||||
)
|
||||
from core.recording.worker.factories import WorkerService
|
||||
from core.recording.worker.mediator import WorkerServiceMediator
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_worker_service():
|
||||
"""Fixture for mock worker service"""
|
||||
return Mock(spec=WorkerService)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mediator(mock_worker_service):
|
||||
"""Fixture for WorkerServiceMediator"""
|
||||
return WorkerServiceMediator(mock_worker_service)
|
||||
|
||||
|
||||
def test_start_recording_success(mediator, mock_worker_service):
|
||||
"""Test successful recording start"""
|
||||
# Setup
|
||||
worker_id = "test-worker-123"
|
||||
mock_worker_service.start.return_value = worker_id
|
||||
|
||||
mock_recording = RecordingFactory(
|
||||
status=RecordingStatusChoices.INITIATED, worker_id=None
|
||||
)
|
||||
mediator.start(mock_recording)
|
||||
|
||||
# Verify worker service call
|
||||
expected_room_name = str(mock_recording.room.id)
|
||||
mock_worker_service.start.assert_called_once_with(
|
||||
expected_room_name, mock_recording.id
|
||||
)
|
||||
|
||||
# Verify recording updates
|
||||
mock_recording.refresh_from_db()
|
||||
assert mock_recording.worker_id == worker_id
|
||||
assert mock_recording.status == RecordingStatusChoices.ACTIVE
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
|
||||
)
|
||||
def test_mediator_start_recording_worker_errors(
|
||||
mediator, mock_worker_service, error_class
|
||||
):
|
||||
"""Test handling of various worker errors during start"""
|
||||
# Setup
|
||||
mock_worker_service.start.side_effect = error_class("Test error")
|
||||
mock_recording = RecordingFactory(
|
||||
status=RecordingStatusChoices.INITIATED, worker_id=None
|
||||
)
|
||||
|
||||
# Execute and verify
|
||||
with pytest.raises(RecordingStartError):
|
||||
mediator.start(mock_recording)
|
||||
|
||||
# Verify recording updates
|
||||
mock_recording.refresh_from_db()
|
||||
assert mock_recording.status == RecordingStatusChoices.FAILED_TO_START
|
||||
assert mock_recording.worker_id is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status",
|
||||
[
|
||||
RecordingStatusChoices.ACTIVE,
|
||||
RecordingStatusChoices.FAILED_TO_START,
|
||||
RecordingStatusChoices.FAILED_TO_STOP,
|
||||
RecordingStatusChoices.STOPPED,
|
||||
RecordingStatusChoices.SAVED,
|
||||
RecordingStatusChoices.ABORTED,
|
||||
],
|
||||
)
|
||||
def test_mediator_start_recording_from_forbidden_status(
|
||||
mediator, mock_worker_service, status
|
||||
):
|
||||
"""Test handling of various worker errors during start"""
|
||||
# Setup
|
||||
mock_recording = RecordingFactory(status=status)
|
||||
|
||||
# Execute and verify
|
||||
with pytest.raises(RecordingStartError):
|
||||
mediator.start(mock_recording)
|
||||
|
||||
# Verify recording was not updated
|
||||
mock_recording.refresh_from_db()
|
||||
assert mock_recording.status == status
|
||||
|
||||
|
||||
def test_mediator_stop_recording_success(mediator, mock_worker_service):
|
||||
"""Test successful recording stop"""
|
||||
# Setup
|
||||
mock_recording = RecordingFactory(
|
||||
status=RecordingStatusChoices.ACTIVE, worker_id="test-worker-123"
|
||||
)
|
||||
mock_worker_service.stop.return_value = "STOPPED"
|
||||
|
||||
# Execute
|
||||
mediator.stop(mock_recording)
|
||||
|
||||
# Verify worker service call
|
||||
mock_worker_service.stop.assert_called_once_with(worker_id=mock_recording.worker_id)
|
||||
|
||||
# Verify recording updates
|
||||
mock_recording.refresh_from_db()
|
||||
assert mock_recording.status == RecordingStatusChoices.STOPPED
|
||||
|
||||
|
||||
def test_mediator_stop_recording_aborted(mediator, mock_worker_service):
|
||||
"""Test recording stop when worker returns ABORTED"""
|
||||
# Setup
|
||||
mock_recording = RecordingFactory(
|
||||
status=RecordingStatusChoices.ACTIVE, worker_id="test-worker-123"
|
||||
)
|
||||
mock_worker_service.stop.return_value = "ABORTED"
|
||||
|
||||
# Execute
|
||||
mediator.stop(mock_recording)
|
||||
|
||||
# Verify recording updates
|
||||
mock_recording.refresh_from_db()
|
||||
assert mock_recording.status == RecordingStatusChoices.ABORTED
|
||||
|
||||
|
||||
@pytest.mark.parametrize("error_class", [WorkerConnectionError, WorkerResponseError])
|
||||
def test_mediator_stop_recording_worker_errors(
|
||||
mediator, mock_worker_service, error_class
|
||||
):
|
||||
"""Test handling of worker errors during stop"""
|
||||
# Setup
|
||||
mock_recording = RecordingFactory(
|
||||
status=RecordingStatusChoices.ACTIVE, worker_id="test-worker-123"
|
||||
)
|
||||
mock_worker_service.stop.side_effect = error_class("Test error")
|
||||
|
||||
# Execute and verify
|
||||
with pytest.raises(RecordingStopError):
|
||||
mediator.stop(mock_recording)
|
||||
|
||||
# Verify recording updates
|
||||
mock_recording.refresh_from_db()
|
||||
assert mock_recording.status == RecordingStatusChoices.FAILED_TO_STOP
|
||||
@@ -0,0 +1,368 @@
|
||||
"""
|
||||
Test worker service classes.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0212,W0621,W0613,E1101
|
||||
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import aiohttp
|
||||
import pytest
|
||||
|
||||
from core.recording.worker.exceptions import WorkerConnectionError, WorkerResponseError
|
||||
from core.recording.worker.factories import WorkerServiceConfig
|
||||
from core.recording.worker.services import (
|
||||
AudioCompositeEgressService,
|
||||
BaseEgressService,
|
||||
VideoCompositeEgressService,
|
||||
livekit_api,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config():
|
||||
"""Fixture to provide a WorkerServiceConfig instance"""
|
||||
return WorkerServiceConfig(
|
||||
output_folder="/test/output",
|
||||
server_configurations={
|
||||
"host": "test.livekit.io",
|
||||
"api_key": "test_key",
|
||||
"api_secret": "test_secret",
|
||||
},
|
||||
verify_ssl=True,
|
||||
bucket_args={
|
||||
"endpoint": "https://s3.test.com",
|
||||
"access_key": "test_key",
|
||||
"secret": "test_secret",
|
||||
"region": "test-region",
|
||||
"bucket": "test-bucket",
|
||||
"force_path_style": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_s3_upload():
|
||||
"""Fixture for mocked S3Upload"""
|
||||
with patch("core.recording.worker.services.livekit_api.S3Upload") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_egress_service():
|
||||
"""Fixture for mocked EgressService"""
|
||||
with patch("core.recording.worker.services.EgressService") as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(config, mock_s3_upload):
|
||||
"""Fixture for BaseEgressService instance"""
|
||||
return BaseEgressService(config)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client_session():
|
||||
"""Fixture for mocked aiohttp.ClientSession"""
|
||||
with patch("aiohttp.ClientSession") as mock:
|
||||
mock.return_value.__aenter__ = AsyncMock()
|
||||
mock.return_value.__aexit__ = AsyncMock()
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_tcp_connector():
|
||||
"""Fixture for TCPConnector"""
|
||||
with patch("aiohttp.TCPConnector") as mock_connector:
|
||||
mock_connector_instance = Mock()
|
||||
mock_connector.return_value = mock_connector_instance
|
||||
yield mock_connector
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def video_service(config):
|
||||
"""Fixture for VideoCompositeEgressService"""
|
||||
service = VideoCompositeEgressService(config)
|
||||
service._handle_request = Mock() # Mock the request handler
|
||||
return service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def audio_service(config):
|
||||
"""Fixture for AudioCompositeEgressService"""
|
||||
service = AudioCompositeEgressService(config)
|
||||
service._handle_request = Mock() # Mock the request handler
|
||||
return service
|
||||
|
||||
|
||||
def test_base_egress_initialization(config, mock_s3_upload):
|
||||
"""Test service initialization"""
|
||||
|
||||
service = BaseEgressService(config)
|
||||
assert service._config == config
|
||||
|
||||
mock_s3_upload.assert_called_once_with(
|
||||
endpoint="https://s3.test.com",
|
||||
access_key="test_key",
|
||||
secret="test_secret",
|
||||
region="test-region",
|
||||
bucket="test-bucket",
|
||||
force_path_style=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"filename,extension,expected",
|
||||
[
|
||||
("test", "mp4", "/test/output/test.mp4"),
|
||||
("recording123", "ogg", "/test/output/recording123.ogg"),
|
||||
("live_stream", "m3u8", "/test/output/live_stream.m3u8"),
|
||||
],
|
||||
)
|
||||
def test_base_egress_filepath_construction(service, filename, extension, expected):
|
||||
"""Test filepath construction with various inputs"""
|
||||
result = service._get_filepath(filename, extension)
|
||||
assert result == expected
|
||||
assert result.startswith(service._config.output_folder)
|
||||
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",
|
||||
[
|
||||
(livekit_api.EgressStatus.EGRESS_ABORTED, "ABORTED"),
|
||||
(livekit_api.EgressStatus.EGRESS_COMPLETE, "FAILED_TO_STOP"),
|
||||
(livekit_api.EgressStatus.EGRESS_ENDING, "STOPPED"),
|
||||
(livekit_api.EgressStatus.EGRESS_FAILED, "FAILED_TO_STOP"),
|
||||
],
|
||||
)
|
||||
def test_base_egress_stop_with_status(service, response_status, expected_result):
|
||||
"""Test stop method with different response statuses"""
|
||||
# Mock _handle_request
|
||||
mock_response = Mock(status=response_status)
|
||||
service._handle_request = Mock(return_value=mock_response)
|
||||
|
||||
# Execute stop
|
||||
result = service.stop("test_worker_id")
|
||||
|
||||
# Verify request and response handling
|
||||
service._handle_request.assert_called_once_with(
|
||||
livekit_api.StopEgressRequest(egress_id="test_worker_id"), "stop_egress"
|
||||
)
|
||||
assert result == expected_result
|
||||
|
||||
|
||||
def test_base_egress_stop_missing_status(service):
|
||||
"""Test stop method when response is missing status"""
|
||||
# Mock _handle_request with missing status
|
||||
mock_response = Mock(status=None)
|
||||
service._handle_request = Mock(return_value=mock_response)
|
||||
|
||||
# Verify error handling
|
||||
with pytest.raises(WorkerResponseError) as exc:
|
||||
service.stop("test_worker_id")
|
||||
|
||||
assert "missing the recording status" in str(exc.value)
|
||||
|
||||
|
||||
def test_base_egress_start_not_implemented(service):
|
||||
"""Test that start method raises NotImplementedError"""
|
||||
with pytest.raises(NotImplementedError) as exc:
|
||||
service.start("test_room", "test_recording")
|
||||
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"
|
||||
|
||||
|
||||
def test_video_composite_egress_start_success(video_service):
|
||||
"""Test successful start of video composite egress"""
|
||||
# Setup mock response
|
||||
egress_id = "test-egress-123"
|
||||
video_service._handle_request.return_value = Mock(egress_id=egress_id)
|
||||
|
||||
# Test parameters
|
||||
room_name = "test-room"
|
||||
recording_id = "rec-123"
|
||||
|
||||
# Call start
|
||||
result = video_service.start(room_name, recording_id)
|
||||
|
||||
# Verify result
|
||||
assert result == egress_id
|
||||
|
||||
# Verify request construction
|
||||
video_service._handle_request.assert_called_once()
|
||||
request = video_service._handle_request.call_args[0][0]
|
||||
method = video_service._handle_request.call_args[0][1]
|
||||
|
||||
# Verify request properties
|
||||
assert isinstance(request, livekit_api.RoomCompositeEgressRequest)
|
||||
assert request.room_name == room_name
|
||||
assert len(request.file_outputs) == 1
|
||||
|
||||
assert not request.audio_only # Video service shouldn't set audio_only
|
||||
|
||||
# Verify file output configuration
|
||||
file_output = request.file_outputs[0]
|
||||
assert file_output.file_type == livekit_api.EncodedFileType.MP4
|
||||
assert file_output.filepath == f"/test/output/{recording_id}.mp4"
|
||||
assert file_output.s3.bucket == "test-bucket"
|
||||
|
||||
# Verify method name
|
||||
assert method == "start_room_composite_egress"
|
||||
|
||||
|
||||
def test_video_composite_egress_start_missing_egress_id(video_service):
|
||||
"""Test handling of missing egress ID in response"""
|
||||
# Setup mock response without egress_id
|
||||
video_service._handle_request.return_value = Mock(egress_id=None)
|
||||
|
||||
with pytest.raises(WorkerResponseError) as exc_info:
|
||||
video_service.start("test-room", "rec-123")
|
||||
|
||||
assert "Egress ID not found" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_audio_composite_egress_hrid(audio_service):
|
||||
"""Test HRID is correct"""
|
||||
assert audio_service.hrid == "audio-recording-composite-livekit-egress"
|
||||
|
||||
|
||||
def test_audio_composite_egress_start_success(audio_service):
|
||||
"""Test successful start of audio composite egress"""
|
||||
# Setup mock response
|
||||
egress_id = "test-egress-123"
|
||||
audio_service._handle_request.return_value = Mock(egress_id=egress_id)
|
||||
|
||||
# Test parameters
|
||||
room_name = "test-room"
|
||||
recording_id = "rec-123"
|
||||
|
||||
# Call start
|
||||
result = audio_service.start(room_name, recording_id)
|
||||
|
||||
# Verify result
|
||||
assert result == egress_id
|
||||
|
||||
# Verify request construction
|
||||
audio_service._handle_request.assert_called_once()
|
||||
request = audio_service._handle_request.call_args[0][0]
|
||||
method = audio_service._handle_request.call_args[0][1]
|
||||
|
||||
# Verify request properties
|
||||
assert isinstance(request, livekit_api.RoomCompositeEgressRequest)
|
||||
assert request.room_name == room_name
|
||||
assert len(request.file_outputs) == 1
|
||||
assert request.audio_only is True # Audio service should set audio_only
|
||||
|
||||
# Verify file output configuration
|
||||
file_output = request.file_outputs[0]
|
||||
assert file_output.file_type == livekit_api.EncodedFileType.OGG
|
||||
assert file_output.filepath == f"/test/output/{recording_id}.ogg"
|
||||
assert file_output.s3.bucket == "test-bucket"
|
||||
|
||||
# Verify method name
|
||||
assert method == "start_room_composite_egress"
|
||||
|
||||
|
||||
def test_audio_composite_egress_start_missing_egress_id(audio_service):
|
||||
"""Test handling of missing egress ID in response"""
|
||||
# Setup mock response without egress_id
|
||||
audio_service._handle_request.return_value = Mock(egress_id=None)
|
||||
|
||||
with pytest.raises(WorkerResponseError) as exc_info:
|
||||
audio_service.start("test-room", "rec-123")
|
||||
|
||||
assert "Egress ID not found" in str(exc_info.value)
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Test rooms API endpoints in the Meet core app: create.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Test rooms API endpoints in the Meet core app: delete.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Test rooms API endpoints in the Meet core app: list.
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Test rooms API endpoints in the Meet core app: retrieve.
|
||||
"""
|
||||
|
||||
import random
|
||||
from unittest import mock
|
||||
|
||||
@@ -37,7 +38,7 @@ def test_api_rooms_retrieve_anonymous_private_pk():
|
||||
def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
|
||||
"""It should be possible to get a room by its id stripped of its dashes."""
|
||||
room = RoomFactory(is_public=False)
|
||||
id_no_dashes = str(room.id).replace("-", "")
|
||||
id_no_dashes = str(room.id)
|
||||
|
||||
client = APIClient()
|
||||
response = client.get(f"/api/v1.0/rooms/{id_no_dashes:s}/")
|
||||
@@ -111,7 +112,9 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed(mock_token):
|
||||
},
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(room="unregistered-room", user=AnonymousUser())
|
||||
mock_token.assert_called_once_with(
|
||||
room="unregistered-room", user=AnonymousUser(), username=None
|
||||
)
|
||||
|
||||
|
||||
@override_settings(ALLOW_UNREGISTERED_ROOMS=True)
|
||||
@@ -141,7 +144,9 @@ def test_api_rooms_retrieve_anonymous_unregistered_allowed_not_normalized(mock_t
|
||||
},
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(room="reunion", user=AnonymousUser())
|
||||
mock_token.assert_called_once_with(
|
||||
room="reunion", user=AnonymousUser(), username=None
|
||||
)
|
||||
|
||||
|
||||
@override_settings(ALLOW_UNREGISTERED_ROOMS=False)
|
||||
@@ -153,7 +158,7 @@ def test_api_rooms_retrieve_anonymous_unregistered_not_allowed():
|
||||
response = client.get("/api/v1.0/rooms/unregistered-room/")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "Not found."}
|
||||
assert response.json() == {"detail": "No Room matches the given query."}
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_token", return_value="foo")
|
||||
@@ -173,7 +178,7 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
expected_name = f"{room.id!s}".replace("-", "")
|
||||
expected_name = f"{room.id!s}"
|
||||
assert response.json() == {
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
@@ -215,7 +220,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
expected_name = f"{room.id!s}".replace("-", "")
|
||||
expected_name = f"{room.id!s}"
|
||||
assert response.json() == {
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
@@ -229,7 +234,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(room=expected_name, user=user)
|
||||
mock_token.assert_called_once_with(room=expected_name, user=user, username=None)
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_authenticated():
|
||||
@@ -296,6 +301,8 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
|
||||
"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,
|
||||
@@ -305,6 +312,8 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
|
||||
"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,
|
||||
@@ -313,7 +322,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
|
||||
expected_name = str(room.id).replace("-", "")
|
||||
expected_name = str(room.id)
|
||||
assert content_dict == {
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
@@ -327,7 +336,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(room=expected_name, user=user)
|
||||
mock_token.assert_called_once_with(room=expected_name, user=user, username=None)
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_token", return_value="foo")
|
||||
@@ -369,6 +378,8 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
|
||||
"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,
|
||||
@@ -378,6 +389,8 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
|
||||
"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,
|
||||
@@ -385,7 +398,7 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
|
||||
],
|
||||
key=lambda x: x["id"],
|
||||
)
|
||||
expected_name = str(room.id).replace("-", "")
|
||||
expected_name = str(room.id)
|
||||
assert content_dict == {
|
||||
"id": str(room.id),
|
||||
"is_administrable": True,
|
||||
@@ -400,4 +413,4 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(room=expected_name, user=user)
|
||||
mock_token.assert_called_once_with(room=expected_name, user=user, username=None)
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
Test rooms API endpoints in the Meet core app: start recording.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621,W0613
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
from ...models import Recording
|
||||
from ...recording.worker.exceptions import RecordingStartError
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_worker_service():
|
||||
"""Mock worker service."""
|
||||
return mock.Mock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_worker_service_factory(mock_worker_service):
|
||||
"""Mock worker service factory."""
|
||||
with mock.patch(
|
||||
"core.api.viewsets.get_worker_service",
|
||||
return_value=mock_worker_service,
|
||||
) as mock_worker_service_factory:
|
||||
yield mock_worker_service_factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_worker_manager(mock_worker_service):
|
||||
"""Mock worker service mediator."""
|
||||
with mock.patch("core.api.viewsets.WorkerServiceMediator") as mock_mediator_class:
|
||||
mock_mediator = mock.Mock()
|
||||
mock_mediator_class.return_value = mock_mediator
|
||||
yield mock_mediator
|
||||
|
||||
|
||||
def test_start_recording_anonymous():
|
||||
"""Anonymous users should not be allowed to start room recordings."""
|
||||
room = RoomFactory()
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert Recording.objects.count() == 0
|
||||
|
||||
|
||||
def test_start_recording_non_owner_and_non_administrator():
|
||||
"""Non-owner and Non-Administrator users should not be allowed to start room recordings."""
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert Recording.objects.count() == 0
|
||||
|
||||
|
||||
def test_start_recording_recording_disabled(settings):
|
||||
"""Should fail if recording is disabled for the room."""
|
||||
settings.RECORDING_ENABLE = False
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
# Make user the room owner
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {"detail": "Access denied, recording is disabled."}
|
||||
assert Recording.objects.count() == 0
|
||||
|
||||
|
||||
def test_start_recording_missing_mode(settings):
|
||||
"""Should fail if recording mode is not provided."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
# Make user the room owner
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id}/start-recording/", {})
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "Invalid request."}
|
||||
assert Recording.objects.count() == 0
|
||||
|
||||
|
||||
def test_start_recording_worker_error(
|
||||
mock_worker_service_factory, mock_worker_manager, settings
|
||||
):
|
||||
"""Should handle worker service errors appropriately."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
# Make user the room owner
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
# Configure mock mediator to raise error
|
||||
mock_start = mock.Mock()
|
||||
mock_start.side_effect = RecordingStartError("Failed to connect to worker")
|
||||
|
||||
mock_worker_manager.start = mock_start
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
|
||||
mock_worker_service_factory.assert_called_once_with(mode="screen_recording")
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.json() == {
|
||||
"error": f"Recording failed to start for room {room.slug}"
|
||||
}
|
||||
|
||||
# Recording object should be created even if worker fails
|
||||
assert Recording.objects.count() == 1
|
||||
recording = Recording.objects.first()
|
||||
assert recording.room == room
|
||||
assert recording.mode == "screen_recording"
|
||||
|
||||
# Verify recording access details
|
||||
assert recording.accesses.count() == 1
|
||||
access = recording.accesses.first()
|
||||
assert access.user == user
|
||||
assert access.role == "owner"
|
||||
|
||||
|
||||
def test_start_recording_success(
|
||||
mock_worker_service_factory, mock_worker_manager, settings
|
||||
):
|
||||
"""Should successfully start recording when everything is configured correctly."""
|
||||
settings.RECORDING_ENABLE = True
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
# Make user the room owner
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
mock_start = mock.Mock()
|
||||
mock_worker_manager.start = mock_start
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/start-recording/",
|
||||
{"mode": "screen_recording"},
|
||||
)
|
||||
|
||||
mock_worker_service_factory.assert_called_once_with(mode="screen_recording")
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json() == {
|
||||
"message": f"Recording successfully started for room {room.slug}"
|
||||
}
|
||||
|
||||
# Verify the mediator was called with the recording
|
||||
recording = Recording.objects.first()
|
||||
mock_start.assert_called_once_with(recording)
|
||||
|
||||
assert recording.room == room
|
||||
assert recording.mode == "screen_recording"
|
||||
|
||||
# Verify recording access details
|
||||
assert recording.accesses.count() == 1
|
||||
access = recording.accesses.first()
|
||||
assert access.user == user
|
||||
assert access.role == "owner"
|
||||
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
Test rooms API endpoints in the Meet core app: stop recording.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621,W0613
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RecordingFactory, RoomFactory, UserFactory
|
||||
from ...models import Recording, RecordingStatusChoices
|
||||
from ...recording.worker.exceptions import RecordingStopError
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_worker_service():
|
||||
"""Mock worker service."""
|
||||
return mock.Mock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_worker_service_factory(mock_worker_service):
|
||||
"""Mock worker service factory."""
|
||||
with mock.patch(
|
||||
"core.api.viewsets.get_worker_service",
|
||||
return_value=mock_worker_service,
|
||||
) as mock_worker_service_factory:
|
||||
yield mock_worker_service_factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_worker_manager(mock_worker_service):
|
||||
"""Mock worker service mediator."""
|
||||
with mock.patch("core.api.viewsets.WorkerServiceMediator") as mock_mediator_class:
|
||||
mock_mediator = mock.Mock()
|
||||
mock_mediator_class.return_value = mock_mediator
|
||||
yield mock_mediator
|
||||
|
||||
|
||||
def test_stop_recording_anonymous():
|
||||
"""Anonymous users should not be allowed to stop room recordings."""
|
||||
room = RoomFactory()
|
||||
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||
|
||||
assert response.status_code == 401
|
||||
# Verify recording status hasn't changed
|
||||
assert Recording.objects.filter(status=RecordingStatusChoices.ACTIVE).count() == 1
|
||||
|
||||
|
||||
def test_stop_recording_non_owner_and_non_administrator():
|
||||
"""Non-owner and Non-Administrator users should not be allowed to stop room recordings."""
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||
|
||||
assert response.status_code == 403
|
||||
# Verify recording status hasn't changed
|
||||
assert Recording.objects.filter(status=RecordingStatusChoices.ACTIVE).count() == 1
|
||||
|
||||
|
||||
def test_stop_recording_recording_disabled(settings):
|
||||
"""Should fail if recording is disabled for the room."""
|
||||
|
||||
settings.RECORDING_ENABLE = False
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
# Make user the room owner
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {"detail": "Access denied, recording is disabled."}
|
||||
# Verify no recording exists
|
||||
assert Recording.objects.count() == 0
|
||||
|
||||
|
||||
def test_stop_recording_no_active_recording(settings):
|
||||
"""Should fail when there is no active recording for the room."""
|
||||
|
||||
settings.RECORDING_ENABLE = True
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
# Make user the room owner
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "No active recording found for this room."}
|
||||
|
||||
|
||||
def test_stop_recording_worker_error(
|
||||
mock_worker_service_factory, mock_worker_manager, settings
|
||||
):
|
||||
"""Should handle worker service errors appropriately."""
|
||||
|
||||
settings.RECORDING_ENABLE = True
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
recording = RecordingFactory(
|
||||
room=room,
|
||||
status=RecordingStatusChoices.ACTIVE,
|
||||
mode="screen_recording",
|
||||
)
|
||||
# Make user the room owner
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
# Configure mock mediator to raise error
|
||||
mock_stop = mock.Mock()
|
||||
mock_stop.side_effect = RecordingStopError("Failed to connect to worker")
|
||||
mock_worker_manager.stop = mock_stop
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||
|
||||
mock_worker_service_factory.assert_called_once_with(mode="screen_recording")
|
||||
mock_stop.assert_called_once_with(recording)
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.json() == {
|
||||
"error": f"Recording failed to stop for room {room.slug}"
|
||||
}
|
||||
# Verify recording status hasn't changed
|
||||
assert Recording.objects.filter(status=RecordingStatusChoices.ACTIVE).count() == 1
|
||||
|
||||
|
||||
def test_stop_recording_success(
|
||||
mock_worker_service_factory, mock_worker_manager, settings
|
||||
):
|
||||
"""Should successfully stop recording when everything is configured correctly."""
|
||||
|
||||
settings.RECORDING_ENABLE = True
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
recording = RecordingFactory(
|
||||
room=room,
|
||||
status=RecordingStatusChoices.ACTIVE,
|
||||
mode="screen_recording",
|
||||
)
|
||||
# Make user the room owner
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
mock_stop = mock.Mock()
|
||||
mock_worker_manager.stop = mock_stop
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(f"/api/v1.0/rooms/{room.id}/stop-recording/")
|
||||
|
||||
mock_worker_service_factory.assert_called_once_with(mode="screen_recording")
|
||||
mock_stop.assert_called_once_with(recording)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": f"Recording stopped for room {room.slug}."}
|
||||
|
||||
# Verify the recording still exists
|
||||
assert Recording.objects.count() == 1
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Test rooms API endpoints in the Meet core app: update.
|
||||
"""
|
||||
|
||||
import random
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
"""
|
||||
Test marketing services.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621,W0613
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
import brevo_python
|
||||
import pytest
|
||||
|
||||
from core.services.marketing_service import (
|
||||
BrevoMarketingService,
|
||||
ContactCreationError,
|
||||
ContactData,
|
||||
get_marketing_service,
|
||||
)
|
||||
|
||||
|
||||
def test_init_missing_api_key(settings):
|
||||
"""Test initialization with missing API key."""
|
||||
settings.BREVO_API_KEY = None
|
||||
with pytest.raises(ImproperlyConfigured, match="Brevo API key is required"):
|
||||
BrevoMarketingService()
|
||||
|
||||
|
||||
def test_create_contact_missing_list_ids(settings):
|
||||
"""Test contact creation with missing list IDs."""
|
||||
|
||||
settings.BREVO_API_KEY = "test-api-key"
|
||||
settings.BREVO_API_CONTACT_LIST_IDS = None
|
||||
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()
|
||||
|
||||
with pytest.raises(
|
||||
ImproperlyConfigured, match="Default Brevo List IDs must be configured"
|
||||
):
|
||||
brevo_service.create_contact(valid_contact_data)
|
||||
|
||||
|
||||
@mock.patch("brevo_python.ContactsApi")
|
||||
def test_create_contact_success(mock_contact_api):
|
||||
"""Test successful contact creation."""
|
||||
|
||||
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.return_value = {"id": "test-id"}
|
||||
response = brevo_service.create_contact(valid_contact_data)
|
||||
|
||||
assert response == {"id": "test-id"}
|
||||
|
||||
mock_api.create_contact.assert_called_once()
|
||||
contact_arg = mock_api.create_contact.call_args[0][0]
|
||||
assert contact_arg.email == "test@example.com"
|
||||
assert contact_arg.attributes == {
|
||||
**settings.BREVO_API_CONTACT_ATTRIBUTES,
|
||||
**valid_contact_data.attributes,
|
||||
}
|
||||
assert set(contact_arg.list_ids) == {1, 2, 3, 4}
|
||||
assert contact_arg.update_enabled is True
|
||||
|
||||
|
||||
@mock.patch("brevo_python.ContactsApi")
|
||||
def test_create_contact_with_timeout(mock_contact_api):
|
||||
"""Test contact creation with timeout."""
|
||||
|
||||
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()
|
||||
brevo_service.create_contact(valid_contact_data, timeout=30)
|
||||
|
||||
mock_api.create_contact.assert_called_once()
|
||||
assert mock_api.create_contact.call_args[1]["_request_timeout"] == 30
|
||||
|
||||
|
||||
@mock.patch("brevo_python.ContactsApi")
|
||||
def test_create_contact_api_error(mock_contact_api):
|
||||
"""Test contact creation API 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 = brevo_python.rest.ApiException()
|
||||
|
||||
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."""
|
||||
get_marketing_service.cache_clear()
|
||||
yield
|
||||
get_marketing_service.cache_clear()
|
||||
|
||||
|
||||
def test_get_marketing_service_caching(clear_marketing_cache):
|
||||
"""Test marketing service caching behavior."""
|
||||
settings.BREVO_API_KEY = "test-api-key"
|
||||
settings.MARKETING_SERVICE_CLASS = (
|
||||
"core.services.marketing_service.BrevoMarketingService"
|
||||
)
|
||||
|
||||
service1 = get_marketing_service()
|
||||
service2 = get_marketing_service()
|
||||
|
||||
assert service1 is service2
|
||||
assert isinstance(service1, BrevoMarketingService)
|
||||
|
||||
|
||||
def test_get_marketing_service_invalid_class(clear_marketing_cache):
|
||||
"""Test handling of invalid service class."""
|
||||
settings.MARKETING_SERVICE_CLASS = "invalid.service.path"
|
||||
|
||||
with pytest.raises(ImportError):
|
||||
get_marketing_service()
|
||||
|
||||
|
||||
@mock.patch("core.services.marketing_service.import_string")
|
||||
def test_service_instantiation_called_once(mock_import_string, clear_marketing_cache):
|
||||
"""Test service class is instantiated only once."""
|
||||
|
||||
settings.BREVO_API_KEY = "test-api-key"
|
||||
settings.MARKETING_SERVICE_CLASS = (
|
||||
"core.services.marketing_service.BrevoMarketingService"
|
||||
)
|
||||
get_marketing_service.cache_clear()
|
||||
|
||||
mock_service_cls = mock.Mock()
|
||||
mock_service_instance = mock.Mock()
|
||||
mock_service_cls.return_value = mock_service_instance
|
||||
mock_import_string.return_value = mock_service_cls
|
||||
|
||||
service1 = get_marketing_service()
|
||||
service2 = get_marketing_service()
|
||||
|
||||
mock_import_string.assert_called_once_with(settings.MARKETING_SERVICE_CLASS)
|
||||
mock_service_cls.assert_called_once()
|
||||
assert service1 is service2
|
||||
assert service1 is mock_service_instance
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Test suite for generated openapi schema.
|
||||
"""
|
||||
|
||||
import json
|
||||
from io import StringIO
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Test resource accesses API endpoints in the Meet core app.
|
||||
"""
|
||||
|
||||
import random
|
||||
from unittest import mock
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Test users API endpoints in the Meet core app.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
@@ -65,7 +66,7 @@ def test_api_users_list_query_email():
|
||||
|
||||
assert response.status_code == 200
|
||||
user_ids = [user["id"] for user in response.json()["results"]]
|
||||
assert user_ids == [str(nicole.id), str(frank.id)]
|
||||
assert user_ids == [str(frank.id), str(nicole.id)]
|
||||
|
||||
|
||||
def test_api_users_retrieve_me_anonymous():
|
||||
@@ -95,6 +96,8 @@ def test_api_users_retrieve_me_authenticated():
|
||||
assert response.json() == {
|
||||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
"short_name": user.short_name,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
"""
|
||||
Unit tests for the Recording model
|
||||
"""
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
import pytest
|
||||
|
||||
from core.factories import (
|
||||
RecordingFactory,
|
||||
RoomFactory,
|
||||
UserFactory,
|
||||
UserRecordingAccessFactory,
|
||||
)
|
||||
from core.models import Recording, RecordingStatusChoices
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_models_recording_str():
|
||||
"""The str representation should be the recording ID."""
|
||||
recording = RecordingFactory()
|
||||
assert str(recording) == f"Recording {recording.id} (initiated)"
|
||||
|
||||
|
||||
def test_models_recording_ordering():
|
||||
"""Recordings should be returned ordered by created_at in descending order."""
|
||||
RecordingFactory.create_batch(3)
|
||||
recordings = Recording.objects.all()
|
||||
assert recordings[0].created_at >= recordings[1].created_at
|
||||
assert recordings[1].created_at >= recordings[2].created_at
|
||||
|
||||
|
||||
def test_models_recording_room_relationship():
|
||||
"""It should maintain proper relationship with room."""
|
||||
room = RoomFactory()
|
||||
recording = RecordingFactory(room=room)
|
||||
assert recording.room == room
|
||||
assert recording in room.recordings.all()
|
||||
|
||||
|
||||
def test_models_recording_default_status():
|
||||
"""A new recording should have INITIATED status by default."""
|
||||
recording = RecordingFactory()
|
||||
assert recording.status == RecordingStatusChoices.INITIATED
|
||||
|
||||
|
||||
def test_models_recording_unique_initiated_or_active_per_room():
|
||||
"""Only one initiated or active recording should be allowed per room."""
|
||||
room = RoomFactory()
|
||||
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
RecordingFactory(room=room, status=RecordingStatusChoices.ACTIVE)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
RecordingFactory(room=room, status=RecordingStatusChoices.INITIATED)
|
||||
|
||||
|
||||
def test_models_recording_multiple_finished_allowed():
|
||||
"""Multiple finished recordings should be allowed in the same room."""
|
||||
room = RoomFactory()
|
||||
RecordingFactory(room=room, status=RecordingStatusChoices.SAVED)
|
||||
RecordingFactory(room=room, status=RecordingStatusChoices.SAVED)
|
||||
assert room.recordings.count() == 2
|
||||
|
||||
|
||||
# Test get_abilities method
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
["owner", "administrator"],
|
||||
)
|
||||
def test_models_recording_get_abilities_privileges_active(role):
|
||||
"""Test abilities for active recording and privileged user."""
|
||||
|
||||
user = UserFactory()
|
||||
access = UserRecordingAccessFactory(role=role, user=user)
|
||||
access.recording.status = RecordingStatusChoices.ACTIVE
|
||||
abilities = access.recording.get_abilities(user)
|
||||
|
||||
assert abilities == {
|
||||
"destroy": False, # Not final status
|
||||
"partial_update": False,
|
||||
"retrieve": True, # Privileged users can always retrieve
|
||||
"stop": True, # Not final status, so can stop
|
||||
"update": False,
|
||||
}
|
||||
|
||||
|
||||
def test_models_recording_get_abilities_member_active():
|
||||
"""Test abilities for a user who is unprivileged."""
|
||||
|
||||
user = UserFactory()
|
||||
access = UserRecordingAccessFactory(role="member", user=user)
|
||||
access.recording.status = RecordingStatusChoices.ACTIVE
|
||||
abilities = access.recording.get_abilities(user)
|
||||
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"partial_update": False,
|
||||
"retrieve": False,
|
||||
"stop": False,
|
||||
"update": False,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
["owner", "administrator"],
|
||||
)
|
||||
def test_models_recording_get_abilities_privileges_final(role):
|
||||
"""Test abilities for active recording and privileged user."""
|
||||
|
||||
user = UserFactory()
|
||||
access = UserRecordingAccessFactory(role=role, user=user)
|
||||
access.recording.status = RecordingStatusChoices.SAVED
|
||||
abilities = access.recording.get_abilities(user)
|
||||
|
||||
assert abilities == {
|
||||
"destroy": True,
|
||||
"partial_update": False,
|
||||
"retrieve": True, # Privileged users can always retrieve
|
||||
"stop": False, # In final status, so can not stop
|
||||
"update": False,
|
||||
}
|
||||
|
||||
|
||||
def test_models_recording_get_abilities_member_final():
|
||||
"""Test abilities for a user who is unprivileged."""
|
||||
|
||||
user = UserFactory()
|
||||
access = UserRecordingAccessFactory(role="member", user=user)
|
||||
access.recording.status = RecordingStatusChoices.SAVED
|
||||
abilities = access.recording.get_abilities(user)
|
||||
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"partial_update": False,
|
||||
"retrieve": False,
|
||||
"stop": False,
|
||||
"update": False,
|
||||
}
|
||||
|
||||
|
||||
# Test is_savable method
|
||||
|
||||
|
||||
def test_models_recording_is_savable_normal():
|
||||
"""Test is_savable for normal recording status."""
|
||||
recording = RecordingFactory(status=RecordingStatusChoices.ACTIVE)
|
||||
assert recording.is_savable() is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status",
|
||||
[
|
||||
RecordingStatusChoices.FAILED_TO_STOP,
|
||||
RecordingStatusChoices.FAILED_TO_START,
|
||||
RecordingStatusChoices.ABORTED,
|
||||
],
|
||||
)
|
||||
def test_models_recording_is_savable_error(status):
|
||||
"""Test is_savable for error status."""
|
||||
recording = RecordingFactory(status=status)
|
||||
assert recording.is_savable() is False
|
||||
|
||||
|
||||
def test_models_recording_is_savable_already_saved():
|
||||
"""Test is_savable for already saved recording."""
|
||||
recording = RecordingFactory(status=RecordingStatusChoices.SAVED)
|
||||
assert recording.is_savable() is False
|
||||
|
||||
|
||||
def test_models_recording_is_savable_only_initiated():
|
||||
"""Test is_savable for only initiated recording."""
|
||||
recording = RecordingFactory(status=RecordingStatusChoices.INITIATED)
|
||||
assert recording.is_savable() is False
|
||||
|
||||
|
||||
# Test few corner cases
|
||||
|
||||
|
||||
def test_models_recording_worker_id_optional():
|
||||
"""Worker ID should be optional."""
|
||||
recording = RecordingFactory(worker_id=None)
|
||||
assert recording.worker_id is None
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-123")
|
||||
assert recording.worker_id == "worker-123"
|
||||
|
||||
|
||||
def test_models_recording_invalid_status():
|
||||
"""Test that setting an invalid status raises an error."""
|
||||
recording = RecordingFactory()
|
||||
recording.status = "INVALID_STATUS"
|
||||
with pytest.raises(ValidationError):
|
||||
recording.save()
|
||||
|
||||
|
||||
def test_models_recording_room_deletion():
|
||||
"""Test that deleting a room cascades to its recordings."""
|
||||
room = RoomFactory()
|
||||
recording = RecordingFactory(room=room)
|
||||
room.delete()
|
||||
assert not Recording.objects.filter(id=recording.id).exists()
|
||||
|
||||
|
||||
def test_models_recording_worker_id_very_long():
|
||||
"""Test worker_id with maximum length."""
|
||||
long_id = "w" * 255
|
||||
recording = RecordingFactory(worker_id=long_id)
|
||||
assert recording.worker_id == long_id
|
||||
|
||||
too_long_id = "w" * 256
|
||||
with pytest.raises(ValidationError):
|
||||
RecordingFactory(worker_id=too_long_id)
|
||||
@@ -0,0 +1,312 @@
|
||||
"""
|
||||
Unit tests for the RecordingAccess model
|
||||
"""
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_models_recording_accesses_str():
|
||||
"""
|
||||
The str representation should include user email, recording ID and role.
|
||||
"""
|
||||
user = factories.UserFactory(email="david.bowman@example.com")
|
||||
access = factories.UserRecordingAccessFactory(
|
||||
role="member",
|
||||
user=user,
|
||||
)
|
||||
assert (
|
||||
str(access)
|
||||
== f"david.bowman@example.com is member in Recording {access.recording.id} (initiated)"
|
||||
)
|
||||
|
||||
|
||||
def test_models_recording_accesses_unique_user():
|
||||
"""Recording accesses should be unique for a given couple of user and recording."""
|
||||
access = factories.UserRecordingAccessFactory()
|
||||
|
||||
with pytest.raises(
|
||||
ValidationError,
|
||||
match="This user is already in this recording.",
|
||||
):
|
||||
factories.UserRecordingAccessFactory(
|
||||
user=access.user, recording=access.recording
|
||||
)
|
||||
|
||||
|
||||
def test_models_recording_accesses_several_empty_teams():
|
||||
"""A recording can have several recording accesses with an empty team."""
|
||||
access = factories.UserRecordingAccessFactory()
|
||||
factories.UserRecordingAccessFactory(recording=access.recording)
|
||||
|
||||
|
||||
def test_models_recording_accesses_unique_team():
|
||||
"""Recording accesses should be unique for a given couple of team and recording."""
|
||||
access = factories.TeamRecordingAccessFactory()
|
||||
|
||||
with pytest.raises(
|
||||
ValidationError,
|
||||
match="This team is already in this recording.",
|
||||
):
|
||||
factories.TeamRecordingAccessFactory(
|
||||
team=access.team, recording=access.recording
|
||||
)
|
||||
|
||||
|
||||
def test_models_recording_accesses_several_null_users():
|
||||
"""A recording can have several recording accesses with a null user."""
|
||||
access = factories.TeamRecordingAccessFactory()
|
||||
factories.TeamRecordingAccessFactory(recording=access.recording)
|
||||
|
||||
|
||||
def test_models_recording_accesses_user_and_team_set():
|
||||
"""User and team can't both be set on a recording access."""
|
||||
with pytest.raises(
|
||||
ValidationError,
|
||||
match="Either user or team must be set, not both.",
|
||||
):
|
||||
factories.UserRecordingAccessFactory(team="my-team")
|
||||
|
||||
|
||||
def test_models_recording_accesses_user_and_team_empty():
|
||||
"""User and team can't both be empty on a recording access."""
|
||||
with pytest.raises(
|
||||
ValidationError,
|
||||
match="Either user or team must be set, not both.",
|
||||
):
|
||||
factories.UserRecordingAccessFactory(user=None)
|
||||
|
||||
|
||||
# get_abilities
|
||||
|
||||
|
||||
def test_models_recording_access_get_abilities_anonymous():
|
||||
"""Check abilities returned for an anonymous user."""
|
||||
access = factories.UserRecordingAccessFactory()
|
||||
abilities = access.get_abilities(AnonymousUser())
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": False,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"set_role_to": [],
|
||||
}
|
||||
|
||||
|
||||
def test_models_recording_access_get_abilities_authenticated():
|
||||
"""Check abilities returned for an authenticated user."""
|
||||
access = factories.UserRecordingAccessFactory()
|
||||
user = factories.UserFactory()
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": False,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"set_role_to": [],
|
||||
}
|
||||
|
||||
|
||||
# - for owner
|
||||
|
||||
|
||||
def test_models_recording_access_get_abilities_for_owner_of_self_allowed():
|
||||
"""
|
||||
Check abilities of self access for the owner of a recording when
|
||||
there is more than one owner left.
|
||||
"""
|
||||
access = factories.UserRecordingAccessFactory(role="owner")
|
||||
factories.UserRecordingAccessFactory(recording=access.recording, role="owner")
|
||||
abilities = access.get_abilities(access.user)
|
||||
assert abilities == {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
"set_role_to": ["administrator", "member"],
|
||||
}
|
||||
|
||||
|
||||
def test_models_recording_access_get_abilities_for_owner_of_self_last():
|
||||
"""
|
||||
Check abilities of self access for the owner of a recording when there is only one owner left.
|
||||
"""
|
||||
access = factories.UserRecordingAccessFactory(role="owner")
|
||||
abilities = access.get_abilities(access.user)
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"set_role_to": [],
|
||||
}
|
||||
|
||||
|
||||
def test_models_recording_access_get_abilities_for_owner_of_owner():
|
||||
"""Check abilities of owner access for the owner of a recording."""
|
||||
access = factories.UserRecordingAccessFactory(role="owner")
|
||||
factories.UserRecordingAccessFactory(recording=access.recording) # another one
|
||||
user = factories.UserRecordingAccessFactory(
|
||||
recording=access.recording, role="owner"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
"set_role_to": ["administrator", "member"],
|
||||
}
|
||||
|
||||
|
||||
def test_models_recording_access_get_abilities_for_owner_of_administrator():
|
||||
"""Check abilities of administrator access for the owner of a recording."""
|
||||
access = factories.UserRecordingAccessFactory(role="administrator")
|
||||
factories.UserRecordingAccessFactory(recording=access.recording) # another one
|
||||
user = factories.UserRecordingAccessFactory(
|
||||
recording=access.recording, role="owner"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
"set_role_to": ["member", "owner"],
|
||||
}
|
||||
|
||||
|
||||
def test_models_recording_access_get_abilities_for_owner_of_member():
|
||||
"""Check abilities of member access for the owner of a recording."""
|
||||
access = factories.UserRecordingAccessFactory(role="member")
|
||||
factories.UserRecordingAccessFactory(recording=access.recording) # another one
|
||||
user = factories.UserRecordingAccessFactory(
|
||||
recording=access.recording, role="owner"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
"set_role_to": ["administrator", "owner"],
|
||||
}
|
||||
|
||||
|
||||
# - for administrator
|
||||
|
||||
|
||||
def test_models_recording_access_get_abilities_for_administrator_of_owner():
|
||||
"""Check abilities of owner access for the administrator of a recording."""
|
||||
access = factories.UserRecordingAccessFactory(role="owner")
|
||||
factories.UserRecordingAccessFactory(recording=access.recording) # another one
|
||||
user = factories.UserRecordingAccessFactory(
|
||||
recording=access.recording, role="administrator"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"set_role_to": [],
|
||||
}
|
||||
|
||||
|
||||
def test_models_recording_access_get_abilities_for_administrator_of_administrator():
|
||||
"""Check abilities of administrator access for the administrator of a recording."""
|
||||
access = factories.UserRecordingAccessFactory(role="administrator")
|
||||
factories.UserRecordingAccessFactory(recording=access.recording) # another one
|
||||
user = factories.UserRecordingAccessFactory(
|
||||
recording=access.recording, role="administrator"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
"set_role_to": ["member"],
|
||||
}
|
||||
|
||||
|
||||
def test_models_recording_access_get_abilities_for_administrator_of_member():
|
||||
"""Check abilities of member access for the administrator of a recording."""
|
||||
access = factories.UserRecordingAccessFactory(role="member")
|
||||
factories.UserRecordingAccessFactory(recording=access.recording) # another one
|
||||
user = factories.UserRecordingAccessFactory(
|
||||
recording=access.recording, role="administrator"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": True,
|
||||
"retrieve": True,
|
||||
"update": True,
|
||||
"partial_update": True,
|
||||
"set_role_to": ["administrator"],
|
||||
}
|
||||
|
||||
|
||||
# - for member
|
||||
|
||||
|
||||
def test_models_recording_access_get_abilities_for_member_of_owner():
|
||||
"""Check abilities of owner access for the member of a recording."""
|
||||
access = factories.UserRecordingAccessFactory(role="owner")
|
||||
factories.UserRecordingAccessFactory(recording=access.recording) # another one
|
||||
user = factories.UserRecordingAccessFactory(
|
||||
recording=access.recording, role="member"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"set_role_to": [],
|
||||
}
|
||||
|
||||
|
||||
def test_models_recording_access_get_abilities_for_member_of_administrator():
|
||||
"""Check abilities of administrator access for the member of a recording."""
|
||||
access = factories.UserRecordingAccessFactory(role="administrator")
|
||||
factories.UserRecordingAccessFactory(recording=access.recording) # another one
|
||||
user = factories.UserRecordingAccessFactory(
|
||||
recording=access.recording, role="member"
|
||||
).user
|
||||
abilities = access.get_abilities(user)
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"set_role_to": [],
|
||||
}
|
||||
|
||||
|
||||
def test_models_recording_access_get_abilities_for_member_of_member_user(
|
||||
django_assert_num_queries,
|
||||
):
|
||||
"""Check abilities of member access for the member of a recording."""
|
||||
access = factories.UserRecordingAccessFactory(role="member")
|
||||
factories.UserRecordingAccessFactory(recording=access.recording) # another one
|
||||
user = factories.UserRecordingAccessFactory(
|
||||
recording=access.recording, role="member"
|
||||
).user
|
||||
|
||||
with django_assert_num_queries(1):
|
||||
abilities = access.get_abilities(user)
|
||||
|
||||
assert abilities == {
|
||||
"destroy": False,
|
||||
"retrieve": True,
|
||||
"update": False,
|
||||
"partial_update": False,
|
||||
"set_role_to": [],
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Unit tests for the ResourceAccess model with user
|
||||
"""
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Unit tests for the Room model
|
||||
"""
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""
|
||||
Unit tests for the User model
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user