Compare commits
285 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d126119ff0 | |||
| 83d04c499b | |||
| 41c1f41ed2 | |||
| dc06b55693 | |||
| 20aceb1932 | |||
| 3671f2a0dd | |||
| d74dd967af | |||
| 3a4f4e7016 | |||
| b7d964db56 | |||
| 88b7a7dc58 | |||
| baca9fc001 | |||
| c432524f2a | |||
| a079ceef71 | |||
| f0742a0978 | |||
| fc1b4d7fa7 | |||
| 46f26eb493 | |||
| ff09c3d969 | |||
| ba9d22f6c8 | |||
| 94e71ba15d | |||
| 9a1384b188 | |||
| 695ac47014 | |||
| b3c1deeb9c | |||
| c08c3efdbb | |||
| 468d09dc3b | |||
| a22d052f46 | |||
| 9734df9d5d | |||
| f596aae1e8 | |||
| 32956f495f | |||
| 69381a6c4b | |||
| d537a4449a | |||
| 50c9304afe | |||
| 37fe23c0f7 | |||
| 255da4bf60 | |||
| 6dccb507d2 | |||
| d202a025e7 | |||
| ac9ba0df62 | |||
| 91562d049c | |||
| 60321296e5 | |||
| ae06873ff5 | |||
| 70ffb758c7 | |||
| 33a94da636 | |||
| aa6757601d | |||
| c26b1b711c | |||
| 3ee33fc2ec | |||
| d43b2857c1 | |||
| 3b1b644ef9 | |||
| ab7a0e1ec2 | |||
| 86a4d68daa | |||
| de3df889ba | |||
| 7c9fc15359 | |||
| a6a67f2752 | |||
| 00eb52cd3f | |||
| 6324be9fd3 | |||
| 66f307b7e8 | |||
| 506b3978e1 | |||
| db65aef56e | |||
| 4e1a4be650 | |||
| 1f603ce17b | |||
| 672ca7dfe4 | |||
| 7405011cd2 | |||
| 1d5aebcfdc | |||
| 0d6881382b | |||
| c428f39456 | |||
| cd9b80b966 | |||
| f80603b4bb | |||
| 3299ae7c39 | |||
| 93ca4f2bf4 | |||
| 19a4e442c5 | |||
| 53f1c9c1d4 | |||
| 28538b63da | |||
| 94ae5d52c2 | |||
| 6b38a3c996 | |||
| 0af30ec366 | |||
| 33e017464f | |||
| 9e91bbe417 | |||
| e821982353 | |||
| 18f4a117ab | |||
| b5b99d4c52 | |||
| 8a4323aa82 | |||
| 5cbac23f13 | |||
| d55810784d | |||
| c2fdefe7b2 | |||
| bf2474d0e1 | |||
| 558d844ef3 | |||
| 94aa828d87 | |||
| 110e062e56 | |||
| f7c2beb30d | |||
| 5bf1456200 | |||
| 80e5c9bb95 | |||
| 74164b8498 | |||
| 82cc2e5d17 | |||
| fa1503db0d | |||
| e106415740 | |||
| 4199328a4a | |||
| 57715fea73 | |||
| c969c302bc | |||
| be29e193c5 | |||
| 53d0e9fe12 | |||
| e04d9a9dab | |||
| fdb6dda65b | |||
| 332662d1e5 | |||
| a8e1bbe085 | |||
| 9eae98ed16 | |||
| 3cae3e66c4 | |||
| 7e463f4554 | |||
| 1c40003c3c | |||
| 5f07d4a88b | |||
| 6cf8e23ab2 | |||
| b4016ce850 | |||
| 74aba2185a | |||
| 0bbaae7c5e | |||
| fbee41f5dd | |||
| 2503411311 | |||
| 13944ceebd | |||
| 50719e8c25 | |||
| 11c2c2dea8 | |||
| d2f79d4524 | |||
| 2168643fd4 | |||
| 356797d326 | |||
| 19b88a2078 | |||
| b169e57193 | |||
| 5d81ba1e20 | |||
| b248395cd6 | |||
| 3eef4765df | |||
| ae920c0c9b | |||
| 86cb10a3c7 | |||
| 634b34f2e9 | |||
| 33774a44d4 | |||
| ce1c3d26d2 | |||
| b5e7d7eeec | |||
| 50b9509c2c | |||
| aaf1163910 | |||
| 00cd4fc92a | |||
| 0aa4f6389b | |||
| e2f60775a9 | |||
| e20acfa5a9 | |||
| fac9435bc7 | |||
| 3e9992bae3 | |||
| 25d4ede2dd | |||
| 6545ecf11a | |||
| b73f18419b | |||
| 49163eba67 | |||
| 38c3776556 | |||
| 75e4092dad | |||
| 2774d76176 | |||
| da05438f45 | |||
| 5d89efec78 | |||
| 7c46029f87 | |||
| e535040ac6 | |||
| 92851b10cc | |||
| ea37a3154e | |||
| 65ddf2e2a1 | |||
| a48501bc02 | |||
| 4d961ed162 | |||
| 710d7964ee | |||
| 01f4d05d6b | |||
| 7fad60d9a9 | |||
| e9ebac46ac | |||
| d64e5d1923 | |||
| c9512004ac | |||
| 879cf20891 | |||
| 433a3a7cdf | |||
| c0bcced3c0 | |||
| 4045662433 | |||
| e7f11194ce | |||
| 4955f3eea7 | |||
| c576a75660 | |||
| 51fa4e84e4 | |||
| eec6a46883 | |||
| 19a240c63f | |||
| 2236674849 | |||
| e5fe81de4d | |||
| 38ab001bcf | |||
| 14885e0ffa | |||
| 054e7ba945 | |||
| 25506b6a2a | |||
| 0498a9e7f9 | |||
| 0a63270154 | |||
| b962dddbf2 | |||
| 16929bcc83 | |||
| 354bdd8bfe | |||
| ccca2b9472 | |||
| 9fc5681137 | |||
| edf29076ba | |||
| 337181add8 | |||
| 5af64e539d | |||
| 291544bd52 | |||
| 13bb6acdf6 | |||
| aa2783ae2e | |||
| c3e4ea0fd1 | |||
| 183e8f6a72 | |||
| 452dbe8bba | |||
| 8818d12ee9 | |||
| fb0c9b766d | |||
| ba873196c7 | |||
| 48937bb7a3 | |||
| 6f09eb3602 | |||
| b7fb6b1b69 | |||
| 2cd4a6efa8 | |||
| b5037db685 | |||
| 19804d2e3f | |||
| a979f05549 | |||
| 09a0496d25 | |||
| da95e804a0 | |||
| 591a3a5d8b | |||
| 30ed0da416 | |||
| bd4fcc2a5e | |||
| fc36ae8b49 | |||
| 87baca247b | |||
| 42d3846660 | |||
| 2d393f9f70 | |||
| b4b4ff79d9 | |||
| 861244ce01 | |||
| 6373593de3 | |||
| 03f6e6519b | |||
| 716d40dd4e | |||
| 17f8ec6319 | |||
| 0b74cf96f2 | |||
| 723b8718f9 | |||
| 07a44dab36 | |||
| e47b027bbc | |||
| d7a4f3946c | |||
| 1b7523bbf1 | |||
| 4326df4b6a | |||
| 564d31ab49 | |||
| 6e0948c696 | |||
| eff0cb4afb | |||
| 08c5245b48 | |||
| 465bf293f0 | |||
| db51ca1aa5 | |||
| 6935001aab | |||
| bbc2b1d9f6 | |||
| 12e2149b9e | |||
| 3bd9363879 | |||
| b48135c3b6 | |||
| 206e74c645 | |||
| 9ebfc8ea29 | |||
| 03796fcbb2 | |||
| 4b6bd3d1c8 | |||
| d45880ab5c | |||
| 4ae3d965f9 | |||
| 8eab45b6d5 | |||
| 4347d87f33 | |||
| 1b52d76168 | |||
| a44b6e8e34 | |||
| 126de638cd | |||
| bf28c5cb84 | |||
| 4f0e7d2c52 | |||
| 3ab5c48296 | |||
| 7f1f573af8 | |||
| d48a18b36b | |||
| eac9158734 | |||
| 994f335266 | |||
| 30bde2fe66 | |||
| 96c18fc627 | |||
| c380ff5e1d | |||
| 6587863afb | |||
| a06daad33d | |||
| 10c3c67d53 | |||
| fa85ef5690 | |||
| cc676cf71b | |||
| 90df8fe5b4 | |||
| 90cfcd31c8 | |||
| 0ed3416ce2 | |||
| e47401246f | |||
| a518179efb | |||
| f17471dae5 | |||
| 36ea44befc | |||
| 047ef83f17 | |||
| e1ece8b5af | |||
| 56379f2d6e | |||
| 341be37fd3 | |||
| 4cce2e0b72 | |||
| f14536dd93 | |||
| dc0e2eefb3 | |||
| a61b34400b | |||
| 3c8e3b9e29 | |||
| 89f2ae548e | |||
| 1f23bbf96e | |||
| 9972692dac | |||
| 6d08e318a7 | |||
| 203f1762e7 | |||
| 470390fc59 | |||
| fe9fe4dd90 | |||
| 7ad9015a6b |
@@ -11,34 +11,17 @@ jobs:
|
||||
notify-argocd:
|
||||
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: Call argocd github webhook
|
||||
run: |
|
||||
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
|
||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
|
||||
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_WEBHOOK_URL
|
||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_PRODUCTION_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
|
||||
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_PRODUCTION_WEBHOOK_URL
|
||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${{ secrets.ARGOCD_WEBHOOK_SECRET }}'' | awk '{print "X-Hub-Signature: sha1="$2}')
|
||||
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ vars.ARGOCD_WEBHOOK_URL }}
|
||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${{ secrets.ARGOCD_PRODUCTION_WEBHOOK_SECRET }}'' | awk '{print "X-Hub-Signature: sha1="$2}')
|
||||
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ secrets.ARGOCD_PRODUCTION_WEBHOOK_URL }}
|
||||
|
||||
start-test-on-preprod:
|
||||
needs:
|
||||
|
||||
@@ -19,26 +19,9 @@ jobs:
|
||||
build-and-push-backend:
|
||||
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
|
||||
@@ -48,7 +31,7 @@ jobs:
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
@@ -69,26 +52,9 @@ jobs:
|
||||
build-and-push-frontend:
|
||||
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
|
||||
@@ -98,7 +64,7 @@ jobs:
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||
-
|
||||
name: Run trivy scan
|
||||
uses: numerique-gouv/action-trivy-cache@main
|
||||
@@ -120,26 +86,9 @@ jobs:
|
||||
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
|
||||
@@ -149,7 +98,7 @@ jobs:
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
run: echo "$DOCKER_HUB_PASSWORD" | docker login -u "$DOCKER_HUB_USER" --password-stdin
|
||||
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
|
||||
-
|
||||
name: Build and push
|
||||
uses: docker/build-push-action@v6
|
||||
@@ -162,39 +111,17 @@ jobs:
|
||||
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'
|
||||
if: github.event_name != 'pull_request'
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
- uses: numerique-gouv/action-argocd-webhook-notification@main
|
||||
id: notify
|
||||
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: Call argocd github webhook
|
||||
run: |
|
||||
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
|
||||
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac ''${ARGOCD_WEBHOOK_SECRET}'' | awk '{print "X-Hub-Signature: sha1="$2}')
|
||||
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" $ARGOCD_WEBHOOK_URL
|
||||
deployment_repo_path: "${{ secrets.DEPLOYMENT_REPO_URL }}"
|
||||
argocd_webhook_secret: "${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET }}"
|
||||
argocd_url: "${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }}"
|
||||
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
python-version: "3.12"
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
- name: Check code formatting with ruff
|
||||
@@ -110,6 +110,16 @@ jobs:
|
||||
- 5432:5432
|
||||
# needed because the postgres container does not provide a healthcheck
|
||||
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
redis:
|
||||
image: redis:5
|
||||
ports:
|
||||
- 6379:6379
|
||||
# Set health checks to wait until redis has started
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
env:
|
||||
DJANGO_CONFIGURATION: Test
|
||||
@@ -121,9 +131,13 @@ jobs:
|
||||
DB_USER: dinum
|
||||
DB_PASSWORD: pass
|
||||
DB_PORT: 5432
|
||||
REDIS_URL: redis://localhost:6379/1
|
||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
LIVEKIT_API_SECRET: secret
|
||||
LIVEKIT_API_KEY: devkey
|
||||
AWS_S3_ENDPOINT_URL: http://localhost:9000
|
||||
AWS_S3_ACCESS_KEY_ID: meet
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -141,10 +155,37 @@ jobs:
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
|
||||
- name: Start MinIO
|
||||
run: |
|
||||
docker pull minio/minio
|
||||
docker run -d --name minio \
|
||||
-p 9000:9000 \
|
||||
-e "MINIO_ACCESS_KEY=meet" \
|
||||
-e "MINIO_SECRET_KEY=password" \
|
||||
-v /data/media:/data \
|
||||
minio/minio server --console-address :9001 /data
|
||||
|
||||
# Tool to wait for a service to be ready
|
||||
- name: Install Dockerize
|
||||
run: |
|
||||
curl -sSL https://github.com/jwilder/dockerize/releases/download/v0.8.0/dockerize-linux-amd64-v0.8.0.tar.gz | sudo tar -C /usr/local/bin -xzv
|
||||
|
||||
- name: Wait for MinIO to be ready
|
||||
run: |
|
||||
dockerize -wait tcp://localhost:9000 -timeout 10s
|
||||
|
||||
- name: Configure MinIO
|
||||
run: |
|
||||
MINIO=$(docker ps | grep minio/minio | sed -E 's/.*\s+([a-zA-Z0-9_-]+)$/\1/')
|
||||
docker exec ${MINIO} sh -c \
|
||||
"mc alias set meet http://localhost:9000 meet password && \
|
||||
mc alias ls && \
|
||||
mc mb meet/meet-media-storage"
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install development dependencies
|
||||
run: pip install --user .[dev]
|
||||
@@ -175,25 +216,56 @@ jobs:
|
||||
- name: Check format
|
||||
run: cd src/frontend/ && npm run check
|
||||
|
||||
lint-sdk:
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/sdk/library
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Check linting
|
||||
run: npm run lint
|
||||
|
||||
- name: Check format
|
||||
run: npm run check
|
||||
|
||||
build-sdk:
|
||||
runs-on: ubuntu-latest
|
||||
needs: lint-sdk
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/sdk/library
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build SDK
|
||||
run: npm run build
|
||||
|
||||
i18n-crowdin:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
-
|
||||
uses: actions/create-github-app-token@v1
|
||||
- uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: "infrastructure,secrets"
|
||||
-
|
||||
name: Checkout repository
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: recursive
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
-
|
||||
name: Load sops secrets
|
||||
- name: Load sops secrets
|
||||
uses: rouja/actions-sops@main
|
||||
with:
|
||||
secret-file: secrets/numerique-gouv/meet/secrets.enc.env
|
||||
@@ -207,7 +279,7 @@ jobs:
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install development dependencies
|
||||
working-directory: src/backend
|
||||
|
||||
@@ -2,30 +2,12 @@ name: Release Chart
|
||||
run-name: Release Chart
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
push:
|
||||
paths:
|
||||
- ./src/helm/meet/**
|
||||
- src/helm/meet/**
|
||||
|
||||
jobs:
|
||||
lint-helmfile:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: ghcr.io/helmfile/helmfile:latest
|
||||
steps:
|
||||
- uses: numerique-gouv/action-helmfile-lint@main
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
age-key: ${{ secrets.SOPS_PRIVATE }}
|
||||
private-key: ${{ secrets.PRIVATE_KEY }}
|
||||
helmfile-src: "src/helm"
|
||||
repositories: "meet,secrets"
|
||||
|
||||
release:
|
||||
needs: helmfile-lint
|
||||
if: github.event_name == 'push'
|
||||
permissions:
|
||||
contents: write
|
||||
runs-on: ubuntu-latest
|
||||
@@ -47,5 +29,5 @@ jobs:
|
||||
uses: numerique-gouv/helm-gh-pages@add-overwrite-option
|
||||
with:
|
||||
charts_dir: ./src/helm
|
||||
linting: off
|
||||
linting: on
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -79,3 +79,6 @@ db.sqlite3
|
||||
|
||||
# Egress output
|
||||
docker/livekit/out
|
||||
|
||||
# LiveKit CA configuration
|
||||
docker/livekit/rootCA.pem
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
[submodule "secrets"]
|
||||
path = secrets
|
||||
url = ../secrets
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# Contributor Covenant Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as
|
||||
contributors and maintainers pledge to making participation in our project and
|
||||
our community a harassment-free experience for everyone, regardless of age, body
|
||||
size, disability, ethnicity, sex characteristics, gender identity and expression,
|
||||
level of experience, education, socio-economic status, nationality, personal
|
||||
appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment
|
||||
include:
|
||||
|
||||
* Using welcoming and inclusive language
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
* The use of sexualized language or imagery and unwelcome sexual attention or
|
||||
advances
|
||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or electronic
|
||||
address, without explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable
|
||||
behavior and are expected to take appropriate and fair corrective action in
|
||||
response to any instances of unacceptable behavior.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or
|
||||
reject comments, commits, code, wiki edits, issues, and other contributions
|
||||
that are not aligned to this Code of Conduct, or to ban temporarily or
|
||||
permanently any contributor for other behaviors that they deem inappropriate,
|
||||
threatening, offensive, or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies both within project spaces and in public spaces
|
||||
when an individual is representing the project or its community. Examples of
|
||||
representing a project or community include using an official project e-mail
|
||||
address, posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event. Representation of a project may be
|
||||
further defined and clarified by project maintainers.
|
||||
|
||||
## Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported by contacting the project team at visio@numerique.gouv.fr. All
|
||||
complaints will be reviewed and investigated and will result in a response that
|
||||
is deemed necessary and appropriate to the circumstances. The project team is
|
||||
obligated to maintain confidentiality with regard to the reporter of an incident.
|
||||
Further details of specific enforcement policies may be posted separately.
|
||||
|
||||
Project maintainers who do not follow or enforce the Code of Conduct in good
|
||||
faith may face temporary or permanent repercussions as determined by other
|
||||
members of the project's leadership.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the [PostHog Code of Conduct](https://github.com/PostHog/posthog/blob/master/CODE_OF_CONDUCT.md), inspired from Contributor Covenant version 1.4,
|
||||
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
|
||||
|
||||
[homepage]: https://www.contributor-covenant.org
|
||||
|
||||
For answers to common questions about this code of conduct, see
|
||||
https://www.contributor-covenant.org/faq
|
||||
@@ -0,0 +1,77 @@
|
||||
# Contributing to the Project
|
||||
|
||||
Thank you for taking the time to contribute! Please follow these guidelines to ensure a smooth and productive workflow. 🚀🚀🚀
|
||||
|
||||
To get started with the project, please refer to the [README.md](https://github.com/suitenumerique/meet/blob/main/README.md) for detailed instructions.
|
||||
|
||||
Please also check out our [dev handbook](https://suitenumerique.gitbook.io/handbook) to learn our best practices.
|
||||
|
||||
|
||||
## Creating an Issue
|
||||
|
||||
When creating an issue, please provide the following details:
|
||||
|
||||
1. **Title**: A concise and descriptive title for the issue.
|
||||
2. **Description**: A detailed explanation of the issue, including relevant context or screenshots if applicable.
|
||||
3. **Steps to Reproduce**: If the issue is a bug, include the steps needed to reproduce the problem.
|
||||
4. **Expected vs. Actual Behavior**: Describe what you expected to happen and what actually happened.
|
||||
5. **Labels**: Add appropriate labels to categorize the issue (e.g., bug, feature request, documentation).
|
||||
|
||||
|
||||
## Commit Message Format
|
||||
|
||||
All commit messages must adhere to the following format:
|
||||
|
||||
`<gitmoji>(type) title description`
|
||||
|
||||
* <**gitmoji**>: Use a gitmoji to represent the purpose of the commit. For example, ✨ for adding a new feature or 🔥 for removing something, see the list here: <https://gitmoji.dev/>.
|
||||
* **(type)**: Describe the type of change. Common types include `backend`, `frontend`, `CI`, `docker` etc...
|
||||
* **title**: A short, descriptive title for the change, starting with a lowercase character.
|
||||
* **description**: Include additional details about what was changed and why.
|
||||
|
||||
### Example Commit Message
|
||||
|
||||
```
|
||||
✨(frontend) add user authentication logic
|
||||
|
||||
Implemented login and signup features, and integrated OAuth2 for social login.
|
||||
```
|
||||
|
||||
## Changelog Update
|
||||
|
||||
Please add a line to the changelog describing your development. The changelog entry should include a brief summary of the changes, this helps in tracking changes effectively and keeping everyone informed. We usually include the title of the pull request, followed by the pull request ID to finish the log entry. The changelog line should be less than 80 characters in total.
|
||||
|
||||
### Example Changelog Message
|
||||
```
|
||||
## [Unreleased]
|
||||
|
||||
## Added
|
||||
|
||||
- ✨(frontend) add AI to the project #321
|
||||
```
|
||||
|
||||
## Pull Requests
|
||||
|
||||
It is nice to add information about the purpose of the pull request to help reviewers understand the context and intent of the changes. If you can, add some pictures or a small video to show the changes.
|
||||
|
||||
### Don't forget to:
|
||||
- check your commits
|
||||
- check the linting: `make lint && make frontend-lint`
|
||||
- check the tests: `make test`
|
||||
- add a changelog entry
|
||||
|
||||
Once all the required tests have passed, you can request a review from the project maintainers.
|
||||
|
||||
## Code Style
|
||||
|
||||
Please maintain consistency in code style. Run any linting tools available to make sure the code is clean and follows the project's conventions.
|
||||
|
||||
## Tests
|
||||
|
||||
Make sure that all new features or fixes have corresponding tests. Run the test suite before pushing your changes to ensure that nothing is broken.
|
||||
|
||||
## Asking for Help
|
||||
|
||||
If you need any help while contributing, feel free to open a discussion or ask for guidance in the issue tracker. We are more than happy to assist!
|
||||
|
||||
Thank you for your contributions! 👍
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Direction Interministérielle du Numérique - Gouvernement Français
|
||||
Copyright (c) 2024-2025 DINUM/Etalab
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
@@ -0,0 +1,79 @@
|
||||
# LICENCE OUVERTE 2.0/OPEN LICENCE 2.0
|
||||
|
||||
## Réutilisation de l’« Information » sous cette licence
|
||||
|
||||
Le « Concédant » concède au « Réutilisateur » un droit non exclusif et gratuit de libre « Réutilisation » de l’« Information » objet de la présente licence, à des fins commerciales ou non, dans le monde entier et pour une durée illimitée, dans les conditions exprimées ci-dessous.
|
||||
|
||||
**Le « Réutilisateur » est libre de réutiliser l’« Information » :**
|
||||
|
||||
- de la communiquer, la reproduire, la copier ;
|
||||
- de l’adapter, la modifier, l’extraire et la transformer, notamment pour créer des « Informations dérivées » ;
|
||||
- de la diffuser, la redistribuer, la publier et la transmettre, de l’exploiter à titre commercial, par exemple en la combinant avec d’autres informations, ou en l’incluant dans votre propre produit ou application.
|
||||
|
||||
**Sous réserve de :**
|
||||
|
||||
- mentionner la paternité de l’«Information» : sa source (a minima le nom du « Concédant ») et la date de la dernière mise à jour de l’« Information » réutilisée.
|
||||
|
||||
Le « Réutilisateur » peut notamment s’ acquitter de cette condition en indiquant l’adresse (URL) renvoyant vers « l’Information » et assurant une mention effective de sa paternité.
|
||||
|
||||
**Par exemple :**
|
||||
|
||||
Dans le cas d’une réutilisation de la base SIRENE de l’INSEE, mentionner l’URL du « Concédant » : www.insee.fr + la date de dernière mise à jour de l’Information réutilisée.
|
||||
|
||||
Cette mention de paternité ne doit ni conférer un caractère officiel à la « Réutilisation » de l’« Information », ni suggérer une quelconque reconnaissance ou caution par le « Concédant », ou par toute autre entité publique, du « Réutilisateur » ou de sa « Réutilisation ».
|
||||
|
||||
## Données à caractère personnel
|
||||
|
||||
L’« Information » mise à disposition peut contenir des « Données à caractère personnel » pouvant faire l’objet d’une « Réutilisation ». Alors, le « Concédant » informe le « Réutilisateur » (par tous moyens) de leur présence, l’ « Information » peut être librement réutilisée, sans faire obstacle aux libertés accordées par la présente licence, à condition de respecter le cadre légal relatif à la protection des données à caractère personnel.
|
||||
|
||||
## Droits de propriété intellectuelle
|
||||
|
||||
Il est garanti au « Réutilisateur » que l’ « Information » ne contient pas de « Droits de propriété intellectuelle » appartenant à des tiers qui pourraient faire obstacle aux libertés qui lui sont accordées par la présente licence.
|
||||
|
||||
Les éventuels « Droits de propriété intellectuelle » détenus par le « Concédant » sur l’ « Information » ne font pas obstacle aux libertés qui sont accordées par la présente licence. Lorsque le « Concédant » détient des « Droits de propriété intellectuelle » » sur l’ « Information », il les cède au « Réutilisateur » de façon non exclusive, à titre gracieux, pour le monde entier, pour toute la durée des « Droits de propriété intellectuelle », et le « Réutilisateur » peut en faire tout usage conformément aux libertés et aux conditions définies par la présente licence.
|
||||
|
||||
## Responsabilité
|
||||
|
||||
L’ «Information» est mise à disposition telle que produite ou reçue, sans autre garantie expresse ou tacite qui n’est pas prévue par la présente licence. L’absence de défauts ou d’erreurs éventuellement contenues dans l’ «Information», comme la fourniture continue de l’ « Information » n’est pas garantie par le «Concédant». Il ne peut être tenu pour responsable de toute perte, préjudice ou dommage de quelque sorte causé à des tiers du fait de la « Réutilisation ».
|
||||
|
||||
Le « Réutilisateur » est seul responsable de la « Réutilisation » de l’« Information ».
|
||||
|
||||
La « Réutilisation » ne doit pas induire en erreur des tiers quant au contenu de l’« Information », sa source et sa date de mise à jour.
|
||||
|
||||
## Droit applicable
|
||||
|
||||
La présente licence est régie par le droit français.
|
||||
|
||||
### Compatibilité de la présente licence
|
||||
|
||||
Elle a été conçue pour être compatible avec toute licence libre qui exige _a minima_ la mention de paternité. Elle est notamment compatible avec la version antérieure de la présente licence ainsi qu’avec les licences « Open Government Licence » (OGL) du Royaume-Uni, « Creative Commons Attribution » (CC-BY) de Creative Commons et « Open Data Commons Attribution » (ODC-BY) de l’Open Knowledge Foundation.
|
||||
|
||||
## Définitions
|
||||
|
||||
Sont considérés, au sens de la présente licence comme :
|
||||
|
||||
- Le « **Concédant** » : toute personne concédant un droit de « Réutilisation » sur l’« Information » dans les libertés et les conditions prévues par la présente licence.
|
||||
- L’« **Information** » :
|
||||
- toute information publique figurant dans des documents communiqués ou publiés par une administration mentionnée au premier alinéa de l’article L.300-2 du CRPA ;
|
||||
- toute information mise à disposition par toute personne selon les termes et conditions de la présente licence.
|
||||
- La « **Réutilisation** » : l’utilisation de l’« Information » à d’autres fins que celles pour lesquelles elle a été produite ou reçue.
|
||||
- Le « **Réutilisateur** » : toute personne qui réutilise les « Informations » conformément aux conditions de la présente licence.
|
||||
- Des « **Données à caractère personnel** » : toute information se rapportant à une personne physique identifiée ou identifiable, pouvant être identifiée directement ou indirectement. Leur « Réutilisation » est subordonnée au respect du cadre juridique en vigueur.
|
||||
- Une « **Information dérivée** » : toute nouvelle donnée ou information créées directement à partir de l’« Information » ou à partir d’une combinaison de l’ « Information » et d’autres données ou informations non soumises à cette licence.
|
||||
- Les « **Droits de propriété intellectuelle** » : tous droits identifiés comme tels par le Code de la propriété intellectuelle (droit d’auteur, droits voisins au droit d’auteur, droit sui generis des producteurs de bases de données).
|
||||
|
||||
## À propos de cette licence
|
||||
|
||||
La présente licence a vocation à être utilisée par les administrations pour la réutilisation de leurs informations publiques. Elle peut également être utilisée par toute personne souhaitant mettre à disposition de l’« Information » dans les conditions définies par la présente licence
|
||||
|
||||
La France est dotée d’un cadre juridique global visant à une diffusion spontanée par les administrations de leurs informations publiques afin d’en permettre la plus large réutilisation.
|
||||
|
||||
Le droit de la « Réutilisation » de l’« Information » des administrations est régi par le code des relations entre le public et l’administration (CRPA) et, le cas échéant, le code du patrimoine (livre II relatif aux archives).
|
||||
|
||||
Cette licence facilite la réutilisation libre et gratuite des informations publiques et figure parmi les licences qui peuvent être utilisées par l’administration en vertu du décret pris en application de l’article L.323-2 du CRPA.
|
||||
|
||||
Etalab est la mission chargée, sous l’autorité du Premier ministre, d’ouvrir le plus grand nombre de données publiques des administrations de l’État et de ses établissements publics. Elle a réalisé la Licence Ouverte pour faciliter la réutilisation libre et gratuite de ces informations publiques, telles que définies par l’article L321-1 du CRPA.
|
||||
|
||||
Cette licence est une version 2.0 de la Licence Ouverte.
|
||||
|
||||
Etalab se réserve la faculté de proposer de nouvelles versions de la Licence Ouverte. Cependant, les « Réutilisateurs » pourront continuer à réutiliser les informations disponibles sous cette licence s’ils le souhaitent.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024-2025 DINUM/Etalab
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -88,10 +88,20 @@ bootstrap: \
|
||||
.PHONY: bootstrap
|
||||
|
||||
# -- Docker/compose
|
||||
build: ## build the app-dev container
|
||||
@$(COMPOSE) build app-dev --no-cache
|
||||
build: ## build the project containers
|
||||
@$(MAKE) build-backend
|
||||
@$(MAKE) build-frontend
|
||||
.PHONY: build
|
||||
|
||||
build-backend: ## build the app-dev container
|
||||
@$(COMPOSE) build app-dev
|
||||
.PHONY: build-backend
|
||||
|
||||
|
||||
build-frontend: ## build the frontend container
|
||||
@$(COMPOSE) build frontend
|
||||
.PHONY: build-frontend
|
||||
|
||||
down: ## stop and remove containers, networks, images, and volumes
|
||||
@$(COMPOSE) down
|
||||
.PHONY: down
|
||||
@@ -100,10 +110,16 @@ logs: ## display app-dev logs (follow mode)
|
||||
@$(COMPOSE) logs -f app-dev
|
||||
.PHONY: logs
|
||||
|
||||
run: ## start the wsgi (production) and development server
|
||||
run-backend: ## start only the backend application and all needed services
|
||||
@$(COMPOSE) up --force-recreate -d celery-dev
|
||||
@echo "Wait for postgresql to be up..."
|
||||
@$(WAIT_DB)
|
||||
.PHONY: run-backend
|
||||
|
||||
run:
|
||||
run: ## start the wsgi (production) and development server
|
||||
@$(MAKE) run-backend
|
||||
@$(COMPOSE) up --force-recreate -d frontend
|
||||
.PHONY: run
|
||||
|
||||
status: ## an alias for "docker compose ps"
|
||||
@@ -114,6 +130,25 @@ stop: ## stop the development server using Docker
|
||||
@$(COMPOSE) stop
|
||||
.PHONY: stop
|
||||
|
||||
# -- Front
|
||||
|
||||
frontend-development-install: ## install the frontend locally
|
||||
cd $(PATH_FRONT) && npm i
|
||||
.PHONY: frontend-development-install
|
||||
|
||||
frontend-lint: ## run the frontend linter
|
||||
cd $(PATH_FRONT) && npm run lint
|
||||
.PHONY: frontend-lint
|
||||
|
||||
frontend-format: ## run the frontend format
|
||||
cd $(PATH_FRONT) && npm run format
|
||||
.PHONY: frontend-format
|
||||
|
||||
run-frontend-development: ## run the frontend in development mode
|
||||
@$(COMPOSE) stop frontend
|
||||
cd $(PATH_FRONT) && npm run dev
|
||||
.PHONY: run-frontend-development
|
||||
|
||||
# -- Backend
|
||||
|
||||
demo: ## flush db then create a demo for load testing purpose
|
||||
@@ -301,34 +336,8 @@ 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
|
||||
install-external-secrets: ## install the kubernetes secrets from Vaultwarden
|
||||
./bin/install-external-secrets.sh
|
||||
.PHONY: build-k8s-cluster
|
||||
|
||||
start-tilt: ## start the kubernetes cluster using kind
|
||||
|
||||
@@ -1,157 +1,119 @@
|
||||
# Meet
|
||||
|
||||
Meet is a simple video and phone conferencing tool, powered by [LiveKit](https://livekit.io/).
|
||||
|
||||
Meet is built on top of [Django Rest
|
||||
Framework](https://www.django-rest-framework.org/) and [Vite.js](https://vitejs.dev/).
|
||||
|
||||
## Getting started
|
||||
|
||||
### Prerequisite
|
||||
|
||||
#### Docker
|
||||
|
||||
Make sure you have a recent version of Docker and [Docker
|
||||
Compose](https://docs.docker.com/compose/install) installed on your laptop:
|
||||
|
||||
```bash
|
||||
$ docker -v
|
||||
Docker version 20.10.2, build 2291f61
|
||||
|
||||
$ docker compose -v
|
||||
docker compose version 1.27.4, build 40524192
|
||||
```
|
||||
|
||||
> ⚠️ You may need to run the following commands with `sudo` but this can be
|
||||
> avoided by assigning your user to the `docker` group.
|
||||
|
||||
#### LiveKit CLI
|
||||
|
||||
Install LiveKit CLI, which provides utilities for interacting with the LiveKit ecosystem (including the server, egress, and more), please follow the instructions available in the [official repository](https://github.com/livekit/livekit-cli).
|
||||
|
||||
### Project bootstrap
|
||||
|
||||
The easiest way to start working on the project is to use GNU Make:
|
||||
|
||||
```bash
|
||||
$ make bootstrap FLUSH_ARGS='--no-input'
|
||||
```
|
||||
|
||||
Then you can access to the project in development mode by going to http://localhost:3000.
|
||||
You will be prompted to log in, the default credentials are:
|
||||
```bash
|
||||
username: meet
|
||||
password: meet
|
||||
```
|
||||
---
|
||||
|
||||
This command builds the `app` container, installs dependencies, performs
|
||||
database migrations and compile translations. It's a good idea to use this
|
||||
command each time you are pulling code from the project repository to avoid
|
||||
dependency-related or migration-related issues.
|
||||
|
||||
Your Docker services should now be up and running 🎉
|
||||
|
||||
[FIXME] Explain how to run the frontend project.
|
||||
|
||||
### Configure LiveKit CLI
|
||||
|
||||
For the optimal DX, create a default project named `meet` to use with `livekit-cli` commands:
|
||||
```bash
|
||||
$ livekit-cli project add
|
||||
URL: http://localhost:7880
|
||||
API Key: devkey
|
||||
API Secret: secret
|
||||
Give it a name for later reference: meet
|
||||
? Make this project default?? [y/N] y
|
||||
```
|
||||
|
||||
Thus, you won't need to pass the project API Key and API Secret for each command.
|
||||
<p align="center">
|
||||
<img alt="meet logo" src="./docs/assets/banner-meet-fr.png" maxWidth="100%">
|
||||
</p>
|
||||
|
||||
|
||||
### Adding content
|
||||
<p align="center">
|
||||
<a href="https://github.com/suitenumerique/meet/stargazers/">
|
||||
<img src="https://img.shields.io/github/stars/suitenumerique/meet" alt="">
|
||||
</a>
|
||||
<a href='http://makeapullrequest.com'><img alt='PRs Welcome' src='https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=shields'/></a>
|
||||
<img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/suitenumerique/meet"/>
|
||||
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/suitenumerique/meet"/>
|
||||
<a href="https://github.com/suitenumerique/meet/blob/main/LICENSE">
|
||||
<img alt="GitHub closed issues" src="https://img.shields.io/github/license/suitenumerique/meet"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
You can create a basic demo site by running:
|
||||
<p align="center">
|
||||
<a href="https://livekit.io/">LiveKit</a> - <a href="https://go.crisp.chat/chat/embed/?website_id=58ea6697-8eba-4492-bc59-ad6562585041">Chat with us</a> - <a href="https://github.com/orgs/suitenumerique/projects/3/views/2">Roadmap</a> - <a href="https://github.com/suitenumerique/meet/blob/main/CHANGELOG.md">Changelog</a> - <a href="https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md">Bug reports</a>
|
||||
</p>
|
||||
|
||||
```bash
|
||||
$ make demo
|
||||
```
|
||||
<p align="center">
|
||||
<a href="https://visio.numerique.gouv.fr/">
|
||||
<img src="https://github.com/user-attachments/assets/09c1faa1-de88-4848-af3a-6fbe793999bf" alt="La Suite Meet Demonstration">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
Finally, you can check all available Make rules using:
|
||||
## La Suite Meet: Simple Video Conferencing
|
||||
|
||||
```bash
|
||||
$ make help
|
||||
```
|
||||
|
||||
### Django admin
|
||||
|
||||
You can access the Django admin site at
|
||||
[http://localhost:8071/admin](http://localhost:8071/admin).
|
||||
|
||||
You first need to create a superuser account:
|
||||
|
||||
```bash
|
||||
$ 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`.
|
||||
Powered by [LiveKit](https://livekit.io/), La Suite Meet offers Zoom-level performance with high-quality video and audio. No installation required—simply join calls directly from your browser. Check out LiveKit's impressive optimizations in their [blog post](https://blog.livekit.io/livekit-one-dot-zero/).
|
||||
### Features
|
||||
- Optimized for stability in large meetings (+100 p.)
|
||||
- Support for multiple screen sharing streams
|
||||
- Non-persistent, secure chat
|
||||
- End-to-end encryption (coming soon)
|
||||
- Meeting recording (coming soon)
|
||||
- Meeting transcription (currently in beta)
|
||||
- Telephony integration (in development)
|
||||
- Secure participation with robust authentication and access control
|
||||
- LiveKit Advances features including :
|
||||
- speaker detection
|
||||
- simulcast
|
||||
- end-to-end optimizations
|
||||
- selective subscription
|
||||
- SVC codecs (VP9, AV1)
|
||||
|
||||
|
||||
#### Getting Started
|
||||
La Suite Meet is fully self-hostable and released under the MIT License, ensuring complete control and flexibility. It's simple to [get started](https://visio.numerique.gouv.fr/) or [request a demo](mailto:visio@numerique.gouv.fr).
|
||||
|
||||
Make sure you have installed:
|
||||
- kubectl
|
||||
- helm
|
||||
- helmfile
|
||||
- tilt
|
||||
We’re continuously adding new features to enhance your experience, with the latest updates coming soon!
|
||||
|
||||
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.
|
||||
## Table of Contents
|
||||
|
||||
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/`.
|
||||
- [Get started](#get-started)
|
||||
- [Docs](#docs)
|
||||
- [Contributing](#contributing)
|
||||
- [Philosophy](#philosophy)
|
||||
- [Open source](#open-source)
|
||||
|
||||
#### 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`:
|
||||
## Get started
|
||||
|
||||
```yaml
|
||||
...
|
||||
### La Suite Meet Cloud (Recommended)
|
||||
Sign up for La Suite Meet Cloud, designed for french public servants. Hosted on SecNumCloud-compliant providers and accessible via government SSO, [ProConnect](https://www.proconnect.gouv.fr/). The easiest way to try our product. Reach out if your entity isn't connected yet to our sso.
|
||||
|
||||
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'),
|
||||
]
|
||||
)
|
||||
...
|
||||
```
|
||||
### Open-source deployment (Advanced)
|
||||
|
||||
Deploy La Suite Meet on your own infrastructure using [our self-hosting guide](https://github.com/suitenumerique/meet/blob/main/docs/installation.md). Our open-source deployment is optimized for Kubernetes, and we're working on supporting additional deployment options. Keycloak integration and any SSO are supported. We offer customer support for open-source setups—just reach out for assistance.
|
||||
|
||||
## Docs
|
||||
|
||||
We're currently working on both technical and user documentation for La Suite Meet. In the meantime, many of the essential aspects are already well covered by the [LiveKit documentation](https://docs.livekit.io/home/) and their [self-hosting guide](https://docs.livekit.io/home/self-hosting/deployment/). Stay tuned for more updates!
|
||||
|
||||
## Contributing
|
||||
|
||||
This project is intended to be community-driven, so please, do not hesitate to
|
||||
get in touch if you have any question related to our implementation or design
|
||||
decisions.
|
||||
We <3 contributions of any kind, big and small:
|
||||
|
||||
- Vote on features or get early access to beta functionality in our [roadmap](https://github.com/orgs/suitenumerique/projects/3/views/2)
|
||||
- Open a PR (see our instructions on [developing La Suite Meet locally](https://github.com/suitenumerique/meet/blob/main/docs/developping_locally.md))
|
||||
- Submit a [feature request](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=enhancement&template=Feature_request.md) or [bug report](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md)
|
||||
|
||||
|
||||
## Philosophy
|
||||
|
||||
We’re relentlessly focused on building the best open-source video conferencing product—La Suite Meet. Growth comes from creating something people truly need, not just from chasing metrics.
|
||||
|
||||
Our users come first. We’re committed to making La Suite Meet as accessible and easy to use as proprietary solutions, ensuring it meets the highest standards.
|
||||
|
||||
Most of the heavy engineering is handled by the incredible LiveKit team, allowing us to focus on delivering a top-tier product. We follow extreme programming practices, favoring pair programming and quick, iterative releases. Challenge our tech and architecture—simplicity is always our top priority.
|
||||
|
||||
|
||||
## Open-source
|
||||
|
||||
Gov 🇫🇷 supports open source! This project is available under [MIT license](https://github.com/suitenumerique/meet/blob/0cc2a7b7b4f4821e2c4d9d790efa739622bb6601/LICENSE).
|
||||
|
||||
All features we develop will always remain open-source, and we are committed to contributing back to the LiveKit community whenever feasible.
|
||||
To learn more, don't hesitate to [reach out](mailto:visio@numerique.gouv.fr).
|
||||
|
||||
### Help us!
|
||||
|
||||
Come help us make La Suite Meet even better. We're growing fast and [would love some help](mailto:visio@numerique.gouv.fr).
|
||||
|
||||
|
||||
## Contributors 🧞
|
||||
|
||||
<a href="https://github.com/suitenumerique/meet/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=suitenumerique/meet" />
|
||||
</a>
|
||||
|
||||
## Credits
|
||||
|
||||
We're using the awesome [LiveKit](https://livekit.io/) implementation. We're also thankful to the teams behind [Django Rest Framework](https://www.django-rest-framework.org/), [Vite.js](https://vite.dev/), and [React Aria](https://github.com/adobe/react-spectrum) — Thanks for your amazing work!
|
||||
|
||||
## License
|
||||
|
||||
This work is released under the MIT License (see [LICENSE](./LICENSE)).
|
||||
Code in this repository is published under the MIT license by DINUM (Direction interministériel du numérique).
|
||||
Documentation (in the docs/) directory is released under the [Etalab-2.0 license](https://spdx.org/licenses/etalab-2.0.html).
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Security is very important to us.
|
||||
|
||||
If you have any issue regarding security, please disclose the information responsibly submiting [this form](https://vdp.numerique.gouv.fr/p/Send-a-report?lang=en) and not by creating an issue on the repository. You can also email us at visio@numerique.gouv.fr
|
||||
|
||||
We appreciate your effort to make Visio more secure.
|
||||
|
||||
## Vulnerability disclosure policy
|
||||
|
||||
Working with security issues in an open source project can be challenging, as we are required to disclose potential problems that could be exploited by attackers. With this in mind, our security fix policy is as follows:
|
||||
|
||||
1. The Maintainers team will handle the fix as usual (Pull Request,
|
||||
release).
|
||||
2. In the release notes, we will include the identification numbers from the
|
||||
GitHub Advisory Database (GHSA) and, if applicable, the Common Vulnerabilities
|
||||
and Exposures (CVE) identifier for the vulnerability.
|
||||
3. Once this grace period has passed, we will publish the vulnerability.
|
||||
|
||||
By adhering to this security policy, we aim to address security concerns
|
||||
effectively and responsibly in our open source software project.
|
||||
@@ -1,6 +1,10 @@
|
||||
load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
|
||||
load('ext://namespace', 'namespace_create', 'namespace_inject')
|
||||
namespace_create('meet')
|
||||
|
||||
def clean_old_images(image_name):
|
||||
local('docker images -q %s | tail -n +2 | xargs -r docker rmi' % image_name)
|
||||
|
||||
docker_build(
|
||||
'localhost:5001/meet-backend:latest',
|
||||
context='..',
|
||||
@@ -15,6 +19,7 @@ docker_build(
|
||||
)
|
||||
]
|
||||
)
|
||||
clean_old_images('localhost:5001/meet-backend')
|
||||
|
||||
docker_build(
|
||||
'localhost:5001/meet-frontend:latest',
|
||||
@@ -26,6 +31,7 @@ docker_build(
|
||||
sync('../src/frontend', '/home/frontend'),
|
||||
]
|
||||
)
|
||||
clean_old_images('localhost:5001/meet-frontend')
|
||||
|
||||
docker_build(
|
||||
'localhost:5001/meet-summary:latest',
|
||||
@@ -37,6 +43,24 @@ docker_build(
|
||||
sync('../src/summary', '/home/summary'),
|
||||
]
|
||||
)
|
||||
clean_old_images('localhost:5001/meet-summary')
|
||||
|
||||
# Copy the mkcert root CA certificate to our Docker build context
|
||||
# This is necessary because we need to inject the certificate into our LiveKit container
|
||||
local_resource(
|
||||
'copy-root-ca',
|
||||
cmd='cp "$(mkcert -CAROOT)/rootCA.pem" ../docker/livekit/rootCA.pem',
|
||||
deps=[], # No dependencies needed
|
||||
)
|
||||
# Build a custom LiveKit Docker image that includes our root CA certificate
|
||||
# This allows LiveKit to trust our local development certificates
|
||||
docker_build(
|
||||
'localhost:5001/meet-livekit:latest',
|
||||
context='../docker/livekit',
|
||||
dockerfile='./../docker/livekit/Dockerfile',
|
||||
only=['.'],
|
||||
)
|
||||
clean_old_images('localhost:5001/meet-livekit')
|
||||
|
||||
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .'))
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env bash
|
||||
set -o errexit
|
||||
|
||||
CURRENT_DIR=$(pwd)
|
||||
NAMESPACE=${1:-meet}
|
||||
SECRET_NAME=${2:-bitwarden-cli-meet}
|
||||
TEMP_SECRET_FILE=$(mktemp)
|
||||
|
||||
|
||||
cleanup() {
|
||||
rm -f "${TEMP_SECRET_FILE}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
|
||||
# Check if kubectl is available
|
||||
check_prerequisites() {
|
||||
if ! command -v kubectl &> /dev/null; then
|
||||
echo "Error: kubectl is not installed or not in PATH"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Check if secret already exists
|
||||
check_secret_exists() {
|
||||
kubectl -n "${NAMESPACE}" get secrets "${SECRET_NAME}" &> /dev/null
|
||||
}
|
||||
|
||||
|
||||
# Collect user input securely
|
||||
get_user_input() {
|
||||
echo "Please provide the following information:"
|
||||
read -p "Enter your Vaultwarden email login: " LOGIN
|
||||
read -s -p "Enter your Vaultwarden password: " PASSWORD
|
||||
echo
|
||||
read -p "Enter your Vaultwarden server url: " URL
|
||||
}
|
||||
|
||||
# Create and apply the secret
|
||||
create_secret() {
|
||||
cat > "${TEMP_SECRET_FILE}" << EOF
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: ${SECRET_NAME}
|
||||
namespace: ${NAMESPACE}
|
||||
type: Opaque
|
||||
stringData:
|
||||
BW_HOST: ${URL}
|
||||
BW_PASSWORD: ${PASSWORD}
|
||||
BW_USERNAME: ${LOGIN}
|
||||
EOF
|
||||
|
||||
kubectl -n "${NAMESPACE}" apply -f "${TEMP_SECRET_FILE}"
|
||||
}
|
||||
|
||||
# Install external-secrets using Helm
|
||||
install_external_secrets() {
|
||||
if ! kubectl get ns external-secrets &>/dev/null; then
|
||||
echo "Installing external-secrets…"
|
||||
helm repo add external-secrets https://charts.external-secrets.io
|
||||
helm upgrade --install external-secrets \
|
||||
external-secrets/external-secrets \
|
||||
-n external-secrets \
|
||||
--create-namespace \
|
||||
--set installCRDs=true
|
||||
else
|
||||
echo "External secrets already deployed"
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
check_prerequisites
|
||||
|
||||
if check_secret_exists; then
|
||||
echo "Secret '${SECRET_NAME}' already present in namespace '${NAMESPACE}'"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo -e ${TEMP_SECRET_FILE}
|
||||
|
||||
get_user_input
|
||||
echo -e "\nCreating Vaultwarden secret…"
|
||||
create_secret
|
||||
install_external_secrets
|
||||
|
||||
echo "Secret installation completed successfully"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
mkdir -p "$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/"
|
||||
PRE_COMMIT_FILE="$(dirname -- "${BASH_SOURCE[0]}")/../.git/hooks/pre-commit"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
curl https://raw.githubusercontent.com/numerique-gouv/tools/refs/heads/main/kind/create_cluster.sh | bash -s -- meet
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
git submodule update --init --recursive
|
||||
git submodule foreach 'git fetch origin; git checkout $(git rev-parse --abbrev-ref HEAD); git reset --hard origin/$(git rev-parse --abbrev-ref HEAD); git submodule update --recursive; git clean -dfx'
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
find . -name "*.enc.*" -exec sops updatekeys -y {} \;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
#!/bin/bash
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
|
||||
@@ -15,6 +15,37 @@ services:
|
||||
ports:
|
||||
- "1081:1080"
|
||||
|
||||
minio:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: minio/minio
|
||||
environment:
|
||||
- MINIO_ROOT_USER=meet
|
||||
- MINIO_ROOT_PASSWORD=password
|
||||
ports:
|
||||
- '9000:9000'
|
||||
- '9001:9001'
|
||||
healthcheck:
|
||||
test: [ "CMD", "mc", "ready", "local" ]
|
||||
interval: 1s
|
||||
timeout: 20s
|
||||
retries: 300
|
||||
entrypoint: ""
|
||||
command: minio server --console-address :9001 /data
|
||||
volumes:
|
||||
- ./data/media:/data
|
||||
|
||||
createbuckets:
|
||||
image: minio/mc
|
||||
depends_on:
|
||||
minio:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
entrypoint: >
|
||||
sh -c "
|
||||
/usr/bin/mc alias set meet http://minio:9000 meet password && \
|
||||
/usr/bin/mc mb meet/meet-media-storage && \
|
||||
exit 0;"
|
||||
|
||||
app-dev:
|
||||
build:
|
||||
context: .
|
||||
@@ -40,6 +71,7 @@ services:
|
||||
- redis
|
||||
- nginx
|
||||
- livekit
|
||||
- createbuckets
|
||||
|
||||
celery-dev:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
@@ -73,6 +105,7 @@ services:
|
||||
- postgresql
|
||||
- redis
|
||||
- livekit
|
||||
- minio
|
||||
|
||||
celery:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
@@ -95,8 +128,21 @@ services:
|
||||
depends_on:
|
||||
- keycloak
|
||||
|
||||
frontend:
|
||||
user: "${DOCKER_USER:-1000}"
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./src/frontend/Dockerfile
|
||||
target: frontend-production
|
||||
args:
|
||||
VITE_API_BASE_URL: "http://localhost:8071"
|
||||
image: meet:frontend-development
|
||||
ports:
|
||||
- "3000:8080"
|
||||
|
||||
dockerize:
|
||||
image: jwilder/dockerize
|
||||
platform: linux/x86_64
|
||||
|
||||
crowdin:
|
||||
image: crowdin/cli:4.0.0
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
FROM livekit/livekit-server:v1.8.0
|
||||
|
||||
# We inject the nip.io certificate manually because the livekit chart doesn't support volume mounting
|
||||
COPY rootCA.pem /etc/ssl/certs/
|
||||
|
||||
ENTRYPOINT ["/livekit-server"]
|
||||
|
After Width: | Height: | Size: 216 KiB |
@@ -0,0 +1,145 @@
|
||||
# Getting Started
|
||||
|
||||
Before setting up, let's review Visio's architecture.
|
||||
|
||||
Visio consists of four main components that run simultaneously:
|
||||
|
||||
- React frontend, built with Vite.js
|
||||
- Django server
|
||||
- LiveKit server
|
||||
- FastAPI server (optional, required for AI beta features)
|
||||
|
||||
These components rely on a few key services:
|
||||
|
||||
- PostgreSQL for storing data (users, rooms, recordings)
|
||||
- Redis for caching and inter-service communication
|
||||
- MinIO for storing files (room recordings)
|
||||
- Celery workers for meeting transcript (optional, required for AI beta features)
|
||||
|
||||
We provide two stack options for getting Visio up and running for development:
|
||||
|
||||
- Docker Compose stack (recommended for most users)
|
||||
- Kubernetes stack powered by Tilt (Advanced)
|
||||
|
||||
We recommend starting with the **Docker Compose** option for simplicity. However, if you're comfortable with running Kubernetes locally, the advanced option mirrors the production environment and provides most of the tools required for development (e.g., hot reloading).
|
||||
|
||||
These instructions are for macOS or Ubuntu. For other distros, adjust as needed.
|
||||
|
||||
If any steps are outdated, please let us know!
|
||||
|
||||
---
|
||||
|
||||
We also provide **GNU make utilities**. To view all available Make rules, run:
|
||||
```shellscript
|
||||
$ make help
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Need Help?
|
||||
If you need any assistance or have questions while getting started, feel free to reach out to @lebaudantoine anytime! Antoine is available to help you onboard and guide you through the process. Chat with him @antoine.lebaud:matrix.org, or from the [support hotline](https://go.crisp.chat/chat/embed/?website_id=58ea6697-8eba-4492-bc59-ad6562585041).
|
||||
|
||||
---
|
||||
|
||||
## Option 1: Developing with Docker
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. Ensure you have a recent version of **Docker** and **Docker Compose** installed:
|
||||
```shellscript
|
||||
$ docker -v
|
||||
Docker version 20.10.2, build 2291f61
|
||||
|
||||
$ docker compose version
|
||||
Docker Compose version v2.32.4
|
||||
```
|
||||
|
||||
2. Install **LiveKit CLI** by following the instructions available in the [official repository](https://github.com/livekit/livekit-cli). After installation, verify that it's working:
|
||||
```shellscript
|
||||
$ lk --version
|
||||
lk version 2.3.1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Project Bootstrap
|
||||
|
||||
1. Bootstrap the project using the **Make** command. This will build the `app` container, install dependencies, run database migrations, and compile translations:
|
||||
```shellscript
|
||||
$ make bootstrap FLUSH_ARGS='--no-input'
|
||||
```
|
||||
|
||||
2. Access the project:
|
||||
- The frontend is available at [http://localhost:3000](http://localhost:3000) with the default credentials:
|
||||
- username: meet
|
||||
- password: meet
|
||||
- The Django backend is available at [http://localhost:8071](http://localhost:8071)
|
||||
|
||||
---
|
||||
|
||||
## Developing
|
||||
|
||||
- To **stop** the application:
|
||||
```shellscript
|
||||
$ make stop
|
||||
```
|
||||
|
||||
- To **restart** the application:
|
||||
```shellscript
|
||||
$ make run
|
||||
```
|
||||
|
||||
- For **frontend development**, start all backend services without the frontend container:
|
||||
```shellscript
|
||||
$ make run-backend
|
||||
```
|
||||
|
||||
Then:
|
||||
```shellscript
|
||||
$ make frontend-development-install
|
||||
$ make run-frontend-development
|
||||
```
|
||||
|
||||
Which is equivalent to these direct npm commands:
|
||||
```shellscript
|
||||
$ cd src/frontend
|
||||
$ npm i
|
||||
$ npm run dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Adding Content
|
||||
|
||||
You can bootstrap demo data with a single command:
|
||||
```shellscript
|
||||
$ make demo
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Option 2: Developing with Kubernetes
|
||||
|
||||
Visio is deployed across staging, preprod, and production environments using **Kubernetes (K8s)**. Reproducing the environment locally is crucial for developing new features or debugging.
|
||||
|
||||
This is facilitated by [Tilt](https://tilt.dev/), which provides Kubernetes-like development for local environments, enabling smart rebuilds and live updates.
|
||||
|
||||
### Getting Started
|
||||
|
||||
Make sure you have the following installed:
|
||||
- kubectl
|
||||
- helm
|
||||
- helmfile
|
||||
- tilt
|
||||
|
||||
To build and start the Kubernetes cluster using **Kind**:
|
||||
```shellscript
|
||||
$ make build-k8s-cluster
|
||||
```
|
||||
|
||||
Once the Kubernetes cluster is ready, start the application stack locally:
|
||||
```shellscript
|
||||
$ make start-tilt-keycloak
|
||||
```
|
||||
|
||||
Monitor Tilt’s progress at [http://localhost:10350/](http://localhost:10350/). After Tilt actions finish, you can access the app at [https://visio.127.0.0.1.nip.io/](https://visio.127.0.0.1.nip.io/).
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
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
|
||||
@@ -43,11 +42,11 @@ It will expire on 23 March 2027 🗓
|
||||
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 💾
|
||||
✓ Preparing nodes 📦
|
||||
✓ Writing configuration 📜
|
||||
✓ Starting control-plane 🕹️
|
||||
✓ Installing CNI 🔌
|
||||
✓ Installing StorageClass 💾
|
||||
Set kubectl context to "kind-visio"
|
||||
You can now use your cluster with:
|
||||
|
||||
@@ -95,13 +94,14 @@ 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.
|
||||
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 ?
|
||||
### 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).
|
||||
|
||||
@@ -153,6 +153,7 @@ 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.
|
||||
|
||||
```
|
||||
@@ -194,6 +195,7 @@ 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 :
|
||||
|
||||
```
|
||||
@@ -212,7 +214,7 @@ POSTGRES_PASSWORD: pass
|
||||
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 add meet https://suitenumerique.github.io/meet/
|
||||
$ helm repo update
|
||||
$ helm install meet meet/meet -f examples/meet.values.yaml
|
||||
```
|
||||
@@ -231,3 +233,98 @@ meet-admin <none> meet.127.0.0.1.nip.io localhost 80, 44
|
||||
```
|
||||
|
||||
You can use Visio on https://meet.127.0.0.1.nip.io. The provisioning user in keycloak is meet/meet.
|
||||
|
||||
## All options
|
||||
|
||||
These are the environmental options available on meet backend.
|
||||
|
||||
| Option | Description | default |
|
||||
| ----------------------------------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| DJANGO_ALLOWED_HOSTS | Host that are allowed | [] |
|
||||
| DJANGO_SECRET_KEY | Secret key used | |
|
||||
| DJANGO_SILENCED_SYSTEM_CHECKS | Silence system checks | [] |
|
||||
| DJANGO_ALLOW_UNSECURE_USER_LISTING | | false |
|
||||
| DB_ENGINE | Database engine used | django.db.backends.postgresql_psycopg2 |
|
||||
| DB_NAME | name of the database | meet |
|
||||
| DB_USER | user used to connect to database | dinum |
|
||||
| DB_PASSWORD | password used to connect to the database | pass |
|
||||
| DB_HOST | hostname of the database | localhost |
|
||||
| DB_PORT | port to connect to database | 5432 |
|
||||
| STORAGES_STATICFILES_BACKEND | Static file serving engine | whitenoise.storage.CompressedManifestStaticFilesStorage |
|
||||
| AWS_S3_ENDPOINT_URL | S3 host endpoint | |
|
||||
| AWS_S3_ACCESS_KEY_ID | s3 access key | |
|
||||
| AWS_S3_SECRET_ACCESS_KEY | s3 secret key | |
|
||||
| AWS_S3_REGION_NAME | s3 region | |
|
||||
| AWS_STORAGE_BUCKET_NAME | s3 bucket name | |
|
||||
| DJANGO_LANGUAGE_CODE | Default language | en-us |
|
||||
| REDIS_URL | redis endpoint | redis://redis:6379/1 |
|
||||
| REQUEST_ENTRY_THROTTLE_RATES | throttle rates | 150/minute |
|
||||
| SPECTACULAR_SETTINGS_ENABLE_DJANGO_DEPLOY_CHECK | deploy check | False |
|
||||
| FRONTEND_ANALYTICS | analytics information | {} |
|
||||
| FRONTEND_SUPPORT | crisp frontend support | {} |
|
||||
| FRONTEND_SILENCE_LIVEKIT_DEBUG | silence livekit debug | false |
|
||||
| DJANGO_EMAIL_BACKEND | email backend library | django.core.mail.backends.smtp.EmailBackend |
|
||||
| DJANGO_EMAIL_HOST | host of the email server | |
|
||||
| DJANGO_EMAIL_HOST_USER | user to connect to the email server | |
|
||||
| DJANGO_EMAIL_HOST_PASSWORD | password to connect tto the email server | |
|
||||
| DJANGO_EMAIL_PORT | por tot connect to the email server | |
|
||||
| DJANGO_EMAIL_USE_TLS | enable tls on email connection | false |
|
||||
| DJANGO_EMAIL_USE_SSL | enable tls on email connection | false |
|
||||
| DJANGO_EMAIL_FROM | email from account | from@example.com |
|
||||
| DJANGO_CORS_ALLOW_ALL_ORIGINS | allow all cors origins | true |
|
||||
| DJANGO_CORS_ALLOWED_ORIGINS | origins to allow in string | [] |
|
||||
| DJANGO_CORS_ALLOWED_ORIGIN_REGEXES | origins to allow in regex | [] |
|
||||
| SENTRY_DSN | sentry server | |
|
||||
| DJANGO_CELERY_BROKER_URL | celery broker host | redis://redis:6379/0 |
|
||||
| DJANGO_CELERY_BROKER_TRANSPORT_OPTIONS | celery broker options | {} |
|
||||
| OIDC_CREATE_USER | create oidc user if not exists | false |
|
||||
| OIDC_VERIFY_SSL | verify ssl for oidc | true |
|
||||
| OIDC_FALLBACK_TO_EMAIL_FOR_IDENTIFICATION | fallback to email for identification | false |
|
||||
| OIDC_RP_SIGN_ALGO | token verification algoritm used by oidc | RS256 |
|
||||
| OIDC_RP_CLIENT_ID | oidc client | meet |
|
||||
| OIDC_RP_CLIENT_SECRET | oidc client secret | |
|
||||
| OIDC_OP_JWKS_ENDPOINT | oidc endpoint for JWKS | |
|
||||
| OIDC_OP_AUTHORIZATION_ENDPOINT | oidc endpoint for authorization | |
|
||||
| OIDC_OP_TOKEN_ENDPOINT | oidc endpoint for token | |
|
||||
| OIDC_OP_USER_ENDPOINT | oidc endpoint for user | |
|
||||
| OIDC_OP_LOGOUT_ENDPOINT | oidc endpoint for logout | |
|
||||
| OIDC_AUTH_REQUEST_EXTRA_PARAMS | extra parameters for oidc request | |
|
||||
| OIDC_RP_SCOPES | oidc scopes | openid email |
|
||||
| LOGIN_REDIRECT_URL | login redirect url | |
|
||||
| LOGIN_REDIRECT_URL_FAILURE | login redurect url for failure | |
|
||||
| LOGOUT_REDIRECT_URL | url to redirect to on logout | |
|
||||
| OIDC_USE_NONCE | use nonce for oidc | true |
|
||||
| OIDC_REDIRECT_REQUIRE_HTTPS | require https for oidc | false |
|
||||
| OIDC_REDIRECT_ALLOWED_HOSTS | allowed redirect hosts for oidc | [] |
|
||||
| OIDC_STORE_ID_TOKEN | store oidc ID token | true |
|
||||
| ALLOW_LOGOUT_GET_METHOD | allow logout through get method | true |
|
||||
| OIDC_REDIRECT_FIELD_NAME | direct field for oidc | returnTo |
|
||||
| OIDC_USERINFO_FULLNAME_FIELDS | full name claim from OIDC token | ["given_name", "usual_name"] |
|
||||
| OIDC_USERINFO_SHORTNAME_FIELD | shortname claim from OIDC token | given_name |
|
||||
| LIVEKIT_API_KEY | livekit api key | |
|
||||
| LIVEKIT_API_SECRET | livekit api secret | |
|
||||
| LIVEKIT_API_URL | livekit api url | |
|
||||
| RESOURCE_DEFAULT_ACCESS_LEVEL | default resource access level for rooms | public |
|
||||
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
|
||||
| RECORDING_ENABLE | record meeting option | false |
|
||||
| RECORDING_OUTPUT_FOLDER | folder to store meetings | recordings |
|
||||
| RECORDING_VERIFY_SSL | verify ssl for recording storage | true |
|
||||
| RECORDING_WORKER_CLASSES | worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
|
||||
| RECORDING_EVENT_PARSER_CLASS | storage event engine for recording | core.recording.event.parsers.MinioParser |
|
||||
| RECORDING_ENABLE_STORAGE_EVENT_AUTH | enable storage event authorization | true |
|
||||
| RECORDING_STORAGE_EVENT_ENABLE | enable recording storage events | false |
|
||||
| RECORDING_STORAGE_EVENT_TOKEN | recording storage event token | |
|
||||
| SUMMARY_SERVICE_ENDPOINT | summary service endpoint | |
|
||||
| SUMMARY_SERVICE_API_TOKEN | api token voor summary service | |
|
||||
| SIGNUP_NEW_USER_TO_MARKETING_EMAIL | signup users to marketing emails | false |
|
||||
| MARKETING_SERVICE_CLASS | markering class | core.services.marketing.BrevoMarketingService |
|
||||
| BREVO_API_KEY | breva api key for marketing emails | |
|
||||
| BREVO_API_CONTACT_LIST_IDS | brevo api contact list ids | [] |
|
||||
| DJANGO_BREVO_API_CONTACT_ATTRIBUTES | brevo contact attributes | {"VISIO_USER": True} |
|
||||
| BREVO_API_TIMEOUT | brevo timeout | 1 |
|
||||
| LOBBY_KEY_PREFIX | lobby prefix | room_lobby |
|
||||
| LOBBY_WAITING_TIMEOUT | lobby waiting tumeout | 3 |
|
||||
| LOBBY_DENIED_TIMEOUT | lobby deny timeout | 5 |
|
||||
| LOBBY_ACCEPTED_TIMEOUT | lobby accept timeout | 21600 |
|
||||
| LOBBY_NOTIFICATION_TYPE | lobby notification types | participantWaiting |
|
||||
| LOBBY_COOKIE_NAME | lobby cookie name | lobbyParticipantId |
|
||||
|
||||
@@ -12,12 +12,19 @@ PYTHONPATH=/app
|
||||
# Mail
|
||||
DJANGO_EMAIL_HOST="mailcatcher"
|
||||
DJANGO_EMAIL_PORT=1025
|
||||
DJANGO_EMAIL_BRAND_NAME=La Suite Numérique
|
||||
DJANGO_EMAIL_SUPPORT_EMAIL=test@yopmail.com
|
||||
DJANGO_EMAIL_LOGO_IMG=http://localhost:3000/assets/logo-suite-numerique.png
|
||||
DJANGO_EMAIL_DOMAIN=http://localhost:3000/
|
||||
|
||||
# Backend url
|
||||
MEET_BASE_URL="http://localhost:8072"
|
||||
|
||||
# Media
|
||||
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
AWS_S3_ENDPOINT_URL=http://minio:9000
|
||||
AWS_S3_ACCESS_KEY_ID=meet
|
||||
AWS_S3_SECRET_ACCESS_KEY=password
|
||||
|
||||
# OIDC
|
||||
OIDC_OP_JWKS_ENDPOINT=http://nginx:8083/realms/meet/protocol/openid-connect/certs
|
||||
@@ -42,3 +49,6 @@ LIVEKIT_API_SECRET=secret
|
||||
LIVEKIT_API_KEY=devkey
|
||||
LIVEKIT_API_URL=http://localhost:7880
|
||||
ALLOW_UNREGISTERED_ROOMS=False
|
||||
|
||||
# Recording
|
||||
SCREEN_RECORDING_BASE_URL=http://localhost:3000/recordings
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"groupName": "ignored js dependencies",
|
||||
"matchManagers": ["npm"],
|
||||
"matchPackageNames": [
|
||||
"eslint"
|
||||
"eslint", "react", "react-dom", "@types/react-dom", "@types/react", "react-i18next"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Client serializers for the Meet core app."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from rest_framework import serializers
|
||||
@@ -92,7 +91,7 @@ class ListRoomSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.Room
|
||||
fields = ["id", "name", "slug", "is_public"]
|
||||
fields = ["id", "name", "slug", "access_level"]
|
||||
read_only_fields = ["id", "slug"]
|
||||
|
||||
|
||||
@@ -101,7 +100,7 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.Room
|
||||
fields = ["id", "name", "slug", "configuration", "is_public"]
|
||||
fields = ["id", "name", "slug", "configuration", "access_level"]
|
||||
read_only_fields = ["id", "slug"]
|
||||
|
||||
def to_representation(self, instance):
|
||||
@@ -129,17 +128,21 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
if not is_admin:
|
||||
del output["configuration"]
|
||||
|
||||
if role is not None or instance.is_public:
|
||||
slug = f"{instance.id!s}"
|
||||
username = request.query_params.get("username", None)
|
||||
should_access_room = (
|
||||
(
|
||||
instance.access_level == models.RoomAccessLevel.TRUSTED
|
||||
and request.user.is_authenticated
|
||||
)
|
||||
or role is not None
|
||||
or instance.is_public
|
||||
)
|
||||
|
||||
output["livekit"] = {
|
||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||
"room": slug,
|
||||
"token": utils.generate_token(
|
||||
room=slug, user=request.user, username=username
|
||||
),
|
||||
}
|
||||
if should_access_room:
|
||||
room_id = f"{instance.id!s}"
|
||||
username = request.query_params.get("username", None)
|
||||
output["livekit"] = utils.generate_livekit_config(
|
||||
room_id=room_id, user=request.user, username=username
|
||||
)
|
||||
|
||||
output["is_administrable"] = is_admin
|
||||
|
||||
@@ -153,7 +156,7 @@ class RecordingSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.Recording
|
||||
fields = ["id", "room", "created_at", "updated_at", "status"]
|
||||
fields = ["id", "room", "created_at", "updated_at", "status", "mode"]
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
@@ -177,3 +180,46 @@ class StartRecordingSerializer(serializers.Serializer):
|
||||
def update(self, instance, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("StartRecordingSerializer is validation-only")
|
||||
|
||||
|
||||
class RequestEntrySerializer(serializers.Serializer):
|
||||
"""Validate request entry data."""
|
||||
|
||||
username = serializers.CharField(required=True)
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("StartRecordingSerializer is validation-only")
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("StartRecordingSerializer is validation-only")
|
||||
|
||||
|
||||
class ParticipantEntrySerializer(serializers.Serializer):
|
||||
"""Validate participant entry decision data."""
|
||||
|
||||
participant_id = serializers.CharField(required=True)
|
||||
allow_entry = serializers.BooleanField(required=True)
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("StartRecordingSerializer is validation-only")
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("StartRecordingSerializer is validation-only")
|
||||
|
||||
|
||||
class CreationCallbackSerializer(serializers.Serializer):
|
||||
"""Validate room creation callback data."""
|
||||
|
||||
callback_id = serializers.CharField(required=True)
|
||||
|
||||
def create(self, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("StartRecordingSerializer is validation-only")
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
"""Not implemented as this is a validation-only serializer."""
|
||||
raise NotImplementedError("StartRecordingSerializer is validation-only")
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import uuid
|
||||
from logging import getLogger
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
@@ -9,12 +10,7 @@ from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.text import slugify
|
||||
|
||||
from rest_framework import (
|
||||
decorators,
|
||||
mixins,
|
||||
pagination,
|
||||
viewsets,
|
||||
)
|
||||
from rest_framework import decorators, mixins, pagination, throttling, viewsets
|
||||
from rest_framework import (
|
||||
exceptions as drf_exceptions,
|
||||
)
|
||||
@@ -25,7 +21,8 @@ from rest_framework import (
|
||||
status as drf_status,
|
||||
)
|
||||
|
||||
from core import models, utils
|
||||
from core import enums, models, utils
|
||||
from core.recording.enums import FileExtension
|
||||
from core.recording.event.authentication import StorageEventAuthentication
|
||||
from core.recording.event.exceptions import (
|
||||
InvalidBucketError,
|
||||
@@ -44,6 +41,15 @@ from core.recording.worker.factories import (
|
||||
from core.recording.worker.mediator import (
|
||||
WorkerServiceMediator,
|
||||
)
|
||||
from core.services.livekit_events import (
|
||||
LiveKitEventsService,
|
||||
LiveKitWebhookError,
|
||||
)
|
||||
from core.services.lobby import (
|
||||
LobbyParticipantNotFound,
|
||||
LobbyService,
|
||||
)
|
||||
from core.services.room_creation import RoomCreation
|
||||
|
||||
from . import permissions, serializers
|
||||
|
||||
@@ -151,9 +157,8 @@ class UserViewSet(
|
||||
queryset = self.queryset
|
||||
|
||||
if self.action == "list":
|
||||
# Exclude all users already in the given document
|
||||
if document_id := self.request.GET.get("document_id", ""):
|
||||
queryset = queryset.exclude(documentaccess__document_id=document_id)
|
||||
if not settings.ALLOW_UNSECURE_USER_LISTING:
|
||||
return models.User.objects.none()
|
||||
|
||||
# Filter users by email similarity
|
||||
if query := self.request.GET.get("q", ""):
|
||||
@@ -178,6 +183,18 @@ class UserViewSet(
|
||||
)
|
||||
|
||||
|
||||
class RequestEntryAnonRateThrottle(throttling.AnonRateThrottle):
|
||||
"""Throttle Anonymous user requesting room entry"""
|
||||
|
||||
scope = "request_entry"
|
||||
|
||||
|
||||
class CreationCallbackAnonRateThrottle(throttling.AnonRateThrottle):
|
||||
"""Throttle Anonymous user requesting room generation callback"""
|
||||
|
||||
scope = "creation_callback"
|
||||
|
||||
|
||||
class RoomViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
@@ -260,6 +277,9 @@ class RoomViewSet(
|
||||
role=models.RoleChoices.OWNER,
|
||||
)
|
||||
|
||||
if callback_id := self.request.data.get("callback_id"):
|
||||
RoomCreation().persist_callback_state(callback_id, room)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
@@ -343,6 +363,140 @@ class RoomViewSet(
|
||||
{"message": f"Recording stopped for room {room.slug}."}
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["POST"],
|
||||
url_path="request-entry",
|
||||
permission_classes=[],
|
||||
throttle_classes=[RequestEntryAnonRateThrottle],
|
||||
)
|
||||
def request_entry(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Request entry to a room"""
|
||||
|
||||
serializer = serializers.RequestEntrySerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
room = self.get_object()
|
||||
lobby_service = LobbyService()
|
||||
|
||||
participant, livekit = lobby_service.request_entry(
|
||||
room=room,
|
||||
request=request,
|
||||
**serializer.validated_data,
|
||||
)
|
||||
response = drf_response.Response({**participant.to_dict(), "livekit": livekit})
|
||||
lobby_service.prepare_response(response, participant.id)
|
||||
|
||||
return response
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
url_path="enter",
|
||||
permission_classes=[
|
||||
permissions.HasPrivilegesOnRoom,
|
||||
],
|
||||
)
|
||||
def allow_participant_to_enter(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Accept or deny a participant's entry request."""
|
||||
|
||||
serializer = serializers.ParticipantEntrySerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
room = self.get_object()
|
||||
|
||||
if room.is_public:
|
||||
return drf_response.Response(
|
||||
{"message": "Room has no lobby system."},
|
||||
status=drf_status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
lobby_service = LobbyService()
|
||||
|
||||
try:
|
||||
lobby_service.handle_participant_entry(
|
||||
room_id=room.id,
|
||||
**serializer.validated_data,
|
||||
)
|
||||
return drf_response.Response({"message": "Participant was updated."})
|
||||
|
||||
except LobbyParticipantNotFound:
|
||||
return drf_response.Response(
|
||||
{"message": "Participant not found."},
|
||||
status=drf_status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["GET"],
|
||||
url_path="waiting-participants",
|
||||
permission_classes=[
|
||||
permissions.HasPrivilegesOnRoom,
|
||||
],
|
||||
)
|
||||
def list_waiting_participants(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""List waiting participants."""
|
||||
room = self.get_object()
|
||||
|
||||
if room.is_public:
|
||||
return drf_response.Response({"participants": []})
|
||||
|
||||
lobby_service = LobbyService()
|
||||
|
||||
participants = lobby_service.list_waiting_participants(room.id)
|
||||
return drf_response.Response({"participants": participants})
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["POST"],
|
||||
url_path="webhooks-livekit",
|
||||
permission_classes=[],
|
||||
)
|
||||
def webhooks_livekit(self, request):
|
||||
"""Process webhooks from LiveKit."""
|
||||
|
||||
livekit_events_service = LiveKitEventsService()
|
||||
|
||||
try:
|
||||
livekit_events_service.receive(request)
|
||||
return drf_response.Response(
|
||||
{"status": "success"}, status=drf_status.HTTP_200_OK
|
||||
)
|
||||
except LiveKitWebhookError as e:
|
||||
status_code = getattr(e, "status_code", drf_status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if status_code == drf_status.HTTP_500_INTERNAL_SERVER_ERROR:
|
||||
raise e
|
||||
|
||||
return drf_response.Response(
|
||||
{"status": "error", "message": str(e)}, status=status_code
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["POST"],
|
||||
url_path="creation-callback",
|
||||
permission_classes=[],
|
||||
throttle_classes=[CreationCallbackAnonRateThrottle],
|
||||
)
|
||||
def creation_callback(self, request):
|
||||
"""Retrieve cached room data via an unauthenticated request with a unique ID.
|
||||
|
||||
Designed for interoperability across iframes, popups, and other contexts,
|
||||
even on the same domain, bypassing browser security restrictions on direct communication.
|
||||
"""
|
||||
|
||||
serializer = serializers.CreationCallbackSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
room = RoomCreation().get_callback_state(
|
||||
callback_id=serializer.validated_data.get("callback_id")
|
||||
)
|
||||
|
||||
return drf_response.Response(
|
||||
{"status": "success", "room": room}, status=drf_status.HTTP_200_OK
|
||||
)
|
||||
|
||||
|
||||
class ResourceAccessListModelMixin:
|
||||
"""List mixin for resource access API."""
|
||||
@@ -392,6 +546,7 @@ class ResourceAccessViewSet(
|
||||
class RecordingViewSet(
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
viewsets.GenericViewSet,
|
||||
):
|
||||
"""
|
||||
@@ -465,3 +620,85 @@ class RecordingViewSet(
|
||||
return drf_response.Response(
|
||||
{"message": "Event processed."},
|
||||
)
|
||||
|
||||
def _auth_get_original_url(self, request):
|
||||
"""
|
||||
Extracts and parses the original URL from the "HTTP_X_ORIGINAL_URL" header.
|
||||
Raises PermissionDenied if the header is missing.
|
||||
The original url is passed by nginx in the "HTTP_X_ORIGINAL_URL" header.
|
||||
See corresponding ingress configuration in Helm chart and read about the
|
||||
nginx.ingress.kubernetes.io/auth-url annotation to understand how the Nginx ingress
|
||||
is configured to do this.
|
||||
Based on the original url and the logged-in user, we must decide if we authorize Nginx
|
||||
to let this request go through (by returning a 200 code) or if we block it (by returning
|
||||
a 403 error). Note that we return 403 errors without any further details for security
|
||||
reasons.
|
||||
"""
|
||||
# Extract the original URL from the request header
|
||||
original_url = request.META.get("HTTP_X_ORIGINAL_URL")
|
||||
if not original_url:
|
||||
logger.debug("Missing HTTP_X_ORIGINAL_URL header in subrequest")
|
||||
raise drf_exceptions.PermissionDenied()
|
||||
|
||||
logger.debug("Original url: '%s'", original_url)
|
||||
return urlparse(original_url)
|
||||
|
||||
def _auth_get_url_params(self, pattern, fragment):
|
||||
"""
|
||||
Extracts URL parameters from the given fragment using the specified regex pattern.
|
||||
Raises PermissionDenied if parameters cannot be extracted.
|
||||
"""
|
||||
|
||||
match = pattern.search(fragment)
|
||||
|
||||
try:
|
||||
return match.groupdict()
|
||||
except (ValueError, AttributeError) as exc:
|
||||
logger.debug("Failed to extract parameters from subrequest URL: %s", exc)
|
||||
raise drf_exceptions.PermissionDenied() from exc
|
||||
|
||||
@decorators.action(detail=False, methods=["get"], url_path="media-auth")
|
||||
def media_auth(self, request, *args, **kwargs):
|
||||
"""
|
||||
This view is used by an Nginx subrequest to control access to a recording's
|
||||
media file.
|
||||
When we let the request go through, we compute authorization headers that will be added to
|
||||
the request going through thanks to the nginx.ingress.kubernetes.io/auth-response-headers
|
||||
annotation. The request will then be proxied to the object storage backend who will
|
||||
respond with the file after checking the signature included in headers.
|
||||
"""
|
||||
|
||||
parsed_url = self._auth_get_original_url(request)
|
||||
|
||||
url_params = self._auth_get_url_params(
|
||||
enums.RECORDING_STORAGE_URL_PATTERN, parsed_url.path
|
||||
)
|
||||
|
||||
user = request.user
|
||||
recording_id = url_params["recording_id"]
|
||||
|
||||
extension = url_params["extension"]
|
||||
if extension not in [item.value for item in FileExtension]:
|
||||
raise drf_exceptions.ValidationError({"detail": "Unsupported extension."})
|
||||
|
||||
try:
|
||||
recording = models.Recording.objects.get(id=recording_id)
|
||||
except models.Recording.DoesNotExist as e:
|
||||
raise drf_exceptions.NotFound("No recording found for this event.") from e
|
||||
|
||||
if extension != recording.extension:
|
||||
raise drf_exceptions.NotFound("No recording found with this extension.")
|
||||
|
||||
abilities = recording.get_abilities(user)
|
||||
|
||||
if not abilities["retrieve"]:
|
||||
logger.debug("User '%s' lacks permission for attachment", user.id)
|
||||
raise drf_exceptions.PermissionDenied()
|
||||
|
||||
if not recording.is_saved:
|
||||
logger.debug("Recording '%s' has not been saved", recording)
|
||||
raise drf_exceptions.PermissionDenied()
|
||||
|
||||
request = utils.generate_s3_authorization_headers(recording.key)
|
||||
|
||||
return drf_response.Response("authorized", headers=request.headers, status=200)
|
||||
|
||||
@@ -10,7 +10,7 @@ from mozilla_django_oidc.auth import (
|
||||
)
|
||||
|
||||
from core.models import User
|
||||
from core.services.marketing_service import (
|
||||
from core.services.marketing import (
|
||||
ContactCreationError,
|
||||
ContactData,
|
||||
get_marketing_service,
|
||||
@@ -121,7 +121,9 @@ class OIDCAuthenticationBackend(MozillaOIDCAuthenticationBackend):
|
||||
contact_data = ContactData(
|
||||
email=email, attributes={"VISIO_SOURCE": ["SIGNIN"]}
|
||||
)
|
||||
marketing_service.create_contact(contact_data, timeout=1)
|
||||
marketing_service.create_contact(
|
||||
contact_data, timeout=settings.BREVO_API_TIMEOUT
|
||||
)
|
||||
except (ContactCreationError, ImproperlyConfigured, ImportError):
|
||||
pass
|
||||
|
||||
|
||||
@@ -2,9 +2,21 @@
|
||||
Core application enums declaration
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
from django.conf import global_settings, settings
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
UUID_REGEX = (
|
||||
r"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
|
||||
)
|
||||
FILE_EXT_REGEX = r"[a-zA-Z0-9]{1,10}"
|
||||
|
||||
# pylint: disable=line-too-long
|
||||
RECORDING_STORAGE_URL_PATTERN = re.compile(
|
||||
f"/media/{settings.RECORDING_OUTPUT_FOLDER}/(?P<recording_id>{UUID_REGEX:s}).(?P<extension>{FILE_EXT_REGEX:s})"
|
||||
)
|
||||
|
||||
# Django sets `LANGUAGES` by default with all supported languages. We can use it for
|
||||
# the choice of languages which should not be limited to the few languages active in
|
||||
# the app.
|
||||
|
||||
@@ -36,8 +36,6 @@ class ResourceFactory(factory.django.DjangoModelFactory):
|
||||
model = models.Resource
|
||||
skip_postgeneration_save = True
|
||||
|
||||
is_public = factory.Faker("boolean", chance_of_getting_true=50)
|
||||
|
||||
@factory.post_generation
|
||||
def users(self, create, extracted, **kwargs):
|
||||
"""Add users to resource from a given list of users."""
|
||||
@@ -70,6 +68,7 @@ class RoomFactory(ResourceFactory):
|
||||
|
||||
name = factory.Faker("catch_phrase")
|
||||
slug = factory.LazyAttribute(lambda o: slugify(o.name))
|
||||
access_level = factory.fuzzy.FuzzyChoice(models.RoomAccessLevel)
|
||||
|
||||
|
||||
class RecordingFactory(factory.django.DjangoModelFactory):
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# Generated by Django 5.1.4 on 2025-01-13 12:25
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0009_alter_recording_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='resourceaccess',
|
||||
options={'ordering': ('-created_at',), 'verbose_name': 'Resource access', 'verbose_name_plural': 'Resource accesses'},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='user',
|
||||
options={'ordering': ('-created_at',), 'verbose_name': 'user', 'verbose_name_plural': 'users'},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.1.5 on 2025-02-16 11:42
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0010_alter_resourceaccess_options_alter_user_options'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name='resource',
|
||||
name='is_public',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='room',
|
||||
name='access_level',
|
||||
field=models.CharField(choices=[('public', 'Public Access'), ('restricted', 'Restricted Access')], default='public', max_length=50),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-04 09:51
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0011_remove_resource_is_public_room_access_level'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='room',
|
||||
name='access_level',
|
||||
field=models.CharField(choices=[('public', 'Public Access'), ('trusted', 'Trusted Access'), ('restricted', 'Restricted Access')], default='public', max_length=50),
|
||||
)
|
||||
]
|
||||
@@ -18,6 +18,8 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from timezone_field import TimeZoneField
|
||||
|
||||
from .recording.enums import FileExtension
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
@@ -80,6 +82,14 @@ class RecordingModeChoices(models.TextChoices):
|
||||
TRANSCRIPT = "transcript", _("TRANSCRIPT")
|
||||
|
||||
|
||||
class RoomAccessLevel(models.TextChoices):
|
||||
"""Room access level choices."""
|
||||
|
||||
PUBLIC = "public", _("Public Access")
|
||||
TRUSTED = "trusted", _("Trusted Access")
|
||||
RESTRICTED = "restricted", _("Restricted Access")
|
||||
|
||||
|
||||
class BaseModel(models.Model):
|
||||
"""
|
||||
Serves as an abstract base model for other models, ensuring that records are validated
|
||||
@@ -241,7 +251,6 @@ def get_resource_roles(resource: models.Model, user: User) -> List[str]:
|
||||
class Resource(BaseModel):
|
||||
"""Model to define access control"""
|
||||
|
||||
is_public = models.BooleanField(default=settings.RESOURCE_DEFAULT_IS_PUBLIC)
|
||||
users = models.ManyToManyField(
|
||||
User,
|
||||
through="ResourceAccess",
|
||||
@@ -361,7 +370,11 @@ class Room(Resource):
|
||||
primary_key=True,
|
||||
)
|
||||
slug = models.SlugField(max_length=100, blank=True, null=True, unique=True)
|
||||
|
||||
access_level = models.CharField(
|
||||
max_length=50,
|
||||
choices=RoomAccessLevel.choices,
|
||||
default=settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
|
||||
)
|
||||
configuration = models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
@@ -394,6 +407,11 @@ class Room(Resource):
|
||||
raise ValidationError({"name": f'Room name "{self.name:s}" is reserved.'})
|
||||
super().clean_fields(exclude=exclude)
|
||||
|
||||
@property
|
||||
def is_public(self):
|
||||
"""Check if a room is public"""
|
||||
return self.access_level == RoomAccessLevel.PUBLIC
|
||||
|
||||
|
||||
class BaseAccessManager(models.Manager):
|
||||
"""Base manager for handling resource access control."""
|
||||
@@ -560,6 +578,26 @@ class Recording(BaseModel):
|
||||
RecordingStatusChoices.STOPPED,
|
||||
}
|
||||
|
||||
@property
|
||||
def is_saved(self) -> bool:
|
||||
"""Check if the recording is in a saved state."""
|
||||
return self.status == RecordingStatusChoices.SAVED
|
||||
|
||||
@property
|
||||
def extension(self):
|
||||
"""Get recording extension based on its mode."""
|
||||
extensions = {
|
||||
RecordingModeChoices.TRANSCRIPT: FileExtension.OGG.value,
|
||||
RecordingModeChoices.SCREEN_RECORDING: FileExtension.MP4.value,
|
||||
}
|
||||
return extensions.get(self.mode, FileExtension.MP4.value)
|
||||
|
||||
@property
|
||||
def key(self):
|
||||
"""Generate the file key based on recording mode."""
|
||||
|
||||
return f"{settings.RECORDING_OUTPUT_FOLDER}/{self.id}.{self.extension}"
|
||||
|
||||
|
||||
class RecordingAccess(BaseAccess):
|
||||
"""Relation model to give access to a recording for a user or a team with a role."""
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Enums related to recordings."""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class FileExtension(Enum):
|
||||
"""Enum for file extensions used in recordings."""
|
||||
|
||||
OGG = "ogg"
|
||||
MP4 = "mp4"
|
||||
@@ -1,8 +1,13 @@
|
||||
"""Service to notify external services when a new recording is ready."""
|
||||
|
||||
import logging
|
||||
import smtplib
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.mail import send_mail
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.translation import get_language, override
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
import requests
|
||||
|
||||
@@ -21,10 +26,7 @@ class NotificationService:
|
||||
return self._notify_summary_service(recording)
|
||||
|
||||
if recording.mode == models.RecordingModeChoices.SCREEN_RECORDING:
|
||||
logger.warning(
|
||||
"Screen recording mode not implemented for recording %s", recording.id
|
||||
)
|
||||
return False
|
||||
return self._notify_user_by_email(recording)
|
||||
|
||||
logger.error(
|
||||
"Unknown recording mode %s for recording %s",
|
||||
@@ -33,6 +35,59 @@ class NotificationService:
|
||||
)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _notify_user_by_email(recording) -> bool:
|
||||
"""
|
||||
Send an email notification to recording owners when their recording is ready.
|
||||
|
||||
The email includes a direct link that redirects owners to a dedicated download
|
||||
page in the frontend where they can access their specific recording.
|
||||
"""
|
||||
|
||||
owner_accesses = models.RecordingAccess.objects.select_related("user").filter(
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording_id=recording.id,
|
||||
)
|
||||
|
||||
if not owner_accesses:
|
||||
logger.error("No owner found for recording %s", recording.id)
|
||||
return False
|
||||
|
||||
language = get_language()
|
||||
|
||||
context = {
|
||||
"brandname": settings.EMAIL_BRAND_NAME,
|
||||
"support_email": settings.EMAIL_SUPPORT_EMAIL,
|
||||
"logo_img": settings.EMAIL_LOGO_IMG,
|
||||
"domain": settings.EMAIL_DOMAIN,
|
||||
"room_name": recording.room.name,
|
||||
"recording_date": recording.created_at.strftime("%A %d %B %Y"),
|
||||
"recording_time": recording.created_at.strftime("%H:%M"),
|
||||
"link": f"{settings.SCREEN_RECORDING_BASE_URL}/{recording.id}",
|
||||
}
|
||||
|
||||
emails = [access.user.email for access in owner_accesses]
|
||||
|
||||
with override(language):
|
||||
msg_html = render_to_string("mail/html/screen_recording.html", context)
|
||||
msg_plain = render_to_string("mail/text/screen_recording.txt", context)
|
||||
subject = str(_("Your recording is ready")) # Force translation
|
||||
|
||||
try:
|
||||
send_mail(
|
||||
subject.capitalize(),
|
||||
msg_plain,
|
||||
settings.EMAIL_FROM,
|
||||
emails,
|
||||
html_message=msg_html,
|
||||
fail_silently=False,
|
||||
)
|
||||
except smtplib.SMTPException as exception:
|
||||
logger.error("notification to %s was not sent: %s", emails, exception)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service(recording):
|
||||
"""Notify summary service about a new recording."""
|
||||
@@ -57,10 +112,8 @@ class NotificationService:
|
||||
logger.error("No owner found for recording %s", recording.id)
|
||||
return False
|
||||
|
||||
key = f"{settings.RECORDING_OUTPUT_FOLDER}/{recording.id}.ogg"
|
||||
|
||||
payload = {
|
||||
"filename": key,
|
||||
"filename": recording.key,
|
||||
"email": owner_access.user.email,
|
||||
"sub": owner_access.user.sub,
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ from asgiref.sync import async_to_sync
|
||||
from livekit import api as livekit_api
|
||||
from livekit.api.egress_service import EgressService
|
||||
|
||||
from ..enums import FileExtension
|
||||
from .exceptions import WorkerConnectionError, WorkerResponseError
|
||||
from .factories import WorkerServiceConfig
|
||||
|
||||
@@ -89,7 +90,9 @@ class VideoCompositeEgressService(BaseEgressService):
|
||||
|
||||
# Save room's recording as a mp4 video file.
|
||||
file_type = livekit_api.EncodedFileType.MP4
|
||||
filepath = self._get_filepath(filename=recording_id, extension="mp4")
|
||||
filepath = self._get_filepath(
|
||||
filename=recording_id, extension=FileExtension.MP4.value
|
||||
)
|
||||
|
||||
file_output = livekit_api.EncodedFileOutput(
|
||||
file_type=file_type,
|
||||
@@ -120,7 +123,9 @@ class AudioCompositeEgressService(BaseEgressService):
|
||||
|
||||
# Save room's recording as an ogg audio file.
|
||||
file_type = livekit_api.EncodedFileType.OGG
|
||||
filepath = self._get_filepath(filename=recording_id, extension="ogg")
|
||||
filepath = self._get_filepath(
|
||||
filename=recording_id, extension=FileExtension.OGG.value
|
||||
)
|
||||
|
||||
file_output = livekit_api.EncodedFileOutput(
|
||||
file_type=file_type,
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""LiveKit Events Service"""
|
||||
|
||||
import uuid
|
||||
from enum import Enum
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from livekit import api
|
||||
|
||||
from .lobby import LobbyService
|
||||
|
||||
|
||||
class LiveKitWebhookError(Exception):
|
||||
"""Base exception for LiveKit webhook processing errors."""
|
||||
|
||||
status_code = 500
|
||||
|
||||
|
||||
class AuthenticationError(LiveKitWebhookError):
|
||||
"""Authentication failed."""
|
||||
|
||||
status_code = 401
|
||||
|
||||
|
||||
class InvalidPayloadError(LiveKitWebhookError):
|
||||
"""Invalid webhook payload."""
|
||||
|
||||
status_code = 400
|
||||
|
||||
|
||||
class UnsupportedEventTypeError(LiveKitWebhookError):
|
||||
"""Unsupported event type."""
|
||||
|
||||
status_code = 422
|
||||
|
||||
|
||||
class ActionFailedError(LiveKitWebhookError):
|
||||
"""Webhook action fails to process or complete."""
|
||||
|
||||
status_code = 500
|
||||
|
||||
|
||||
class LiveKitWebhookEventType(Enum):
|
||||
"""LiveKit webhook event types."""
|
||||
|
||||
# Room events
|
||||
ROOM_STARTED = "room_started"
|
||||
ROOM_FINISHED = "room_finished"
|
||||
|
||||
# Participant events
|
||||
PARTICIPANT_JOINED = "participant_joined"
|
||||
PARTICIPANT_LEFT = "participant_left"
|
||||
|
||||
# Track events
|
||||
TRACK_PUBLISHED = "track_published"
|
||||
TRACK_UNPUBLISHED = "track_unpublished"
|
||||
|
||||
# Egress events
|
||||
EGRESS_STARTED = "egress_started"
|
||||
EGRESS_UPDATED = "egress_updated"
|
||||
EGRESS_ENDED = "egress_ended"
|
||||
|
||||
# Ingress events
|
||||
INGRESS_STARTED = "ingress_started"
|
||||
INGRESS_ENDED = "ingress_ended"
|
||||
|
||||
|
||||
class LiveKitEventsService:
|
||||
"""Service for processing and handling LiveKit webhook events and notifications."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize with required services."""
|
||||
|
||||
token_verifier = api.TokenVerifier(
|
||||
settings.LIVEKIT_CONFIGURATION["api_key"],
|
||||
settings.LIVEKIT_CONFIGURATION["api_secret"],
|
||||
)
|
||||
self.webhook_receiver = api.WebhookReceiver(token_verifier)
|
||||
self.lobby_service = LobbyService()
|
||||
|
||||
def receive(self, request):
|
||||
"""Process webhook and route to appropriate handler."""
|
||||
|
||||
auth_token = request.headers.get("Authorization")
|
||||
if not auth_token:
|
||||
raise AuthenticationError("Authorization header missing")
|
||||
|
||||
try:
|
||||
data = self.webhook_receiver.receive(
|
||||
request.body.decode("utf-8"), auth_token
|
||||
)
|
||||
except Exception as e:
|
||||
raise InvalidPayloadError("Invalid webhook payload") from e
|
||||
|
||||
try:
|
||||
webhook_type = LiveKitWebhookEventType(data.event)
|
||||
except ValueError as e:
|
||||
raise UnsupportedEventTypeError(
|
||||
f"Unknown webhook type: {data.event}"
|
||||
) from e
|
||||
|
||||
handler_name = f"_handle_{webhook_type.value}"
|
||||
handler = getattr(self, handler_name, None)
|
||||
|
||||
if not handler or not callable(handler):
|
||||
return
|
||||
|
||||
# pylint: disable=not-callable
|
||||
handler(data)
|
||||
|
||||
def _handle_room_finished(self, data):
|
||||
"""Handle 'room_finished' event."""
|
||||
try:
|
||||
room_id = uuid.UUID(data.room.name)
|
||||
self.lobby_service.clear_room_cache(room_id)
|
||||
except Exception as e:
|
||||
raise ActionFailedError("Failed to process room finished event") from e
|
||||
@@ -0,0 +1,385 @@
|
||||
"""Lobby Service"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
from uuid import UUID
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit.api import ( # pylint: disable=E0611
|
||||
ListRoomsRequest,
|
||||
LiveKitAPI,
|
||||
SendDataRequest,
|
||||
TwirpError,
|
||||
)
|
||||
|
||||
from core import models, utils
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LobbyParticipantStatus(Enum):
|
||||
"""Possible states of a participant in the lobby system.
|
||||
Values are lowercase strings for consistent serialization and API responses.
|
||||
"""
|
||||
|
||||
UNKNOWN = "unknown"
|
||||
WAITING = "waiting"
|
||||
ACCEPTED = "accepted"
|
||||
DENIED = "denied"
|
||||
|
||||
|
||||
class LobbyError(Exception):
|
||||
"""Base exception for lobby-related errors."""
|
||||
|
||||
|
||||
class LobbyParticipantParsingError(LobbyError):
|
||||
"""Raised when participant data parsing fails."""
|
||||
|
||||
|
||||
class LobbyParticipantNotFound(LobbyError):
|
||||
"""Raised when participant is not found."""
|
||||
|
||||
|
||||
class LobbyNotificationError(LobbyError):
|
||||
"""Raised when LiveKit notification fails."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class LobbyParticipant:
|
||||
"""Participant in a lobby system."""
|
||||
|
||||
status: LobbyParticipantStatus
|
||||
username: str
|
||||
color: str
|
||||
id: str
|
||||
|
||||
def to_dict(self) -> Dict[str, str]:
|
||||
"""Serialize the participant object to a dict representation."""
|
||||
return {
|
||||
"status": self.status.value,
|
||||
"username": self.username,
|
||||
"id": self.id,
|
||||
"color": self.color,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict) -> "LobbyParticipant":
|
||||
"""Create a LobbyParticipant instance from a dictionary."""
|
||||
try:
|
||||
status = LobbyParticipantStatus(
|
||||
data.get("status", LobbyParticipantStatus.UNKNOWN.value)
|
||||
)
|
||||
return cls(
|
||||
status=status,
|
||||
username=data["username"],
|
||||
id=data["id"],
|
||||
color=data["color"],
|
||||
)
|
||||
except (KeyError, ValueError) as e:
|
||||
logger.exception("Error creating Participant from dict:")
|
||||
raise LobbyParticipantParsingError("Invalid participant data") from e
|
||||
|
||||
|
||||
class LobbyService:
|
||||
"""Service for managing participant access through a lobby system.
|
||||
|
||||
Handles participant entry requests, status management, and notifications
|
||||
using cache for state management and LiveKit for real-time updates.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _get_cache_key(room_id: UUID, participant_id: str) -> str:
|
||||
"""Generate cache key for participant(s) data."""
|
||||
return f"{settings.LOBBY_KEY_PREFIX}_{room_id!s}_{participant_id}"
|
||||
|
||||
@staticmethod
|
||||
def _get_or_create_participant_id(request) -> str:
|
||||
"""Extract unique participant identifier from the request."""
|
||||
return request.COOKIES.get(settings.LOBBY_COOKIE_NAME, uuid.uuid4().hex)
|
||||
|
||||
@staticmethod
|
||||
def prepare_response(response, participant_id):
|
||||
"""Set participant cookie if needed."""
|
||||
if not response.cookies.get(settings.LOBBY_COOKIE_NAME):
|
||||
response.set_cookie(
|
||||
key=settings.LOBBY_COOKIE_NAME,
|
||||
value=participant_id,
|
||||
httponly=True,
|
||||
secure=True,
|
||||
samesite="Lax",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def can_bypass_lobby(room, user) -> bool:
|
||||
"""Determines if a user can bypass the waiting lobby and join a room directly.
|
||||
|
||||
A user can bypass the lobby if:
|
||||
1. The room is public (open to everyone)
|
||||
2. The room has TRUSTED access level and the user is authenticated
|
||||
|
||||
Note: Room access levels can change while participants are waiting in the lobby.
|
||||
This function only checks the current state and should be called each time
|
||||
a participant requests entry to ensure consistent access control, even for
|
||||
participants who have already begun waiting.
|
||||
"""
|
||||
return room.is_public or (
|
||||
room.access_level == models.RoomAccessLevel.TRUSTED
|
||||
and user.is_authenticated
|
||||
)
|
||||
|
||||
def request_entry(
|
||||
self,
|
||||
room,
|
||||
request,
|
||||
username: str,
|
||||
) -> Tuple[LobbyParticipant, Optional[Dict]]:
|
||||
"""Request entry to a room for a participant.
|
||||
|
||||
This usual status transitions is:
|
||||
UNKNOWN -> WAITING -> (ACCEPTED | DENIED)
|
||||
|
||||
Flow:
|
||||
1. Check current status
|
||||
2. If waiting, refresh timeout to maintain position
|
||||
3. If unknown, add to waiting list
|
||||
4. If accepted, generate LiveKit config
|
||||
5. If denied, do nothing.
|
||||
"""
|
||||
|
||||
participant_id = self._get_or_create_participant_id(request)
|
||||
participant = self._get_participant(room.id, participant_id)
|
||||
|
||||
if self.can_bypass_lobby(room=room, user=request.user):
|
||||
if participant is None:
|
||||
participant = LobbyParticipant(
|
||||
status=LobbyParticipantStatus.ACCEPTED,
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color=utils.generate_color(participant_id),
|
||||
)
|
||||
else:
|
||||
participant.status = LobbyParticipantStatus.ACCEPTED
|
||||
|
||||
livekit_config = utils.generate_livekit_config(
|
||||
room_id=str(room.id),
|
||||
user=request.user,
|
||||
username=username,
|
||||
color=participant.color,
|
||||
)
|
||||
return participant, livekit_config
|
||||
|
||||
livekit_config = None
|
||||
|
||||
if participant is None:
|
||||
participant = self.enter(room.id, participant_id, username)
|
||||
|
||||
elif participant.status == LobbyParticipantStatus.WAITING:
|
||||
self.refresh_waiting_status(room.id, participant_id)
|
||||
|
||||
elif participant.status == LobbyParticipantStatus.ACCEPTED:
|
||||
# wrongly named, contains access token to join a room
|
||||
livekit_config = utils.generate_livekit_config(
|
||||
room_id=str(room.id),
|
||||
user=request.user,
|
||||
username=username,
|
||||
color=participant.color,
|
||||
)
|
||||
|
||||
return participant, livekit_config
|
||||
|
||||
def refresh_waiting_status(self, room_id: UUID, participant_id: str):
|
||||
"""Refresh timeout for waiting participant.
|
||||
|
||||
Extends the waiting period for a participant to maintain their position
|
||||
in the lobby queue. Automatic removal if the participant is not
|
||||
actively checking their status.
|
||||
"""
|
||||
cache.touch(
|
||||
self._get_cache_key(room_id, participant_id), settings.LOBBY_WAITING_TIMEOUT
|
||||
)
|
||||
|
||||
def enter(
|
||||
self, room_id: UUID, participant_id: str, username: str
|
||||
) -> LobbyParticipant:
|
||||
"""Add participant to waiting lobby.
|
||||
|
||||
Create a new participant entry in waiting status and notify room
|
||||
participants of the new entry request.
|
||||
|
||||
Raises:
|
||||
LobbyNotificationError: If room notification fails
|
||||
"""
|
||||
|
||||
color = utils.generate_color(participant_id)
|
||||
|
||||
participant = LobbyParticipant(
|
||||
status=LobbyParticipantStatus.WAITING,
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color=color,
|
||||
)
|
||||
|
||||
try:
|
||||
self.notify_participants(room_id=room_id)
|
||||
except LobbyNotificationError:
|
||||
# If room not created yet, there is no participants to notify
|
||||
pass
|
||||
|
||||
cache_key = self._get_cache_key(room_id, participant_id)
|
||||
cache.set(
|
||||
cache_key,
|
||||
participant.to_dict(),
|
||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||
)
|
||||
|
||||
return participant
|
||||
|
||||
def _get_participant(
|
||||
self, room_id: UUID, participant_id: str
|
||||
) -> Optional[LobbyParticipant]:
|
||||
"""Check participant's current status in the lobby."""
|
||||
|
||||
cache_key = self._get_cache_key(room_id, participant_id)
|
||||
data = cache.get(cache_key)
|
||||
|
||||
if not data:
|
||||
return None
|
||||
|
||||
try:
|
||||
return LobbyParticipant.from_dict(data)
|
||||
except LobbyParticipantParsingError:
|
||||
logger.error("Corrupted participant data found and removed: %s", cache_key)
|
||||
cache.delete(cache_key)
|
||||
return None
|
||||
|
||||
def list_waiting_participants(self, room_id: UUID) -> List[dict]:
|
||||
"""List all waiting participants for a room."""
|
||||
|
||||
pattern = self._get_cache_key(room_id, "*")
|
||||
keys = cache.keys(pattern)
|
||||
|
||||
if not keys:
|
||||
return []
|
||||
|
||||
data = cache.get_many(keys)
|
||||
|
||||
waiting_participants = []
|
||||
for cache_key, raw_participant in data.items():
|
||||
try:
|
||||
participant = LobbyParticipant.from_dict(raw_participant)
|
||||
except LobbyParticipantParsingError:
|
||||
cache.delete(cache_key)
|
||||
continue
|
||||
if participant.status == LobbyParticipantStatus.WAITING:
|
||||
waiting_participants.append(participant.to_dict())
|
||||
|
||||
return waiting_participants
|
||||
|
||||
def handle_participant_entry(
|
||||
self,
|
||||
room_id: UUID,
|
||||
participant_id: str,
|
||||
allow_entry: bool,
|
||||
) -> None:
|
||||
"""Handle decision on participant entry.
|
||||
|
||||
Updates participant status based on allow_entry:
|
||||
- If accepted: ACCEPTED status with extended timeout matching LiveKit token
|
||||
- If denied: DENIED status with short timeout allowing status check and retry
|
||||
"""
|
||||
if allow_entry:
|
||||
decision = {
|
||||
"status": LobbyParticipantStatus.ACCEPTED,
|
||||
"timeout": settings.LOBBY_ACCEPTED_TIMEOUT,
|
||||
}
|
||||
else:
|
||||
decision = {
|
||||
"status": LobbyParticipantStatus.DENIED,
|
||||
"timeout": settings.LOBBY_DENIED_TIMEOUT,
|
||||
}
|
||||
|
||||
self._update_participant_status(room_id, participant_id, **decision)
|
||||
|
||||
def _update_participant_status(
|
||||
self,
|
||||
room_id: UUID,
|
||||
participant_id: str,
|
||||
status: LobbyParticipantStatus,
|
||||
timeout: int,
|
||||
) -> None:
|
||||
"""Update participant status with appropriate timeout."""
|
||||
|
||||
cache_key = self._get_cache_key(room_id, participant_id)
|
||||
|
||||
data = cache.get(cache_key)
|
||||
if not data:
|
||||
logger.error("Participant %s not found", participant_id)
|
||||
raise LobbyParticipantNotFound("Participant not found")
|
||||
|
||||
try:
|
||||
participant = LobbyParticipant.from_dict(data)
|
||||
except LobbyParticipantParsingError:
|
||||
logger.exception(
|
||||
"Removed corrupted data for participant %s:", participant_id
|
||||
)
|
||||
cache.delete(cache_key)
|
||||
raise
|
||||
|
||||
participant.status = status
|
||||
cache.set(cache_key, participant.to_dict(), timeout=timeout)
|
||||
|
||||
@async_to_sync
|
||||
async def notify_participants(self, room_id: UUID):
|
||||
"""Notify room participants about a new waiting participant using LiveKit.
|
||||
|
||||
Raises:
|
||||
LobbyNotificationError: If notification fails to send
|
||||
"""
|
||||
|
||||
notification_data = {
|
||||
"type": settings.LOBBY_NOTIFICATION_TYPE,
|
||||
}
|
||||
|
||||
lkapi = LiveKitAPI(**settings.LIVEKIT_CONFIGURATION)
|
||||
|
||||
try:
|
||||
room_response = await lkapi.room.list_rooms(
|
||||
ListRoomsRequest(
|
||||
names=[str(room_id)],
|
||||
)
|
||||
)
|
||||
|
||||
# Check if the room exists
|
||||
if not room_response.rooms:
|
||||
return
|
||||
|
||||
await lkapi.room.send_data(
|
||||
SendDataRequest(
|
||||
room=str(room_id),
|
||||
data=json.dumps(notification_data).encode("utf-8"),
|
||||
kind="RELIABLE",
|
||||
)
|
||||
)
|
||||
except TwirpError as e:
|
||||
logger.exception("Failed to notify room participants")
|
||||
raise LobbyNotificationError("Failed to notify room participants") from e
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
def clear_room_cache(self, room_id: UUID) -> None:
|
||||
"""Clear all participant entries from the cache for a specific room."""
|
||||
|
||||
pattern = self._get_cache_key(room_id, "*")
|
||||
keys = cache.keys(pattern)
|
||||
|
||||
if not keys:
|
||||
return
|
||||
|
||||
cache.delete_many(keys)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Room creation service."""
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
|
||||
class RoomCreation:
|
||||
"""Room creation related methods"""
|
||||
|
||||
@staticmethod
|
||||
def _get_cache_key(callback_id):
|
||||
"""Generate a standardized cache key for room creation callbacks."""
|
||||
return f"room-creation-callback_{callback_id}"
|
||||
|
||||
def persist_callback_state(self, callback_id: str, room) -> None:
|
||||
"""Store room data in cache using the callback ID as an identifier."""
|
||||
data = {
|
||||
"slug": room.slug,
|
||||
}
|
||||
cache.set(
|
||||
self._get_cache_key(callback_id),
|
||||
data,
|
||||
timeout=settings.ROOM_CREATION_CALLBACK_CACHE_TIMEOUT,
|
||||
)
|
||||
|
||||
def get_callback_state(self, callback_id: str) -> dict:
|
||||
"""Retrieve and clear cached room data for the given callback ID."""
|
||||
|
||||
cache_key = self._get_cache_key(callback_id)
|
||||
data = cache.get(cache_key)
|
||||
|
||||
if not data:
|
||||
return {}
|
||||
|
||||
cache.delete(cache_key)
|
||||
|
||||
return data
|
||||
@@ -1,14 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Generate Document</title>
|
||||
</head>
|
||||
<body>
|
||||
<h2>Generate Document</h2>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
{{ form.as_p }}
|
||||
<button type="submit">Generate PDF</button>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
@@ -9,7 +9,7 @@ import pytest
|
||||
from core import models
|
||||
from core.authentication.backends import OIDCAuthenticationBackend
|
||||
from core.factories import UserFactory
|
||||
from core.services import marketing_service
|
||||
from core.services import marketing
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -535,7 +535,7 @@ def test_marketing_signup_handles_service_initialization_errors(
|
||||
@pytest.mark.parametrize(
|
||||
"error",
|
||||
[
|
||||
marketing_service.ContactCreationError,
|
||||
marketing.ContactCreationError,
|
||||
ImproperlyConfigured,
|
||||
ImportError,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
Test event notification.
|
||||
"""
|
||||
|
||||
# pylint: disable=E1128,W0621,W0613,W0212
|
||||
|
||||
import smtplib
|
||||
from unittest import mock
|
||||
|
||||
from django.contrib.sites.models import Site
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories, models
|
||||
from core.recording.event.notification import NotificationService, notification_service
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mocked_current_site():
|
||||
"""Mocks the Site.objects.get_current()to return a controlled predefined domain."""
|
||||
|
||||
site_mock = mock.Mock()
|
||||
site_mock.domain = "test-domain.com"
|
||||
|
||||
with mock.patch.object(
|
||||
Site.objects, "get_current", return_value=site_mock
|
||||
) as patched:
|
||||
yield patched
|
||||
|
||||
|
||||
@mock.patch.object(NotificationService, "_notify_summary_service", return_value=True)
|
||||
def test_notify_external_services_transcript_mode(mock_notify_summary):
|
||||
"""Test notification routing for transcript mode recordings."""
|
||||
|
||||
service = NotificationService()
|
||||
|
||||
recording = factories.RecordingFactory(mode=models.RecordingModeChoices.TRANSCRIPT)
|
||||
result = service.notify_external_services(recording)
|
||||
|
||||
assert result is True
|
||||
mock_notify_summary.assert_called_once_with(recording)
|
||||
|
||||
|
||||
@mock.patch.object(NotificationService, "_notify_user_by_email", return_value=True)
|
||||
def test_notify_external_services_screen_recording_mode(mock_notify_email):
|
||||
"""Test notification routing for screen recording mode."""
|
||||
|
||||
service = NotificationService()
|
||||
|
||||
recording = factories.RecordingFactory(
|
||||
mode=models.RecordingModeChoices.SCREEN_RECORDING
|
||||
)
|
||||
|
||||
result = service.notify_external_services(recording)
|
||||
|
||||
assert result is True
|
||||
mock_notify_email.assert_called_once_with(recording)
|
||||
|
||||
|
||||
def test_notify_external_services_unknown_mode(caplog):
|
||||
"""Test notification for unknown recording mode."""
|
||||
recording = factories.RecordingFactory()
|
||||
|
||||
# Bypass validation
|
||||
recording.mode = "unknown"
|
||||
|
||||
service = NotificationService()
|
||||
|
||||
result = service.notify_external_services(recording)
|
||||
|
||||
assert result is False
|
||||
assert f"Unknown recording mode unknown for recording {recording.id}" in caplog.text
|
||||
|
||||
|
||||
def test_notify_user_by_email_success(mocked_current_site, settings):
|
||||
"""Test successful email notification to recording owners."""
|
||||
settings.EMAIL_BRAND_NAME = "ACME"
|
||||
settings.EMAIL_SUPPORT_EMAIL = "support@acme.com"
|
||||
settings.EMAIL_LOGO_IMG = "https://acme.com/logo"
|
||||
settings.SCREEN_RECORDING_BASE_URL = "https://acme.com/recordings"
|
||||
settings.EMAIL_FROM = "notifications@acme.com"
|
||||
|
||||
recording = factories.RecordingFactory(room__name="Conference Room A")
|
||||
|
||||
owners = [
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER
|
||||
).user,
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER
|
||||
).user,
|
||||
]
|
||||
owner_emails = [owner.email for owner in owners]
|
||||
|
||||
# Create non-owner users to verify they don't receive emails
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.MEMBER
|
||||
)
|
||||
factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.ADMIN
|
||||
)
|
||||
|
||||
notification_service = NotificationService()
|
||||
|
||||
with mock.patch("core.recording.event.notification.send_mail") as mock_send_mail:
|
||||
result = notification_service._notify_user_by_email(recording)
|
||||
|
||||
assert result is True
|
||||
mock_send_mail.assert_called_once()
|
||||
|
||||
subject, body, sender, recipients = mock_send_mail.call_args[0]
|
||||
|
||||
assert subject == "Your recording is ready"
|
||||
|
||||
# Verify email contains expected content
|
||||
required_content = [
|
||||
"ACME", # Brand name
|
||||
"support@acme.com", # Support email
|
||||
"https://acme.com/logo", # Logo URL
|
||||
f"https://acme.com/recordings/{recording.id}", # Recording link
|
||||
"Conference Room A", # Room name
|
||||
recording.created_at.strftime("%A %d %B %Y"), # Formatted date
|
||||
recording.created_at.strftime("%H:%M"), # Formatted time
|
||||
]
|
||||
|
||||
for content in required_content:
|
||||
assert content in body
|
||||
|
||||
assert sender == "notifications@acme.com"
|
||||
|
||||
# Verify all owners received the email (order-independent comparison)
|
||||
assert sorted(recipients) == sorted(owner_emails)
|
||||
|
||||
|
||||
def test_notify_user_by_email_no_owners(mocked_current_site, caplog):
|
||||
"""Test email notification when no owners are found."""
|
||||
|
||||
# Recording with no access
|
||||
recording = factories.RecordingFactory()
|
||||
|
||||
result = notification_service._notify_user_by_email(recording)
|
||||
|
||||
assert result is False
|
||||
assert f"No owner found for recording {recording.id}" in caplog.text
|
||||
|
||||
|
||||
def test_notify_user_by_email_smtp_exception(mocked_current_site, caplog):
|
||||
"""Test email notification when an exception occurs."""
|
||||
|
||||
recording = factories.RecordingFactory(room__name="Conference Room A")
|
||||
owner = factories.UserRecordingAccessFactory(
|
||||
recording=recording, role=models.RoleChoices.OWNER
|
||||
).user
|
||||
|
||||
notification_service = NotificationService()
|
||||
|
||||
with mock.patch(
|
||||
"core.recording.event.notification.send_mail",
|
||||
side_effect=smtplib.SMTPException("SMTP Error"),
|
||||
) as mock_send_mail:
|
||||
result = notification_service._notify_user_by_email(recording)
|
||||
|
||||
assert result is False
|
||||
mock_send_mail.assert_called_once()
|
||||
assert f"notification to ['{owner.email}'] was not sent" in caplog.text
|
||||
@@ -22,6 +22,27 @@ def test_api_recordings_list_anonymous():
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_recordings_list_authenticated_no_recording():
|
||||
"""
|
||||
Authenticated users listing recordings should only
|
||||
see recordings to which they have access.
|
||||
"""
|
||||
user = factories.UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
other_user = factories.UserFactory()
|
||||
factories.UserRecordingAccessFactory(user=other_user)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert results == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
["administrator", "member", "owner"],
|
||||
@@ -58,9 +79,10 @@ def test_api_recordings_list_authenticated_direct(role):
|
||||
assert results[0] == {
|
||||
"id": str(recording.id),
|
||||
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"mode": recording.mode,
|
||||
"room": {
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_public": room.is_public,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
"""
|
||||
Test media-auth authorization API endpoint in docs core app.
|
||||
"""
|
||||
|
||||
from io import BytesIO
|
||||
from urllib.parse import urlparse
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.storage import default_storage
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import models
|
||||
from core.factories import RecordingFactory, UserFactory, UserRecordingAccessFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_documents_media_auth_unauthenticated():
|
||||
"""
|
||||
Test that unauthenticated requests to download media are rejected
|
||||
"""
|
||||
original_url = f"http://localhost/media/recordings/{uuid4()!s}.mp4"
|
||||
|
||||
response = APIClient().get(
|
||||
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_api_documents_media_auth_wrong_path():
|
||||
"""
|
||||
Test that media URLs with incorrect path structures are rejected
|
||||
"""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
original_url = f"http://localhost/media/wrong-path/{uuid4()!s}.mp4"
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_api_documents_media_auth_unknown_recording():
|
||||
"""
|
||||
Test that requests for non-existent recordings are properly handled
|
||||
"""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
original_url = f"http://localhost/media/recordings/{uuid4()!s}.mp4"
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_api_documents_media_auth_no_abilities():
|
||||
"""
|
||||
Test that users without any access permissions cannot download recordings
|
||||
"""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED)
|
||||
original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4"
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_api_documents_media_auth_wrong_abilities():
|
||||
"""
|
||||
Test that users with insufficient role permissions cannot download recordings
|
||||
"""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED)
|
||||
|
||||
UserRecordingAccessFactory(user=user, recording=recording, role="member")
|
||||
|
||||
original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4"
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize("wrong_status", ["initiated", "active", "failed_to_stop"])
|
||||
def test_api_documents_media_auth_unsaved(wrong_status):
|
||||
"""
|
||||
Test that recordings that aren't in 'saved' status cannot be downloaded
|
||||
"""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
recording = RecordingFactory(status=wrong_status)
|
||||
UserRecordingAccessFactory(user=user, recording=recording, role="owner")
|
||||
|
||||
original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4"
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_api_documents_media_auth_mismatched_extension():
|
||||
"""
|
||||
Test that requests with mismatched file extensions are rejected
|
||||
"""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
recording = RecordingFactory(
|
||||
status=models.RecordingStatusChoices.SAVED,
|
||||
mode=models.RecordingModeChoices.TRANSCRIPT,
|
||||
)
|
||||
UserRecordingAccessFactory(user=user, recording=recording, role="owner")
|
||||
|
||||
original_url = f"http://localhost/media/recordings/{recording.id!s}.mp4"
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "No recording found with this extension."}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"wrong_extension", ["jpg", "txt", "mp3"], ids=["image", "text", "audio"]
|
||||
)
|
||||
def test_api_documents_media_auth_wrong_extension(wrong_extension):
|
||||
"""
|
||||
Trying to download a recording with an unsupported extension should return
|
||||
a validation error (400) with details about allowed extensions.
|
||||
"""
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED)
|
||||
UserRecordingAccessFactory(user=user, recording=recording, role="owner")
|
||||
|
||||
original_url = (
|
||||
f"http://localhost/media/recordings/{recording.id!s}.{wrong_extension}"
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"detail": "Unsupported extension."}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["screen_recording", "transcript"])
|
||||
def test_api_documents_media_auth_success_owner(mode):
|
||||
"""
|
||||
Test downloading a recording when logged in and authorized.
|
||||
Verifies S3 authentication headers and successful file retrieval.
|
||||
"""
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED, mode=mode)
|
||||
UserRecordingAccessFactory(user=user, recording=recording, role="owner")
|
||||
|
||||
default_storage.connection.meta.client.put_object(
|
||||
Bucket=default_storage.bucket_name,
|
||||
Key=recording.key,
|
||||
Body=BytesIO(b"my prose"),
|
||||
ContentType="text/plain",
|
||||
)
|
||||
|
||||
original_url = f"http://localhost/media/{recording.key:s}"
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
authorization = response["Authorization"]
|
||||
assert "AWS4-HMAC-SHA256 Credential=" in authorization
|
||||
assert (
|
||||
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
||||
in authorization
|
||||
)
|
||||
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
||||
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/meet-media-storage/{recording.key:s}"
|
||||
response = requests.get(
|
||||
file_url,
|
||||
headers={
|
||||
"authorization": authorization,
|
||||
"x-amz-date": response["x-amz-date"],
|
||||
"x-amz-content-sha256": response["x-amz-content-sha256"],
|
||||
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
|
||||
},
|
||||
timeout=1,
|
||||
)
|
||||
assert response.content.decode("utf-8") == "my prose"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["screen_recording", "transcript"])
|
||||
def test_api_documents_media_auth_success_administrator(mode):
|
||||
"""
|
||||
Test downloading a recording when logged in and authorized.
|
||||
Verifies S3 authentication headers and successful file retrieval.
|
||||
"""
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
recording = RecordingFactory(status=models.RecordingStatusChoices.SAVED, mode=mode)
|
||||
UserRecordingAccessFactory(user=user, recording=recording, role="administrator")
|
||||
|
||||
default_storage.connection.meta.client.put_object(
|
||||
Bucket=default_storage.bucket_name,
|
||||
Key=recording.key,
|
||||
Body=BytesIO(b"my prose"),
|
||||
ContentType="text/plain",
|
||||
)
|
||||
|
||||
original_url = f"http://localhost/media/{recording.key:s}"
|
||||
response = client.get(
|
||||
"/api/v1.0/recordings/media-auth/", HTTP_X_ORIGINAL_URL=original_url
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
authorization = response["Authorization"]
|
||||
assert "AWS4-HMAC-SHA256 Credential=" in authorization
|
||||
assert (
|
||||
"SignedHeaders=host;x-amz-content-sha256;x-amz-date, Signature="
|
||||
in authorization
|
||||
)
|
||||
assert response["X-Amz-Date"] == timezone.now().strftime("%Y%m%dT%H%M%SZ")
|
||||
|
||||
s3_url = urlparse(settings.AWS_S3_ENDPOINT_URL)
|
||||
file_url = f"{settings.AWS_S3_ENDPOINT_URL:s}/meet-media-storage/{recording.key:s}"
|
||||
response = requests.get(
|
||||
file_url,
|
||||
headers={
|
||||
"authorization": authorization,
|
||||
"x-amz-date": response["x-amz-date"],
|
||||
"x-amz-content-sha256": response["x-amz-content-sha256"],
|
||||
"Host": f"{s3_url.hostname:s}:{s3_url.port:d}",
|
||||
},
|
||||
timeout=1,
|
||||
)
|
||||
assert response.content.decode("utf-8") == "my prose"
|
||||
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
Test recordings API endpoints in the Meet core app: retrieve.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RecordingFactory, UserFactory, UserRecordingAccessFactory
|
||||
from ...models import RecordingStatusChoices
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_recording_retrieve_anonymous():
|
||||
"""Anonymous users should not be able to retrieve recordings."""
|
||||
recording = RecordingFactory()
|
||||
client = APIClient()
|
||||
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"detail": "Authentication credentials were not provided."
|
||||
}
|
||||
|
||||
|
||||
def test_api_recording_retrieve_authenticated():
|
||||
"""Authenticated users without access receive 404 when requesting recordings.
|
||||
|
||||
The API returns 404 instead of 403 to avoid revealing the existence of
|
||||
resources the user doesn't have permission to access.
|
||||
"""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
other_user = UserFactory()
|
||||
recording = UserRecordingAccessFactory(user=other_user).recording
|
||||
|
||||
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "No Recording matches the given query."}
|
||||
|
||||
|
||||
def test_api_recording_retrieve_members():
|
||||
"""
|
||||
A user who is a member of a recording should not be able to retrieve it.
|
||||
"""
|
||||
user = UserFactory()
|
||||
recording = RecordingFactory()
|
||||
|
||||
UserRecordingAccessFactory(recording=recording, user=user, role="member")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {
|
||||
"detail": "You do not have permission to perform this action."
|
||||
}
|
||||
|
||||
|
||||
def test_api_recording_retrieve_administrators():
|
||||
"""A user who is an administrator of a recording should be able to retrieve it."""
|
||||
|
||||
user = UserFactory()
|
||||
recording = RecordingFactory()
|
||||
|
||||
UserRecordingAccessFactory(recording=recording, user=user, role="administrator")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
room = recording.room
|
||||
|
||||
assert content == {
|
||||
"id": str(recording.id),
|
||||
"room": {
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
},
|
||||
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"status": str(recording.status),
|
||||
"mode": str(recording.mode),
|
||||
}
|
||||
|
||||
|
||||
def test_api_recording_retrieve_owners():
|
||||
"""A user who is an owner of a recording should be able to retrieve it."""
|
||||
user = UserFactory()
|
||||
recording = RecordingFactory()
|
||||
|
||||
UserRecordingAccessFactory(recording=recording, user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
room = recording.room
|
||||
|
||||
assert content == {
|
||||
"id": str(recording.id),
|
||||
"room": {
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
},
|
||||
"created_at": recording.created_at.isoformat().replace("+00:00", "Z"),
|
||||
"updated_at": recording.updated_at.isoformat().replace("+00:00", "Z"),
|
||||
"status": str(recording.status),
|
||||
"mode": str(recording.mode),
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status",
|
||||
[
|
||||
RecordingStatusChoices.INITIATED,
|
||||
RecordingStatusChoices.ACTIVE,
|
||||
RecordingStatusChoices.SAVED,
|
||||
RecordingStatusChoices.FAILED_TO_START,
|
||||
RecordingStatusChoices.FAILED_TO_STOP,
|
||||
RecordingStatusChoices.ABORTED,
|
||||
],
|
||||
)
|
||||
def test_api_recording_retrieve_any_status(status):
|
||||
"""Test that recordings with any status can be retrieved."""
|
||||
user = UserFactory()
|
||||
recording = RecordingFactory(status=status)
|
||||
|
||||
UserRecordingAccessFactory(recording=recording, user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/recordings/{recording.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert content["id"] == str(recording.id)
|
||||
assert content["status"] == status
|
||||
@@ -2,6 +2,9 @@
|
||||
Test rooms API endpoints in the Meet core app: create.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621,W0613
|
||||
from django.core.cache import cache
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
@@ -11,6 +14,15 @@ from ...models import Room
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reset_cache():
|
||||
"""Provide cache cleanup after each test to maintain test isolation."""
|
||||
yield
|
||||
keys = cache.keys("room-creation-callback_*")
|
||||
if keys:
|
||||
cache.delete(*keys)
|
||||
|
||||
|
||||
def test_api_rooms_create_anonymous():
|
||||
"""Anonymous users should not be allowed to create rooms."""
|
||||
client = APIClient()
|
||||
@@ -26,7 +38,7 @@ def test_api_rooms_create_anonymous():
|
||||
assert Room.objects.exists() is False
|
||||
|
||||
|
||||
def test_api_rooms_create_authenticated():
|
||||
def test_api_rooms_create_authenticated(reset_cache):
|
||||
"""
|
||||
Authenticated users should be able to create rooms and should automatically be declared
|
||||
as owner of the newly created room.
|
||||
@@ -49,6 +61,33 @@ def test_api_rooms_create_authenticated():
|
||||
assert room.slug == "my-room"
|
||||
assert room.accesses.filter(role="owner", user=user).exists() is True
|
||||
|
||||
rooms_data = cache.keys("room-creation-callback_*")
|
||||
assert not rooms_data
|
||||
|
||||
|
||||
def test_api_rooms_create_generation_cache(reset_cache):
|
||||
"""
|
||||
Authenticated users creating a room with a callback ID should have room data stored in cache.
|
||||
"""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/",
|
||||
{"name": "my room", "callback_id": "1234"},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get()
|
||||
assert room.name == "my room"
|
||||
assert room.slug == "my-room"
|
||||
assert room.accesses.filter(role="owner", user=user).exists() is True
|
||||
|
||||
room_data = cache.get("room-creation-callback_1234")
|
||||
assert room_data.get("slug") == "my-room"
|
||||
|
||||
|
||||
def test_api_rooms_create_authenticated_existing_slug():
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
Test rooms API endpoints in the Meet core app: creation callback functionality.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621,W0613
|
||||
from django.core.cache import cache
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import UserFactory
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
# Tests for creation_callback endpoint
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reset_cache():
|
||||
"""Provide cache cleanup after each test to maintain test isolation."""
|
||||
yield
|
||||
keys = cache.keys("room-creation-callback_*")
|
||||
if keys:
|
||||
cache.delete(*keys)
|
||||
|
||||
|
||||
def test_api_rooms_create_anonymous(reset_cache):
|
||||
"""Anonymous user can retrieve room data once using a valid callback ID."""
|
||||
client = APIClient()
|
||||
cache.set("room-creation-callback_123", {"slug": "my room"})
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/creation-callback/",
|
||||
{
|
||||
"callback_id": "123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "success", "room": {"slug": "my room"}}
|
||||
|
||||
# Data should be cleared after retrieval
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/creation-callback/",
|
||||
{
|
||||
"callback_id": "123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "success", "room": {}}
|
||||
|
||||
|
||||
def test_api_rooms_create_empty_cache():
|
||||
"""Valid callback ID return empty room data when nothing is stored in the cache."""
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/creation-callback/",
|
||||
{
|
||||
"callback_id": "123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "success", "room": {}}
|
||||
|
||||
|
||||
def test_api_rooms_create_missing_callback_id():
|
||||
"""Requests without a callback ID properly fail with a 400 status code."""
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/creation-callback/",
|
||||
{},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_api_rooms_create_authenticated(reset_cache):
|
||||
"""Authenticated users can also successfully retrieve room data using a valid callback ID"""
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
cache.set("room-creation-callback_123", {"slug": "my room"})
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/creation-callback/",
|
||||
{
|
||||
"callback_id": "123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "success", "room": {"slug": "my room"}}
|
||||
@@ -9,14 +9,16 @@ from rest_framework.pagination import PageNumberPagination
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
from ...models import RoomAccessLevel
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_rooms_list_anonymous():
|
||||
"""Anonymous users should not be able to list rooms."""
|
||||
RoomFactory(is_public=False)
|
||||
RoomFactory(is_public=True)
|
||||
RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
client = APIClient()
|
||||
|
||||
@@ -38,10 +40,13 @@ def test_api_rooms_list_authenticated():
|
||||
|
||||
other_user = UserFactory()
|
||||
|
||||
RoomFactory(is_public=False)
|
||||
RoomFactory(is_public=True)
|
||||
room_user_accesses = RoomFactory(is_public=False, users=[user])
|
||||
RoomFactory(is_public=False, users=[other_user])
|
||||
RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
room_user_accesses = RoomFactory(
|
||||
access_level=RoomAccessLevel.RESTRICTED, users=[user]
|
||||
)
|
||||
RoomFactory(access_level=RoomAccessLevel.RESTRICTED, users=[other_user])
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/rooms/",
|
||||
@@ -105,7 +110,7 @@ def test_api_rooms_list_authenticated_distinct():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
room = RoomFactory(is_public=True, users=[user, other_user])
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC, users=[user, other_user])
|
||||
|
||||
response = client.get(
|
||||
"/api/v1.0/rooms/",
|
||||
|
||||
@@ -0,0 +1,624 @@
|
||||
"""
|
||||
Test rooms API endpoints in the Meet core app: lobby functionality.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621,W0613,W0212
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
from django.core.cache import cache
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ... import utils
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
from ...models import RoomAccessLevel
|
||||
from ...services.lobby import (
|
||||
LobbyService,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
# Tests for request_entry endpoint
|
||||
|
||||
|
||||
def test_request_entry_anonymous(settings):
|
||||
"""Anonymous users should be allowed to request entry to a room."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Lobby cache should be empty before the request
|
||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||
assert not lobby_keys
|
||||
|
||||
with (
|
||||
mock.patch.object(LobbyService, "notify_participants", return_value=None),
|
||||
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||
):
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify the lobby cookie was properly set
|
||||
cookie = response.cookies.get("mocked-cookie")
|
||||
assert cookie is not None
|
||||
|
||||
participant_id = cookie.value
|
||||
|
||||
# Verify response content matches expected structure and values
|
||||
assert response.json() == {
|
||||
"id": participant_id,
|
||||
"username": "test_user",
|
||||
"status": "waiting",
|
||||
"color": "mocked-color",
|
||||
"livekit": None,
|
||||
}
|
||||
|
||||
# Verify a participant was stored in cache
|
||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||
assert len(lobby_keys) == 1
|
||||
|
||||
# Verify participant data was correctly stored in cache
|
||||
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{participant_id}")
|
||||
assert participant_data.get("username") == "test_user"
|
||||
|
||||
|
||||
def test_request_entry_authenticated_user(settings):
|
||||
"""Authenticated users should be allowed to request entry."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Lobby cache should be empty before the request
|
||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||
assert not lobby_keys
|
||||
|
||||
with (
|
||||
mock.patch.object(LobbyService, "notify_participants", return_value=None),
|
||||
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||
):
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify the lobby cookie was properly set
|
||||
cookie = response.cookies.get("mocked-cookie")
|
||||
assert cookie is not None
|
||||
|
||||
participant_id = cookie.value
|
||||
|
||||
# Verify response content matches expected structure and values
|
||||
assert response.json() == {
|
||||
"id": participant_id,
|
||||
"username": "test_user",
|
||||
"status": "waiting",
|
||||
"color": "mocked-color",
|
||||
"livekit": None,
|
||||
}
|
||||
|
||||
# Verify a participant was stored in cache
|
||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||
assert len(lobby_keys) == 1
|
||||
|
||||
# Verify participant data was correctly stored in cache
|
||||
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{participant_id}")
|
||||
assert participant_data.get("username") == "test_user"
|
||||
|
||||
|
||||
def test_request_entry_with_existing_participants(settings):
|
||||
"""Anonymous users should be allowed to request entry to a room with existing participants."""
|
||||
# Create a restricted access room
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
|
||||
# Configure test settings for cookies and cache
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Add two participants already waiting in the lobby
|
||||
cache.set(
|
||||
f"mocked-cache-prefix_{room.id}_participant1",
|
||||
{
|
||||
"id": "participant1",
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
},
|
||||
)
|
||||
cache.set(
|
||||
f"mocked-cache-prefix_{room.id}_participant2",
|
||||
{
|
||||
"id": "participant2",
|
||||
"username": "user2",
|
||||
"status": "accepted",
|
||||
"color": "#654321",
|
||||
},
|
||||
)
|
||||
|
||||
# Verify two participants are in the lobby before the request
|
||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||
assert len(lobby_keys) == 2
|
||||
|
||||
# Mock external service calls to isolate the test
|
||||
with (
|
||||
mock.patch.object(LobbyService, "notify_participants", return_value=None),
|
||||
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||
):
|
||||
# Make request as a new anonymous user
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
)
|
||||
|
||||
# Verify successful response
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify the lobby cookie was properly set for the new participant
|
||||
cookie = response.cookies.get("mocked-cookie")
|
||||
assert cookie is not None
|
||||
|
||||
participant_id = cookie.value
|
||||
|
||||
# Verify response content matches expected structure and values
|
||||
assert response.json() == {
|
||||
"id": participant_id,
|
||||
"username": "test_user",
|
||||
"status": "waiting",
|
||||
"color": "mocked-color",
|
||||
"livekit": None,
|
||||
}
|
||||
|
||||
# Verify now three participants are in the lobby cache
|
||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||
assert len(lobby_keys) == 3
|
||||
|
||||
# Verify the new participant data was correctly stored in cache
|
||||
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_{participant_id}")
|
||||
assert participant_data.get("username") == "test_user"
|
||||
|
||||
|
||||
def test_request_entry_public_room(settings):
|
||||
"""Entry requests to public rooms should return ACCEPTED status with LiveKit config."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
client = APIClient()
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Lobby cache should be empty before the request
|
||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||
assert not lobby_keys
|
||||
|
||||
with (
|
||||
mock.patch.object(LobbyService, "notify_participants", return_value=None),
|
||||
mock.patch.object(
|
||||
LobbyService, "_get_or_create_participant_id", return_value="123"
|
||||
),
|
||||
mock.patch.object(
|
||||
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
||||
),
|
||||
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||
):
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify the lobby cookie was set
|
||||
cookie = response.cookies.get("mocked-cookie")
|
||||
assert cookie is not None
|
||||
assert cookie.value == "123"
|
||||
|
||||
# Verify response content matches expected structure and values
|
||||
assert response.json() == {
|
||||
"id": "123",
|
||||
"username": "test_user",
|
||||
"status": "accepted",
|
||||
"color": "mocked-color",
|
||||
"livekit": {"token": "test-token"},
|
||||
}
|
||||
|
||||
# Verify lobby cache is still empty after the request
|
||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||
assert not lobby_keys
|
||||
|
||||
|
||||
def test_request_entry_authenticated_user_public_room(settings):
|
||||
"""While authenticated, entry request to public rooms should get accepted."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Lobby cache should be empty before the request
|
||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||
assert not lobby_keys
|
||||
|
||||
with (
|
||||
mock.patch.object(LobbyService, "notify_participants", return_value=None),
|
||||
mock.patch.object(
|
||||
LobbyService, "_get_or_create_participant_id", return_value="123"
|
||||
),
|
||||
mock.patch.object(
|
||||
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
||||
),
|
||||
mock.patch.object(utils, "generate_color", return_value="mocked-color"),
|
||||
):
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "test_user"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify the lobby cookie was set
|
||||
cookie = response.cookies.get("mocked-cookie")
|
||||
assert cookie is not None
|
||||
assert cookie.value == "123"
|
||||
|
||||
# Verify response content matches expected structure and values
|
||||
assert response.json() == {
|
||||
"id": "123",
|
||||
"username": "test_user",
|
||||
"status": "accepted",
|
||||
"color": "mocked-color",
|
||||
"livekit": {"token": "test-token"},
|
||||
}
|
||||
|
||||
# Verify lobby cache is still empty after the request
|
||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||
assert not lobby_keys
|
||||
|
||||
|
||||
def test_request_entry_waiting_participant_public_room(settings):
|
||||
"""While waiting, entry request to public rooms should get accepted."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
client = APIClient()
|
||||
|
||||
settings.LOBBY_COOKIE_NAME = "mocked-cookie"
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Add a waiting participant to the room's lobby cache
|
||||
cache.set(
|
||||
f"mocked-cache-prefix_{room.id}_participant1",
|
||||
{
|
||||
"id": "participant1",
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
},
|
||||
)
|
||||
|
||||
# Simulate a browser with existing participant cookie
|
||||
client.cookies.load({"mocked-cookie": "participant1"})
|
||||
|
||||
with (
|
||||
mock.patch.object(LobbyService, "notify_participants", return_value=None),
|
||||
mock.patch.object(
|
||||
utils, "generate_livekit_config", return_value={"token": "test-token"}
|
||||
),
|
||||
):
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{"username": "user1"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify the lobby cookie was set
|
||||
cookie = response.cookies.get("mocked-cookie")
|
||||
assert cookie is not None
|
||||
assert cookie.value == "participant1"
|
||||
|
||||
# Verify response content matches expected structure and values
|
||||
assert response.json() == {
|
||||
"id": "participant1",
|
||||
"username": "user1",
|
||||
"status": "accepted",
|
||||
"color": "#123456",
|
||||
"livekit": {"token": "test-token"},
|
||||
}
|
||||
|
||||
# Verify participant remains in the lobby cache after acceptance
|
||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||
assert len(lobby_keys) == 1
|
||||
|
||||
|
||||
def test_request_entry_invalid_data():
|
||||
"""Should return 400 for invalid request data."""
|
||||
room = RoomFactory()
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/request-entry/",
|
||||
{}, # Missing required username field
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_request_entry_room_not_found():
|
||||
"""Should return 404 for non-existent room."""
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{uuid.uuid4()!s}/request-entry/",
|
||||
{"username": "anonymous"},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
# Tests for allow_participant_to_enter endpoint
|
||||
|
||||
|
||||
def test_allow_participant_to_enter_anonymous():
|
||||
"""Anonymous users should not be allowed to manage entry requests."""
|
||||
room = RoomFactory()
|
||||
client = APIClient()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/enter/",
|
||||
{"participant_id": "test-id", "allow_entry": True},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_allow_participant_to_enter_non_owner():
|
||||
"""Non-privileged users should not be allowed to manage entry requests."""
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/enter/",
|
||||
{"participant_id": "test-id", "allow_entry": True},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_allow_participant_to_enter_public_room():
|
||||
"""Should return 404 for public rooms that don't use the lobby system."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
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}/enter/",
|
||||
{"participant_id": "test-id", "allow_entry": True},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"message": "Room has no lobby system."}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"allow_entry, updated_status", [(True, "accepted"), (False, "denied")]
|
||||
)
|
||||
def test_allow_participant_to_enter_success(settings, allow_entry, updated_status):
|
||||
"""Should successfully update participant status when everything is correct."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
user = UserFactory()
|
||||
# Make user the room owner
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
cache.set(
|
||||
f"mocked-cache-prefix_{room.id!s}_participant1",
|
||||
{
|
||||
"id": "test-id",
|
||||
"status": "waiting",
|
||||
"username": "foo",
|
||||
"color": "123",
|
||||
},
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/enter/",
|
||||
{"participant_id": "participant1", "allow_entry": allow_entry},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"message": "Participant was updated."}
|
||||
|
||||
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_participant1")
|
||||
assert participant_data.get("status") == updated_status
|
||||
|
||||
|
||||
def test_allow_participant_to_enter_participant_not_found(settings):
|
||||
"""Should handle case when participant is not found."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
user = UserFactory()
|
||||
# Make user the room owner
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
participant_data = cache.get(f"mocked-cache-prefix_{room.id!s}_test-id")
|
||||
assert participant_data is None
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/enter/",
|
||||
{"participant_id": "test-id", "allow_entry": True},
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"message": "Participant not found."}
|
||||
|
||||
|
||||
def test_allow_participant_to_enter_invalid_data():
|
||||
"""Should return 400 for invalid request data."""
|
||||
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}/enter/",
|
||||
{}, # Missing required fields
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
# Tests for list_waiting_participants endpoint
|
||||
|
||||
|
||||
def test_list_waiting_participants_anonymous():
|
||||
"""Anonymous users should not be allowed to list waiting participants."""
|
||||
room = RoomFactory()
|
||||
client = APIClient()
|
||||
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_list_waiting_participants_non_owner():
|
||||
"""Non-privileged users should not be allowed to list waiting participants."""
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_list_waiting_participants_public_room():
|
||||
"""Should return empty list for public rooms."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
user = UserFactory()
|
||||
# Make user the room owner
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
# Lobby cache should be empty before the request
|
||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||
assert not lobby_keys
|
||||
|
||||
with mock.patch(
|
||||
"core.api.viewsets.LobbyService", autospec=True
|
||||
) as mocked_lobby_service:
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
|
||||
|
||||
# Verify lobby service was not instantiated
|
||||
mocked_lobby_service.assert_not_called()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"participants": []}
|
||||
|
||||
|
||||
def test_list_waiting_participants_success(settings):
|
||||
"""Should successfully return list of waiting participants."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
user = UserFactory()
|
||||
# Make user the room owner
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Add participants in the lobby
|
||||
cache.set(
|
||||
f"mocked-cache-prefix_{room.id}_participant1",
|
||||
{
|
||||
"id": "participant1",
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
},
|
||||
)
|
||||
cache.set(
|
||||
f"mocked-cache-prefix_{room.id}_participant2",
|
||||
{
|
||||
"id": "participant2",
|
||||
"username": "user2",
|
||||
"status": "waiting",
|
||||
"color": "#654321",
|
||||
},
|
||||
)
|
||||
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
participants = response.json().get("participants")
|
||||
assert sorted(participants, key=lambda p: p["id"]) == [
|
||||
{
|
||||
"id": "participant1",
|
||||
"username": "user1",
|
||||
"status": "waiting",
|
||||
"color": "#123456",
|
||||
},
|
||||
{
|
||||
"id": "participant2",
|
||||
"username": "user2",
|
||||
"status": "waiting",
|
||||
"color": "#654321",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_list_waiting_participants_empty(settings):
|
||||
"""Should handle case when there are no waiting participants."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
user = UserFactory()
|
||||
# Make user the room owner
|
||||
room.accesses.create(user=user, role="owner")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix"
|
||||
|
||||
# Lobby cache should be empty before the request
|
||||
lobby_keys = cache.keys(f"mocked-cache-prefix_{room.id}_*")
|
||||
assert not lobby_keys
|
||||
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id}/waiting-participants/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"participants": []}
|
||||
@@ -12,6 +12,7 @@ import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||
from ...models import RoomAccessLevel
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -21,15 +22,34 @@ def test_api_rooms_retrieve_anonymous_private_pk():
|
||||
Anonymous users should be allowed to retrieve a private room but should not be
|
||||
given any token.
|
||||
"""
|
||||
room = RoomFactory(is_public=False)
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_anonymous_trusted_pk():
|
||||
"""
|
||||
Anonymous users should be allowed to retrieve a room that has a trusted access_level,
|
||||
but should not be given any token.
|
||||
"""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
client = APIClient()
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"access_level": "trusted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"is_public": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -37,7 +57,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)
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
id_no_dashes = str(room.id)
|
||||
|
||||
client = APIClient()
|
||||
@@ -45,9 +65,9 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"is_public": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -55,15 +75,15 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
|
||||
|
||||
def test_api_rooms_retrieve_anonymous_private_slug():
|
||||
"""It should be possible to get a room by its slug."""
|
||||
room = RoomFactory(is_public=False)
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
response = client.get(f"/api/v1.0/rooms/{room.slug!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"is_public": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -71,15 +91,15 @@ def test_api_rooms_retrieve_anonymous_private_slug():
|
||||
|
||||
def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
|
||||
"""Getting a room by a slug that is not normalized should work."""
|
||||
room = RoomFactory(name="Réunion", is_public=False)
|
||||
room = RoomFactory(name="Réunion", access_level=RoomAccessLevel.RESTRICTED)
|
||||
client = APIClient()
|
||||
response = client.get("/api/v1.0/rooms/Réunion/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"is_public": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -171,18 +191,18 @@ def test_api_rooms_retrieve_anonymous_unregistered_not_allowed():
|
||||
)
|
||||
def test_api_rooms_retrieve_anonymous_public(mock_token):
|
||||
"""
|
||||
Anonymous users should be able to retrieve a room with a token provided it is public.
|
||||
Anonymous users should be able to retrieve a room with a token provided, if the room is public.
|
||||
"""
|
||||
room = RoomFactory(is_public=True)
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
client = APIClient()
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
expected_name = f"{room.id!s}"
|
||||
assert response.json() == {
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"is_public": True,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
@@ -209,7 +229,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
which they are not related, provided the room is public.
|
||||
They should not see related users.
|
||||
"""
|
||||
room = RoomFactory(is_public=True)
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
@@ -222,9 +242,9 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
|
||||
expected_name = f"{room.id!s}"
|
||||
assert response.json() == {
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"is_public": True,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
@@ -234,7 +254,53 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(room=expected_name, user=user, username=None)
|
||||
mock_token.assert_called_once_with(
|
||||
room=expected_name, user=user, username=None, color=None
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_token", return_value="foo")
|
||||
@override_settings(
|
||||
LIVEKIT_CONFIGURATION={
|
||||
"api_key": "key",
|
||||
"api_secret": "secret",
|
||||
"url": "test_url_value",
|
||||
}
|
||||
)
|
||||
def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
||||
"""
|
||||
Authenticated users should be allowed to retrieve a room and get a token for a room to
|
||||
which they are not related, provided the room has a trusted access_level.
|
||||
They should not see related users.
|
||||
"""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
expected_name = f"{room.id!s}"
|
||||
assert response.json() == {
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
"token": "foo",
|
||||
},
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
|
||||
mock_token.assert_called_once_with(
|
||||
room=expected_name, user=user, username=None, color=None
|
||||
)
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_authenticated():
|
||||
@@ -242,7 +308,7 @@ def test_api_rooms_retrieve_authenticated():
|
||||
Authenticated users should be allowed to retrieve a private room to which they
|
||||
are not related but should not be given any token.
|
||||
"""
|
||||
room = RoomFactory(is_public=False)
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
@@ -254,9 +320,9 @@ def test_api_rooms_retrieve_authenticated():
|
||||
assert response.status_code == 200
|
||||
|
||||
assert response.json() == {
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"is_public": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -324,9 +390,9 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries):
|
||||
|
||||
expected_name = str(room.id)
|
||||
assert content_dict == {
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"is_public": room.is_public,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
@@ -336,7 +402,9 @@ 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, username=None)
|
||||
mock_token.assert_called_once_with(
|
||||
room=expected_name, user=user, username=None, color=None
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_token", return_value="foo")
|
||||
@@ -400,9 +468,9 @@ def test_api_rooms_retrieve_administrators(mock_token, django_assert_num_queries
|
||||
)
|
||||
expected_name = str(room.id)
|
||||
assert content_dict == {
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": True,
|
||||
"is_public": room.is_public,
|
||||
"configuration": {},
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
@@ -413,4 +481,6 @@ 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, username=None)
|
||||
mock_token.assert_called_once_with(
|
||||
room=expected_name, user=user, username=None, color=None
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
from ...models import RoomAccessLevel
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -54,17 +55,18 @@ def test_api_rooms_update_members():
|
||||
not be allowed to update it.
|
||||
"""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(name="Old name", users=[(user, "member")])
|
||||
room = RoomFactory(
|
||||
access_level=RoomAccessLevel.PUBLIC, name="Old name", users=[(user, "member")]
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
new_is_public = not room.is_public
|
||||
response = client.put(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
{
|
||||
"name": "New name",
|
||||
"slug": "should-be-ignored",
|
||||
"is_public": new_is_public,
|
||||
"access_level": RoomAccessLevel.RESTRICTED,
|
||||
"configuration": {"the_key": "the_value"},
|
||||
},
|
||||
format="json",
|
||||
@@ -73,24 +75,26 @@ def test_api_rooms_update_members():
|
||||
room.refresh_from_db()
|
||||
assert room.name == "Old name"
|
||||
assert room.slug == "old-name"
|
||||
assert room.is_public != new_is_public
|
||||
assert room.access_level != RoomAccessLevel.RESTRICTED
|
||||
assert room.configuration == {}
|
||||
|
||||
|
||||
def test_api_rooms_update_administrators():
|
||||
"""Administrators or owners of a room should be allowed to update it."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, random.choice(["administrator", "owner"]))])
|
||||
room = RoomFactory(
|
||||
access_level=RoomAccessLevel.RESTRICTED,
|
||||
users=[(user, random.choice(["administrator", "owner"]))],
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
new_is_public = not room.is_public
|
||||
response = client.put(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
{
|
||||
"name": "New name",
|
||||
"slug": "should-be-ignored",
|
||||
"is_public": new_is_public,
|
||||
"access_level": RoomAccessLevel.PUBLIC,
|
||||
"configuration": {"the_key": "the_value"},
|
||||
},
|
||||
format="json",
|
||||
@@ -99,7 +103,7 @@ def test_api_rooms_update_administrators():
|
||||
room.refresh_from_db()
|
||||
assert room.name == "New name"
|
||||
assert room.slug == "new-name"
|
||||
assert room.is_public == new_is_public
|
||||
assert room.access_level == RoomAccessLevel.PUBLIC
|
||||
assert room.configuration == {"the_key": "the_value"}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
Test LiveKit webhook endpoint on the rooms API.
|
||||
"""
|
||||
|
||||
# ruff: noqa: PLR0913
|
||||
# pylint: disable=R0913,W0621,R0917,W0613
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from livekit import api
|
||||
|
||||
from ...services.livekit_events import ActionFailedError, LiveKitEventsService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def webhook_event_data():
|
||||
"""Sample webhook event data for testing."""
|
||||
return {
|
||||
"event": "room_finished",
|
||||
"room": {
|
||||
"sid": "RM_hycBMAjmt6Ub",
|
||||
"name": "00000000-0000-0000-0000-000000000000",
|
||||
"emptyTimeout": 300,
|
||||
"creationTime": "1692627281",
|
||||
"turnPassword": "2Pvdj+/WV1xV4EkB8klJ9xkXDWY=",
|
||||
"enabledCodecs": [
|
||||
{"mime": "audio/opus"},
|
||||
{"mime": "video/H264"},
|
||||
{"mime": "video/VP8"},
|
||||
],
|
||||
},
|
||||
"id": "EV_eugWmGhovZmm",
|
||||
"createdAt": "1692985556",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def serialized_event_data(webhook_event_data):
|
||||
"""Serialize event data to JSON."""
|
||||
return json.dumps(webhook_event_data)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_livekit_config(settings):
|
||||
"""Mock LiveKit configuration."""
|
||||
settings.LIVEKIT_CONFIGURATION = {
|
||||
"api_key": "test_api_key",
|
||||
"api_secret": "test_api_secret",
|
||||
"url": "https://test-livekit.example.com/",
|
||||
}
|
||||
return settings.LIVEKIT_CONFIGURATION
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_token(serialized_event_data, mock_livekit_config):
|
||||
"""Generate authentication token for webhook request."""
|
||||
hash64 = base64.b64encode(
|
||||
hashlib.sha256(serialized_event_data.encode()).digest()
|
||||
).decode()
|
||||
token = api.AccessToken(
|
||||
mock_livekit_config["api_key"], mock_livekit_config["api_secret"]
|
||||
)
|
||||
token.claims.sha256 = hash64
|
||||
return token.to_jwt()
|
||||
|
||||
|
||||
def test_missing_auth_header(client, serialized_event_data, mock_livekit_config):
|
||||
"""Should return 401 when auth header is missing."""
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/webhooks-livekit/",
|
||||
data=serialized_event_data,
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {
|
||||
"status": "error",
|
||||
"message": "Authorization header missing",
|
||||
}
|
||||
|
||||
|
||||
def test_invalid_payload(client, auth_token, mock_livekit_config):
|
||||
"""Should return 400 for invalid payload."""
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/webhooks-livekit/",
|
||||
data=json.dumps({"invalid": "payload"}),
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION=auth_token,
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"status": "error", "message": "Invalid webhook payload"}
|
||||
|
||||
|
||||
def test_unknown_event_type(client, mock_livekit_config):
|
||||
"""Should return 422 for unknown event type."""
|
||||
event_data = json.dumps({"event": "unknown_event_type"})
|
||||
|
||||
# Generate auth token for this specific payload
|
||||
hash64 = base64.b64encode(hashlib.sha256(event_data.encode()).digest()).decode()
|
||||
token = api.AccessToken(
|
||||
mock_livekit_config["api_key"], mock_livekit_config["api_secret"]
|
||||
)
|
||||
token.claims.sha256 = hash64
|
||||
auth_token = token.to_jwt()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/webhooks-livekit/",
|
||||
data=event_data,
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION=auth_token,
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json() == {
|
||||
"status": "error",
|
||||
"message": "Unknown webhook type: unknown_event_type",
|
||||
}
|
||||
|
||||
|
||||
@mock.patch.object(LiveKitEventsService, "_handle_room_finished")
|
||||
def test_handled_event_type(
|
||||
mock_handler,
|
||||
client,
|
||||
serialized_event_data,
|
||||
auth_token,
|
||||
mock_livekit_config,
|
||||
):
|
||||
"""Should process valid webhook successfully."""
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/webhooks-livekit/",
|
||||
data=serialized_event_data,
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION=auth_token,
|
||||
)
|
||||
|
||||
mock_handler.assert_called_once()
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "success"}
|
||||
|
||||
|
||||
def test_unhandled_event_type(client, mock_livekit_config):
|
||||
"""Should return 200 for event types that have no handler."""
|
||||
event_data = json.dumps({"event": "room_started"})
|
||||
|
||||
hash64 = base64.b64encode(hashlib.sha256(event_data.encode()).digest()).decode()
|
||||
token = api.AccessToken(
|
||||
mock_livekit_config["api_key"], mock_livekit_config["api_secret"]
|
||||
)
|
||||
token.claims.sha256 = hash64
|
||||
auth_token = token.to_jwt()
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/webhooks-livekit/",
|
||||
data=event_data,
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION=auth_token,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "success"}
|
||||
|
||||
|
||||
def test_action_error(client, mock_livekit_config):
|
||||
"""Should raise exceptions when errors occur during LiveKit webhook processing."""
|
||||
event_data = json.dumps(
|
||||
{
|
||||
"event": "room_finished",
|
||||
"room": {"sid": "RM_hycBMAjmt6Ub", "name": "invalid-uuid"},
|
||||
}
|
||||
)
|
||||
hash64 = base64.b64encode(hashlib.sha256(event_data.encode()).digest()).decode()
|
||||
token = api.AccessToken(
|
||||
mock_livekit_config["api_key"], mock_livekit_config["api_secret"]
|
||||
)
|
||||
token.claims.sha256 = hash64
|
||||
auth_token = token.to_jwt()
|
||||
|
||||
with pytest.raises(
|
||||
ActionFailedError,
|
||||
match="Failed to process room finished event",
|
||||
):
|
||||
client.post(
|
||||
"/api/v1.0/rooms/webhooks-livekit/",
|
||||
data=event_data,
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION=auth_token,
|
||||
)
|
||||
@@ -0,0 +1,132 @@
|
||||
"""
|
||||
Test LiveKitEvents service.
|
||||
"""
|
||||
# pylint: disable=W0621,W0613, W0212
|
||||
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from core.services.livekit_events import (
|
||||
ActionFailedError,
|
||||
AuthenticationError,
|
||||
InvalidPayloadError,
|
||||
LiveKitEventsService,
|
||||
UnsupportedEventTypeError,
|
||||
api,
|
||||
)
|
||||
from core.services.lobby import LobbyService
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_livekit_config(settings):
|
||||
"""Mock LiveKit configuration."""
|
||||
settings.LIVEKIT_CONFIGURATION = {
|
||||
"api_key": "test_api_key",
|
||||
"api_secret": "test_api_secret",
|
||||
"url": "https://test-livekit.example.com/",
|
||||
}
|
||||
return settings.LIVEKIT_CONFIGURATION
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def service(mock_livekit_config):
|
||||
"""Initialize LiveKitEventsService."""
|
||||
return LiveKitEventsService()
|
||||
|
||||
|
||||
@mock.patch("livekit.api.TokenVerifier")
|
||||
@mock.patch("livekit.api.WebhookReceiver")
|
||||
def test_initialization(
|
||||
mock_webhook_receiver, mock_token_verifier, mock_livekit_config
|
||||
):
|
||||
"""Should correctly initialize the service with required dependencies."""
|
||||
|
||||
api_key = mock_livekit_config["api_key"]
|
||||
api_secret = mock_livekit_config["api_secret"]
|
||||
|
||||
service = LiveKitEventsService()
|
||||
|
||||
mock_token_verifier.assert_called_once_with(api_key, api_secret)
|
||||
mock_webhook_receiver.assert_called_once_with(mock_token_verifier.return_value)
|
||||
assert isinstance(service.lobby_service, LobbyService)
|
||||
|
||||
|
||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||
def test_handle_room_finished(mock_clear_cache, service):
|
||||
"""Should clear lobby cache when room is finished."""
|
||||
|
||||
mock_room_name = uuid.uuid4()
|
||||
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(mock_room_name)
|
||||
|
||||
service._handle_room_finished(mock_data)
|
||||
|
||||
mock_clear_cache.assert_called_once_with(mock_room_name)
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
LobbyService, "clear_room_cache", side_effect=Exception("Test error")
|
||||
)
|
||||
def test_handle_room_finished_error(mock_clear_cache, service):
|
||||
"""Should raise ActionFailedError when processing fails."""
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = "00000000-0000-0000-0000-000000000000"
|
||||
with pytest.raises(
|
||||
ActionFailedError, match="Failed to process room finished event"
|
||||
):
|
||||
service._handle_room_finished(mock_data)
|
||||
|
||||
|
||||
def test_handle_room_finished_invalid_room_name(service):
|
||||
"""Should raise ActionFailedError when processing fails."""
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = "invalid"
|
||||
with pytest.raises(
|
||||
ActionFailedError, match="Failed to process room finished event"
|
||||
):
|
||||
service._handle_room_finished(mock_data)
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
api.WebhookReceiver, "receive", side_effect=Exception("Invalid payload")
|
||||
)
|
||||
def test_receive_invalid_payload(mock_receive, service):
|
||||
"""Should raise InvalidPayloadError for invalid payloads."""
|
||||
mock_request = mock.MagicMock()
|
||||
mock_request.headers = {"Authorization": "test_token"}
|
||||
mock_request.body = b"{}"
|
||||
|
||||
with pytest.raises(InvalidPayloadError, match="Invalid webhook payload"):
|
||||
service.receive(mock_request)
|
||||
|
||||
|
||||
def test_receive_missing_auth(service):
|
||||
"""Should raise AuthenticationError when auth header is missing."""
|
||||
mock_request = mock.MagicMock()
|
||||
mock_request.headers = {}
|
||||
|
||||
with pytest.raises(AuthenticationError, match="Authorization header missing"):
|
||||
service.receive(mock_request)
|
||||
|
||||
|
||||
@mock.patch.object(api.WebhookReceiver, "receive")
|
||||
def test_receive_unsupported_event(mock_receive, service):
|
||||
"""Should raise LiveKitWebhookError for unsupported events."""
|
||||
mock_request = mock.MagicMock()
|
||||
mock_request.headers = {"Authorization": "test_token"}
|
||||
mock_request.body = b"{}"
|
||||
|
||||
# Mock returned data with unsupported event type
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.event = "unsupported_event"
|
||||
mock_receive.return_value = mock_data
|
||||
|
||||
with pytest.raises(
|
||||
UnsupportedEventTypeError, match="Unknown webhook type: unsupported_event"
|
||||
):
|
||||
service.receive(mock_request)
|
||||
@@ -0,0 +1,952 @@
|
||||
"""
|
||||
Test lobby service.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0621,W0613, W0212, R0913
|
||||
# ruff: noqa: PLR0913
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.http import HttpResponse
|
||||
|
||||
import pytest
|
||||
from livekit.api import TwirpError
|
||||
|
||||
from core.factories import RoomFactory
|
||||
from core.models import RoomAccessLevel
|
||||
from core.services.lobby import (
|
||||
LobbyNotificationError,
|
||||
LobbyParticipant,
|
||||
LobbyParticipantNotFound,
|
||||
LobbyParticipantParsingError,
|
||||
LobbyParticipantStatus,
|
||||
LobbyService,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def lobby_service():
|
||||
"""Return a LobbyService instance."""
|
||||
return LobbyService()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def participant_id():
|
||||
"""Return a string ID for test participant."""
|
||||
return "test-participant-id"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def username():
|
||||
"""Return a username for test participant."""
|
||||
return "test-username"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def participant_dict():
|
||||
"""Return a valid participant dictionary."""
|
||||
return {
|
||||
"status": "waiting",
|
||||
"username": "test-username",
|
||||
"id": "test-participant-id",
|
||||
"color": "#123456",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def participant_data():
|
||||
"""Return a valid LobbyParticipant instance."""
|
||||
return LobbyParticipant(
|
||||
status=LobbyParticipantStatus.WAITING,
|
||||
username="test-username",
|
||||
id="test-participant-id",
|
||||
color="#123456",
|
||||
)
|
||||
|
||||
|
||||
def test_lobby_participant_to_dict(participant_data):
|
||||
"""Test LobbyParticipant serialization to dict."""
|
||||
result = participant_data.to_dict()
|
||||
|
||||
assert result["status"] == "waiting"
|
||||
assert result["username"] == "test-username"
|
||||
assert result["id"] == "test-participant-id"
|
||||
assert result["color"] == "#123456"
|
||||
|
||||
|
||||
def test_lobby_participant_from_dict_success(participant_dict):
|
||||
"""Test successful LobbyParticipant creation from dict."""
|
||||
participant = LobbyParticipant.from_dict(participant_dict)
|
||||
|
||||
assert participant.status == LobbyParticipantStatus.WAITING
|
||||
assert participant.username == "test-username"
|
||||
assert participant.id == "test-participant-id"
|
||||
assert participant.color == "#123456"
|
||||
|
||||
|
||||
def test_lobby_participant_from_dict_default_status():
|
||||
"""Test LobbyParticipant creation with missing status defaults to UNKNOWN."""
|
||||
data_without_status = {
|
||||
"username": "test-username",
|
||||
"id": "test-participant-id",
|
||||
"color": "#123456",
|
||||
}
|
||||
|
||||
participant = LobbyParticipant.from_dict(data_without_status)
|
||||
|
||||
assert participant.status == LobbyParticipantStatus.UNKNOWN
|
||||
assert participant.username == "test-username"
|
||||
assert participant.id == "test-participant-id"
|
||||
assert participant.color == "#123456"
|
||||
|
||||
|
||||
def test_lobby_participant_from_dict_missing_fields():
|
||||
"""Test LobbyParticipant creation with missing fields."""
|
||||
invalid_data = {"username": "test-username"}
|
||||
|
||||
with pytest.raises(LobbyParticipantParsingError, match="Invalid participant data"):
|
||||
LobbyParticipant.from_dict(invalid_data)
|
||||
|
||||
|
||||
def test_lobby_participant_from_dict_invalid_status():
|
||||
"""Test LobbyParticipant creation with invalid status."""
|
||||
invalid_data = {
|
||||
"status": "invalid_status",
|
||||
"username": "test-username",
|
||||
"id": "test-participant-id",
|
||||
"color": "#123456",
|
||||
}
|
||||
|
||||
with pytest.raises(LobbyParticipantParsingError, match="Invalid participant data"):
|
||||
LobbyParticipant.from_dict(invalid_data)
|
||||
|
||||
|
||||
def test_get_cache_key(lobby_service, participant_id):
|
||||
"""Test cache key generation."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
cache_key = lobby_service._get_cache_key(room.id, participant_id)
|
||||
|
||||
expected_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_{participant_id}"
|
||||
assert cache_key == expected_key
|
||||
|
||||
|
||||
def test_get_or_create_participant_id_from_cookie(lobby_service):
|
||||
"""Test extracting participant ID from cookie."""
|
||||
request = mock.Mock()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: "existing-id"}
|
||||
|
||||
participant_id = lobby_service._get_or_create_participant_id(request)
|
||||
|
||||
assert participant_id == "existing-id"
|
||||
|
||||
|
||||
@mock.patch("uuid.uuid4")
|
||||
def test_get_or_create_participant_id_new(mock_uuid4, lobby_service):
|
||||
"""Test creating new participant ID when cookie is missing."""
|
||||
mock_uuid4.return_value = mock.Mock(hex="generated-id")
|
||||
request = mock.Mock()
|
||||
request.COOKIES = {}
|
||||
|
||||
participant_id = lobby_service._get_or_create_participant_id(request)
|
||||
|
||||
assert participant_id == "generated-id"
|
||||
mock_uuid4.assert_called_once()
|
||||
|
||||
|
||||
def test_prepare_response_existing_cookie(lobby_service, participant_id):
|
||||
"""Test response preparation with existing cookie."""
|
||||
response = HttpResponse()
|
||||
response.cookies[settings.LOBBY_COOKIE_NAME] = "existing-cookie"
|
||||
|
||||
lobby_service.prepare_response(response, participant_id)
|
||||
|
||||
# Verify cookie wasn't set again
|
||||
cookie = response.cookies.get(settings.LOBBY_COOKIE_NAME)
|
||||
assert cookie.value == "existing-cookie"
|
||||
assert cookie.value != participant_id
|
||||
|
||||
|
||||
def test_prepare_response_new_cookie(lobby_service, participant_id):
|
||||
"""Test response preparation with new cookie."""
|
||||
response = HttpResponse()
|
||||
|
||||
lobby_service.prepare_response(response, participant_id)
|
||||
|
||||
# Verify cookie was set
|
||||
cookie = response.cookies.get(settings.LOBBY_COOKIE_NAME)
|
||||
assert cookie is not None
|
||||
assert cookie.value == participant_id
|
||||
assert cookie["httponly"] is True
|
||||
assert cookie["secure"] is True
|
||||
assert cookie["samesite"] == "Lax"
|
||||
|
||||
# It's a session cookies (no max_age specified):
|
||||
assert not cookie["max-age"]
|
||||
|
||||
|
||||
def test_can_bypass_lobby_public_room(lobby_service):
|
||||
"""Should return True for public rooms regardless of user auth."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
|
||||
# Anonymous user
|
||||
user = mock.Mock()
|
||||
user.is_authenticated = False
|
||||
assert lobby_service.can_bypass_lobby(room, user) is True
|
||||
|
||||
# Authenticated user
|
||||
user.is_authenticated = True
|
||||
assert lobby_service.can_bypass_lobby(room, user) is True
|
||||
|
||||
|
||||
def test_can_bypass_lobby_trusted_room_authenticated(lobby_service):
|
||||
"""Should return True for trusted rooms with authenticated users."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
|
||||
# Authenticated user
|
||||
user = mock.Mock()
|
||||
user.is_authenticated = True
|
||||
assert lobby_service.can_bypass_lobby(room, user) is True
|
||||
|
||||
|
||||
def test_can_bypass_lobby_trusted_room_anonymous(lobby_service):
|
||||
"""Should return False for trusted rooms with anonymous users."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
|
||||
# Anonymous user
|
||||
user = mock.Mock()
|
||||
user.is_authenticated = False
|
||||
assert lobby_service.can_bypass_lobby(room, user) is False
|
||||
|
||||
|
||||
def test_can_bypass_lobby_private_room(lobby_service):
|
||||
"""Should return False for private rooms regardless of user auth."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
# Anonymous user
|
||||
user = mock.Mock()
|
||||
user.is_authenticated = False
|
||||
assert lobby_service.can_bypass_lobby(room, user) is False
|
||||
|
||||
# Authenticated user
|
||||
user.is_authenticated = True
|
||||
assert lobby_service.can_bypass_lobby(room, user) is False
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_livekit_config")
|
||||
def test_request_entry_public_room(
|
||||
mock_generate_config, lobby_service, participant_id, username
|
||||
):
|
||||
"""Test requesting entry to a public room."""
|
||||
request = mock.Mock()
|
||||
request.user = mock.Mock()
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
|
||||
mocked_participant = LobbyParticipant(
|
||||
status=LobbyParticipantStatus.UNKNOWN,
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
)
|
||||
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||
mock_generate_config.return_value = {"token": "test-token"}
|
||||
|
||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||
|
||||
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
||||
assert livekit_config == {"token": "test-token"}
|
||||
mock_generate_config.assert_called_once_with(
|
||||
room_id=str(room.id),
|
||||
user=request.user,
|
||||
username=username,
|
||||
color=participant.color,
|
||||
)
|
||||
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_livekit_config")
|
||||
def test_request_entry_trusted_room(
|
||||
mock_generate_config, lobby_service, participant_id, username
|
||||
):
|
||||
"""Test requesting entry to a trusted room when the user is authenticated."""
|
||||
request = mock.Mock()
|
||||
request.user = mock.Mock()
|
||||
request.user.is_authenticated = True
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
|
||||
mocked_participant = LobbyParticipant(
|
||||
status=LobbyParticipantStatus.UNKNOWN,
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
)
|
||||
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||
mock_generate_config.return_value = {"token": "test-token"}
|
||||
|
||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||
|
||||
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
||||
assert livekit_config == {"token": "test-token"}
|
||||
mock_generate_config.assert_called_once_with(
|
||||
room_id=str(room.id),
|
||||
user=request.user,
|
||||
username=username,
|
||||
color=participant.color,
|
||||
)
|
||||
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.LobbyService.enter")
|
||||
def test_request_entry_new_participant(
|
||||
mock_enter, lobby_service, participant_id, username
|
||||
):
|
||||
"""Test requesting entry for a new participant."""
|
||||
request = mock.Mock()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
lobby_service._get_participant = mock.Mock(return_value=None)
|
||||
|
||||
participant_data = LobbyParticipant(
|
||||
status=LobbyParticipantStatus.WAITING,
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
)
|
||||
mock_enter.return_value = participant_data
|
||||
|
||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||
|
||||
assert participant == participant_data
|
||||
assert livekit_config is None
|
||||
mock_enter.assert_called_once_with(room.id, participant_id, username)
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.LobbyService.refresh_waiting_status")
|
||||
def test_request_entry_waiting_participant(
|
||||
mock_refresh, lobby_service, participant_id, username
|
||||
):
|
||||
"""Test requesting entry for a waiting participant."""
|
||||
request = mock.Mock()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
mocked_participant = LobbyParticipant(
|
||||
status=LobbyParticipantStatus.WAITING,
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
)
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||
|
||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||
|
||||
assert participant.status == LobbyParticipantStatus.WAITING
|
||||
assert livekit_config is None
|
||||
mock_refresh.assert_called_once_with(room.id, participant_id)
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_livekit_config")
|
||||
def test_request_entry_accepted_participant(
|
||||
mock_generate_config, lobby_service, participant_id, username
|
||||
):
|
||||
"""Test requesting entry for an accepted participant."""
|
||||
request = mock.Mock()
|
||||
request.user = mock.Mock()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
mocked_participant = LobbyParticipant(
|
||||
status=LobbyParticipantStatus.ACCEPTED,
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
)
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||
|
||||
mock_generate_config.return_value = {"token": "test-token"}
|
||||
|
||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||
|
||||
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
||||
assert livekit_config == {"token": "test-token"}
|
||||
mock_generate_config.assert_called_once_with(
|
||||
room_id=str(room.id),
|
||||
user=request.user,
|
||||
username=username,
|
||||
color="#123456",
|
||||
)
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
|
||||
"""Test refreshing waiting status for a participant."""
|
||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
lobby_service.refresh_waiting_status(room.id, participant_id)
|
||||
mock_cache.touch.assert_called_once_with(
|
||||
"mocked_cache_key", settings.LOBBY_WAITING_TIMEOUT
|
||||
)
|
||||
|
||||
|
||||
# pylint: disable=R0917
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
@mock.patch("core.utils.generate_color")
|
||||
@mock.patch("core.services.lobby.LobbyService.notify_participants")
|
||||
def test_enter_success(
|
||||
mock_notify,
|
||||
mock_generate_color,
|
||||
mock_cache,
|
||||
lobby_service,
|
||||
participant_id,
|
||||
username,
|
||||
):
|
||||
"""Test successful participant entry."""
|
||||
mock_generate_color.return_value = "#123456"
|
||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
participant = lobby_service.enter(room.id, participant_id, username)
|
||||
|
||||
mock_generate_color.assert_called_once_with(participant_id)
|
||||
assert participant.status == LobbyParticipantStatus.WAITING
|
||||
assert participant.username == username
|
||||
assert participant.id == participant_id
|
||||
assert participant.color == "#123456"
|
||||
|
||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
mock_cache.set.assert_called_once_with(
|
||||
"mocked_cache_key",
|
||||
participant.to_dict(),
|
||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||
)
|
||||
mock_notify.assert_called_once_with(room_id=room.id)
|
||||
|
||||
|
||||
# pylint: disable=R0917
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
@mock.patch("core.utils.generate_color")
|
||||
@mock.patch("core.services.lobby.LobbyService.notify_participants")
|
||||
def test_enter_with_notification_error(
|
||||
mock_notify,
|
||||
mock_generate_color,
|
||||
mock_cache,
|
||||
lobby_service,
|
||||
participant_id,
|
||||
username,
|
||||
):
|
||||
"""Test participant entry with notification error."""
|
||||
mock_generate_color.return_value = "#123456"
|
||||
mock_notify.side_effect = LobbyNotificationError("Error notifying")
|
||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
participant = lobby_service.enter(room.id, participant_id, username)
|
||||
|
||||
mock_generate_color.assert_called_once_with(participant_id)
|
||||
assert participant.status == LobbyParticipantStatus.WAITING
|
||||
assert participant.username == username
|
||||
|
||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
mock_cache.set.assert_called_once_with(
|
||||
"mocked_cache_key",
|
||||
participant.to_dict(),
|
||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
def test_get_participant_not_found(mock_cache, lobby_service, participant_id):
|
||||
"""Test getting a participant that doesn't exist."""
|
||||
mock_cache.get.return_value = None
|
||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
result = lobby_service._get_participant(room.id, participant_id)
|
||||
|
||||
assert result is None
|
||||
|
||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||
mock_cache.get.assert_called_once_with("mocked_cache_key")
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
@mock.patch("core.services.lobby.LobbyParticipant.from_dict")
|
||||
def test_get_participant_parsing_error(
|
||||
mock_from_dict, mock_cache, lobby_service, participant_id
|
||||
):
|
||||
"""Test handling corrupted participant data."""
|
||||
mock_cache.get.return_value = {"some": "data"}
|
||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||
mock_from_dict.side_effect = LobbyParticipantParsingError("Invalid data")
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
result = lobby_service._get_participant(room.id, participant_id)
|
||||
|
||||
assert result is None
|
||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||
mock_cache.delete.assert_called_once_with("mocked_cache_key")
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
def test_list_waiting_participants_empty(mock_cache, lobby_service):
|
||||
"""Test listing waiting participants when none exist."""
|
||||
mock_cache.keys.return_value = []
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
result = lobby_service.list_waiting_participants(room.id)
|
||||
|
||||
assert result == []
|
||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||
mock_cache.keys.assert_called_once_with(pattern)
|
||||
mock_cache.get_many.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
def test_list_waiting_participants(mock_cache, lobby_service, participant_dict):
|
||||
"""Test listing waiting participants with valid data."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||
mock_cache.keys.return_value = [cache_key]
|
||||
mock_cache.get_many.return_value = {cache_key: participant_dict}
|
||||
|
||||
result = lobby_service.list_waiting_participants(room.id)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["status"] == "waiting"
|
||||
assert result[0]["username"] == "test-username"
|
||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||
mock_cache.keys.assert_called_once_with(pattern)
|
||||
mock_cache.get_many.assert_called_once_with([cache_key])
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
def test_list_waiting_participants_multiple(mock_cache, lobby_service):
|
||||
"""Test listing multiple waiting participants with valid data."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
cache_key1 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||
cache_key2 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
|
||||
|
||||
participant1 = {
|
||||
"status": "waiting",
|
||||
"username": "user1",
|
||||
"id": "participant1",
|
||||
"color": "#123456",
|
||||
}
|
||||
|
||||
participant2 = {
|
||||
"status": "waiting",
|
||||
"username": "user2",
|
||||
"id": "participant2",
|
||||
"color": "#654321",
|
||||
}
|
||||
|
||||
mock_cache.keys.return_value = [cache_key1, cache_key2]
|
||||
mock_cache.get_many.return_value = {
|
||||
cache_key1: participant1,
|
||||
cache_key2: participant2,
|
||||
}
|
||||
|
||||
result = lobby_service.list_waiting_participants(room.id)
|
||||
|
||||
assert len(result) == 2
|
||||
|
||||
# Verify both participants are in the result
|
||||
assert any(p["id"] == "participant1" and p["username"] == "user1" for p in result)
|
||||
assert any(p["id"] == "participant2" and p["username"] == "user2" for p in result)
|
||||
|
||||
# Verify all participants have waiting status
|
||||
assert all(p["status"] == "waiting" for p in result)
|
||||
|
||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||
mock_cache.keys.assert_called_once_with(pattern)
|
||||
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
def test_list_waiting_participants_corrupted_data(mock_cache, lobby_service):
|
||||
"""Test listing waiting participants with corrupted data."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
cache_key = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||
mock_cache.keys.return_value = [cache_key]
|
||||
mock_cache.get_many.return_value = {cache_key: {"invalid": "data"}}
|
||||
|
||||
result = lobby_service.list_waiting_participants(room.id)
|
||||
|
||||
assert result == []
|
||||
mock_cache.delete.assert_called_once_with(cache_key)
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
def test_list_waiting_participants_partially_corrupted(mock_cache, lobby_service):
|
||||
"""Test listing waiting participants with one valid and one corrupted entry."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
cache_key1 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||
cache_key2 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
|
||||
|
||||
valid_participant = {
|
||||
"status": "waiting",
|
||||
"username": "user2",
|
||||
"id": "participant2",
|
||||
"color": "#654321",
|
||||
}
|
||||
|
||||
corrupted_participant = {"invalid": "data"}
|
||||
|
||||
mock_cache.keys.return_value = [cache_key1, cache_key2]
|
||||
mock_cache.get_many.return_value = {
|
||||
cache_key1: corrupted_participant,
|
||||
cache_key2: valid_participant,
|
||||
}
|
||||
|
||||
result = lobby_service.list_waiting_participants(room.id)
|
||||
|
||||
# Check that only the valid participant is returned
|
||||
assert len(result) == 1
|
||||
assert result[0]["id"] == "participant2"
|
||||
assert result[0]["status"] == "waiting"
|
||||
assert result[0]["username"] == "user2"
|
||||
|
||||
# Verify corrupted entry was deleted
|
||||
mock_cache.delete.assert_called_once_with(cache_key1)
|
||||
|
||||
# Verify both cache keys were queried
|
||||
pattern = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_*"
|
||||
mock_cache.keys.assert_called_once_with(pattern)
|
||||
mock_cache.get_many.assert_called_once_with([cache_key1, cache_key2])
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
def test_list_waiting_participants_non_waiting(mock_cache, lobby_service):
|
||||
"""Test listing only waiting participants (not accepted/denied)."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
cache_key1 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant1"
|
||||
cache_key2 = f"{settings.LOBBY_KEY_PREFIX}_{room.id!s}_participant2"
|
||||
|
||||
participant1 = {
|
||||
"status": "waiting",
|
||||
"username": "user1",
|
||||
"id": "participant1",
|
||||
"color": "#123456",
|
||||
}
|
||||
participant2 = {
|
||||
"status": "accepted",
|
||||
"username": "user2",
|
||||
"id": "participant2",
|
||||
"color": "#654321",
|
||||
}
|
||||
|
||||
mock_cache.keys.return_value = [cache_key1, cache_key2]
|
||||
mock_cache.get_many.return_value = {
|
||||
cache_key1: participant1,
|
||||
cache_key2: participant2,
|
||||
}
|
||||
|
||||
result = lobby_service.list_waiting_participants(room.id)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["id"] == "participant1"
|
||||
assert result[0]["status"] == "waiting"
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.LobbyService._update_participant_status")
|
||||
def test_handle_participant_entry_allow(mock_update, lobby_service, participant_id):
|
||||
"""Test handling allowed participant entry."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
lobby_service.handle_participant_entry(room.id, participant_id, allow_entry=True)
|
||||
|
||||
mock_update.assert_called_once_with(
|
||||
room.id,
|
||||
participant_id,
|
||||
status=LobbyParticipantStatus.ACCEPTED,
|
||||
timeout=settings.LOBBY_ACCEPTED_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.LobbyService._update_participant_status")
|
||||
def test_handle_participant_entry_deny(mock_update, lobby_service, participant_id):
|
||||
"""Test handling denied participant entry."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
lobby_service.handle_participant_entry(room.id, participant_id, allow_entry=False)
|
||||
|
||||
mock_update.assert_called_once_with(
|
||||
room.id,
|
||||
participant_id,
|
||||
status=LobbyParticipantStatus.DENIED,
|
||||
timeout=settings.LOBBY_DENIED_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
def test_update_participant_status_not_found(mock_cache, lobby_service, participant_id):
|
||||
"""Test updating status for non-existent participant."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
mock_cache.get.return_value = None
|
||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||
|
||||
with pytest.raises(LobbyParticipantNotFound, match="Participant not found"):
|
||||
lobby_service._update_participant_status(
|
||||
room.id,
|
||||
participant_id,
|
||||
status=LobbyParticipantStatus.ACCEPTED,
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||
mock_cache.get.assert_called_once_with("mocked_cache_key")
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
@mock.patch("core.services.lobby.LobbyParticipant.from_dict")
|
||||
def test_update_participant_status_corrupted_data(
|
||||
mock_from_dict, mock_cache, lobby_service, participant_id
|
||||
):
|
||||
"""Test updating status with corrupted participant data."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
mock_cache.get.return_value = {"some": "data"}
|
||||
mock_from_dict.side_effect = LobbyParticipantParsingError("Invalid data")
|
||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||
|
||||
with pytest.raises(LobbyParticipantParsingError):
|
||||
lobby_service._update_participant_status(
|
||||
room.id,
|
||||
participant_id,
|
||||
status=LobbyParticipantStatus.ACCEPTED,
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
mock_cache.delete.assert_called_once_with("mocked_cache_key")
|
||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.cache")
|
||||
def test_update_participant_status_success(mock_cache, lobby_service, participant_id):
|
||||
"""Test successful participant status update."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
participant_dict = {
|
||||
"status": "waiting",
|
||||
"username": "test-username",
|
||||
"id": participant_id,
|
||||
"color": "#123456",
|
||||
}
|
||||
|
||||
mock_cache.get.return_value = participant_dict
|
||||
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
|
||||
|
||||
lobby_service._update_participant_status(
|
||||
room.id,
|
||||
participant_id,
|
||||
status=LobbyParticipantStatus.ACCEPTED,
|
||||
timeout=60,
|
||||
)
|
||||
|
||||
expected_data = {
|
||||
"status": "accepted",
|
||||
"username": "test-username",
|
||||
"id": participant_id,
|
||||
"color": "#123456",
|
||||
}
|
||||
mock_cache.set.assert_called_once_with(
|
||||
"mocked_cache_key", expected_data, timeout=60
|
||||
)
|
||||
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.LiveKitAPI")
|
||||
def test_notify_participants_success_no_room(mock_livekit_api, lobby_service):
|
||||
"""Test the notify_participants method when the LiveKit room doesn't exist yet."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
# Set up the mock LiveKitAPI and its behavior
|
||||
mock_api_instance = mock.Mock()
|
||||
mock_api_instance.room = mock.Mock()
|
||||
mock_api_instance.room.send_data = mock.AsyncMock()
|
||||
|
||||
# Create a proper response object with an empty rooms list
|
||||
class MockResponse:
|
||||
"""LiveKit API response mock with empty rooms list."""
|
||||
|
||||
rooms = []
|
||||
|
||||
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
|
||||
mock_api_instance.aclose = mock.AsyncMock()
|
||||
mock_livekit_api.return_value = mock_api_instance
|
||||
|
||||
# Act
|
||||
lobby_service.notify_participants(room.id)
|
||||
|
||||
# Assert
|
||||
# Verify the API was initialized with correct configuration
|
||||
mock_livekit_api.assert_called_once_with(**settings.LIVEKIT_CONFIGURATION)
|
||||
|
||||
# Verify that the service checked for existing rooms
|
||||
mock_api_instance.room.list_rooms.assert_called_once()
|
||||
|
||||
# Verify the send_data method was not called since no room exists
|
||||
mock_api_instance.room.send_data.assert_not_called()
|
||||
|
||||
# Verify the connection was properly closed
|
||||
mock_api_instance.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.LiveKitAPI")
|
||||
def test_notify_participants_success(mock_livekit_api, lobby_service):
|
||||
"""Test successful participant notification."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
# Set up the mock LiveKitAPI and its behavior
|
||||
mock_api_instance = mock.Mock()
|
||||
mock_api_instance.room = mock.Mock()
|
||||
mock_api_instance.room.send_data = mock.AsyncMock()
|
||||
|
||||
class MockResponse:
|
||||
"""LiveKit API response mock with non-empty rooms list."""
|
||||
|
||||
rooms = ["room-1"]
|
||||
|
||||
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
|
||||
|
||||
mock_api_instance.aclose = mock.AsyncMock()
|
||||
mock_livekit_api.return_value = mock_api_instance
|
||||
|
||||
# Call the function
|
||||
lobby_service.notify_participants(room.id)
|
||||
|
||||
# Verify the API was called correctly
|
||||
mock_livekit_api.assert_called_once_with(**settings.LIVEKIT_CONFIGURATION)
|
||||
|
||||
# Verify that the service checked for existing rooms
|
||||
mock_api_instance.room.list_rooms.assert_called_once()
|
||||
|
||||
# Verify the send_data method was called
|
||||
mock_api_instance.room.send_data.assert_called_once()
|
||||
send_data_request = mock_api_instance.room.send_data.call_args[0][0]
|
||||
assert send_data_request.room == str(room.id)
|
||||
assert (
|
||||
json.loads(send_data_request.data.decode("utf-8"))["type"]
|
||||
== settings.LOBBY_NOTIFICATION_TYPE
|
||||
)
|
||||
assert send_data_request.kind == 0 # RELIABLE mode in Livekit protocol
|
||||
|
||||
# Verify aclose was called
|
||||
mock_api_instance.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.lobby.LiveKitAPI")
|
||||
def test_notify_participants_error(mock_livekit_api, lobby_service):
|
||||
"""Test participant notification with API error."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
# Set up the mock LiveKitAPI and its behavior
|
||||
mock_api_instance = mock.Mock()
|
||||
mock_api_instance.room = mock.Mock()
|
||||
mock_api_instance.room.send_data = mock.AsyncMock(
|
||||
side_effect=TwirpError(msg="test error", code=123)
|
||||
)
|
||||
|
||||
class MockResponse:
|
||||
"""LiveKit API response mock with non-empty rooms list."""
|
||||
|
||||
rooms = ["room-1"]
|
||||
|
||||
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
|
||||
|
||||
mock_api_instance.aclose = mock.AsyncMock()
|
||||
mock_livekit_api.return_value = mock_api_instance
|
||||
|
||||
# Call the function and expect an exception
|
||||
with pytest.raises(
|
||||
LobbyNotificationError, match="Failed to notify room participants"
|
||||
):
|
||||
lobby_service.notify_participants(room.id)
|
||||
|
||||
# Verify the API was called correctly
|
||||
mock_livekit_api.assert_called_once_with(**settings.LIVEKIT_CONFIGURATION)
|
||||
|
||||
# Verify that the service checked for existing rooms
|
||||
mock_api_instance.room.list_rooms.assert_called_once()
|
||||
|
||||
# Verify send_data was called
|
||||
mock_api_instance.room.send_data.assert_called_once()
|
||||
|
||||
# Verify aclose was still called after the exception
|
||||
mock_api_instance.aclose.assert_called_once()
|
||||
|
||||
|
||||
def test_clear_room_cache(settings, lobby_service):
|
||||
"""Test clearing room cache actually removes entries from cache."""
|
||||
|
||||
settings.LOBBY_KEY_PREFIX = "test-lobby"
|
||||
settings.LOBBY_WAITING_TIMEOUT = 10000
|
||||
settings.LOBBY_ACCEPTED_TIMEOUT = 10000
|
||||
settings.LOBBY_DENIED_TIMEOUT = 10000
|
||||
|
||||
room_id = uuid.uuid4()
|
||||
|
||||
cache.set(
|
||||
f"test-lobby_{room_id!s}_participant1",
|
||||
LobbyParticipant(
|
||||
status=LobbyParticipantStatus.WAITING,
|
||||
username="participant1",
|
||||
id="participant1",
|
||||
color="#123456",
|
||||
),
|
||||
timeout=settings.LOBBY_WAITING_TIMEOUT,
|
||||
)
|
||||
cache.set(
|
||||
f"test-lobby_{room_id!s}_participant2",
|
||||
LobbyParticipant(
|
||||
status=LobbyParticipantStatus.ACCEPTED,
|
||||
username="participant2",
|
||||
id="participant2",
|
||||
color="#123456",
|
||||
),
|
||||
timeout=settings.LOBBY_ACCEPTED_TIMEOUT,
|
||||
)
|
||||
cache.set(
|
||||
f"test-lobby_{room_id!s}_participant3",
|
||||
LobbyParticipant(
|
||||
status=LobbyParticipantStatus.DENIED,
|
||||
username="participant3",
|
||||
id="participant3",
|
||||
color="#123456",
|
||||
),
|
||||
timeout=settings.LOBBY_DENIED_TIMEOUT,
|
||||
)
|
||||
|
||||
lobby_service.clear_room_cache(room_id)
|
||||
|
||||
assert cache.keys(f"test-lobby_{room_id!s}_*") == []
|
||||
|
||||
|
||||
def test_clear_room_empty(settings, lobby_service):
|
||||
"""Test clearing room cache when it's already empty."""
|
||||
|
||||
settings.LOBBY_KEY_PREFIX = "test-lobby"
|
||||
room_id = uuid.uuid4()
|
||||
|
||||
assert cache.keys(f"test-lobby_{room_id!s}_*") == []
|
||||
lobby_service.clear_room_cache(room_id)
|
||||
assert cache.keys(f"test-lobby_{room_id!s}_*") == []
|
||||
@@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured
|
||||
import brevo_python
|
||||
import pytest
|
||||
|
||||
from core.services.marketing_service import (
|
||||
from core.services.marketing import (
|
||||
BrevoMarketingService,
|
||||
ContactCreationError,
|
||||
ContactData,
|
||||
@@ -144,9 +144,7 @@ def clear_marketing_cache():
|
||||
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"
|
||||
)
|
||||
settings.MARKETING_SERVICE_CLASS = "core.services.marketing.BrevoMarketingService"
|
||||
|
||||
service1 = get_marketing_service()
|
||||
service2 = get_marketing_service()
|
||||
@@ -163,14 +161,12 @@ def test_get_marketing_service_invalid_class(clear_marketing_cache):
|
||||
get_marketing_service()
|
||||
|
||||
|
||||
@mock.patch("core.services.marketing_service.import_string")
|
||||
@mock.patch("core.services.marketing.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"
|
||||
)
|
||||
settings.MARKETING_SERVICE_CLASS = "core.services.marketing.BrevoMarketingService"
|
||||
get_marketing_service.cache_clear()
|
||||
|
||||
mock_service_cls = mock.Mock()
|
||||
@@ -16,7 +16,7 @@ from ..factories import (
|
||||
UserFactory,
|
||||
UserResourceAccessFactory,
|
||||
)
|
||||
from ..models import ResourceAccess, RoleChoices
|
||||
from ..models import ResourceAccess, RoleChoices, RoomAccessLevel
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -44,13 +44,13 @@ def test_api_room_user_accesses_list_authenticated_not_related():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
public_room = RoomFactory(is_public=True)
|
||||
public_room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
UserResourceAccessFactory(resource=public_room)
|
||||
UserResourceAccessFactory(resource=public_room, role="member")
|
||||
UserResourceAccessFactory(resource=public_room, role="administrator")
|
||||
UserResourceAccessFactory(resource=public_room, role="owner")
|
||||
|
||||
private_room = RoomFactory(is_public=False)
|
||||
private_room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
UserResourceAccessFactory(resource=private_room)
|
||||
UserResourceAccessFactory(resource=private_room, role="member")
|
||||
UserResourceAccessFactory(resource=private_room, role="administrator")
|
||||
@@ -73,13 +73,17 @@ def test_api_room_user_accesses_list_authenticated_member():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
public_room = RoomFactory(is_public=True, users=[(user, "member")])
|
||||
public_room = RoomFactory(
|
||||
access_level=RoomAccessLevel.PUBLIC, users=[(user, "member")]
|
||||
)
|
||||
UserResourceAccessFactory(resource=public_room)
|
||||
UserResourceAccessFactory(resource=public_room, role="member")
|
||||
UserResourceAccessFactory(resource=public_room, role="administrator")
|
||||
UserResourceAccessFactory(resource=public_room, role="owner")
|
||||
|
||||
private_room = RoomFactory(is_public=False, users=[(user, "member")])
|
||||
private_room = RoomFactory(
|
||||
access_level=RoomAccessLevel.RESTRICTED, users=[(user, "member")]
|
||||
)
|
||||
UserResourceAccessFactory(resource=private_room)
|
||||
UserResourceAccessFactory(resource=private_room, role="member")
|
||||
UserResourceAccessFactory(resource=private_room, role="administrator")
|
||||
@@ -102,7 +106,7 @@ def test_api_room_user_accesses_list_authenticated_administrator():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
public_room = RoomFactory(is_public=True)
|
||||
public_room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
public_room_accesses = (
|
||||
# Access for the logged-in user
|
||||
UserResourceAccessFactory(
|
||||
@@ -115,7 +119,7 @@ def test_api_room_user_accesses_list_authenticated_administrator():
|
||||
UserResourceAccessFactory(resource=public_room, role="owner"),
|
||||
)
|
||||
|
||||
private_room = RoomFactory(is_public=False)
|
||||
private_room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
private_room_accesses = (
|
||||
# Access for the logged-in user
|
||||
UserResourceAccessFactory(
|
||||
@@ -148,7 +152,7 @@ def test_api_room_user_accesses_list_authenticated_owner():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
public_room = RoomFactory(is_public=True)
|
||||
public_room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
public_room_accesses = (
|
||||
# Access for the logged-in user
|
||||
UserResourceAccessFactory(resource=public_room, user=user, role="owner"),
|
||||
@@ -158,7 +162,7 @@ def test_api_room_user_accesses_list_authenticated_owner():
|
||||
UserResourceAccessFactory(resource=public_room, role="administrator"),
|
||||
UserResourceAccessFactory(resource=public_room, role="owner"),
|
||||
)
|
||||
private_room = RoomFactory(is_public=False)
|
||||
private_room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
private_room_accesses = (
|
||||
# Access for the logged-in user
|
||||
UserResourceAccessFactory(resource=private_room, user=user, role="owner"),
|
||||
@@ -252,8 +256,8 @@ def test_api_room_user_accesses_retrieve_authenticated_not_related():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
for is_public in [True, False]:
|
||||
room = RoomFactory(is_public=is_public)
|
||||
for access_level in [RoomAccessLevel.PUBLIC, RoomAccessLevel.RESTRICTED]:
|
||||
room = RoomFactory(access_level=access_level)
|
||||
assert len(RoleChoices.choices) == 3
|
||||
|
||||
for role, _name in RoleChoices.choices:
|
||||
@@ -277,9 +281,9 @@ def test_api_room_user_accesses_retrieve_authenticated_member():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
for is_public in [True, False]:
|
||||
for access_level in [RoomAccessLevel.PUBLIC, RoomAccessLevel.RESTRICTED]:
|
||||
room = RoomFactory(
|
||||
is_public=is_public,
|
||||
access_level=access_level,
|
||||
users=[(user, "member")],
|
||||
)
|
||||
assert len(RoleChoices.choices) == 3
|
||||
@@ -305,8 +309,8 @@ def test_api_room_user_accesses_retrieve_authenticated_administrator():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
for is_public in [True, False]:
|
||||
room = RoomFactory(is_public=is_public, users=[(user, "administrator")])
|
||||
for access_level in [RoomAccessLevel.PUBLIC, RoomAccessLevel.RESTRICTED]:
|
||||
room = RoomFactory(access_level=access_level, users=[(user, "administrator")])
|
||||
assert len(RoleChoices.choices) == 3
|
||||
|
||||
for role, _name in RoleChoices.choices:
|
||||
@@ -334,8 +338,8 @@ def test_api_room_user_accesses_retrieve_authenticated_owner():
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
for is_public in [True, False]:
|
||||
room = RoomFactory(is_public=is_public, users=[(user, "owner")])
|
||||
for access_level in [RoomAccessLevel.PUBLIC, RoomAccessLevel.RESTRICTED]:
|
||||
room = RoomFactory(access_level=access_level, users=[(user, "owner")])
|
||||
assert len(RoleChoices.choices) == 3
|
||||
|
||||
for role, _name in RoleChoices.choices:
|
||||
|
||||
@@ -22,10 +22,32 @@ def test_api_users_list_anonymous():
|
||||
}
|
||||
|
||||
|
||||
def test_api_users_list_authenticated():
|
||||
def test_api_users_list_authenticated_secure(settings):
|
||||
"""
|
||||
Authenticated users should be able to list users.
|
||||
Authenticated users should not be able to list any user
|
||||
when ALLOW_UNSECURE_USER_LISTING is False.
|
||||
"""
|
||||
settings.ALLOW_UNSECURE_USER_LISTING = False
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
factories.UserFactory.create_batch(2)
|
||||
response = client.get(
|
||||
"/api/v1.0/users/",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert len(content["results"]) == 0
|
||||
|
||||
|
||||
def test_api_users_list_authenticated_unsecure(settings):
|
||||
"""
|
||||
Authenticated users should be able to list all users
|
||||
when ALLOW_UNSECURE_USER_LISTING is True.
|
||||
"""
|
||||
settings.ALLOW_UNSECURE_USER_LISTING = True
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
@@ -40,11 +62,12 @@ def test_api_users_list_authenticated():
|
||||
assert len(content["results"]) == 3
|
||||
|
||||
|
||||
def test_api_users_list_query_email():
|
||||
def test_api_users_list_query_email(settings):
|
||||
"""
|
||||
Authenticated users should be able to list users
|
||||
and filter by email.
|
||||
"""
|
||||
settings.ALLOW_UNSECURE_USER_LISTING = True
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
|
||||
@@ -12,7 +12,8 @@ from core.factories import (
|
||||
UserFactory,
|
||||
UserRecordingAccessFactory,
|
||||
)
|
||||
from core.models import Recording, RecordingStatusChoices
|
||||
from core.models import Recording, RecordingModeChoices, RecordingStatusChoices
|
||||
from core.recording.enums import FileExtension
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -216,3 +217,71 @@ def test_models_recording_worker_id_very_long():
|
||||
too_long_id = "w" * 256
|
||||
with pytest.raises(ValidationError):
|
||||
RecordingFactory(worker_id=too_long_id)
|
||||
|
||||
|
||||
# Test key property method
|
||||
|
||||
|
||||
def test_models_recording_key_for_transcript(settings):
|
||||
"""Test key property returns correct path for transcript mode."""
|
||||
|
||||
settings.RECORDING_OUTPUT_FOLDER = "/custom/path"
|
||||
|
||||
recording = RecordingFactory(mode=RecordingModeChoices.TRANSCRIPT)
|
||||
expected_path = f"/custom/path/{recording.id}.{FileExtension.OGG.value}"
|
||||
assert recording.key == expected_path
|
||||
|
||||
|
||||
def test_models_recording_key_for_screen_recording(settings):
|
||||
"""Test key property returns correct path for screen recording mode."""
|
||||
settings.RECORDING_OUTPUT_FOLDER = "/custom/path"
|
||||
|
||||
recording = RecordingFactory(mode=RecordingModeChoices.SCREEN_RECORDING)
|
||||
expected_path = f"/custom/path/{recording.id}.{FileExtension.MP4.value}"
|
||||
assert recording.key == expected_path
|
||||
|
||||
|
||||
def test_models_recording_key_for_unknown_mode(settings):
|
||||
"""Test key property uses default extension for unknown mode."""
|
||||
|
||||
settings.RECORDING_OUTPUT_FOLDER = "/custom/path"
|
||||
recording = RecordingFactory()
|
||||
# Directly set an invalid mode (bypassing validation)
|
||||
recording.mode = "unknown_mode"
|
||||
expected_path = f"/custom/path/{recording.id}.{FileExtension.MP4.value}"
|
||||
assert recording.key == expected_path
|
||||
|
||||
|
||||
# Test is_saved method
|
||||
|
||||
|
||||
def test_models_recording_is_saved_true():
|
||||
"""Test is_saved property returns True for SAVED status."""
|
||||
recording = RecordingFactory(status=RecordingStatusChoices.SAVED)
|
||||
assert recording.is_saved is True
|
||||
|
||||
|
||||
def test_models_recording_is_saved_false_active():
|
||||
"""Test is_saved property returns False for ACTIVE status."""
|
||||
recording = RecordingFactory(status=RecordingStatusChoices.ACTIVE)
|
||||
assert recording.is_saved is False
|
||||
|
||||
|
||||
def test_models_recording_is_saved_false_initiated():
|
||||
"""Test is_saved property returns False for INITIATED status."""
|
||||
recording = RecordingFactory(status=RecordingStatusChoices.INITIATED)
|
||||
assert recording.is_saved is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status",
|
||||
[
|
||||
RecordingStatusChoices.FAILED_TO_STOP,
|
||||
RecordingStatusChoices.FAILED_TO_START,
|
||||
RecordingStatusChoices.ABORTED,
|
||||
],
|
||||
)
|
||||
def test_models_recording_is_saved_false_error_states(status):
|
||||
"""Test is_saved property returns False for error statuses."""
|
||||
recording = RecordingFactory(status=status)
|
||||
assert recording.is_saved is False
|
||||
|
||||
@@ -8,7 +8,7 @@ from django.core.exceptions import ValidationError
|
||||
import pytest
|
||||
|
||||
from core.factories import RoomFactory, UserFactory
|
||||
from core.models import Room
|
||||
from core.models import Room, RoomAccessLevel
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -80,10 +80,10 @@ def test_models_rooms_users():
|
||||
assert list(room.users.all()) == [user]
|
||||
|
||||
|
||||
def test_models_rooms_is_public_default():
|
||||
def test_models_rooms_access_level_default():
|
||||
"""A room should be public by default."""
|
||||
room = Room.objects.create(name="room")
|
||||
assert room.is_public is True
|
||||
assert room.access_level == RoomAccessLevel.PUBLIC
|
||||
|
||||
|
||||
# Access rights methods
|
||||
@@ -164,3 +164,14 @@ def test_models_rooms_access_rights_owner_direct(django_assert_num_queries):
|
||||
assert room.is_administrator(user) is True
|
||||
with django_assert_num_queries(1):
|
||||
assert room.is_owner(user) is True
|
||||
|
||||
|
||||
def test_models_rooms_is_public_property():
|
||||
"""Test the is_public property returns correctly based on access_level."""
|
||||
# Test public room
|
||||
public_room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
assert public_room.is_public is True
|
||||
|
||||
# Test non-public room
|
||||
private_room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
assert private_room.is_public is False
|
||||
|
||||
@@ -11,7 +11,9 @@ from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
import botocore
|
||||
from livekit.api import AccessToken, VideoGrants
|
||||
|
||||
|
||||
@@ -37,7 +39,9 @@ def generate_color(identity: str) -> str:
|
||||
return f"hsl({hue}, {saturation}%, {lightness}%)"
|
||||
|
||||
|
||||
def generate_token(room: str, user, username: Optional[str] = None) -> str:
|
||||
def generate_token(
|
||||
room: str, user, username: Optional[str] = None, color: Optional[str] = None
|
||||
) -> str:
|
||||
"""Generate a LiveKit access token for a user in a specific room.
|
||||
|
||||
Args:
|
||||
@@ -45,6 +49,8 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
|
||||
user (User): The user which request the access token.
|
||||
username (Optional[str]): The username to be displayed in the room.
|
||||
If none, a default value will be used.
|
||||
color (Optional[str]): The color to be displayed in the room.
|
||||
If none, a value will be generated
|
||||
|
||||
Returns:
|
||||
str: The LiveKit JWT access token.
|
||||
@@ -69,6 +75,9 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
|
||||
identity = str(user.sub)
|
||||
default_username = str(user)
|
||||
|
||||
if color is None:
|
||||
color = generate_color(identity)
|
||||
|
||||
token = (
|
||||
AccessToken(
|
||||
api_key=settings.LIVEKIT_CONFIGURATION["api_key"],
|
||||
@@ -77,7 +86,59 @@ def generate_token(room: str, user, username: Optional[str] = None) -> str:
|
||||
.with_grants(video_grants)
|
||||
.with_identity(identity)
|
||||
.with_name(username or default_username)
|
||||
.with_metadata(json.dumps({"color": generate_color(identity)}))
|
||||
.with_metadata(json.dumps({"color": color}))
|
||||
)
|
||||
|
||||
return token.to_jwt()
|
||||
|
||||
|
||||
def generate_livekit_config(
|
||||
room_id: str, user, username: str, color: Optional[str] = None
|
||||
) -> dict:
|
||||
"""Generate LiveKit configuration for room access.
|
||||
|
||||
Args:
|
||||
room_id: Room identifier
|
||||
user: User instance requesting access
|
||||
username: Display name in room
|
||||
|
||||
Returns:
|
||||
dict: LiveKit configuration with URL, room and access token
|
||||
"""
|
||||
return {
|
||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||
"room": room_id,
|
||||
"token": generate_token(
|
||||
room=room_id, user=user, username=username, color=color
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def generate_s3_authorization_headers(key):
|
||||
"""
|
||||
Generate authorization headers for an s3 object.
|
||||
These headers can be used as an alternative to signed urls with many benefits:
|
||||
- the urls of our files never expire and can be stored in our recording' metadata
|
||||
- we don't leak authorized urls that could be shared (file access can only be done
|
||||
with cookies)
|
||||
- access control is truly realtime
|
||||
- the object storage service does not need to be exposed on internet
|
||||
"""
|
||||
|
||||
url = default_storage.unsigned_connection.meta.client.generate_presigned_url(
|
||||
"get_object",
|
||||
ExpiresIn=0,
|
||||
Params={"Bucket": default_storage.bucket_name, "Key": key},
|
||||
)
|
||||
|
||||
request = botocore.awsrequest.AWSRequest(method="get", url=url)
|
||||
|
||||
s3_client = default_storage.connection.meta.client
|
||||
# pylint: disable=protected-access
|
||||
credentials = s3_client._request_signer._credentials # noqa: SLF001
|
||||
frozen_credentials = credentials.get_frozen_credentials()
|
||||
region = s3_client.meta.region_name
|
||||
auth = botocore.auth.S3SigV4Auth(frozen_credentials, "s3", region)
|
||||
auth.add_auth(request)
|
||||
|
||||
return request
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-04-03 10:31+0000\n"
|
||||
"POT-Creation-Date: 2025-04-14 19:04+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -17,28 +17,32 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#: core/admin.py:31
|
||||
#: core/admin.py:26
|
||||
msgid "Personal info"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:33
|
||||
#: core/admin.py:39
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:45
|
||||
#: core/admin.py:51
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/serializers.py:128
|
||||
msgid "Markdown Body"
|
||||
#: core/api/serializers.py:60
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication.py:71
|
||||
#: core/authentication/backends.py:77
|
||||
msgid "User info contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication.py:91
|
||||
msgid "Claims contained no recognizable user identification"
|
||||
#: core/authentication/backends.py:102
|
||||
msgid "User account is disabled"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:142
|
||||
msgid "Multiple user accounts share a common email."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:27
|
||||
@@ -53,156 +57,412 @@ msgstr ""
|
||||
msgid "Owner"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:41
|
||||
msgid "id"
|
||||
#: core/models.py:45
|
||||
msgid "Initiated"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:42
|
||||
msgid "primary key for the record as UUID"
|
||||
#: core/models.py:46
|
||||
msgid "Active"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:47
|
||||
msgid "Stopped"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:48
|
||||
msgid "created on"
|
||||
msgid "Saved"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:49
|
||||
msgid "Aborted"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:50
|
||||
msgid "Failed to Start"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:51
|
||||
msgid "Failed to Stop"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:52
|
||||
msgid "Notification succeeded"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:79
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:80
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:86
|
||||
msgid "Public Access"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:87
|
||||
msgid "Trusted Access"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:88
|
||||
msgid "Restricted Access"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:100
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:101
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:107
|
||||
msgid "created on"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:108
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:54
|
||||
#: core/models.py:113
|
||||
msgid "updated on"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:55
|
||||
#: core/models.py:114
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:75
|
||||
#: core/models.py:134
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:81
|
||||
#: core/models.py:140
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:83
|
||||
#: core/models.py:142
|
||||
msgid ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:91
|
||||
#: core/models.py:150
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:96
|
||||
#: core/models.py:155
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:103
|
||||
#: core/models.py:157
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:159
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:165
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:104
|
||||
#: core/models.py:166
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:110
|
||||
#: core/models.py:172
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:113
|
||||
#: core/models.py:175
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:115
|
||||
#: core/models.py:177
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:118
|
||||
#: core/models.py:180
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:120
|
||||
#: core/models.py:182
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:123
|
||||
#: core/models.py:185
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:126
|
||||
#: core/models.py:188
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:138
|
||||
#: core/models.py:201
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:139
|
||||
#: core/models.py:202
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:161
|
||||
msgid "title"
|
||||
#: core/models.py:261
|
||||
msgid "Resource"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:162
|
||||
msgid "description"
|
||||
#: core/models.py:262
|
||||
msgid "Resources"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:163
|
||||
msgid "code"
|
||||
#: core/models.py:316
|
||||
msgid "Resource access"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:164
|
||||
msgid "css"
|
||||
#: core/models.py:317
|
||||
msgid "Resource accesses"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:166
|
||||
msgid "public"
|
||||
#: core/models.py:323
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:168
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
#: core/models.py:379
|
||||
msgid "Visio room configuration"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:174
|
||||
msgid "Template"
|
||||
#: core/models.py:380
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:175
|
||||
msgid "Templates"
|
||||
#: core/models.py:386 core/models.py:506
|
||||
msgid "Room"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:256
|
||||
msgid "Template/user relation"
|
||||
#: core/models.py:387
|
||||
msgid "Rooms"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:257
|
||||
msgid "Template/user relations"
|
||||
#: core/models.py:517
|
||||
msgid "Worker ID"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:263
|
||||
msgid "This user is already in this template."
|
||||
#: core/models.py:519
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:269
|
||||
msgid "This team is already in this template."
|
||||
#: core/models.py:527
|
||||
msgid "Recording mode"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:275
|
||||
#: core/models.py:528
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:534
|
||||
msgid "Recording"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:535
|
||||
msgid "Recordings"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:592
|
||||
msgid "Recording/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:593
|
||||
msgid "Recording/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:599
|
||||
msgid "This user is already in this recording."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:605
|
||||
msgid "This team is already in this recording."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:611
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: meet/settings.py:134
|
||||
#: core/recording/event/authentication.py:58
|
||||
msgid "Authentication is enabled but token is not configured."
|
||||
msgstr ""
|
||||
|
||||
#: core/recording/event/authentication.py:70
|
||||
msgid "Authorization header is required"
|
||||
msgstr ""
|
||||
|
||||
#: core/recording/event/authentication.py:78
|
||||
msgid "Invalid authorization header."
|
||||
msgstr ""
|
||||
|
||||
#: core/recording/event/authentication.py:88
|
||||
msgid "Invalid token"
|
||||
msgstr ""
|
||||
|
||||
#: core/recording/event/notification.py:81
|
||||
msgid "Your recording is ready"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:160
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
msgid "La Suite Numérique"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:190
|
||||
#: core/templates/mail/text/invitation.txt:5
|
||||
msgid "Invitation to join a team"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:198
|
||||
msgid "Welcome to <strong>Meet</strong>"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:216
|
||||
#: core/templates/mail/text/invitation.txt:12
|
||||
msgid "Logo"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:226
|
||||
#: core/templates/mail/text/invitation.txt:14
|
||||
msgid ""
|
||||
"We are delighted to welcome you to our community on Meet, your new companion "
|
||||
"to collaborate on documents efficiently, intuitively, and securely."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:231
|
||||
#: core/templates/mail/text/invitation.txt:15
|
||||
msgid ""
|
||||
"Our application is designed to help you organize, collaborate, and manage "
|
||||
"permissions."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:236
|
||||
#: core/templates/mail/text/invitation.txt:16
|
||||
msgid "With Meet, you will be able to:"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:237
|
||||
#: core/templates/mail/text/invitation.txt:17
|
||||
msgid "Create documents."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:238
|
||||
#: core/templates/mail/text/invitation.txt:18
|
||||
msgid "Invite members of your document or community in just a few clicks."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:249
|
||||
#: core/templates/mail/text/invitation.txt:20
|
||||
msgid "Visit Meet"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:258
|
||||
#: core/templates/mail/text/invitation.txt:22
|
||||
msgid ""
|
||||
"We are confident that Meet will help you increase efficiency and "
|
||||
"productivity while strengthening the bond among members."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:263
|
||||
#: core/templates/mail/text/invitation.txt:23
|
||||
msgid ""
|
||||
"Feel free to explore all the features of the application and share your "
|
||||
"feedback and suggestions with us. Your feedback is valuable to us and will "
|
||||
"enable us to continually improve our service."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:268
|
||||
#: core/templates/mail/text/invitation.txt:24
|
||||
msgid ""
|
||||
"Once again, welcome aboard! We are eager to accompany you on you "
|
||||
"collaboration adventure."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:275
|
||||
#: core/templates/mail/text/invitation.txt:26
|
||||
msgid "Sincerely,"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:276
|
||||
#: core/templates/mail/text/invitation.txt:28
|
||||
msgid "The La Suite Numérique Team"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:159
|
||||
#: core/templates/mail/text/screen_recording.txt:3
|
||||
msgid "Logo email"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:188
|
||||
#: core/templates/mail/text/screen_recording.txt:6
|
||||
msgid "Your recording is ready!"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:195
|
||||
#: core/templates/mail/text/screen_recording.txt:8
|
||||
#, python-format
|
||||
msgid ""
|
||||
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
|
||||
"%(recording_time)s is now ready to download. "
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:201
|
||||
#: core/templates/mail/text/screen_recording.txt:10
|
||||
msgid "To keep this recording permanently:"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:203
|
||||
#: core/templates/mail/text/screen_recording.txt:12
|
||||
msgid "Click the \\"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:204
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
msgid "Use the \\"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:205
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
msgid "Save the file to your preferred location"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:216
|
||||
#: core/templates/mail/text/screen_recording.txt:16
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:225
|
||||
#: core/templates/mail/text/screen_recording.txt:18
|
||||
#, python-format
|
||||
msgid ""
|
||||
" If you have any questions or need assistance, please contact our support "
|
||||
"team at %(support_email)s. "
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:240
|
||||
#: core/templates/mail/text/screen_recording.txt:22
|
||||
#, python-format
|
||||
msgid " Thank you for using %(brandname)s. "
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/text/invitation.txt:8
|
||||
msgid "Welcome to Meet"
|
||||
msgstr ""
|
||||
|
||||
#: meet/settings.py:161
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: meet/settings.py:135
|
||||
#: meet/settings.py:162
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
@@ -8,7 +8,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-04-03 10:31+0000\n"
|
||||
"POT-Creation-Date: 2025-04-14 19:04+0000\n"
|
||||
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language-Team: LANGUAGE <LL@li.org>\n"
|
||||
@@ -17,28 +17,32 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
|
||||
#: core/admin.py:31
|
||||
#: core/admin.py:26
|
||||
msgid "Personal info"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:33
|
||||
#: core/admin.py:39
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
|
||||
#: core/admin.py:45
|
||||
#: core/admin.py:51
|
||||
msgid "Important dates"
|
||||
msgstr ""
|
||||
|
||||
#: core/api/serializers.py:128
|
||||
msgid "Markdown Body"
|
||||
#: core/api/serializers.py:60
|
||||
msgid "You must be administrator or owner of a room to add accesses to it."
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication.py:71
|
||||
#: core/authentication/backends.py:77
|
||||
msgid "User info contained no recognizable user identification"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication.py:91
|
||||
msgid "Claims contained no recognizable user identification"
|
||||
#: core/authentication/backends.py:102
|
||||
msgid "User account is disabled"
|
||||
msgstr ""
|
||||
|
||||
#: core/authentication/backends.py:142
|
||||
msgid "Multiple user accounts share a common email."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:27
|
||||
@@ -53,156 +57,412 @@ msgstr ""
|
||||
msgid "Owner"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:41
|
||||
msgid "id"
|
||||
#: core/models.py:45
|
||||
msgid "Initiated"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:42
|
||||
msgid "primary key for the record as UUID"
|
||||
#: core/models.py:46
|
||||
msgid "Active"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:47
|
||||
msgid "Stopped"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:48
|
||||
msgid "created on"
|
||||
msgid "Saved"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:49
|
||||
msgid "Aborted"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:50
|
||||
msgid "Failed to Start"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:51
|
||||
msgid "Failed to Stop"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:52
|
||||
msgid "Notification succeeded"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:79
|
||||
msgid "SCREEN_RECORDING"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:80
|
||||
msgid "TRANSCRIPT"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:86
|
||||
msgid "Public Access"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:87
|
||||
msgid "Trusted Access"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:88
|
||||
msgid "Restricted Access"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:100
|
||||
msgid "id"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:101
|
||||
msgid "primary key for the record as UUID"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:107
|
||||
msgid "created on"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:108
|
||||
msgid "date and time at which a record was created"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:54
|
||||
#: core/models.py:113
|
||||
msgid "updated on"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:55
|
||||
#: core/models.py:114
|
||||
msgid "date and time at which a record was last updated"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:75
|
||||
#: core/models.py:134
|
||||
msgid ""
|
||||
"Enter a valid sub. This value may contain only letters, numbers, and @/./+/-/"
|
||||
"_ characters."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:81
|
||||
#: core/models.py:140
|
||||
msgid "sub"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:83
|
||||
#: core/models.py:142
|
||||
msgid ""
|
||||
"Required. 255 characters or fewer. Letters, numbers, and @/./+/-/_ "
|
||||
"characters only."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:91
|
||||
#: core/models.py:150
|
||||
msgid "identity email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:96
|
||||
#: core/models.py:155
|
||||
msgid "admin email address"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:103
|
||||
#: core/models.py:157
|
||||
msgid "full name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:159
|
||||
msgid "short name"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:165
|
||||
msgid "language"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:104
|
||||
#: core/models.py:166
|
||||
msgid "The language in which the user wants to see the interface."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:110
|
||||
#: core/models.py:172
|
||||
msgid "The timezone in which the user wants to see times."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:113
|
||||
#: core/models.py:175
|
||||
msgid "device"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:115
|
||||
#: core/models.py:177
|
||||
msgid "Whether the user is a device or a real user."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:118
|
||||
#: core/models.py:180
|
||||
msgid "staff status"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:120
|
||||
#: core/models.py:182
|
||||
msgid "Whether the user can log into this admin site."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:123
|
||||
#: core/models.py:185
|
||||
msgid "active"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:126
|
||||
#: core/models.py:188
|
||||
msgid ""
|
||||
"Whether this user should be treated as active. Unselect this instead of "
|
||||
"deleting accounts."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:138
|
||||
#: core/models.py:201
|
||||
msgid "user"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:139
|
||||
#: core/models.py:202
|
||||
msgid "users"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:161
|
||||
msgid "title"
|
||||
#: core/models.py:261
|
||||
msgid "Resource"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:162
|
||||
msgid "description"
|
||||
#: core/models.py:262
|
||||
msgid "Resources"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:163
|
||||
msgid "code"
|
||||
#: core/models.py:316
|
||||
msgid "Resource access"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:164
|
||||
msgid "css"
|
||||
#: core/models.py:317
|
||||
msgid "Resource accesses"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:166
|
||||
msgid "public"
|
||||
#: core/models.py:323
|
||||
msgid "Resource access with this User and Resource already exists."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:168
|
||||
msgid "Whether this template is public for anyone to use."
|
||||
#: core/models.py:379
|
||||
msgid "Visio room configuration"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:174
|
||||
msgid "Template"
|
||||
#: core/models.py:380
|
||||
msgid "Values for Visio parameters to configure the room."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:175
|
||||
msgid "Templates"
|
||||
#: core/models.py:386 core/models.py:506
|
||||
msgid "Room"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:256
|
||||
msgid "Template/user relation"
|
||||
#: core/models.py:387
|
||||
msgid "Rooms"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:257
|
||||
msgid "Template/user relations"
|
||||
#: core/models.py:517
|
||||
msgid "Worker ID"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:263
|
||||
msgid "This user is already in this template."
|
||||
#: core/models.py:519
|
||||
msgid ""
|
||||
"Enter an identifier for the worker recording.This ID is retained even when "
|
||||
"the worker stops, allowing for easy tracking."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:269
|
||||
msgid "This team is already in this template."
|
||||
#: core/models.py:527
|
||||
msgid "Recording mode"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:275
|
||||
#: core/models.py:528
|
||||
msgid "Defines the mode of recording being called."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:534
|
||||
msgid "Recording"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:535
|
||||
msgid "Recordings"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:592
|
||||
msgid "Recording/user relation"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:593
|
||||
msgid "Recording/user relations"
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:599
|
||||
msgid "This user is already in this recording."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:605
|
||||
msgid "This team is already in this recording."
|
||||
msgstr ""
|
||||
|
||||
#: core/models.py:611
|
||||
msgid "Either user or team must be set, not both."
|
||||
msgstr ""
|
||||
|
||||
#: meet/settings.py:134
|
||||
#: core/recording/event/authentication.py:58
|
||||
msgid "Authentication is enabled but token is not configured."
|
||||
msgstr ""
|
||||
|
||||
#: core/recording/event/authentication.py:70
|
||||
msgid "Authorization header is required"
|
||||
msgstr ""
|
||||
|
||||
#: core/recording/event/authentication.py:78
|
||||
msgid "Invalid authorization header."
|
||||
msgstr ""
|
||||
|
||||
#: core/recording/event/authentication.py:88
|
||||
msgid "Invalid token"
|
||||
msgstr ""
|
||||
|
||||
#: core/recording/event/notification.py:81
|
||||
msgid "Your recording is ready"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:160
|
||||
#: core/templates/mail/text/invitation.txt:3
|
||||
msgid "La Suite Numérique"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:190
|
||||
#: core/templates/mail/text/invitation.txt:5
|
||||
msgid "Invitation to join a team"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:198
|
||||
msgid "Welcome to <strong>Meet</strong>"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:216
|
||||
#: core/templates/mail/text/invitation.txt:12
|
||||
msgid "Logo"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:226
|
||||
#: core/templates/mail/text/invitation.txt:14
|
||||
msgid ""
|
||||
"We are delighted to welcome you to our community on Meet, your new companion "
|
||||
"to collaborate on documents efficiently, intuitively, and securely."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:231
|
||||
#: core/templates/mail/text/invitation.txt:15
|
||||
msgid ""
|
||||
"Our application is designed to help you organize, collaborate, and manage "
|
||||
"permissions."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:236
|
||||
#: core/templates/mail/text/invitation.txt:16
|
||||
msgid "With Meet, you will be able to:"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:237
|
||||
#: core/templates/mail/text/invitation.txt:17
|
||||
msgid "Create documents."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:238
|
||||
#: core/templates/mail/text/invitation.txt:18
|
||||
msgid "Invite members of your document or community in just a few clicks."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:249
|
||||
#: core/templates/mail/text/invitation.txt:20
|
||||
msgid "Visit Meet"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:258
|
||||
#: core/templates/mail/text/invitation.txt:22
|
||||
msgid ""
|
||||
"We are confident that Meet will help you increase efficiency and "
|
||||
"productivity while strengthening the bond among members."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:263
|
||||
#: core/templates/mail/text/invitation.txt:23
|
||||
msgid ""
|
||||
"Feel free to explore all the features of the application and share your "
|
||||
"feedback and suggestions with us. Your feedback is valuable to us and will "
|
||||
"enable us to continually improve our service."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:268
|
||||
#: core/templates/mail/text/invitation.txt:24
|
||||
msgid ""
|
||||
"Once again, welcome aboard! We are eager to accompany you on you "
|
||||
"collaboration adventure."
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:275
|
||||
#: core/templates/mail/text/invitation.txt:26
|
||||
msgid "Sincerely,"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/invitation.html:276
|
||||
#: core/templates/mail/text/invitation.txt:28
|
||||
msgid "The La Suite Numérique Team"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:159
|
||||
#: core/templates/mail/text/screen_recording.txt:3
|
||||
msgid "Logo email"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:188
|
||||
#: core/templates/mail/text/screen_recording.txt:6
|
||||
msgid "Your recording is ready!"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:195
|
||||
#: core/templates/mail/text/screen_recording.txt:8
|
||||
#, python-format
|
||||
msgid ""
|
||||
" Your recording of \"%(room_name)s\" on %(recording_date)s at "
|
||||
"%(recording_time)s is now ready to download. "
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:201
|
||||
#: core/templates/mail/text/screen_recording.txt:10
|
||||
msgid "To keep this recording permanently:"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:203
|
||||
#: core/templates/mail/text/screen_recording.txt:12
|
||||
msgid "Click the \\"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:204
|
||||
#: core/templates/mail/text/screen_recording.txt:13
|
||||
msgid "Use the \\"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:205
|
||||
#: core/templates/mail/text/screen_recording.txt:14
|
||||
msgid "Save the file to your preferred location"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:216
|
||||
#: core/templates/mail/text/screen_recording.txt:16
|
||||
msgid "Open"
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:225
|
||||
#: core/templates/mail/text/screen_recording.txt:18
|
||||
#, python-format
|
||||
msgid ""
|
||||
" If you have any questions or need assistance, please contact our support "
|
||||
"team at %(support_email)s. "
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/html/screen_recording.html:240
|
||||
#: core/templates/mail/text/screen_recording.txt:22
|
||||
#, python-format
|
||||
msgid " Thank you for using %(brandname)s. "
|
||||
msgstr ""
|
||||
|
||||
#: core/templates/mail/text/invitation.txt:8
|
||||
msgid "Welcome to Meet"
|
||||
msgstr ""
|
||||
|
||||
#: meet/settings.py:161
|
||||
msgid "English"
|
||||
msgstr ""
|
||||
|
||||
#: meet/settings.py:135
|
||||
#: meet/settings.py:162
|
||||
msgid "French"
|
||||
msgstr ""
|
||||
|
||||
@@ -19,6 +19,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
import sentry_sdk
|
||||
from configurations import Configuration, values
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
from sentry_sdk.integrations.logging import ignore_logger
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
@@ -73,6 +74,9 @@ class Base(Configuration):
|
||||
ALLOWED_HOSTS = values.ListValue([])
|
||||
SECRET_KEY = values.Value(None)
|
||||
SILENCED_SYSTEM_CHECKS = values.ListValue([])
|
||||
ALLOW_UNSECURE_USER_LISTING = values.BooleanValue(
|
||||
False, environ_name="ALLOW_UNSECURE_USER_LISTING", environ_prefix=None
|
||||
)
|
||||
|
||||
# Application definition
|
||||
ROOT_URLCONF = "meet.urls"
|
||||
@@ -235,7 +239,17 @@ class Base(Configuration):
|
||||
|
||||
# Cache
|
||||
CACHES = {
|
||||
"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"},
|
||||
"default": {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": values.Value(
|
||||
"redis://redis:6379/1",
|
||||
environ_name="REDIS_URL",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
@@ -252,6 +266,18 @@ class Base(Configuration):
|
||||
"PAGE_SIZE": 20,
|
||||
"DEFAULT_VERSIONING_CLASS": "rest_framework.versioning.URLPathVersioning",
|
||||
"DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema",
|
||||
"DEFAULT_THROTTLE_RATES": {
|
||||
"request_entry": values.Value(
|
||||
default="150/minute",
|
||||
environ_name="REQUEST_ENTRY_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"creation_callback": values.Value(
|
||||
default="600/minute",
|
||||
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
SPECTACULAR_SETTINGS = {
|
||||
@@ -281,6 +307,9 @@ class Base(Configuration):
|
||||
"silence_livekit_debug_logs": values.BooleanValue(
|
||||
False, environ_name="FRONTEND_SILENCE_LIVEKIT_DEBUG", environ_prefix=None
|
||||
),
|
||||
"is_silent_login_enabled": values.BooleanValue(
|
||||
True, environ_name="FRONTEND_IS_SILENT_LOGING_ENABLED", environ_prefix=None
|
||||
),
|
||||
}
|
||||
|
||||
# Mail
|
||||
@@ -292,9 +321,12 @@ class Base(Configuration):
|
||||
EMAIL_USE_TLS = values.BooleanValue(False)
|
||||
EMAIL_USE_SSL = values.BooleanValue(False)
|
||||
EMAIL_FROM = values.Value("from@example.com")
|
||||
EMAIL_BRAND_NAME = values.Value(None)
|
||||
EMAIL_SUPPORT_EMAIL = values.Value(None)
|
||||
EMAIL_LOGO_IMG = values.Value(None)
|
||||
EMAIL_DOMAIN = values.Value(None)
|
||||
|
||||
AUTH_USER_MODEL = "core.User"
|
||||
INVITATION_VALIDITY_DURATION = 604800 # 7 days, in seconds
|
||||
|
||||
# CORS
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
@@ -411,8 +443,8 @@ class Base(Configuration):
|
||||
),
|
||||
"url": values.Value(environ_name="LIVEKIT_API_URL", environ_prefix=None),
|
||||
}
|
||||
RESOURCE_DEFAULT_IS_PUBLIC = values.BooleanValue(
|
||||
True, environ_name="RESOURCE_DEFAULT_IS_PUBLIC", environ_prefix=None
|
||||
RESOURCE_DEFAULT_ACCESS_LEVEL = values.Value(
|
||||
"public", environ_name="RESOURCE_DEFAULT_ACCESS_LEVEL", environ_prefix=None
|
||||
)
|
||||
ALLOW_UNREGISTERED_ROOMS = values.BooleanValue(
|
||||
True, environ_name="ALLOW_UNREGISTERED_ROOMS", environ_prefix=None
|
||||
@@ -456,19 +488,18 @@ class Base(Configuration):
|
||||
SUMMARY_SERVICE_API_TOKEN = values.Value(
|
||||
None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None
|
||||
)
|
||||
SCREEN_RECORDING_BASE_URL = values.Value(
|
||||
None, environ_name="SUMMARY_SERVICE_API_TOKEN", environ_prefix=None
|
||||
)
|
||||
|
||||
# Marketing and communication settings
|
||||
SIGNUP_NEW_USER_TO_MARKETING_EMAIL = values.BooleanValue(
|
||||
False,
|
||||
environ_name="SIGNUP_NEW_USERS_TO_NEWSLETTER",
|
||||
False, # When enabled, new users are automatically added to mailing list.
|
||||
environ_name="SIGNUP_NEW_USER_TO_MARKETING_EMAIL",
|
||||
environ_prefix=None,
|
||||
help_text=(
|
||||
"When enabled, new users are automatically added to mailing list "
|
||||
"for product updates, marketing communications, and customized emails. "
|
||||
),
|
||||
)
|
||||
MARKETING_SERVICE_CLASS = values.Value(
|
||||
"core.services.marketing_service.BrevoMarketingService",
|
||||
"core.services.marketing.BrevoMarketingService",
|
||||
environ_name="MARKETING_SERVICE_CLASS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
@@ -482,6 +513,42 @@ class Base(Configuration):
|
||||
converter=lambda x: int(x), # pylint: disable=unnecessary-lambda
|
||||
)
|
||||
BREVO_API_CONTACT_ATTRIBUTES = values.DictValue({"VISIO_USER": True})
|
||||
BREVO_API_TIMEOUT = values.PositiveIntegerValue(
|
||||
1, environ_name="BREVO_API_TIMEOUT", environ_prefix=None
|
||||
)
|
||||
|
||||
# Lobby configurations
|
||||
LOBBY_KEY_PREFIX = values.Value(
|
||||
"room_lobby", environ_name="LOBBY_KEY_PREFIX", environ_prefix=None
|
||||
)
|
||||
LOBBY_WAITING_TIMEOUT = values.PositiveIntegerValue(
|
||||
3, environ_name="LOBBY_WAITING_TIMEOUT", environ_prefix=None
|
||||
)
|
||||
LOBBY_DENIED_TIMEOUT = values.PositiveIntegerValue(
|
||||
5, environ_name="LOBBY_DENIED_TIMEOUT", environ_prefix=None
|
||||
)
|
||||
LOBBY_ACCEPTED_TIMEOUT = values.PositiveIntegerValue(
|
||||
21600, # 6hrs
|
||||
environ_name="LOBBY_ACCEPTED_TIMEOUT",
|
||||
environ_prefix=None,
|
||||
)
|
||||
LOBBY_NOTIFICATION_TYPE = values.Value(
|
||||
"participantWaiting",
|
||||
environ_name="LOBBY_NOTIFICATION_TYPE",
|
||||
environ_prefix=None,
|
||||
)
|
||||
LOBBY_COOKIE_NAME = values.Value(
|
||||
"lobbyParticipantId",
|
||||
environ_name="LOBBY_COOKIE_NAME",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Calendar integrations
|
||||
ROOM_CREATION_CALLBACK_CACHE_TIMEOUT = values.PositiveIntegerValue(
|
||||
600, # 10 minutes
|
||||
environ_name="ROOM_CREATION_CALLBACK_CACHE_TIMEOUT",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
@property
|
||||
@@ -534,6 +601,9 @@ class Base(Configuration):
|
||||
scope = sentry_sdk.get_global_scope()
|
||||
scope.set_tag("application", "backend")
|
||||
|
||||
# Ignore the logs added by the DockerflowMiddleware
|
||||
ignore_logger("request.summary")
|
||||
|
||||
|
||||
class Build(Base):
|
||||
"""Settings used when the application is built.
|
||||
|
||||
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "0.1.12"
|
||||
version = "0.1.18"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -25,40 +25,38 @@ license = { file = "LICENSE" }
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"boto3==1.35.90",
|
||||
"boto3==1.37.24",
|
||||
"Brotli==1.1.0",
|
||||
"brevo-python==1.1.2",
|
||||
"celery[redis]==5.4.0",
|
||||
"django-configurations==2.5.1",
|
||||
"django-cors-headers==4.6.0",
|
||||
"django-cors-headers==4.7.0",
|
||||
"django-countries==7.6.1",
|
||||
"django-parler==2.3",
|
||||
"redis==5.2.1",
|
||||
"django-redis==5.4.0",
|
||||
"django-storages[s3]==1.14.4",
|
||||
"django-storages[s3]==1.14.5",
|
||||
"django-timezone-field>=5.1",
|
||||
"django==5.1.4",
|
||||
"django==5.1.8",
|
||||
"djangorestframework==3.15.2",
|
||||
"drf_spectacular==0.28.0",
|
||||
"dockerflow==2024.4.2",
|
||||
"easy_thumbnails==2.10",
|
||||
"factory_boy==3.3.1",
|
||||
"factory_boy==3.3.3",
|
||||
"gunicorn==23.0.0",
|
||||
"jsonschema==4.23.0",
|
||||
"june-analytics-python==2.3.0",
|
||||
"markdown==3.7",
|
||||
"nested-multipart-parser==1.5.0",
|
||||
"psycopg[binary]==3.2.3",
|
||||
"psycopg[binary]==3.2.6",
|
||||
"PyJWT==2.10.1",
|
||||
"python-frontmatter==1.1.0",
|
||||
"requests==2.32.3",
|
||||
"sentry-sdk==2.19.2",
|
||||
"url-normalize==1.4.3",
|
||||
"WeasyPrint>=60.2",
|
||||
"whitenoise==6.8.2",
|
||||
"sentry-sdk==2.24.1",
|
||||
"whitenoise==6.9.0",
|
||||
"mozilla-django-oidc==4.0.1",
|
||||
"livekit-api==0.8.1",
|
||||
"aiohttp==3.11.11",
|
||||
"livekit-api==0.8.2",
|
||||
"aiohttp==3.11.14",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -70,21 +68,21 @@ dependencies = [
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"django-extensions==3.2.3",
|
||||
"drf-spectacular-sidecar==2024.12.1",
|
||||
"drf-spectacular-sidecar==2025.3.1",
|
||||
"freezegun==1.5.1",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==8.31.0",
|
||||
"pyfakefs==5.7.3",
|
||||
"ipython==9.0.2",
|
||||
"pyfakefs==5.8.0",
|
||||
"pylint-django==2.6.1",
|
||||
"pylint==3.3.3",
|
||||
"pylint==3.3.6",
|
||||
"pytest-cov==6.0.0",
|
||||
"pytest-django==4.9.0",
|
||||
"pytest==8.3.4",
|
||||
"pytest-django==4.10.0",
|
||||
"pytest==8.3.5",
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.6.1",
|
||||
"responses==0.25.3",
|
||||
"ruff==0.8.4",
|
||||
"types-requests==2.32.0.20241016",
|
||||
"responses==0.25.7",
|
||||
"ruff==0.11.2",
|
||||
"types-requests==2.32.0.20250306",
|
||||
]
|
||||
|
||||
[tool.setuptools]
|
||||
|
||||
@@ -29,11 +29,19 @@ FROM meet AS meet-builder
|
||||
|
||||
WORKDIR /home/frontend
|
||||
|
||||
ARG VITE_API_BASE_URL
|
||||
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# ---- Front-end image ----
|
||||
FROM nginxinc/nginx-unprivileged:1.26-alpine AS frontend-production
|
||||
|
||||
USER root
|
||||
RUN apk update && apk upgrade libssl3 libcrypto3 libxml2>=2.12.7-r2 libxslt>=1.1.39-r2
|
||||
|
||||
USER nginx
|
||||
|
||||
# Un-privileged user running the application
|
||||
ARG DOCKER_USER
|
||||
USER ${DOCKER_USER}
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"input": ["src/**/*.{ts,tsx}", "!src/styled-system/**/*", "!src/**/*.d.ts"],
|
||||
"output": "src/locales/$LOCALE/$NAMESPACE.json",
|
||||
"createOldCatalogs": false,
|
||||
"locales": ["en", "fr", "de"],
|
||||
"locales": ["en", "fr", "de", "nl"],
|
||||
"sort": true
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "0.1.12",
|
||||
"version": "0.1.18",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
@@ -13,48 +13,48 @@
|
||||
"check": "prettier --check ./src"
|
||||
},
|
||||
"dependencies": {
|
||||
"@livekit/components-react": "2.6.9",
|
||||
"@livekit/components-react": "2.8.1",
|
||||
"@livekit/components-styles": "1.1.4",
|
||||
"@livekit/track-processors": "0.3.2",
|
||||
"@pandacss/preset-panda": "0.48.0",
|
||||
"@react-aria/toast": "3.0.0-beta.18",
|
||||
"@remixicon/react": "4.5.0",
|
||||
"@tanstack/react-query": "5.61.3",
|
||||
"@livekit/track-processors": "0.3.3",
|
||||
"@pandacss/preset-panda": "0.53.3",
|
||||
"@react-aria/toast": "3.0.1",
|
||||
"@remixicon/react": "4.6.0",
|
||||
"@tanstack/react-query": "5.69.0",
|
||||
"crisp-sdk-web": "1.0.25",
|
||||
"hoofd": "1.7.1",
|
||||
"i18next": "24.0.2",
|
||||
"i18next-browser-languagedetector": "8.0.0",
|
||||
"i18next-parser": "9.0.2",
|
||||
"hoofd": "1.7.3",
|
||||
"i18next": "24.2.3",
|
||||
"i18next-browser-languagedetector": "8.0.4",
|
||||
"i18next-parser": "9.3.0",
|
||||
"i18next-resources-to-backend": "1.2.1",
|
||||
"livekit-client": "2.6.3",
|
||||
"posthog-js": "1.188.0",
|
||||
"livekit-client": "2.9.8",
|
||||
"posthog-js": "1.232.6",
|
||||
"react": "18.3.1",
|
||||
"react-aria-components": "1.5.0",
|
||||
"react-aria-components": "1.7.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-i18next": "15.1.1",
|
||||
"use-sound": "4.0.3",
|
||||
"valtio": "2.1.2",
|
||||
"wouter": "3.3.5"
|
||||
"use-sound": "5.0.0",
|
||||
"valtio": "2.1.4",
|
||||
"wouter": "3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@pandacss/dev": "0.48.0",
|
||||
"@tanstack/eslint-plugin-query": "5.61.3",
|
||||
"@tanstack/react-query-devtools": "5.61.3",
|
||||
"@types/node": "22.9.3",
|
||||
"@pandacss/dev": "0.53.3",
|
||||
"@tanstack/eslint-plugin-query": "5.68.0",
|
||||
"@tanstack/react-query-devtools": "5.69.0",
|
||||
"@types/node": "22.13.13",
|
||||
"@types/react": "18.3.12",
|
||||
"@types/react-dom": "18.3.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.15.0",
|
||||
"@typescript-eslint/parser": "8.15.0",
|
||||
"@vitejs/plugin-react": "4.3.3",
|
||||
"@typescript-eslint/eslint-plugin": "8.28.0",
|
||||
"@typescript-eslint/parser": "8.28.0",
|
||||
"@vitejs/plugin-react": "4.3.4",
|
||||
"eslint": "8.57.0",
|
||||
"eslint-config-prettier": "9.1.0",
|
||||
"eslint-config-prettier": "10.1.1",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"eslint-plugin-react-hooks": "5.0.0",
|
||||
"eslint-plugin-react-refresh": "0.4.14",
|
||||
"postcss": "8.4.49",
|
||||
"prettier": "3.3.3",
|
||||
"typescript": "5.7.2",
|
||||
"vite": "5.4.11",
|
||||
"vite-tsconfig-paths": "5.1.3"
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"eslint-plugin-react-refresh": "0.4.19",
|
||||
"postcss": "8.5.3",
|
||||
"prettier": "3.5.3",
|
||||
"typescript": "5.8.2",
|
||||
"vite": "6.2.4",
|
||||
"vite-tsconfig-paths": "5.1.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,40 +35,7 @@ const config: Config = {
|
||||
exclude: [],
|
||||
jsxFramework: 'react',
|
||||
outdir: 'src/styled-system',
|
||||
globalFontface: {
|
||||
Marianne: [
|
||||
{
|
||||
src: 'url(/fonts/Marianne-Regular-subset.woff2) format("woff2")',
|
||||
fontWeight: 400,
|
||||
fontStyle: 'normal',
|
||||
fontDisplay: 'swap',
|
||||
},
|
||||
{
|
||||
path: 'url(/fonts/Marianne-Regular_Italic-subset.woff2) format("woff2")',
|
||||
fontWeight: 400,
|
||||
fontStyle: 'italic',
|
||||
fontDisplay: 'swap',
|
||||
},
|
||||
{
|
||||
path: 'url(/fonts/Marianne-Medium-subset.woff2) format("woff2")',
|
||||
fontWeight: 500,
|
||||
fontStyle: 'normal',
|
||||
fontDisplay: 'swap',
|
||||
},
|
||||
{
|
||||
path: 'url(/fonts/Marianne-Bold-subset.woff2) format("woff2")',
|
||||
fontWeight: 700,
|
||||
fontStyle: 'normal',
|
||||
fontDisplay: 'swap',
|
||||
},
|
||||
{
|
||||
path: 'url(/fonts/Marianne-ExtraBold-subset.woff2) format("woff2")',
|
||||
fontWeight: 800,
|
||||
fontStyle: 'normal',
|
||||
fontDisplay: 'swap',
|
||||
},
|
||||
],
|
||||
},
|
||||
globalFontface: {},
|
||||
theme: {
|
||||
...pandaPreset.theme,
|
||||
// media queries are defined in em so that zooming with text-only mode triggers breakpoints
|
||||
@@ -121,6 +88,32 @@ const config: Config = {
|
||||
'50%': { opacity: '0.65' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
rotate: {
|
||||
'0%': {
|
||||
transform: 'rotate(0deg)',
|
||||
},
|
||||
'100%': {
|
||||
transform: 'rotate(360deg)',
|
||||
},
|
||||
},
|
||||
prixClipFix: {
|
||||
'0%': {
|
||||
clipPath: 'polygon(50% 50%, 0 0, 0 0, 0 0, 0 0, 0 0)',
|
||||
},
|
||||
'25%': {
|
||||
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 0, 100% 0, 100% 0)',
|
||||
},
|
||||
'50%': {
|
||||
clipPath:
|
||||
'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 100% 100%, 100% 100%)',
|
||||
},
|
||||
'75%': {
|
||||
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 100%)',
|
||||
},
|
||||
'100%': {
|
||||
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 0)',
|
||||
},
|
||||
},
|
||||
},
|
||||
tokens: defineTokens({
|
||||
/* we take a few things from the panda preset but for now we clear out some stuff.
|
||||
@@ -197,6 +190,7 @@ const config: Config = {
|
||||
fonts: {
|
||||
sans: {
|
||||
value: [
|
||||
'Marianne',
|
||||
'Source Sans',
|
||||
'Source Sans fallback',
|
||||
'ui-sans-serif',
|
||||
|
||||
|
After Width: | Height: | Size: 958 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 955 KiB |
|
After Width: | Height: | Size: 986 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 457 B |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 27 KiB |
@@ -17,6 +17,7 @@ import { AppInitialization } from '@/components/AppInitialization'
|
||||
function App() {
|
||||
const { i18n } = useTranslation()
|
||||
useLang(i18n.language)
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppInitialization />
|
||||
@@ -30,7 +31,10 @@ function App() {
|
||||
<Route component={NotFoundScreen} />
|
||||
</Switch>
|
||||
</Layout>
|
||||
<ReactQueryDevtools initialIsOpen={false} buttonPosition="top-left" />
|
||||
<ReactQueryDevtools
|
||||
initialIsOpen={false}
|
||||
buttonPosition="bottom-left"
|
||||
/>
|
||||
</I18nProvider>
|
||||
</Suspense>
|
||||
</QueryClientProvider>
|
||||
|
||||
@@ -2,4 +2,7 @@ export const keys = {
|
||||
user: 'user',
|
||||
room: 'room',
|
||||
config: 'config',
|
||||
requestEntry: 'requestEntry',
|
||||
waitingParticipants: 'waitingParticipants',
|
||||
roomCreationCallback: 'roomCreationCallback',
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { fetchApi } from './fetchApi'
|
||||
import { keys } from './queryKeys'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { RecordingMode } from '@/features/rooms/api/startRecording'
|
||||
import { RecordingMode } from '@/features/recording'
|
||||
|
||||
export interface ApiConfig {
|
||||
analytics?: {
|
||||
@@ -12,6 +12,7 @@ export interface ApiConfig {
|
||||
id: string
|
||||
}
|
||||
silence_livekit_debug_logs?: boolean
|
||||
is_silent_login_enabled?: boolean
|
||||
recording?: {
|
||||
is_enabled?: boolean
|
||||
available_modes?: RecordingMode[]
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export const VisioIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M0.841702 8.95427C0.75 9.42479 0.75 10.0042 0.75 10.9748V13.3096C0.75 14.5138 0.75 15.1159 0.925134 15.6549C1.08009 16.1319 1.33356 16.5709 1.6691 16.9435C2.04833 17.3647 2.56977 17.6658 3.61264 18.2679L5.6346 19.4353C6.55753 19.9681 7.07208 20.2652 7.56247 20.4081V13.2043C7.56247 12.8442 7.36578 12.5129 7.04965 12.3404L0.841702 8.95427Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M17.747 10.0123V13.7685C17.747 14.2878 17.9727 14.7815 18.3654 15.1214L21.0688 17.4609C21.227 17.5947 21.3912 17.7042 21.5616 17.7894C21.738 17.8685 21.9084 17.9081 22.0726 17.9081C22.4255 17.9081 22.7084 17.7925 22.9213 17.5613C23.1404 17.324 23.2499 17.0168 23.2499 16.6396V7.14866C23.2499 6.77146 23.1404 6.46726 22.9213 6.23607C22.7084 5.9988 22.4255 5.88017 22.0726 5.88017C21.9084 5.88017 21.738 5.91971 21.5616 5.9988C21.3912 6.07789 21.227 6.1874 21.0688 6.32733L18.3675 8.65759C17.9735 8.99746 17.747 9.49201 17.747 10.0123Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M1.74517 7.61282C1.67 7.57182 1.59117 7.54405 1.51135 7.52872C1.56171 7.46443 1.61433 7.40178 1.66914 7.34091C2.04837 6.91973 2.56981 6.61868 3.61268 6.01657L5.63464 4.84919C6.67751 4.24708 7.19894 3.94603 7.7533 3.82819C8.2438 3.72394 8.75074 3.72394 9.24124 3.82819C9.7956 3.94603 10.317 4.24708 11.3599 4.84919L13.358 6.0028C13.366 6.00738 13.374 6.01197 13.3819 6.01658C14.4248 6.61868 14.9462 6.91973 15.3255 7.34091C15.661 7.71357 15.9145 8.15259 16.0694 8.62951C16.2446 9.16852 16.2446 9.77063 16.2446 10.9748V13.3096C16.2446 14.5138 16.2446 15.1159 16.0694 15.6549C15.9145 16.1319 15.661 16.5709 15.3255 16.9435C14.9462 17.3647 14.4248 17.6657 13.382 18.2678C13.373 18.273 13.364 18.2782 13.3551 18.2833L11.3599 19.4353C10.317 20.0374 9.7956 20.3384 9.24124 20.4562C9.21846 20.4611 9.19564 20.4657 9.17279 20.4701L9.17278 13.2043C9.17278 12.255 8.65422 11.3814 7.82079 10.9268L1.74517 7.61282Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||