mirror of
https://github.com/suitenumerique/meet.git
synced 2026-09-29 22:19:08 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8017b03436 | |||
| 1a728cf049 | |||
| 4071984f8e | |||
| 2ae602606c | |||
| f28389b624 | |||
| cbb8740f41 | |||
| b4b9fe54fb | |||
| 88685d613a | |||
| 6cde1b4461 | |||
| 68fe3f96fc | |||
| 3f9942a61d | |||
| 62a2515c6b | |||
| fa9b30c6bf | |||
| 4d9ee4e9c5 | |||
| 72cd5a8f18 | |||
| 21dc63b8ce | |||
| 8d5d42cfdb | |||
| 2480a76b62 | |||
| 31c8f3ec06 | |||
| a8c4aee0c2 |
@@ -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"
|
||||
|
||||
+28
-1
@@ -8,11 +8,34 @@ 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
|
||||
|
||||
@@ -28,9 +51,13 @@ 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.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
|
||||
|
||||
|
||||
@@ -69,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
|
||||
|
||||
@@ -77,6 +93,9 @@ default: help
|
||||
data/media:
|
||||
@mkdir -p data/media
|
||||
|
||||
$(STORAGE_DIRS):
|
||||
@mkdir -p $@
|
||||
|
||||
data/static:
|
||||
@mkdir -p data/static
|
||||
|
||||
@@ -85,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 \
|
||||
@@ -317,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'])
|
||||
|
||||
+31
-23
@@ -15,36 +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: >
|
||||
sh -c "
|
||||
/usr/bin/mc alias set meet http://minio:9000 meet password && \
|
||||
/usr/bin/mc mb meet/meet-media-storage && \
|
||||
exit 0;"
|
||||
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:
|
||||
@@ -70,7 +78,7 @@ services:
|
||||
- postgresql
|
||||
- mailcatcher
|
||||
- redis
|
||||
- createbuckets
|
||||
- garage-cors
|
||||
extra_hosts:
|
||||
- "127.0.0.1.nip.io:host-gateway"
|
||||
networks:
|
||||
@@ -110,7 +118,7 @@ services:
|
||||
- postgresql
|
||||
- redis
|
||||
- livekit
|
||||
- minio
|
||||
- garage
|
||||
|
||||
celery:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
@@ -244,7 +252,7 @@ services:
|
||||
- /app/.venv
|
||||
depends_on:
|
||||
- livekit
|
||||
- minio
|
||||
- garage
|
||||
develop:
|
||||
watch:
|
||||
- action: rebuild
|
||||
@@ -297,7 +305,7 @@ services:
|
||||
depends_on:
|
||||
- redis-summary
|
||||
- app-summary-dev
|
||||
- minio
|
||||
- garage
|
||||
develop:
|
||||
watch:
|
||||
- action: rebuild
|
||||
@@ -317,7 +325,7 @@ services:
|
||||
depends_on:
|
||||
- redis-summary
|
||||
- app-summary-dev
|
||||
- minio
|
||||
- garage
|
||||
develop:
|
||||
watch:
|
||||
- action: rebuild
|
||||
|
||||
+1
-1
@@ -163,7 +163,7 @@ in
|
||||
REDIS_URL = "redis://127.0.0.1:6379/1";
|
||||
CELERY_BROKER_URL = "redis://127.0.0.1:6379/0";
|
||||
|
||||
# S3 / MinIO
|
||||
# S3 / Garage
|
||||
AWS_S3_ENDPOINT_URL = "http://127.0.0.1:9000";
|
||||
|
||||
# OIDC
|
||||
|
||||
@@ -19,7 +19,9 @@ services:
|
||||
<<: *keep-id
|
||||
celery-dev:
|
||||
<<: *keep-id
|
||||
minio:
|
||||
garage:
|
||||
<<: *keep-id
|
||||
garage-cors:
|
||||
<<: *keep-id
|
||||
node:
|
||||
<<: *keep-id
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.1",
|
||||
"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.1",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.1.tgz",
|
||||
"integrity": "sha512-9YbX5E6gd1H+yaOSX3izCsPj5iWXyH7X4oC+iuHHJJw8AeHglzOK5SJf+1CHxaYKYigcea+8jvzSxqYx46YvyA==",
|
||||
"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.1",
|
||||
"i18next": "26.4.2",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"regenerator-runtime": "0.14.1"
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ RUN sed -i "s|^URIs: http://|URIs: https://|" /etc/apt/sources.list.d/debian.sou
|
||||
&& 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()
|
||||
@@ -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,
|
||||
@@ -188,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):
|
||||
@@ -243,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.
|
||||
|
||||
@@ -927,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 @@
|
||||
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()),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -87,6 +87,17 @@ class RecordingStatusChoices(models.TextChoices):
|
||||
cls.FAILED_TO_STOP,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
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):
|
||||
"""Recording mode choices."""
|
||||
@@ -395,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"""
|
||||
|
||||
@@ -418,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,
|
||||
@@ -426,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")
|
||||
@@ -451,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.
|
||||
@@ -486,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.
|
||||
@@ -682,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,6 +8,7 @@ from enum import Enum
|
||||
from logging import getLogger
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
from livekit import api
|
||||
|
||||
@@ -271,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)
|
||||
@@ -283,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__)
|
||||
|
||||
@@ -115,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"]
|
||||
|
||||
@@ -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,7 +118,8 @@ def test_start_subtitle_invalid_token():
|
||||
assert response.json() == {"detail": "Invalid LiveKit token: Not enough segments"}
|
||||
|
||||
|
||||
def test_start_subtitle_disabled_by_default(mock_livekit_token, settings):
|
||||
@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
|
||||
|
||||
@@ -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",
|
||||
[
|
||||
|
||||
@@ -5,12 +5,16 @@ Test LiveKitEvents service.
|
||||
|
||||
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 (
|
||||
@@ -23,7 +27,10 @@ from core.services.livekit_events import (
|
||||
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,
|
||||
@@ -696,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()
|
||||
@@ -716,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -730,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"],
|
||||
@@ -1249,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
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: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -31,6 +31,7 @@ const buttonClass = css({
|
||||
enum DisconnectReasonKey {
|
||||
DuplicateIdentity = 'duplicateIdentity',
|
||||
ParticipantRemoved = 'participantRemoved',
|
||||
RoomDeleted = 'roomDeleted',
|
||||
}
|
||||
|
||||
const FeedbackRoute = () => {
|
||||
@@ -46,6 +47,8 @@ const FeedbackRoute = () => {
|
||||
return DisconnectReasonKey.DuplicateIdentity
|
||||
case DisconnectReason.PARTICIPANT_REMOVED:
|
||||
return DisconnectReasonKey.ParticipantRemoved
|
||||
case DisconnectReason.ROOM_DELETED:
|
||||
return DisconnectReasonKey.RoomDeleted
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -56,7 +59,10 @@ const FeedbackRoute = () => {
|
||||
}
|
||||
}, [])
|
||||
|
||||
const showBackButton = reasonKey !== DisconnectReasonKey.ParticipantRemoved
|
||||
// Rejoining is not possible once removed or once the room is deleted
|
||||
const showBackButton =
|
||||
reasonKey !== DisconnectReasonKey.ParticipantRemoved &&
|
||||
reasonKey !== DisconnectReasonKey.RoomDeleted
|
||||
|
||||
return (
|
||||
<Screen layout="centered" footer={false}>
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"heading": {
|
||||
"normal": "Du hast das Meeting verlassen",
|
||||
"duplicateIdentity": "Du bist dem Meeting von einem anderen Gerät aus beigetreten",
|
||||
"participantRemoved": "Du wurdest vom Host aus dem Meeting entfernt"
|
||||
"participantRemoved": "Du wurdest vom Host aus dem Meeting entfernt",
|
||||
"roomDeleted": "Dieses Meeting wurde gelöscht"
|
||||
},
|
||||
"home": "Zur Startseite zurückkehren",
|
||||
"back": "Dem Meeting erneut beitreten"
|
||||
@@ -94,6 +95,10 @@
|
||||
"timeoutInvite": {
|
||||
"title": "Du kannst diesem Meeting nicht beitreten",
|
||||
"body": "Niemand hat auf deine Anfrage reagiert"
|
||||
},
|
||||
"deleted": {
|
||||
"title": "Du kannst diesem Meeting nicht beitreten",
|
||||
"body": "Dieses Meeting wurde gelöscht."
|
||||
}
|
||||
},
|
||||
"leaveRoomPrompt": "Hiermit verlässt du das Meeting.",
|
||||
@@ -215,6 +220,10 @@
|
||||
"heading": "Authentifizierung erforderlich",
|
||||
"body": "Dieser Raum wurde noch nicht erstellt. Bitte authentifiziere dich, um ihn zu erstellen, oder warte, bis eine authentifizierte Person dies tut."
|
||||
},
|
||||
"deletedRoom": {
|
||||
"heading": "Meeting gelöscht",
|
||||
"body": "Dieses Meeting wurde gelöscht und kann nicht mehr betreten werden."
|
||||
},
|
||||
"screenShare": {
|
||||
"title": "Bildschirmfreigabe nicht möglich",
|
||||
"ariaLabel": "Bildschirmfreigabe nicht möglich",
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"heading": {
|
||||
"normal": "You have left the meeting",
|
||||
"duplicateIdentity": "You have joined the meeting from another device",
|
||||
"participantRemoved": "You have been removed from the meeting by a host"
|
||||
"participantRemoved": "You have been removed from the meeting by a host",
|
||||
"roomDeleted": "This meeting has been deleted"
|
||||
},
|
||||
"home": "Return to home",
|
||||
"back": "Rejoin the meeting"
|
||||
@@ -94,6 +95,10 @@
|
||||
"timeoutInvite": {
|
||||
"title": "You cannot join this call",
|
||||
"body": "No one responded to your request"
|
||||
},
|
||||
"deleted": {
|
||||
"title": "You cannot join this call",
|
||||
"body": "This meeting has been deleted."
|
||||
}
|
||||
},
|
||||
"leaveRoomPrompt": "This will make you leave the meeting.",
|
||||
@@ -215,6 +220,10 @@
|
||||
"heading": "Authentication Required",
|
||||
"body": "This room has not been created yet. Please authenticate to create it or wait for an authenticated user to do so."
|
||||
},
|
||||
"deletedRoom": {
|
||||
"heading": "Meeting deleted",
|
||||
"body": "This meeting has been deleted and can no longer be joined."
|
||||
},
|
||||
"screenShare": {
|
||||
"title": "Unable to share your screen",
|
||||
"ariaLabel": "Unable to share your screen",
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"heading": {
|
||||
"normal": "Has salido de la reunión",
|
||||
"duplicateIdentity": "Te has unido a la reunión desde otro dispositivo",
|
||||
"participantRemoved": "Un administrador te ha expulsado de la llamada"
|
||||
"participantRemoved": "Un administrador te ha expulsado de la llamada",
|
||||
"roomDeleted": "Esta reunión ha sido eliminada"
|
||||
},
|
||||
"home": "Volver al inicio",
|
||||
"back": "Volver a la reunión"
|
||||
@@ -94,6 +95,10 @@
|
||||
"timeoutInvite": {
|
||||
"title": "No puedes participar en esta llamada",
|
||||
"body": "Nadie ha respondido a tu solicitud de participación en la llamada"
|
||||
},
|
||||
"deleted": {
|
||||
"title": "No puedes participar en esta llamada",
|
||||
"body": "Esta reunión ha sido eliminada."
|
||||
}
|
||||
},
|
||||
"leaveRoomPrompt": "Volver al inicio hará que salgas de la reunión.",
|
||||
@@ -215,6 +220,10 @@
|
||||
"heading": "Autenticación necesaria",
|
||||
"body": "Esta reunión todavía no se ha creado. Autentícate para crearla o espera a que lo haga un usuario autenticado."
|
||||
},
|
||||
"deletedRoom": {
|
||||
"heading": "Reunión eliminada",
|
||||
"body": "Esta reunión ha sido eliminada y ya no es posible unirse a ella."
|
||||
},
|
||||
"screenShare": {
|
||||
"title": "No se puede compartir tu pantalla",
|
||||
"ariaLabel": "No se puede compartir tu pantalla",
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"heading": {
|
||||
"normal": "Vous avez quitté la réunion",
|
||||
"duplicateIdentity": "Vous avez rejoint la réunion depuis un autre appareil",
|
||||
"participantRemoved": "Vous avez été exclu de l'appel par un administrateur"
|
||||
"participantRemoved": "Vous avez été exclu de l'appel par un administrateur",
|
||||
"roomDeleted": "Cette réunion a été supprimée"
|
||||
},
|
||||
"home": "Retourner à l'accueil",
|
||||
"back": "Réintégrer la réunion"
|
||||
@@ -94,6 +95,10 @@
|
||||
"timeoutInvite": {
|
||||
"title": "Vous ne pouvez pas participer à cet appel",
|
||||
"body": "Personne n'a répondu à votre demande de participation à l'appel"
|
||||
},
|
||||
"deleted": {
|
||||
"title": "Vous ne pouvez pas participer à cet appel",
|
||||
"body": "Cette réunion a été supprimée."
|
||||
}
|
||||
},
|
||||
"leaveRoomPrompt": "Revenir à l'accueil vous fera quitter la réunion.",
|
||||
@@ -215,6 +220,10 @@
|
||||
"heading": "Authentification requise",
|
||||
"body": "Cette réunion n'a pas encore été créée. Veuillez vous authentifier pour la créer ou attendre qu'un utilisateur authentifié le fasse."
|
||||
},
|
||||
"deletedRoom": {
|
||||
"heading": "Réunion supprimée",
|
||||
"body": "Cette réunion a été supprimée et n'est plus accessible."
|
||||
},
|
||||
"screenShare": {
|
||||
"title": "Impossible de partager votre écran",
|
||||
"ariaLabel": "Impossible de partager votre écran",
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"heading": {
|
||||
"normal": "Je hebt de vergadering verlaten",
|
||||
"duplicateIdentity": "U heeft de vergadering via een ander apparaat geopend",
|
||||
"participantRemoved": "U bent door een beheerder uit het gesprek verwijderd"
|
||||
"participantRemoved": "U bent door een beheerder uit het gesprek verwijderd",
|
||||
"roomDeleted": "Deze vergadering is verwijderd"
|
||||
},
|
||||
"home": "Keer terug naar het hoofdscherm",
|
||||
"back": "Sluit weer bij de vergadering aan"
|
||||
@@ -94,6 +95,10 @@
|
||||
"timeoutInvite": {
|
||||
"title": "U kunt niet deelnemen aan dit gesprek",
|
||||
"body": "Niemand heeft gereageerd op uw verzoek om deel te nemen aan het gesprek"
|
||||
},
|
||||
"deleted": {
|
||||
"title": "U kunt niet deelnemen aan dit gesprek",
|
||||
"body": "Deze vergadering is verwijderd."
|
||||
}
|
||||
},
|
||||
"leaveRoomPrompt": "Dat zal u de vergadering doen verlaten.",
|
||||
@@ -215,6 +220,10 @@
|
||||
"heading": "Verificatie vereist",
|
||||
"body": "Deze ruimte is nog niet gemaakt. Logt u alstublieft in om hem aan te maken, of wacht tot een ingelogde gebruiker dat doet."
|
||||
},
|
||||
"deletedRoom": {
|
||||
"heading": "Vergadering verwijderd",
|
||||
"body": "Deze vergadering is verwijderd en u kunt er niet meer aan deelnemen."
|
||||
},
|
||||
"screenShare": {
|
||||
"title": "Kan uw scherm niet delen",
|
||||
"ariaLabel": "Kan uw scherm niet delen",
|
||||
|
||||
@@ -24,10 +24,11 @@ _summaryEnvVars: &summaryEnvVars
|
||||
APP_NAME: summary-microservice
|
||||
APP_API_TOKEN: password
|
||||
AWS_STORAGE_BUCKET_NAME: meet-media-storage
|
||||
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000/
|
||||
AWS_S3_ACCESS_KEY_ID: meet
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
AWS_S3_ENDPOINT_URL: http://garage.meet.svc.cluster.local:9000/
|
||||
AWS_S3_ACCESS_KEY_ID: meet-access-key
|
||||
AWS_S3_SECRET_ACCESS_KEY: meet-secret-access-key
|
||||
AWS_S3_SECURE_ACCESS: False
|
||||
AWS_S3_REGION_NAME: local
|
||||
AUTHORIZED_TENANTS: >
|
||||
[
|
||||
{
|
||||
@@ -161,9 +162,9 @@ backend:
|
||||
FRONTEND_TRANSCRIPTION_DESTINATION: "https://docs.numerique.gouv.fr"
|
||||
FRONTEND_IS_SILENT_LOGIN_ENABLED: False
|
||||
# S3 Storage
|
||||
AWS_S3_ENDPOINT_URL: http://minio.meet.svc.cluster.local:9000
|
||||
AWS_S3_ACCESS_KEY_ID: meet
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
AWS_S3_ENDPOINT_URL: http://garage.meet.svc.cluster.local:9000
|
||||
AWS_S3_ACCESS_KEY_ID: meet-access-key
|
||||
AWS_S3_SECRET_ACCESS_KEY: meet-secret-access-key
|
||||
AWS_STORAGE_BUCKET_NAME: meet-media-storage
|
||||
# Telephony
|
||||
ROOM_TELEPHONY_ENABLED: True
|
||||
@@ -180,7 +181,7 @@ backend:
|
||||
# Custom Background
|
||||
AWS_S3_REGION_NAME: local
|
||||
AWS_S3_SIGNATURE_VERSION: s3v4
|
||||
AWS_S3_DOMAIN_REPLACE: https://minio.127.0.0.1.nip.io
|
||||
AWS_S3_DOMAIN_REPLACE: https://garage.127.0.0.1.nip.io
|
||||
MEDIA_BASE_URL: https://meet.127.0.0.1.nip.io
|
||||
FILE_UPLOAD_ENABLED: True
|
||||
CELERY_ENABLED: True
|
||||
@@ -264,11 +265,11 @@ ingressMedia:
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/auth-url: https://meet.127.0.0.1.nip.io/api/v1.0/recordings/media-auth/
|
||||
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
|
||||
nginx.ingress.kubernetes.io/upstream-vhost: minio.meet.svc.cluster.local:9000
|
||||
nginx.ingress.kubernetes.io/upstream-vhost: garage.meet.svc.cluster.local:9000
|
||||
nginx.ingress.kubernetes.io/rewrite-target: /meet-media-storage/$1
|
||||
|
||||
serviceMedia:
|
||||
host: minio.meet.svc.cluster.local
|
||||
host: garage.meet.svc.cluster.local
|
||||
port: 9000
|
||||
|
||||
# ---- Extra ingress/service for background file uploads ------------
|
||||
@@ -280,11 +281,11 @@ ingressMediaFiles:
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/auth-url: https://meet.127.0.0.1.nip.io/api/v1.0/files/media-auth/
|
||||
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
|
||||
nginx.ingress.kubernetes.io/upstream-vhost: minio.meet.svc.cluster.local:9000
|
||||
nginx.ingress.kubernetes.io/upstream-vhost: garage.meet.svc.cluster.local:9000
|
||||
nginx.ingress.kubernetes.io/rewrite-target: /meet-media-storage/files/$1
|
||||
|
||||
serviceMediaFiles:
|
||||
host: minio.meet.svc.cluster.local
|
||||
host: garage.meet.svc.cluster.local
|
||||
port: 9000
|
||||
|
||||
# ---- STT Orchestration Microservice Components --------------------
|
||||
@@ -362,10 +363,11 @@ agentMetadata:
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
ENABLE_SILERO_VAD: "false"
|
||||
AWS_S3_ENDPOINT_URL: minio.meet.svc.cluster.local:9000
|
||||
AWS_S3_ACCESS_KEY_ID: meet
|
||||
AWS_S3_SECRET_ACCESS_KEY: password
|
||||
AWS_S3_ENDPOINT_URL: garage.meet.svc.cluster.local:9000
|
||||
AWS_S3_ACCESS_KEY_ID: meet-access-key
|
||||
AWS_S3_SECRET_ACCESS_KEY: meet-secret-access-key
|
||||
AWS_S3_SECURE_ACCESS: False
|
||||
AWS_S3_REGION_NAME: local
|
||||
AWS_STORAGE_BUCKET_NAME: meet-media-storage
|
||||
AWS_S3_OUTPUT_FOLDER: metadata
|
||||
|
||||
|
||||
@@ -16,11 +16,11 @@ egress:
|
||||
address: redis-master:6379
|
||||
password: pass
|
||||
s3:
|
||||
access_key: meet
|
||||
secret: password
|
||||
access_key: meet-access-key
|
||||
secret: meet-secret-access-key
|
||||
region: local
|
||||
bucket: meet-media-storage
|
||||
endpoint: http://minio:9000
|
||||
endpoint: http://garage:9000
|
||||
force_path_style: true
|
||||
|
||||
loadBalancer:
|
||||
|
||||
@@ -19,11 +19,11 @@ egress:
|
||||
address: redis-master:6379
|
||||
password: pass
|
||||
s3:
|
||||
access_key: meet
|
||||
secret: password
|
||||
access_key: meet-access-key
|
||||
secret: meet-secret-access-key
|
||||
region: local
|
||||
bucket: meet-media-storage
|
||||
endpoint: http://minio:9000
|
||||
endpoint: http://garage:9000
|
||||
force_path_style: true
|
||||
|
||||
loadBalancer:
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: garage
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: 10m
|
||||
spec:
|
||||
rules:
|
||||
- host: "garage.127.0.0.1.nip.io"
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: garage
|
||||
port:
|
||||
number: 9000
|
||||
tls:
|
||||
- hosts:
|
||||
- garage.127.0.0.1.nip.io
|
||||
secretName: meet-tls
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: garage
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
spec:
|
||||
ports:
|
||||
- name: client
|
||||
port: 9000
|
||||
protocol: TCP
|
||||
targetPort: 9000
|
||||
selector:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: garage
|
||||
type: ClusterIP
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: garage-config
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
data:
|
||||
garage.toml: |
|
||||
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"
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: garage-dev
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
type: Opaque
|
||||
data:
|
||||
GARAGE_RPC_SECRET: {{ printf "%s/garage-rpc-secret" .Release.Namespace | sha256sum | b64enc }}
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: garage
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
labels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: garage
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: garage
|
||||
replicas: 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: garage
|
||||
spec:
|
||||
containers:
|
||||
- name: garage
|
||||
command:
|
||||
- /garage
|
||||
- server
|
||||
- --single-node
|
||||
- --default-bucket
|
||||
env:
|
||||
- name: GARAGE_RPC_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: garage-dev
|
||||
key: GARAGE_RPC_SECRET
|
||||
- name: GARAGE_DEFAULT_ACCESS_KEY
|
||||
value: meet-access-key
|
||||
- name: GARAGE_DEFAULT_SECRET_KEY
|
||||
value: meet-secret-access-key
|
||||
- name: GARAGE_DEFAULT_BUCKET
|
||||
value: meet-media-storage
|
||||
image: "dxflrs/garage:v2.4.1"
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 9000
|
||||
name: client
|
||||
readinessProbe:
|
||||
exec:
|
||||
command:
|
||||
- /garage
|
||||
- health
|
||||
volumeMounts:
|
||||
- mountPath: /etc/garage.toml
|
||||
name: config
|
||||
subPath: garage.toml
|
||||
- mountPath: /var/lib/garage
|
||||
name: data
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: garage-config
|
||||
- name: data
|
||||
emptyDir:
|
||||
---
|
||||
# Garage denies cross-origin requests by default: allow the frontend to upload
|
||||
# files straight to the bucket
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: garage-cors
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: aws-cli
|
||||
image: amazon/aws-cli:2.37.1
|
||||
env:
|
||||
- name: AWS_ACCESS_KEY_ID
|
||||
value: meet-access-key
|
||||
- name: AWS_SECRET_ACCESS_KEY
|
||||
value: meet-secret-access-key
|
||||
- name: AWS_DEFAULT_REGION
|
||||
value: local
|
||||
- name: AWS_ENDPOINT_URL
|
||||
value: http://garage:9000
|
||||
- name: BUCKET
|
||||
value: meet-media-storage
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
deadline=$(($(date +%s) + 300))
|
||||
until aws s3api head-bucket --bucket="$BUCKET" --cli-connect-timeout=5; do
|
||||
if [ "$(date +%s)" -ge "$deadline" ]; then
|
||||
echo "Bucket $BUCKET still unavailable on $AWS_ENDPOINT_URL" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Waiting for bucket $BUCKET on $AWS_ENDPOINT_URL"
|
||||
sleep 5
|
||||
done
|
||||
exec aws s3api put-bucket-cors --bucket="$BUCKET" \
|
||||
--cors-configuration='{"CORSRules": [{"AllowedOrigins": ["https://meet.127.0.0.1.nip.io"], "AllowedMethods": ["GET", "HEAD", "PUT"], "AllowedHeaders": ["*"], "ExposeHeaders": ["ETag"]}]}'
|
||||
restartPolicy: Never
|
||||
backoffLimit: 3
|
||||
@@ -1,115 +0,0 @@
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: minio
|
||||
annotations:
|
||||
nginx.ingress.kubernetes.io/proxy-body-size: 10m
|
||||
spec:
|
||||
rules:
|
||||
- host: "minio.127.0.0.1.nip.io"
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: minio
|
||||
port:
|
||||
number: 9000
|
||||
tls:
|
||||
- hosts:
|
||||
- minio.127.0.0.1.nip.io
|
||||
secretName: meet-tls
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: minio
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
spec:
|
||||
ports:
|
||||
- name: client
|
||||
port: 9000
|
||||
protocol: TCP
|
||||
targetPort: 9000
|
||||
- name: console
|
||||
port: 9001
|
||||
protocol: TCP
|
||||
targetPort: 9001
|
||||
selector:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: minio
|
||||
type: ClusterIP
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: minio
|
||||
namespace: {{ .Release.Namespace | quote }}
|
||||
labels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: minio
|
||||
spec:
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: minio
|
||||
replicas: 1
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/instance: extra
|
||||
app.kubernetes.io/name: minio
|
||||
spec:
|
||||
containers:
|
||||
- name: minio
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
minio server --console-address :9001 /data
|
||||
env:
|
||||
- name: MINIO_ROOT_USER
|
||||
value: meet
|
||||
- name: MINIO_ROOT_PASSWORD
|
||||
value: password
|
||||
image: "quay.io/minio/minio"
|
||||
imagePullPolicy: IfNotPresent
|
||||
ports:
|
||||
- containerPort: 9000
|
||||
name: client
|
||||
- containerPort: 9001
|
||||
name: console
|
||||
volumeMounts:
|
||||
- mountPath: /data
|
||||
name: data
|
||||
- mountPath: /etc/ssl/certs/mkcert-ca.pem
|
||||
name: mkcert
|
||||
subPath: rootCA.pem
|
||||
volumes:
|
||||
- name: data
|
||||
emptyDir:
|
||||
- name: mkcert
|
||||
secret:
|
||||
secretName: mkcert
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: minio-bucket
|
||||
spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: mc
|
||||
image: quay.io/minio/mc
|
||||
command:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
/usr/bin/mc alias set meet http://minio:9000 meet password && \
|
||||
/usr/bin/mc mb meet/meet-media-storage && \
|
||||
exit 0
|
||||
restartPolicy: Never
|
||||
backoffLimit: 3
|
||||
@@ -45,10 +45,10 @@
|
||||
| `ingressMedia.tls.additional[].hosts[]` | Hosts for additional TLS config | |
|
||||
| `ingressMedia.annotations.nginx.ingress.kubernetes.io/auth-url` | | `https://meet.example.com/api/v1.0/recordings/media-auth/` |
|
||||
| `ingressMedia.annotations.nginx.ingress.kubernetes.io/auth-response-headers` | | `Authorization, X-Amz-Date, X-Amz-Content-SHA256` |
|
||||
| `ingressMedia.annotations.nginx.ingress.kubernetes.io/upstream-vhost` | | `minio.meet.svc.cluster.local:9000` |
|
||||
| `ingressMedia.annotations.nginx.ingress.kubernetes.io/upstream-vhost` | | `garage.meet.svc.cluster.local:9000` |
|
||||
| `ingressMedia.annotations.nginx.ingress.kubernetes.io/configuration-snippet` | | `add_header Content-Security-Policy "default-src 'none'" always;
|
||||
` |
|
||||
| `serviceMedia.host` | | `minio.meet.svc.cluster.local` |
|
||||
| `serviceMedia.host` | | `garage.meet.svc.cluster.local` |
|
||||
| `serviceMedia.port` | | `9000` |
|
||||
| `serviceMedia.annotations` | | `{}` |
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ ingressMedia:
|
||||
nginx.ingress.kubernetes.io/use-regex: "true"
|
||||
nginx.ingress.kubernetes.io/auth-url: https://meet.example.com/api/v1.0/recordings/media-auth/
|
||||
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
|
||||
nginx.ingress.kubernetes.io/upstream-vhost: minio.meet.svc.cluster.local:9000
|
||||
nginx.ingress.kubernetes.io/upstream-vhost: garage.meet.svc.cluster.local:9000
|
||||
nginx.ingress.kubernetes.io/configuration-snippet: |
|
||||
add_header Content-Security-Policy "default-src 'none'" always;
|
||||
|
||||
@@ -136,7 +136,7 @@ ingressMedia:
|
||||
## @param serviceMedia.port
|
||||
## @param serviceMedia.annotations
|
||||
serviceMedia:
|
||||
host: minio.meet.svc.cluster.local
|
||||
host: garage.meet.svc.cluster.local
|
||||
port: 9000
|
||||
annotations: {}
|
||||
|
||||
@@ -171,7 +171,7 @@ ingressMediaFiles:
|
||||
nginx.ingress.kubernetes.io/use-regex: "true"
|
||||
nginx.ingress.kubernetes.io/auth-url: https://meet.example.com/api/v1.0/files/media-auth/
|
||||
nginx.ingress.kubernetes.io/auth-response-headers: "Authorization, X-Amz-Date, X-Amz-Content-SHA256"
|
||||
nginx.ingress.kubernetes.io/upstream-vhost: minio.meet.svc.cluster.local:9000
|
||||
nginx.ingress.kubernetes.io/upstream-vhost: garage.meet.svc.cluster.local:9000
|
||||
nginx.ingress.kubernetes.io/configuration-snippet: |
|
||||
add_header Content-Security-Policy "default-src 'none'" always;
|
||||
add_header Content-Disposition "attachment";
|
||||
@@ -180,7 +180,7 @@ ingressMediaFiles:
|
||||
## @param serviceMediaFiles.port
|
||||
## @param serviceMediaFiles.annotations
|
||||
serviceMediaFiles:
|
||||
host: minio.meet.svc.cluster.local
|
||||
host: garage.meet.svc.cluster.local
|
||||
port: 9000
|
||||
annotations: {}
|
||||
|
||||
@@ -289,6 +289,9 @@ backend:
|
||||
## @param backend.cronjobs[1].name Name of the CronJob
|
||||
## @param backend.cronjobs[1].schedule Schedule in cron format
|
||||
## @param backend.cronjobs[1].command The bash command to execute in the CronJob
|
||||
## @param backend.cronjobs[2].name Name of the CronJob
|
||||
## @param backend.cronjobs[2].schedule Schedule in cron format
|
||||
## @param backend.cronjobs[2].command The bash command to execute in the CronJob
|
||||
|
||||
cronjobs:
|
||||
- name: clean-pending-files
|
||||
@@ -303,6 +306,12 @@ backend:
|
||||
- "/bin/sh"
|
||||
- "-c"
|
||||
- "python manage.py purge_deleted_files"
|
||||
- name: purge-inactive-rooms
|
||||
schedule: "0 1 * * *"
|
||||
command:
|
||||
- "/bin/sh"
|
||||
- "-c"
|
||||
- "python manage.py purge_inactive_rooms"
|
||||
|
||||
## @param backend.mergeDuplicateUsers.command backend merge_duplicate_users command
|
||||
## @param backend.mergeDuplicateUsers.restartPolicy backend merge_duplicate_users job restart policy
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.31.0",
|
||||
"version": "1.32.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mail_mjml",
|
||||
"version": "1.31.0",
|
||||
"version": "1.32.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@html-to/text-cli": "0.6.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mail_mjml",
|
||||
"version": "1.31.0",
|
||||
"version": "1.32.1",
|
||||
"description": "An util to generate html and text django's templates from mjml templates",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.31.0",
|
||||
"version": "1.32.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sdk",
|
||||
"version": "1.31.0",
|
||||
"version": "1.32.1",
|
||||
"license": "ISC",
|
||||
"workspaces": [
|
||||
"./library",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sdk",
|
||||
"version": "1.31.0",
|
||||
"version": "1.32.1",
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"description": "",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
|
||||
[project]
|
||||
name = "summary"
|
||||
version = "1.31.0"
|
||||
version = "1.32.1"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"fastapi[standard]>=0.105.0",
|
||||
@@ -10,18 +10,18 @@ dependencies = [
|
||||
"pydantic-settings>=2.1.0",
|
||||
"celery==5.6.3",
|
||||
"redis==5.2.1",
|
||||
"boto3==1.43.56",
|
||||
"dockerflow==2026.3.4",
|
||||
"minio==7.2.20",
|
||||
"openai==2.48.0",
|
||||
"posthog==7.29.0",
|
||||
"openai==3.3.1",
|
||||
"posthog==7.44.0",
|
||||
"requests==2.34.2",
|
||||
"sentry-sdk[fastapi, celery]==2.66.1",
|
||||
"sentry-sdk[fastapi, celery]==2.68.1",
|
||||
"langfuse==4.14.1"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"ruff==0.16.0",
|
||||
"ruff==0.16.4",
|
||||
"pytest==9.1.1",
|
||||
"responses>=0.25.8",
|
||||
]
|
||||
|
||||
@@ -77,7 +77,7 @@ class Settings(BaseSettings):
|
||||
summarize_queue_v2: str = "summarize-queue-v2"
|
||||
call_webhook_queue_v2: str = "call-webhook-queue-v2"
|
||||
|
||||
# Minio settings
|
||||
# S3 settings
|
||||
aws_storage_bucket_name: str
|
||||
aws_s3_endpoint_url: str
|
||||
aws_s3_access_key_id: str
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""File service to encapsulate files' manipulations."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -9,11 +8,13 @@ import tempfile
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
from functools import cached_property
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import boto3
|
||||
import requests
|
||||
from minio import Minio
|
||||
from botocore.config import Config
|
||||
|
||||
from summary.core.config import get_settings
|
||||
from summary.core.shared_models import WhisperXResponse
|
||||
@@ -266,30 +267,44 @@ class FileServiceException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _build_s3_client():
|
||||
"""Build 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 = (
|
||||
settings.aws_s3_endpoint_url.removeprefix("https://")
|
||||
.removeprefix("http://")
|
||||
.rstrip("/")
|
||||
)
|
||||
scheme = "https" if settings.aws_s3_secure_access else "http"
|
||||
|
||||
return boto3.client(
|
||||
"s3",
|
||||
endpoint_url=f"{scheme}://{endpoint}",
|
||||
aws_access_key_id=settings.aws_s3_access_key_id,
|
||||
aws_secret_access_key=settings.aws_s3_secret_access_key.get_secret_value(),
|
||||
region_name=settings.aws_s3_region_name,
|
||||
config=Config(signature_version="s3v4", s3={"addressing_style": "path"}),
|
||||
)
|
||||
|
||||
|
||||
class FileService:
|
||||
"""Service for downloading and preparing files from MinIO storage."""
|
||||
"""Service for downloading and preparing files from S3 storage."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize FileService with MinIO client and configuration."""
|
||||
endpoint = (
|
||||
settings.aws_s3_endpoint_url.removeprefix("https://")
|
||||
.removeprefix("http://")
|
||||
.rstrip("/")
|
||||
)
|
||||
|
||||
self._minio_client = Minio(
|
||||
endpoint,
|
||||
access_key=settings.aws_s3_access_key_id,
|
||||
secret_key=settings.aws_s3_secret_access_key.get_secret_value(),
|
||||
secure=settings.aws_s3_secure_access,
|
||||
region=settings.aws_s3_region_name,
|
||||
)
|
||||
|
||||
"""Initialize FileService with its configuration."""
|
||||
self._bucket_name = settings.aws_storage_bucket_name
|
||||
self._stream_chunk_size = 32 * 1024
|
||||
|
||||
self._max_duration_seconds = settings.recording_max_duration
|
||||
|
||||
@cached_property
|
||||
def _s3_client(self):
|
||||
"""S3 client, created on first use."""
|
||||
return _build_s3_client()
|
||||
|
||||
def _download_from_cloud_storage_url(self, cloud_storage_url: str) -> Path:
|
||||
"""Download file from a cloud storage URL to local temporary file."""
|
||||
logger.info(
|
||||
@@ -368,7 +383,7 @@ class FileService:
|
||||
):
|
||||
"""Download and prepare audio file for processing.
|
||||
|
||||
Downloads file from MinIO or an external cloud URL, validates duration,
|
||||
Downloads file from S3 or an external cloud URL, validates duration,
|
||||
and yields an open file handle with metadata. Automatically cleans up
|
||||
temporary files when the context exits.
|
||||
"""
|
||||
@@ -416,16 +431,13 @@ class FileService:
|
||||
logger.warning("Failed to remove temporary file %s: %s", path, e)
|
||||
|
||||
def store_transcript(self, *, transcript: WhisperXResponse, job_id: str) -> None:
|
||||
"""Store transcript in MinIO."""
|
||||
"""Store transcript in S3."""
|
||||
logger.info("Storing transcript for job id %s", job_id)
|
||||
transcript_path = f"{settings.aws_transcript_path}/{job_id}.json"
|
||||
logger.debug("Transcript path: %s", transcript_path)
|
||||
data = transcript.model_dump_json().encode()
|
||||
self._minio_client.put_object(
|
||||
self._bucket_name,
|
||||
transcript_path,
|
||||
io.BytesIO(data),
|
||||
length=len(data),
|
||||
self._s3_client.put_object(
|
||||
Bucket=self._bucket_name, Key=transcript_path, Body=data
|
||||
)
|
||||
logger.info("Transcript stored successfully for job id %s", job_id)
|
||||
|
||||
@@ -433,21 +445,20 @@ class FileService:
|
||||
"""Get signed URL for transcript file."""
|
||||
transcript_path = f"{settings.aws_transcript_path}/{job_id}.json"
|
||||
logger.debug("Transcript path: %s", transcript_path)
|
||||
return self._minio_client.presigned_get_object(
|
||||
self._bucket_name, transcript_path, expires=timedelta(hours=24)
|
||||
return self._s3_client.generate_presigned_url(
|
||||
"get_object",
|
||||
Params={"Bucket": self._bucket_name, "Key": transcript_path},
|
||||
ExpiresIn=int(timedelta(hours=24).total_seconds()),
|
||||
)
|
||||
|
||||
def store_summary(self, *, summary: str, job_id: str) -> None:
|
||||
"""Store summary in MinIO."""
|
||||
"""Store summary in S3."""
|
||||
logger.info("Storing summary for job id %s", job_id)
|
||||
summary_path = f"{settings.aws_summary_path}/{job_id}.txt"
|
||||
logger.debug("Summary path: %s", summary_path)
|
||||
data = summary.encode()
|
||||
self._minio_client.put_object(
|
||||
self._bucket_name,
|
||||
summary_path,
|
||||
io.BytesIO(data),
|
||||
length=len(data),
|
||||
self._s3_client.put_object(
|
||||
Bucket=self._bucket_name, Key=summary_path, Body=data
|
||||
)
|
||||
logger.info("Summary stored successfully for job id %s", job_id)
|
||||
|
||||
@@ -455,6 +466,8 @@ class FileService:
|
||||
"""Get signed URL for summary file."""
|
||||
summary_path = f"{settings.aws_summary_path}/{job_id}.txt"
|
||||
logger.debug("Summary path: %s", summary_path)
|
||||
return self._minio_client.presigned_get_object(
|
||||
self._bucket_name, summary_path, expires=timedelta(hours=24)
|
||||
return self._s3_client.generate_presigned_url(
|
||||
"get_object",
|
||||
Params={"Bucket": self._bucket_name, "Key": summary_path},
|
||||
ExpiresIn=int(timedelta(hours=24).total_seconds()),
|
||||
)
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
"""Unit tests for the file service."""
|
||||
|
||||
import json
|
||||
from collections.abc import Callable, Iterator
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
|
||||
import pytest
|
||||
from botocore.stub import Stubber
|
||||
|
||||
from summary.core import file_service
|
||||
from summary.core.file_service import (
|
||||
FileService,
|
||||
MediaInfo,
|
||||
extract_audio_from_media,
|
||||
get_media_info,
|
||||
)
|
||||
from summary.core.shared_models import WhisperXResponse
|
||||
|
||||
BASE_PATH = Path(__file__).parent.parent / "assets"
|
||||
|
||||
@@ -24,6 +29,46 @@ MEDIA_INFO_SAMPLE_VISIO = MediaInfo(
|
||||
)
|
||||
|
||||
|
||||
S3Settings = Callable[..., None]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def s3_settings(monkeypatch: pytest.MonkeyPatch) -> S3Settings:
|
||||
"""Configure the S3 settings read by the file service.
|
||||
|
||||
The returned function overrides some of them on top of the current ones.
|
||||
The (frozen) settings are replaced by a copy, restored after the test.
|
||||
"""
|
||||
|
||||
def override(**values) -> None:
|
||||
monkeypatch.setattr(
|
||||
file_service, "settings", file_service.settings.model_copy(update=values)
|
||||
)
|
||||
|
||||
override(
|
||||
aws_s3_endpoint_url="garage:9000",
|
||||
aws_s3_secure_access=False,
|
||||
aws_s3_region_name="fr-par",
|
||||
aws_storage_bucket_name="meet-media-storage",
|
||||
)
|
||||
return override
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def s3_stubber(
|
||||
monkeypatch: pytest.MonkeyPatch, s3_settings: S3Settings
|
||||
) -> Iterator[Stubber]:
|
||||
"""Stub the S3 client built by the file service.
|
||||
|
||||
An unexpected S3 call fails the test instead of reaching the network.
|
||||
"""
|
||||
stubber = Stubber(file_service._build_s3_client())
|
||||
stubber.activate()
|
||||
monkeypatch.setattr(file_service, "_build_s3_client", lambda: stubber.client)
|
||||
yield stubber
|
||||
stubber.assert_no_pending_responses()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"media_info",
|
||||
[
|
||||
@@ -149,3 +194,92 @@ def test_extract_audio_from_video():
|
||||
assert path.name.endswith(".m4a")
|
||||
finally:
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("endpoint_url", "secure_access", "expected_endpoint_url"),
|
||||
[
|
||||
("garage:9000", False, "http://garage:9000"),
|
||||
("http://garage:9000/", False, "http://garage:9000"),
|
||||
("s3.example.com", True, "https://s3.example.com"),
|
||||
("http://s3.example.com", True, "https://s3.example.com"),
|
||||
],
|
||||
)
|
||||
def test_s3_client_endpoint_follows_secure_access(
|
||||
s3_settings: S3Settings,
|
||||
endpoint_url: str,
|
||||
secure_access: bool,
|
||||
expected_endpoint_url: str,
|
||||
) -> None:
|
||||
"""The endpoint scheme is taken from aws_s3_secure_access, not from the URL."""
|
||||
s3_settings(aws_s3_endpoint_url=endpoint_url, aws_s3_secure_access=secure_access)
|
||||
|
||||
assert FileService()._s3_client.meta.endpoint_url == expected_endpoint_url
|
||||
|
||||
|
||||
@pytest.mark.parametrize("region_name", ["fr-par", None])
|
||||
def test_s3_client_region_is_passed_as_is(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
s3_settings: S3Settings,
|
||||
region_name: str | None,
|
||||
) -> None:
|
||||
"""The configured region goes straight to boto3, even when it is unset."""
|
||||
s3_settings(aws_s3_region_name=region_name)
|
||||
boto3_client = Mock()
|
||||
monkeypatch.setattr(file_service.boto3, "client", boto3_client)
|
||||
|
||||
assert FileService()._s3_client is boto3_client.return_value
|
||||
|
||||
boto3_client.assert_called_once()
|
||||
assert boto3_client.call_args.kwargs["region_name"] == region_name
|
||||
|
||||
|
||||
def test_store_transcript(s3_stubber: Stubber) -> None:
|
||||
"""The transcript is stored as JSON under the transcripts path."""
|
||||
transcript = WhisperXResponse(segments=())
|
||||
s3_stubber.add_response(
|
||||
"put_object",
|
||||
{},
|
||||
{
|
||||
"Bucket": "meet-media-storage",
|
||||
"Key": "transcripts/job-1.json",
|
||||
"Body": transcript.model_dump_json().encode(),
|
||||
},
|
||||
)
|
||||
|
||||
FileService().store_transcript(transcript=transcript, job_id="job-1")
|
||||
|
||||
|
||||
def test_store_summary(s3_stubber: Stubber) -> None:
|
||||
"""The summary is stored as text under the summaries path."""
|
||||
s3_stubber.add_response(
|
||||
"put_object",
|
||||
{},
|
||||
{
|
||||
"Bucket": "meet-media-storage",
|
||||
"Key": "summaries/job-1.txt",
|
||||
"Body": b"The summary",
|
||||
},
|
||||
)
|
||||
|
||||
FileService().store_summary(summary="The summary", job_id="job-1")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "expected_path"),
|
||||
[
|
||||
("get_transcript_signed_url", "/meet-media-storage/transcripts/job-1.json"),
|
||||
("get_summary_signed_url", "/meet-media-storage/summaries/job-1.txt"),
|
||||
],
|
||||
)
|
||||
def test_signed_urls(s3_stubber: Stubber, method: str, expected_path: str) -> None:
|
||||
"""Signed URLs are path-style, SigV4-signed for the region, valid for a day."""
|
||||
url = urlparse(getattr(FileService(), method)("job-1"))
|
||||
query = parse_qs(url.query)
|
||||
|
||||
assert url.scheme == "http"
|
||||
assert url.netloc == "garage:9000"
|
||||
assert url.path == expected_path
|
||||
assert query["X-Amz-Algorithm"] == ["AWS4-HMAC-SHA256"]
|
||||
assert "/fr-par/s3/aws4_request" in query["X-Amz-Credential"][0]
|
||||
assert query["X-Amz-Expires"] == ["86400"]
|
||||
|
||||
Generated
+580
-578
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user