mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-29 14:09:17 +00:00
Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8017b03436 | |||
| 1a728cf049 | |||
| 4071984f8e | |||
| 2ae602606c | |||
| f28389b624 | |||
| cbb8740f41 | |||
| b4b9fe54fb | |||
| 88685d613a | |||
| 6cde1b4461 | |||
| 68fe3f96fc | |||
| 3f9942a61d | |||
| 62a2515c6b | |||
| fa9b30c6bf | |||
| 4d9ee4e9c5 | |||
| 72cd5a8f18 | |||
| 21dc63b8ce | |||
| 8d5d42cfdb | |||
| 2480a76b62 | |||
| 31c8f3ec06 | |||
| a8c4aee0c2 | |||
| 9a2ad63524 | |||
| 67f9e54784 | |||
| 5d3255ddbc | |||
| d89b01b681 | |||
| 660c0ed684 | |||
| 8d980192c8 | |||
| de1f7158f5 | |||
| 6e17c6533c | |||
| 27e32c0370 | |||
| 6d4403d4fa | |||
| 37ae308825 | |||
| 16fd2dc4e8 | |||
| 9791a8a3b2 | |||
| 5b0dece79b | |||
| 96135a0263 | |||
| ce2e2a4d64 | |||
| e5dc9c2f15 | |||
| e97eab9b5e | |||
| 436b3dc9df | |||
| 6ea2a85810 | |||
| 3d1ea88e8f | |||
| beb74af574 | |||
| c785b4a627 | |||
| a9a4246abb | |||
| 3cf7f60eaa | |||
| bf215f1513 | |||
| 75836fc817 | |||
| 1affe65b4a | |||
| c34ecbd3ef | |||
| 78960d9769 | |||
| 41d07d36ee | |||
| f7386e1741 | |||
| f1da04eb27 | |||
| 2970420b84 | |||
| 771f58c0aa | |||
| b159b20695 | |||
| 226d004838 | |||
| b723b7bb62 |
@@ -1,28 +0,0 @@
|
||||
---
|
||||
name: 🐛 Bug Report
|
||||
about: If something is not working as expected 🤔.
|
||||
|
||||
---
|
||||
|
||||
## Bug Report
|
||||
|
||||
**Problematic behavior**
|
||||
A clear and concise description of the behavior.
|
||||
|
||||
**Expected behavior/code**
|
||||
A clear and concise description of what you expected to happen (or code).
|
||||
|
||||
**Steps to Reproduce**
|
||||
1. Do this...
|
||||
2. Then this...
|
||||
3. And then the bug happens!
|
||||
|
||||
**Environment**
|
||||
- Meet version:
|
||||
- Platform:
|
||||
|
||||
**Possible Solution**
|
||||
<!--- Only if you have suggestions on a fix for the bug -->
|
||||
|
||||
**Additional context/Screenshots**
|
||||
Add any other context about the problem here. If applicable, add screenshots to help explain.
|
||||
@@ -1,23 +0,0 @@
|
||||
---
|
||||
name: ✨ Feature Request
|
||||
about: I have a suggestion (and may want to build it 💪)!
|
||||
|
||||
---
|
||||
|
||||
## Feature Request
|
||||
|
||||
**Is your feature request related to a problem or unsupported use case? Please describe.**
|
||||
A clear and concise description of what the problem is. For example: I need to do some task and I have an issue...
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen. Add any considered drawbacks.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Discovery, Documentation, Adoption, Migration Strategy**
|
||||
If you can, explain how users will be able to use this and possibly write out a version the docs (if applicable).
|
||||
Maybe a screenshot or design?
|
||||
|
||||
**Do you want to work on it through a Pull Request?**
|
||||
<!-- Make sure to coordinate with us before you spend too much time working on an implementation! -->
|
||||
@@ -1,22 +0,0 @@
|
||||
---
|
||||
name: 🤗 Support Question
|
||||
about: If you have a question 💬, or something was not clear from the docs!
|
||||
|
||||
---
|
||||
|
||||
<!-- ^ Click "Preview" for a nicer view! ^
|
||||
We primarily use GitHub as an issue tracker. If however you're encountering an issue not covered in the docs, we may be able to help! -->
|
||||
|
||||
---
|
||||
|
||||
Please make sure you have read our [main Readme](https://github.com/numerique-gouv/meet).
|
||||
|
||||
Also make sure it was not already answered in [an open or close issue](https://github.com/numerique-gouv/meet/issues).
|
||||
|
||||
If your question was not covered, and you feel like it should be, fire away! We'd love to improve our docs! 👌
|
||||
|
||||
**Topic**
|
||||
What's the general area of your question: for example, docker setup, database schema, search functionality,...
|
||||
|
||||
**Question**
|
||||
Try to be as specific as possible so we can help you as best we can. Please be patient 🙏
|
||||
@@ -1,11 +0,0 @@
|
||||
## Purpose
|
||||
|
||||
Description...
|
||||
|
||||
|
||||
## Proposal
|
||||
|
||||
Description...
|
||||
|
||||
- [] item 1...
|
||||
- [] item 2...
|
||||
+18
-29
@@ -226,8 +226,9 @@ jobs:
|
||||
REDIS_URL: redis://localhost:6379/1
|
||||
STORAGES_STATICFILES_BACKEND: django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
AWS_S3_ENDPOINT_URL: http://localhost:9000
|
||||
AWS_S3_ACCESS_KEY_ID: meet
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
AWS_S3_ACCESS_KEY_ID: meet-access-key
|
||||
AWS_S3_SECRET_ACCESS_KEY: meet-secret-access-key
|
||||
AWS_S3_REGION_NAME: local
|
||||
OIDC_RS_CLIENT_ID: meet
|
||||
OIDC_RS_CLIENT_SECRET: ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_OP_INTROSPECTION_ENDPOINT: https://oidc.example.com/introspect
|
||||
@@ -250,34 +251,22 @@ jobs:
|
||||
path: "src/backend/core/templates/mail"
|
||||
key: mail-templates-${{ hashFiles('src/mail/mjml') }}
|
||||
|
||||
- name: Start MinIO
|
||||
# Creates the access key and the bucket on startup
|
||||
- name: Start Garage
|
||||
run: |
|
||||
docker pull quay.io/minio/minio
|
||||
docker run -d --name minio \
|
||||
docker run -d --name garage \
|
||||
-p 9000:9000 \
|
||||
-e "MINIO_ACCESS_KEY=meet" \
|
||||
-e "MINIO_SECRET_KEY=password" \
|
||||
-v /data/media:/data \
|
||||
quay.io/minio/minio server --console-address :9001 /data
|
||||
-v "${GITHUB_WORKSPACE}/docker/files/etc/garage/garage.toml:/etc/garage.toml:ro" \
|
||||
-e "GARAGE_RPC_SECRET=$(openssl rand -hex 32)" \
|
||||
-e "GARAGE_DEFAULT_ACCESS_KEY=meet-access-key" \
|
||||
-e "GARAGE_DEFAULT_SECRET_KEY=meet-secret-access-key" \
|
||||
-e "GARAGE_DEFAULT_BUCKET=meet-media-storage" \
|
||||
dxflrs/garage:v2.4.1 \
|
||||
/garage server --single-node --default-bucket
|
||||
|
||||
# Tool to wait for a service to be ready
|
||||
- name: Install Dockerize
|
||||
- name: Wait for Garage to be ready
|
||||
run: |
|
||||
curl --proto "=https" --proto-redir "=https" --tlsv1.2 -sSLf \
|
||||
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"
|
||||
timeout 30 sh -c 'until docker exec garage /garage health; do sleep 1; done'
|
||||
|
||||
- name: Install Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||
@@ -310,9 +299,9 @@ jobs:
|
||||
env:
|
||||
AUTHORIZED_TENANTS: '[{"id": "test-tenant", "api_key": "test-api-token", "webhook_url": "https://example.com/webhook", "webhook_api_key": "test-webhook-api-key"}]'
|
||||
AWS_STORAGE_BUCKET_NAME: "http://meet-media-storage"
|
||||
AWS_S3_ENDPOINT_URL: "minio:9000"
|
||||
AWS_S3_ACCESS_KEY_ID: "meet"
|
||||
AWS_S3_SECRET_ACCESS_KEY: "password"
|
||||
AWS_S3_ENDPOINT_URL: "garage:9000"
|
||||
AWS_S3_ACCESS_KEY_ID: "meet-access-key"
|
||||
AWS_S3_SECRET_ACCESS_KEY: "meet-secret-access-key"
|
||||
WHISPERX_BASE_URL: "https://configure-your-url.com"
|
||||
WHISPERX_ASR_MODEL: "large-v2"
|
||||
WHISPERX_API_KEY: "test-whisperx-secret"
|
||||
|
||||
@@ -86,3 +86,6 @@ docker/livekit/rootCA.pem
|
||||
|
||||
# Frontend rollup-plugin-visualizer
|
||||
/src/frontend/rollup-plugin-visualizer/*
|
||||
|
||||
# NixOS
|
||||
.devenv
|
||||
|
||||
@@ -8,12 +8,41 @@ and this project adheres to
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- ⬆️(backend) update python dependencies
|
||||
- ⬆️(summary) update python dependencies
|
||||
- ⬆️(agents) update python dependencies
|
||||
|
||||
### Fixed
|
||||
|
||||
- ⚡️(frontend) disable posthog-js periodic feature flag reloads
|
||||
|
||||
## [1.32.1] - 2026-09-25
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🔒️(backend) fix CVE-2026-73228 and CVE-2026-73229 in drf
|
||||
- 🔒️(agent) fix CRITICAL CVE-2026-63072 / CVE-2026-63073 in libssl3t64
|
||||
|
||||
## [1.32.0] - 2026-09-25
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(backend) make the LiveKit default video codec configurable
|
||||
- ✨(backend) purge rooms inactive for a configurable period
|
||||
- 🔧(dev) add support for Bureautix workstations
|
||||
- ✨(frontend) add screen share zoom controls #1498
|
||||
- 🔨(makefile) add targets to list and download files stored in Garage
|
||||
- ✨(backend) add room soft-deletion to the external API
|
||||
- ✨(backend) answer 410 Gone when accessing a soft-deleted room
|
||||
|
||||
### Changed
|
||||
|
||||
- 🔥(backend) remove unused API viewset and permission helpers
|
||||
- 🔊(backend) pin the dockerflow logger level to WARNING
|
||||
- 🚑️(summary) serve health endpoints with the dockerflow router
|
||||
- ♻️(backend) serve the dockerflow views early in the middleware stack
|
||||
- 📈(frontend) include LiveKit SIDs in the connection analytics event
|
||||
- 🔇(backend) silence expected 401 warnings on /me
|
||||
- 🔇(backend) silence noisy request summary info logs
|
||||
@@ -22,13 +51,30 @@ and this project adheres to
|
||||
- ⬆️(frontend) upgrade posthog-js from 1.414.0 to 1.418.10
|
||||
- ⬆️(addons) upgrade i18next from 26.3.6 to 26.4.0
|
||||
- ⬆️(frontend) upgrade humanize-duration from 3.33.2 to 3.34.1
|
||||
- ⬆️(addons) upgrade i18next from 26.4.0 to 26.4.2
|
||||
- 🔖(helm) release chart 0.0.28
|
||||
- ♻️(backend) decouple recording event handling from LiveKit egress statuses
|
||||
- ♻️(agents) replace the minio client by boto3
|
||||
- 🔧(compose) replace MinIO by Garage for local development
|
||||
- 🔧(helm) point media services to Garage by default
|
||||
- ♻️(backend) soft delete rooms instead of hard-delete
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(helm) probe liveness on __lbheartbeat__ and readiness on __heartbeat__
|
||||
- 🐛(helm) render periodSeconds and failureThreshold on probes
|
||||
- 🐛(backend) report the app release to Sentry instead of "NA"
|
||||
- 🐛(frontend) play the waiting room notification sound on every arrival
|
||||
- 🐛(frontend) apply saved reception resolution when joining a meeting #1714
|
||||
- 🐛(backend) acknowledge unknown LiveKit webhook events instead of 422
|
||||
- 🔒️(backend) enforce display name setting on rename API
|
||||
- 🔒️(backend) reject inactive users in resource server backend
|
||||
- 🐛(frontend) fix file permissions in the Docker image
|
||||
- 🚸(frontend) inform user that recording waits until a track is published
|
||||
- 🔒(backend) upgrade base image to python:3.13.5-alpine3.24
|
||||
- 🐛(backend) handle failed and aborted egresses
|
||||
- 🩹(frontend) notify participants when a recording fails or is aborted
|
||||
- 🔒️(frontend) fix HIGH CVE-2026-93990 in libexpat
|
||||
|
||||
## [1.31.0] - 2026-09-08
|
||||
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
# Django Meet
|
||||
|
||||
# ---- base image to inherit from ----
|
||||
FROM python:3.13.5-alpine3.21 AS base
|
||||
FROM python:3.13.15-alpine3.24 AS base
|
||||
|
||||
# Upgrade pip to its latest release to speed up dependencies installation
|
||||
RUN python -m pip install --upgrade pip
|
||||
|
||||
@@ -36,14 +36,9 @@ DB_PORT = 5432
|
||||
|
||||
# -- Docker
|
||||
# Get the current user ID to use for docker run and docker exec commands
|
||||
ifneq ($(findstring podman,$(DOCKER_HOST)),)
|
||||
DOCKER_UID = 0
|
||||
DOCKER_GID = 0
|
||||
else
|
||||
DOCKER_UID = $(shell id -u)
|
||||
DOCKER_GID = $(shell id -g)
|
||||
endif
|
||||
DOCKER_USER ?= $(DOCKER_UID):$(DOCKER_GID)
|
||||
DOCKER_USER = $(DOCKER_UID):$(DOCKER_GID)
|
||||
COMPOSE = DOCKER_USER=$(DOCKER_USER) docker compose
|
||||
COMPOSE_EXEC = $(COMPOSE) exec
|
||||
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
|
||||
@@ -74,6 +69,22 @@ LINT_SUMMARY = echo 'lint:ruff-format started…' && $(LINT_RUFF_FORMAT)
|
||||
# -- Frontend
|
||||
PATH_FRONT = ./src/frontend
|
||||
|
||||
# -- Storage
|
||||
GARAGE_BUCKET = meet-media-storage
|
||||
STORAGE_FOLDERS = recordings transcripts summaries
|
||||
STORAGE_DIRS = $(addprefix data/,$(STORAGE_FOLDERS))
|
||||
COMPOSE_RUN_AWS = $(COMPOSE_RUN) --user $(DOCKER_USER)
|
||||
AWS_CLI = garage-cors --endpoint-url=http://garage:9000
|
||||
# Extensions listed in each folder (skips the Egress manifests in recordings/)
|
||||
recordings_EXTENSIONS = mp4 ogg
|
||||
transcripts_EXTENSIONS = json
|
||||
summaries_EXTENSIONS = txt
|
||||
# $(1): folder. Lists its objects with a known extension, most recent first
|
||||
storage_list = s3api list-objects-v2 --bucket $(GARAGE_BUCKET) \
|
||||
--prefix $(1)/
|
||||
storage_query = reverse(sort_by(Contents[?$(foreach ext,$($(1)_EXTENSIONS), \
|
||||
ends_with(Key, `".$(ext)"`) ||) `false`] || `[]`, &LastModified))
|
||||
|
||||
# ==============================================================================
|
||||
# RULES
|
||||
|
||||
@@ -82,6 +93,9 @@ default: help
|
||||
data/media:
|
||||
@mkdir -p data/media
|
||||
|
||||
$(STORAGE_DIRS):
|
||||
@mkdir -p $@
|
||||
|
||||
data/static:
|
||||
@mkdir -p data/static
|
||||
|
||||
@@ -90,6 +104,7 @@ data/static:
|
||||
create-env-files: ## Copy the dist env files to env files
|
||||
create-env-files: \
|
||||
env.d/development/common \
|
||||
env.d/development/garage \
|
||||
env.d/development/crowdin \
|
||||
env.d/development/postgresql \
|
||||
env.d/development/kc_postgresql \
|
||||
@@ -297,7 +312,7 @@ shell: ## connect to database shell
|
||||
# -- Database
|
||||
|
||||
dbshell: ## connect to database shell
|
||||
docker compose exec app-dev python manage.py dbshell
|
||||
@$(COMPOSE_EXEC_APP) python manage.py dbshell
|
||||
.PHONY: dbshell
|
||||
|
||||
resetdb: FLUSH_ARGS ?=
|
||||
@@ -322,12 +337,38 @@ env.d/development/summary:
|
||||
env.d/development/kube-secret:
|
||||
cp -n env.d/development/kube-secret.dist env.d/development/kube-secret
|
||||
|
||||
env.d/development/garage:
|
||||
sed "s/^GARAGE_RPC_SECRET=.*/GARAGE_RPC_SECRET=$$(openssl rand -hex 32)/" \
|
||||
env.d/development/garage.dist > env.d/development/garage
|
||||
|
||||
env.d/development/multi_user_transcriber:
|
||||
cp -n env.d/development/multi_user_transcriber.dist env.d/development/multi_user_transcriber
|
||||
|
||||
env.d/development/metadata_collector:
|
||||
cp -n env.d/development/metadata_collector.dist env.d/development/metadata_collector
|
||||
|
||||
# -- Storage
|
||||
|
||||
recordings-download-latest: ## download the latest recording from Garage into data/recordings
|
||||
transcripts-download-latest: ## download the latest transcript from Garage into data/transcripts
|
||||
summaries-download-latest: ## download the latest summary from Garage into data/summaries
|
||||
$(STORAGE_FOLDERS:%=%-download-latest): %-download-latest: data/%
|
||||
@key=$$($(COMPOSE_RUN_AWS) -T $(AWS_CLI) $(call storage_list,$*) \
|
||||
--query '$(call storage_query,$*)[0].Key' --output text) && \
|
||||
if [ "$$key" = "None" ]; then echo "No $* found"; exit 1; fi && \
|
||||
$(COMPOSE_RUN_AWS) --volume $(CURDIR)/data/$*:/aws/data/$* \
|
||||
$(AWS_CLI) s3 cp "s3://$(GARAGE_BUCKET)/$$key" data/$*/
|
||||
.PHONY: $(STORAGE_FOLDERS:%=%-download-latest)
|
||||
|
||||
recordings-list: ## list recordings stored in Garage, most recent first
|
||||
transcripts-list: ## list transcripts stored in Garage, most recent first
|
||||
summaries-list: ## list summaries stored in Garage, most recent first
|
||||
$(STORAGE_FOLDERS:%=%-list): %-list:
|
||||
@$(COMPOSE_RUN_AWS) $(AWS_CLI) $(call storage_list,$*) \
|
||||
--query '$(call storage_query,$*)[].{Date: LastModified, Key: Key, "Size (bytes)": Size}' \
|
||||
--output table
|
||||
.PHONY: $(STORAGE_FOLDERS:%=%-list)
|
||||
|
||||
# -- Internationalization
|
||||
|
||||
env.d/development/crowdin:
|
||||
|
||||
+38
@@ -16,6 +16,44 @@ the following command inside your docker container:
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Purging inactive rooms
|
||||
|
||||
Rooms now keep track of the last time they were started (`last_started_at`), fed by LiveKit's `room_started` webhook. A new `purge_inactive_rooms` management command permanently deletes the rooms that have not been started for `ROOM_INACTIVITY_DELETION_DAYS` days. See [the room purge documentation](docs/features/room-purge.md).
|
||||
|
||||
- The feature is **disabled by default**: nothing is deleted unless you set `ROOM_INACTIVITY_DELETION_DAYS`.
|
||||
- The migration marks every existing room as started at the time of the upgrade, so no existing room can be purged before a full inactivity period has elapsed after upgrading.
|
||||
- Rooms holding a saved recording their users may still access are kept: any saved recording, or, when `RECORDING_EXPIRATION_DAYS` is set, a saved recording created within that window.
|
||||
- Inactivity is measured from LiveKit's `room_started` webhook: if it is not delivered to your backend, rooms in daily use look inactive and get purged.
|
||||
- When a room is purged, all it's configuration and access rights are also deleted. Its slug becomes available again and can be reused when a meeting is created from that same URL.
|
||||
|
||||
* With `ALLOW_UNREGISTERED_ROOMS=false`, only an authenticated user can navigate to a previously existing link after the room has been purged. Doing so recreates the room in the database with a fresh configuration, with that user associated with it and granted admin rights.
|
||||
* With `ALLOW_UNREGISTERED_ROOMS=true`, any user can reopen the purged room by navigating to the same URL. In that case, the room is created dynamically and no corresponding room entry is persisted in the database.
|
||||
|
||||
### Local development: MinIO replaced by Garage
|
||||
|
||||
The development stacks now use [Garage](https://garagehq.deuxfleurs.fr/) instead of MinIO as S3 storage. Garage keeps its own format in `data/media/meta` and `data/media/data` and cannot read what MinIO left there, so local recordings and files will be lost.
|
||||
|
||||
To migrate a local environment:
|
||||
|
||||
1. Stop the stack and remove its containers, including the former `minio` one: `docker compose down --remove-orphans`
|
||||
2. Optionally reclaim the space used by MinIO: `rm -rf data/media && make data/media`
|
||||
3. In your `env.d/development/*` files, replace `minio:9000` by `garage:9000`, the `meet` / `password` credentials by `meet-access-key` / `meet-secret-access-key`, and add `AWS_S3_REGION_NAME=local` (or delete these files and run `make create-env-files`)
|
||||
4. Run `make create-env-files` to generate `env.d/development/garage`, which holds a random RPC secret for Garage.
|
||||
5. Rebuild the images, since the summary and agent images now install boto3 instead of minio
|
||||
|
||||
### Summary service and metadata collector: boto3 replaces the minio client
|
||||
|
||||
The summary service and the metadata collector agent now talk to S3 through boto3 instead of the minio client, with the same settings.
|
||||
Requests are now signed for `AWS_S3_REGION_NAME` as-is. When it is not set, the region is no longer looked up from the bucket: boto3 falls back to `AWS_DEFAULT_REGION`, then to `us-east-1`. If you left `AWS_S3_REGION_NAME` unset, set it to your provider's region before upgrading, or providers that check the signing region will reject the transcripts, summaries and meeting metadata uploads, as well as their signed URLs.
|
||||
|
||||
Also:
|
||||
- Signed URLs to transcripts and summaries are now always path-style (`<endpoint>/<bucket>/<key>`), whereas the minio client used virtual-hosted-style URLs
|
||||
- The metadata collector now accepts `AWS_S3_ENDPOINT_URL` with or without a scheme, like the summary service: the scheme always follows `AWS_S3_SECURE_ACCESS`.
|
||||
|
||||
### Helm chart: media services default to Garage
|
||||
|
||||
The `meet` chart now defaults `serviceMedia.host` and `serviceMediaFiles.host` to `garage.meet.svc.cluster.local`, and the `upstream-vhost` annotation of `ingressMedia` and `ingressMediaFiles` to `garage.meet.svc.cluster.local:9000`. If you relied on the former `minio.meet.svc.cluster.local` defaults, set these values explicitly to your S3 service before upgrading, or recordings and files stop being served under `/media`.
|
||||
|
||||
## v1.30.0
|
||||
|
||||
### Removing S3 storage-event webhooks for recordings
|
||||
|
||||
+2
-2
@@ -104,8 +104,8 @@ k8s_yaml(secret_yaml_generic(
|
||||
|
||||
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev-keycloak} template .'))
|
||||
|
||||
k8s_resource('minio-bucket', resource_deps=['minio'])
|
||||
k8s_resource('meet-backend', resource_deps=['postgresql', 'minio', 'redis', 'livekit-livekit-server'])
|
||||
k8s_resource('garage-cors', resource_deps=['garage'])
|
||||
k8s_resource('meet-backend', resource_deps=['postgresql', 'garage-cors', 'redis', 'livekit-livekit-server'])
|
||||
k8s_resource('meet-celery-backend', resource_deps=['redis'])
|
||||
k8s_resource('meet-celery-summarize', resource_deps=['redis'])
|
||||
k8s_resource('meet-celery-summary-backend', resource_deps=['redis'])
|
||||
|
||||
+3
-7
@@ -5,7 +5,7 @@ set -eo pipefail
|
||||
REPO_DIR="$(cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd)"
|
||||
UNSET_USER=0
|
||||
|
||||
COMPOSE_FILE="${REPO_DIR}/compose.yml"
|
||||
export COMPOSE_FILE="${COMPOSE_FILE:-${REPO_DIR}/compose.yml}"
|
||||
COMPOSE_PROJECT="meet"
|
||||
|
||||
|
||||
@@ -25,11 +25,8 @@ function _set_user() {
|
||||
return
|
||||
fi
|
||||
|
||||
# USER_ID = USER_ID or the engine-appropriate default if USER_ID is not set.
|
||||
case "${DOCKER_HOST:-}" in
|
||||
*podman*) USER_ID=${USER_ID:-0} ;;
|
||||
*) USER_ID=${USER_ID:-$(id -u)} ;;
|
||||
esac
|
||||
# USER_ID = USER_ID or `id -u` if USER_ID is not set
|
||||
USER_ID=${USER_ID:-$(id -u)}
|
||||
|
||||
echo "🙋(user) ID: ${USER_ID}"
|
||||
}
|
||||
@@ -45,7 +42,6 @@ function _docker_compose() {
|
||||
echo "🐳(compose) project: '${COMPOSE_PROJECT}' file: '${COMPOSE_FILE}'"
|
||||
docker compose \
|
||||
-p "${COMPOSE_PROJECT}" \
|
||||
-f "${COMPOSE_FILE}" \
|
||||
--project-directory "${REPO_DIR}" \
|
||||
"$@"
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# shellcheck source=bin/_config.sh
|
||||
source "$(dirname "${BASH_SOURCE[0]}")/_config.sh"
|
||||
|
||||
_docker_compose "$@"
|
||||
+31
-19
@@ -15,32 +15,44 @@ services:
|
||||
ports:
|
||||
- "1081:1080"
|
||||
|
||||
minio:
|
||||
garage:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: quay.io/minio/minio
|
||||
image: dxflrs/garage:v2.4.1
|
||||
command: /garage server --single-node --default-bucket
|
||||
env_file:
|
||||
- env.d/development/garage
|
||||
environment:
|
||||
- MINIO_ROOT_USER=meet
|
||||
- MINIO_ROOT_PASSWORD=password
|
||||
- GARAGE_DEFAULT_ACCESS_KEY=meet-access-key
|
||||
- GARAGE_DEFAULT_SECRET_KEY=meet-secret-access-key
|
||||
- GARAGE_DEFAULT_BUCKET=meet-media-storage
|
||||
ports:
|
||||
- '9000:9000'
|
||||
- '9001:9001'
|
||||
- '127.0.0.1:9000:9000'
|
||||
healthcheck:
|
||||
test: [ "CMD", "mc", "ready", "local" ]
|
||||
test: [ "CMD", "/garage", "health" ]
|
||||
interval: 1s
|
||||
timeout: 20s
|
||||
retries: 300
|
||||
entrypoint: ""
|
||||
command: minio server --console-address :9001 /data
|
||||
volumes:
|
||||
- ./data/media:/data
|
||||
- ./docker/files/etc/garage/garage.toml:/etc/garage.toml:ro
|
||||
- ./data/media:/var/lib/garage
|
||||
|
||||
createbuckets:
|
||||
image: quay.io/minio/mc
|
||||
# Garage denies cross-origin requests by default: allow the frontend to upload files
|
||||
garage-cors:
|
||||
image: amazon/aws-cli:2.37.1
|
||||
environment:
|
||||
- AWS_ACCESS_KEY_ID=meet-access-key
|
||||
- AWS_SECRET_ACCESS_KEY=meet-secret-access-key
|
||||
- AWS_DEFAULT_REGION=local
|
||||
depends_on:
|
||||
minio:
|
||||
garage:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
entrypoint: ["/bin/sh", "-c", "mc alias set meet http://minio:9000 meet password && mc mb --ignore-existing meet/meet-media-storage"]
|
||||
command:
|
||||
- s3api
|
||||
- put-bucket-cors
|
||||
- --endpoint-url=http://garage:9000
|
||||
- --bucket=meet-media-storage
|
||||
- '--cors-configuration={"CORSRules": [{"AllowedOrigins": ["http://localhost:3000"], "AllowedMethods": ["GET", "HEAD", "PUT"], "AllowedHeaders": ["*"], "ExposeHeaders": ["ETag"]}]}'
|
||||
|
||||
app-dev:
|
||||
build:
|
||||
@@ -66,7 +78,7 @@ services:
|
||||
- postgresql
|
||||
- mailcatcher
|
||||
- redis
|
||||
- createbuckets
|
||||
- garage-cors
|
||||
extra_hosts:
|
||||
- "127.0.0.1.nip.io:host-gateway"
|
||||
networks:
|
||||
@@ -106,7 +118,7 @@ services:
|
||||
- postgresql
|
||||
- redis
|
||||
- livekit
|
||||
- minio
|
||||
- garage
|
||||
|
||||
celery:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
@@ -240,7 +252,7 @@ services:
|
||||
- /app/.venv
|
||||
depends_on:
|
||||
- livekit
|
||||
- minio
|
||||
- garage
|
||||
develop:
|
||||
watch:
|
||||
- action: rebuild
|
||||
@@ -293,7 +305,7 @@ services:
|
||||
depends_on:
|
||||
- redis-summary
|
||||
- app-summary-dev
|
||||
- minio
|
||||
- garage
|
||||
develop:
|
||||
watch:
|
||||
- action: rebuild
|
||||
@@ -313,7 +325,7 @@ services:
|
||||
depends_on:
|
||||
- redis-summary
|
||||
- app-summary-dev
|
||||
- minio
|
||||
- garage
|
||||
develop:
|
||||
watch:
|
||||
- action: rebuild
|
||||
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"nodes": {
|
||||
"devenv": {
|
||||
"locked": {
|
||||
"dir": "src/modules",
|
||||
"lastModified": 1778705847,
|
||||
"narHash": "sha256-EQnZCy7r4VMO6KDoytxHBa0mFbM1D9g1kaDfs/s0YZA=",
|
||||
"ref": "refs/tags/v2.1.2",
|
||||
"rev": "ea3d94ac9d6bf6a1313773170122ca4e2ef5a0be",
|
||||
"revCount": 6569,
|
||||
"type": "git",
|
||||
"url": "https://github.com/cachix/devenv"
|
||||
},
|
||||
"original": {
|
||||
"dir": "src/modules",
|
||||
"ref": "refs/tags/v2.1.2",
|
||||
"type": "git",
|
||||
"url": "https://github.com/cachix/devenv"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1789542786,
|
||||
"narHash": "sha256-ajQuqcxnj6RYnwCjA/9FTCnrdR2+BDzstE8UembXf60=",
|
||||
"ref": "nixos-26.05",
|
||||
"rev": "4c7870105e7f1fdf9c48688c8d7efc21abf0688a",
|
||||
"shallow": true,
|
||||
"type": "git",
|
||||
"url": "https://github.com/NixOS/nixpkgs"
|
||||
},
|
||||
"original": {
|
||||
"ref": "nixos-26.05",
|
||||
"shallow": true,
|
||||
"type": "git",
|
||||
"url": "https://github.com/NixOS/nixpkgs"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"devenv": "devenv",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
+265
@@ -0,0 +1,265 @@
|
||||
# =============================================================================
|
||||
# devenv.nix — La Suite Meet ("Visio") developer environment
|
||||
# =============================================================================
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
|
||||
let
|
||||
python = pkgs.python313;
|
||||
nodejs = pkgs.nodejs_22;
|
||||
|
||||
backendDir = "src/backend";
|
||||
agentsDir = "src/agents";
|
||||
summaryDir = "src/summary";
|
||||
frontendDir = "src/frontend";
|
||||
|
||||
readDotEnv =
|
||||
file:
|
||||
let
|
||||
lines = lib.splitString "\n" (builtins.readFile file);
|
||||
unquote =
|
||||
v:
|
||||
let
|
||||
len = builtins.stringLength v;
|
||||
in
|
||||
if len >= 2 && lib.hasPrefix "\"" v && lib.hasSuffix "\"" v then
|
||||
builtins.substring 1 (len - 2) v
|
||||
else if len >= 2 && lib.hasPrefix "'" v && lib.hasSuffix "'" v then
|
||||
builtins.substring 1 (len - 2) v
|
||||
else
|
||||
v;
|
||||
parseLine =
|
||||
line:
|
||||
let
|
||||
m = builtins.match "[ \t]*([A-Za-z_][A-Za-z0-9_]*)[ \t]*=[ \t]*(.*)" line;
|
||||
in
|
||||
if m == null then null else { name = builtins.elemAt m 0; value = unquote (builtins.elemAt m 1); };
|
||||
in
|
||||
builtins.listToAttrs (builtins.filter (x: x != null) (map parseLine lines));
|
||||
|
||||
# Reuse existing .env
|
||||
dotEnv =
|
||||
(readDotEnv ./env.d/development/common.dist)
|
||||
// (readDotEnv ./env.d/development/postgresql.dist);
|
||||
|
||||
sharedEnv = builtins.removeAttrs dotEnv [ "PYTHONPATH" ]; # only makes sense inside the backend container.
|
||||
in
|
||||
{
|
||||
options.meet = {
|
||||
agents.enable = lib.mkEnableOption "tooling for the LiveKit agents in src/agents";
|
||||
summary.enable = lib.mkEnableOption "tooling for the summary service in src/summary";
|
||||
k8s.enable = lib.mkEnableOption "Kubernetes dev utilities";
|
||||
};
|
||||
|
||||
config = {
|
||||
# Profile can be activated with devenv --profile <profile> shell
|
||||
profiles = {
|
||||
agents.module = {
|
||||
meet.agents.enable = true;
|
||||
};
|
||||
summary.module = {
|
||||
meet.summary.enable = true;
|
||||
};
|
||||
k8s.module = {
|
||||
meet.k8s.enable = true;
|
||||
};
|
||||
};
|
||||
languages.python = {
|
||||
enable = true;
|
||||
package = python;
|
||||
directory = backendDir;
|
||||
manylinux.enable = pkgs.stdenv.hostPlatform.isLinux;
|
||||
|
||||
libraries = [
|
||||
"${config.devenv.dotfile}/profile"
|
||||
pkgs.file
|
||||
pkgs.zlib
|
||||
pkgs.libffi
|
||||
pkgs.openssl
|
||||
];
|
||||
|
||||
uv.enable = true;
|
||||
uv.sync.enable = false;
|
||||
venv.enable = false;
|
||||
lsp.enable = true;
|
||||
};
|
||||
|
||||
languages.javascript = {
|
||||
enable = true;
|
||||
package = nodejs;
|
||||
directory = frontendDir;
|
||||
|
||||
npm.enable = true;
|
||||
yarn.enable = true;
|
||||
corepack.enable = false;
|
||||
};
|
||||
|
||||
languages.typescript.enable = false;
|
||||
languages.nix.enable = true;
|
||||
|
||||
packages =
|
||||
with pkgs;
|
||||
[
|
||||
gnumake
|
||||
file
|
||||
shared-mime-info
|
||||
gettext
|
||||
postgresql_16
|
||||
git
|
||||
curl
|
||||
jq
|
||||
podman
|
||||
podman-compose
|
||||
docker-client
|
||||
]
|
||||
|
||||
# -- LiveKit agents
|
||||
++ lib.optionals config.meet.agents.enable [
|
||||
glib
|
||||
portaudio
|
||||
livekit-cli
|
||||
]
|
||||
|
||||
# -- summary service
|
||||
++ lib.optionals config.meet.summary.enable [
|
||||
redis
|
||||
]
|
||||
|
||||
# -- Kubernetes tools
|
||||
++ lib.optionals config.meet.k8s.enable [
|
||||
kubectl
|
||||
kubernetes-helm
|
||||
helmfile
|
||||
tilt
|
||||
kind
|
||||
mkcert
|
||||
];
|
||||
|
||||
env = sharedEnv // {
|
||||
UV_LINK_MODE = "copy";
|
||||
|
||||
PYTHONDONTWRITEBYTECODE = "1";
|
||||
PYTHONUNBUFFERED = "1";
|
||||
|
||||
UV_PROJECT_ENVIRONMENT = lib.mkForce ".venv";
|
||||
|
||||
COMPOSE_PROJECT_NAME = "meet";
|
||||
|
||||
DJANGO_DATA_DIR = "${config.devenv.root}/data";
|
||||
|
||||
# Database / Pgsql
|
||||
DB_HOST = "127.0.0.1";
|
||||
DB_PORT = "15432";
|
||||
PGHOST = "127.0.0.1";
|
||||
PGPORT = "15432";
|
||||
PGDATABASE = sharedEnv.DB_NAME;
|
||||
PGUSER = sharedEnv.DB_USER;
|
||||
PGPASSWORD = sharedEnv.DB_PASSWORD;
|
||||
|
||||
REDIS_URL = "redis://127.0.0.1:6379/1";
|
||||
CELERY_BROKER_URL = "redis://127.0.0.1:6379/0";
|
||||
|
||||
# S3 / Garage
|
||||
AWS_S3_ENDPOINT_URL = "http://127.0.0.1:9000";
|
||||
|
||||
# OIDC
|
||||
OIDC_OP_JWKS_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/certs";
|
||||
OIDC_OP_TOKEN_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/token";
|
||||
OIDC_OP_USER_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/userinfo";
|
||||
OIDC_OP_INTROSPECTION_ENDPOINT = "http://localhost:8083/realms/meet/protocol/openid-connect/token/introspect";
|
||||
|
||||
# summary service
|
||||
SUMMARY_SERVICE_ENDPOINT = "http://127.0.0.1:8001/api/v2/async-jobs/transcribe/";
|
||||
SUMMARY_SERVICE_VERSION = "2";
|
||||
|
||||
# Mail
|
||||
DJANGO_EMAIL_HOST = "127.0.0.1";
|
||||
};
|
||||
|
||||
scripts = {
|
||||
|
||||
meet-venv = {
|
||||
description = "Create/refresh meet uv virtualenvs for backend, agents and summary";
|
||||
exec = ''
|
||||
set -euo pipefail
|
||||
cd "$DEVENV_ROOT"
|
||||
|
||||
echo "==> ${backendDir} (uv sync --locked, dependency-groups)"
|
||||
( cd "${backendDir}" && uv sync --locked --all-groups )
|
||||
|
||||
echo "==> ${agentsDir} (uv sync --locked --all-extras)"
|
||||
( cd "${agentsDir}" && uv sync --locked --all-extras )
|
||||
|
||||
echo "==> ${summaryDir} (uv sync --locked --all-extras)"
|
||||
( cd "${summaryDir}" && uv sync --locked --all-extras )
|
||||
|
||||
echo
|
||||
echo "Synced the following virtualenvs successfully:"
|
||||
echo " ${backendDir}/.venv"
|
||||
echo " ${agentsDir}/.venv"
|
||||
echo " ${summaryDir}/.venv"
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
enterShell = ''
|
||||
# Make podman socket accessible in order to launch regular docker commands.
|
||||
# Set MEET_PODMAN_SOCKET=0 to keep the DOCKER_HOST of the calling environment.
|
||||
case "''${MEET_PODMAN_SOCKET:-1}" in
|
||||
0|false|no|off) ;;
|
||||
*)
|
||||
_rundir="''${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
|
||||
export DOCKER_HOST="unix://$_rundir/podman/podman.sock"
|
||||
unset _rundir
|
||||
;;
|
||||
esac
|
||||
|
||||
# Compose files to merge
|
||||
_compose_dir="${config.devenv.root}/docker/compose.d"
|
||||
_compose_files="${config.devenv.root}/compose.yml"
|
||||
|
||||
export DOCKER_USER="$(id -u):$(id -g)"
|
||||
|
||||
case "''${DOCKER_HOST:-}" in
|
||||
*podman*)
|
||||
_compose_files="$_compose_files:$_compose_dir/compose.podman.yml"
|
||||
|
||||
# Build images with Podman/Buildah rather than BuildKit. `docker
|
||||
# compose build` otherwise has buildx boot a moby/buildkit container,
|
||||
# and that container lands in its own network namespace with neither
|
||||
# the proxy in its environment nor any route to it.
|
||||
# Buildah has neither problem: base images are resolved by the Podman systemd
|
||||
# service, which inherits the proxy from its systemd socket activated unit, and
|
||||
# RUN steps execute in the *host* network namespace
|
||||
|
||||
export DOCKER_BUILDKIT=0
|
||||
export COMPOSE_BAKE=false
|
||||
;;
|
||||
esac
|
||||
|
||||
|
||||
# Apply Bureautix override
|
||||
if [ -n "''${http_proxy:-}" ]; then
|
||||
_compose_files="$_compose_files:$_compose_dir/compose.bureautix.yml"
|
||||
fi
|
||||
|
||||
export COMPOSE_FILE="$_compose_files"
|
||||
unset _compose_dir _compose_files
|
||||
|
||||
# Make binaries accessible
|
||||
for _d in \
|
||||
"$DEVENV_ROOT/${backendDir}/.venv/bin" \
|
||||
"$DEVENV_ROOT/${agentsDir}/.venv/bin" \
|
||||
"$DEVENV_ROOT/${summaryDir}/.venv/bin" \
|
||||
"$DEVENV_ROOT/${frontendDir}/node_modules/.bin"
|
||||
do
|
||||
[ -d "$_d" ] && export PATH="$_d:$PATH"
|
||||
done
|
||||
unset _d
|
||||
'';
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
inputs:
|
||||
nixpkgs:
|
||||
url: git+https://github.com/NixOS/nixpkgs?ref=nixos-26.05&shallow=1
|
||||
devenv:
|
||||
url: git+https://github.com/cachix/devenv?ref=refs/tags/v2.1.2&dir=src/modules
|
||||
@@ -0,0 +1,48 @@
|
||||
# Bureautix proxy overrides
|
||||
#
|
||||
# Builds submitted through the Docker API of the Podman service get none of
|
||||
# the proxy variables in their RUN steps. We need to pass the http_proxy explicitely
|
||||
# otherwise all connections fail during the build.
|
||||
|
||||
x-proxy-vars: &proxy-vars
|
||||
http_proxy: ${http_proxy:-}
|
||||
https_proxy: ${https_proxy:-}
|
||||
no_proxy: ${no_proxy:-}
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
args:
|
||||
<<: *proxy-vars
|
||||
app-dev:
|
||||
build:
|
||||
args:
|
||||
<<: *proxy-vars
|
||||
frontend:
|
||||
build:
|
||||
args:
|
||||
<<: *proxy-vars
|
||||
metadata-collector-dev:
|
||||
build:
|
||||
args:
|
||||
<<: *proxy-vars
|
||||
multi-user-transcriber-dev:
|
||||
build:
|
||||
args:
|
||||
<<: *proxy-vars
|
||||
app-summary-dev:
|
||||
build:
|
||||
args:
|
||||
<<: *proxy-vars
|
||||
celery-summary-transcribe:
|
||||
build:
|
||||
args:
|
||||
<<: *proxy-vars
|
||||
celery-summary-summarize:
|
||||
build:
|
||||
args:
|
||||
<<: *proxy-vars
|
||||
# The local proxy listens on 8080 and collides with Keycloak's published admin port.
|
||||
keycloak:
|
||||
ports: !override
|
||||
- "8081:8080"
|
||||
@@ -0,0 +1,35 @@
|
||||
# Rootless Podman override for compose.yml.
|
||||
#
|
||||
# Rootless Podman maps container UID 0 to the host user and every other
|
||||
# container UID to a subuid that owns nothing in the worktree. Meet compose.ymlruns
|
||||
# its containers as DOCKER_USER=$(id -u):$(id -g), which would land on such a
|
||||
# subuid and make every bind mount effectively read-only.
|
||||
#
|
||||
# `userns_mode: keep-id` maps the host user to the same UID and GID inside the
|
||||
# container instead, so DOCKER_USER keeps its Docker value and files written
|
||||
# through a bind mount are owned by the host user on both sides.
|
||||
#
|
||||
# Only the services that mount the worktree and run as DOCKER_USER are listed.
|
||||
|
||||
x-keep-id: &keep-id
|
||||
userns_mode: keep-id
|
||||
|
||||
services:
|
||||
app-dev:
|
||||
<<: *keep-id
|
||||
celery-dev:
|
||||
<<: *keep-id
|
||||
garage:
|
||||
<<: *keep-id
|
||||
garage-cors:
|
||||
<<: *keep-id
|
||||
node:
|
||||
<<: *keep-id
|
||||
crowdin:
|
||||
<<: *keep-id
|
||||
metadata-collector-dev:
|
||||
<<: *keep-id
|
||||
multi-user-transcriber-dev:
|
||||
<<: *keep-id
|
||||
app-summary-dev:
|
||||
<<: *keep-id
|
||||
@@ -57,7 +57,8 @@ RUN npx webpack --mode production
|
||||
FROM nginxinc/nginx-unprivileged:1.30.4-alpine3.24 AS frontend-production
|
||||
|
||||
USER root
|
||||
RUN apk del curl
|
||||
RUN apk upgrade --no-cache libexpat && \
|
||||
apk del curl
|
||||
USER nginx
|
||||
|
||||
USER nginx
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
# Garage configuration for local development only: single node, no replication.
|
||||
# See https://garagehq.deuxfleurs.fr/documentation/reference-manual/configuration/
|
||||
metadata_dir = "/var/lib/garage/meta"
|
||||
data_dir = "/var/lib/garage/data"
|
||||
db_engine = "lmdb"
|
||||
|
||||
replication_factor = 1
|
||||
|
||||
rpc_bind_addr = "127.0.0.1:3901"
|
||||
rpc_public_addr = "127.0.0.1:3901"
|
||||
|
||||
[s3_api]
|
||||
api_bind_addr = "[::]:9000"
|
||||
# Clients must sign their requests for this region (AWS_S3_REGION_NAME)
|
||||
s3_region = "local"
|
||||
@@ -17,9 +17,9 @@ server {
|
||||
proxy_set_header X-Amz-Date $authDate;
|
||||
proxy_set_header X-Amz-Content-SHA256 $authContentSha256;
|
||||
|
||||
# Get resource from Minio
|
||||
proxy_pass http://minio:9000/meet-media-storage/;
|
||||
proxy_set_header Host minio:9000;
|
||||
# Get resource from Garage
|
||||
proxy_pass http://garage:9000/meet-media-storage/;
|
||||
proxy_set_header Host garage:9000;
|
||||
# To use with ds_proxy
|
||||
# proxy_pass http://ds-proxy:4444/upstream/meet-media-storage/;
|
||||
# proxy_set_header Host ds-proxy:4444;
|
||||
|
||||
@@ -21,3 +21,10 @@ turn:
|
||||
- 192.168.0.0/16
|
||||
- 172.16.0.0/12
|
||||
|
||||
rtc:
|
||||
node_ip: 127.0.0.1
|
||||
advertise_internal_ip: true
|
||||
udp_port: 7882
|
||||
tcp_port: 7881
|
||||
use_external_ip: false
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ 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)
|
||||
- Garage 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:
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
# Room purge
|
||||
|
||||
Rooms pile up over time and most of them are only used once. The `purge_inactive_rooms` management command permanently deletes the rooms that have not been started for a configurable number of days. It is disabled by default.
|
||||
|
||||
## How it works
|
||||
|
||||
Each time LiveKit tells the backend that a room has started (`room_started` webhook), the backend records the date on the room (`last_started_at`).
|
||||
A room is inactive when:
|
||||
|
||||
- it was last started more than `ROOM_INACTIVITY_DELETION_DAYS` days ago, or
|
||||
- it was never started and was created more than `ROOM_INACTIVITY_DELETION_DAYS` days ago.
|
||||
|
||||
Rooms that existed before this feature was deployed are considered started on the day of the release, so none of them can be purged before a full inactivity period has elapsed.
|
||||
|
||||
The command is meant to run once a day. The Helm chart schedules it in `backend.cronjobs` (`purge-inactive-rooms`, 01:00); it does nothing until `ROOM_INACTIVITY_DELETION_DAYS` is set.
|
||||
|
||||
```bash
|
||||
python manage.py purge_inactive_rooms # delete the inactive rooms
|
||||
python manage.py purge_inactive_rooms --dry-run # only list the rooms that would be deleted
|
||||
```
|
||||
|
||||
## Rooms that are kept
|
||||
|
||||
A recording can only be reached through its room. An inactive room is kept as long as it holds a saved recording its users may still access:
|
||||
|
||||
- with `RECORDING_EXPIRATION_DAYS` set, a saved recording created less than that many days ago,
|
||||
- with `RECORDING_EXPIRATION_DAYS` unset, any saved recording.
|
||||
|
||||
## What happens to a purged room
|
||||
|
||||
The room is deleted from the database, along with its accesses, its telephony PIN code, and the recording entries it still holds — the expired ones and those that were never saved, since any other recording would have protected the room — together with their own accesses.
|
||||
|
||||
The recording **files in the bucket are left untouched**: the backend never deletes anything from the storage, it only drops the database entries pointing at it. Removing the files is the job of the bucket lifecycle policy, which should match `RECORDING_EXPIRATION_DAYS` (see the [recording documentation](recording.md)). When the two do not match, the purge leaves objects behind: they become unreachable, since serving a recording requires its database entry, but they keep costing storage.
|
||||
|
||||
⚠️ When a room is purged, all it's configuration and access rights are also deleted. Its slug becomes available again
|
||||
and can be reused when a meeting is created from that same URL.
|
||||
|
||||
* With `ALLOW_UNREGISTERED_ROOMS=false`, only an authenticated user can navigate to a previously existing link after the room has been purged. Doing so recreates the room in the database with a fresh configuration, with that user associated with it and granted admin rights.
|
||||
|
||||
* With `ALLOW_UNREGISTERED_ROOMS=true`, any user can reopen the purged room by navigating to the same URL. In that case, the room is created dynamically and no corresponding room entry is persisted in the database.
|
||||
@@ -42,7 +42,7 @@ sequenceDiagram
|
||||
participant Backend as Backend API
|
||||
participant Summary as Summary Service
|
||||
participant Celery as Celery Workers (transcribe-queue)
|
||||
participant MinIO as MinIO (Object Storage)
|
||||
participant S3 as S3 (Object Storage)
|
||||
participant STT as WhisperX API
|
||||
participant Docs as LaSuite Docs
|
||||
|
||||
@@ -50,7 +50,7 @@ sequenceDiagram
|
||||
Note right of Backend: Payload contains 7 params: owner_id, filename, email, sub, room, recording_date, recording_time
|
||||
|
||||
Summary->>Celery: Register task (transcribe-queue)
|
||||
Celery->>MinIO: Fetch audio file
|
||||
Celery->>S3: Fetch audio file
|
||||
Celery->>STT: Transcribe audio (WhisperX)
|
||||
STT-->>Celery: Segmented transcript
|
||||
|
||||
@@ -72,11 +72,12 @@ sequenceDiagram
|
||||
| celery_result_backend | String | `"redis://redis/0"` | Celery result backend URL. |
|
||||
| celery_max_retries | Integer | `1` | Maximum number of retries for Celery tasks. |
|
||||
| transcribe_queue | String | `"transcribe-queue"` | Name of the Celery queue for transcription tasks. |
|
||||
| aws_storage_bucket_name | String | — | Name of the S3/MinIO bucket used for storing recordings. |
|
||||
| aws_s3_endpoint_url | String | — | Endpoint URL of the S3/MinIO storage. |
|
||||
| aws_s3_access_key_id | String | — | Access key for S3/MinIO. |
|
||||
| aws_s3_secret_access_key | Secret | — | Secret key for S3/MinIO. |
|
||||
| aws_s3_secure_access | Boolean | `True` | Use HTTPS for S3/MinIO requests. |
|
||||
| aws_storage_bucket_name | String | — | Name of the S3 bucket used for storing recordings. |
|
||||
| aws_s3_endpoint_url | String | — | Endpoint URL of the S3 storage. |
|
||||
| aws_s3_access_key_id | String | — | Access key for S3. |
|
||||
| aws_s3_secret_access_key | Secret | — | Secret key for S3. |
|
||||
| aws_s3_secure_access | Boolean | `True` | Use HTTPS for S3 requests. |
|
||||
| aws_s3_region_name | String | — | Region used to sign S3 requests, passed as-is to boto3. |
|
||||
| whisperx_api_key | Secret | — | API key for accessing WhisperX. |
|
||||
| whisperx_base_url | String | `"https://api.whisperx.com/v1"` | Base URL for the WhisperX API. |
|
||||
| whisperx_asr_model | String | `"whisper-1"` | ASR model used for transcription. |
|
||||
|
||||
@@ -14,7 +14,7 @@ All services are required to run the minimalist instance of LaSuite Meet. Click
|
||||
| **OIDC Provider** | User authentication | [Keycloak setup](../examples/compose/keycloak/README.md) |
|
||||
| **SMTP Service** | Email notifications | - |
|
||||
|
||||
> [!NOTE] Some advanced features, as Recording and transcription, require additional services (MinIO, email). See `/features` folder for details.
|
||||
> [!NOTE] Some advanced features, as Recording and transcription, require additional services (S3-compatible object storage, email). See `/features` folder for details.
|
||||
|
||||
|
||||
## Software Requirements
|
||||
|
||||
@@ -403,6 +403,7 @@ These are the environmental options available on meet backend.
|
||||
| LIVEKIT_ENABLE_FIREFOX_PROXY_WORKAROUND | Firefox-only connection warmup: pre-calls WebSocket endpoint (expecting 401) to initialize cache, resolving proxy/network connectivity issues. | false |
|
||||
| RESOURCE_DEFAULT_ACCESS_LEVEL | Default resource access level for rooms | public |
|
||||
| ALLOW_UNREGISTERED_ROOMS | Allow usage of unregistered rooms | true |
|
||||
| ROOM_INACTIVITY_DELETION_DAYS | Days without being started after which a room is purged. Unset to never purge | |
|
||||
| RECORDING_ENABLE | Record meeting option | false |
|
||||
| RECORDING_OUTPUT_FOLDER | Folder to store meetings | recordings |
|
||||
| RECORDING_WORKER_CLASSES | Worker classes for recording | {"screen_recording": "core.recording.worker.services.VideoCompositeEgressService","transcript": "core.recording.worker.services.AudioCompositeEgressService"} |
|
||||
|
||||
@@ -24,9 +24,10 @@ MEET_BASE_URL="http://localhost:8072"
|
||||
# Media
|
||||
STORAGES_STATICFILES_BACKEND=django.contrib.staticfiles.storage.StaticFilesStorage
|
||||
AWS_S3_DOMAIN_REPLACE=http://localhost:9000
|
||||
AWS_S3_ENDPOINT_URL=http://minio:9000
|
||||
AWS_S3_ACCESS_KEY_ID=meet
|
||||
AWS_S3_SECRET_ACCESS_KEY=password
|
||||
AWS_S3_ENDPOINT_URL=http://garage:9000
|
||||
AWS_S3_ACCESS_KEY_ID=meet-access-key
|
||||
AWS_S3_SECRET_ACCESS_KEY=meet-secret-access-key
|
||||
AWS_S3_REGION_NAME=local
|
||||
MEDIA_BASE_URL=http://localhost:3000
|
||||
FILE_UPLOAD_ENABLED=True
|
||||
|
||||
@@ -63,7 +64,8 @@ ALLOW_UNREGISTERED_ROOMS=False
|
||||
|
||||
# Recording
|
||||
RECORDING_ENABLE=True
|
||||
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe/
|
||||
SUMMARY_SERVICE_VERSION=2
|
||||
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe
|
||||
SUMMARY_SERVICE_API_TOKEN=password
|
||||
SUMMARY_SERVICE_WEBHOOK_API_TOKEN=webhook-password
|
||||
RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Filled with a random value by `make create-env-files`
|
||||
GARAGE_RPC_SECRET=
|
||||
@@ -2,8 +2,9 @@ LIVEKIT_URL=ws://livekit:7880
|
||||
LIVEKIT_API_KEY=devkey
|
||||
LIVEKIT_API_SECRET=secret
|
||||
|
||||
AWS_S3_ENDPOINT_URL=minio:9000
|
||||
AWS_S3_ACCESS_KEY_ID=meet
|
||||
AWS_S3_SECRET_ACCESS_KEY=password
|
||||
AWS_S3_ENDPOINT_URL=garage:9000
|
||||
AWS_S3_ACCESS_KEY_ID=meet-access-key
|
||||
AWS_S3_SECRET_ACCESS_KEY=meet-secret-access-key
|
||||
AWS_S3_REGION_NAME=local
|
||||
AWS_STORAGE_BUCKET_NAME=meet-media-storage
|
||||
AWS_S3_SECURE_ACCESS=False
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
AWS_S3_ENDPOINT_URL=minio:9000
|
||||
AWS_S3_ACCESS_KEY_ID=meet
|
||||
AWS_S3_SECRET_ACCESS_KEY=password
|
||||
AWS_S3_ENDPOINT_URL=garage:9000
|
||||
AWS_S3_ACCESS_KEY_ID=meet-access-key
|
||||
AWS_S3_SECRET_ACCESS_KEY=meet-secret-access-key
|
||||
AWS_S3_REGION_NAME=local
|
||||
|
||||
LIVEKIT_URL=ws://livekit:7880
|
||||
LIVEKIT_API_KEY=devkey
|
||||
|
||||
@@ -2,11 +2,12 @@ APP_NAME="meet-app-summary-dev"
|
||||
APP_API_TOKEN="password"
|
||||
|
||||
AWS_STORAGE_BUCKET_NAME="meet-media-storage"
|
||||
AWS_S3_ENDPOINT_URL="minio:9000"
|
||||
AWS_S3_ENDPOINT_URL="garage:9000"
|
||||
AWS_S3_SECURE_ACCESS=false
|
||||
|
||||
AWS_S3_ACCESS_KEY_ID="meet"
|
||||
AWS_S3_SECRET_ACCESS_KEY="password"
|
||||
AWS_S3_ACCESS_KEY_ID="meet-access-key"
|
||||
AWS_S3_SECRET_ACCESS_KEY="meet-secret-access-key"
|
||||
AWS_S3_REGION_NAME="local"
|
||||
|
||||
WHISPERX_BASE_URL="https://configure-your-url.com"
|
||||
WHISPERX_ASR_MODEL="large-v2"
|
||||
|
||||
Generated
+4
-4
@@ -10,7 +10,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "3.50.0",
|
||||
"i18next": "26.4.0",
|
||||
"i18next": "26.4.2",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"regenerator-runtime": "0.14.1"
|
||||
},
|
||||
@@ -9367,9 +9367,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.4.0",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.0.tgz",
|
||||
"integrity": "sha512-rsmK5bFqsD1AetSFSIa43wtNR4WpvvH4p0tLEsTxkC7QTrfdFm06nbQ95bh8Og4wwaCnUEcm9DVYL2cgxitiQg==",
|
||||
"version": "26.4.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz",
|
||||
"integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"core-js": "3.50.0",
|
||||
"i18next": "26.4.0",
|
||||
"i18next": "26.4.2",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"regenerator-runtime": "0.14.1"
|
||||
},
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
FROM python:3.14.6-slim AS base
|
||||
|
||||
# Install system dependencies required by LiveKit
|
||||
RUN apt-get update && apt-get install -y \
|
||||
# Install system dependencies required by LiveKit, fetching packages over HTTPS only for Bureautix proxy
|
||||
RUN sed -i "s|^URIs: http://|URIs: https://|" /etc/apt/sources.list.d/debian.sources \
|
||||
&& apt-get update && apt-get install -y --no-install-recommends \
|
||||
libglib2.0-0 \
|
||||
libgobject-2.0-0 \
|
||||
libssl3t64 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
|
||||
|
||||
@@ -6,9 +6,11 @@ import logging
|
||||
import os
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
from io import BytesIO
|
||||
from typing import List, Optional
|
||||
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
from botocore.exceptions import BotoCoreError, ClientError
|
||||
from dotenv import load_dotenv
|
||||
from livekit import api, rtc
|
||||
from livekit.agents import (
|
||||
@@ -28,8 +30,6 @@ from livekit.agents import (
|
||||
room_io as lk_room_io,
|
||||
)
|
||||
from livekit.plugins import silero
|
||||
from minio import Minio
|
||||
from minio.error import S3Error
|
||||
|
||||
from exceptions import MissingConfigError
|
||||
from observability import configure_sentry, set_job_context
|
||||
@@ -59,6 +59,30 @@ server = AgentServer(
|
||||
server.setup_fnc = prewarm
|
||||
|
||||
|
||||
def create_s3_client():
|
||||
"""Create an S3 client for the configured endpoint and region.
|
||||
|
||||
The endpoint may be given with or without a scheme: the scheme always
|
||||
follows AWS_S3_SECURE_ACCESS.
|
||||
"""
|
||||
endpoint = (
|
||||
os.getenv("AWS_S3_ENDPOINT_URL", "")
|
||||
.removeprefix("https://")
|
||||
.removeprefix("http://")
|
||||
.rstrip("/")
|
||||
)
|
||||
secure = os.getenv("AWS_S3_SECURE_ACCESS", "False").lower() == "true"
|
||||
|
||||
return boto3.client(
|
||||
"s3",
|
||||
endpoint_url=f"{'https' if secure else 'http'}://{endpoint}",
|
||||
aws_access_key_id=os.getenv("AWS_S3_ACCESS_KEY_ID"),
|
||||
aws_secret_access_key=os.getenv("AWS_S3_SECRET_ACCESS_KEY"),
|
||||
region_name=os.getenv("AWS_S3_REGION_NAME"),
|
||||
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MetadataEvent:
|
||||
"""A single timestamped event recorded during a meeting."""
|
||||
@@ -121,18 +145,13 @@ class MetadataCollector:
|
||||
|
||||
def __init__(self, ctx: JobContext, recording_id: str):
|
||||
"""Initialize metadata agent."""
|
||||
self.minio_client = Minio(
|
||||
endpoint=os.getenv("AWS_S3_ENDPOINT_URL"),
|
||||
access_key=os.getenv("AWS_S3_ACCESS_KEY_ID"),
|
||||
secret_key=os.getenv("AWS_S3_SECRET_ACCESS_KEY"),
|
||||
secure=os.getenv("AWS_S3_SECURE_ACCESS", "False").lower() == "true",
|
||||
)
|
||||
|
||||
if (bucket_name := os.getenv("AWS_STORAGE_BUCKET_NAME")) is not None:
|
||||
self.bucket_name = bucket_name
|
||||
else:
|
||||
raise MissingConfigError
|
||||
|
||||
self.s3_client = create_s3_client()
|
||||
|
||||
self.ctx = ctx
|
||||
self._sessions: dict[str, AgentSession] = {}
|
||||
self._tasks: set[asyncio.Task] = set()
|
||||
@@ -201,20 +220,18 @@ class MetadataCollector:
|
||||
}
|
||||
|
||||
data = json.dumps(payload, indent=2).encode("utf-8")
|
||||
stream = BytesIO(data)
|
||||
|
||||
try:
|
||||
self.minio_client.put_object(
|
||||
self.bucket_name,
|
||||
self.output_filename,
|
||||
stream,
|
||||
length=len(data),
|
||||
content_type="application/json",
|
||||
self.s3_client.put_object(
|
||||
Bucket=self.bucket_name,
|
||||
Key=self.output_filename,
|
||||
Body=data,
|
||||
ContentType="application/json",
|
||||
)
|
||||
logger.info(
|
||||
"Uploaded speaker meeting metadata",
|
||||
)
|
||||
except S3Error:
|
||||
except (BotoCoreError, ClientError):
|
||||
logger.exception(
|
||||
"Failed to upload meeting metadata",
|
||||
)
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
|
||||
[project]
|
||||
name = "agents"
|
||||
version = "1.31.0"
|
||||
version = "1.32.1"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"livekit-agents==1.6.7",
|
||||
"livekit-plugins-deepgram==1.6.7",
|
||||
"livekit-plugins-silero==1.6.7",
|
||||
"livekit-agents==1.7.0",
|
||||
"livekit-plugins-deepgram==1.7.0",
|
||||
"livekit-plugins-silero==1.7.0",
|
||||
"livekit-plugins-kyutai-lasuite==0.0.6",
|
||||
"python-dotenv==1.2.2",
|
||||
"protobuf==6.33.6",
|
||||
"minio==7.2.20",
|
||||
"sentry-sdk==2.66.1",
|
||||
"boto3==1.43.56",
|
||||
"python-dotenv==1.2.3",
|
||||
"protobuf==7.36.0",
|
||||
"sentry-sdk==2.68.1",
|
||||
"websockets==17.1",
|
||||
"httpx==0.28.1",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"ruff==0.16.0",
|
||||
"ruff==0.16.4",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
Generated
+859
-854
File diff suppressed because it is too large
Load Diff
@@ -279,9 +279,23 @@ class RoomAdmin(admin.ModelAdmin):
|
||||
|
||||
inlines = (ResourceAccessInline,)
|
||||
search_fields = ["name", "slug", "=id"]
|
||||
list_display = ["name", "slug", "access_level", "get_owner", "created_at"]
|
||||
list_filter = ["access_level", "created_at"]
|
||||
readonly_fields = ["id", "created_at", "updated_at"]
|
||||
list_display = [
|
||||
"name",
|
||||
"slug",
|
||||
"access_level",
|
||||
"get_owner",
|
||||
"created_at",
|
||||
"deleted_at",
|
||||
]
|
||||
list_filter = ["access_level", "created_at", "deleted_at", "last_started_at"]
|
||||
readonly_fields = [
|
||||
"id",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"deleted_at",
|
||||
"last_started_at",
|
||||
]
|
||||
actions = []
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""Optimize queries by prefetching related access and user data to avoid N+1 queries."""
|
||||
|
||||
@@ -9,6 +9,7 @@ class AnalyticsEvent(StrEnum):
|
||||
# Rooms
|
||||
ROOM_CREATED = "room_created"
|
||||
ROOM_UPDATED = "room_updated"
|
||||
ROOM_DELETED = "room_deleted"
|
||||
|
||||
# Roomkit (meeting-room SIP devices)
|
||||
ROOMKIT_JOINED = "roomkit_joined"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Exceptions and guards shared by the API endpoints."""
|
||||
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from rest_framework import exceptions, status
|
||||
|
||||
from core import models
|
||||
|
||||
|
||||
class RoomGone(exceptions.APIException):
|
||||
"""Raised when the requested room has been soft deleted."""
|
||||
|
||||
status_code = status.HTTP_410_GONE
|
||||
default_detail = _("This room has been deleted.")
|
||||
default_code = "room_deleted"
|
||||
|
||||
|
||||
def ensure_room_not_deleted(resource):
|
||||
"""Raise a 410 Gone if the resource is a soft-deleted room.
|
||||
|
||||
Accepts a room or its parent resource, as referenced by accesses. Call it
|
||||
after permissions are checked, so a deleted room is only revealed to users
|
||||
who would have been granted access to it.
|
||||
"""
|
||||
if isinstance(resource, models.Room):
|
||||
room = resource
|
||||
else:
|
||||
room = getattr(resource, "room", None)
|
||||
|
||||
if room is not None and room.is_deleted:
|
||||
raise RoomGone()
|
||||
@@ -12,10 +12,6 @@ from ..services.participants_management import (
|
||||
ParticipantsManagementException,
|
||||
)
|
||||
|
||||
ACTION_FOR_METHOD_TO_PERMISSION = {
|
||||
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
|
||||
}
|
||||
|
||||
|
||||
class IsAuthenticated(permissions.BasePermission):
|
||||
"""
|
||||
@@ -27,15 +23,6 @@ class IsAuthenticated(permissions.BasePermission):
|
||||
return bool(request.auth) or request.user.is_authenticated
|
||||
|
||||
|
||||
class IsAuthenticatedOrSafe(IsAuthenticated):
|
||||
"""Allows access to authenticated users (or anonymous users but only on safe methods)."""
|
||||
|
||||
def has_permission(self, request, view):
|
||||
if request.method in permissions.SAFE_METHODS:
|
||||
return True
|
||||
return super().has_permission(request, view)
|
||||
|
||||
|
||||
class IsSelf(IsAuthenticated):
|
||||
"""
|
||||
Allows access only to authenticated users. Alternative method checking the presence
|
||||
|
||||
@@ -20,6 +20,7 @@ from rest_framework.exceptions import PermissionDenied
|
||||
from timezone_field.rest_framework import TimeZoneSerializerField
|
||||
|
||||
from core import models, utils
|
||||
from core.api.exceptions import ensure_room_not_deleted
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -108,6 +109,7 @@ class ResourceAccessSerializerMixin:
|
||||
_("You must be administrator or owner of a room to add accesses to it.")
|
||||
)
|
||||
|
||||
ensure_room_not_deleted(resource)
|
||||
return resource
|
||||
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ from rest_framework.settings import api_settings
|
||||
|
||||
from core import analytics, enums, models, utils
|
||||
from core.api import throttling
|
||||
from core.api.exceptions import ensure_room_not_deleted
|
||||
from core.api.filters import ListFileFilter
|
||||
from core.enums import MEDIA_STORAGE_URL_PATTERN
|
||||
from core.recording.enums import FileExtension
|
||||
@@ -75,7 +76,7 @@ from core.services.participants_management import (
|
||||
ParticipantsManagementException,
|
||||
)
|
||||
from core.services.room_creation import RoomCreation
|
||||
from core.services.room_management import RoomManagement
|
||||
from core.services.room_management import RoomManagement, RoomManagementException
|
||||
from core.services.room_roles import (
|
||||
RoomRoleError,
|
||||
RoomRoleService,
|
||||
@@ -95,60 +96,6 @@ from .feature_flag import FeatureFlag
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class NestedGenericViewSet(viewsets.GenericViewSet):
|
||||
"""
|
||||
A generic Viewset aims to be used in a nested route context.
|
||||
e.g: `/api/v1.0/resource_1/<resource_1_pk>/resource_2/<resource_2_pk>/`
|
||||
|
||||
It allows to define all url kwargs and lookup fields to perform the lookup.
|
||||
"""
|
||||
|
||||
lookup_fields: list[str] = ["pk"]
|
||||
lookup_url_kwargs: list[str] = []
|
||||
|
||||
def __getattribute__(self, file):
|
||||
"""
|
||||
This method is overridden to allow to get the last lookup field or lookup url kwarg
|
||||
when accessing the `lookup_field` or `lookup_url_kwarg` attribute. This is useful
|
||||
to keep compatibility with all methods used by the parent class `GenericViewSet`.
|
||||
"""
|
||||
if file in ["lookup_field", "lookup_url_kwarg"]:
|
||||
return getattr(self, file + "s", [None])[-1]
|
||||
|
||||
return super().__getattribute__(file)
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
Get the list of files for this view.
|
||||
|
||||
`lookup_fields` attribute is enumerated here to perform the nested lookup.
|
||||
"""
|
||||
queryset = super().get_queryset()
|
||||
|
||||
# The last lookup field is removed to perform the nested lookup as it corresponds
|
||||
# to the object pk, it is used within get_object method.
|
||||
lookup_url_kwargs = (
|
||||
self.lookup_url_kwargs[:-1]
|
||||
if self.lookup_url_kwargs
|
||||
else self.lookup_fields[:-1]
|
||||
)
|
||||
|
||||
filter_kwargs = {}
|
||||
for index, lookup_url_kwarg in enumerate(lookup_url_kwargs):
|
||||
if lookup_url_kwarg not in self.kwargs:
|
||||
raise KeyError(
|
||||
f"Expected view {self.__class__.__name__} to be called with a URL "
|
||||
f'keyword argument named "{lookup_url_kwarg}". Fix your URL conf, or '
|
||||
"set the `.lookup_fields` attribute on the view correctly."
|
||||
)
|
||||
|
||||
filter_kwargs.update(
|
||||
{self.lookup_fields[index]: self.kwargs[lookup_url_kwarg]}
|
||||
)
|
||||
|
||||
return queryset.filter(**filter_kwargs)
|
||||
|
||||
|
||||
class SerializerPerActionMixin:
|
||||
"""
|
||||
A mixin to allow to define serializer classes for each action.
|
||||
@@ -242,10 +189,9 @@ class RoomViewSet(
|
||||
filter_kwargs = {"pk": self.kwargs["pk"]}
|
||||
except ValueError:
|
||||
filter_kwargs = {"slug": slugify(self.kwargs["pk"])}
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
obj = get_object_or_404(queryset, **filter_kwargs)
|
||||
# May raise a permission denied
|
||||
obj = get_object_or_404(models.Room.all_objects, **filter_kwargs)
|
||||
self.check_object_permissions(self.request, obj)
|
||||
ensure_room_not_deleted(obj)
|
||||
return obj
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
@@ -297,6 +243,18 @@ class RoomViewSet(
|
||||
serializer = self.get_serializer(queryset, many=True)
|
||||
return drf_response.Response(serializer.data)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Soft delete the room and close its LiveKit room.
|
||||
|
||||
The room and its recordings are kept in database for traceability.
|
||||
"""
|
||||
try:
|
||||
RoomManagement.soft_delete(instance)
|
||||
except RoomManagementException as e:
|
||||
raise drf_exceptions.APIException(
|
||||
"Could not delete the room, please try again."
|
||||
) from e
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Set the current user as owner of the newly created room.
|
||||
|
||||
@@ -981,6 +939,13 @@ class ResourceAccessViewSet(
|
||||
|
||||
return queryset
|
||||
|
||||
def get_object(self):
|
||||
"""Accesses to a soft-deleted room can be read but no longer modified."""
|
||||
access = super().get_object()
|
||||
if self.request.method not in drf_permissions.SAFE_METHODS:
|
||||
ensure_room_not_deleted(access.resource)
|
||||
return access
|
||||
|
||||
|
||||
class RecordingViewSet(
|
||||
mixins.DestroyModelMixin,
|
||||
|
||||
@@ -22,11 +22,13 @@ from rest_framework import (
|
||||
from rest_framework import (
|
||||
status as drf_status,
|
||||
)
|
||||
from rest_framework.generics import get_object_or_404
|
||||
|
||||
from core import analytics, api, models
|
||||
from core.api.exceptions import ensure_room_not_deleted
|
||||
from core.api.feature_flag import FeatureFlag
|
||||
from core.services.jwt_token import JwtTokenService
|
||||
from core.services.room_management import RoomManagement
|
||||
from core.services.room_management import RoomManagement, RoomManagementException
|
||||
|
||||
from ..services.provisional_user_service import (
|
||||
ProvisionalUserCreationDisabledError,
|
||||
@@ -142,6 +144,7 @@ class ApplicationViewSet(viewsets.ViewSet):
|
||||
|
||||
class RoomViewSet(
|
||||
mixins.CreateModelMixin,
|
||||
mixins.DestroyModelMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
mixins.UpdateModelMixin,
|
||||
@@ -159,9 +162,13 @@ class RoomViewSet(
|
||||
- create: Create a new room owned by the user (requires 'rooms:create' scope)
|
||||
- partial_update: Update a room's access level and configuration, for
|
||||
administrators and owners only (requires 'rooms:update' scope)
|
||||
- destroy: Soft delete a room and close its LiveKit room, for owners only
|
||||
(requires 'rooms:delete' scope)
|
||||
|
||||
Detail operations on a soft-deleted room answer 410 Gone.
|
||||
"""
|
||||
|
||||
http_method_names = ["get", "post", "patch", "head", "options"]
|
||||
http_method_names = ["get", "post", "patch", "delete", "head", "options"]
|
||||
|
||||
authentication_classes = [
|
||||
authentication.ApplicationJWTAuthentication,
|
||||
@@ -176,6 +183,17 @@ class RoomViewSet(
|
||||
queryset = models.Room.objects.all()
|
||||
serializer_class = serializers.RoomSerializer
|
||||
|
||||
def get_object(self):
|
||||
"""Get the room, answer 410 if it has been deleted.
|
||||
|
||||
Permissions are checked first so a deleted room is only revealed to
|
||||
users who would have been granted access to it.
|
||||
"""
|
||||
room = get_object_or_404(models.Room.all_objects, pk=self.kwargs["pk"])
|
||||
self.check_object_permissions(self.request, room)
|
||||
ensure_room_not_deleted(room)
|
||||
return room
|
||||
|
||||
def list(self, request, *args, **kwargs):
|
||||
"""Limit listed rooms to the ones related to the authenticated user."""
|
||||
|
||||
@@ -239,6 +257,16 @@ class RoomViewSet(
|
||||
|
||||
self._track_room_event(room, analytics.AnalyticsEvent.ROOM_CREATED)
|
||||
|
||||
def perform_destroy(self, instance):
|
||||
"""Soft delete the room, close its LiveKit room, then log and track it."""
|
||||
try:
|
||||
RoomManagement.soft_delete(instance)
|
||||
except RoomManagementException as e:
|
||||
raise drf_exceptions.APIException(
|
||||
"Could not delete the room, please try again."
|
||||
) from e
|
||||
self._track_room_event(instance, analytics.AnalyticsEvent.ROOM_DELETED)
|
||||
|
||||
def perform_update(self, serializer: serializers.RoomSerializer):
|
||||
"""Persist the room update, sync it to LiveKit, then log and track it."""
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Purge inactive rooms."""
|
||||
|
||||
from datetime import timedelta
|
||||
from itertools import batched
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.db.models import Exists, OuterRef, Q
|
||||
from django.utils import timezone
|
||||
|
||||
from core.models import Recording, RecordingStatusChoices, Room
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
CHUNK_SIZE = 500
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Delete rooms that have not been started for ROOM_INACTIVITY_DELETION_DAYS days:
|
||||
- rooms which were last started before that period
|
||||
- rooms never started and created before that period
|
||||
|
||||
Rooms holding a saved recording that has not expired are kept.
|
||||
"""
|
||||
|
||||
help = "Purge inactive rooms"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
action="store_true",
|
||||
help="List the rooms that would be purged without deleting them",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Browse inactive rooms and delete them chunk by chunk."""
|
||||
|
||||
if not settings.ROOM_INACTIVITY_DELETION_DAYS:
|
||||
self.stdout.write(
|
||||
"Purging inactive rooms is disabled "
|
||||
"(ROOM_INACTIVITY_DELETION_DAYS is not set)."
|
||||
)
|
||||
return
|
||||
|
||||
now = timezone.now()
|
||||
inactive_rooms = self.get_inactive_rooms(now)
|
||||
|
||||
inactive_count = inactive_rooms.count()
|
||||
if not inactive_count:
|
||||
self.stdout.write("No inactive room to purge.")
|
||||
return
|
||||
|
||||
if options["dry_run"]:
|
||||
self.stdout.write(
|
||||
f"[dry-run] {inactive_count} inactive room(s) would be purged:"
|
||||
)
|
||||
names = inactive_rooms.values_list("name", flat=True)
|
||||
for name in names.iterator(chunk_size=CHUNK_SIZE):
|
||||
self.stdout.write(f"- {name}")
|
||||
return
|
||||
|
||||
purged_count = 0
|
||||
rooms = inactive_rooms.values_list("pk", "slug").iterator(chunk_size=CHUNK_SIZE)
|
||||
for chunk in batched(rooms, CHUNK_SIZE, strict=False):
|
||||
for room_id, slug in chunk:
|
||||
logger.info("Purging inactive room %s (%s)", room_id, slug)
|
||||
|
||||
_, deleted_by_model = inactive_rooms.filter(
|
||||
pk__in=[room_id for room_id, _ in chunk]
|
||||
).delete()
|
||||
purged_count += deleted_by_model.get("core.Room", 0)
|
||||
|
||||
self.stdout.write(f"Purged {purged_count} inactive room(s).")
|
||||
|
||||
@staticmethod
|
||||
def get_inactive_rooms(now):
|
||||
"""Return the rooms inactive for too long that no recording protects."""
|
||||
|
||||
threshold = now - timedelta(days=settings.ROOM_INACTIVITY_DELETION_DAYS)
|
||||
is_inactive = Q(last_started_at__lt=threshold) | Q(
|
||||
last_started_at__isnull=True, created_at__lt=threshold
|
||||
)
|
||||
|
||||
protected_recordings = Recording.objects.filter(
|
||||
room=OuterRef("pk"), status__in=RecordingStatusChoices.saved_statuses()
|
||||
)
|
||||
if settings.RECORDING_EXPIRATION_DAYS:
|
||||
protected_recordings = protected_recordings.filter(
|
||||
created_at__gte=now - timedelta(days=settings.RECORDING_EXPIRATION_DAYS)
|
||||
)
|
||||
|
||||
return Room.objects.filter(is_inactive, ~Exists(protected_recordings))
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.16 on 2026-09-23 16:49
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0022_user_default_room_access_level_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='recording',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('initiated', 'Initiated'), ('active', 'Active'), ('stopped', 'Stopped'), ('saved', 'Saved'), ('aborted', 'Aborted'), ('failed', 'Failed'), ('failed_to_start', 'Failed to Start'), ('failed_to_stop', 'Failed to Stop'), ('notification_succeeded', 'Notification succeeded'), ('external_process_successful', 'External process successful'), ('external_process_failed', 'External process failed')], default='initiated', max_length=50),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
from django.db import migrations, models
|
||||
import django.utils.timezone
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0023_alter_recording_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='room',
|
||||
name='last_started_at',
|
||||
field=models.DateTimeField(blank=True, default=django.utils.timezone.now, editable=False, help_text='date and time at which the room was last started', null=True, verbose_name='last started at'),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,29 @@
|
||||
# Generated by Django 5.2.14 on 2026-09-16 10:00
|
||||
|
||||
import django.db.models.manager
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0024_room_last_started_at'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='room',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='room',
|
||||
options={'default_manager_name': 'all_objects', 'ordering': ('name',), 'verbose_name': 'Room', 'verbose_name_plural': 'Rooms'},
|
||||
),
|
||||
migrations.AlterModelManagers(
|
||||
name='room',
|
||||
managers=[
|
||||
('all_objects', django.db.models.manager.Manager()),
|
||||
],
|
||||
),
|
||||
]
|
||||
+71
-10
@@ -58,6 +58,7 @@ class RecordingStatusChoices(models.TextChoices):
|
||||
STOPPED = "stopped", _("Stopped")
|
||||
SAVED = "saved", _("Saved")
|
||||
ABORTED = "aborted", _("Aborted")
|
||||
FAILED = "failed", _("Failed")
|
||||
FAILED_TO_START = "failed_to_start", _("Failed to Start")
|
||||
FAILED_TO_STOP = "failed_to_stop", _("Failed to Stop")
|
||||
NOTIFICATION_SUCCEEDED = "notification_succeeded", _("Notification succeeded")
|
||||
@@ -79,6 +80,7 @@ class RecordingStatusChoices(models.TextChoices):
|
||||
cls.STOPPED,
|
||||
cls.SAVED,
|
||||
cls.ABORTED,
|
||||
cls.FAILED,
|
||||
cls.EXTERNAL_PROCESS_SUCCESSFUL,
|
||||
cls.EXTERNAL_PROCESS_FAILED,
|
||||
cls.FAILED_TO_START,
|
||||
@@ -86,9 +88,15 @@ class RecordingStatusChoices(models.TextChoices):
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def is_unsuccessful(cls, status):
|
||||
"""Determine if the recording status represents an unsuccessful state."""
|
||||
return status in {cls.ABORTED, cls.FAILED_TO_START, cls.FAILED_TO_STOP}
|
||||
def saved_statuses(cls):
|
||||
"""Return the statuses of a recording whose file users can access."""
|
||||
|
||||
return {
|
||||
cls.NOTIFICATION_SUCCEEDED,
|
||||
cls.SAVED,
|
||||
cls.EXTERNAL_PROCESS_SUCCESSFUL,
|
||||
cls.EXTERNAL_PROCESS_FAILED,
|
||||
}
|
||||
|
||||
|
||||
class RecordingModeChoices(models.TextChoices):
|
||||
@@ -398,6 +406,33 @@ class ResourceAccess(BaseModel):
|
||||
return super().delete(*args, **kwargs)
|
||||
|
||||
|
||||
class RoomQuerySet(models.QuerySet):
|
||||
"""QuerySet exposing the room lifecycle filters."""
|
||||
|
||||
def active(self):
|
||||
"""Rooms that have not been soft deleted."""
|
||||
return self.filter(deleted_at__isnull=True)
|
||||
|
||||
def deleted(self):
|
||||
"""Rooms that have been soft deleted."""
|
||||
return self.filter(deleted_at__isnull=False)
|
||||
|
||||
|
||||
class RoomManager(models.Manager.from_queryset(RoomQuerySet)):
|
||||
"""Manager hiding soft-deleted rooms, exposed as ``Room.objects``.
|
||||
|
||||
It is deliberately not the model's default manager: Django relies on
|
||||
``_default_manager`` for unique validation, which must see deleted rooms as
|
||||
they keep holding their slug and pin code. Forward relations (e.g.
|
||||
``recording.room``) go through the base manager and still resolve deleted
|
||||
rooms, which keeps recordings and their notifications working.
|
||||
"""
|
||||
|
||||
def get_queryset(self):
|
||||
"""Exclude soft-deleted rooms."""
|
||||
return super().get_queryset().active()
|
||||
|
||||
|
||||
class Room(Resource):
|
||||
"""Model for one room"""
|
||||
|
||||
@@ -421,6 +456,7 @@ class Room(Resource):
|
||||
verbose_name=_("Visio room configuration"),
|
||||
help_text=_("Values for Visio parameters to configure the room."),
|
||||
)
|
||||
deleted_at = models.DateTimeField(null=True, blank=True)
|
||||
pin_code = models.CharField(
|
||||
max_length=None,
|
||||
unique=True,
|
||||
@@ -429,9 +465,21 @@ class Room(Resource):
|
||||
verbose_name=_("Room PIN code"),
|
||||
help_text=_("Unique n-digit code that identifies this room in telephony mode."),
|
||||
)
|
||||
last_started_at = models.DateTimeField(
|
||||
verbose_name=_("last started at"),
|
||||
help_text=_("date and time at which the room was last started"),
|
||||
blank=True,
|
||||
null=True,
|
||||
editable=False,
|
||||
)
|
||||
|
||||
# Managers
|
||||
objects = RoomManager()
|
||||
all_objects = models.Manager.from_queryset(RoomQuerySet)()
|
||||
|
||||
class Meta:
|
||||
db_table = "meet_room"
|
||||
default_manager_name = "all_objects"
|
||||
ordering = ("name",)
|
||||
verbose_name = _("Room")
|
||||
verbose_name_plural = _("Rooms")
|
||||
@@ -454,6 +502,23 @@ class Room(Resource):
|
||||
)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def is_deleted(self):
|
||||
"""Whether the room has been soft deleted."""
|
||||
return self.deleted_at is not None
|
||||
|
||||
def soft_delete(self):
|
||||
"""Soft delete the room.
|
||||
|
||||
The room is hidden from the default manager making it impossible to
|
||||
list, join or update.
|
||||
"""
|
||||
if self.deleted_at:
|
||||
raise RuntimeError("This room is already deleted.")
|
||||
|
||||
self.deleted_at = timezone.now()
|
||||
self.save(update_fields=["deleted_at"])
|
||||
|
||||
def clean_fields(self, exclude=None):
|
||||
"""
|
||||
Automatically generate the slug from the name and make sure it does not look like a UUID.
|
||||
@@ -489,7 +554,7 @@ class Room(Resource):
|
||||
|
||||
for _ in range(settings.ROOM_TELEPHONY_PIN_MAX_RETRIES):
|
||||
pin_code = str(secrets.randbelow(max_value)).zfill(length)
|
||||
if not Room.objects.filter(pin_code=pin_code).exists():
|
||||
if not Room.all_objects.filter(pin_code=pin_code).exists():
|
||||
return pin_code
|
||||
|
||||
# Log a warning as a temporary measure until backend observability is implemented.
|
||||
@@ -582,6 +647,7 @@ class Recording(BaseModel):
|
||||
4. NOTIFICATION_SUCCEEDED: External service has been notified of this recording
|
||||
|
||||
Error States:
|
||||
- FAILED: Egress failed mid-recording
|
||||
- FAILED_TO_START: Worker failed to initialize recording
|
||||
- FAILED_TO_STOP: Worker failed during stop operation
|
||||
- ABORTED: Recording was terminated before completion
|
||||
@@ -684,12 +750,7 @@ class Recording(BaseModel):
|
||||
@property
|
||||
def is_saved(self) -> bool:
|
||||
"""Check if the recording is in a saved state."""
|
||||
return self.status in {
|
||||
RecordingStatusChoices.NOTIFICATION_SUCCEEDED,
|
||||
RecordingStatusChoices.SAVED,
|
||||
RecordingStatusChoices.EXTERNAL_PROCESS_SUCCESSFUL,
|
||||
RecordingStatusChoices.EXTERNAL_PROCESS_FAILED,
|
||||
}
|
||||
return self.status in RecordingStatusChoices.saved_statuses()
|
||||
|
||||
@property
|
||||
def extension(self):
|
||||
|
||||
@@ -8,3 +8,50 @@ class FileExtension(Enum):
|
||||
|
||||
OGG = "ogg"
|
||||
MP4 = "mp4"
|
||||
|
||||
|
||||
class RecordingWorkerEvent(Enum):
|
||||
"""Lifecycle events a recording worker reports about a recording.
|
||||
|
||||
It is intended to be free of SFU-specific vocabulary.
|
||||
"""
|
||||
|
||||
# The worker accepted the request but is not recording yet.
|
||||
STARTING = "starting"
|
||||
# The worker is recording.
|
||||
STARTED = "started"
|
||||
# The worker stopped recording and is flushing the media file.
|
||||
SAVING = "saving"
|
||||
|
||||
# The recording ended, its media file is available.
|
||||
COMPLETED = "completed"
|
||||
# The recording ended on its configured limit, its media file is available.
|
||||
LIMIT_REACHED = "limit reached"
|
||||
# The worker stopped before it ever started recording, there is no media file.
|
||||
ABORTED = "aborted"
|
||||
# The worker hit a runtime error once recording had started; its media file
|
||||
# may be available.
|
||||
FAILED = "failed"
|
||||
|
||||
@classmethod
|
||||
def is_terminal(cls, event):
|
||||
"""Determine if the event ends the recording's lifecycle (successful or not)."""
|
||||
|
||||
return event in TERMINAL_EVENTS
|
||||
|
||||
|
||||
SUCCESSFUL_EVENTS = frozenset(
|
||||
{
|
||||
RecordingWorkerEvent.COMPLETED,
|
||||
RecordingWorkerEvent.LIMIT_REACHED,
|
||||
}
|
||||
)
|
||||
|
||||
UNSUCCESSFUL_EVENTS = frozenset(
|
||||
{
|
||||
RecordingWorkerEvent.ABORTED,
|
||||
RecordingWorkerEvent.FAILED,
|
||||
}
|
||||
)
|
||||
|
||||
TERMINAL_EVENTS = SUCCESSFUL_EVENTS | UNSUCCESSFUL_EVENTS
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"""Recording-related LiveKit Events Service"""
|
||||
|
||||
# pylint: disable=no-member
|
||||
"""Recording-related Events Service"""
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
from livekit import api
|
||||
|
||||
from core import models, utils
|
||||
from core.models import Recording
|
||||
from core.recording.enums import (
|
||||
UNSUCCESSFUL_EVENTS,
|
||||
RecordingWorkerEvent,
|
||||
)
|
||||
from core.recording.event.notification import notification_service
|
||||
from core.services.room_management import (
|
||||
RoomManagement,
|
||||
@@ -26,22 +26,110 @@ class RecordingNotSavableError(Exception):
|
||||
"""Recording cannot be saved because it is either in an error state or has already been saved"""
|
||||
|
||||
|
||||
# Notification sent to the room's participants, per event and recording mode.
|
||||
NOTIFICATION_PREFIXES = {
|
||||
models.RecordingModeChoices.SCREEN_RECORDING: "screenRecording",
|
||||
models.RecordingModeChoices.TRANSCRIPT: "transcription",
|
||||
}
|
||||
NOTIFICATION_SUFFIXES = {
|
||||
RecordingWorkerEvent.LIMIT_REACHED: "LimitReached",
|
||||
RecordingWorkerEvent.FAILED: "Failed",
|
||||
RecordingWorkerEvent.ABORTED: "Aborted",
|
||||
}
|
||||
|
||||
|
||||
def get_notification_type(recording_mode, event):
|
||||
"""Generate corresponding notification type string."""
|
||||
try:
|
||||
return f"{NOTIFICATION_PREFIXES[recording_mode]}{NOTIFICATION_SUFFIXES[event]}"
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
|
||||
# Recording status in the room's metadata, per event.
|
||||
ROOM_METADATA_RECORDING_STATUSES = {
|
||||
RecordingWorkerEvent.STARTED: "started",
|
||||
RecordingWorkerEvent.SAVING: "saving",
|
||||
}
|
||||
|
||||
|
||||
class RecordingEventsService:
|
||||
"""Handles recording-related LiveKit webhook events."""
|
||||
"""Handles recording-related worker events.
|
||||
|
||||
Two entry points: `handle_update` for the events a running recording
|
||||
reports, and `handle_terminal_event` for the one ending it.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def handle_update(recording: Recording, egress_status):
|
||||
"""Handle egress status updates and sync recording state to room metadata."""
|
||||
def log_worker_error(recording, event, error=None, error_code=None):
|
||||
"""Log FAILED at error level and expected ABORTED outcomes at info level."""
|
||||
|
||||
if event == RecordingWorkerEvent.FAILED:
|
||||
log = logger.error
|
||||
elif event == RecordingWorkerEvent.ABORTED:
|
||||
log = logger.info
|
||||
else:
|
||||
return
|
||||
|
||||
log(
|
||||
"Recording worker reported %s for recording %s (room=%s, mode=%s): %s (error_code=%s)",
|
||||
event.value,
|
||||
recording.id,
|
||||
recording.room.id,
|
||||
recording.mode,
|
||||
error or "no error reported",
|
||||
error_code or "no error_code reported",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _notify_participants(recording: Recording, event: RecordingWorkerEvent):
|
||||
"""Notify the room's participants that a recording ended on the given event."""
|
||||
recording_mode = recording.options.get("original_mode", None) or recording.mode
|
||||
|
||||
notification_type = get_notification_type(recording_mode, event)
|
||||
if not notification_type:
|
||||
logger.warning(
|
||||
"Could not find notification type for: "
|
||||
"room=%s, recording_id=%s, mode=%s, event=%s",
|
||||
recording.room.id,
|
||||
recording.id,
|
||||
recording_mode,
|
||||
event.value,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
utils.notify_participants(
|
||||
room_name=str(recording.room.id),
|
||||
notification_data={"type": notification_type},
|
||||
)
|
||||
except utils.NotificationError as e:
|
||||
raise RecordingEventsError(
|
||||
f"Failed to notify participants in room '{recording.room.id}' about "
|
||||
f"recording {event.value} (recording_id={recording.id})"
|
||||
) from e
|
||||
|
||||
@staticmethod
|
||||
def _log_notification_failure(recording, event: RecordingWorkerEvent):
|
||||
"""Log a participant notification error on an unsuccessful recording."""
|
||||
|
||||
logger.exception(
|
||||
"Failed to notify participants that recording %s %s (room=%s)",
|
||||
recording.id,
|
||||
event.value,
|
||||
recording.room.id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def handle_update(recording: Recording, event: RecordingWorkerEvent):
|
||||
"""Handle non-terminal worker events and sync recording state to room metadata.
|
||||
|
||||
Terminal events are dispatched through `handle_terminal_event` instead.
|
||||
"""
|
||||
|
||||
room_name = str(recording.room.id)
|
||||
|
||||
status_mapping = {
|
||||
api.EgressStatus.EGRESS_ACTIVE: "started",
|
||||
api.EgressStatus.EGRESS_ENDING: "saving",
|
||||
api.EgressStatus.EGRESS_ABORTED: "aborted",
|
||||
}
|
||||
|
||||
recording_status = status_mapping.get(egress_status)
|
||||
recording_status = ROOM_METADATA_RECORDING_STATUSES.get(event)
|
||||
if recording_status:
|
||||
try:
|
||||
RoomManagement.update_metadata(
|
||||
@@ -55,42 +143,113 @@ class RecordingEventsService:
|
||||
except RoomManagementException as e:
|
||||
logger.exception("Failed to update room's metadata: %s", e)
|
||||
|
||||
@staticmethod
|
||||
def handle_limit_reached(recording: Recording):
|
||||
def handle_terminal_event(self, recording: Recording, event: RecordingWorkerEvent):
|
||||
"""Run the appropriate handlers for a terminal event, given the recording's state."""
|
||||
|
||||
if not RecordingWorkerEvent.is_terminal(event):
|
||||
logger.warning(
|
||||
"Ignoring non-terminal event %s dispatched as a terminal event "
|
||||
"for recording %s.",
|
||||
event.value,
|
||||
recording.id,
|
||||
)
|
||||
return
|
||||
|
||||
if event in UNSUCCESSFUL_EVENTS:
|
||||
self._flag_unsuccessful_recording(recording, event)
|
||||
else:
|
||||
self._save_successful_recording(recording, event)
|
||||
|
||||
def _flag_unsuccessful_recording(
|
||||
self, recording: Recording, event: RecordingWorkerEvent
|
||||
):
|
||||
"""Persist the outcome of a recording the worker announced as unsuccessful."""
|
||||
|
||||
# Aborted
|
||||
if event == RecordingWorkerEvent.ABORTED:
|
||||
if recording.status == models.RecordingStatusChoices.ACTIVE:
|
||||
self._apply_outcome(recording, event, self._handle_aborted)
|
||||
return
|
||||
|
||||
# Failed
|
||||
if event == RecordingWorkerEvent.FAILED:
|
||||
if recording.is_savable():
|
||||
self._apply_outcome(recording, event, self._handle_failed)
|
||||
return
|
||||
|
||||
logger.error(
|
||||
"Unsuccessful event %s has no handler; recording %s keeps status '%s'.",
|
||||
event.value,
|
||||
recording.id,
|
||||
recording.status,
|
||||
)
|
||||
|
||||
def _save_successful_recording(
|
||||
self, recording: Recording, event: RecordingWorkerEvent
|
||||
):
|
||||
"""Save a recording whose media file the worker made available."""
|
||||
|
||||
# Limit reached
|
||||
if (
|
||||
event == RecordingWorkerEvent.LIMIT_REACHED
|
||||
and recording.status == models.RecordingStatusChoices.ACTIVE
|
||||
):
|
||||
self._apply_outcome(recording, event, self._handle_limit_reached)
|
||||
|
||||
try:
|
||||
self._handle_successful(recording)
|
||||
except RecordingNotSavableError:
|
||||
logger.warning(
|
||||
"Recording %s is not savable on a completed recording "
|
||||
"(already saved or in an error state); ignoring.",
|
||||
recording.id,
|
||||
)
|
||||
|
||||
def _apply_outcome(
|
||||
self, recording: Recording, event: RecordingWorkerEvent, handler
|
||||
):
|
||||
"""Keep notification failure non-fatal."""
|
||||
|
||||
try:
|
||||
handler(recording)
|
||||
except RecordingEventsError:
|
||||
self._log_notification_failure(recording, event)
|
||||
|
||||
@classmethod
|
||||
def _handle_limit_reached(cls, recording: Recording):
|
||||
"""Stop recording and notify participants when limit is reached."""
|
||||
|
||||
recording.status = models.RecordingStatusChoices.STOPPED
|
||||
recording.save()
|
||||
|
||||
notification_mapping = {
|
||||
models.RecordingModeChoices.SCREEN_RECORDING: "screenRecordingLimitReached",
|
||||
models.RecordingModeChoices.TRANSCRIPT: "transcriptionLimitReached",
|
||||
}
|
||||
cls._notify_participants(recording, RecordingWorkerEvent.LIMIT_REACHED)
|
||||
|
||||
notification_type = notification_mapping.get(recording.mode)
|
||||
if not notification_type:
|
||||
return
|
||||
@classmethod
|
||||
def _handle_failed(cls, recording: Recording):
|
||||
"""Set recording status to failed, matching the worker event, and notify participants.
|
||||
|
||||
try:
|
||||
utils.notify_participants(
|
||||
room_name=str(recording.room.id),
|
||||
notification_data={"type": notification_type},
|
||||
)
|
||||
except utils.NotificationError as e:
|
||||
logger.exception(
|
||||
"Failed to notify participants about recording limit reached: "
|
||||
"room=%s, recording_id=%s, mode=%s",
|
||||
recording.room.id,
|
||||
recording.id,
|
||||
recording.mode,
|
||||
)
|
||||
raise RecordingEventsError(
|
||||
f"Failed to notify participants in room '{recording.room.id}' about "
|
||||
f"recording limit reached (recording_id={recording.id})"
|
||||
) from e
|
||||
FAILED: used when an actual runtime/pipeline error occurs after the
|
||||
recording has started
|
||||
"""
|
||||
recording.status = models.RecordingStatusChoices.FAILED
|
||||
recording.save()
|
||||
|
||||
cls._notify_participants(recording, RecordingWorkerEvent.FAILED)
|
||||
|
||||
@classmethod
|
||||
def _handle_aborted(cls, recording: Recording):
|
||||
"""Set recording status to aborted, matching the worker event, and notify participants.
|
||||
|
||||
ABORTED: used when the worker stops before it ever became
|
||||
active/recording
|
||||
"""
|
||||
recording.status = models.RecordingStatusChoices.ABORTED
|
||||
recording.save()
|
||||
|
||||
cls._notify_participants(recording, RecordingWorkerEvent.ABORTED)
|
||||
|
||||
@staticmethod
|
||||
def handle_complete(recording: Recording):
|
||||
def _handle_successful(recording: Recording):
|
||||
"""Notify external services and save recording."""
|
||||
|
||||
if not recording.is_savable():
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
# pylint: disable=no-member
|
||||
|
||||
import logging
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit import api as livekit_api
|
||||
|
||||
@@ -10,6 +12,8 @@ from ..enums import FileExtension
|
||||
from .exceptions import WorkerConnectionError, WorkerResponseError
|
||||
from .factories import WorkerServiceConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BaseEgressService:
|
||||
"""Base egress defining common methods to manage and interact with LiveKit egress processes."""
|
||||
@@ -49,6 +53,22 @@ class BaseEgressService:
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
@staticmethod
|
||||
def _log_egress_error(response, event: str):
|
||||
"""Log the reason LiveKit reported an unsuccessful egress on stop.
|
||||
|
||||
Mirrors the logging done in the 'egress_ended' webhook. The
|
||||
StopEgress response carries the same error fields.
|
||||
"""
|
||||
logger.error(
|
||||
"Egress %s on stop (egress_id=%s, status=%s): %s (error_code=%s)",
|
||||
event,
|
||||
response.egress_id,
|
||||
livekit_api.EgressStatus.Name(response.status),
|
||||
response.error or "no error reported",
|
||||
response.error_code or "no error_code reported",
|
||||
)
|
||||
|
||||
def stop(self, worker_id: str) -> str:
|
||||
"""Stop an ongoing egress worker.
|
||||
The StopEgressRequest is shared among all types of egress,
|
||||
@@ -66,14 +86,26 @@ class BaseEgressService:
|
||||
"LiveKit response is missing the recording status."
|
||||
)
|
||||
|
||||
# To avoid exposing EgressStatus values and coupling with LiveKit outside of this class,
|
||||
# the response status is mapped to simpler "ABORTED", "STOPPED" or "FAILED_TO_STOP" strings.
|
||||
if response.status == livekit_api.EgressStatus.EGRESS_ABORTED:
|
||||
return "ABORTED"
|
||||
|
||||
if response.status == livekit_api.EgressStatus.EGRESS_ENDING:
|
||||
return "STOPPED"
|
||||
|
||||
if response.status == livekit_api.EgressStatus.EGRESS_LIMIT_REACHED:
|
||||
return "STOPPED"
|
||||
|
||||
# Cases below should be very infrequent as status changes should be
|
||||
# received and processed by `handle_ended`, thus `stop` would not
|
||||
# be called (unless failure and stop are very close in time).
|
||||
# We therefore accept not to notify the user in this code branch.
|
||||
# This could be fixed in a future refactoring.
|
||||
if response.status == livekit_api.EgressStatus.EGRESS_ABORTED:
|
||||
self._log_egress_error(response, "aborted")
|
||||
return "ABORTED"
|
||||
|
||||
if response.status == livekit_api.EgressStatus.EGRESS_FAILED:
|
||||
self._log_egress_error(response, "failed")
|
||||
return "FAILED"
|
||||
|
||||
self._log_egress_error(response, "failed to stop")
|
||||
return "FAILED_TO_STOP"
|
||||
|
||||
def start(self, room_name, recording_id):
|
||||
|
||||
@@ -8,19 +8,17 @@ from enum import Enum
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
from livekit import api
|
||||
|
||||
from core import models
|
||||
from core.recording.enums import RecordingWorkerEvent
|
||||
from core.recording.services.metadata_collector import (
|
||||
MetadataCollectorException,
|
||||
MetadataCollectorService,
|
||||
)
|
||||
from core.recording.services.recording_events import (
|
||||
RecordingEventsError,
|
||||
RecordingEventsService,
|
||||
RecordingNotSavableError,
|
||||
)
|
||||
from core.recording.services.recording_events import RecordingEventsService
|
||||
|
||||
from .lobby import LobbyService
|
||||
from .presence import PresenceCache
|
||||
@@ -84,6 +82,30 @@ class LiveKitWebhookEventType(Enum):
|
||||
INGRESS_ENDED = "ingress_ended"
|
||||
|
||||
|
||||
# LiveKit egress statuses mapped to recording worker event statuses
|
||||
EGRESS_STATUS_TO_RECORDING_EVENT = {
|
||||
api.EgressStatus.EGRESS_STARTING: RecordingWorkerEvent.STARTING,
|
||||
api.EgressStatus.EGRESS_ACTIVE: RecordingWorkerEvent.STARTED,
|
||||
api.EgressStatus.EGRESS_ENDING: RecordingWorkerEvent.SAVING,
|
||||
api.EgressStatus.EGRESS_COMPLETE: RecordingWorkerEvent.COMPLETED,
|
||||
api.EgressStatus.EGRESS_LIMIT_REACHED: RecordingWorkerEvent.LIMIT_REACHED,
|
||||
api.EgressStatus.EGRESS_ABORTED: RecordingWorkerEvent.ABORTED,
|
||||
api.EgressStatus.EGRESS_FAILED: RecordingWorkerEvent.FAILED,
|
||||
}
|
||||
|
||||
|
||||
def to_recording_event(egress_status):
|
||||
"""Translate a LiveKit egress status into a recording worker event."""
|
||||
|
||||
event = EGRESS_STATUS_TO_RECORDING_EVENT.get(egress_status)
|
||||
if event is None:
|
||||
logger.warning(
|
||||
"Unmapped LiveKit egress status '%s', ignoring the event.",
|
||||
egress_status,
|
||||
)
|
||||
return event
|
||||
|
||||
|
||||
class LiveKitEventsService:
|
||||
"""Service for processing and handling LiveKit webhook events and notifications."""
|
||||
|
||||
@@ -173,12 +195,20 @@ class LiveKitEventsService:
|
||||
f"Recording with worker ID {egress_id} does not exist"
|
||||
) from err
|
||||
|
||||
egress_status = data.egress_info.status
|
||||
self.recording_events.handle_update(recording, egress_status)
|
||||
event = to_recording_event(data.egress_info.status)
|
||||
if event is None:
|
||||
return
|
||||
|
||||
self.recording_events.handle_update(recording, event)
|
||||
|
||||
def _handle_egress_ended(self, data):
|
||||
"""Handle 'egress_ended' event."""
|
||||
"""Handle 'egress_ended' event.
|
||||
|
||||
Egress ended is sent with one of these statuses:
|
||||
EGRESS_COMPLETE, EGRESS_FAILED, EGRESS_ABORTED, EGRESS_LIMIT_REACHED
|
||||
"""
|
||||
|
||||
# Fetch recording
|
||||
try:
|
||||
recording = models.Recording.objects.select_related("room").get(
|
||||
worker_id=data.egress_info.egress_id
|
||||
@@ -188,6 +218,17 @@ class LiveKitEventsService:
|
||||
f"Recording with worker ID {data.egress_info.egress_id} does not exist"
|
||||
) from err
|
||||
|
||||
event = to_recording_event(data.egress_info.status)
|
||||
|
||||
# Log if/why the recording failed
|
||||
self.recording_events.log_worker_error(
|
||||
recording,
|
||||
event,
|
||||
error=data.egress_info.error,
|
||||
error_code=data.egress_info.error_code,
|
||||
)
|
||||
|
||||
# Update room
|
||||
try:
|
||||
room_name = str(recording.room.id)
|
||||
RoomManagement.update_metadata(
|
||||
@@ -201,38 +242,17 @@ class LiveKitEventsService:
|
||||
except RoomManagementException as e:
|
||||
logger.exception("Failed to update room's metadata: %s", e)
|
||||
|
||||
# Stop metadata collector
|
||||
if recording.options.get("metadata_collector_dispatch_id", None) is not None:
|
||||
try:
|
||||
MetadataCollectorService().stop(recording)
|
||||
except MetadataCollectorException:
|
||||
logger.warning("Failed to stop the MetadataCollectorService")
|
||||
|
||||
if (
|
||||
data.egress_info.status == api.EgressStatus.EGRESS_LIMIT_REACHED
|
||||
and recording.status == models.RecordingStatusChoices.ACTIVE
|
||||
):
|
||||
try:
|
||||
self.recording_events.handle_limit_reached(recording)
|
||||
except RecordingEventsError as e:
|
||||
raise ActionFailedError(
|
||||
f"Failed to process limit reached event for recording {recording}"
|
||||
) from e
|
||||
if event is None:
|
||||
return
|
||||
|
||||
# Finalize the recording, the egress has uploaded the file to the storage
|
||||
if data.egress_info.status in [
|
||||
api.EgressStatus.EGRESS_COMPLETE,
|
||||
api.EgressStatus.EGRESS_LIMIT_REACHED,
|
||||
]:
|
||||
try:
|
||||
self.recording_events.handle_complete(recording)
|
||||
except RecordingNotSavableError:
|
||||
logger.warning(
|
||||
"Recording %s is not savable on egress complete "
|
||||
"(already saved or in an error state); ignoring.",
|
||||
recording.id,
|
||||
)
|
||||
|
||||
# Silently ignoring EGRESS_ABORTED, EGRESS_FAILED
|
||||
self.recording_events.handle_terminal_event(recording, event)
|
||||
|
||||
@staticmethod
|
||||
def _is_connection_test_room(room_name: str) -> bool:
|
||||
@@ -252,10 +272,22 @@ class LiveKitEventsService:
|
||||
raise ActionFailedError("Failed to process room started event") from e
|
||||
|
||||
try:
|
||||
room = models.Room.objects.get(id=room_id)
|
||||
room = models.Room.all_objects.get(id=room_id)
|
||||
except models.Room.DoesNotExist as err:
|
||||
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
|
||||
|
||||
# The block below is intended to fix the issue where long-lived livekit
|
||||
# tokens allow users who already entered a room to re-create it,
|
||||
# even if it was closed.
|
||||
if room.is_deleted:
|
||||
self._close_deleted_room(room_id)
|
||||
return
|
||||
|
||||
# Update through the queryset to skip the full_clean run by save()
|
||||
models.Room.all_objects.filter(pk=room.pk).update(
|
||||
last_started_at=timezone.now()
|
||||
)
|
||||
|
||||
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
|
||||
try:
|
||||
self.sip_management.ensure_dispatch_rule(room)
|
||||
@@ -264,6 +296,25 @@ class LiveKitEventsService:
|
||||
f"Failed to create sip dispatch rule for room {room_id}"
|
||||
) from e
|
||||
|
||||
@staticmethod
|
||||
def _close_deleted_room(room_id):
|
||||
"""Close a LiveKit room recreated after its room was soft deleted.
|
||||
|
||||
LiveKit auto-creates a room on join, so a participant still holding a
|
||||
valid token can bring a deleted room back to life until the token expires.
|
||||
"""
|
||||
|
||||
logger.warning(
|
||||
"LiveKit room %s started for a deleted room, closing it", room_id
|
||||
)
|
||||
|
||||
try:
|
||||
RoomManagement.delete_room(str(room_id))
|
||||
except RoomNotFoundException:
|
||||
logger.info("LiveKit room %s is already closed", room_id)
|
||||
except RoomManagementException as e:
|
||||
raise ActionFailedError(f"Failed to close deleted room {room_id}") from e
|
||||
|
||||
def _handle_room_finished(self, data):
|
||||
"""Handle 'room_finished' event."""
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import json
|
||||
from logging import getLogger
|
||||
from typing import Dict, Optional
|
||||
|
||||
from django.db import transaction
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit.api import (
|
||||
DeleteRoomRequest,
|
||||
@@ -15,6 +17,7 @@ from livekit.api import (
|
||||
)
|
||||
|
||||
from core import utils
|
||||
from core.models import Room
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
@@ -76,10 +79,6 @@ class RoomManagement:
|
||||
|
||||
except TwirpError as e:
|
||||
if e.code == "not_found":
|
||||
logger.warning(
|
||||
"Room %s not found in LiveKit, skipping metadata update",
|
||||
room_name,
|
||||
)
|
||||
raise RoomNotFoundException("Room does not exist") from e
|
||||
|
||||
logger.exception(
|
||||
@@ -119,6 +118,27 @@ class RoomManagement:
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
@classmethod
|
||||
def soft_delete(cls, room: Room):
|
||||
"""Soft delete a room and close its LiveKit room.
|
||||
|
||||
Raises:
|
||||
RoomManagementException: the LiveKit room could not be closed.
|
||||
"""
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
room.soft_delete()
|
||||
try:
|
||||
cls.delete_room(str(room.id))
|
||||
except RoomNotFoundException:
|
||||
logger.info(
|
||||
"Room %s is not live in LiveKit, nothing to close", room.id
|
||||
)
|
||||
except RoomManagementException:
|
||||
room.deleted_at = None
|
||||
raise
|
||||
|
||||
@classmethod
|
||||
def sync_room_metadata(cls, room):
|
||||
"""Push a room's configuration and access level to its LiveKit room metadata.
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
"""Tests for the purge_inactive_rooms management command."""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
from io import StringIO
|
||||
from unittest import mock
|
||||
|
||||
from django.core.management import call_command
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from core import factories, models
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
COMMAND_MODULE = "core.management.commands.purge_inactive_rooms"
|
||||
|
||||
BEFORE_PERIOD = timedelta(days=366)
|
||||
WITHIN_PERIOD = timedelta(days=364)
|
||||
|
||||
|
||||
@pytest.fixture(name="purge_enabled", autouse=True)
|
||||
def fixture_purge_enabled(settings):
|
||||
"""Enable the purge of the rooms inactive for a year."""
|
||||
settings.ROOM_INACTIVITY_DELETION_DAYS = 365
|
||||
settings.RECORDING_EXPIRATION_DAYS = 30
|
||||
|
||||
|
||||
def create_at(date, factory, **kwargs):
|
||||
"""Build an object with the factory as if it was created at the given date."""
|
||||
with mock.patch("django.utils.timezone.now", return_value=date):
|
||||
return factory(**kwargs)
|
||||
|
||||
|
||||
def call_purge(*args):
|
||||
"""Run the purge command and return what it wrote on stdout."""
|
||||
out = StringIO()
|
||||
call_command("purge_inactive_rooms", *args, stdout=out)
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def room_exists(room):
|
||||
"""Tell whether the room is still in database."""
|
||||
return models.Room.objects.filter(pk=room.pk).exists()
|
||||
|
||||
|
||||
def test_purge_inactive_rooms_disabled(settings):
|
||||
"""Should delete nothing when no inactivity period is configured."""
|
||||
settings.ROOM_INACTIVITY_DELETION_DAYS = None
|
||||
room = create_at(timezone.now() - BEFORE_PERIOD, factories.RoomFactory)
|
||||
|
||||
assert "disabled" in call_purge()
|
||||
|
||||
assert room_exists(room)
|
||||
|
||||
|
||||
def test_purge_inactive_rooms_without_recording_expiration(settings):
|
||||
"""Should purge when recordings never expire, keeping rooms with a saved one."""
|
||||
settings.RECORDING_EXPIRATION_DAYS = None
|
||||
long_ago = timezone.now() - BEFORE_PERIOD
|
||||
room = create_at(long_ago, factories.RoomFactory)
|
||||
room_with_recording = create_at(long_ago, factories.RoomFactory)
|
||||
create_at(
|
||||
long_ago,
|
||||
factories.RecordingFactory,
|
||||
room=room_with_recording,
|
||||
status=models.RecordingStatusChoices.SAVED,
|
||||
)
|
||||
|
||||
assert call_purge() == "Purged 1 inactive room(s).\n"
|
||||
|
||||
assert not room_exists(room)
|
||||
assert room_exists(room_with_recording)
|
||||
|
||||
|
||||
def test_purge_inactive_rooms_without_recording_expiration_not_saved(settings):
|
||||
"""Should delete a room whose recordings were never saved when none expire."""
|
||||
settings.RECORDING_EXPIRATION_DAYS = None
|
||||
room = create_at(timezone.now() - BEFORE_PERIOD, factories.RoomFactory)
|
||||
factories.RecordingFactory(room=room, status=models.RecordingStatusChoices.FAILED)
|
||||
|
||||
call_purge()
|
||||
|
||||
assert not room_exists(room)
|
||||
|
||||
|
||||
def test_purge_inactive_rooms_started_before_period(caplog):
|
||||
"""Should delete a room that was last started before the inactivity period."""
|
||||
now = timezone.now()
|
||||
room = create_at(
|
||||
now - timedelta(days=800),
|
||||
factories.RoomFactory,
|
||||
last_started_at=now - BEFORE_PERIOD,
|
||||
)
|
||||
|
||||
with caplog.at_level(logging.INFO, logger=COMMAND_MODULE):
|
||||
output = call_purge()
|
||||
|
||||
assert output == "Purged 1 inactive room(s).\n"
|
||||
assert not room_exists(room)
|
||||
assert f"Purging inactive room {room.pk} ({room.slug})" in caplog.text
|
||||
|
||||
|
||||
def test_purge_inactive_rooms_never_started_created_before_period():
|
||||
"""Should delete a room that was never started and created before the period."""
|
||||
room = create_at(timezone.now() - BEFORE_PERIOD, factories.RoomFactory)
|
||||
|
||||
call_purge()
|
||||
|
||||
assert not room_exists(room)
|
||||
|
||||
|
||||
def test_purge_inactive_rooms_started_within_period():
|
||||
"""Should keep a room created long ago that was started within the period."""
|
||||
now = timezone.now()
|
||||
room = create_at(
|
||||
now - timedelta(days=800),
|
||||
factories.RoomFactory,
|
||||
last_started_at=now - WITHIN_PERIOD,
|
||||
)
|
||||
|
||||
assert call_purge() == "No inactive room to purge.\n"
|
||||
|
||||
assert room_exists(room)
|
||||
|
||||
|
||||
def test_purge_inactive_rooms_never_started_created_within_period():
|
||||
"""Should keep a room that was never started but created within the period."""
|
||||
room = create_at(timezone.now() - WITHIN_PERIOD, factories.RoomFactory)
|
||||
|
||||
assert call_purge() == "No inactive room to purge.\n"
|
||||
|
||||
assert room_exists(room)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status", sorted(models.RecordingStatusChoices.saved_statuses())
|
||||
)
|
||||
def test_purge_inactive_rooms_recording_not_expired(settings, status):
|
||||
"""Should keep a room holding a saved recording that has not expired yet."""
|
||||
settings.RECORDING_EXPIRATION_DAYS = 400
|
||||
long_ago = timezone.now() - BEFORE_PERIOD
|
||||
room = create_at(long_ago, factories.RoomFactory)
|
||||
create_at(long_ago, factories.RecordingFactory, room=room, status=status)
|
||||
|
||||
call_purge()
|
||||
|
||||
assert room_exists(room)
|
||||
|
||||
|
||||
def test_purge_inactive_rooms_recording_expired(settings):
|
||||
"""Should delete a room along with its recordings when they all have expired."""
|
||||
settings.RECORDING_EXPIRATION_DAYS = 30
|
||||
long_ago = timezone.now() - BEFORE_PERIOD
|
||||
room = create_at(long_ago, factories.RoomFactory)
|
||||
recording = create_at(
|
||||
long_ago,
|
||||
factories.RecordingFactory,
|
||||
room=room,
|
||||
status=models.RecordingStatusChoices.SAVED,
|
||||
)
|
||||
|
||||
call_purge()
|
||||
|
||||
assert not room_exists(room)
|
||||
assert not models.Recording.objects.filter(pk=recording.pk).exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status",
|
||||
[
|
||||
status
|
||||
for status in models.RecordingStatusChoices
|
||||
if status not in models.RecordingStatusChoices.saved_statuses()
|
||||
],
|
||||
)
|
||||
def test_purge_inactive_rooms_recording_not_saved(status):
|
||||
"""Should delete a room whose recordings were never saved, even unexpired."""
|
||||
room = create_at(timezone.now() - BEFORE_PERIOD, factories.RoomFactory)
|
||||
factories.RecordingFactory(room=room, status=status)
|
||||
|
||||
call_purge()
|
||||
|
||||
assert not room_exists(room)
|
||||
|
||||
|
||||
def test_purge_inactive_rooms_recording_saved_among_others():
|
||||
"""Should keep a room holding a saved recording next to a failed one."""
|
||||
room = create_at(timezone.now() - BEFORE_PERIOD, factories.RoomFactory)
|
||||
factories.RecordingFactory(room=room, status=models.RecordingStatusChoices.FAILED)
|
||||
factories.RecordingFactory(room=room, status=models.RecordingStatusChoices.SAVED)
|
||||
|
||||
call_purge()
|
||||
|
||||
assert room_exists(room)
|
||||
|
||||
|
||||
def test_purge_inactive_rooms_deletes_accesses_and_resource():
|
||||
"""Should delete the last owner access and the resource of a purged room."""
|
||||
room = create_at(timezone.now() - BEFORE_PERIOD, factories.RoomFactory)
|
||||
access = factories.UserResourceAccessFactory(
|
||||
resource=room, role=models.RoleChoices.OWNER
|
||||
)
|
||||
|
||||
call_purge()
|
||||
|
||||
assert not room_exists(room)
|
||||
assert not models.Resource.objects.filter(pk=room.pk).exists()
|
||||
assert not models.ResourceAccess.objects.filter(pk=access.pk).exists()
|
||||
assert models.User.objects.filter(pk=access.user.pk).exists()
|
||||
|
||||
|
||||
def test_purge_inactive_rooms_dry_run():
|
||||
"""Should list the inactive rooms by name without deleting them on a dry run."""
|
||||
long_ago = timezone.now() - BEFORE_PERIOD
|
||||
rooms = [
|
||||
create_at(long_ago, factories.RoomFactory, name=name)
|
||||
for name in ("Alpha room", "Beta room")
|
||||
]
|
||||
factories.RoomFactory(name="Recent room")
|
||||
|
||||
assert call_purge("--dry-run") == (
|
||||
"[dry-run] 2 inactive room(s) would be purged:\n- Alpha room\n- Beta room\n"
|
||||
)
|
||||
|
||||
assert all(room_exists(room) for room in rooms)
|
||||
|
||||
|
||||
def test_purge_inactive_rooms_several_chunks():
|
||||
"""Should delete every inactive room when they span several chunks."""
|
||||
long_ago = timezone.now() - BEFORE_PERIOD
|
||||
rooms = [create_at(long_ago, factories.RoomFactory) for _ in range(5)]
|
||||
|
||||
with mock.patch(f"{COMMAND_MODULE}.CHUNK_SIZE", 2):
|
||||
output = call_purge()
|
||||
|
||||
assert output == "Purged 5 inactive room(s).\n"
|
||||
assert not any(room_exists(room) for room in rooms)
|
||||
@@ -117,14 +117,14 @@ def test_api_files_create_file_authenticated_success():
|
||||
policy_parsed = urlparse(policy)
|
||||
|
||||
assert policy_parsed.scheme == "http"
|
||||
assert policy_parsed.netloc in ["minio:9000", "localhost:9000"]
|
||||
assert policy_parsed.netloc in ["garage:9000", "localhost:9000"]
|
||||
assert policy_parsed.path == f"/meet-media-storage/tmp/files/{file.id!s}.png"
|
||||
|
||||
query_params = parse_qs(policy_parsed.query)
|
||||
|
||||
assert query_params.pop("X-Amz-Algorithm") == ["AWS4-HMAC-SHA256"]
|
||||
assert query_params.pop("X-Amz-Credential") == [
|
||||
f"meet/{now.strftime('%Y%m%d')}/us-east-1/s3/aws4_request"
|
||||
f"meet-access-key/{now.strftime('%Y%m%d')}/local/s3/aws4_request"
|
||||
]
|
||||
assert query_params.pop("X-Amz-Date") == [now.strftime("%Y%m%dT%H%M%SZ")]
|
||||
assert query_params.pop("X-Amz-Expires") == ["60"]
|
||||
|
||||
@@ -2,18 +2,23 @@
|
||||
Test RecordingEventsService service.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name
|
||||
# pylint: disable=redefined-outer-name,protected-access
|
||||
|
||||
import logging
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from core.factories import RecordingFactory
|
||||
from core.recording.enums import RecordingWorkerEvent
|
||||
from core.recording.services.recording_events import (
|
||||
RecordingEventsError,
|
||||
RecordingEventsService,
|
||||
RecordingNotSavableError,
|
||||
)
|
||||
from core.services.room_management import (
|
||||
RoomManagementException,
|
||||
)
|
||||
from core.utils import NotificationError
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
@@ -34,10 +39,10 @@ def service():
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_limit_reached_success(mock_notify, mode, notification_type, service):
|
||||
"""Test handle_limit_reached stops recording and notifies participants."""
|
||||
"""Test _handle_limit_reached stops recording and notifies participants."""
|
||||
|
||||
recording = RecordingFactory(status="active", mode=mode)
|
||||
service.handle_limit_reached(recording)
|
||||
service._handle_limit_reached(recording)
|
||||
|
||||
assert recording.status == "stopped"
|
||||
mock_notify.assert_called_once_with(
|
||||
@@ -48,13 +53,69 @@ def test_handle_limit_reached_success(mock_notify, mode, notification_type, serv
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "notification_type"),
|
||||
(
|
||||
("screen_recording", "screenRecordingLimitReached"),
|
||||
("transcript", "transcriptionLimitReached"),
|
||||
("screen_recording", "screenRecordingFailed"),
|
||||
("transcript", "transcriptionFailed"),
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_limit_reached_error(mock_notify, mode, notification_type, service):
|
||||
"""Test handle_limit_reached raises RecordingEventsError when notification fails."""
|
||||
def test_handle_failed_success(mock_notify, mode, notification_type, service):
|
||||
"""Test _handle_failed marks recording as failed and notifies participants."""
|
||||
|
||||
recording = RecordingFactory(status="active", mode=mode)
|
||||
service._handle_failed(recording)
|
||||
|
||||
assert recording.status == "failed"
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "notification_type"),
|
||||
(
|
||||
("screen_recording", "screenRecordingAborted"),
|
||||
("transcript", "transcriptionAborted"),
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_aborted_success(mock_notify, mode, notification_type, service):
|
||||
"""Test _handle_aborted marks recording as aborted and notifies participants."""
|
||||
|
||||
recording = RecordingFactory(status="active", mode=mode)
|
||||
service._handle_aborted(recording)
|
||||
|
||||
assert recording.status == "aborted"
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "notification_prefix"),
|
||||
(("screen_recording", "screenRecording"), ("transcript", "transcription")),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("handler", "expected_status", "event", "notification_suffix"),
|
||||
(
|
||||
("_handle_limit_reached", "stopped", "limit reached", "LimitReached"),
|
||||
("_handle_failed", "failed", "failed", "Failed"),
|
||||
("_handle_aborted", "aborted", "aborted", "Aborted"),
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_event_notification_error( # noqa: PLR0913, PLR0917
|
||||
mock_notify,
|
||||
handler,
|
||||
expected_status,
|
||||
event,
|
||||
notification_suffix,
|
||||
mode,
|
||||
notification_prefix,
|
||||
service,
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
"""Test handlers raise RecordingEventsError when notifying participants fails,
|
||||
while still applying the recording status of their event.
|
||||
"""
|
||||
|
||||
mock_notify.side_effect = NotificationError("Error notifying")
|
||||
|
||||
@@ -62,14 +123,15 @@ def test_handle_limit_reached_error(mock_notify, mode, notification_type, servic
|
||||
|
||||
with pytest.raises(
|
||||
RecordingEventsError,
|
||||
match=r"Failed to notify participants in room '.+' "
|
||||
r"about recording limit reached \(recording_id=.+\)",
|
||||
match=rf"Failed to notify participants in room '.+' "
|
||||
rf"about recording {event} \(recording_id=.+\)",
|
||||
):
|
||||
service.handle_limit_reached(recording)
|
||||
getattr(service, handler)(recording)
|
||||
|
||||
assert recording.status == "stopped"
|
||||
assert recording.status == expected_status
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||
room_name=str(recording.room.id),
|
||||
notification_data={"type": f"{notification_prefix}{notification_suffix}"},
|
||||
)
|
||||
|
||||
|
||||
@@ -82,19 +144,19 @@ def test_handle_limit_reached_error(mock_notify, mode, notification_type, servic
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
def test_handle_complete_saves_recording( # pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||
def test_handle_successful_saves_recording( # pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||
mock_notify_external_services,
|
||||
notify_return_value,
|
||||
expected_status,
|
||||
status,
|
||||
service,
|
||||
):
|
||||
"""Test handle_complete notifies external services and saves a savable recording."""
|
||||
"""Test _handle_successful notifies external services and saves a savable recording."""
|
||||
|
||||
mock_notify_external_services.return_value = notify_return_value
|
||||
|
||||
recording = RecordingFactory(status=status)
|
||||
service.handle_complete(recording)
|
||||
service._handle_successful(recording)
|
||||
|
||||
mock_notify_external_services.assert_called_once_with(recording)
|
||||
|
||||
@@ -104,23 +166,293 @@ def test_handle_complete_saves_recording( # pylint: disable=too-many-arguments,
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status",
|
||||
["initiated", "saved", "notification_succeeded", "aborted", "failed_to_start"],
|
||||
[
|
||||
"initiated",
|
||||
"saved",
|
||||
"notification_succeeded",
|
||||
"aborted",
|
||||
"failed",
|
||||
"failed_to_start",
|
||||
],
|
||||
)
|
||||
@mock.patch(
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
def test_handle_complete_non_savable_recording(
|
||||
def test_handle_successful_non_savable_recording(
|
||||
mock_notify_external_services, status, service
|
||||
):
|
||||
"""Test handle_complete refuses recordings that are already saved or in error."""
|
||||
"""Test _handle_successful refuses recordings that are already saved or in error."""
|
||||
|
||||
recording = RecordingFactory(status=status)
|
||||
|
||||
with pytest.raises(RecordingNotSavableError):
|
||||
service.handle_complete(recording)
|
||||
service._handle_successful(recording)
|
||||
|
||||
mock_notify_external_services.assert_not_called()
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == status
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("event", "recording_status"),
|
||||
(
|
||||
(RecordingWorkerEvent.STARTED, "started"),
|
||||
(RecordingWorkerEvent.SAVING, "saving"),
|
||||
),
|
||||
)
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_update_syncs_room_metadata(
|
||||
mock_update_metadata, event, recording_status, service
|
||||
):
|
||||
"""Test handle_update updates the room's metadata."""
|
||||
|
||||
recording = RecordingFactory(status="active")
|
||||
service.handle_update(recording, event)
|
||||
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
str(recording.room.id), {"recording_status": recording_status}
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"event",
|
||||
(
|
||||
RecordingWorkerEvent.STARTING,
|
||||
RecordingWorkerEvent.COMPLETED,
|
||||
RecordingWorkerEvent.LIMIT_REACHED,
|
||||
RecordingWorkerEvent.ABORTED,
|
||||
RecordingWorkerEvent.FAILED,
|
||||
),
|
||||
)
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_update_ignores_events_without_a_metadata_status(
|
||||
mock_update_metadata, event, service
|
||||
):
|
||||
"""Test handle_update doesn't update metadata for events it doesn't match."""
|
||||
|
||||
recording = RecordingFactory(status="active")
|
||||
service.handle_update(recording, event)
|
||||
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("event", "initial_status", "expected_status", "notification_type"),
|
||||
(
|
||||
(RecordingWorkerEvent.LIMIT_REACHED, "active", "saved", "LimitReached"),
|
||||
(RecordingWorkerEvent.LIMIT_REACHED, "stopped", "saved", None),
|
||||
(RecordingWorkerEvent.LIMIT_REACHED, "saved", "saved", None),
|
||||
(RecordingWorkerEvent.ABORTED, "active", "aborted", "Aborted"),
|
||||
(RecordingWorkerEvent.ABORTED, "failed_to_stop", "failed_to_stop", None),
|
||||
(RecordingWorkerEvent.FAILED, "active", "failed", "Failed"),
|
||||
(RecordingWorkerEvent.FAILED, "stopped", "failed", "Failed"),
|
||||
(RecordingWorkerEvent.FAILED, "aborted", "aborted", None),
|
||||
(RecordingWorkerEvent.COMPLETED, "active", "saved", None),
|
||||
(RecordingWorkerEvent.COMPLETED, "saved", "saved", None),
|
||||
),
|
||||
)
|
||||
@mock.patch(
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_terminal_event_dispatches_on_event_and_status( # noqa: PLR0913, PLR0917
|
||||
mock_notify,
|
||||
mock_notify_external_services,
|
||||
event,
|
||||
initial_status,
|
||||
expected_status,
|
||||
notification_type,
|
||||
service,
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
"""Test handle_terminal_event chooses the right handler from the event and status."""
|
||||
|
||||
mock_notify_external_services.return_value = False
|
||||
|
||||
recording = RecordingFactory(status=initial_status, mode="screen_recording")
|
||||
service.handle_terminal_event(recording, event)
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == expected_status
|
||||
|
||||
if notification_type is None:
|
||||
mock_notify.assert_not_called()
|
||||
else:
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(recording.room.id),
|
||||
notification_data={"type": f"screenRecording{notification_type}"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"event",
|
||||
(
|
||||
RecordingWorkerEvent.STARTING,
|
||||
RecordingWorkerEvent.STARTED,
|
||||
RecordingWorkerEvent.SAVING,
|
||||
),
|
||||
)
|
||||
@mock.patch(
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_terminal_event_ignores_non_terminal_events(
|
||||
mock_notify, mock_notify_external_services, event, service, caplog
|
||||
):
|
||||
"""Test handle_terminal_event refuses non-terminal events."""
|
||||
|
||||
recording = RecordingFactory(status="active")
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
service.handle_terminal_event(recording, event)
|
||||
|
||||
assert f"Ignoring non-terminal event {event.value}" in caplog.text
|
||||
mock_notify.assert_not_called()
|
||||
mock_notify_external_services.assert_not_called()
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == "active"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("event", "expected_status"),
|
||||
(
|
||||
(RecordingWorkerEvent.LIMIT_REACHED, "saved"),
|
||||
(RecordingWorkerEvent.ABORTED, "aborted"),
|
||||
(RecordingWorkerEvent.FAILED, "failed"),
|
||||
),
|
||||
)
|
||||
@mock.patch(
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
def test_handle_terminal_event_survives_a_notification_failure( # noqa: PLR0913, PLR0917
|
||||
mock_notify,
|
||||
mock_notify_external_services,
|
||||
event,
|
||||
expected_status,
|
||||
service,
|
||||
caplog,
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
"""Test handle_terminal_event logs a notification failure instead of raising.
|
||||
|
||||
The recording status must still be persisted: participants missing their
|
||||
notification should not disturb recording.
|
||||
"""
|
||||
|
||||
mock_notify_external_services.return_value = False
|
||||
mock_notify.side_effect = NotificationError("Error notifying")
|
||||
|
||||
recording = RecordingFactory(status="active")
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
service.handle_terminal_event(recording, event)
|
||||
|
||||
assert f"Failed to notify participants that recording {recording.id}" in caplog.text
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == expected_status
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status",
|
||||
["failed_to_start", "aborted", "failed", "failed_to_stop", "saved", "initiated"],
|
||||
)
|
||||
@mock.patch(
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
def test_handle_terminal_event_ignores_a_non_savable_recording(
|
||||
mock_notify_external_services, status, service, caplog
|
||||
):
|
||||
"""Test handle_terminal_event handles a redelivered event idempotently.
|
||||
|
||||
A terminal event may be redelivered for an already finalized recording;
|
||||
this must not raise, otherwise the webhook would 500 and be retried.
|
||||
"""
|
||||
|
||||
recording = RecordingFactory(status=status)
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
service.handle_terminal_event(recording, RecordingWorkerEvent.COMPLETED)
|
||||
|
||||
assert f"Recording {recording.id} is not savable" in caplog.text
|
||||
mock_notify_external_services.assert_not_called()
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == status
|
||||
|
||||
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_update_survives_a_metadata_failure(
|
||||
mock_update_metadata, service, caplog
|
||||
):
|
||||
"""Test handle_update logs a metadata failure instead of raising."""
|
||||
|
||||
mock_update_metadata.side_effect = RoomManagementException("Error updating")
|
||||
|
||||
recording = RecordingFactory(status="active")
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
service.handle_update(recording, RecordingWorkerEvent.SAVING)
|
||||
|
||||
assert "Failed to update room's metadata" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("event", "expected_level"),
|
||||
(
|
||||
(RecordingWorkerEvent.ABORTED, logging.INFO),
|
||||
(RecordingWorkerEvent.FAILED, logging.ERROR),
|
||||
),
|
||||
)
|
||||
def test_log_worker_error_reports_an_unsuccessful_event(
|
||||
event, expected_level, service, caplog
|
||||
):
|
||||
"""Test log_worker_error records the reason the recording did not succeed."""
|
||||
|
||||
recording = RecordingFactory(status="active", mode="screen_recording")
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
service.log_worker_error(
|
||||
recording, event, error="could not connect to the room", error_code=500
|
||||
)
|
||||
|
||||
assert (
|
||||
f"Recording worker reported {event.value} for recording {recording.id}"
|
||||
in caplog.text
|
||||
)
|
||||
assert "could not connect to the room" in caplog.text
|
||||
assert "error_code=500" in caplog.text
|
||||
worker_logs = [
|
||||
record
|
||||
for record in caplog.records
|
||||
if record.name == "core.recording.services.recording_events"
|
||||
]
|
||||
assert [record.levelno for record in worker_logs] == [expected_level]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"event",
|
||||
(
|
||||
RecordingWorkerEvent.STARTING,
|
||||
RecordingWorkerEvent.STARTED,
|
||||
RecordingWorkerEvent.SAVING,
|
||||
RecordingWorkerEvent.COMPLETED,
|
||||
RecordingWorkerEvent.LIMIT_REACHED,
|
||||
None,
|
||||
),
|
||||
)
|
||||
def test_log_worker_error_stays_quiet_on_anything_else(event, service, caplog):
|
||||
"""Test log_worker_error ignores events other than FAILED and ABORTED."""
|
||||
|
||||
recording = RecordingFactory(status="active")
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
service.log_worker_error(recording, event, error="some error", error_code=500)
|
||||
|
||||
assert "Recording worker reported" not in caplog.text
|
||||
|
||||
@@ -224,6 +224,7 @@ def test_api_recording_retrieve_expired(settings):
|
||||
RecordingStatusChoices.INITIATED,
|
||||
RecordingStatusChoices.ACTIVE,
|
||||
RecordingStatusChoices.SAVED,
|
||||
RecordingStatusChoices.FAILED,
|
||||
RecordingStatusChoices.FAILED_TO_START,
|
||||
RecordingStatusChoices.FAILED_TO_STOP,
|
||||
RecordingStatusChoices.ABORTED,
|
||||
|
||||
@@ -4,6 +4,7 @@ Test worker service classes.
|
||||
|
||||
# pylint: disable=protected-access,redefined-outer-name,unused-argument,no-member
|
||||
|
||||
import logging
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
@@ -154,9 +155,9 @@ def test_base_egress_filepath_construction(service, filename, extension, expecte
|
||||
"response_status,expected_result",
|
||||
[
|
||||
(livekit_api.EgressStatus.EGRESS_ABORTED, "ABORTED"),
|
||||
(livekit_api.EgressStatus.EGRESS_FAILED, "FAILED"),
|
||||
(livekit_api.EgressStatus.EGRESS_COMPLETE, "FAILED_TO_STOP"),
|
||||
(livekit_api.EgressStatus.EGRESS_ENDING, "STOPPED"),
|
||||
(livekit_api.EgressStatus.EGRESS_FAILED, "FAILED_TO_STOP"),
|
||||
],
|
||||
)
|
||||
def test_base_egress_stop_with_status(service, response_status, expected_result):
|
||||
@@ -175,6 +176,32 @@ def test_base_egress_stop_with_status(service, response_status, expected_result)
|
||||
assert result == expected_result
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"response_status,event",
|
||||
[
|
||||
(livekit_api.EgressStatus.EGRESS_ABORTED, "aborted"),
|
||||
(livekit_api.EgressStatus.EGRESS_FAILED, "failed"),
|
||||
(livekit_api.EgressStatus.EGRESS_COMPLETE, "failed to stop"),
|
||||
],
|
||||
)
|
||||
def test_base_egress_stop_logs_livekit_error(service, response_status, event, caplog):
|
||||
"""Should log the reason LiveKit reported for an unsuccessful stop."""
|
||||
mock_response = Mock(
|
||||
status=response_status,
|
||||
egress_id="test_worker_id",
|
||||
error="could not connect to the room",
|
||||
error_code=500,
|
||||
)
|
||||
service._handle_request = Mock(return_value=mock_response)
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
service.stop("test_worker_id")
|
||||
|
||||
assert f"Egress {event} on stop (egress_id=test_worker_id" in caplog.text
|
||||
assert "could not connect to the room" in caplog.text
|
||||
assert "error_code=500" in caplog.text
|
||||
|
||||
|
||||
def test_base_egress_stop_missing_status(service):
|
||||
"""Test stop method when response is missing status"""
|
||||
# Mock _handle_request with missing status
|
||||
|
||||
@@ -112,6 +112,29 @@ def test_api_rooms_create_authenticated_existing_slug():
|
||||
assert response.json() == {"slug": ["Room with this Slug already exists."]}
|
||||
|
||||
|
||||
def test_api_rooms_create_authenticated_slug_held_by_soft_deleted_room():
|
||||
"""
|
||||
A deleted room keeps its slug: creating a room with the same name should
|
||||
fail validation rather than hit the database constraint.
|
||||
"""
|
||||
RoomFactory(name="my room").soft_delete()
|
||||
user = UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/",
|
||||
{
|
||||
"name": "My Room!",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"slug": ["Room with this Slug already exists."]}
|
||||
assert Room.all_objects.count() == 1
|
||||
|
||||
|
||||
def test_api_rooms_create_authenticated_user_default_access_level():
|
||||
"""
|
||||
The user's default room access level should be applied to the new room
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
Test rooms API endpoints in the Meet core app: delete.
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
from ...models import Room
|
||||
from ...services.room_management import RoomManagement, RoomNotFoundException
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -83,10 +86,11 @@ def test_api_rooms_delete_administrators():
|
||||
assert Room.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_rooms_delete_owners():
|
||||
@mock.patch.object(RoomManagement, "delete_room")
|
||||
def test_api_rooms_delete_owners(mock_delete_room):
|
||||
"""
|
||||
Authenticated users should be able to delete a room for which they are directly
|
||||
owner.
|
||||
owner. The room is soft deleted and its LiveKit room is closed.
|
||||
"""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
@@ -99,4 +103,71 @@ def test_api_rooms_delete_owners():
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
mock_delete_room.assert_called_once_with(str(room.id))
|
||||
assert Room.objects.exists() is False
|
||||
assert Room.all_objects.get(id=room.id).deleted_at is not None
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
RoomManagement,
|
||||
"delete_room",
|
||||
side_effect=RoomNotFoundException("Room does not exist"),
|
||||
)
|
||||
def test_api_rooms_delete_owners_room_not_live(mock_delete_room):
|
||||
"""
|
||||
Deleting a room that is not live in LiveKit should still soft delete it.
|
||||
"""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/rooms/{room.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
mock_delete_room.assert_called_once_with(str(room.id))
|
||||
assert Room.objects.exists() is False
|
||||
assert Room.all_objects.get(id=room.id).deleted_at is not None
|
||||
|
||||
|
||||
@mock.patch.object(RoomManagement, "delete_room")
|
||||
def test_api_rooms_delete_soft_deleted(mock_delete_room):
|
||||
"""Deleting a room that is already soft deleted should return a 410."""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
room.soft_delete()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/rooms/{room.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 410
|
||||
assert response.json() == {"detail": "This room has been deleted."}
|
||||
mock_delete_room.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch.object(RoomManagement, "delete_room")
|
||||
def test_api_rooms_delete_soft_deleted_not_owner(mock_delete_room):
|
||||
"""
|
||||
Deleting a soft-deleted room as a non-owner should return a 403,
|
||||
not revealing that the room has been deleted.
|
||||
"""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "administrator")])
|
||||
room.soft_delete()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/rooms/{room.id}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
mock_delete_room.assert_not_called()
|
||||
|
||||
@@ -7,6 +7,7 @@ from unittest import mock
|
||||
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.test.utils import override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
@@ -187,6 +188,28 @@ def test_api_rooms_retrieve_anonymous_unregistered_not_allowed():
|
||||
assert response.json() == {"detail": "No Room matches the given query."}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("allow_unregistered_rooms", [True, False])
|
||||
@pytest.mark.parametrize("lookup", ["id", "slug"])
|
||||
@mock.patch("core.utils.generate_token", return_value="foo")
|
||||
def test_api_rooms_retrieve_soft_deleted(
|
||||
mock_token, lookup, allow_unregistered_rooms, settings
|
||||
):
|
||||
"""
|
||||
Retrieving a soft-deleted room should return a 410, and never fall back
|
||||
to an unregistered room with the same slug.
|
||||
"""
|
||||
settings.ALLOW_UNREGISTERED_ROOMS = allow_unregistered_rooms
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
room.soft_delete()
|
||||
|
||||
client = APIClient()
|
||||
response = client.get(f"/api/v1.0/rooms/{getattr(room, lookup)!s}/")
|
||||
|
||||
assert response.status_code == 410
|
||||
assert response.json() == {"detail": "This room has been deleted."}
|
||||
mock_token.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_token", return_value="foo")
|
||||
@override_settings(
|
||||
LIVEKIT_CONFIGURATION={
|
||||
@@ -507,3 +530,20 @@ def test_api_rooms_retrieve_administrators(
|
||||
role=str(user_access.role),
|
||||
participant_id=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("access_level", RoomAccessLevel)
|
||||
@pytest.mark.parametrize("role", [None, *RoleChoices])
|
||||
def test_api_rooms_retrieve_last_started_at_not_exposed(role, access_level):
|
||||
"""Should not expose when the room was last started, whoever the requester is."""
|
||||
room = RoomFactory(access_level=access_level, last_started_at=timezone.now())
|
||||
client = APIClient()
|
||||
user = UserFactory()
|
||||
if role is not None:
|
||||
UserResourceAccessFactory(resource=room, user=user, role=role)
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get(f"/api/v1.0/rooms/{room.id!s}/")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "last_started_at" not in response.json()
|
||||
|
||||
@@ -7,6 +7,7 @@ import uuid
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import pytest
|
||||
from livekit.api import AccessToken, TwirpError, VideoGrants
|
||||
@@ -117,9 +118,12 @@ def test_start_subtitle_invalid_token():
|
||||
assert response.json() == {"detail": "Invalid LiveKit token: Not enough segments"}
|
||||
|
||||
|
||||
@override_settings(ROOM_SUBTITLE_ENABLED=False)
|
||||
def test_start_subtitle_disabled_by_default(mock_livekit_token):
|
||||
"""Test that subtitle functionality is disabled when feature flag is off."""
|
||||
|
||||
settings.ROOM_SUBTITLE_ENABLED = False
|
||||
|
||||
room = RoomFactory()
|
||||
user = UserFactory()
|
||||
client = APIClient()
|
||||
|
||||
@@ -3,8 +3,11 @@ Test rooms API endpoints in the Meet core app: update.
|
||||
"""
|
||||
|
||||
import random
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
@@ -225,6 +228,39 @@ def test_api_rooms_update_administrators_name_only(mock_update_metadata):
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["put", "patch"])
|
||||
def test_api_rooms_update_last_started_at_ignored(method):
|
||||
"""Should ignore a "last_started_at" value sent by a client.
|
||||
|
||||
The field is only ever written by the LiveKit "room_started" webhook: it is not
|
||||
declared on the serializer and is "editable=False" on the model. A client must
|
||||
not be able to keep a room alive by postponing its last start date.
|
||||
"""
|
||||
user = UserFactory()
|
||||
last_started_at = timezone.now() - timedelta(days=30)
|
||||
room = RoomFactory(
|
||||
name="Old name",
|
||||
last_started_at=last_started_at,
|
||||
users=[(user, random.choice(["administrator", "owner"]))],
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = getattr(client, method)(
|
||||
f"/api/v1.0/rooms/{room.id!s}/",
|
||||
{"name": "New name", "last_started_at": timezone.now().isoformat()},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "last_started_at" not in response.json()
|
||||
|
||||
room.refresh_from_db()
|
||||
# The rest of the payload was applied, so the request was not simply rejected
|
||||
assert room.name == "New name"
|
||||
assert room.last_started_at == last_started_at
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"configuration",
|
||||
[
|
||||
|
||||
@@ -3,23 +3,34 @@ Test LiveKitEvents service.
|
||||
"""
|
||||
# pylint: disable=W0621,W0613, W0212, E0611
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
from unittest import mock
|
||||
|
||||
from django.utils import timezone
|
||||
|
||||
import pytest
|
||||
from livekit.api import EgressStatus
|
||||
|
||||
from core.factories import RecordingFactory, RoomFactory
|
||||
from core.models import Room
|
||||
from core.recording.enums import RecordingWorkerEvent
|
||||
from core.recording.services.recording_events import RecordingEventsService
|
||||
from core.services.livekit_events import (
|
||||
EGRESS_STATUS_TO_RECORDING_EVENT,
|
||||
ActionFailedError,
|
||||
AuthenticationError,
|
||||
InvalidPayloadError,
|
||||
LiveKitEventsService,
|
||||
api,
|
||||
to_recording_event,
|
||||
)
|
||||
from core.services.lobby import LobbyService
|
||||
from core.services.room_management import RoomManagementException
|
||||
from core.services.room_management import (
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
from core.services.sip_management import (
|
||||
SIPException,
|
||||
SIPManagement,
|
||||
@@ -77,7 +88,7 @@ def test_initialization(
|
||||
def test_handle_egress_ended_success( # pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||
mock_update_metadata, mock_notify, mode, notification_type, service
|
||||
):
|
||||
"""Should successfully stop recording and notifies all participant."""
|
||||
"""Should successfully stop recording and notify all participant."""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
||||
mock_data = mock.MagicMock()
|
||||
@@ -104,7 +115,6 @@ def test_handle_egress_ended_success( # pylint: disable=too-many-arguments, too
|
||||
(
|
||||
(EgressStatus.EGRESS_ACTIVE, "started"),
|
||||
(EgressStatus.EGRESS_ENDING, "saving"),
|
||||
(EgressStatus.EGRESS_ABORTED, "aborted"),
|
||||
),
|
||||
)
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
@@ -125,29 +135,6 @@ def test_handle_egress_updated_success(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"egress_status",
|
||||
(
|
||||
EgressStatus.EGRESS_FAILED,
|
||||
EgressStatus.EGRESS_LIMIT_REACHED,
|
||||
),
|
||||
)
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_updated_non_handled(
|
||||
mock_update_metadata, egress_status, service
|
||||
):
|
||||
"""Should ignore certain egress status and don't trigger metadata updates."""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="initiated")
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = egress_status
|
||||
|
||||
service._handle_egress_updated(mock_data)
|
||||
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mode", "notification_type"),
|
||||
(
|
||||
@@ -180,33 +167,38 @@ def test_handle_egress_ended_metadata_update_fails( # pylint: disable=too-many-
|
||||
assert recording.status == "saved"
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_notification_fails(
|
||||
mock_update_metadata, mock_notify, service
|
||||
mock_update_metadata, mock_notify, mock_notify_external_services, service
|
||||
):
|
||||
"""Should raise ActionFailedError when notification fails but still stop recording."""
|
||||
"""Should still stop and save the recording when notifying participants fails."""
|
||||
|
||||
mock_notify_external_services.return_value = False
|
||||
mock_notify.side_effect = NotificationError("Error notifying")
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
|
||||
|
||||
mock_notify.side_effect = NotificationError("Error notifying")
|
||||
|
||||
with pytest.raises(
|
||||
ActionFailedError,
|
||||
match=r"Failed to process limit reached event for recording .+",
|
||||
):
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == "stopped"
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(recording.room.id),
|
||||
notification_data={"type": "screenRecordingLimitReached"},
|
||||
)
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||
)
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == "saved"
|
||||
|
||||
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
@@ -232,17 +224,25 @@ def test_handle_egress_ended_recording_not_found(
|
||||
assert recording.status == "active"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"egress_status",
|
||||
(
|
||||
EgressStatus.EGRESS_FAILED,
|
||||
EgressStatus.EGRESS_ABORTED,
|
||||
EgressStatus.EGRESS_LIMIT_REACHED,
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_recording_not_active(
|
||||
mock_update_metadata, mock_notify, service
|
||||
def test_handle_egress_ended_recording_should_not_be_saved(
|
||||
mock_update_metadata, mock_notify, egress_status, service
|
||||
):
|
||||
"""Should ignore non-active recordings."""
|
||||
"""Don't update status for recordings that must not be saved."""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="failed_to_stop")
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = "worker-1"
|
||||
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
|
||||
mock_data.egress_info.status = egress_status
|
||||
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
@@ -255,12 +255,24 @@ def test_handle_egress_ended_recording_not_active(
|
||||
assert recording.status == "failed_to_stop"
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_recording_not_limit_reached(
|
||||
mock_update_metadata, mock_notify, service
|
||||
def test_handle_egress_ended_complete_does_not_notify_participants(
|
||||
mock_update_metadata, mock_notify, mock_notify_external_services, service
|
||||
):
|
||||
"""Should ignore egress non-limit-reached statuses."""
|
||||
"""Shouldn't notify participants on a successful egress.
|
||||
|
||||
EGRESS_COMPLETE is the only ended status that notifies no one: limit
|
||||
reached, aborted and failed egresses each send their own notification.
|
||||
A stopped recording is simply finalized, which is the nominal flow once
|
||||
the egress uploaded the file of a user-initiated stop.
|
||||
"""
|
||||
|
||||
mock_notify_external_services.return_value = False
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="stopped")
|
||||
mock_data = mock.MagicMock()
|
||||
@@ -273,7 +285,10 @@ def test_handle_egress_ended_recording_not_limit_reached(
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||
)
|
||||
assert recording.status == "stopped"
|
||||
mock_notify_external_services.assert_called_once_with(recording)
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == "saved"
|
||||
|
||||
|
||||
@mock.patch("core.services.livekit_events.MetadataCollectorService")
|
||||
@@ -333,96 +348,163 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
|
||||
mock_collector.stop.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("egress_status", "recording_status", "event", "expected_level"),
|
||||
(
|
||||
(EgressStatus.EGRESS_ABORTED, "active", "aborted", logging.INFO),
|
||||
(EgressStatus.EGRESS_FAILED, "active", "failed", logging.ERROR),
|
||||
# The synchronous stop may already have persisted the terminal status,
|
||||
# and the error details exist only in the webhook payload.
|
||||
(EgressStatus.EGRESS_ABORTED, "aborted", "aborted", logging.INFO),
|
||||
(EgressStatus.EGRESS_FAILED, "failed", "failed", logging.ERROR),
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_logs_livekit_error( # noqa: PLR0913, PLR0917
|
||||
mock_update_metadata,
|
||||
mock_notify,
|
||||
egress_status,
|
||||
recording_status,
|
||||
event,
|
||||
expected_level,
|
||||
service,
|
||||
caplog,
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
"""Should log the reason LiveKit reported an unsuccessful egress."""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status=recording_status)
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = egress_status
|
||||
mock_data.egress_info.error = "could not connect to the room"
|
||||
mock_data.egress_info.error_code = 500
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
assert (
|
||||
f"Recording worker reported {event} for recording {recording.id}" in caplog.text
|
||||
)
|
||||
assert "could not connect to the room" in caplog.text
|
||||
assert "error_code=500" in caplog.text
|
||||
|
||||
worker_logs = [
|
||||
record
|
||||
for record in caplog.records
|
||||
if record.name == "core.recording.services.recording_events"
|
||||
]
|
||||
assert [record.levelno for record in worker_logs] == [expected_level]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"egress_status",
|
||||
(EgressStatus.EGRESS_COMPLETE, EgressStatus.EGRESS_LIMIT_REACHED),
|
||||
)
|
||||
@mock.patch(
|
||||
"core.recording.services.recording_events.notification_service."
|
||||
"notify_external_services"
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
@pytest.mark.parametrize(
|
||||
"egress_status",
|
||||
[EgressStatus.EGRESS_COMPLETE, EgressStatus.EGRESS_LIMIT_REACHED],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"notify_return_value, recording_status",
|
||||
[(True, "notification_succeeded"), (False, "saved")],
|
||||
)
|
||||
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913, PLR0917
|
||||
def test_handle_egress_ended_does_not_log_error_on_successful_egress( # noqa: PLR0913, PLR0917
|
||||
mock_update_metadata,
|
||||
mock_notify,
|
||||
mock_notify_external_services,
|
||||
notify_return_value,
|
||||
recording_status,
|
||||
egress_status,
|
||||
service,
|
||||
caplog,
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
"""Should notify external services and save the recording on egress completion
|
||||
(EGRESS_COMPLETE or EGRESS_LIMIT_REACHED).
|
||||
"""
|
||||
mock_notify_external_services.return_value = notify_return_value
|
||||
"""Shouldn't log an egress error when LiveKit reports a successful egress."""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = egress_status
|
||||
|
||||
service._handle_egress_ended(mock_data)
|
||||
with caplog.at_level(logging.ERROR):
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_notify_external_services.assert_called_once_with(recording)
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == recording_status
|
||||
assert "Recording worker reported" not in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"egress_status",
|
||||
[
|
||||
EgressStatus.EGRESS_STARTING,
|
||||
EgressStatus.EGRESS_ACTIVE,
|
||||
EgressStatus.EGRESS_ENDING,
|
||||
EgressStatus.EGRESS_FAILED,
|
||||
EgressStatus.EGRESS_ABORTED,
|
||||
],
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_does_not_save_on_wrong_status(
|
||||
mock_update_metadata, egress_status, service
|
||||
def test_handle_egress_ended_logs_livekit_error_before_cleaning_up(
|
||||
mock_update_metadata, mock_notify, service, caplog
|
||||
):
|
||||
"""Shouldn't save on invalid status."""
|
||||
"""Should log the failure reason even when the cleanup fails afterwards."""
|
||||
|
||||
mock_update_metadata.side_effect = RuntimeError("LiveKit is unreachable")
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status="active")
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = egress_status
|
||||
mock_data.egress_info.status = EgressStatus.EGRESS_FAILED
|
||||
mock_data.egress_info.error = "could not connect to the room"
|
||||
mock_data.egress_info.error_code = 500
|
||||
|
||||
service._handle_egress_ended(mock_data)
|
||||
with caplog.at_level(logging.ERROR), pytest.raises(RuntimeError):
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == "active"
|
||||
assert (
|
||||
f"Recording worker reported failed for recording {recording.id}" in caplog.text
|
||||
)
|
||||
assert "could not connect to the room" in caplog.text
|
||||
|
||||
|
||||
def test_egress_status_mapping_covers_every_livekit_status():
|
||||
"""Every egress status LiveKit can report must translate to a recording event."""
|
||||
|
||||
unmapped = [
|
||||
name
|
||||
for name in EgressStatus.keys()
|
||||
if getattr(EgressStatus, name) not in EGRESS_STATUS_TO_RECORDING_EVENT
|
||||
]
|
||||
|
||||
assert not unmapped
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
|
||||
("egress_status", "expected_event"),
|
||||
(
|
||||
(EgressStatus.EGRESS_STARTING, RecordingWorkerEvent.STARTING),
|
||||
(EgressStatus.EGRESS_ACTIVE, RecordingWorkerEvent.STARTED),
|
||||
(EgressStatus.EGRESS_ENDING, RecordingWorkerEvent.SAVING),
|
||||
(EgressStatus.EGRESS_COMPLETE, RecordingWorkerEvent.COMPLETED),
|
||||
(EgressStatus.EGRESS_LIMIT_REACHED, RecordingWorkerEvent.LIMIT_REACHED),
|
||||
(EgressStatus.EGRESS_ABORTED, RecordingWorkerEvent.ABORTED),
|
||||
(EgressStatus.EGRESS_FAILED, RecordingWorkerEvent.FAILED),
|
||||
),
|
||||
)
|
||||
def test_to_recording_event_translates_egress_status(egress_status, expected_event):
|
||||
"""Should translate a LiveKit egress status into a recording worker event."""
|
||||
|
||||
assert to_recording_event(egress_status) == expected_event
|
||||
|
||||
|
||||
def test_to_recording_event_returns_none_on_unmapped_status(caplog):
|
||||
"""Should warn and return None when LiveKit reports an unknown status."""
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
event = to_recording_event(999)
|
||||
|
||||
assert event is None
|
||||
assert "Unmapped LiveKit egress status" in caplog.text
|
||||
|
||||
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_ignores_non_savable_recording(
|
||||
mock_update_metadata, status, service
|
||||
):
|
||||
"""Should handle non-savable recordings idempotently without raising.
|
||||
def test_handle_egress_updated_ignores_unmapped_status(mock_update_metadata, service):
|
||||
"""Shouldn't touch the room's metadata when the egress status is unknown."""
|
||||
|
||||
'egress_ended' may be redelivered (e.g. for an already-saved recording);
|
||||
this must not raise, otherwise the webhook would 500 and LiveKit would retry.
|
||||
"""
|
||||
|
||||
recording = RecordingFactory(worker_id="worker-1", status=status)
|
||||
RecordingFactory(worker_id="worker-1", status="active")
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = EgressStatus.EGRESS_COMPLETE
|
||||
mock_data.egress_info.egress_id = "worker-1"
|
||||
mock_data.egress_info.status = 999
|
||||
|
||||
service._handle_egress_ended(mock_data)
|
||||
service._handle_egress_updated(mock_data)
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == status
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||
@@ -621,6 +703,82 @@ def test_handle_room_started_skips_dispatch_rule_when_telephony_disabled(
|
||||
mock_ensure_dispatch_rule.assert_not_called()
|
||||
|
||||
|
||||
def test_handle_room_started_records_access(service, settings):
|
||||
"""Should record the access on a room that is started for the first time."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = False
|
||||
settings.ROOMKIT_ENABLED = False
|
||||
room = RoomFactory()
|
||||
other_room = RoomFactory()
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(room.id)
|
||||
|
||||
now = timezone.now()
|
||||
with mock.patch("django.utils.timezone.now", return_value=now):
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
room.refresh_from_db()
|
||||
assert room.last_started_at == now
|
||||
|
||||
other_room.refresh_from_db()
|
||||
assert other_room.last_started_at is None
|
||||
|
||||
|
||||
def test_handle_room_started_overwrites_previous_access(service, settings):
|
||||
"""Should overwrite the previous access each time the room is started again."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = False
|
||||
settings.ROOMKIT_ENABLED = False
|
||||
now = timezone.now()
|
||||
room = RoomFactory(last_started_at=now - timedelta(days=30))
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(room.id)
|
||||
|
||||
with mock.patch("django.utils.timezone.now", return_value=now):
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
room.refresh_from_db()
|
||||
assert room.last_started_at == now
|
||||
|
||||
|
||||
def test_handle_room_started_only_updates_access(service, settings):
|
||||
"""Should leave the slug and the update date untouched when recording the access."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = False
|
||||
settings.ROOMKIT_ENABLED = False
|
||||
room = RoomFactory()
|
||||
Room.objects.filter(pk=room.pk).update(slug="𓆑")
|
||||
room.refresh_from_db()
|
||||
updated_at = room.updated_at
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(room.id)
|
||||
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
room.refresh_from_db()
|
||||
assert room.last_started_at is not None
|
||||
assert room.slug == "𓆑"
|
||||
assert room.updated_at == updated_at
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
SIPManagement,
|
||||
"ensure_dispatch_rule",
|
||||
side_effect=SIPException("Test error"),
|
||||
)
|
||||
def test_handle_room_started_records_access_when_dispatch_rule_creation_fails(
|
||||
mock_ensure_dispatch_rule, service, settings
|
||||
):
|
||||
"""Should still record the access when ensuring the dispatch rule fails."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
room = RoomFactory()
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(room.id)
|
||||
|
||||
with pytest.raises(ActionFailedError):
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
room.refresh_from_db()
|
||||
assert room.last_started_at is not None
|
||||
|
||||
|
||||
def test_handle_room_started_raises_error_for_invalid_room_name(service):
|
||||
"""Should raise ActionFailedError when room name format is invalid when room starts."""
|
||||
mock_data = mock.MagicMock()
|
||||
@@ -641,6 +799,61 @@ def test_handle_room_started_raises_error_for_nonexistent_room(service):
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
|
||||
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
|
||||
@mock.patch("core.services.room_management.RoomManagement.delete_room")
|
||||
def test_handle_room_started_closes_deleted_room(
|
||||
mock_delete_room, mock_ensure_dispatch_rule, service, settings
|
||||
):
|
||||
"""Should close a LiveKit room recreated for a soft-deleted room."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
room = RoomFactory()
|
||||
room.soft_delete()
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(room.id)
|
||||
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
mock_delete_room.assert_called_once_with(str(room.id))
|
||||
mock_ensure_dispatch_rule.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.room_management.RoomManagement.delete_room",
|
||||
side_effect=RoomNotFoundException("Room does not exist"),
|
||||
)
|
||||
def test_handle_room_started_ignores_already_closed_deleted_room(
|
||||
mock_delete_room, service
|
||||
):
|
||||
"""Should proceed silently when the deleted room is already closed in LiveKit."""
|
||||
room = RoomFactory()
|
||||
room.soft_delete()
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(room.id)
|
||||
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
mock_delete_room.assert_called_once_with(str(room.id))
|
||||
|
||||
|
||||
@mock.patch(
|
||||
"core.services.room_management.RoomManagement.delete_room",
|
||||
side_effect=RoomManagementException("Could not delete room"),
|
||||
)
|
||||
def test_handle_room_started_raises_error_when_closing_deleted_room_fails(
|
||||
mock_delete_room, service
|
||||
):
|
||||
"""Should raise ActionFailedError when the deleted room cannot be closed."""
|
||||
room = RoomFactory()
|
||||
room.soft_delete()
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(room.id)
|
||||
|
||||
expected_error = f"Failed to close deleted room {room.id}"
|
||||
|
||||
with pytest.raises(ActionFailedError, match=expected_error):
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
api.WebhookReceiver, "receive", side_effect=Exception("Invalid payload")
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ import pytest
|
||||
from livekit.api import TwirpError
|
||||
|
||||
from core.factories import RoomFactory
|
||||
from core.models import RoomAccessLevel
|
||||
from core.models import Room, RoomAccessLevel
|
||||
from core.services.room_management import (
|
||||
RoomManagement,
|
||||
RoomManagementException,
|
||||
@@ -62,6 +62,29 @@ def test_delete_room_raises_management_exception(mock_create_livekit_client):
|
||||
mock_api.aclose.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@mock.patch.object(RoomManagement, "delete_room")
|
||||
def test_soft_delete_failure_rolls_back_and_can_be_retried(mock_delete_room):
|
||||
"""A failed soft delete leaves the room untouched, in database and in memory,
|
||||
so it can be retried."""
|
||||
room = RoomFactory()
|
||||
mock_delete_room.side_effect = RoomManagementException("Could not delete room")
|
||||
|
||||
with pytest.raises(RoomManagementException):
|
||||
RoomManagement.soft_delete(room)
|
||||
|
||||
assert room.deleted_at is None
|
||||
assert Room.objects.filter(id=room.id).exists()
|
||||
|
||||
mock_delete_room.side_effect = None
|
||||
RoomManagement.soft_delete(room)
|
||||
|
||||
assert mock_delete_room.call_count == 2
|
||||
assert room.deleted_at is not None
|
||||
assert Room.all_objects.get(id=room.id).deleted_at is not None
|
||||
assert Room.objects.filter(id=room.id).exists() is False
|
||||
|
||||
|
||||
@mock.patch.object(RoomManagement, "update_metadata")
|
||||
def test_sync_room_metadata_pushes_configuration_and_access_level(mock_update_metadata):
|
||||
"""The room's configuration and access level are forwarded to LiveKit."""
|
||||
|
||||
@@ -956,3 +956,99 @@ def test_api_room_user_access_delete_owners_last_owner():
|
||||
|
||||
assert response.status_code == 403
|
||||
assert ResourceAccess.objects.count() == 1
|
||||
|
||||
|
||||
# Soft-deleted rooms
|
||||
|
||||
|
||||
def test_api_room_user_accesses_create_soft_deleted_room():
|
||||
"""
|
||||
Owners of a soft-deleted room should not be allowed to add accesses to it.
|
||||
"""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
room.soft_delete()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/resource-accesses/",
|
||||
{
|
||||
"user": str(UserFactory().id),
|
||||
"resource": str(room.id),
|
||||
"role": "member",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 410
|
||||
assert response.json() == {"detail": "This room has been deleted."}
|
||||
assert ResourceAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_room_user_accesses_create_soft_deleted_room_not_administrator():
|
||||
"""
|
||||
Users without privileges on a soft-deleted room should get a 403,
|
||||
not revealing that the room has been deleted.
|
||||
"""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "member")])
|
||||
room.soft_delete()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/resource-accesses/",
|
||||
{
|
||||
"user": str(UserFactory().id),
|
||||
"resource": str(room.id),
|
||||
"role": "member",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert ResourceAccess.objects.count() == 1
|
||||
|
||||
|
||||
def test_api_room_user_accesses_update_soft_deleted_room():
|
||||
"""
|
||||
Owners of a soft-deleted room should not be allowed to update its accesses.
|
||||
"""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
access = UserResourceAccessFactory(resource=room, role="member")
|
||||
room.soft_delete()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/resource-accesses/{access.id!s}/",
|
||||
{"role": "administrator"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 410
|
||||
access.refresh_from_db()
|
||||
assert access.role == "member"
|
||||
|
||||
|
||||
def test_api_room_user_access_delete_soft_deleted_room():
|
||||
"""
|
||||
Owners of a soft-deleted room should not be allowed to remove its accesses.
|
||||
"""
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, "owner")])
|
||||
access = UserResourceAccessFactory(resource=room, role="member")
|
||||
room.soft_delete()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.delete(
|
||||
f"/api/v1.0/resource-accesses/{access.id!s}/",
|
||||
)
|
||||
|
||||
assert response.status_code == 410
|
||||
assert ResourceAccess.objects.filter(id=access.id).exists() is True
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Tests for the Dockerflow health endpoints backing the Kubernetes probes."""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from django.core import checks
|
||||
from django.test.utils import override_settings
|
||||
|
||||
import pytest
|
||||
from dockerflow.django.views import django_check_registry
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/__lbheartbeat__", "/__heartbeat__"])
|
||||
def test_dockerflow_endpoints_are_anonymous(client, path):
|
||||
"""Both endpoints answer without authentication."""
|
||||
response = client.get(path)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_dockerflow_lbheartbeat_is_a_liveness_signal(client, django_assert_num_queries):
|
||||
"""The load balancer heartbeat answers 200 without touching the database."""
|
||||
with django_assert_num_queries(0):
|
||||
response = client.get("/__lbheartbeat__")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_dockerflow_lbheartbeat_ignores_allowed_hosts(client):
|
||||
"""The probes are served before ALLOWED_HOSTS is enforced."""
|
||||
response = client.get("/__lbheartbeat__", headers={"host": "1.2.3.4:8000"})
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_dockerflow_heartbeat_reports_the_configured_checks(client):
|
||||
"""The heartbeat runs the database, migrations and redis checks."""
|
||||
with override_settings(DEBUG=True):
|
||||
response = client.get("/__heartbeat__")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
payload = response.json()
|
||||
assert payload["status"] == "ok"
|
||||
assert payload["details"] == {}
|
||||
assert payload["checks"]["check_database_connected"] == "ok"
|
||||
assert payload["checks"]["check_migrations_applied"] == "ok"
|
||||
|
||||
|
||||
def test_dockerflow_heartbeat_is_a_readiness_signal(client):
|
||||
"""A check reporting an error takes the heartbeat down with a 500."""
|
||||
|
||||
def failing_check(**kwargs):
|
||||
return [checks.Error("Could not connect to database", id="health.E001")]
|
||||
|
||||
with mock.patch.object(
|
||||
django_check_registry, "get_checks", return_value=[failing_check]
|
||||
):
|
||||
response = client.get("/__heartbeat__")
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.json()["status"] == "error"
|
||||
|
||||
|
||||
def test_dockerflow_heartbeat_tolerates_warnings(client):
|
||||
"""A warning in the check should not affect the probes."""
|
||||
|
||||
def warning_check(**kwargs):
|
||||
return [checks.Warning("Unapplied migration", id="health.W001")]
|
||||
|
||||
with mock.patch.object(
|
||||
django_check_registry, "get_checks", return_value=[warning_check]
|
||||
):
|
||||
response = client.get("/__heartbeat__")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "warning"
|
||||
@@ -26,7 +26,11 @@ from core.models import (
|
||||
RoomAccessLevel,
|
||||
User,
|
||||
)
|
||||
from core.services.room_management import RoomManagement
|
||||
from core.services.room_management import (
|
||||
RoomManagement,
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -563,6 +567,53 @@ def test_api_rooms_retrieve_not_found():
|
||||
assert "no room matches the given query." in str(response.data).lower()
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_invalid_id():
|
||||
"""Retrieving a room with a malformed id should return a 404."""
|
||||
|
||||
user = UserFactory()
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get("/external-api/v1.0/rooms/not-a-uuid/")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_soft_deleted():
|
||||
"""Retrieving a soft-deleted room should return a 410."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
room.soft_delete()
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 410
|
||||
assert response.json() == {"detail": "This room has been deleted."}
|
||||
|
||||
|
||||
def test_api_rooms_retrieve_soft_deleted_not_member():
|
||||
"""Retrieving a soft-deleted room without any role on it should return a 403,
|
||||
not revealing that the room has been deleted."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory()
|
||||
room.soft_delete()
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_RETRIEVE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.get(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_api_rooms_create_requires_authentication():
|
||||
"""Creating rooms without authentication should return 401."""
|
||||
|
||||
@@ -1392,6 +1443,204 @@ def test_api_rooms_update_tracks_analytics(mock_update_metadata, mock_capture):
|
||||
mock_update_metadata.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch.object(RoomManagement, "delete_room")
|
||||
def test_api_rooms_delete_requires_authentication(mock_delete_room):
|
||||
"""Deleting a room without authentication should return 401."""
|
||||
|
||||
room = RoomFactory(users=[(UserFactory(), RoleChoices.OWNER)])
|
||||
|
||||
client = APIClient()
|
||||
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 401
|
||||
assert Room.objects.filter(id=room.id).exists() is True
|
||||
mock_delete_room.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch.object(RoomManagement, "delete_room")
|
||||
def test_api_rooms_delete_requires_scope(mock_delete_room):
|
||||
"""Deleting a room requires the ROOMS_DELETE scope."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
# Token without ROOMS_DELETE scope
|
||||
token = generate_test_token(
|
||||
user, [ApplicationScope.ROOMS_RETRIEVE, ApplicationScope.ROOMS_UPDATE]
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert (
|
||||
"insufficient permissions. required scope: rooms:delete"
|
||||
in str(response.data).lower()
|
||||
)
|
||||
assert Room.objects.filter(id=room.id).exists() is True
|
||||
mock_delete_room.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", [RoleChoices.ADMIN, RoleChoices.MEMBER, None])
|
||||
@mock.patch.object(RoomManagement, "delete_room")
|
||||
def test_api_rooms_delete_without_ownership(mock_delete_room, role):
|
||||
"""Only owners should be able to delete a room, administrators included."""
|
||||
|
||||
user = UserFactory()
|
||||
users = [(user, role)] if role else []
|
||||
room = RoomFactory(users=users)
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 403
|
||||
assert Room.objects.filter(id=room.id).exists() is True
|
||||
mock_delete_room.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch.object(RoomManagement, "delete_room")
|
||||
def test_api_rooms_delete_unknown_room(mock_delete_room):
|
||||
"""Deleting a room that does not exist should return 404."""
|
||||
|
||||
user = UserFactory()
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.delete(f"/external-api/v1.0/rooms/{uuid.uuid4()}/")
|
||||
|
||||
assert response.status_code == 404
|
||||
mock_delete_room.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.external_api.viewsets.analytics.capture")
|
||||
@mock.patch.object(RoomManagement, "delete_room")
|
||||
def test_api_rooms_delete_success(mock_delete_room, mock_capture):
|
||||
"""Owners should be able to delete a room: it is soft deleted, its LiveKit
|
||||
room is closed and a ROOM_DELETED analytics event is emitted."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(
|
||||
users=[(user, RoleChoices.OWNER)], access_level=RoomAccessLevel.TRUSTED
|
||||
)
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
|
||||
application = Application.objects.get()
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 204
|
||||
mock_delete_room.assert_called_once_with(str(room.id))
|
||||
assert Room.objects.filter(id=room.id).exists() is False
|
||||
assert Room.all_objects.get(id=room.id).deleted_at is not None
|
||||
|
||||
mock_capture.assert_called_once()
|
||||
captured_user, event, properties = mock_capture.call_args[0]
|
||||
|
||||
assert captured_user == user
|
||||
assert event == AnalyticsEvent.ROOM_DELETED
|
||||
assert properties == {
|
||||
"room_id": str(room.pk),
|
||||
"access_level": RoomAccessLevel.TRUSTED,
|
||||
"client_id": str(application.client_id),
|
||||
"external_api": True,
|
||||
"auth_method": "ApplicationJWTAuthentication",
|
||||
"$set": {"email": user.email},
|
||||
}
|
||||
|
||||
|
||||
@mock.patch("core.external_api.viewsets.analytics.capture")
|
||||
@mock.patch.object(
|
||||
RoomManagement,
|
||||
"delete_room",
|
||||
side_effect=RoomManagementException("Could not delete room"),
|
||||
)
|
||||
def test_api_rooms_delete_livekit_failure(mock_delete_room, mock_capture):
|
||||
"""When the LiveKit room can't be closed, the deletion should be rolled back
|
||||
and no analytics event emitted."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 500
|
||||
assert response.json() == {"detail": "Could not delete the room, please try again."}
|
||||
mock_delete_room.assert_called_once_with(str(room.id))
|
||||
assert Room.objects.get(id=room.id).deleted_at is None
|
||||
mock_capture.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
RoomManagement,
|
||||
"delete_room",
|
||||
side_effect=RoomNotFoundException("Room does not exist"),
|
||||
)
|
||||
def test_api_rooms_delete_room_not_live(mock_delete_room):
|
||||
"""Deleting a room that is not live in LiveKit should still soft delete it."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 204
|
||||
mock_delete_room.assert_called_once_with(str(room.id))
|
||||
assert Room.objects.filter(id=room.id).exists() is False
|
||||
assert Room.all_objects.get(id=room.id).deleted_at is not None
|
||||
|
||||
|
||||
@mock.patch.object(RoomManagement, "delete_room")
|
||||
def test_api_rooms_delete_soft_deleted(mock_delete_room):
|
||||
"""Deleting a room that is already soft deleted should return a 410."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.OWNER)])
|
||||
room.soft_delete()
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 410
|
||||
mock_delete_room.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch.object(RoomManagement, "delete_room")
|
||||
def test_api_rooms_delete_soft_deleted_not_owner(mock_delete_room):
|
||||
"""Deleting a soft-deleted room as a non-owner should return a 403,
|
||||
not revealing that the room has been deleted."""
|
||||
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.ADMIN)])
|
||||
room.soft_delete()
|
||||
|
||||
token = generate_test_token(user, [ApplicationScope.ROOMS_DELETE])
|
||||
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f"Bearer {token}")
|
||||
response = client.delete(f"/external-api/v1.0/rooms/{room.id}/")
|
||||
|
||||
assert response.status_code == 403
|
||||
mock_delete_room.assert_not_called()
|
||||
|
||||
|
||||
def test_api_rooms_response_no_url(settings):
|
||||
"""Response should not include url field when APPLICATION_BASE_URL is None."""
|
||||
settings.APPLICATION_BASE_URL = None
|
||||
|
||||
@@ -157,6 +157,7 @@ def test_models_recording_is_savable_normal():
|
||||
@pytest.mark.parametrize(
|
||||
"status",
|
||||
[
|
||||
RecordingStatusChoices.FAILED,
|
||||
RecordingStatusChoices.FAILED_TO_STOP,
|
||||
RecordingStatusChoices.FAILED_TO_START,
|
||||
RecordingStatusChoices.ABORTED,
|
||||
@@ -279,6 +280,7 @@ def test_models_recording_is_saved_false_initiated():
|
||||
@pytest.mark.parametrize(
|
||||
"status",
|
||||
[
|
||||
RecordingStatusChoices.FAILED,
|
||||
RecordingStatusChoices.FAILED_TO_STOP,
|
||||
RecordingStatusChoices.FAILED_TO_START,
|
||||
RecordingStatusChoices.ABORTED,
|
||||
|
||||
@@ -92,6 +92,12 @@ def test_models_rooms_access_level_default():
|
||||
assert room.access_level == RoomAccessLevel.PUBLIC
|
||||
|
||||
|
||||
def test_models_rooms_last_started_at_default():
|
||||
"""Should have no last access date until the room is started."""
|
||||
room = Room.objects.create(name="room")
|
||||
assert room.last_started_at is None
|
||||
|
||||
|
||||
# Access rights methods
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Unit tests for the get_release settings helper."""
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
from meet.settings import get_release
|
||||
|
||||
|
||||
@pytest.fixture(name="base_dir")
|
||||
def fixture_empty_base_dir(tmp_path, monkeypatch):
|
||||
"""Point get_release at an empty directory."""
|
||||
monkeypatch.setattr("meet.settings.BASE_DIR", str(tmp_path))
|
||||
return tmp_path
|
||||
|
||||
|
||||
def test_get_release_reads_project_pyproject():
|
||||
"""Should return the semantic version of the backend's pyproject.toml."""
|
||||
assert re.fullmatch(r"\d+\.\d+\.\d+", get_release())
|
||||
|
||||
|
||||
def test_get_release_reads_pyproject_version(base_dir):
|
||||
"""Should return the version declared in the [project] table."""
|
||||
(base_dir / "pyproject.toml").write_text(
|
||||
'[project]\nname = "meet"\nversion = "1.2.3"\n', encoding="utf-8"
|
||||
)
|
||||
assert get_release() == "1.2.3"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("base_dir")
|
||||
def test_get_release_missing_pyproject():
|
||||
"""Should fall back to "NA" without a pyproject.toml."""
|
||||
assert get_release() == "NA"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"content",
|
||||
[
|
||||
'[project]\nname = "meet"\n', # no version
|
||||
"[tool.uv]\npackage = true\n", # no [project] table
|
||||
"[project\nversion = ", # malformed TOML
|
||||
],
|
||||
)
|
||||
def test_get_release_unreadable_version(base_dir, content):
|
||||
"""Should fall back to "NA" without a readable version in pyproject.toml."""
|
||||
(base_dir / "pyproject.toml").write_text(content, encoding="utf-8")
|
||||
assert get_release() == "NA"
|
||||
@@ -12,7 +12,7 @@ https://docs.djangoproject.com/en/3.1/ref/settings/
|
||||
|
||||
# pylint: disable=too-many-lines
|
||||
|
||||
import json
|
||||
import tomllib
|
||||
import warnings
|
||||
from os import path
|
||||
from socket import gethostbyname, gethostname
|
||||
@@ -37,19 +37,11 @@ GB = 1024 * MB
|
||||
def get_release():
|
||||
"""
|
||||
Get the current release of the application
|
||||
|
||||
By release, we mean the release from the version.json file à la Mozilla [1]
|
||||
(if any). If this file has not been found, it defaults to "NA".
|
||||
|
||||
[1]
|
||||
https://github.com/mozilla-services/Dockerflow/blob/master/docs/version_object.md
|
||||
"""
|
||||
# Try to get the current release from the version.json file generated by the
|
||||
# CI during the Docker image build
|
||||
try:
|
||||
with open(path.join(BASE_DIR, "version.json"), encoding="utf8") as version:
|
||||
return json.load(version)["version"]
|
||||
except FileNotFoundError:
|
||||
with open(path.join(BASE_DIR, "pyproject.toml"), "rb") as pyproject:
|
||||
return tomllib.load(pyproject)["project"]["version"]
|
||||
except (FileNotFoundError, KeyError, tomllib.TOMLDecodeError):
|
||||
return "NA" # Default: not available
|
||||
|
||||
|
||||
@@ -295,6 +287,7 @@ class Base(Configuration):
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"dockerflow.django.middleware.DockerflowMiddleware",
|
||||
"whitenoise.middleware.WhiteNoiseMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.locale.LocaleMiddleware",
|
||||
@@ -304,7 +297,6 @@ class Base(Configuration):
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"dockerflow.django.middleware.DockerflowMiddleware",
|
||||
]
|
||||
|
||||
AUTHENTICATION_BACKENDS = [
|
||||
@@ -738,6 +730,9 @@ class Base(Configuration):
|
||||
ALLOW_UNREGISTERED_ROOMS = values.BooleanValue(
|
||||
True, environ_name="ALLOW_UNREGISTERED_ROOMS", environ_prefix=None
|
||||
)
|
||||
ROOM_INACTIVITY_DELETION_DAYS = values.PositiveIntegerValue(
|
||||
None, environ_name="ROOM_INACTIVITY_DELETION_DAYS", environ_prefix=None
|
||||
)
|
||||
# if provided, treat as suspicious (possible privilege escalation attempt).
|
||||
PARTICIPANT_FORBIDDEN_PERMISSION_FIELDS = values.ListValue(
|
||||
["hidden", "recorder", "agent"],
|
||||
@@ -1181,6 +1176,13 @@ class Base(Configuration):
|
||||
environ_prefix="",
|
||||
)
|
||||
},
|
||||
"dockerflow": {
|
||||
"level": values.Value(
|
||||
"WARNING",
|
||||
environ_name="LOGGING_LEVEL_DOCKERFLOW",
|
||||
environ_prefix="",
|
||||
)
|
||||
},
|
||||
"core": {
|
||||
"handlers": ["console"],
|
||||
"level": values.Value(
|
||||
@@ -1250,6 +1252,25 @@ class Base(Configuration):
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
if cls.ROOM_INACTIVITY_DELETION_DAYS:
|
||||
if not cls.RECORDING_EXPIRATION_DAYS:
|
||||
warnings.warn(
|
||||
"ROOM_INACTIVITY_DELETION_DAYS is set but "
|
||||
"RECORDING_EXPIRATION_DAYS is not. Recordings never expire, so "
|
||||
"inactive rooms holding a saved recording will never be purged.",
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
elif cls.RECORDING_EXPIRATION_DAYS >= cls.ROOM_INACTIVITY_DELETION_DAYS:
|
||||
warnings.warn(
|
||||
"RECORDING_EXPIRATION_DAYS is greater than or equal to "
|
||||
"ROOM_INACTIVITY_DELETION_DAYS. Inactive rooms holding a saved "
|
||||
"recording will be kept past the inactivity period, until their "
|
||||
"recordings expire.",
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
# The SENTRY_DSN setting should be available to activate sentry for an environment
|
||||
if cls.SENTRY_DSN is not None:
|
||||
sentry_sdk.init(
|
||||
|
||||
+15
-15
@@ -2,12 +2,12 @@
|
||||
# Meet package
|
||||
#
|
||||
[build-system]
|
||||
requires = ["uv_build>=0.11.16,<0.12.0"]
|
||||
requires = ["uv_build>=0.12.6,<0.13.0"]
|
||||
build-backend = "uv_build"
|
||||
|
||||
[project]
|
||||
name = "meet"
|
||||
version = "1.31.0"
|
||||
version = "1.32.1"
|
||||
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
|
||||
classifiers = [
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
@@ -24,7 +24,7 @@ keywords = ["Django", "Contacts", "Templates", "RBAC"]
|
||||
license = "MIT"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"boto3==1.43.56",
|
||||
"boto3==1.43.80",
|
||||
"Brotli==1.2.0",
|
||||
"brevo-python==1.2.0",
|
||||
"celery[redis]==5.6.3",
|
||||
@@ -33,7 +33,7 @@ dependencies = [
|
||||
"django-cors-headers==4.9.0",
|
||||
"django-countries==9.0.0",
|
||||
"django-filter==26.1",
|
||||
"django-lasuite[all]==0.0.27",
|
||||
"django-lasuite[all]==0.0.29",
|
||||
"django-parler==2.4",
|
||||
"redis==5.2.1",
|
||||
"django-redis==7.0.0",
|
||||
@@ -41,30 +41,30 @@ dependencies = [
|
||||
"django-timezone-field>=5.1",
|
||||
"django-pydantic-field==0.5.4",
|
||||
"django==5.2.16",
|
||||
"djangorestframework==3.17.1",
|
||||
"djangorestframework==3.18.0",
|
||||
"drf_spectacular==0.30.0",
|
||||
"dockerflow==2026.3.4",
|
||||
"easy_thumbnails==2.10.1",
|
||||
"factory_boy==3.3.3",
|
||||
"gunicorn==26.0.0",
|
||||
"gunicorn==26.2.0",
|
||||
"jsonschema==4.26.0",
|
||||
"markdown==3.10.2",
|
||||
"markdown==3.10.3",
|
||||
"nested-multipart-parser==1.6.0",
|
||||
"posthog==7.29.0",
|
||||
"posthog==7.44.0",
|
||||
"psycopg[binary]==3.3.4",
|
||||
"pydantic==2.13.4",
|
||||
"PyJWT==2.13.0",
|
||||
"python-frontmatter==1.3.0",
|
||||
"python-magic==0.4.27",
|
||||
"requests==2.34.2",
|
||||
"sentry-sdk==2.66.1",
|
||||
"sentry-sdk==2.68.1",
|
||||
"whitenoise==6.12.0",
|
||||
"mozilla-django-oidc==5.0.2",
|
||||
"livekit-api==1.2.0",
|
||||
"aiohttp==3.14.3",
|
||||
"urllib3==2.7.0",
|
||||
"phonenumbers==9.0.34",
|
||||
"cryptography==50.0.0", # CVE-2026-69247
|
||||
"phonenumbers==9.0.37",
|
||||
"cryptography==50.0.1", # CVE-2026-69247
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
@@ -76,20 +76,20 @@ dependencies = [
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"django-extensions==4.1",
|
||||
"drf-spectacular-sidecar==2026.7.1",
|
||||
"drf-spectacular-sidecar==2026.8.1",
|
||||
"freezegun==1.5.5",
|
||||
"ipdb==0.13.13",
|
||||
"ipython==9.15.0",
|
||||
"ipython==9.16.1",
|
||||
"pyfakefs==6.2.0",
|
||||
"pylint-django==2.8.0",
|
||||
"pylint<4.0.0",
|
||||
"pytest-cov==7.1.0",
|
||||
"pytest-django==4.12.0",
|
||||
"pytest-django==4.14.0",
|
||||
"pytest==9.1.1",
|
||||
"pytest-icdiff==0.9",
|
||||
"pytest-xdist==3.8.0",
|
||||
"responses==0.26.2",
|
||||
"ruff==0.16.0",
|
||||
"ruff==0.16.4",
|
||||
"types-requests==2.33.0.20260712",
|
||||
]
|
||||
|
||||
|
||||
Generated
+750
-533
File diff suppressed because it is too large
Load Diff
@@ -45,7 +45,8 @@ RUN npm run build
|
||||
FROM nginxinc/nginx-unprivileged:1.30.4-alpine3.24 AS frontend-production
|
||||
|
||||
USER root
|
||||
RUN apk del curl
|
||||
RUN apk upgrade --no-cache libexpat && \
|
||||
apk del curl
|
||||
USER nginx
|
||||
|
||||
# Un-privileged user running the application
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"version": "1.31.0",
|
||||
"version": "1.32.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "meet",
|
||||
"version": "1.31.0",
|
||||
"version": "1.32.1",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/atkinson-hyperlegible-next": "5.3.0",
|
||||
"@fontsource-variable/lexend": "5.3.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "meet",
|
||||
"private": true,
|
||||
"version": "1.31.0",
|
||||
"version": "1.32.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "panda codegen && vite",
|
||||
|
||||
@@ -41,6 +41,7 @@ export const useAnalytics = ({
|
||||
api_host: host,
|
||||
flags_api_host: flags_api_host,
|
||||
person_profiles: 'always',
|
||||
remote_config_refresh_interval_ms: 0,
|
||||
capture_pageview: 'history_change',
|
||||
capture_pageleave: true,
|
||||
capture_exceptions: {
|
||||
|
||||
@@ -96,6 +96,10 @@ export const MainNotificationToast = () => {
|
||||
case NotificationType.ScreenRecordingStopped:
|
||||
case NotificationType.TranscriptionLimitReached:
|
||||
case NotificationType.ScreenRecordingLimitReached:
|
||||
case NotificationType.TranscriptionFailed:
|
||||
case NotificationType.ScreenRecordingFailed:
|
||||
case NotificationType.TranscriptionAborted:
|
||||
case NotificationType.ScreenRecordingAborted:
|
||||
toastQueue.add(
|
||||
{
|
||||
participant,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Div } from '@/primitives'
|
||||
import { ToastProvider } from './components/ToastProvider'
|
||||
import { RecordingWaitingForTracksNotification } from './components/RecordingWaitingForTracksNotification'
|
||||
import { WaitingParticipantNotification } from './components/WaitingParticipantNotification'
|
||||
|
||||
export const NotificationProvider = ({
|
||||
@@ -12,5 +13,6 @@ export const NotificationProvider = ({
|
||||
<Div position="absolute" bottom={bottom} right={right} zIndex={1000}>
|
||||
<ToastProvider />
|
||||
<WaitingParticipantNotification />
|
||||
<RecordingWaitingForTracksNotification />
|
||||
</Div>
|
||||
)
|
||||
|
||||
@@ -10,11 +10,15 @@ export enum NotificationType {
|
||||
TranscriptionStarted = 'transcriptionStarted',
|
||||
TranscriptionStopped = 'transcriptionStopped',
|
||||
TranscriptionLimitReached = 'transcriptionLimitReached',
|
||||
TranscriptionFailed = 'transcriptionFailed',
|
||||
TranscriptionAborted = 'transcriptionAborted',
|
||||
TranscriptionRequested = 'transcriptionRequested',
|
||||
ScreenRecordingStarted = 'screenRecordingStarted',
|
||||
ScreenRecordingStopped = 'screenRecordingStopped',
|
||||
ScreenRecordingRequested = 'screenRecordingRequested',
|
||||
ScreenRecordingLimitReached = 'screenRecordingLimitReached',
|
||||
ScreenRecordingFailed = 'screenRecordingFailed',
|
||||
ScreenRecordingAborted = 'screenRecordingAborted',
|
||||
RecordingSaving = 'recordingSaving',
|
||||
PermissionsRemoved = 'permissionsRemoved',
|
||||
RoleChanged = 'roleChanged',
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Text } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { RecordingMode } from '@/features/recording'
|
||||
import { useRecordingWaitingForTracks } from '@/features/recording/hooks/useRecordingWaitingForTracks'
|
||||
import { StyledToastContainer } from './StyledToastContainer'
|
||||
|
||||
export const RecordingWaitingForTracksNotification = () => {
|
||||
const { t } = useTranslation('notifications')
|
||||
const isTranscriptWaiting = useRecordingWaitingForTracks(
|
||||
RecordingMode.Transcript
|
||||
)
|
||||
const isScreenRecordingWaiting = useRecordingWaitingForTracks(
|
||||
RecordingMode.ScreenRecording
|
||||
)
|
||||
|
||||
if (!isTranscriptWaiting && !isScreenRecordingWaiting) return null
|
||||
|
||||
return (
|
||||
<StyledToastContainer role="status">
|
||||
<HStack padding={14}>
|
||||
<Text
|
||||
margin={false}
|
||||
className={css({
|
||||
maxWidth: '22rem',
|
||||
wordBreak: 'break-word',
|
||||
overflowWrap: 'break-word',
|
||||
whiteSpace: 'normal',
|
||||
})}
|
||||
>
|
||||
{t(
|
||||
isTranscriptWaiting
|
||||
? 'transcript.waitingForTracks'
|
||||
: 'screenRecording.waitingForTracks'
|
||||
)}
|
||||
</Text>
|
||||
</HStack>
|
||||
</StyledToastContainer>
|
||||
)
|
||||
}
|
||||
@@ -22,12 +22,20 @@ export function ToastAnyRecording({ state, ...props }: Readonly<ToastProps>) {
|
||||
return 'transcript.stopped'
|
||||
case NotificationType.TranscriptionLimitReached:
|
||||
return 'transcript.limitReached'
|
||||
case NotificationType.TranscriptionFailed:
|
||||
return 'transcript.failed'
|
||||
case NotificationType.TranscriptionAborted:
|
||||
return 'transcript.aborted'
|
||||
case NotificationType.ScreenRecordingStarted:
|
||||
return 'screenRecording.started'
|
||||
case NotificationType.ScreenRecordingStopped:
|
||||
return 'screenRecording.stopped'
|
||||
case NotificationType.ScreenRecordingLimitReached:
|
||||
return 'screenRecording.limitReached'
|
||||
case NotificationType.ScreenRecordingFailed:
|
||||
return 'screenRecording.failed'
|
||||
case NotificationType.ScreenRecordingAborted:
|
||||
return 'screenRecording.aborted'
|
||||
default:
|
||||
return
|
||||
}
|
||||
|
||||
@@ -58,6 +58,10 @@ const renderToast = (
|
||||
case NotificationType.ScreenRecordingStarted:
|
||||
case NotificationType.ScreenRecordingStopped:
|
||||
case NotificationType.ScreenRecordingLimitReached:
|
||||
case NotificationType.TranscriptionFailed:
|
||||
case NotificationType.ScreenRecordingFailed:
|
||||
case NotificationType.TranscriptionAborted:
|
||||
case NotificationType.ScreenRecordingAborted:
|
||||
return <ToastAnyRecording key={toast.key} toast={toast} state={state} />
|
||||
|
||||
case NotificationType.TranscriptionRequested:
|
||||
|
||||
+9
-2
@@ -34,6 +34,15 @@ export const WaitingParticipantNotification = () => {
|
||||
const isParticipantListEmpty = (p?: WaitingParticipant[]) => p?.length == 0
|
||||
|
||||
useEffect(() => {
|
||||
const previousIds = new Set(prevWaitingParticipant?.map(({ id }) => id))
|
||||
const hasNewWaitingParticipant = waitingParticipants.some(
|
||||
({ id }) => !previousIds.has(id)
|
||||
)
|
||||
|
||||
if (hasNewWaitingParticipant) {
|
||||
triggerNotificationSound(NotificationType.ParticipantWaiting)
|
||||
}
|
||||
|
||||
// Show notification when the first participant enters the waiting room
|
||||
if (
|
||||
!isParticipantListEmpty(waitingParticipants) &&
|
||||
@@ -42,8 +51,6 @@ export const WaitingParticipantNotification = () => {
|
||||
) {
|
||||
setShowQuickActionsMessage(true)
|
||||
|
||||
triggerNotificationSound(NotificationType.ParticipantJoined)
|
||||
|
||||
if (timerRef.current !== null) {
|
||||
clearTimeout(timerRef.current)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ export const useNotificationSound = () => {
|
||||
participantJoined: [0, 1150],
|
||||
handRaised: [1400, 180],
|
||||
messageReceived: [1580, 300],
|
||||
waiting: [2039, 710],
|
||||
participantWaiting: [2039, 710],
|
||||
success: [2740, 1304],
|
||||
},
|
||||
volume: notificationsSnap.soundNotificationVolume,
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Track } from 'livekit-client'
|
||||
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
||||
import { ParticipantTileFocus } from './participantTileFocus/ParticipantTileFocus'
|
||||
import { FullScreenShareWarning } from './FullScreenShareWarning'
|
||||
import { ScreenShareZoomableVideo } from '@/features/rooms/livekit/components/ScreenShareZoomableVideo'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
|
||||
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
|
||||
@@ -89,6 +90,8 @@ export const ParticipantTile: (
|
||||
)
|
||||
|
||||
const isScreenShare = trackReference.source != Track.Source.Camera
|
||||
const isRemoteScreenShare =
|
||||
isScreenShare && !trackReference.participant.isLocal
|
||||
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
|
||||
|
||||
const participantColor = getParticipantColor(trackReference.participant)
|
||||
@@ -98,11 +101,23 @@ export const ParticipantTile: (
|
||||
})
|
||||
const participantName = name || identity || 'Unknown'
|
||||
|
||||
// tileRef: fullscreen target, and the node the focus overlay listens on.
|
||||
// setRefs merges it with the forwarded ref on the same node.
|
||||
const tileRef = React.useRef<HTMLDivElement>(null)
|
||||
const setRefs = React.useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
;(tileRef as React.MutableRefObject<HTMLDivElement | null>).current = node
|
||||
if (typeof ref === 'function') ref(node)
|
||||
else if (ref)
|
||||
(ref as React.MutableRefObject<HTMLDivElement | null>).current = node
|
||||
},
|
||||
[ref]
|
||||
)
|
||||
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||
|
||||
const interactiveProps = {
|
||||
...elementProps,
|
||||
// Ensure the tile is focusable to expose contextual controls to keyboard users.
|
||||
tabIndex: 0,
|
||||
'aria-label': t('containerLabel', { name: participantName }),
|
||||
onFocus: (event: React.FocusEvent<HTMLDivElement>) => {
|
||||
@@ -120,8 +135,41 @@ export const ParticipantTile: (
|
||||
},
|
||||
}
|
||||
|
||||
const isVideoTrack =
|
||||
isTrackReference(trackReference) &&
|
||||
trackReference.publication.kind === 'video'
|
||||
|
||||
let trackMedia: React.ReactNode = null
|
||||
if (isVideoTrack) {
|
||||
const videoTrack = (
|
||||
<VideoTrack
|
||||
trackRef={trackReference}
|
||||
onSubscriptionStatusChanged={handleSubscribe}
|
||||
manageSubscription={autoManageSubscription}
|
||||
/>
|
||||
)
|
||||
// Zoom toolbar stays out of picture-in-picture: that window has its own
|
||||
// document and the fullscreen API is off. Follow-up PR can restore zoom
|
||||
// there without the dead fullscreen button.
|
||||
trackMedia =
|
||||
isRemoteScreenShare && !disableTileControls ? (
|
||||
<ScreenShareZoomableVideo tileRef={tileRef}>
|
||||
{videoTrack}
|
||||
</ScreenShareZoomableVideo>
|
||||
) : (
|
||||
videoTrack
|
||||
)
|
||||
} else if (isTrackReference(trackReference)) {
|
||||
trackMedia = (
|
||||
<AudioTrack
|
||||
trackRef={trackReference}
|
||||
onSubscriptionStatusChanged={handleSubscribe}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={ref} style={{ position: 'relative' }} {...interactiveProps}>
|
||||
<div ref={setRefs} style={{ position: 'relative' }} {...interactiveProps}>
|
||||
<TrackRefContextIfNeeded trackRef={trackReference}>
|
||||
<ParticipantContextIfNeeded participant={trackReference.participant}>
|
||||
{trackReference.participant.isLocal && (
|
||||
@@ -129,23 +177,7 @@ export const ParticipantTile: (
|
||||
)}
|
||||
{children ?? (
|
||||
<>
|
||||
{isTrackReference(trackReference) &&
|
||||
(trackReference.publication?.kind === 'video' ||
|
||||
trackReference.source === Track.Source.Camera ||
|
||||
trackReference.source === Track.Source.ScreenShare) ? (
|
||||
<VideoTrack
|
||||
trackRef={trackReference}
|
||||
onSubscriptionStatusChanged={handleSubscribe}
|
||||
manageSubscription={autoManageSubscription}
|
||||
/>
|
||||
) : (
|
||||
isTrackReference(trackReference) && (
|
||||
<AudioTrack
|
||||
trackRef={trackReference}
|
||||
onSubscriptionStatusChanged={handleSubscribe}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
{trackMedia}
|
||||
<div className="lk-participant-placeholder">
|
||||
<ParticipantPlaceholder
|
||||
color={participantColor}
|
||||
@@ -164,6 +196,7 @@ export const ParticipantTile: (
|
||||
{!disableMetadata && !disableTileControls && (
|
||||
<ParticipantTileFocus
|
||||
trackRef={trackReference}
|
||||
tileRef={tileRef}
|
||||
hasKeyboardFocus={hasKeyboardFocus}
|
||||
/>
|
||||
)}
|
||||
|
||||
+47
-25
@@ -7,38 +7,64 @@ import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
||||
import { FocusButton } from './FocusButton'
|
||||
import { EffectsButton } from './EffectsButton'
|
||||
import { MuteButton } from './MuteButton'
|
||||
import { ZoomButton } from './ZoomButton'
|
||||
|
||||
const MOUSE_IDLE_TIME = 3000
|
||||
|
||||
type FadeOverlayProps = {
|
||||
children: ReactNode
|
||||
hasKeyboardFocus: boolean
|
||||
tileRef: React.RefObject<HTMLDivElement | null>
|
||||
}
|
||||
|
||||
const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
|
||||
// Pointer-events none so this overlay doesn't block the zoom surface below.
|
||||
// The tile node still gets the mouse events, so we listen on it directly
|
||||
// rather than lifting the state up: this keeps mouse moves from re-rendering
|
||||
// the tile and the video it contains.
|
||||
const FadeOverlay = ({
|
||||
children,
|
||||
hasKeyboardFocus,
|
||||
tileRef,
|
||||
}: FadeOverlayProps) => {
|
||||
const [active, setActive] = useState(false)
|
||||
const idleTimerRef = useRef<number | null>(null)
|
||||
|
||||
const clearIdleTimer = () => {
|
||||
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current)
|
||||
}
|
||||
useEffect(() => {
|
||||
const tile = tileRef.current
|
||||
if (!tile) return
|
||||
|
||||
const armIdleTimer = () => {
|
||||
clearIdleTimer()
|
||||
idleTimerRef.current = window.setTimeout(() => {
|
||||
const clearIdleTimer = () => {
|
||||
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current)
|
||||
idleTimerRef.current = null
|
||||
}
|
||||
|
||||
const handleActivity = () => {
|
||||
setActive(true)
|
||||
clearIdleTimer()
|
||||
idleTimerRef.current = window.setTimeout(
|
||||
() => setActive(false),
|
||||
MOUSE_IDLE_TIME
|
||||
)
|
||||
}
|
||||
|
||||
const handleLeave = () => {
|
||||
clearIdleTimer()
|
||||
setActive(false)
|
||||
}, MOUSE_IDLE_TIME)
|
||||
}
|
||||
}
|
||||
|
||||
const handleActivity = () => {
|
||||
setActive(true)
|
||||
armIdleTimer()
|
||||
}
|
||||
tile.addEventListener('mouseenter', handleActivity)
|
||||
tile.addEventListener('mousemove', handleActivity)
|
||||
tile.addEventListener('mouseleave', handleLeave)
|
||||
|
||||
useEffect(() => clearIdleTimer, [])
|
||||
return () => {
|
||||
clearIdleTimer()
|
||||
tile.removeEventListener('mouseenter', handleActivity)
|
||||
tile.removeEventListener('mousemove', handleActivity)
|
||||
tile.removeEventListener('mouseleave', handleLeave)
|
||||
}
|
||||
}, [tileRef])
|
||||
|
||||
const isVisible = hasKeyboardFocus || active
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
@@ -50,15 +76,10 @@ const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
pointerEvents: 'none',
|
||||
})}
|
||||
data-visible={isVisible || undefined}
|
||||
aria-hidden={!isVisible}
|
||||
onMouseEnter={handleActivity}
|
||||
onMouseMove={handleActivity}
|
||||
onMouseLeave={() => {
|
||||
clearIdleTimer()
|
||||
setActive(false)
|
||||
}}
|
||||
>
|
||||
{isVisible && children}
|
||||
</div>
|
||||
@@ -67,9 +88,11 @@ const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
|
||||
|
||||
export const ParticipantTileFocus = ({
|
||||
trackRef,
|
||||
tileRef,
|
||||
hasKeyboardFocus,
|
||||
}: {
|
||||
trackRef: TrackReferenceOrPlaceholder
|
||||
tileRef: React.RefObject<HTMLDivElement | null>
|
||||
hasKeyboardFocus: boolean
|
||||
}) => {
|
||||
const participant = trackRef.participant
|
||||
@@ -78,7 +101,7 @@ export const ParticipantTileFocus = ({
|
||||
const canMute = useCanMute(participant)
|
||||
|
||||
return (
|
||||
<FadeOverlay hasKeyboardFocus={hasKeyboardFocus}>
|
||||
<FadeOverlay hasKeyboardFocus={hasKeyboardFocus} tileRef={tileRef}>
|
||||
<div
|
||||
className={css({
|
||||
backgroundColor: 'primaryDark.50',
|
||||
@@ -87,6 +110,7 @@ export const ParticipantTileFocus = ({
|
||||
display: 'flex',
|
||||
opacity: 0.6,
|
||||
animation: 'overlayIn 200ms linear 300ms backwards',
|
||||
pointerEvents: 'auto',
|
||||
_hover: {
|
||||
opacity: 0.95,
|
||||
},
|
||||
@@ -94,7 +118,7 @@ export const ParticipantTileFocus = ({
|
||||
>
|
||||
<HStack gap={0.5} padding={0.5}>
|
||||
<FocusButton trackRef={trackRef} />
|
||||
{!isScreenShare ? (
|
||||
{!isScreenShare && (
|
||||
<>
|
||||
{isLocal ? (
|
||||
<EffectsButton />
|
||||
@@ -102,8 +126,6 @@ export const ParticipantTileFocus = ({
|
||||
canMute && <MuteButton participant={participant} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
!isLocal && <ZoomButton trackRef={trackRef} />
|
||||
)}
|
||||
</HStack>
|
||||
</div>
|
||||
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useFullScreen } from '@/features/rooms/livekit/hooks/useFullScreen'
|
||||
import { Button } from '@/primitives'
|
||||
import { RiFullscreenLine } from '@remixicon/react'
|
||||
|
||||
export const ZoomButton = ({
|
||||
trackRef,
|
||||
}: {
|
||||
trackRef: TrackReferenceOrPlaceholder
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({
|
||||
trackRef,
|
||||
})
|
||||
|
||||
if (!isFullscreenAvailable) {
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primaryTextDark"
|
||||
square
|
||||
tooltip={t('fullScreen')}
|
||||
onPress={() => toggleFullScreen()}
|
||||
>
|
||||
<RiFullscreenLine />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTracks } from '@livekit/components-react'
|
||||
import { Track } from 'livekit-client'
|
||||
import { RecordingMode } from '@/features/recording'
|
||||
import { useRecordingStatuses } from './useRecordingStatuses'
|
||||
|
||||
const STARTING_NOTIFICATION_DELAY = 15_000
|
||||
|
||||
export const useRecordingWaitingForTracks = (mode: RecordingMode) => {
|
||||
const { isStarting } = useRecordingStatuses(mode)
|
||||
const tracks = useTracks(
|
||||
mode === RecordingMode.Transcript ? [Track.Source.Microphone] : undefined,
|
||||
{ onlySubscribed: false }
|
||||
)
|
||||
const [delayElapsed, setDelayElapsed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setDelayElapsed(false)
|
||||
if (!isStarting) return
|
||||
|
||||
const timeout = window.setTimeout(
|
||||
() => setDelayElapsed(true),
|
||||
STARTING_NOTIFICATION_DELAY
|
||||
)
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [isStarting, mode])
|
||||
|
||||
return isStarting && delayElapsed && tracks.length === 0
|
||||
}
|
||||
@@ -9,8 +9,9 @@ export enum RecordingStatus {
|
||||
Stopped = 'stopped',
|
||||
Saved = 'saved',
|
||||
Aborted = 'aborted',
|
||||
FailedToStart = 'failedToStart',
|
||||
FailedToStop = 'failedToStop',
|
||||
Failed = 'failed',
|
||||
FailedToStart = 'failed_to_start',
|
||||
FailedToStop = 'failed_to_stop',
|
||||
NotificationSucceed = 'notification_succeeded',
|
||||
ExternalProcessSuccessful = 'external_process_successful',
|
||||
ExternalProcessFailed = 'external_process_failed',
|
||||
|
||||
@@ -12,6 +12,8 @@ export enum ApiLobbyStatus {
|
||||
DENIED = 'denied',
|
||||
TIMEOUT = 'timeout',
|
||||
ACCEPTED = 'accepted',
|
||||
// Client-side only: the room was deleted while waiting
|
||||
DELETED = 'deleted',
|
||||
}
|
||||
|
||||
export interface ApiRequestEntry {
|
||||
|
||||
@@ -85,6 +85,7 @@ export const Conference = ({
|
||||
const {
|
||||
status: fetchStatus,
|
||||
isError: isFetchError,
|
||||
error: fetchError,
|
||||
data,
|
||||
} = useQuery({
|
||||
queryKey: fetchKey,
|
||||
@@ -98,6 +99,8 @@ export const Conference = ({
|
||||
if (error.statusCode == '404') {
|
||||
createRoom({ slug: roomId, username })
|
||||
}
|
||||
// A deleted room can't be recreated, surface the error instead
|
||||
if (error.statusCode == '410') throw error
|
||||
}),
|
||||
retry: false,
|
||||
})
|
||||
@@ -198,6 +201,15 @@ export const Conference = ({
|
||||
}, [apiConfig?.livekit])
|
||||
|
||||
const { t } = useTranslation('rooms')
|
||||
if (fetchError?.statusCode == 410) {
|
||||
return (
|
||||
<ErrorScreen
|
||||
title={t('error.deletedRoom.heading')}
|
||||
body={t('error.deletedRoom.body')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (isCreateError) {
|
||||
// this error screen should be replaced by a proper waiting room for anonymous user.
|
||||
return (
|
||||
@@ -282,6 +294,7 @@ export const Conference = ({
|
||||
return
|
||||
case DisconnectReason.DUPLICATE_IDENTITY:
|
||||
case DisconnectReason.PARTICIPANT_REMOVED:
|
||||
case DisconnectReason.ROOM_DELETED:
|
||||
navigateTo(
|
||||
'feedback',
|
||||
{},
|
||||
|
||||
@@ -74,7 +74,9 @@ export const Lobby = ({
|
||||
const { openLoginHint } = useLoginHint()
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const { data } = await refetchRoom()
|
||||
const { data, error } = await refetchRoom()
|
||||
|
||||
if (error?.statusCode == 410) return
|
||||
|
||||
if (!data?.livekit) {
|
||||
// Display a message to inform the user that by logging in, they won't have to wait for room entry approval.
|
||||
@@ -88,6 +90,22 @@ export const Lobby = ({
|
||||
enterRoom()
|
||||
}
|
||||
|
||||
if (
|
||||
status === ApiLobbyStatus.DELETED ||
|
||||
(isError && error?.statusCode == 410)
|
||||
) {
|
||||
return (
|
||||
<VStack alignItems="center" textAlign="center">
|
||||
<H lvl={1} margin={false} centered>
|
||||
{t('deleted.title')}
|
||||
</H>
|
||||
<Text as="p" variant="note">
|
||||
{t('deleted.body')}
|
||||
</Text>
|
||||
</VStack>
|
||||
)
|
||||
}
|
||||
|
||||
switch (status) {
|
||||
case ApiLobbyStatus.TIMEOUT:
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { keys } from '@/api/queryKeys'
|
||||
import { ApiError } from '@/api/ApiError'
|
||||
import {
|
||||
requestEntry,
|
||||
ApiLobbyStatus,
|
||||
@@ -39,10 +40,21 @@ export const useLobby = ({
|
||||
const { data: waitingData } = useQuery({
|
||||
queryKey: [keys.requestEntry, roomId],
|
||||
queryFn: async () => {
|
||||
const response = await requestEntry({
|
||||
roomId,
|
||||
username,
|
||||
})
|
||||
let response: ApiRequestEntry
|
||||
try {
|
||||
response = await requestEntry({
|
||||
roomId,
|
||||
username,
|
||||
})
|
||||
} catch (error) {
|
||||
// The room was deleted while waiting, stop polling
|
||||
if (error instanceof ApiError && error.statusCode === 410) {
|
||||
clearWaitingTimeout()
|
||||
setStatus(ApiLobbyStatus.DELETED)
|
||||
return { status: ApiLobbyStatus.DELETED }
|
||||
}
|
||||
throw error
|
||||
}
|
||||
if (response.status === ApiLobbyStatus.ACCEPTED) {
|
||||
clearWaitingTimeout()
|
||||
setStatus(ApiLobbyStatus.ACCEPTED)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Button } from '@/primitives'
|
||||
import { RiCollapseDiagonalLine, RiExpandDiagonalLine } from '@remixicon/react'
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useScreenReaderAnnounce } from '@/hooks/useScreenReaderAnnounce'
|
||||
|
||||
// Keeps the fullscreen state here rather than on the toolbar, so entering or
|
||||
// leaving fullscreen does not re-render the zoom controls.
|
||||
export const ScreenShareFullscreenButton = memo(
|
||||
({
|
||||
containerRef,
|
||||
}: {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
|
||||
const announce = useScreenReaderAnnounce()
|
||||
|
||||
const [isFullscreen, setIsFullscreen] = useState(false)
|
||||
// Tracks whether this tile's container triggered fullscreen (vs another share's).
|
||||
const wasThisTileFullscreen = useRef(false)
|
||||
|
||||
// Covers Esc and browser UI exits, not just this button.
|
||||
// Only this tile's instance announces to avoid duplicates with multiple shares.
|
||||
useEffect(() => {
|
||||
const onChange = () => {
|
||||
const isThisTileFullscreen =
|
||||
document.fullscreenElement === containerRef.current
|
||||
setIsFullscreen(isThisTileFullscreen)
|
||||
|
||||
if (isThisTileFullscreen) {
|
||||
wasThisTileFullscreen.current = true
|
||||
announce(t('fullScreenEntered'), 'assertive')
|
||||
} else if (wasThisTileFullscreen.current) {
|
||||
wasThisTileFullscreen.current = false
|
||||
announce(t('fullScreenExited'), 'assertive')
|
||||
}
|
||||
}
|
||||
document.addEventListener('fullscreenchange', onChange)
|
||||
return () => document.removeEventListener('fullscreenchange', onChange)
|
||||
}, [announce, t, containerRef])
|
||||
|
||||
const toggleFullScreen = useCallback(async () => {
|
||||
try {
|
||||
if (document.fullscreenElement === containerRef.current) {
|
||||
await document.exitFullscreen()
|
||||
} else {
|
||||
// Tile container so zoom controls stay visible in fullscreen.
|
||||
await containerRef.current?.requestFullscreen()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error toggling fullscreen:', error)
|
||||
}
|
||||
}, [containerRef])
|
||||
|
||||
if (!document.fullscreenEnabled) return null
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primaryTextDark"
|
||||
square
|
||||
tooltip={isFullscreen ? t('exitFullScreen') : t('fullScreen')}
|
||||
aria-label={isFullscreen ? t('exitFullScreen') : t('fullScreen')}
|
||||
onPress={toggleFullScreen}
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<RiCollapseDiagonalLine size={20} />
|
||||
) : (
|
||||
<RiExpandDiagonalLine size={20} />
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ScreenShareFullscreenButton.displayName = 'ScreenShareFullscreenButton'
|
||||
@@ -0,0 +1,165 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Button } from '@/primitives'
|
||||
import {
|
||||
RiFullscreenExitLine,
|
||||
RiZoomInLine,
|
||||
RiZoomOutLine,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Toolbar } from 'react-aria-components'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { isMacintosh } from '@/utils/livekit'
|
||||
import { srOnly } from '@/styles/a11y'
|
||||
import { ScreenShareFullscreenButton } from './ScreenShareFullscreenButton'
|
||||
|
||||
interface ScreenShareZoomControlsProps {
|
||||
containerRef: React.RefObject<HTMLDivElement | null>
|
||||
isZoomed: boolean
|
||||
zoomPercentage: number
|
||||
canZoomIn: boolean
|
||||
canZoomOut: boolean
|
||||
onZoomIn: () => void
|
||||
onZoomOut: () => void
|
||||
onResetZoom: () => void
|
||||
}
|
||||
|
||||
export const ScreenShareZoomControls = ({
|
||||
containerRef,
|
||||
isZoomed,
|
||||
zoomPercentage,
|
||||
canZoomIn,
|
||||
canZoomOut,
|
||||
onZoomIn,
|
||||
onZoomOut,
|
||||
onResetZoom,
|
||||
}: ScreenShareZoomControlsProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'screenShareZoom' })
|
||||
|
||||
const zoomInButtonRef = useRef<HTMLButtonElement>(null)
|
||||
const hadFocusInCollapsibleRef = useRef(false)
|
||||
|
||||
// Back at 100 % the collapsible controls are disabled and hidden, which drops
|
||||
// keyboard focus on the body. Hand it to the zoom in button instead, the only
|
||||
// control of that group still reachable.
|
||||
useEffect(() => {
|
||||
if (isZoomed || !hadFocusInCollapsibleRef.current) return
|
||||
hadFocusInCollapsibleRef.current = false
|
||||
zoomInButtonRef.current?.focus()
|
||||
}, [isZoomed])
|
||||
|
||||
const wheelShortcut = t(isMacintosh() ? 'wheelShortcutMac' : 'wheelShortcut')
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
bottom: '12px',
|
||||
right: '12px',
|
||||
zIndex: 2,
|
||||
pointerEvents: 'auto',
|
||||
})}
|
||||
>
|
||||
<Toolbar
|
||||
aria-label={t('toolbarLabel')}
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'primaryDark.50',
|
||||
borderRadius: '2rem',
|
||||
padding: '0.5rem',
|
||||
opacity: 0.7,
|
||||
transition: 'opacity 200ms linear',
|
||||
_hover: {
|
||||
opacity: 0.95,
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span className={srOnly}>
|
||||
{t(isMacintosh() ? 'wheelShortcutHintMac' : 'wheelShortcutHint')}
|
||||
</span>
|
||||
{/* Animated wrapper: collapses to 0 when not zoomed. padding/margin
|
||||
trick keeps overflow:hidden from clipping focus rings. */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
transition: 'max-width 200ms ease-out, opacity 200ms ease-out',
|
||||
padding: '3px',
|
||||
margin: '-3px',
|
||||
})}
|
||||
style={{
|
||||
maxWidth: isZoomed ? '12rem' : '0',
|
||||
opacity: isZoomed ? 1 : 0,
|
||||
}}
|
||||
aria-hidden={!isZoomed}
|
||||
onFocus={() => {
|
||||
hadFocusInCollapsibleRef.current = true
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
// Disabling a focused button blurs it with no relatedTarget, so the
|
||||
// flag must survive that case for the effect above to rescue focus.
|
||||
if (e.relatedTarget) hadFocusInCollapsibleRef.current = false
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primaryTextDark"
|
||||
square
|
||||
tooltip={t('fitToWindow')}
|
||||
aria-label={t('fitToWindow')}
|
||||
isDisabled={!isZoomed}
|
||||
onPress={onResetZoom}
|
||||
>
|
||||
<RiFullscreenExitLine size={20} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primaryTextDark"
|
||||
square
|
||||
tooltip={t('zoomOutWithShortcut', {
|
||||
shortcut: wheelShortcut,
|
||||
})}
|
||||
aria-label={t('zoomOut')}
|
||||
isDisabled={!isZoomed || !canZoomOut}
|
||||
onPress={onZoomOut}
|
||||
>
|
||||
<RiZoomOutLine size={20} />
|
||||
</Button>
|
||||
{/* Visual only - zoom level is announced via useScreenReaderAnnounce. */}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={css({
|
||||
color: 'white',
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: 500,
|
||||
minWidth: '3.25rem',
|
||||
textAlign: 'center',
|
||||
userSelect: 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0 0.25rem',
|
||||
whiteSpace: 'nowrap',
|
||||
})}
|
||||
>
|
||||
{zoomPercentage} %
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
ref={zoomInButtonRef}
|
||||
size="sm"
|
||||
variant="primaryTextDark"
|
||||
square
|
||||
tooltip={t('zoomInWithShortcut', { shortcut: wheelShortcut })}
|
||||
aria-label={t('zoomIn')}
|
||||
isDisabled={!canZoomIn}
|
||||
onPress={onZoomIn}
|
||||
>
|
||||
<RiZoomInLine size={20} />
|
||||
</Button>
|
||||
<ScreenShareFullscreenButton containerRef={containerRef} />
|
||||
</Toolbar>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user