mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-13 12:17:24 +00:00
Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8ab229813 | |||
| 7bd5ab7a13 | |||
| ac8eae7295 | |||
| 15b1ab7e0a | |||
| 2e509eff28 | |||
| ca56ae87c2 | |||
| 2c5a766d02 | |||
| a67467b193 | |||
| 24d5a5a035 | |||
| 9e500a59ea | |||
| 8a0d4b1ad6 | |||
| d0726ba631 | |||
| e65036fe90 | |||
| 8b198726e9 | |||
| fc4774199b | |||
| 9a3e54e6ec | |||
| a0d4d82e18 | |||
| 8ed49c880b | |||
| be369bc04b | |||
| be98c5210e | |||
| e400d05a7c | |||
| 9af42568bf | |||
| 7eba983c15 | |||
| b7210e62bb | |||
| 653326347b | |||
| 187e289548 | |||
| 496a192ced | |||
| 715c18d276 | |||
| dd3f23b1bf | |||
| 066cd5f704 | |||
| 19dfa26ee4 | |||
| 82aafa2030 | |||
| bfb02b2242 | |||
| ec9cd84cf8 | |||
| 65172447ca | |||
| 71d76abc0f | |||
| 47f3da4153 | |||
| ab0b2d67c3 | |||
| a911226d07 | |||
| adaffb0343 | |||
| ee5710e61c | |||
| 6dd914fe66 | |||
| a52ea119ac | |||
| fc4ee70265 | |||
| 81f23aeb88 | |||
| a5651249c5 | |||
| 167839ebd0 | |||
| 18ec920ebe | |||
| 8a24502295 | |||
| c3f212bf54 | |||
| a0274a2d54 |
@@ -1,29 +0,0 @@
|
||||
# /!\
|
||||
# Security Note: This action is not hardened against prompt injection attacks and should only be used
|
||||
# to review trusted PRs. Configure your repository with "Require approval for all external contributors"
|
||||
# to ensure workflows only run after a maintainer has reviewed the PR.
|
||||
name: Security Review
|
||||
|
||||
permissions:
|
||||
pull-requests: write # Needed for leaving PR comments
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'main'
|
||||
|
||||
jobs:
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || github.sha }}
|
||||
fetch-depth: 2
|
||||
|
||||
- uses: anthropics/claude-code-security-review@0c6a49f1fa56a1d472575da86a94dbc1edb78eda
|
||||
with:
|
||||
comment-pr: true
|
||||
exclude-directories: docs,gitlint,LICENSES,bin
|
||||
claude-api-key: ${{ secrets.CLAUDE_API_KEY }}
|
||||
+23
-7
@@ -12,20 +12,36 @@ and this project adheres to
|
||||
|
||||
- ✨(summary) report exception type in failure analytics
|
||||
- ✨(frontend) add configurable documentation menu item
|
||||
|
||||
## Fixed
|
||||
|
||||
- 🐛(transcription) fix silent bug in speaker assignment
|
||||
- 🐛(summary) extend tasks auto retry logic
|
||||
- 🐛(summary) properly detect when failure webhook should be sent
|
||||
- ✨(frontend) allow promoting authenticated participants
|
||||
- ✨(frontend) introduce an "unauthenticated" participant badge
|
||||
- ✨(backend) add roomkit viewset to start a room without WebRTC join
|
||||
- ✨(frontend) let users set default configuration for generated links
|
||||
- ✨(frontend) expose media state to external gateways
|
||||
- ✨(backend) push recordings to the owner's Drive (POC)
|
||||
|
||||
### Changed
|
||||
|
||||
- ⬆️(frontend) upgrade @mediapipe/tasks-vision from 0.10.14 to 0.10.35
|
||||
- ⬆️(frontend) upgrade i18next from 26.3.1 to 26.3.2
|
||||
- ⬆️(frontend) upgrade i18next from 26.3.1 to 26.3.6
|
||||
- ⬆️(frontend) upgrade posthog-js from 1.391.2 to 1.395.0
|
||||
- ⬆️(frontend) upgrade @tanstack/react-query from 5.101.0 to 5.101.1
|
||||
- ⬆️(frontend) upgrade livekit-client from 2.19.2 to 2.20.0
|
||||
- ⚡️(frontend) limit unnecessary re-renders #1510
|
||||
- 📝(legal) update terms of service
|
||||
- 💄(frontend) render Avatar initials in uppercase
|
||||
- 💄(frontend) improve participant name rendering in the list
|
||||
- 🚚(backend) rename TelephonyService to SIPManagement
|
||||
|
||||
### Fixed
|
||||
|
||||
- 🐛(transcription) fix silent bug in speaker assignment
|
||||
- 🐛(summary) extend tasks auto retry logic
|
||||
- 🐛(summary) properly detect when failure webhook should be sent
|
||||
- 🐛(backend) preserve recording metadata when updating room access
|
||||
- 🐛(backend) allow any string as sub in the API serializer
|
||||
- 🐛(frontend) fall back to user.full_name on request-entry
|
||||
- 🚸(frontend) show two initials in the Avatar when possible
|
||||
- 🩹(all) clear the SonarCloud reliability finding and the lint debt
|
||||
|
||||
## [1.24.0] - 2026-07-21
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ COMPOSE_EXEC = $(COMPOSE) exec
|
||||
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
|
||||
COMPOSE_RUN = $(COMPOSE) run --rm
|
||||
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
|
||||
COMPOSE_RUN_LINT = $(COMPOSE_RUN) --no-deps app-dev
|
||||
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
|
||||
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
|
||||
|
||||
@@ -51,6 +52,14 @@ WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT
|
||||
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
||||
MAIL_NPM = $(COMPOSE_RUN) -w /app/src/mail node npm
|
||||
|
||||
# -- Linters
|
||||
LINT_RUFF_FORMAT = ruff format .
|
||||
LINT_RUFF_CHECK = ruff check . --fix
|
||||
LINT_PYLINT = pylint meet demo core
|
||||
LINT_BACK = echo 'lint:ruff-format started…' && $(LINT_RUFF_FORMAT) \
|
||||
&& echo 'lint:ruff-check started…' && $(LINT_RUFF_CHECK) \
|
||||
&& echo 'lint:pylint started…' && $(LINT_PYLINT)
|
||||
|
||||
# -- Frontend
|
||||
PATH_FRONT = ./src/frontend
|
||||
|
||||
@@ -84,6 +93,7 @@ bootstrap: \
|
||||
data/media \
|
||||
data/static \
|
||||
create-env-files \
|
||||
create-docker-network \
|
||||
build \
|
||||
migrate \
|
||||
demo \
|
||||
@@ -117,13 +127,19 @@ down: ## stop and remove containers, networks, images, and volumes
|
||||
@$(COMPOSE) down
|
||||
.PHONY: down
|
||||
|
||||
create-docker-network: ## create the shared lasuite-network if it doesn't exist
|
||||
@docker network create lasuite-network || true
|
||||
.PHONY: create-docker-network
|
||||
|
||||
logs: ## display app-dev logs (follow mode)
|
||||
@$(COMPOSE) logs -f app-dev
|
||||
.PHONY: logs
|
||||
|
||||
run-backend: ## start only the backend application and all needed services
|
||||
@$(MAKE) create-docker-network
|
||||
@$(COMPOSE) up --force-recreate -d celery-dev --remove-orphans
|
||||
@$(COMPOSE) up --force-recreate -d nginx
|
||||
@$(COMPOSE) up -d livekit
|
||||
@echo "Wait for postgresql to be up..."
|
||||
@$(WAIT_DB)
|
||||
.PHONY: run-backend
|
||||
@@ -188,27 +204,23 @@ demo: ## flush db then create a demo for load testing purpose
|
||||
@$(MANAGE) create_demo
|
||||
.PHONY: demo
|
||||
|
||||
# Nota bene: Black should come after isort just in case they don't agree...
|
||||
lint: ## lint back-end python sources
|
||||
lint: \
|
||||
lint-ruff-format \
|
||||
lint-ruff-check \
|
||||
lint-pylint
|
||||
@$(COMPOSE_RUN_LINT) sh -c "$(LINT_BACK)"
|
||||
.PHONY: lint
|
||||
|
||||
lint-ruff-format: ## format back-end python sources with ruff
|
||||
@echo 'lint:ruff-format started…'
|
||||
@$(COMPOSE_RUN_APP) ruff format .
|
||||
@$(COMPOSE_RUN_LINT) $(LINT_RUFF_FORMAT)
|
||||
.PHONY: lint-ruff-format
|
||||
|
||||
lint-ruff-check: ## lint back-end python sources with ruff
|
||||
@echo 'lint:ruff-check started…'
|
||||
@$(COMPOSE_RUN_APP) ruff check . --fix
|
||||
@$(COMPOSE_RUN_LINT) $(LINT_RUFF_CHECK)
|
||||
.PHONY: lint-ruff-check
|
||||
|
||||
lint-pylint: ## lint back-end python sources with pylint only on changed files from main
|
||||
@echo 'lint:pylint started…'
|
||||
@$(COMPOSE_RUN_APP) pylint meet demo core
|
||||
@$(COMPOSE_RUN_LINT) $(LINT_PYLINT)
|
||||
.PHONY: lint-pylint
|
||||
|
||||
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"`
|
||||
|
||||
+24
-11
@@ -14,6 +14,9 @@ services:
|
||||
image: sj26/mailcatcher:latest
|
||||
ports:
|
||||
- "1081:1080"
|
||||
networks:
|
||||
- default
|
||||
- lasuite
|
||||
|
||||
minio:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
@@ -33,6 +36,10 @@ services:
|
||||
command: minio server --console-address :9001 /data
|
||||
volumes:
|
||||
- ./data/media:/data
|
||||
networks:
|
||||
default:
|
||||
aliases:
|
||||
- meet-minio
|
||||
|
||||
createbuckets:
|
||||
image: minio/mc
|
||||
@@ -85,7 +92,6 @@ services:
|
||||
- postgresql
|
||||
- mailcatcher
|
||||
- redis
|
||||
- livekit
|
||||
- createbuckets
|
||||
- createwebhook
|
||||
extra_hosts:
|
||||
@@ -93,11 +99,12 @@ services:
|
||||
networks:
|
||||
- resource-server
|
||||
- default
|
||||
- lasuite
|
||||
|
||||
celery-dev:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: meet:backend-development
|
||||
command: ["celery", "-A", "meet.celery_app", "worker", "-l", "DEBUG"]
|
||||
command: ["celery", "-A", "meet.celery_app", "worker", "-l", "DEBUG", "--pool=solo"]
|
||||
environment:
|
||||
- DJANGO_CONFIGURATION=Development
|
||||
env_file:
|
||||
@@ -109,6 +116,9 @@ services:
|
||||
- /app/.venv
|
||||
depends_on:
|
||||
- app-dev
|
||||
networks:
|
||||
- default
|
||||
- lasuite
|
||||
|
||||
app:
|
||||
build:
|
||||
@@ -132,7 +142,7 @@ services:
|
||||
celery:
|
||||
user: ${DOCKER_USER:-1000}
|
||||
image: meet:backend-production
|
||||
command: ["celery", "-A", "meet.celery_app", "worker", "-l", "INFO"]
|
||||
command: ["celery", "-A", "meet.celery_app", "worker", "-l", "INFO", "--pool=solo"]
|
||||
environment:
|
||||
- DJANGO_CONFIGURATION=Demo
|
||||
env_file:
|
||||
@@ -196,32 +206,32 @@ services:
|
||||
- env.d/development/kc_postgresql
|
||||
|
||||
keycloak:
|
||||
image: quay.io/keycloak/keycloak:20.0.1
|
||||
image: quay.io/keycloak/keycloak:26.3.2
|
||||
volumes:
|
||||
- ./docker/auth/realm.json:/opt/keycloak/data/import/realm.json
|
||||
command:
|
||||
- start-dev
|
||||
- --features=preview
|
||||
- --import-realm
|
||||
- --proxy=edge
|
||||
- --hostname-url=http://localhost:8083
|
||||
- --hostname-admin-url=http://localhost:8083/
|
||||
- --proxy-headers=xforwarded
|
||||
- --hostname=http://localhost:8083
|
||||
- --hostname-strict=false
|
||||
- --hostname-strict-https=false
|
||||
environment:
|
||||
KEYCLOAK_ADMIN: admin
|
||||
KEYCLOAK_ADMIN_PASSWORD: admin
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME: admin
|
||||
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
|
||||
KC_DB: postgres
|
||||
KC_DB_URL_HOST: kc_postgresql
|
||||
KC_DB_URL_DATABASE: keycloak
|
||||
KC_DB_PASSWORD: pass
|
||||
KC_DB_USERNAME: meet
|
||||
KC_DB_SCHEMA: public
|
||||
PROXY_ADDRESS_FORWARDING: 'true'
|
||||
ports:
|
||||
- "8080:8080"
|
||||
depends_on:
|
||||
- kc_postgresql
|
||||
networks:
|
||||
- default
|
||||
- lasuite
|
||||
|
||||
livekit:
|
||||
image: livekit/livekit-server
|
||||
@@ -338,3 +348,6 @@ services:
|
||||
networks:
|
||||
default:
|
||||
resource-server:
|
||||
lasuite:
|
||||
name: lasuite-network
|
||||
external: true
|
||||
|
||||
@@ -845,6 +845,142 @@
|
||||
"offline_access",
|
||||
"microprofile-jwt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"clientId": "drive",
|
||||
"name": "",
|
||||
"description": "",
|
||||
"rootUrl": "",
|
||||
"adminUrl": "",
|
||||
"baseUrl": "",
|
||||
"surrogateAuthRequired": false,
|
||||
"enabled": "true",
|
||||
"alwaysDisplayInConsole": false,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"secret": "ThisIsAnExampleKeyForDevPurposeOnly",
|
||||
"redirectUris": [
|
||||
"http://localhost:3100/*",
|
||||
"http://localhost:8171/*",
|
||||
"http://localhost:8085/*"
|
||||
],
|
||||
"webOrigins": [
|
||||
"http://localhost:3100",
|
||||
"http://localhost:8171",
|
||||
"http://localhost:8085"
|
||||
],
|
||||
"notBefore": 0,
|
||||
"bearerOnly": false,
|
||||
"consentRequired": false,
|
||||
"standardFlowEnabled": true,
|
||||
"implicitFlowEnabled": false,
|
||||
"directAccessGrantsEnabled": false,
|
||||
"serviceAccountsEnabled": false,
|
||||
"publicClient": false,
|
||||
"frontchannelLogout": true,
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"access.token.lifespan": "-1",
|
||||
"client.secret.creation.time": "1707820779",
|
||||
"user.info.response.signature.alg": "RS256",
|
||||
"post.logout.redirect.uris": "http://localhost:3100/*##http://localhost:8171/*##http://localhost:8085/*",
|
||||
"oauth2.device.authorization.grant.enabled": "false",
|
||||
"use.jwks.url": "false",
|
||||
"backchannel.logout.revoke.offline.tokens": "false",
|
||||
"use.refresh.tokens": "true",
|
||||
"tls-client-certificate-bound-access-tokens": "false",
|
||||
"oidc.ciba.grant.enabled": "false",
|
||||
"backchannel.logout.session.required": "true",
|
||||
"client_credentials.use_refresh_token": "false",
|
||||
"acr.loa.map": "{}",
|
||||
"require.pushed.authorization.requests": "false",
|
||||
"display.on.consent.screen": "false",
|
||||
"client.session.idle.timeout": "-1",
|
||||
"token.response.type.bearer.lower-case": "false"
|
||||
},
|
||||
"authenticationFlowBindingOverrides": {},
|
||||
"fullScopeAllowed": true,
|
||||
"nodeReRegistrationTimeout": -1,
|
||||
"defaultClientScopes": [
|
||||
"web-origins",
|
||||
"acr",
|
||||
"roles",
|
||||
"profile",
|
||||
"email"
|
||||
],
|
||||
"optionalClientScopes": [
|
||||
"address",
|
||||
"phone",
|
||||
"offline_access",
|
||||
"microprofile-jwt"
|
||||
]
|
||||
},
|
||||
{
|
||||
"clientId": "deploycenter",
|
||||
"name": "",
|
||||
"description": "",
|
||||
"rootUrl": "",
|
||||
"adminUrl": "",
|
||||
"baseUrl": "",
|
||||
"surrogateAuthRequired": false,
|
||||
"enabled": "true",
|
||||
"alwaysDisplayInConsole": false,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
"secret": "ThisIsAnExampleKeyForDevPurposeOnly",
|
||||
"redirectUris": [
|
||||
"http://localhost:3100/*",
|
||||
"http://localhost:8171/*",
|
||||
"http://localhost:8085/*"
|
||||
],
|
||||
"webOrigins": [
|
||||
"http://localhost:3100",
|
||||
"http://localhost:8171",
|
||||
"http://localhost:8085"
|
||||
],
|
||||
"notBefore": 0,
|
||||
"bearerOnly": false,
|
||||
"consentRequired": false,
|
||||
"standardFlowEnabled": true,
|
||||
"implicitFlowEnabled": false,
|
||||
"directAccessGrantsEnabled": false,
|
||||
"serviceAccountsEnabled": false,
|
||||
"publicClient": false,
|
||||
"frontchannelLogout": true,
|
||||
"protocol": "openid-connect",
|
||||
"attributes": {
|
||||
"access.token.lifespan": "-1",
|
||||
"client.secret.creation.time": "1707820779",
|
||||
"user.info.response.signature.alg": "RS256",
|
||||
"post.logout.redirect.uris": "http://localhost:3100/*##http://localhost:8171/*##http://localhost:8085/*",
|
||||
"oauth2.device.authorization.grant.enabled": "false",
|
||||
"use.jwks.url": "false",
|
||||
"backchannel.logout.revoke.offline.tokens": "false",
|
||||
"use.refresh.tokens": "true",
|
||||
"tls-client-certificate-bound-access-tokens": "false",
|
||||
"oidc.ciba.grant.enabled": "false",
|
||||
"backchannel.logout.session.required": "true",
|
||||
"client_credentials.use_refresh_token": "false",
|
||||
"acr.loa.map": "{}",
|
||||
"require.pushed.authorization.requests": "false",
|
||||
"display.on.consent.screen": "false",
|
||||
"client.session.idle.timeout": "-1",
|
||||
"token.response.type.bearer.lower-case": "false"
|
||||
},
|
||||
"authenticationFlowBindingOverrides": {},
|
||||
"fullScopeAllowed": true,
|
||||
"nodeReRegistrationTimeout": -1,
|
||||
"defaultClientScopes": [
|
||||
"web-origins",
|
||||
"acr",
|
||||
"roles",
|
||||
"profile",
|
||||
"email"
|
||||
],
|
||||
"optionalClientScopes": [
|
||||
"address",
|
||||
"phone",
|
||||
"offline_access",
|
||||
"microprofile-jwt"
|
||||
]
|
||||
}
|
||||
],
|
||||
"clientScopes": [
|
||||
|
||||
@@ -126,6 +126,61 @@ RECORDING_STORAGE_EVENT_TOKEN = <token>
|
||||
> Questions? Open an issue on [GitHub](https://github.com/suitenumerique/meet/issues/new?assignees=&labels=bug&template=Bug_report.md) or join our [Matrix community](https://matrix.to/#/#meet-official:matrix.org).
|
||||
|
||||
|
||||
## Push recordings to Drive
|
||||
|
||||
Once a recording is over, it can be pushed (streamed) to the user's [Drive](https://github.com/suitenumerique/drive), in addition to staying in the object storage.
|
||||
|
||||
Drive is called as a resource server, following its
|
||||
[resource server documentation](https://github.com/suitenumerique/drive/blob/main/docs/resource_server.md):
|
||||
|
||||
|
||||
### Special requirements
|
||||
|
||||
- Drive configured as an OIDC resource server, accepting Meet's audience
|
||||
(`OIDC_RS_ALLOWED_AUDIENCES` must contain Meet's client id), with the `items`
|
||||
endpoint allowing the `list`, `children` and `upload_ended` actions.
|
||||
- `OIDC_STORE_ACCESS_TOKEN` enabled on Meet, along with
|
||||
`OIDC_STORE_REFRESH_TOKEN_KEY`, the Fernet key encrypting the stored token.
|
||||
|
||||
> [!CAUTION]
|
||||
> This is a proof of concept: the access token is captured when the recording
|
||||
> starts and assumed to still be valid when the recording ends. Long recordings
|
||||
> may therefore fail to be pushed. Exchanging it for a long-lived, narrowly
|
||||
> scoped token is the intended follow-up.
|
||||
|
||||
### Configuration options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| ----------------------------------------------------- | ----------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **RECORDING_PUSH_TO_DRIVE_ENABLED** | Boolean | `False` | Enable pushing recordings to the owner's Drive. |
|
||||
| **DRIVE_API_BASE_URL** | String | `None` | Base URL of Drive's external API, e.g. `https://fichiers.numerique.gouv.fr/external_api/v1.0`. |
|
||||
| **RECORDING_PUSH_TO_DRIVE_SIGNED_URL_EXPIRY_SECONDS** | Integer | `3600` | Lifetime of the signed URL the worker downloads the recording from. |
|
||||
| **OIDC_STORE_ACCESS_TOKEN** | Boolean | `False` | Keep the user's access token in the session, required to call Drive on their behalf. |
|
||||
| **OIDC_STORE_REFRESH_TOKEN_KEY** | Secret/File | `None` | Fernet key encrypting OIDC tokens at rest. Generate one with `Fernet.generate_key()`. |
|
||||
| **DRIVE_UPLOAD_STORAGE_NETLOC** | String | `None` | Development only: `host:port` to reach Drive's object storage at, when the domain Drive signs its upload URLs with only resolves from a browser. |
|
||||
|
||||
### Local development
|
||||
|
||||
Meet and Drive run as two separate compose projects, joined by the external
|
||||
`lasuite-network` (`make create-docker-network`). Meet's backend containers reach
|
||||
Drive's nginx at `drive-nginx:8083` and its object storage at `drive-minio:9000`.
|
||||
|
||||
On the Drive side:
|
||||
|
||||
```bash
|
||||
OIDC_RESOURCE_SERVER_ENABLED=True
|
||||
OIDC_RS_CLIENT_ID=drive
|
||||
OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
OIDC_RS_AUDIENCE_CLAIM=client_id
|
||||
OIDC_RS_ALLOWED_AUDIENCES=meet
|
||||
```
|
||||
|
||||
`DRIVE_UPLOAD_STORAGE_NETLOC` is needed because Drive signs its upload URLs
|
||||
with `localhost:9100`, which does not resolve from Meet's containers. The
|
||||
presigned signature covers the `Host` header, so the backend keeps announcing the
|
||||
signed host and only swaps the address it connects to.
|
||||
|
||||
|
||||
## LiveKit Egress
|
||||
|
||||
La Suite Meet uses LiveKit Egress to record room sessions. For reference, see the [LiveKit Egress repository](https://github.com/livekit/egress) and the [official documentation](https://docs.livekit.io/home/egress/overview/).
|
||||
|
||||
@@ -24,7 +24,7 @@ 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_ENDPOINT_URL=http://meet-minio:9000
|
||||
AWS_S3_ACCESS_KEY_ID=meet
|
||||
AWS_S3_SECRET_ACCESS_KEY=password
|
||||
MEDIA_BASE_URL=http://localhost:3000
|
||||
@@ -50,6 +50,10 @@ LOGOUT_REDIRECT_URL=http://localhost:3000
|
||||
OIDC_REDIRECT_ALLOWED_HOSTS=localhost:8083,localhost:3000
|
||||
OIDC_AUTH_REQUEST_EXTRA_PARAMS={"acr_values": "eidas1"}
|
||||
|
||||
# The key below encrypts OIDC tokens at rest.
|
||||
OIDC_STORE_ACCESS_TOKEN=True
|
||||
OIDC_STORE_REFRESH_TOKEN_KEY=
|
||||
|
||||
OIDC_RS_CLIENT_ID=meet
|
||||
OIDC_RS_CLIENT_SECRET=ThisIsAnExampleKeyForDevPurposeOnly
|
||||
|
||||
@@ -63,13 +67,19 @@ ALLOW_UNREGISTERED_ROOMS=False
|
||||
|
||||
# Recording
|
||||
RECORDING_ENABLE=True
|
||||
RECORDING_STORAGE_EVENT_ENABLE=True
|
||||
RECORDING_STORAGE_EVENT_ENABLE=False
|
||||
RECORDING_STORAGE_EVENT_TOKEN=password
|
||||
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
|
||||
|
||||
# Push recordings to Drive
|
||||
# DRIVE_UPLOAD_STORAGE_NETLOC is a development-only workaround.
|
||||
RECORDING_PUSH_TO_DRIVE_ENABLED=True
|
||||
DRIVE_API_BASE_URL=http://drive-app-dev:8000/external_api/v1.0
|
||||
DRIVE_UPLOAD_STORAGE_NETLOC=drive-minio:9000
|
||||
|
||||
# Recording encoding (LiveKit Egress advanced options).
|
||||
# When RECORDING_ENCODING_ENABLED is False (default), LiveKit uses its built-in
|
||||
# H264_720P_30 preset (1280x720, 30fps, 3000 kbps). Enable and tune to reduce
|
||||
@@ -85,6 +95,10 @@ RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
|
||||
# Telephony
|
||||
ROOM_TELEPHONY_ENABLED=True
|
||||
|
||||
# RoomKit
|
||||
# ROOMKIT_ENABLED = True
|
||||
# ROOMKIT_SERVER_TO_SERVER_API_TOKEN = ThisIsAnExampleKeyForDevPurposeOnly
|
||||
|
||||
# Metadata
|
||||
METADATA_COLLECTOR_ENABLED=True
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ LIVEKIT_URL=ws://livekit:7880
|
||||
LIVEKIT_API_KEY=devkey
|
||||
LIVEKIT_API_SECRET=secret
|
||||
|
||||
AWS_S3_ENDPOINT_URL=minio:9000
|
||||
AWS_S3_ENDPOINT_URL=meet-minio:9000
|
||||
AWS_S3_ACCESS_KEY_ID=meet
|
||||
AWS_S3_SECRET_ACCESS_KEY=password
|
||||
AWS_STORAGE_BUCKET_NAME=meet-media-storage
|
||||
|
||||
@@ -2,7 +2,7 @@ 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="meet-minio:9000"
|
||||
AWS_S3_SECURE_ACCESS=false
|
||||
|
||||
AWS_S3_ACCESS_KEY_ID="meet"
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
publiccodeYmlVersion: 0.5.0
|
||||
name: LaSuite Meet
|
||||
applicationSuite: LaSuite
|
||||
url: https://github.com/suitenumerique/meet
|
||||
releaseDate: 2026-07-22
|
||||
platforms:
|
||||
- web
|
||||
organisation:
|
||||
name: DINUM
|
||||
uri: https://numerique.gouv.fr
|
||||
fundedBy:
|
||||
- name: Direction interministérielle du numérique (DINUM)
|
||||
uri: https://www.numerique.gouv.fr
|
||||
developmentStatus: stable
|
||||
softwareType: standalone/web
|
||||
intendedAudience:
|
||||
countries:
|
||||
- FR
|
||||
description:
|
||||
en:
|
||||
localisedName: LaSuite Meet
|
||||
shortDescription: "Open Source video conference solution, based on LiveKit"
|
||||
longDescription: "Open Source video conference application, based on LiveKit,
|
||||
Django and React. It is the official web video conference application of
|
||||
French Ministries."
|
||||
features:
|
||||
- Optimized for stability in large meetings (+100 p.)
|
||||
- Support for multiple screen sharing streams
|
||||
- Non-persistent, secure chat
|
||||
- Meeting recording
|
||||
- Meeting transcription & Summary
|
||||
- Telephony integration
|
||||
- Secure participation with robust authentication and access control
|
||||
- Customizable frontend style
|
||||
legal:
|
||||
license: MIT
|
||||
maintenance:
|
||||
type: internal
|
||||
contacts:
|
||||
- name: "Samuel Paccoud"
|
||||
email: samuel.paccoud@numerique.gouv.fr
|
||||
affiliation: DINUM
|
||||
- name: "Antoine Lebaud"
|
||||
email: antoine.lebaud.ext@numerique.gouv.fr
|
||||
affiliation: DINUM
|
||||
localisation:
|
||||
localisationReady: true
|
||||
availableLanguages:
|
||||
- fr
|
||||
- de
|
||||
- en
|
||||
- nl
|
||||
Generated
+5
-5
@@ -10,7 +10,7 @@
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-js": "3.49.0",
|
||||
"i18next": "^26.3.2",
|
||||
"i18next": "^26.3.6",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"regenerator-runtime": "0.14.1"
|
||||
},
|
||||
@@ -9364,9 +9364,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.3.2",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.2.tgz",
|
||||
"integrity": "sha512-QQkXAM1sPDHqhxMQuBeHVMUn6mJchF+wdpOoQerciLAFqO3ZYdxO0EUbeEhruyutnNwpUQIITDVzLjwnNL0T1w==",
|
||||
"version": "26.3.6",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
|
||||
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -9383,7 +9383,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"typescript": "^5 || ^6"
|
||||
"typescript": "^5 || ^6 || ^7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"core-js": "3.49.0",
|
||||
"i18next": "26.3.2",
|
||||
"i18next": "26.3.6",
|
||||
"i18next-browser-languagedetector": "8.2.1",
|
||||
"regenerator-runtime": "0.14.1"
|
||||
},
|
||||
|
||||
@@ -8,3 +8,6 @@ class AnalyticsEvent(StrEnum):
|
||||
|
||||
# Rooms
|
||||
ROOM_CREATED = "room_created"
|
||||
|
||||
# Roomkit (meeting-room SIP devices)
|
||||
ROOMKIT_JOINED = "roomkit_joined"
|
||||
|
||||
@@ -61,6 +61,9 @@ def get_frontend_configuration(request):
|
||||
],
|
||||
},
|
||||
"telephony": build_telephony_config(),
|
||||
"resource": {
|
||||
"default_access_level": settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
|
||||
},
|
||||
"subtitle": {"enabled": settings.ROOM_SUBTITLE_ENABLED},
|
||||
"livekit": {
|
||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||
|
||||
@@ -16,6 +16,7 @@ class FeatureFlag:
|
||||
"file_upload": "FILE_UPLOAD_ENABLED",
|
||||
"addons": "ADDONS_ENABLED",
|
||||
"application": "APPLICATION_ENABLED",
|
||||
"roomkit": "ROOMKIT_ENABLED",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -6,6 +6,11 @@ from django.http import Http404
|
||||
from rest_framework import permissions
|
||||
|
||||
from ..models import RoleChoices
|
||||
from ..services.participants_management import (
|
||||
ParticipantNotFoundException,
|
||||
ParticipantsManagement,
|
||||
ParticipantsManagementException,
|
||||
)
|
||||
|
||||
ACTION_FOR_METHOD_TO_PERMISSION = {
|
||||
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
|
||||
@@ -166,3 +171,30 @@ class CanMuteParticipant(permissions.BasePermission):
|
||||
|
||||
# LiveKit token scoped to this room
|
||||
return request.auth.video.room == str(obj.id)
|
||||
|
||||
|
||||
class IsPresentInMeeting(permissions.BasePermission):
|
||||
"""Check that the requesting user is currently connected to the meeting.
|
||||
|
||||
The requester must be session-authenticated (their DB identity is needed
|
||||
to check privileges); presence is verified against LiveKit using their
|
||||
`sub` as participant identity. Fails closed on LiveKit errors.
|
||||
"""
|
||||
|
||||
message = "You must be connected to the meeting to perform this action."
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
"""Verify the requester's identity is a participant of the room."""
|
||||
user = request.user
|
||||
|
||||
if not user or not user.is_authenticated:
|
||||
return False
|
||||
|
||||
try:
|
||||
return ParticipantsManagement().check_if_in_meeting(
|
||||
room_name=str(obj.pk), identity=str(user.sub)
|
||||
)
|
||||
except ParticipantNotFoundException:
|
||||
return False
|
||||
except ParticipantsManagementException:
|
||||
return False
|
||||
|
||||
@@ -31,9 +31,28 @@ class UserSerializer(serializers.ModelSerializer):
|
||||
|
||||
class Meta:
|
||||
model = models.User
|
||||
fields = ["id", "email", "full_name", "short_name", "timezone", "language"]
|
||||
fields = [
|
||||
"id",
|
||||
"email",
|
||||
"full_name",
|
||||
"short_name",
|
||||
"timezone",
|
||||
"language",
|
||||
"default_room_access_level",
|
||||
"default_room_configuration",
|
||||
]
|
||||
read_only_fields = ["id", "email", "full_name", "short_name"]
|
||||
|
||||
def validate_default_room_configuration(self, value):
|
||||
"""Validate the default room configuration against the RoomConfiguration schema."""
|
||||
if value is None or value == {}:
|
||||
return value
|
||||
try:
|
||||
RoomConfiguration.model_validate(value)
|
||||
except PydanticValidationError as e:
|
||||
raise serializers.ValidationError(e.errors()) from e
|
||||
return value
|
||||
|
||||
|
||||
class UserLightSerializer(serializers.ModelSerializer):
|
||||
"""Serialize users with limited fields."""
|
||||
@@ -183,13 +202,11 @@ class RoomSerializer(serializers.ModelSerializer):
|
||||
user=request.user,
|
||||
username=username,
|
||||
configuration=output["configuration"],
|
||||
is_admin_or_owner=is_admin_or_owner,
|
||||
role=role,
|
||||
)
|
||||
else:
|
||||
del output["pin_code"]
|
||||
|
||||
output["is_administrable"] = is_admin_or_owner
|
||||
|
||||
return output
|
||||
|
||||
|
||||
@@ -299,8 +316,8 @@ class RoomInviteSerializer(serializers.Serializer):
|
||||
class BaseParticipantsManagementSerializer(BaseValidationOnlySerializer):
|
||||
"""Base serializer for participant management operations."""
|
||||
|
||||
participant_identity = serializers.UUIDField(
|
||||
help_text="LiveKit participant identity (UUID format)"
|
||||
participant_identity = serializers.CharField(
|
||||
help_text="LiveKit participant identity (matching the user's sub format)"
|
||||
)
|
||||
|
||||
|
||||
@@ -312,6 +329,15 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
|
||||
)
|
||||
|
||||
|
||||
class ParticipantRoleSerializer(BaseParticipantsManagementSerializer):
|
||||
"""Validate an in-meeting role change (promotion/demotion) request."""
|
||||
|
||||
role = serializers.ChoiceField(
|
||||
choices=[models.RoleChoices.MEMBER, models.RoleChoices.ADMIN],
|
||||
help_text="Target role. Ownership cannot be granted this way.",
|
||||
)
|
||||
|
||||
|
||||
TrackSource = Literal["camera", "microphone", "screen_share", "screen_share_audio"]
|
||||
|
||||
|
||||
|
||||
@@ -73,3 +73,15 @@ class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
|
||||
"""Throttle Anonymous user requesting room generation callback"""
|
||||
|
||||
scope = "creation_callback"
|
||||
|
||||
|
||||
class RoomKitJoinRateThrottle(MonitoredUserRateThrottle):
|
||||
"""Throttle the LiveKit SIP module requesting roomkit joins.
|
||||
|
||||
The roomkit endpoints are authenticated as a machine user, so all requests
|
||||
share a single throttle bucket. This is not a security measure against
|
||||
brute-force attacks but a guard against accidental hammering from a buggy
|
||||
SIP module.
|
||||
"""
|
||||
|
||||
scope = "roomkit_join"
|
||||
|
||||
@@ -88,6 +88,10 @@ from core.services.room_management import (
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
from core.services.room_roles import (
|
||||
RoomRoleError,
|
||||
RoomRoleService,
|
||||
)
|
||||
from core.services.subtitle import SubtitleException, SubtitleService
|
||||
from core.tasks.file import process_file_deletion
|
||||
|
||||
@@ -304,8 +308,27 @@ class RoomViewSet(
|
||||
return drf_response.Response(serializer.data)
|
||||
|
||||
def perform_create(self, serializer):
|
||||
"""Set the current user as owner of the newly created room."""
|
||||
room = serializer.save()
|
||||
"""Set the current user as owner of the newly created room.
|
||||
|
||||
Apply the user's default room preferences (access level and configuration)
|
||||
unless the request explicitly provides its own values.
|
||||
"""
|
||||
user = self.request.user
|
||||
save_kwargs = {}
|
||||
|
||||
if (
|
||||
"access_level" not in serializer.validated_data
|
||||
and user.default_room_access_level not in (None, "")
|
||||
):
|
||||
save_kwargs["access_level"] = user.default_room_access_level
|
||||
|
||||
user_default_configuration = user.default_room_configuration
|
||||
if not serializer.validated_data.get(
|
||||
"configuration"
|
||||
) and user_default_configuration not in (None, {}):
|
||||
save_kwargs["configuration"] = user.default_room_configuration
|
||||
|
||||
room = serializer.save(**save_kwargs)
|
||||
models.ResourceAccess.objects.create(
|
||||
resource=room,
|
||||
user=self.request.user,
|
||||
@@ -360,6 +383,37 @@ class RoomViewSet(
|
||||
room.id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _store_drive_credentials(request, recording):
|
||||
"""Keep the OIDC access token needed to push the recording to Drive later.
|
||||
|
||||
Pushing happens long after this request, when the egress is over and the
|
||||
user may be gone, so the token has to be stored.
|
||||
|
||||
POC limitation: we assume the token is still valid by then. The target
|
||||
design is a token exchange performed here, to get a long-lived
|
||||
token narrowly scoped to that upload.
|
||||
"""
|
||||
|
||||
if not settings.RECORDING_PUSH_TO_DRIVE_ENABLED:
|
||||
return
|
||||
|
||||
if recording.mode != models.RecordingModeChoices.SCREEN_RECORDING:
|
||||
# Only videos are pushed to Drive, no need for a token otherwise.
|
||||
return
|
||||
|
||||
access_token = request.session.get("oidc_access_token")
|
||||
|
||||
if not access_token:
|
||||
logger.warning(
|
||||
"No OIDC access token in session, recording %s will not be pushed "
|
||||
"to Drive. Is OIDC_STORE_ACCESS_TOKEN enabled?",
|
||||
recording.id,
|
||||
)
|
||||
return
|
||||
|
||||
recording.set_owner_access_token(access_token)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
@@ -395,6 +449,7 @@ class RoomViewSet(
|
||||
role=models.RoleChoices.OWNER,
|
||||
recording=recording,
|
||||
)
|
||||
self._store_drive_credentials(request, recording)
|
||||
|
||||
except (DjangoValidationError, IntegrityError):
|
||||
# DjangoValidationError covers the Python-level check (full_clean);
|
||||
@@ -639,6 +694,53 @@ class RoomViewSet(
|
||||
status=drf_status.HTTP_200_OK,
|
||||
)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
url_path="update-participant-role",
|
||||
permission_classes=[
|
||||
permissions.HasPrivilegesOnRoom,
|
||||
permissions.IsPresentInMeeting,
|
||||
],
|
||||
)
|
||||
def update_participant_role(self, request, pk=None): # pylint: disable=unused-argument
|
||||
"""Promote or demote a participant currently connected to the meeting.
|
||||
|
||||
Requires the requester to be session-authenticated, have privileges
|
||||
(admin/owner) on the room, and be connected to the meeting.
|
||||
|
||||
If the target participant has a user account, the role is persisted
|
||||
(`ResourceAccess`) then mirrored to their LiveKit attributes.
|
||||
|
||||
If the participant is anonymous, the promotion will fail.
|
||||
"""
|
||||
|
||||
room = self.get_object()
|
||||
|
||||
serializer = serializers.ParticipantRoleSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
participant_identity = serializer.validated_data["participant_identity"]
|
||||
role = serializer.validated_data["role"]
|
||||
|
||||
if str(request.user.sub) == str(participant_identity):
|
||||
return drf_response.Response(
|
||||
{"error": "You cannot change your own role."},
|
||||
status=drf_status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
|
||||
try:
|
||||
result = RoomRoleService().set_participant_role(
|
||||
room=room,
|
||||
participant_identity=participant_identity,
|
||||
role=role,
|
||||
actor=request.user,
|
||||
)
|
||||
except RoomRoleError as e:
|
||||
return drf_response.Response({"error": str(e)}, status=e.status_code)
|
||||
|
||||
return drf_response.Response(result, status=drf_status.HTTP_200_OK)
|
||||
|
||||
@decorators.action(
|
||||
detail=True,
|
||||
methods=["post"],
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.14 on 2026-08-03 13:40
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('core', '0021_recording_external_process_id_alter_recording_status'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='default_room_access_level',
|
||||
field=models.CharField(blank=True, choices=[('public', 'Public Access'), ('trusted', 'Trusted Access'), ('restricted', 'Restricted Access')], help_text='Access level applied by default to new rooms created by this user. When empty, the instance default is used.', max_length=50, null=True, verbose_name='default room access level'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='user',
|
||||
name='default_room_configuration',
|
||||
field=models.JSONField(blank=True, default=dict, help_text='Configurations applied by default to new rooms created by this user.', verbose_name='default room configuration'),
|
||||
),
|
||||
]
|
||||
@@ -189,6 +189,25 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin):
|
||||
default=settings.TIME_ZONE,
|
||||
help_text=_("The timezone in which the user wants to see times."),
|
||||
)
|
||||
default_room_access_level = models.CharField(
|
||||
max_length=50,
|
||||
choices=RoomAccessLevel.choices,
|
||||
blank=True,
|
||||
null=True,
|
||||
verbose_name=_("default room access level"),
|
||||
help_text=_(
|
||||
"Access level applied by default to new rooms created by this user. "
|
||||
"When empty, the instance default is used."
|
||||
),
|
||||
)
|
||||
default_room_configuration = models.JSONField(
|
||||
blank=True,
|
||||
default=dict,
|
||||
verbose_name=_("default room configuration"),
|
||||
help_text=_(
|
||||
"Configurations applied by default to new rooms created by this user."
|
||||
),
|
||||
)
|
||||
is_device = models.BooleanField(
|
||||
_("device"),
|
||||
default=False,
|
||||
@@ -429,7 +448,14 @@ class Room(Resource):
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Generate a unique n-digit pin code for new rooms."""
|
||||
if settings.ROOM_TELEPHONY_ENABLED and not self.pk and not self.pin_code:
|
||||
|
||||
# Roomkit devices also join by PIN, so a PIN is needed as soon as
|
||||
# either integration is enabled.
|
||||
if (
|
||||
(settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED)
|
||||
and not self.pk
|
||||
and not self.pin_code
|
||||
):
|
||||
self.pin_code = self.generate_unique_pin_code(
|
||||
length=settings.ROOM_TELEPHONY_PIN_LENGTH
|
||||
)
|
||||
@@ -613,6 +639,17 @@ class Recording(BaseModel):
|
||||
verbose_name=_("External Process ID"),
|
||||
help_text=_("ID of the external process associated with the recording."),
|
||||
)
|
||||
owner_access_token = models.TextField(
|
||||
null=True,
|
||||
blank=True,
|
||||
editable=False,
|
||||
verbose_name=_("Owner access token"),
|
||||
help_text=_(
|
||||
"Encrypted OIDC access token of the user who started the recording, "
|
||||
"used to push the recording to their Drive on their behalf. "
|
||||
"Dropped as soon as the push has been attempted."
|
||||
),
|
||||
)
|
||||
|
||||
class Meta:
|
||||
db_table = "meet_recording"
|
||||
@@ -715,6 +752,39 @@ class Recording(BaseModel):
|
||||
|
||||
return self.expired_at < timezone.now()
|
||||
|
||||
def set_owner_access_token(self, access_token: str) -> None:
|
||||
"""Store the OIDC access token of the user who started the recording.
|
||||
|
||||
It is stored encrypted, and only long enough for the worker to push the
|
||||
recording to that user's Drive once the recording is over.
|
||||
"""
|
||||
|
||||
self.owner_access_token = utils.encrypt_secret(access_token)
|
||||
self.save(update_fields=["owner_access_token", "updated_at"])
|
||||
|
||||
def get_owner_access_token(self) -> Optional[str]:
|
||||
"""Return the stored OIDC access token, or None if there is none left."""
|
||||
|
||||
if not self.owner_access_token:
|
||||
return None
|
||||
|
||||
try:
|
||||
return utils.decrypt_secret(self.owner_access_token)
|
||||
except utils.SecretDecryptionError:
|
||||
logger.exception(
|
||||
"Could not decrypt the access token of recording %s", self.id
|
||||
)
|
||||
return None
|
||||
|
||||
def clear_owner_access_token(self) -> None:
|
||||
"""Drop the stored access token, it is a user credential."""
|
||||
|
||||
if self.owner_access_token is None:
|
||||
return
|
||||
|
||||
self.owner_access_token = None
|
||||
self.save(update_fields=["owner_access_token", "updated_at"])
|
||||
|
||||
|
||||
class RecordingAccess(BaseAccess):
|
||||
"""Relation model to give access to a recording for a user or a team with a role."""
|
||||
|
||||
@@ -19,6 +19,7 @@ from livekit import api as livekit_api
|
||||
|
||||
from core import models, utils
|
||||
from core.analytics import UserFeatureFlag, is_user_feature_flag_enabled
|
||||
from core.tasks.push_recording import push_recording
|
||||
from core.utils import generate_download_s3_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -45,7 +46,15 @@ class NotificationService:
|
||||
"""Service for processing recordings and notifying external services."""
|
||||
|
||||
def notify_external_services(self, recording):
|
||||
"""Process a recording based on its mode."""
|
||||
"""Process a recording, then push the video to the owner's Drive."""
|
||||
|
||||
try:
|
||||
return self._notify_by_mode(recording)
|
||||
finally:
|
||||
self._push_recording_to_drive(recording)
|
||||
|
||||
def _notify_by_mode(self, recording):
|
||||
"""Route a recording to the services its mode calls for."""
|
||||
|
||||
if recording.mode == models.RecordingModeChoices.TRANSCRIPT:
|
||||
return self._notify_summary_service(recording)
|
||||
@@ -222,6 +231,31 @@ class NotificationService:
|
||||
f"Unknown summary service version: {settings.SUMMARY_SERVICE_VERSION}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _push_recording_to_drive(recording: models.Recording):
|
||||
"""Hand the recording over to the task pushing it to the owner's Drive."""
|
||||
|
||||
if not settings.RECORDING_PUSH_TO_DRIVE_ENABLED:
|
||||
return
|
||||
|
||||
if recording.mode != models.RecordingModeChoices.SCREEN_RECORDING:
|
||||
recording.clear_owner_access_token()
|
||||
return
|
||||
|
||||
if not recording.owner_access_token:
|
||||
logger.warning(
|
||||
"No access token stored for recording %s, skipping the Drive push",
|
||||
recording.id,
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
push_recording.delay(str(recording.id))
|
||||
except Exception: # pylint: disable=broad-except
|
||||
logger.exception(
|
||||
"Could not schedule the Drive push of recording %s", recording.id
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _notify_summary_service_v1(recording: models.Recording):
|
||||
"""Notify summary service about a new recording."""
|
||||
|
||||
@@ -9,6 +9,11 @@ from livekit import api
|
||||
from core import models, utils
|
||||
from core.models import Recording
|
||||
from core.recording.event.notification import notification_service
|
||||
from core.services.room_management import (
|
||||
RoomManagement,
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
@@ -39,10 +44,15 @@ class RecordingEventsService:
|
||||
recording_status = status_mapping.get(egress_status)
|
||||
if recording_status:
|
||||
try:
|
||||
utils.update_room_metadata(
|
||||
RoomManagement().update_metadata(
|
||||
room_name, {"recording_status": recording_status}
|
||||
)
|
||||
except utils.MetadataUpdateException as e:
|
||||
except RoomNotFoundException:
|
||||
logger.info(
|
||||
"LiveKit room %s no longer exists, skipping metadata update",
|
||||
room_name,
|
||||
)
|
||||
except RoomManagementException as e:
|
||||
logger.exception("Failed to update room's metadata: %s", e)
|
||||
|
||||
@staticmethod
|
||||
@@ -97,4 +107,4 @@ class RecordingEventsService:
|
||||
if notification_succeeded
|
||||
else models.RecordingStatusChoices.SAVED
|
||||
)
|
||||
recording.save()
|
||||
recording.save(update_fields=["status", "updated_at"])
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
|
||||
import logging
|
||||
|
||||
from core import utils
|
||||
from core.models import Recording, RecordingStatusChoices
|
||||
from core.services.room_management import (
|
||||
RoomManagement,
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
|
||||
from .exceptions import (
|
||||
RecordingStartError,
|
||||
@@ -64,10 +68,15 @@ class WorkerServiceMediator:
|
||||
mode = recording.options.get("original_mode", None) or recording.mode
|
||||
|
||||
try:
|
||||
utils.update_room_metadata(
|
||||
RoomManagement().update_metadata(
|
||||
room_name, {"recording_mode": mode, "recording_status": "starting"}
|
||||
)
|
||||
except utils.MetadataUpdateException as e:
|
||||
except RoomNotFoundException:
|
||||
logger.info(
|
||||
"LiveKit room %s no longer exists, skipping metadata update",
|
||||
room_name,
|
||||
)
|
||||
except RoomManagementException as e:
|
||||
logger.exception("Failed to update room's metadata: %s", e)
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Meet core roomkit API endpoints for meeting-room (SIP) device integration."""
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Authentication for the roomkit API of the Meet core app."""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from rest_framework.authentication import BaseAuthentication
|
||||
from rest_framework.exceptions import AuthenticationFailed
|
||||
|
||||
from core.recording.event.authentication import MachineUser
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ServerToServerAuthentication(BaseAuthentication):
|
||||
"""Custom authentication class for roomkit server-to-server requests.
|
||||
|
||||
Validates the Authorization header against the roomkit server-to-server
|
||||
token. A valid PIN code is intentionally not enough to authenticate: the
|
||||
endpoints are restricted to the LiveKit SIP module's credentials.
|
||||
"""
|
||||
|
||||
AUTH_HEADER = "Authorization"
|
||||
TOKEN_TYPE = "Bearer" # noqa S105
|
||||
|
||||
def authenticate(self, request):
|
||||
"""Validate the Bearer token from the Authorization header.
|
||||
|
||||
Returns a (MachineUser, token) pair on success, and raises
|
||||
AuthenticationFailed if the header is missing, malformed, or contains
|
||||
an invalid token.
|
||||
"""
|
||||
required_token = settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN
|
||||
if not required_token:
|
||||
raise AuthenticationFailed("Server-to-server token is not configured.")
|
||||
|
||||
auth_header = request.headers.get(self.AUTH_HEADER)
|
||||
if not auth_header:
|
||||
logger.warning(
|
||||
"Roomkit authentication failed: missing Authorization header (ip: %s)",
|
||||
request.META.get("REMOTE_ADDR"),
|
||||
)
|
||||
raise AuthenticationFailed("Authorization header is missing.")
|
||||
|
||||
# Validate token format and existence
|
||||
auth_parts = auth_header.split(" ")
|
||||
if len(auth_parts) != 2 or auth_parts[0] != self.TOKEN_TYPE:
|
||||
raise AuthenticationFailed("Invalid authorization header.")
|
||||
|
||||
token = auth_parts[1]
|
||||
|
||||
# Use constant-time comparison to prevent timing attacks
|
||||
if not secrets.compare_digest(token.encode(), required_token.encode()):
|
||||
logger.warning(
|
||||
"Roomkit authentication failed: invalid token (ip: %s)",
|
||||
request.META.get("REMOTE_ADDR"),
|
||||
)
|
||||
raise AuthenticationFailed("Invalid server-to-server token.")
|
||||
|
||||
return MachineUser(username="roomkit"), token
|
||||
|
||||
def authenticate_header(self, request):
|
||||
"""Return the WWW-Authenticate header value."""
|
||||
return f"{self.TOKEN_TYPE} realm='Roomkit server to server'"
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Serializers for the roomkit API of the Meet core app."""
|
||||
|
||||
# pylint: disable=abstract-method
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from core.api.serializers import BaseValidationOnlySerializer
|
||||
|
||||
|
||||
class RoomKitJoinSerializer(BaseValidationOnlySerializer):
|
||||
"""Validate roomkit join requests from the LiveKit SIP module."""
|
||||
|
||||
pin_code = serializers.CharField(required=True)
|
||||
|
||||
def validate_pin_code(self, value):
|
||||
"""Ensure the PIN code matches the configured length."""
|
||||
if len(value) != settings.ROOM_TELEPHONY_PIN_LENGTH:
|
||||
raise serializers.ValidationError("PIN code length is invalid.")
|
||||
return value
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Roomkit API endpoints for meeting-room (SIP) device integration."""
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
from rest_framework import decorators, viewsets
|
||||
from rest_framework import (
|
||||
exceptions as drf_exceptions,
|
||||
)
|
||||
from rest_framework import (
|
||||
response as drf_response,
|
||||
)
|
||||
from rest_framework import (
|
||||
status as drf_status,
|
||||
)
|
||||
|
||||
from core import analytics, models
|
||||
from core.api import permissions, throttling
|
||||
from core.api.feature_flag import FeatureFlag
|
||||
from core.services.sip_management import SIPException, SIPManagement
|
||||
|
||||
from . import authentication, serializers
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class RoomKitViewSet(viewsets.ViewSet):
|
||||
"""Server-to-server API endpoints for the roomkit integration.
|
||||
|
||||
Groups all interactions between roomkit (SIP) devices and the backend,
|
||||
brokered by the LiveKit SIP module. All endpoints are authenticated
|
||||
with the roomkit server-to-server tokens.
|
||||
"""
|
||||
|
||||
authentication_classes = [authentication.ServerToServerAuthentication]
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
@decorators.action(
|
||||
detail=False,
|
||||
methods=["post"],
|
||||
url_path="join",
|
||||
throttle_classes=[throttling.RoomKitJoinRateThrottle],
|
||||
)
|
||||
@FeatureFlag.require("roomkit")
|
||||
def join(self, request):
|
||||
"""Prepare a room for a meeting-room (SIP) device joining by PIN code.
|
||||
|
||||
Called by the LiveKit SIP module when a meeting-room device dials in
|
||||
with a PIN code before any WebRTC participant has joined. Resolves the
|
||||
room by PIN and creates its SIP dispatch rule, so the device can enter
|
||||
without waiting for a WebRTC user.
|
||||
|
||||
The webhook-based creation path is kept: both converge on the same rule
|
||||
through the shared SIPManagement.
|
||||
"""
|
||||
|
||||
serializer = serializers.RoomKitJoinSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
try:
|
||||
room = models.Room.objects.get(
|
||||
pin_code=serializer.validated_data["pin_code"]
|
||||
)
|
||||
except models.Room.DoesNotExist as e:
|
||||
raise drf_exceptions.NotFound("No room found for this PIN code.") from e
|
||||
|
||||
try:
|
||||
created = SIPManagement().ensure_dispatch_rule(room)
|
||||
except SIPException as e:
|
||||
raise drf_exceptions.APIException("Could not create dispatch rule.") from e
|
||||
|
||||
analytics.capture(
|
||||
request.user,
|
||||
analytics.AnalyticsEvent.ROOMKIT_JOINED,
|
||||
{
|
||||
"room_id": str(room.pk),
|
||||
"dispatch_rule_created": created,
|
||||
},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Roomkit join requested: room_id=%s, dispatch_rule_created=%s",
|
||||
room.id,
|
||||
created,
|
||||
)
|
||||
|
||||
return drf_response.Response(
|
||||
{"status": "success"},
|
||||
status=drf_status.HTTP_200_OK,
|
||||
)
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Client for La Suite Drive's external API (OIDC resource server).
|
||||
|
||||
Drive exposes `/external_api/v1.0/*` to applications holding an user's OIDC
|
||||
access token.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# (connect, read) timeouts, in seconds. The upload one covers a
|
||||
# whole recording being relayed to Drive's object storage.
|
||||
API_TIMEOUT = (10, 30)
|
||||
UPLOAD_TIMEOUT = (10, 1800)
|
||||
|
||||
|
||||
class DriveError(Exception):
|
||||
"""Raised when Drive's external API cannot fulfill a request."""
|
||||
|
||||
|
||||
class SizedStream:
|
||||
"""Read-only byte stream of a known size, suitable as a `requests` body.
|
||||
|
||||
`requests` falls back to a chunked transfer encoding when it cannot guess the
|
||||
body size upfront, which presigned S3 uploads reject. Advertising the size
|
||||
through `__len__` makes it send a plain `Content-Length` instead, while the
|
||||
underlying stream is still consumed chunk by chunk.
|
||||
"""
|
||||
|
||||
def __init__(self, stream, length: int):
|
||||
"""Wrap `stream`, whose full content is `length` bytes long."""
|
||||
self._stream = stream
|
||||
self._length = length
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Return the total size of the stream, in bytes."""
|
||||
return self._length
|
||||
|
||||
def __iter__(self):
|
||||
"""Iterate over the stream, required for `requests` to stream the body."""
|
||||
return iter(self._stream)
|
||||
|
||||
def read(self, amt=None) -> bytes:
|
||||
"""Read up to `amt` bytes from the stream."""
|
||||
return self._stream.read(amt)
|
||||
|
||||
|
||||
class DriveClient:
|
||||
"""Access Drive's external API on behalf of a user.
|
||||
|
||||
The client is bound to a single user access token: every call is performed
|
||||
as that user, and Drive applies its own permissions accordingly.
|
||||
"""
|
||||
|
||||
def __init__(self, access_token: str, *, base_url: str | None = None):
|
||||
"""Prepare a session authenticated with the user's OIDC access token."""
|
||||
|
||||
self._base_url = (base_url or settings.DRIVE_API_BASE_URL or "").rstrip("/")
|
||||
|
||||
if not self._base_url:
|
||||
raise DriveError(
|
||||
"Drive API is not configured, set DRIVE_API_BASE_URL to enable it."
|
||||
)
|
||||
|
||||
if not access_token:
|
||||
raise DriveError("An access token is required to call Drive.")
|
||||
|
||||
self._session = requests.Session()
|
||||
self._session.headers.update(
|
||||
{
|
||||
"Authorization": f"Bearer {access_token}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
)
|
||||
|
||||
def __enter__(self):
|
||||
"""Allow use as a context manager, closing the session on exit."""
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
"""Close the underlying HTTP session."""
|
||||
self.close()
|
||||
|
||||
def close(self):
|
||||
"""Release the underlying HTTP session."""
|
||||
self._session.close()
|
||||
|
||||
def _request(self, method, path, **kwargs):
|
||||
"""Perform an authenticated call to the external API and return its body."""
|
||||
|
||||
url = f"{self._base_url}{path}"
|
||||
kwargs.setdefault("timeout", API_TIMEOUT)
|
||||
|
||||
try:
|
||||
response = self._session.request(method, url, **kwargs)
|
||||
response.raise_for_status()
|
||||
except requests.RequestException as exc:
|
||||
raise DriveError(f"Drive call failed: {method} {url}") from exc
|
||||
|
||||
if not response.content:
|
||||
return None
|
||||
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError as exc:
|
||||
raise DriveError(f"Drive returned a non-JSON body for {url}") from exc
|
||||
|
||||
def create_file(self, *, filename: str) -> dict:
|
||||
"""Create a file item at the root of the user's Drive and return it.
|
||||
|
||||
The returned item carries a `policy`: the presigned URL the content has
|
||||
to be uploaded to.
|
||||
"""
|
||||
|
||||
item = self._request(
|
||||
"POST",
|
||||
"/items/",
|
||||
json={"type": "file", "filename": filename},
|
||||
)
|
||||
|
||||
if not item or not item.get("policy"):
|
||||
raise DriveError(
|
||||
f"Drive did not return an upload policy for file '{filename}'."
|
||||
)
|
||||
|
||||
return item
|
||||
|
||||
@staticmethod
|
||||
def _resolve_upload_target(policy_url: str) -> tuple[str, str | None]:
|
||||
"""Return the address to connect to, and the `Host` header to send.
|
||||
|
||||
Drive signs its upload URLs with the object storage domain meant for
|
||||
browsers, which may not resolve from dev split docker compose setup.
|
||||
The signature covers the `Host` header, so we swap the address we connect
|
||||
to but keep announcing the original host.
|
||||
"""
|
||||
|
||||
override = settings.DRIVE_UPLOAD_STORAGE_NETLOC
|
||||
|
||||
if not override:
|
||||
return policy_url, None
|
||||
|
||||
parsed = urlparse(policy_url)
|
||||
return urlunparse(parsed._replace(netloc=override)), parsed.netloc
|
||||
|
||||
def upload_content(self, *, policy_url: str, stream, content_length, content_type):
|
||||
"""Push `stream` to the presigned URL, without buffering it as a whole."""
|
||||
|
||||
url, host_header = self._resolve_upload_target(policy_url)
|
||||
|
||||
headers = {
|
||||
"Content-Type": content_type,
|
||||
"Content-Length": str(content_length),
|
||||
"x-amz-acl": "private",
|
||||
}
|
||||
|
||||
if host_header:
|
||||
headers["Host"] = host_header
|
||||
|
||||
try:
|
||||
# A bare `requests.put`, not the authenticated session: the presigned
|
||||
# URL carries its own credentials
|
||||
response = requests.put(
|
||||
url,
|
||||
data=SizedStream(stream, content_length),
|
||||
headers=headers,
|
||||
timeout=UPLOAD_TIMEOUT,
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.RequestException as exc:
|
||||
raise DriveError("Upload to Drive's object storage failed.") from exc
|
||||
|
||||
def complete_upload(self, item_id: str) -> None:
|
||||
"""Inform Drive that the upload is over, making the file available."""
|
||||
|
||||
self._request("POST", f"/items/{item_id}/upload-ended/", json={})
|
||||
@@ -11,7 +11,7 @@ from django.conf import settings
|
||||
|
||||
from livekit import api
|
||||
|
||||
from core import models, utils
|
||||
from core import models
|
||||
from core.recording.services.metadata_collector import (
|
||||
MetadataCollectorException,
|
||||
MetadataCollectorService,
|
||||
@@ -23,7 +23,12 @@ from core.recording.services.recording_events import (
|
||||
)
|
||||
|
||||
from .lobby import LobbyService
|
||||
from .telephony import TelephonyException, TelephonyService
|
||||
from .room_management import (
|
||||
RoomManagement,
|
||||
RoomManagementException,
|
||||
RoomNotFoundException,
|
||||
)
|
||||
from .sip_management import SIPException, SIPManagement
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
@@ -102,7 +107,7 @@ class LiveKitEventsService:
|
||||
)
|
||||
self.webhook_receiver = api.WebhookReceiver(token_verifier)
|
||||
self.lobby_service = LobbyService()
|
||||
self.telephony_service = TelephonyService()
|
||||
self.sip_management = SIPManagement()
|
||||
self.recording_events = RecordingEventsService()
|
||||
|
||||
self._filter_regex = None
|
||||
@@ -177,10 +182,15 @@ class LiveKitEventsService:
|
||||
|
||||
try:
|
||||
room_name = str(recording.room.id)
|
||||
utils.update_room_metadata(
|
||||
room_name, {}, ["recording_mode", "recording_status"]
|
||||
RoomManagement().update_metadata(
|
||||
room_name, remove_keys=["recording_mode", "recording_status"]
|
||||
)
|
||||
except utils.MetadataUpdateException as e:
|
||||
except RoomNotFoundException:
|
||||
logger.info(
|
||||
"LiveKit room %s no longer exists, skipping metadata update",
|
||||
room_name,
|
||||
)
|
||||
except RoomManagementException as e:
|
||||
logger.exception("Failed to update room's metadata: %s", e)
|
||||
|
||||
if recording.options.get("metadata_collector_dispatch_id", None) is not None:
|
||||
@@ -235,12 +245,12 @@ class LiveKitEventsService:
|
||||
except models.Room.DoesNotExist as err:
|
||||
raise ActionFailedError(f"Room with ID {room_id} does not exist") from err
|
||||
|
||||
if settings.ROOM_TELEPHONY_ENABLED:
|
||||
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
|
||||
try:
|
||||
self.telephony_service.create_dispatch_rule(room)
|
||||
except TelephonyException as e:
|
||||
self.sip_management.ensure_dispatch_rule(room)
|
||||
except SIPException as e:
|
||||
raise ActionFailedError(
|
||||
f"Failed to create telephony dispatch rule for room {room_id}"
|
||||
f"Failed to create sip dispatch rule for room {room_id}"
|
||||
) from e
|
||||
|
||||
def _handle_room_finished(self, data):
|
||||
@@ -255,12 +265,12 @@ class LiveKitEventsService:
|
||||
)
|
||||
raise ActionFailedError("Failed to process room finished event") from e
|
||||
|
||||
if settings.ROOM_TELEPHONY_ENABLED:
|
||||
if settings.ROOM_TELEPHONY_ENABLED or settings.ROOMKIT_ENABLED:
|
||||
try:
|
||||
self.telephony_service.delete_dispatch_rule(room_id)
|
||||
except TelephonyException as e:
|
||||
self.sip_management.delete_dispatch_rule(room_id)
|
||||
except SIPException as e:
|
||||
raise ActionFailedError(
|
||||
f"Failed to delete telephony dispatch rule for room {room_id}"
|
||||
f"Failed to delete sip dispatch rule for room {room_id}"
|
||||
) from e
|
||||
|
||||
try:
|
||||
|
||||
@@ -104,21 +104,30 @@ class LobbyService:
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def can_bypass_lobby(room, user) -> bool:
|
||||
def can_bypass_lobby(room, user, role) -> bool:
|
||||
"""Determines if a user can bypass the waiting lobby and join a room directly.
|
||||
|
||||
A user can bypass the lobby if:
|
||||
1. The room is public (open to everyone)
|
||||
2. The room has TRUSTED access level and the user is authenticated
|
||||
2. The room has RESTRICTED access level and the user has any role
|
||||
|
||||
Note: Room access levels can change while participants are waiting in the lobby.
|
||||
This function only checks the current state and should be called each time
|
||||
a participant requests entry to ensure consistent access control, even for
|
||||
participants who have already begun waiting.
|
||||
"""
|
||||
return room.is_public or (
|
||||
room.access_level == models.RoomAccessLevel.TRUSTED
|
||||
and user.is_authenticated
|
||||
return (
|
||||
room.is_public
|
||||
or (
|
||||
room.access_level == models.RoomAccessLevel.TRUSTED
|
||||
and user.is_authenticated
|
||||
)
|
||||
or (
|
||||
room.access_level == models.RoomAccessLevel.RESTRICTED
|
||||
and user.is_authenticated
|
||||
and role is not None
|
||||
)
|
||||
)
|
||||
|
||||
def request_entry(
|
||||
@@ -144,8 +153,9 @@ class LobbyService:
|
||||
participant = self._get_participant(room.id, participant_id)
|
||||
|
||||
room_id = str(room.id)
|
||||
user_role = room.get_role(request.user)
|
||||
|
||||
if self.can_bypass_lobby(room=room, user=request.user):
|
||||
if self.can_bypass_lobby(room=room, user=request.user, role=user_role):
|
||||
if participant is None:
|
||||
participant = LobbyParticipant(
|
||||
status=LobbyParticipantStatus.ACCEPTED,
|
||||
@@ -162,8 +172,8 @@ class LobbyService:
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id=participant_id,
|
||||
role=user_role,
|
||||
)
|
||||
return participant, livekit_config
|
||||
|
||||
@@ -183,8 +193,8 @@ class LobbyService:
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id=participant_id,
|
||||
role=user_role,
|
||||
)
|
||||
|
||||
return participant, livekit_config
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Dict, Optional
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit.api import (
|
||||
ListRoomsRequest,
|
||||
TwirpError,
|
||||
UpdateRoomMetadataRequest,
|
||||
)
|
||||
@@ -29,20 +30,45 @@ class RoomManagement:
|
||||
"""Service for managing LiveKit rooms."""
|
||||
|
||||
@async_to_sync
|
||||
async def update_metadata(self, room_name: str, metadata: Optional[Dict] = None):
|
||||
"""Update a LiveKit room's metadata.
|
||||
async def update_metadata(
|
||||
self,
|
||||
room_name: str,
|
||||
metadata: Optional[Dict] = None,
|
||||
remove_keys: Optional[list[str]] = None,
|
||||
):
|
||||
"""Merge values into a LiveKit room's metadata.
|
||||
|
||||
The `room_name` corresponds to the LiveKit room identifier
|
||||
(i.e. the Room model's UUID as a string).
|
||||
|
||||
Raises:
|
||||
RoomNotFoundException: the room does not exist in LiveKit.
|
||||
RoomManagementException: the metadata update otherwise fails.
|
||||
"""
|
||||
|
||||
lkapi = utils.create_livekit_client()
|
||||
|
||||
try:
|
||||
response = await lkapi.room.list_rooms(ListRoomsRequest(names=[room_name]))
|
||||
|
||||
if not response.rooms:
|
||||
logger.warning(
|
||||
"Room %s not found in LiveKit, skipping metadata update",
|
||||
room_name,
|
||||
)
|
||||
raise RoomNotFoundException("Room does not exist")
|
||||
|
||||
existing_metadata = json.loads(response.rooms[0].metadata or "{}")
|
||||
|
||||
for key in remove_keys or []:
|
||||
existing_metadata.pop(key, None)
|
||||
|
||||
updated_metadata = {**existing_metadata, **(metadata or {})}
|
||||
|
||||
await lkapi.room.update_room_metadata(
|
||||
UpdateRoomMetadataRequest(
|
||||
room=room_name,
|
||||
metadata=json.dumps(metadata) if metadata is not None else "",
|
||||
metadata=json.dumps(updated_metadata),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Room role management service.
|
||||
|
||||
Single entry point for changing a user's role on a room, used by:
|
||||
- the in-meeting endpoint (promote/demote a connected participant)
|
||||
- (more to come soon)
|
||||
|
||||
`ResourceAccess` is the source of truth. The LiveKit `room_role`
|
||||
participant attribute is only a projection of it, synced best-effort.
|
||||
"""
|
||||
|
||||
from logging import getLogger
|
||||
from uuid import UUID
|
||||
|
||||
from core import models
|
||||
from core.services.participants_management import (
|
||||
ParticipantNotFoundException,
|
||||
ParticipantsManagement,
|
||||
ParticipantsManagementException,
|
||||
)
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class RoomRoleError(Exception):
|
||||
"""Base exception for room role management errors."""
|
||||
|
||||
status_code = 400
|
||||
|
||||
|
||||
class SelfActionError(RoomRoleError):
|
||||
"""Raised when a user tries to change their own role."""
|
||||
|
||||
status_code = 403
|
||||
|
||||
|
||||
class OwnerRoleError(RoomRoleError):
|
||||
"""Raised when trying to demote an owner or grant ownership."""
|
||||
|
||||
status_code = 403
|
||||
|
||||
|
||||
class ParticipantNotInMeetingError(RoomRoleError):
|
||||
"""Raised when the target participant is not connected to the meeting."""
|
||||
|
||||
status_code = 404
|
||||
|
||||
|
||||
class UserNotFoundError(RoomRoleError):
|
||||
"""Raised when the target participant has no user account in database."""
|
||||
|
||||
status_code = 404
|
||||
|
||||
|
||||
ASSIGNABLE_ROLES = (models.RoleChoices.MEMBER, models.RoleChoices.ADMIN)
|
||||
|
||||
|
||||
class RoomRoleService:
|
||||
"""Manage promotion and demotion of room co-hosts."""
|
||||
|
||||
def set_role(
|
||||
self, room: models.Room, user: models.User, role: str, actor: models.User
|
||||
):
|
||||
"""Persist `role` for `user` on `room`, idempotently and atomically.
|
||||
|
||||
Returns the up-to-date `ResourceAccess`. Never grants or removes
|
||||
ownership: granting OWNER is refused, and an existing OWNER access
|
||||
is never modified.
|
||||
"""
|
||||
|
||||
if role not in ASSIGNABLE_ROLES:
|
||||
raise OwnerRoleError("Ownership cannot be granted through this action.")
|
||||
|
||||
if actor is not None and user == actor:
|
||||
raise SelfActionError("You cannot change your own role.")
|
||||
|
||||
access, created = models.ResourceAccess.objects.get_or_create(
|
||||
resource=room,
|
||||
user=user,
|
||||
defaults={"role": role},
|
||||
)
|
||||
|
||||
if created:
|
||||
return access
|
||||
|
||||
if access.role == models.RoleChoices.OWNER:
|
||||
raise OwnerRoleError("Room owners cannot be demoted.")
|
||||
|
||||
if access.role != role:
|
||||
access.role = role
|
||||
access.save(update_fields=["role", "updated_at"])
|
||||
|
||||
return access
|
||||
|
||||
def set_participant_role(
|
||||
self,
|
||||
room: models.Room,
|
||||
participant_identity: UUID,
|
||||
role: str,
|
||||
actor: models.User,
|
||||
):
|
||||
"""Change the role of a participant currently connected to the meeting.
|
||||
|
||||
- The participant must be connected (checked against LiveKit).
|
||||
- The participant must map to a user account.
|
||||
- The role is persisted in DB then mirrored to LiveKit.
|
||||
|
||||
Returns a dict: {"role", "livekit_synced"}.
|
||||
"""
|
||||
|
||||
room_name = str(room.pk)
|
||||
participants_management = ParticipantsManagement()
|
||||
|
||||
try:
|
||||
is_in_meeting = participants_management.check_if_in_meeting(
|
||||
room_name=room_name, identity=str(participant_identity)
|
||||
)
|
||||
except ParticipantNotFoundException as e:
|
||||
raise ParticipantNotInMeetingError(
|
||||
"Participant is not connected to this meeting."
|
||||
) from e
|
||||
|
||||
if not is_in_meeting:
|
||||
raise ParticipantNotInMeetingError(
|
||||
"Participant is not connected to this meeting."
|
||||
)
|
||||
|
||||
user = models.User.objects.filter(sub=participant_identity).first()
|
||||
|
||||
if user is None:
|
||||
raise UserNotFoundError(
|
||||
"This participant has no user account and cannot be assigned a role."
|
||||
)
|
||||
|
||||
# Source of truth first: even if the LiveKit sync below fails,
|
||||
# the role is real and any fresh token will carry it.
|
||||
self.set_role(room=room, user=user, role=role, actor=actor)
|
||||
|
||||
livekit_synced = self._sync_livekit_role(
|
||||
room_name=room_name,
|
||||
participant_identity=str(participant_identity),
|
||||
role=str(role),
|
||||
)
|
||||
|
||||
return {
|
||||
"role": role,
|
||||
"livekit_synced": livekit_synced,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _sync_livekit_role(room_name: str, participant_identity: str, role: str):
|
||||
"""Mirror the role to the participant's LiveKit attributes.
|
||||
|
||||
Best-effort: returns False on failure instead of raising, so callers
|
||||
can report a partial success. Re-running the action re-syncs.
|
||||
"""
|
||||
try:
|
||||
ParticipantsManagement().update(
|
||||
room_name=room_name,
|
||||
identity=participant_identity,
|
||||
attributes={"room_role": role},
|
||||
)
|
||||
except ParticipantNotFoundException:
|
||||
# The participant left between the presence check and the update:
|
||||
# harmless, the DB state (if any) remains authoritative.
|
||||
logger.info(
|
||||
"Participant %s left room %s before role sync",
|
||||
participant_identity,
|
||||
room_name,
|
||||
)
|
||||
return False
|
||||
except ParticipantsManagementException:
|
||||
logger.exception(
|
||||
"Could not sync role to LiveKit for participant %s in room %s",
|
||||
participant_identity,
|
||||
room_name,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
+38
-10
@@ -1,9 +1,9 @@
|
||||
"""Telephony service for managing SIP dispatch rules for room access."""
|
||||
"""SIP management service for managing SIP dispatch rules for room access."""
|
||||
|
||||
from logging import getLogger
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from livekit.api import TwirpError
|
||||
from livekit.api import TwirpError, TwirpErrorCode
|
||||
from livekit.protocol.sip import (
|
||||
CreateSIPDispatchRuleRequest,
|
||||
DeleteSIPDispatchRuleRequest,
|
||||
@@ -17,12 +17,16 @@ from core import utils
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class TelephonyException(Exception):
|
||||
"""Exception raised when telephony operations fail."""
|
||||
class SIPException(Exception):
|
||||
"""Exception raised when SIP operations fail."""
|
||||
|
||||
|
||||
class TelephonyService:
|
||||
"""Service for managing participant access through the telephony system (SIP)."""
|
||||
class DispatchRuleConflictError(SIPException):
|
||||
"""Raised when a dispatch rule already exists for the same routing criteria."""
|
||||
|
||||
|
||||
class SIPManagement:
|
||||
"""Service for managing SIP access through the telephony or roomkit system (SIP)."""
|
||||
|
||||
def _rule_name(self, room_id):
|
||||
"""Generate the rule name for a room based on its ID."""
|
||||
@@ -32,7 +36,7 @@ class TelephonyService:
|
||||
async def create_dispatch_rule(self, room):
|
||||
"""Create a SIP inbound dispatch rule for direct room routing.
|
||||
|
||||
Configures telephony to route incoming SIP calls directly to the specified room
|
||||
Configures livekit-sip to route incoming SIP calls directly to the specified room
|
||||
using the room's ID and PIN code for authentication.
|
||||
"""
|
||||
|
||||
@@ -51,10 +55,12 @@ class TelephonyService:
|
||||
try:
|
||||
await lkapi.sip.create_sip_dispatch_rule(create=request)
|
||||
except TwirpError as e:
|
||||
if e.code == TwirpErrorCode.ALREADY_EXISTS:
|
||||
raise DispatchRuleConflictError("Dispatch rule already exists") from e
|
||||
logger.exception(
|
||||
"Unexpected error creating dispatch rule for room %s", room.id
|
||||
)
|
||||
raise TelephonyException("Could not create dispatch rule") from e
|
||||
raise SIPException("Could not create dispatch rule") from e
|
||||
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
@@ -79,7 +85,7 @@ class TelephonyService:
|
||||
)
|
||||
except TwirpError as e:
|
||||
logger.exception("Failed to list dispatch rules for room %s", room_id)
|
||||
raise TelephonyException("Could not list dispatch rules") from e
|
||||
raise SIPException("Could not list dispatch rules") from e
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
@@ -94,6 +100,28 @@ class TelephonyService:
|
||||
if existing_rule.name == rule_name
|
||||
]
|
||||
|
||||
@async_to_sync
|
||||
async def has_dispatch_rule(self, room_id):
|
||||
"""Check whether at least one dispatch rule exists for a specific room."""
|
||||
return bool(await self._list_dispatch_rules_ids(room_id))
|
||||
|
||||
def ensure_dispatch_rule(self, room):
|
||||
"""Create the SIP dispatch rule for a room if it does not already exist.
|
||||
|
||||
Returns:
|
||||
bool: True if a rule was created, False if it already existed.
|
||||
"""
|
||||
|
||||
if self.has_dispatch_rule(room.pk):
|
||||
return False
|
||||
|
||||
try:
|
||||
self.create_dispatch_rule(room)
|
||||
except DispatchRuleConflictError:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@async_to_sync
|
||||
async def delete_dispatch_rule(self, room_id):
|
||||
"""Delete all SIP inbound dispatch rules associated with a specific room."""
|
||||
@@ -118,7 +146,7 @@ class TelephonyService:
|
||||
|
||||
except TwirpError as e:
|
||||
logger.exception("Failed to delete dispatch rules for room %s", room_id)
|
||||
raise TelephonyException("Could not delete dispatch rules") from e
|
||||
raise SIPException("Could not delete dispatch rules") from e
|
||||
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Asynchronous tasks of the core application.
|
||||
|
||||
Importing the task modules here is what makes Celery's `autodiscover_tasks`
|
||||
register them: it only imports the `core.tasks` package itself, never its
|
||||
submodules.
|
||||
"""
|
||||
|
||||
from core.tasks.file import process_file_deletion
|
||||
from core.tasks.push_recording import push_recording
|
||||
|
||||
__all__ = ["process_file_deletion", "push_recording"]
|
||||
@@ -1,4 +1,12 @@
|
||||
"""
|
||||
Celery task decorator that degrades to a synchronous call when Celery is off.
|
||||
"""
|
||||
|
||||
# The Celery app is imported lazily so that importing this module does not pull
|
||||
# in Celery when CELERY_ENABLED is false.
|
||||
|
||||
# ruff: noqa: PLC0415
|
||||
# pylint: disable=import-outside-toplevel
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Task pushing a finished recording to its owner's Drive."""
|
||||
|
||||
import logging
|
||||
|
||||
from django.conf import settings
|
||||
|
||||
import requests
|
||||
|
||||
from core import models, utils
|
||||
from core.services.drive import API_TIMEOUT, DriveClient, DriveError
|
||||
from core.tasks._task import task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# (connect, read) timeouts for the download, in seconds. The read one has to
|
||||
# accommodate a whole recording being relayed.
|
||||
DOWNLOAD_TIMEOUT = (API_TIMEOUT[0], 1800)
|
||||
|
||||
|
||||
def _build_filename(recording: models.Recording) -> str:
|
||||
"""Return a filename for the Drive item."""
|
||||
|
||||
return (
|
||||
f"{recording.room.slug}-"
|
||||
f"{recording.created_at:%Y-%m-%d-%H-%M}."
|
||||
f"{recording.extension}"
|
||||
)
|
||||
|
||||
|
||||
@task
|
||||
def push_recording(recording_id: str) -> bool:
|
||||
"""Push a recording to the Drive of the user who started it.
|
||||
|
||||
The recording is streamed from object storage to Drive's presigned
|
||||
URL. It is NOT fully downloaded to the worker's disk or memory.
|
||||
|
||||
The access token stored when the recording started is consumed here and
|
||||
dropped afterwards whatever the outcome.
|
||||
|
||||
Mostly taken from: https://github.com/suitenumerique/drive/blob/main/docs/resource_server.md
|
||||
"""
|
||||
|
||||
try:
|
||||
recording = models.Recording.objects.select_related("room").get(pk=recording_id)
|
||||
except models.Recording.DoesNotExist:
|
||||
logger.error(
|
||||
"Recording %s does not exist, cannot push it to Drive", recording_id
|
||||
)
|
||||
return False
|
||||
|
||||
access_token = recording.get_owner_access_token()
|
||||
|
||||
if not access_token:
|
||||
logger.error(
|
||||
"No access token stored for recording %s, cannot push it to Drive. "
|
||||
"Was OIDC_STORE_ACCESS_TOKEN enabled when the recording started?",
|
||||
recording_id,
|
||||
)
|
||||
return False
|
||||
|
||||
download_url = utils.generate_download_s3_url(
|
||||
recording.key,
|
||||
expires_in=settings.RECORDING_PUSH_TO_DRIVE_SIGNED_URL_EXPIRY_SECONDS,
|
||||
override_domain=False,
|
||||
)
|
||||
filename = _build_filename(recording)
|
||||
|
||||
try:
|
||||
with DriveClient(access_token) as drive:
|
||||
item = drive.create_file(filename=filename)
|
||||
|
||||
# The bytes are relayed chunk by chunk: the recording is never held
|
||||
# in memory as a whole.
|
||||
with requests.get(
|
||||
download_url, stream=True, timeout=DOWNLOAD_TIMEOUT
|
||||
) as download:
|
||||
download.raise_for_status()
|
||||
|
||||
content_length = download.headers.get("Content-Length")
|
||||
if content_length is None:
|
||||
raise DriveError(
|
||||
"Object storage did not return the recording size, "
|
||||
"cannot stream it to Drive."
|
||||
)
|
||||
|
||||
drive.upload_content(
|
||||
policy_url=item["policy"],
|
||||
stream=download.raw,
|
||||
content_length=int(content_length),
|
||||
content_type=download.headers.get(
|
||||
"Content-Type", "application/octet-stream"
|
||||
),
|
||||
)
|
||||
|
||||
drive.complete_upload(item["id"])
|
||||
|
||||
except (DriveError, requests.RequestException):
|
||||
logger.exception("Failed to push recording %s to Drive", recording_id)
|
||||
return False
|
||||
|
||||
finally:
|
||||
recording.clear_owner_access_token()
|
||||
|
||||
logger.info(
|
||||
"Recording %s pushed to Drive as '%s' (item %s)",
|
||||
recording_id,
|
||||
filename,
|
||||
item["id"],
|
||||
)
|
||||
return True
|
||||
@@ -117,7 +117,7 @@ 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 ["meet-minio:9000", "minio:9000", "localhost:9000"]
|
||||
assert policy_parsed.path == f"/meet-media-storage/tmp/files/{file.id!s}.png"
|
||||
|
||||
query_params = parse_qs(policy_parsed.query)
|
||||
|
||||
@@ -34,10 +34,8 @@ def mediator(mock_worker_service):
|
||||
return WorkerServiceMediator(mock_worker_service)
|
||||
|
||||
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_start_recording_success(
|
||||
mock_update_room_metadata, mediator, mock_worker_service
|
||||
):
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_start_recording_success(mock_update_metadata, mediator, mock_worker_service):
|
||||
"""Test successful recording start"""
|
||||
# Setup
|
||||
worker_id = "test-worker-123"
|
||||
@@ -60,7 +58,7 @@ def test_start_recording_success(
|
||||
assert mock_recording.worker_id == worker_id
|
||||
assert mock_recording.status == RecordingStatusChoices.ACTIVE
|
||||
|
||||
mock_update_room_metadata.assert_called_once_with(
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
str(mock_recording.room.id),
|
||||
{"recording_mode": mock_recording.mode, "recording_status": "starting"},
|
||||
)
|
||||
@@ -69,9 +67,9 @@ def test_start_recording_success(
|
||||
@pytest.mark.parametrize(
|
||||
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
|
||||
)
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_mediator_start_recording_worker_errors(
|
||||
mock_update_room_metadata, mediator, mock_worker_service, error_class
|
||||
mock_update_metadata, mediator, mock_worker_service, error_class
|
||||
):
|
||||
"""Test handling of various worker errors during start"""
|
||||
# Setup
|
||||
@@ -89,7 +87,7 @@ def test_mediator_start_recording_worker_errors(
|
||||
assert mock_recording.status == RecordingStatusChoices.FAILED_TO_START
|
||||
assert mock_recording.worker_id is None
|
||||
|
||||
mock_update_room_metadata.assert_not_called()
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -103,9 +101,9 @@ def test_mediator_start_recording_worker_errors(
|
||||
RecordingStatusChoices.ABORTED,
|
||||
],
|
||||
)
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_mediator_start_recording_from_forbidden_status(
|
||||
mock_update_room_metadata, mediator, mock_worker_service, status
|
||||
mock_update_metadata, mediator, mock_worker_service, status
|
||||
):
|
||||
"""Test handling of various worker errors during start"""
|
||||
# Setup
|
||||
@@ -119,7 +117,7 @@ def test_mediator_start_recording_from_forbidden_status(
|
||||
mock_recording.refresh_from_db()
|
||||
assert mock_recording.status == status
|
||||
|
||||
mock_update_room_metadata.assert_not_called()
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
|
||||
def test_mediator_stop_recording_success(mediator, mock_worker_service):
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the roomkit API of the Meet core app."""
|
||||
@@ -0,0 +1,305 @@
|
||||
"""
|
||||
Test the roomkit join server-to-server API endpoint.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from ...factories import RoomFactory
|
||||
from ...services.sip_management import SIPException
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
||||
def test_join_anonymous(mock_sip_management, settings, client):
|
||||
"""Requests without an Authorization header should be rejected."""
|
||||
|
||||
mock_sip_instance = mock_sip_management.return_value
|
||||
|
||||
settings.ROOMKIT_ENABLED = True
|
||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||
|
||||
room = RoomFactory(pin_code="1234567890")
|
||||
|
||||
response = client.post("/api/v1.0/roomkit/join/", {"pin_code": room.pin_code})
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {"detail": "Authorization header is missing."}
|
||||
mock_sip_instance.ensure_dispatch_rule.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
||||
def test_join_malformed_authorization_header(mock_sip_management, settings, client):
|
||||
"""Requests with a malformed Authorization header should be rejected."""
|
||||
|
||||
mock_sip_instance = mock_sip_management.return_value
|
||||
|
||||
settings.ROOMKIT_ENABLED = True
|
||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||
|
||||
room = RoomFactory(pin_code="1234567890")
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/roomkit/join/",
|
||||
{"pin_code": room.pin_code},
|
||||
HTTP_AUTHORIZATION="testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {"detail": "Invalid authorization header."}
|
||||
mock_sip_instance.ensure_dispatch_rule.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
||||
def test_join_wrong_bearer(mock_sip_management, settings, client):
|
||||
"""Requests with an incorrect bearer token should be rejected."""
|
||||
|
||||
mock_sip_instance = mock_sip_management.return_value
|
||||
|
||||
settings.ROOMKIT_ENABLED = True
|
||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||
|
||||
room = RoomFactory(pin_code="1234567890")
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/roomkit/join/",
|
||||
{"pin_code": room.pin_code},
|
||||
HTTP_AUTHORIZATION="Bearer wrongAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert response.json() == {"detail": "Invalid server-to-server token."}
|
||||
mock_sip_instance.ensure_dispatch_rule.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
||||
def test_join_token_not_configured(mock_sip_management, settings, client):
|
||||
"""Requests should be rejected when no server-to-server token is configured."""
|
||||
|
||||
mock_sip_instance = mock_sip_management.return_value
|
||||
|
||||
settings.ROOMKIT_ENABLED = True
|
||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = None
|
||||
|
||||
room = RoomFactory(pin_code="1234567890")
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/roomkit/join/",
|
||||
{"pin_code": room.pin_code},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
mock_sip_instance.ensure_dispatch_rule.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
||||
def test_join_roomkit_disabled(mock_sip_management, settings, client):
|
||||
"""The endpoint should not be exposed when the roomkit integration is disabled."""
|
||||
|
||||
mock_sip_instance = mock_sip_management.return_value
|
||||
|
||||
settings.ROOMKIT_ENABLED = False
|
||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||
|
||||
room = RoomFactory(pin_code="1234567890")
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/roomkit/join/",
|
||||
{"pin_code": room.pin_code},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
mock_sip_instance.ensure_dispatch_rule.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
||||
def test_join_missing_pin(mock_sip_management, settings, client):
|
||||
"""Requests without a PIN code should be rejected."""
|
||||
|
||||
mock_sip_instance = mock_sip_management.return_value
|
||||
|
||||
settings.ROOMKIT_ENABLED = True
|
||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/roomkit/join/",
|
||||
{},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"pin_code": ["This field is required."]}
|
||||
mock_sip_instance.ensure_dispatch_rule.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
||||
def test_join_blank_pin(mock_sip_management, settings, client):
|
||||
"""Requests with a blank PIN code should be rejected."""
|
||||
|
||||
mock_sip_instance = mock_sip_management.return_value
|
||||
|
||||
settings.ROOMKIT_ENABLED = True
|
||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/roomkit/join/",
|
||||
{"pin_code": ""},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"pin_code": ["This field may not be blank."]}
|
||||
mock_sip_instance.ensure_dispatch_rule.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
||||
def test_join_wrong_pin_length(mock_sip_management, settings, client):
|
||||
"""Requests with a PIN code of unexpected length should be rejected."""
|
||||
|
||||
mock_sip_instance = mock_sip_management.return_value
|
||||
|
||||
settings.ROOMKIT_ENABLED = True
|
||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||
settings.ROOM_TELEPHONY_PIN_LENGTH = 10
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/roomkit/join/",
|
||||
{"pin_code": "123"},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"pin_code": ["PIN code length is invalid."]}
|
||||
mock_sip_instance.ensure_dispatch_rule.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
||||
def test_join_unknown_pin(mock_sip_management, settings, client):
|
||||
"""Requests with a PIN matching no room should return 404 and create no rule."""
|
||||
|
||||
mock_sip_instance = mock_sip_management.return_value
|
||||
|
||||
settings.ROOMKIT_ENABLED = True
|
||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||
|
||||
RoomFactory(pin_code="1234567890")
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/roomkit/join/",
|
||||
{"pin_code": "0987654321"},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {"detail": "No room found for this PIN code."}
|
||||
mock_sip_instance.ensure_dispatch_rule.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
||||
def test_join_success(mock_sip_management, settings, client):
|
||||
"""Requests with a valid PIN should create the dispatch rule."""
|
||||
|
||||
mock_sip_instance = mock_sip_management.return_value
|
||||
|
||||
settings.ROOMKIT_ENABLED = True
|
||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||
|
||||
room = RoomFactory(pin_code="1234567890")
|
||||
mock_sip_instance.ensure_dispatch_rule.return_value = True
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/roomkit/join/",
|
||||
{"pin_code": room.pin_code},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "success"}
|
||||
mock_sip_instance.ensure_dispatch_rule.assert_called_once_with(room)
|
||||
|
||||
|
||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
||||
def test_join_dispatch_rule_already_exists(mock_sip_management, settings, client):
|
||||
"""Requests should succeed when the dispatch rule already exists (idempotency)."""
|
||||
|
||||
mock_sip_instance = mock_sip_management.return_value
|
||||
|
||||
settings.ROOMKIT_ENABLED = True
|
||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||
|
||||
room = RoomFactory(pin_code="1234567890")
|
||||
mock_sip_instance.ensure_dispatch_rule.return_value = False
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/roomkit/join/",
|
||||
{"pin_code": room.pin_code},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "success"}
|
||||
mock_sip_instance.ensure_dispatch_rule.assert_called_once_with(room)
|
||||
|
||||
|
||||
@mock.patch("core.roomkit.viewsets.analytics.capture")
|
||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
||||
def test_join_tracks_analytics_event(
|
||||
mock_sip_management, mock_capture, settings, client
|
||||
):
|
||||
"""Successful joins should be tracked with an analytics event."""
|
||||
|
||||
mock_sip_instance = mock_sip_management.return_value
|
||||
|
||||
settings.ROOMKIT_ENABLED = True
|
||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||
|
||||
room = RoomFactory(pin_code="1234567890")
|
||||
mock_sip_instance.ensure_dispatch_rule.return_value = True
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/roomkit/join/",
|
||||
{"pin_code": room.pin_code},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_capture.assert_called_once()
|
||||
_user, event, properties = mock_capture.call_args[0]
|
||||
assert str(event) == "roomkit_joined"
|
||||
assert properties == {
|
||||
"room_id": str(room.pk),
|
||||
"dispatch_rule_created": True,
|
||||
}
|
||||
|
||||
|
||||
@mock.patch("core.roomkit.viewsets.analytics.capture")
|
||||
@mock.patch("core.roomkit.viewsets.SIPManagement")
|
||||
def test_join_sip_failure(mock_sip_management, mock_capture, settings, client):
|
||||
"""Requests should fail with a server error when the sip management service fails."""
|
||||
|
||||
mock_sip_instance = mock_sip_management.return_value
|
||||
|
||||
settings.ROOMKIT_ENABLED = True
|
||||
settings.ROOMKIT_SERVER_TO_SERVER_API_TOKEN = "testAuthToken"
|
||||
|
||||
room = RoomFactory(pin_code="1234567890")
|
||||
mock_sip_instance.ensure_dispatch_rule.side_effect = SIPException(
|
||||
"Could not create dispatch rule"
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/roomkit/join/",
|
||||
{"pin_code": room.pin_code},
|
||||
HTTP_AUTHORIZATION="Bearer testAuthToken",
|
||||
raise_request_exception=False,
|
||||
)
|
||||
|
||||
assert response.status_code == 500
|
||||
mock_sip_instance.ensure_dispatch_rule.assert_called_once_with(room)
|
||||
mock_capture.assert_not_called()
|
||||
@@ -3,13 +3,14 @@ Test rooms API endpoints in the Meet core app: create.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
from ...models import Room
|
||||
from ...models import Room, RoomAccessLevel
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -109,3 +110,205 @@ def test_api_rooms_create_authenticated_existing_slug():
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.json() == {"slug": ["Room with this Slug already exists."]}
|
||||
|
||||
|
||||
def test_api_rooms_create_authenticated_user_default_access_level():
|
||||
"""
|
||||
The user's default room access level should be applied to the new room
|
||||
when the request does not provide one.
|
||||
"""
|
||||
user = UserFactory(default_room_access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/",
|
||||
{
|
||||
"name": "my room",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get()
|
||||
assert room.access_level == RoomAccessLevel.RESTRICTED
|
||||
|
||||
|
||||
def test_api_rooms_create_authenticated_explicit_access_level_overrides_default():
|
||||
"""
|
||||
An access level explicitly provided in the request should take precedence
|
||||
over the user's default room access level.
|
||||
"""
|
||||
user = UserFactory(default_room_access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/",
|
||||
{
|
||||
"name": "my room",
|
||||
"access_level": RoomAccessLevel.TRUSTED,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get()
|
||||
assert room.access_level == RoomAccessLevel.TRUSTED
|
||||
|
||||
|
||||
def test_api_rooms_create_authenticated_no_user_default_access_level():
|
||||
"""
|
||||
When the user has no default room access level, the instance default
|
||||
should be applied to the new room.
|
||||
"""
|
||||
user = UserFactory(default_room_access_level=None)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/",
|
||||
{
|
||||
"name": "my room",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get()
|
||||
assert room.access_level == settings.RESOURCE_DEFAULT_ACCESS_LEVEL
|
||||
|
||||
|
||||
def test_api_rooms_create_authenticated_user_default_configuration():
|
||||
"""
|
||||
The user's default room configuration should be applied to the new room
|
||||
when the request does not provide one.
|
||||
"""
|
||||
user = UserFactory(default_room_configuration={"everyone_can_mute": False})
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/",
|
||||
{
|
||||
"name": "my room",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get()
|
||||
assert room.configuration == {"everyone_can_mute": False}
|
||||
|
||||
|
||||
def test_api_rooms_create_authenticated_explicit_configuration_overrides_default():
|
||||
"""
|
||||
A configuration explicitly provided in the request should take precedence
|
||||
over the user's default room configuration.
|
||||
"""
|
||||
user = UserFactory(default_room_configuration={"everyone_can_mute": False})
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/",
|
||||
{
|
||||
"name": "my room",
|
||||
"configuration": {"can_publish_sources": ["camera", "microphone"]},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get()
|
||||
assert room.configuration == {"can_publish_sources": ["camera", "microphone"]}
|
||||
|
||||
|
||||
def test_api_rooms_create_authenticated_empty_configuration_falls_back_to_default():
|
||||
"""
|
||||
An empty configuration in the request should not be considered an explicit
|
||||
value: the user's default room configuration should still be applied.
|
||||
"""
|
||||
user = UserFactory(default_room_configuration={"everyone_can_mute": True})
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/",
|
||||
{
|
||||
"name": "my room",
|
||||
"configuration": {},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get()
|
||||
assert room.configuration == {"everyone_can_mute": True}
|
||||
|
||||
|
||||
def test_api_rooms_create_authenticated_empty_user_default_configuration():
|
||||
"""
|
||||
When the user's default room configuration is empty, the new room should
|
||||
keep its default empty configuration.
|
||||
"""
|
||||
user = UserFactory(default_room_configuration={})
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/",
|
||||
{
|
||||
"name": "my room",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get()
|
||||
assert room.configuration == {}
|
||||
|
||||
|
||||
def test_api_rooms_create_authenticated_request_precedence_over_user_empty():
|
||||
"""
|
||||
When the user's default room configuration is empty, the request should take precedence.
|
||||
"""
|
||||
user = UserFactory(default_room_configuration={})
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/",
|
||||
{"name": "my room", "configuration": {"everyone_can_mute": True}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get()
|
||||
assert room.configuration == {"everyone_can_mute": True}
|
||||
|
||||
|
||||
def test_api_rooms_create_authenticated_blank_user_default_access_level():
|
||||
"""
|
||||
A blank default room access level (stored as an empty string) should be
|
||||
treated as unset: the instance default should be applied to the new room
|
||||
instead of persisting an invalid empty access level.
|
||||
"""
|
||||
user = UserFactory(default_room_access_level="")
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
"/api/v1.0/rooms/",
|
||||
{
|
||||
"name": "my room",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
room = Room.objects.get()
|
||||
assert room.access_level == settings.RESOURCE_DEFAULT_ACCESS_LEVEL
|
||||
|
||||
@@ -80,7 +80,7 @@ def test_mute_participant_with_livekit_token_for_this_room(mock_livekit_client):
|
||||
room = RoomFactory()
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
token = utils.generate_token(str(room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
@@ -106,7 +106,7 @@ def test_mute_participant_with_livekit_token_for_another_room_forbidden(
|
||||
other_room = RoomFactory()
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=False)
|
||||
token = utils.generate_token(str(other_room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
|
||||
response = client.post(
|
||||
@@ -146,7 +146,7 @@ def test_mute_participant_everyone_can_mute_disabled_blocks_non_admin(
|
||||
room = RoomFactory(configuration={"everyone_can_mute": False})
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
token = utils.generate_token(str(room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
@@ -293,7 +293,7 @@ def test_mute_participant_admin_with_token_for_this_room(mock_livekit_client):
|
||||
)
|
||||
# Token identity matches the admin user so LiveKitTokenAuthentication
|
||||
# resolves request.user back to the admin.
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=True)
|
||||
token = utils.generate_token(str(room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
@@ -323,7 +323,7 @@ def test_mute_participant_admin_with_token_for_another_room(mock_livekit_client)
|
||||
# Token is scoped to a DIFFERENT room, and admin status must only be
|
||||
# honored when established via session, never via a LiveKit
|
||||
# token, which can be replayed off-host.
|
||||
token = utils.generate_token(str(other_room.id), user, is_admin_or_owner=True)
|
||||
token = utils.generate_token(str(other_room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
|
||||
response = client.post(
|
||||
@@ -354,7 +354,7 @@ def test_mute_participant_admin_token_replayed_does_not_grant_admin(
|
||||
role=random.choice(["administrator", "owner"]),
|
||||
)
|
||||
# The token is the only credential.
|
||||
token = utils.generate_token(str(room.id), admin_user, is_admin_or_owner=True)
|
||||
token = utils.generate_token(str(room.id), admin_user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
@@ -374,7 +374,7 @@ def test_mute_participant_livekit_token_triggers_presence_check(mock_livekit_cli
|
||||
room = RoomFactory()
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
token = utils.generate_token(str(room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
@@ -405,7 +405,7 @@ def test_mute_participant_livekit_token_presence_check_returns_participant(
|
||||
)
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
token = utils.generate_token(str(room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
@@ -433,7 +433,7 @@ def test_mute_participant_livekit_token_presence_check_participant_not_found(
|
||||
)
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
token = utils.generate_token(str(room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
@@ -462,7 +462,7 @@ def test_mute_participant_livekit_token_presence_check_twirp_error_forbidden(
|
||||
)
|
||||
|
||||
user = AnonymousUser()
|
||||
token = utils.generate_token(str(room.id), user, is_admin_or_owner=False)
|
||||
token = utils.generate_token(str(room.id), user)
|
||||
|
||||
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||
response = client.post(
|
||||
@@ -719,13 +719,13 @@ def test_update_participant_invalid_payload():
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {"participant_identity": "invalid-uuid"}
|
||||
payload = {"participant_identity": ["test"]}
|
||||
|
||||
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
||||
assert "Must be a valid UUID." in str(response.data)
|
||||
assert "Not a valid string." in str(response.data)
|
||||
|
||||
|
||||
def test_update_participant_no_update_fields():
|
||||
@@ -918,7 +918,7 @@ def test_remove_participant_invalid_payload():
|
||||
)
|
||||
client.force_authenticate(user=user)
|
||||
|
||||
payload = {"participant_identity": "invalid-uuid"}
|
||||
payload = {"participant_identity": ["invalid-uuid"]}
|
||||
|
||||
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
|
||||
response = client.post(url, payload, format="json")
|
||||
|
||||
@@ -12,7 +12,7 @@ import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||
from ...models import RoomAccessLevel
|
||||
from ...models import RoleChoices, RoomAccessLevel
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -31,7 +31,6 @@ def test_api_rooms_retrieve_anonymous_private_pk():
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -51,7 +50,6 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
|
||||
"configuration": {},
|
||||
"access_level": "trusted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -70,7 +68,6 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -87,7 +84,6 @@ def test_api_rooms_retrieve_anonymous_private_slug():
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -104,7 +100,6 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -214,7 +209,6 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
|
||||
"configuration": {},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
@@ -261,7 +255,6 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
@@ -278,7 +271,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
||||
username=None,
|
||||
color=None,
|
||||
sources=["camera"],
|
||||
is_admin_or_owner=False,
|
||||
role=None,
|
||||
participant_id=None,
|
||||
)
|
||||
|
||||
@@ -313,7 +306,6 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
||||
"configuration": {},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
@@ -330,7 +322,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
||||
username=None,
|
||||
color=None,
|
||||
sources=None,
|
||||
is_admin_or_owner=False,
|
||||
role=None,
|
||||
participant_id=None,
|
||||
)
|
||||
|
||||
@@ -355,7 +347,6 @@ def test_api_rooms_retrieve_authenticated():
|
||||
"configuration": {},
|
||||
"access_level": "restricted",
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"name": room.name,
|
||||
"slug": room.slug,
|
||||
}
|
||||
@@ -401,7 +392,6 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
"configuration": {"can_publish_sources": ["camera"]},
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": False,
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
"room": expected_name,
|
||||
@@ -418,7 +408,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
||||
username=None,
|
||||
color=None,
|
||||
sources=["camera"],
|
||||
is_admin_or_owner=False,
|
||||
role=str(RoleChoices.MEMBER),
|
||||
participant_id=None,
|
||||
)
|
||||
|
||||
@@ -463,6 +453,8 @@ def test_api_rooms_retrieve_administrators(
|
||||
{
|
||||
"id": str(other_user_access.id),
|
||||
"user": {
|
||||
"default_room_access_level": None,
|
||||
"default_room_configuration": {},
|
||||
"id": str(other_user_access.user.id),
|
||||
"email": other_user_access.user.email,
|
||||
"full_name": other_user_access.user.full_name,
|
||||
@@ -476,6 +468,8 @@ def test_api_rooms_retrieve_administrators(
|
||||
{
|
||||
"id": str(user_access.id),
|
||||
"user": {
|
||||
"default_room_access_level": None,
|
||||
"default_room_configuration": {},
|
||||
"id": str(user_access.user.id),
|
||||
"email": user_access.user.email,
|
||||
"full_name": user_access.user.full_name,
|
||||
@@ -493,7 +487,6 @@ def test_api_rooms_retrieve_administrators(
|
||||
assert content_dict == {
|
||||
"access_level": str(room.access_level),
|
||||
"id": str(room.id),
|
||||
"is_administrable": True,
|
||||
"configuration": {},
|
||||
"livekit": {
|
||||
"url": "test_url_value",
|
||||
@@ -511,6 +504,6 @@ def test_api_rooms_retrieve_administrators(
|
||||
username=None,
|
||||
color=None,
|
||||
sources=None,
|
||||
is_admin_or_owner=True,
|
||||
role=str(user_access.role),
|
||||
participant_id=None,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
"""
|
||||
Test rooms API endpoints in the Meet core app: update-participant-role.
|
||||
"""
|
||||
|
||||
# pylint: disable=redefined-outer-name,unused-argument
|
||||
|
||||
import uuid
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from ...factories import RoomFactory, UserFactory
|
||||
from ...models import ResourceAccess, RoleChoices
|
||||
from ...services.participants_management import ParticipantNotFoundException
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_update_participant_role_anonymous():
|
||||
"""Anonymous requesters are rejected."""
|
||||
client = APIClient()
|
||||
room = RoomFactory()
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": "some-identity", "role": "administrator"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_update_participant_role_requires_privileges():
|
||||
"""A simple member cannot promote other participants."""
|
||||
client = APIClient()
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.MEMBER)])
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": "some-identity", "role": "administrator"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_requester_not_in_meeting(mock_perm_pm):
|
||||
"""An admin who is not connected to the meeting is rejected."""
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = False
|
||||
client = APIClient()
|
||||
user = UserFactory()
|
||||
room = RoomFactory(users=[(user, RoleChoices.ADMIN)])
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": "some-identity", "role": "administrator"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
mock_perm_pm.return_value.check_if_in_meeting.assert_called_once_with(
|
||||
room_name=str(room.pk), identity=str(user.sub)
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_cannot_target_self(mock_perm_pm):
|
||||
"""Requesters cannot change their own role."""
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||
client = APIClient()
|
||||
user = UserFactory(sub=uuid.uuid4())
|
||||
room = RoomFactory(users=[(user, RoleChoices.ADMIN)])
|
||||
client.force_login(user)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": user.sub, "role": "member"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {"error": "You cannot change your own role."}
|
||||
|
||||
|
||||
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
|
||||
@mock.patch("core.services.room_roles.ParticipantsManagement")
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_promotes_authenticated_target(
|
||||
mock_perm_pm, mock_svc_pm, mock_sync
|
||||
):
|
||||
"""Promoting a connected, authenticated participant persists the role."""
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_sync.return_value = True
|
||||
|
||||
client = APIClient()
|
||||
admin = UserFactory()
|
||||
target = UserFactory(sub=uuid.uuid4())
|
||||
room = RoomFactory(users=[(admin, RoleChoices.OWNER)])
|
||||
client.force_login(admin)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": str(target.sub), "role": "administrator"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"role": "administrator",
|
||||
"livekit_synced": True,
|
||||
}
|
||||
access = ResourceAccess.objects.get(resource=room, user=target)
|
||||
assert access.role == RoleChoices.ADMIN
|
||||
mock_sync.assert_called_once_with(
|
||||
room_name=str(room.pk),
|
||||
participant_identity=str(target.sub),
|
||||
role="administrator",
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
|
||||
@mock.patch("core.services.room_roles.ParticipantsManagement")
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_demotes_authenticated_target(
|
||||
mock_perm_pm, mock_svc_pm, mock_sync
|
||||
):
|
||||
"""Demoting a connected admin back to member updates the access row."""
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_sync.return_value = True
|
||||
|
||||
client = APIClient()
|
||||
admin = UserFactory()
|
||||
target = UserFactory(sub=uuid.uuid4())
|
||||
room = RoomFactory(users=[(admin, RoleChoices.OWNER), (target, RoleChoices.ADMIN)])
|
||||
client.force_login(admin)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": str(target.sub), "role": "member"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
access = ResourceAccess.objects.get(resource=room, user=target)
|
||||
assert access.role == RoleChoices.MEMBER
|
||||
|
||||
|
||||
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
|
||||
@mock.patch("core.services.room_roles.ParticipantsManagement")
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_cannot_demote_owner(
|
||||
mock_perm_pm, mock_svc_pm, mock_sync
|
||||
):
|
||||
"""Room owners can never be demoted."""
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
|
||||
|
||||
client = APIClient()
|
||||
admin = UserFactory()
|
||||
owner = UserFactory(sub=uuid.uuid4())
|
||||
room = RoomFactory(users=[(admin, RoleChoices.ADMIN), (owner, RoleChoices.OWNER)])
|
||||
client.force_login(admin)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": str(owner.sub), "role": "member"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.json() == {"error": "Room owners cannot be demoted."}
|
||||
assert (
|
||||
ResourceAccess.objects.get(resource=room, user=owner).role == RoleChoices.OWNER
|
||||
)
|
||||
mock_sync.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
|
||||
@mock.patch("core.services.room_roles.ParticipantsManagement")
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_anonymous_target_is_ephemeral(
|
||||
mock_perm_pm, mock_svc_pm, mock_sync
|
||||
):
|
||||
"""Promoting an anonymous participant should not be possible."""
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_sync.return_value = True
|
||||
|
||||
client = APIClient()
|
||||
admin = UserFactory()
|
||||
room = RoomFactory(users=[(admin, RoleChoices.ADMIN)])
|
||||
client.force_login(admin)
|
||||
|
||||
anonymous_identity = uuid.uuid4()
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": anonymous_identity, "role": "administrator"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert response.json() == {
|
||||
"error": "This participant has no user account and cannot be assigned a role."
|
||||
}
|
||||
assert not ResourceAccess.objects.filter(resource=room).exclude(user=admin).exists()
|
||||
|
||||
|
||||
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
|
||||
@mock.patch("core.services.room_roles.ParticipantsManagement")
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_target_not_in_meeting(
|
||||
mock_perm_pm, mock_svc_pm, mock_sync
|
||||
):
|
||||
"""Only connected participants can be promoted or demoted."""
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_svc_pm.return_value.check_if_in_meeting.side_effect = (
|
||||
ParticipantNotFoundException("Participant does not exist")
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
admin = UserFactory()
|
||||
target = UserFactory(sub=uuid.uuid4())
|
||||
room = RoomFactory(users=[(admin, RoleChoices.ADMIN)])
|
||||
client.force_login(admin)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": str(target.sub), "role": "administrator"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert not ResourceAccess.objects.filter(resource=room, user=target).exists()
|
||||
mock_sync.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
|
||||
@mock.patch("core.services.room_roles.ParticipantsManagement")
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_is_idempotent_and_resyncs(
|
||||
mock_perm_pm, mock_svc_pm, mock_sync
|
||||
):
|
||||
"""Promoting an existing admin succeeds and still re-syncs LiveKit."""
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_sync.return_value = True
|
||||
|
||||
client = APIClient()
|
||||
admin = UserFactory()
|
||||
target = UserFactory(sub=uuid.uuid4())
|
||||
room = RoomFactory(users=[(admin, RoleChoices.OWNER), (target, RoleChoices.ADMIN)])
|
||||
client.force_login(admin)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": str(target.sub), "role": "administrator"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_sync.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.room_roles.RoomRoleService._sync_livekit_role")
|
||||
@mock.patch("core.services.room_roles.ParticipantsManagement")
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_livekit_failure_reports_partial_success(
|
||||
mock_perm_pm, mock_svc_pm, mock_sync
|
||||
):
|
||||
"""A LiveKit sync failure does not lose the persisted role."""
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_svc_pm.return_value.check_if_in_meeting.return_value = True
|
||||
mock_sync.return_value = False
|
||||
|
||||
client = APIClient()
|
||||
admin = UserFactory()
|
||||
target = UserFactory(sub=uuid.uuid4())
|
||||
room = RoomFactory(users=[(admin, RoleChoices.OWNER)])
|
||||
client.force_login(admin)
|
||||
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": str(target.sub), "role": "administrator"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"role": "administrator",
|
||||
"livekit_synced": False,
|
||||
}
|
||||
assert (
|
||||
ResourceAccess.objects.get(resource=room, user=target).role == RoleChoices.ADMIN
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.api.permissions.ParticipantsManagement")
|
||||
def test_update_participant_role_rejects_owner_role(mock_perm_pm):
|
||||
"""The owner role can never be granted through this endpoint."""
|
||||
client = APIClient()
|
||||
admin = UserFactory()
|
||||
room = RoomFactory(users=[(admin, RoleChoices.ADMIN)])
|
||||
client.force_login(admin)
|
||||
|
||||
mock_perm_pm.return_value.check_if_in_meeting.return_value = True
|
||||
response = client.post(
|
||||
f"/api/v1.0/rooms/{room.id}/update-participant-role/",
|
||||
{"participant_identity": "some-identity", "role": "owner"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
@@ -20,8 +20,12 @@ from core.services.livekit_events import (
|
||||
api,
|
||||
)
|
||||
from core.services.lobby import LobbyService
|
||||
from core.services.telephony import TelephonyException, TelephonyService
|
||||
from core.utils import MetadataUpdateException, NotificationError
|
||||
from core.services.room_management import RoomManagementException
|
||||
from core.services.sip_management import (
|
||||
SIPException,
|
||||
SIPManagement,
|
||||
)
|
||||
from core.utils import NotificationError
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -58,7 +62,7 @@ def test_initialization(
|
||||
mock_token_verifier.assert_called_once_with(api_key, api_secret)
|
||||
mock_webhook_receiver.assert_called_once_with(mock_token_verifier.return_value)
|
||||
assert isinstance(service.lobby_service, LobbyService)
|
||||
assert isinstance(service.telephony_service, TelephonyService)
|
||||
assert isinstance(service.sip_management, SIPManagement)
|
||||
assert isinstance(service.recording_events, RecordingEventsService)
|
||||
|
||||
|
||||
@@ -70,12 +74,13 @@ def test_initialization(
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_handle_egress_ended_success(
|
||||
mock_update_room_metadata, mock_notify, mode, notification_type, service
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_success( # noqa: PLR0913 # pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||
mock_update_metadata, mock_notify, mode, notification_type, service, settings
|
||||
):
|
||||
"""Should successfully stop recording and notifies all participant."""
|
||||
|
||||
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
@@ -86,8 +91,8 @@ def test_handle_egress_ended_success(
|
||||
mock_notify.assert_called_once_with(
|
||||
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||
)
|
||||
mock_update_room_metadata.assert_called_once_with(
|
||||
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||
)
|
||||
|
||||
recording.refresh_from_db()
|
||||
@@ -104,9 +109,9 @@ def test_handle_egress_ended_success(
|
||||
(EgressStatus.EGRESS_ABORTED, "aborted"),
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_updated_success(
|
||||
mock_update_room_metadata, egress_status, status, service
|
||||
mock_update_metadata, egress_status, status, service
|
||||
):
|
||||
"""Should successfully update room's metadata."""
|
||||
|
||||
@@ -117,7 +122,7 @@ def test_handle_egress_updated_success(
|
||||
|
||||
service._handle_egress_updated(mock_data)
|
||||
|
||||
mock_update_room_metadata.assert_called_once_with(
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
str(recording.room.id), {"recording_status": status}
|
||||
)
|
||||
|
||||
@@ -129,9 +134,9 @@ def test_handle_egress_updated_success(
|
||||
EgressStatus.EGRESS_LIMIT_REACHED,
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_updated_non_handled(
|
||||
mock_update_room_metadata, egress_status, service
|
||||
mock_update_metadata, egress_status, service
|
||||
):
|
||||
"""Should ignore certain egress status and don't trigger metadata updates."""
|
||||
|
||||
@@ -142,7 +147,7 @@ def test_handle_egress_updated_non_handled(
|
||||
|
||||
service._handle_egress_updated(mock_data)
|
||||
|
||||
mock_update_room_metadata.assert_not_called()
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -153,18 +158,19 @@ def test_handle_egress_updated_non_handled(
|
||||
),
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
def test_handle_egress_ended_metadata_update_fails(
|
||||
mock_update_room_metadata, mock_notify, mode, notification_type, service
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_metadata_update_fails( # noqa: PLR0913 # pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||
mock_update_metadata, mock_notify, mode, notification_type, service, settings
|
||||
):
|
||||
"""Should successfully stop and save recording when metadata's update fails."""
|
||||
|
||||
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.egress_info.egress_id = recording.worker_id
|
||||
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
|
||||
|
||||
mock_update_room_metadata.side_effect = MetadataUpdateException("Error notifying")
|
||||
mock_update_metadata.side_effect = RoomManagementException("Error notifying")
|
||||
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
@@ -178,9 +184,9 @@ def test_handle_egress_ended_metadata_update_fails(
|
||||
|
||||
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_notification_fails(
|
||||
mock_update_room_metadata, mock_notify, service
|
||||
mock_update_metadata, mock_notify, service
|
||||
):
|
||||
"""Should raise ActionFailedError when notification fails but still stop recording."""
|
||||
|
||||
@@ -200,15 +206,15 @@ def test_handle_egress_ended_notification_fails(
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == "stopped"
|
||||
|
||||
mock_update_room_metadata.assert_called_once_with(
|
||||
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||
)
|
||||
|
||||
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_recording_not_found(
|
||||
mock_update_room_metadata, mock_notify, service
|
||||
mock_update_metadata, mock_notify, service
|
||||
):
|
||||
"""Should raise ActionFailedError when recording doesn't exist."""
|
||||
|
||||
@@ -223,16 +229,16 @@ def test_handle_egress_ended_recording_not_found(
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_notify.assert_not_called()
|
||||
mock_update_room_metadata.assert_not_called()
|
||||
mock_update_metadata.assert_not_called()
|
||||
|
||||
recording.refresh_from_db()
|
||||
assert recording.status == "active"
|
||||
|
||||
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_recording_not_active(
|
||||
mock_update_room_metadata, mock_notify, service
|
||||
mock_update_metadata, mock_notify, service
|
||||
):
|
||||
"""Should ignore non-active recordings."""
|
||||
|
||||
@@ -244,8 +250,8 @@ def test_handle_egress_ended_recording_not_active(
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_notify.assert_not_called()
|
||||
mock_update_room_metadata.assert_called_once_with(
|
||||
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||
)
|
||||
|
||||
recording.refresh_from_db()
|
||||
@@ -253,9 +259,9 @@ def test_handle_egress_ended_recording_not_active(
|
||||
|
||||
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_recording_not_limit_reached(
|
||||
mock_update_room_metadata, mock_notify, service
|
||||
mock_update_metadata, mock_notify, service
|
||||
):
|
||||
"""Should ignore egress non-limit-reached statuses."""
|
||||
|
||||
@@ -267,16 +273,16 @@ def test_handle_egress_ended_recording_not_limit_reached(
|
||||
service._handle_egress_ended(mock_data)
|
||||
|
||||
mock_notify.assert_not_called()
|
||||
mock_update_room_metadata.assert_called_once_with(
|
||||
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
||||
mock_update_metadata.assert_called_once_with(
|
||||
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||
)
|
||||
assert recording.status == "stopped"
|
||||
|
||||
|
||||
@mock.patch("core.services.livekit_events.MetadataCollectorService")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_calls_metadata_collector_stop_when_conditions_are_met(
|
||||
mock_update_room_metadata, mock_collector_class, service, settings
|
||||
mock_update_metadata, mock_collector_class, service, settings
|
||||
):
|
||||
"""Should call MetadataCollectorService.stop when it exists."""
|
||||
settings.METADATA_COLLECTOR_ENABLED = True
|
||||
@@ -306,7 +312,7 @@ def test_handle_egress_ended_calls_metadata_collector_stop_when_conditions_are_m
|
||||
],
|
||||
)
|
||||
@mock.patch("core.services.livekit_events.MetadataCollectorService")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditions_not_met(
|
||||
_, mock_collector_class, metadata_enabled, options, service, settings
|
||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
@@ -335,7 +341,7 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
|
||||
"notify_external_services"
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
@pytest.mark.parametrize(
|
||||
"egress_status",
|
||||
[EgressStatus.EGRESS_COMPLETE, EgressStatus.EGRESS_LIMIT_REACHED],
|
||||
@@ -345,7 +351,7 @@ def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditio
|
||||
[(True, "notification_succeeded"), (False, "saved")],
|
||||
)
|
||||
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
|
||||
mock_update_room_metadata,
|
||||
mock_update_metadata,
|
||||
mock_notify,
|
||||
mock_notify_external_services,
|
||||
notify_return_value,
|
||||
@@ -378,7 +384,7 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
|
||||
"notify_external_services"
|
||||
)
|
||||
@mock.patch("core.utils.notify_participants")
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
@pytest.mark.parametrize(
|
||||
"egress_status, expected_status",
|
||||
[
|
||||
@@ -387,7 +393,7 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
|
||||
],
|
||||
)
|
||||
def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: PLR0913
|
||||
mock_update_room_metadata,
|
||||
mock_update_metadata,
|
||||
mock_notify,
|
||||
mock_notify_external_services,
|
||||
egress_status,
|
||||
@@ -424,9 +430,9 @@ def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: P
|
||||
EgressStatus.EGRESS_ABORTED,
|
||||
],
|
||||
)
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_does_not_save_on_wrong_status(
|
||||
mock_update_room_metadata, egress_status, service, settings
|
||||
mock_update_metadata, egress_status, service, settings
|
||||
):
|
||||
"""Shouldn't save on invalid status."""
|
||||
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||
@@ -445,9 +451,9 @@ def test_handle_egress_ended_does_not_save_on_wrong_status(
|
||||
@pytest.mark.parametrize(
|
||||
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
|
||||
)
|
||||
@mock.patch("core.utils.update_room_metadata")
|
||||
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||
def test_handle_egress_ended_ignores_non_savable_recording(
|
||||
mock_update_room_metadata, status, service, settings
|
||||
mock_update_metadata, status, service, settings
|
||||
):
|
||||
"""Should handle non-savable recordings idempotently without raising.
|
||||
|
||||
@@ -468,11 +474,11 @@ def test_handle_egress_ended_ignores_non_savable_recording(
|
||||
|
||||
|
||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
||||
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
|
||||
def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
|
||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||
):
|
||||
"""Should clear lobby cache and delete telephony dispatch rule when room finishes."""
|
||||
"""Should clear lobby cache and delete SIP dispatch rule when room finishes."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
mock_room_name = uuid.uuid4()
|
||||
mock_data = mock.MagicMock()
|
||||
@@ -485,12 +491,31 @@ def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
|
||||
|
||||
|
||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
||||
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
|
||||
def test_handle_room_finished_deletes_dispatch_rule_when_only_roomkit_enabled(
|
||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||
):
|
||||
"""Should delete dispatch rule when only roomkit is enabled when room finishes."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = False
|
||||
settings.ROOMKIT_ENABLED = True
|
||||
mock_room_name = uuid.uuid4()
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(mock_room_name)
|
||||
|
||||
service._handle_room_finished(mock_data)
|
||||
|
||||
mock_delete_dispatch_rule.assert_called_once_with(mock_room_name)
|
||||
mock_clear_cache.assert_called_once_with(mock_room_name)
|
||||
|
||||
|
||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
|
||||
def test_handle_room_finished_skips_telephony_when_disabled(
|
||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||
):
|
||||
"""Should clear lobby cache but skip dispatch rule deletion when telephony is disabled."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = False
|
||||
settings.ROOMKIT_ENABLED = False
|
||||
mock_room_name = uuid.uuid4()
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(mock_room_name)
|
||||
@@ -504,7 +529,7 @@ def test_handle_room_finished_skips_telephony_when_disabled(
|
||||
@mock.patch.object(
|
||||
LobbyService, "clear_room_cache", side_effect=Exception("Test error")
|
||||
)
|
||||
@mock.patch.object(TelephonyService, "delete_dispatch_rule")
|
||||
@mock.patch.object(SIPManagement, "delete_dispatch_rule")
|
||||
def test_handle_room_finished_raises_error_when_cache_clearing_fails(
|
||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||
):
|
||||
@@ -527,9 +552,9 @@ def test_handle_room_finished_raises_error_when_cache_clearing_fails(
|
||||
|
||||
@mock.patch.object(LobbyService, "clear_room_cache")
|
||||
@mock.patch.object(
|
||||
TelephonyService,
|
||||
SIPManagement,
|
||||
"delete_dispatch_rule",
|
||||
side_effect=TelephonyException("Test error"),
|
||||
side_effect=SIPException("Test error"),
|
||||
)
|
||||
def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
|
||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||
@@ -540,7 +565,7 @@ def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
|
||||
mock_data.room.name = "00000000-0000-0000-0000-000000000000"
|
||||
|
||||
expected_error = (
|
||||
"Failed to delete telephony dispatch rule for room "
|
||||
"Failed to delete sip dispatch rule for room "
|
||||
"00000000-0000-0000-0000-000000000000"
|
||||
)
|
||||
|
||||
@@ -561,11 +586,11 @@ def test_handle_room_finished_raises_error_for_invalid_room_name(service):
|
||||
service._handle_room_finished(mock_data)
|
||||
|
||||
|
||||
@mock.patch.object(TelephonyService, "create_dispatch_rule")
|
||||
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
|
||||
def test_handle_room_started_creates_dispatch_rule_successfully(
|
||||
mock_create_dispatch_rule, service, settings
|
||||
mock_ensure_dispatch_rule, service, settings
|
||||
):
|
||||
"""Should create telephony dispatch rule when room starts successfully."""
|
||||
"""Should ensure the SIP dispatch rule exists when room starts successfully."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
room = RoomFactory()
|
||||
mock_data = mock.MagicMock()
|
||||
@@ -573,22 +598,75 @@ def test_handle_room_started_creates_dispatch_rule_successfully(
|
||||
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
mock_create_dispatch_rule.assert_called_once_with(room)
|
||||
mock_ensure_dispatch_rule.assert_called_once_with(room)
|
||||
|
||||
|
||||
@mock.patch.object(TelephonyService, "create_dispatch_rule")
|
||||
def test_handle_room_started_skips_dispatch_rule_when_telephony_disabled(
|
||||
mock_create_dispatch_rule, service, settings
|
||||
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
|
||||
def test_handle_room_started_creates_dispatch_rule_when_only_roomkit_enabled(
|
||||
mock_ensure_dispatch_rule, service, settings
|
||||
):
|
||||
"""Should skip creating telephony dispatch rule when telephony is disabled during room start."""
|
||||
"""Should ensure the dispatch rule exists when only roomkit is enabled during room start."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = False
|
||||
settings.ROOMKIT_ENABLED = True
|
||||
room = RoomFactory()
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(room.id)
|
||||
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
mock_create_dispatch_rule.assert_not_called()
|
||||
mock_ensure_dispatch_rule.assert_called_once_with(room)
|
||||
|
||||
|
||||
@mock.patch.object(SIPManagement, "ensure_dispatch_rule", return_value=False)
|
||||
def test_handle_room_started_ignores_existing_dispatch_rule(
|
||||
mock_ensure_dispatch_rule, service, settings
|
||||
):
|
||||
"""Should proceed silently when the dispatch rule already exists when room starts."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
room = RoomFactory()
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(room.id)
|
||||
|
||||
# ensure_dispatch_rule reports the rule as pre-existing: nothing to raise
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
mock_ensure_dispatch_rule.assert_called_once_with(room)
|
||||
|
||||
|
||||
@mock.patch.object(
|
||||
SIPManagement,
|
||||
"ensure_dispatch_rule",
|
||||
side_effect=SIPException("Test error"),
|
||||
)
|
||||
def test_handle_room_started_raises_error_when_dispatch_rule_creation_fails(
|
||||
mock_ensure_dispatch_rule, service, settings
|
||||
):
|
||||
"""Should raise ActionFailedError when ensuring the dispatch rule fails when room starts."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = True
|
||||
room = RoomFactory()
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(room.id)
|
||||
|
||||
expected_error = f"Failed to create sip dispatch rule for room {room.id}"
|
||||
|
||||
with pytest.raises(ActionFailedError, match=expected_error):
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
|
||||
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
|
||||
def test_handle_room_started_skips_dispatch_rule_when_telephony_disabled(
|
||||
mock_ensure_dispatch_rule, service, settings
|
||||
):
|
||||
"""Should skip ensuring the SIP dispatch rule when telephony is disabled during room start."""
|
||||
settings.ROOM_TELEPHONY_ENABLED = False
|
||||
settings.ROOMKIT_ENABLED = False
|
||||
room = RoomFactory()
|
||||
mock_data = mock.MagicMock()
|
||||
mock_data.room.name = str(room.id)
|
||||
|
||||
service._handle_room_started(mock_data)
|
||||
|
||||
mock_ensure_dispatch_rule.assert_not_called()
|
||||
|
||||
|
||||
def test_handle_room_started_raises_error_for_invalid_room_name(service):
|
||||
|
||||
@@ -9,13 +9,14 @@ import uuid
|
||||
from unittest import mock
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.core.cache import cache
|
||||
from django.http import HttpResponse
|
||||
|
||||
import pytest
|
||||
|
||||
from core.factories import RoomFactory
|
||||
from core.models import RoomAccessLevel
|
||||
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||
from core.models import RoleChoices, RoomAccessLevel
|
||||
from core.services.lobby import (
|
||||
LobbyParticipant,
|
||||
LobbyParticipantNotFound,
|
||||
@@ -188,17 +189,17 @@ def test_prepare_response_new_cookie(lobby_service, participant_id):
|
||||
|
||||
|
||||
def test_can_bypass_lobby_public_room(lobby_service):
|
||||
"""Should return True for public rooms regardless of user auth."""
|
||||
"""Should return True for public rooms regardless of user auth and role."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
|
||||
# Anonymous user
|
||||
user = mock.Mock()
|
||||
user.is_authenticated = False
|
||||
assert lobby_service.can_bypass_lobby(room, user) is True
|
||||
assert lobby_service.can_bypass_lobby(room, user, role=None) is True
|
||||
|
||||
# Authenticated user
|
||||
user.is_authenticated = True
|
||||
assert lobby_service.can_bypass_lobby(room, user) is True
|
||||
assert lobby_service.can_bypass_lobby(room, user, role=None) is True
|
||||
|
||||
|
||||
def test_can_bypass_lobby_trusted_room_authenticated(lobby_service):
|
||||
@@ -208,7 +209,7 @@ def test_can_bypass_lobby_trusted_room_authenticated(lobby_service):
|
||||
# Authenticated user
|
||||
user = mock.Mock()
|
||||
user.is_authenticated = True
|
||||
assert lobby_service.can_bypass_lobby(room, user) is True
|
||||
assert lobby_service.can_bypass_lobby(room, user, role=None) is True
|
||||
|
||||
|
||||
def test_can_bypass_lobby_trusted_room_anonymous(lobby_service):
|
||||
@@ -218,21 +219,34 @@ def test_can_bypass_lobby_trusted_room_anonymous(lobby_service):
|
||||
# Anonymous user
|
||||
user = mock.Mock()
|
||||
user.is_authenticated = False
|
||||
assert lobby_service.can_bypass_lobby(room, user) is False
|
||||
assert lobby_service.can_bypass_lobby(room, user, role=None) is False
|
||||
|
||||
|
||||
def test_can_bypass_lobby_private_room(lobby_service):
|
||||
"""Should return False for private rooms regardless of user auth."""
|
||||
"""Should return False for private rooms regardless of user auth if role is not."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
# Anonymous user
|
||||
user = mock.Mock()
|
||||
user.is_authenticated = False
|
||||
assert lobby_service.can_bypass_lobby(room, user) is False
|
||||
assert lobby_service.can_bypass_lobby(room, user, role=None) is False
|
||||
|
||||
# Authenticated user
|
||||
user.is_authenticated = True
|
||||
assert lobby_service.can_bypass_lobby(room, user) is False
|
||||
assert lobby_service.can_bypass_lobby(room, user, role=None) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"role",
|
||||
[RoleChoices.MEMBER, RoleChoices.ADMIN, RoleChoices.OWNER],
|
||||
)
|
||||
def test_can_bypass_lobby_private_room_with_any_role(role, lobby_service):
|
||||
"""Should return True for private rooms if the user is authenticated and has any role."""
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
user = mock.Mock()
|
||||
user.is_authenticated = True
|
||||
assert lobby_service.can_bypass_lobby(room, user, role=role) is True
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_livekit_config")
|
||||
@@ -241,7 +255,7 @@ def test_request_entry_public_room(
|
||||
):
|
||||
"""Test requesting entry to a public room."""
|
||||
request = mock.Mock()
|
||||
request.user = mock.Mock()
|
||||
request.user = AnonymousUser()
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||
|
||||
@@ -266,8 +280,8 @@ def test_request_entry_public_room(
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id="test-participant-id",
|
||||
role=None,
|
||||
)
|
||||
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
@@ -279,8 +293,7 @@ def test_request_entry_trusted_room(
|
||||
):
|
||||
"""Test requesting entry to a trusted room when the user is authenticated."""
|
||||
request = mock.Mock()
|
||||
request.user = mock.Mock()
|
||||
request.user.is_authenticated = True
|
||||
request.user = UserFactory()
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||
|
||||
@@ -305,8 +318,8 @@ def test_request_entry_trusted_room(
|
||||
username=username,
|
||||
color=participant.color,
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id="test-participant-id",
|
||||
role=None,
|
||||
)
|
||||
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
@@ -319,6 +332,7 @@ def test_request_entry_new_participant(
|
||||
"""Test requesting entry for a new participant."""
|
||||
request = mock.Mock()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
request.user = AnonymousUser()
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
@@ -348,6 +362,7 @@ def test_request_entry_waiting_participant(
|
||||
"""Test requesting entry for a waiting participant."""
|
||||
request = mock.Mock()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
request.user = AnonymousUser()
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
@@ -374,7 +389,7 @@ def test_request_entry_accepted_participant(
|
||||
):
|
||||
"""Test requesting entry for an accepted participant."""
|
||||
request = mock.Mock()
|
||||
request.user = mock.Mock()
|
||||
request.user = AnonymousUser()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
@@ -400,8 +415,48 @@ def test_request_entry_accepted_participant(
|
||||
username=username,
|
||||
color="#123456",
|
||||
configuration=room.configuration,
|
||||
is_admin_or_owner=False,
|
||||
participant_id="test-participant-id",
|
||||
role=None,
|
||||
)
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
@mock.patch("core.utils.generate_livekit_config")
|
||||
def test_request_entry_participant_with_role(
|
||||
mock_generate_config, lobby_service, participant_id, username
|
||||
):
|
||||
"""Test requesting entry for a participant with a role on the room."""
|
||||
request = mock.Mock()
|
||||
request.user = UserFactory()
|
||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||
|
||||
UserResourceAccessFactory(resource=room, user=request.user, role="administrator")
|
||||
|
||||
mocked_participant = LobbyParticipant(
|
||||
status=LobbyParticipantStatus.ACCEPTED,
|
||||
username=username,
|
||||
id=participant_id,
|
||||
color="#123456",
|
||||
)
|
||||
lobby_service._get_or_create_participant_id = mock.Mock(return_value=participant_id)
|
||||
lobby_service._get_participant = mock.Mock(return_value=mocked_participant)
|
||||
|
||||
mock_generate_config.return_value = {"token": "test-token"}
|
||||
|
||||
participant, livekit_config = lobby_service.request_entry(room, request, username)
|
||||
|
||||
assert participant.status == LobbyParticipantStatus.ACCEPTED
|
||||
assert livekit_config == {"token": "test-token"}
|
||||
mock_generate_config.assert_called_once_with(
|
||||
room_id=str(room.id),
|
||||
user=request.user,
|
||||
username=username,
|
||||
color="#123456",
|
||||
configuration=room.configuration,
|
||||
participant_id="test-participant-id",
|
||||
role="administrator",
|
||||
)
|
||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
||||
|
||||
|
||||
+162
-35
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Test telephony service.
|
||||
Test SIP mamagement service.
|
||||
"""
|
||||
|
||||
# pylint: disable=W0212
|
||||
@@ -20,7 +20,11 @@ from livekit.protocol.sip import (
|
||||
|
||||
from core.factories import RoomFactory
|
||||
from core.models import RoomAccessLevel
|
||||
from core.services.telephony import TelephonyException, TelephonyService
|
||||
from core.services.sip_management import (
|
||||
DispatchRuleConflictError,
|
||||
SIPException,
|
||||
SIPManagement,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
@@ -35,9 +39,9 @@ def create_mock_livekit_client():
|
||||
|
||||
def test_rule_name():
|
||||
"""Test rule name generation."""
|
||||
telephony_service = TelephonyService()
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
rule_name = telephony_service._rule_name(room.id)
|
||||
rule_name = sip_management._rule_name(room.id)
|
||||
|
||||
assert rule_name == f"SIP_{str(room.id)}"
|
||||
|
||||
@@ -45,14 +49,14 @@ def test_rule_name():
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_create_dispatch_rule_success(mock_client_factory):
|
||||
"""Test successful dispatch rule creation."""
|
||||
telephony_service = TelephonyService()
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_api = create_mock_livekit_client()
|
||||
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock()
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
telephony_service.create_dispatch_rule(room)
|
||||
sip_management.create_dispatch_rule(room)
|
||||
|
||||
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
|
||||
create_request = mock_api.sip.create_sip_dispatch_rule.call_args[1]["create"]
|
||||
@@ -67,7 +71,7 @@ def test_create_dispatch_rule_success(mock_client_factory):
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_create_dispatch_rule_api_failure(mock_client_factory):
|
||||
"""Test dispatch rule creation when API fails."""
|
||||
telephony_service = TelephonyService()
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_api = create_mock_livekit_client()
|
||||
@@ -76,8 +80,8 @@ def test_create_dispatch_rule_api_failure(mock_client_factory):
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
with pytest.raises(TelephonyException, match="Could not create dispatch rule"):
|
||||
telephony_service.create_dispatch_rule(room)
|
||||
with pytest.raises(SIPException, match="Could not create dispatch rule"):
|
||||
sip_management.create_dispatch_rule(room)
|
||||
|
||||
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
|
||||
mock_api.aclose.assert_called_once()
|
||||
@@ -86,7 +90,7 @@ def test_create_dispatch_rule_api_failure(mock_client_factory):
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_list_dispatch_rules_ids_success(mock_client_factory):
|
||||
"""Test successful listing of dispatch rule IDs."""
|
||||
telephony_service = TelephonyService()
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_rules = [
|
||||
@@ -111,7 +115,7 @@ def test_list_dispatch_rules_ids_success(mock_client_factory):
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
|
||||
result = async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
|
||||
|
||||
assert len(result) == 2
|
||||
assert "rule-1" in result
|
||||
@@ -127,7 +131,7 @@ def test_list_dispatch_rules_ids_success(mock_client_factory):
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
|
||||
"""Test listing dispatch rule IDs when no rules exist."""
|
||||
telephony_service = TelephonyService()
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_api = create_mock_livekit_client()
|
||||
@@ -136,7 +140,7 @@ def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
|
||||
result = async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
|
||||
|
||||
assert result == []
|
||||
mock_api.aclose.assert_called_once()
|
||||
@@ -145,7 +149,7 @@ def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
|
||||
"""Test listing dispatch rule IDs when no rules match the room."""
|
||||
telephony_service = TelephonyService()
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_rules = [
|
||||
@@ -163,7 +167,7 @@ def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
result = async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
|
||||
result = async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
|
||||
|
||||
assert result == []
|
||||
mock_api.aclose.assert_called_once()
|
||||
@@ -172,7 +176,7 @@ def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_list_dispatch_rules_ids_api_failure(mock_client_factory):
|
||||
"""Test listing dispatch rule IDs when API fails."""
|
||||
telephony_service = TelephonyService()
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_api = create_mock_livekit_client()
|
||||
@@ -181,34 +185,34 @@ def test_list_dispatch_rules_ids_api_failure(mock_client_factory):
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
with pytest.raises(TelephonyException, match="Could not list dispatch rules"):
|
||||
async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
|
||||
with pytest.raises(SIPException, match="Could not list dispatch rules"):
|
||||
async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
|
||||
|
||||
mock_api.sip.list_sip_dispatch_rule.assert_called_once()
|
||||
mock_api.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_delete_dispatch_rule_no_rules(mock_client_factory, mock_list_rules):
|
||||
"""Test deleting dispatch rules when no rules exist."""
|
||||
telephony_service = TelephonyService()
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_list_rules.return_value = []
|
||||
|
||||
result = telephony_service.delete_dispatch_rule(room.id)
|
||||
result = sip_management.delete_dispatch_rule(room.id)
|
||||
|
||||
assert result is False
|
||||
mock_list_rules.assert_called_once_with(room.id)
|
||||
mock_client_factory.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
|
||||
"""Test deleting a single dispatch rule."""
|
||||
telephony_service = TelephonyService()
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_list_rules.return_value = ["rule-1"]
|
||||
@@ -216,7 +220,7 @@ def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
|
||||
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
result = telephony_service.delete_dispatch_rule(room.id)
|
||||
result = sip_management.delete_dispatch_rule(room.id)
|
||||
|
||||
assert result is True
|
||||
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
|
||||
@@ -226,11 +230,11 @@ def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
|
||||
mock_api.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rules):
|
||||
"""Test deleting multiple dispatch rules."""
|
||||
telephony_service = TelephonyService()
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
|
||||
@@ -238,7 +242,7 @@ def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rule
|
||||
mock_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
result = telephony_service.delete_dispatch_rule(room.id)
|
||||
result = sip_management.delete_dispatch_rule(room.id)
|
||||
|
||||
assert result is True
|
||||
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 3
|
||||
@@ -253,11 +257,11 @@ def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rule
|
||||
mock_api.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rules):
|
||||
"""Test deleting multiple dispatch rules when one deletion fails."""
|
||||
telephony_service = TelephonyService()
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
|
||||
@@ -277,18 +281,18 @@ def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rul
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"):
|
||||
telephony_service.delete_dispatch_rule(room.id)
|
||||
with pytest.raises(SIPException, match="Could not delete dispatch rules"):
|
||||
sip_management.delete_dispatch_rule(room.id)
|
||||
|
||||
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 2
|
||||
mock_api.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.services.telephony.TelephonyService._list_dispatch_rules_ids")
|
||||
@mock.patch("core.services.sip_management.SIPManagement._list_dispatch_rules_ids")
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules):
|
||||
"""Test deleting dispatch rules when API fails immediately."""
|
||||
telephony_service = TelephonyService()
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_list_rules.return_value = ["rule-1"]
|
||||
@@ -298,8 +302,131 @@ def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules):
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"):
|
||||
telephony_service.delete_dispatch_rule(room.id)
|
||||
with pytest.raises(SIPException, match="Could not delete dispatch rules"):
|
||||
sip_management.delete_dispatch_rule(room.id)
|
||||
|
||||
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
|
||||
mock_api.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_create_dispatch_rule_conflict_raises_dedicated_error(mock_client_factory):
|
||||
"""Test that a LiveKit conflict error raises DispatchRuleConflictError."""
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_api = create_mock_livekit_client()
|
||||
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock(
|
||||
side_effect=TwirpError(
|
||||
msg=(
|
||||
"Dispatch rule for the same trunk, inbound number, number, and "
|
||||
"PIN combination already exists in dispatch rule"
|
||||
),
|
||||
code="already_exists",
|
||||
status=409,
|
||||
)
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
with pytest.raises(DispatchRuleConflictError):
|
||||
sip_management.create_dispatch_rule(room)
|
||||
|
||||
mock_api.aclose.assert_called_once()
|
||||
|
||||
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_ensure_dispatch_rule_creates_when_missing(mock_client_factory):
|
||||
"""Test that ensure_dispatch_rule creates the rule when none exists."""
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_api = create_mock_livekit_client()
|
||||
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
|
||||
return_value=ListSIPDispatchRuleResponse(items=[])
|
||||
)
|
||||
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock()
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
created = sip_management.ensure_dispatch_rule(room)
|
||||
|
||||
assert created is True
|
||||
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
|
||||
create_request = mock_api.sip.create_sip_dispatch_rule.call_args[1]["create"]
|
||||
|
||||
assert isinstance(create_request, CreateSIPDispatchRuleRequest)
|
||||
assert create_request.name == f"SIP_{str(room.id)}"
|
||||
assert create_request.rule.dispatch_rule_direct.room_name == str(room.id)
|
||||
assert create_request.rule.dispatch_rule_direct.pin == str(room.pin_code)
|
||||
|
||||
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_ensure_dispatch_rule_skips_when_existing(mock_client_factory):
|
||||
"""Test that ensure_dispatch_rule is idempotent when the rule already exists."""
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
existing_rule = SIPDispatchRuleInfo(
|
||||
sip_dispatch_rule_id="rule-1", name=f"SIP_{str(room.id)}"
|
||||
)
|
||||
mock_api = create_mock_livekit_client()
|
||||
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
|
||||
return_value=ListSIPDispatchRuleResponse(items=[existing_rule])
|
||||
)
|
||||
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock()
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
created = sip_management.ensure_dispatch_rule(room)
|
||||
|
||||
assert created is False
|
||||
mock_api.sip.create_sip_dispatch_rule.assert_not_called()
|
||||
|
||||
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_ensure_dispatch_rule_returns_false_on_conflict(mock_client_factory):
|
||||
"""Test that ensure_dispatch_rule tolerates a concurrent rule creation.
|
||||
|
||||
If the rule is created by a concurrent caller (e.g. the LiveKit webhook)
|
||||
between the existence check and the creation, LiveKit rejects the
|
||||
duplicate and ensure_dispatch_rule reports the rule as already existing.
|
||||
"""
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_api = create_mock_livekit_client()
|
||||
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
|
||||
return_value=ListSIPDispatchRuleResponse(items=[])
|
||||
)
|
||||
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock(
|
||||
side_effect=TwirpError(
|
||||
msg=(
|
||||
"Dispatch rule for the same trunk, inbound number, number, and "
|
||||
"PIN combination already exists in dispatch rule"
|
||||
),
|
||||
code="already_exists",
|
||||
status=409,
|
||||
)
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
created = sip_management.ensure_dispatch_rule(room)
|
||||
|
||||
assert created is False
|
||||
|
||||
|
||||
@mock.patch("core.utils.create_livekit_client")
|
||||
def test_ensure_dispatch_rule_raises_on_other_failures(mock_client_factory):
|
||||
"""Test that ensure_dispatch_rule propagates unexpected LiveKit failures."""
|
||||
sip_management = SIPManagement()
|
||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||
|
||||
mock_api = create_mock_livekit_client()
|
||||
mock_api.sip.list_sip_dispatch_rule = mock.AsyncMock(
|
||||
return_value=ListSIPDispatchRuleResponse(items=[])
|
||||
)
|
||||
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock(
|
||||
side_effect=TwirpError(msg="Internal server error", code="unknown", status=500)
|
||||
)
|
||||
mock_client_factory.return_value = mock_api
|
||||
|
||||
with pytest.raises(SIPException, match="Could not create dispatch rule"):
|
||||
sip_management.ensure_dispatch_rule(room)
|
||||
@@ -119,6 +119,8 @@ def test_api_users_retrieve_me_authenticated(settings):
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {
|
||||
"default_room_access_level": None,
|
||||
"default_room_configuration": {},
|
||||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
"full_name": user.full_name,
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
Test the default room preferences exposed on the users API.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from core import factories
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_api_users_me_includes_default_room_preferences():
|
||||
"""The "me" endpoint should expose the user's default room preferences."""
|
||||
user = factories.UserFactory(
|
||||
default_room_access_level="restricted",
|
||||
default_room_configuration={"everyone_can_mute": False},
|
||||
)
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.get("/api/v1.0/users/me/")
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.json()
|
||||
assert content["default_room_access_level"] == "restricted"
|
||||
assert content["default_room_configuration"] == {"everyone_can_mute": False}
|
||||
|
||||
|
||||
def test_api_users_update_default_room_preferences():
|
||||
"""Users should be able to update their own default room preferences."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/users/{user.id!s}/",
|
||||
{
|
||||
"default_room_access_level": "trusted",
|
||||
"default_room_configuration": {
|
||||
"can_publish_sources": ["microphone", "camera"],
|
||||
"everyone_can_mute": False,
|
||||
},
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
user.refresh_from_db()
|
||||
assert user.default_room_access_level == "trusted"
|
||||
assert user.default_room_configuration == {
|
||||
"can_publish_sources": ["microphone", "camera"],
|
||||
"everyone_can_mute": False,
|
||||
}
|
||||
|
||||
|
||||
def test_api_users_update_default_room_access_level_invalid():
|
||||
"""An invalid access level should be rejected."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/users/{user.id!s}/",
|
||||
{"default_room_access_level": "invalid"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
user.refresh_from_db()
|
||||
assert user.default_room_access_level is None
|
||||
|
||||
|
||||
def test_api_users_update_default_room_configuration_invalid():
|
||||
"""An invalid room configuration should be rejected."""
|
||||
user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/users/{user.id!s}/",
|
||||
{"default_room_configuration": {"unknown_field": True}},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
user.refresh_from_db()
|
||||
assert user.default_room_configuration == {}
|
||||
|
||||
|
||||
def test_api_users_update_other_user_default_room_preferences_forbidden():
|
||||
"""Users should not be able to update someone else's preferences."""
|
||||
user = factories.UserFactory()
|
||||
other_user = factories.UserFactory()
|
||||
|
||||
client = APIClient()
|
||||
client.force_login(user)
|
||||
|
||||
response = client.patch(
|
||||
f"/api/v1.0/users/{other_user.id!s}/",
|
||||
{"default_room_access_level": "restricted"},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
other_user.refresh_from_db()
|
||||
assert other_user.default_room_access_level is None
|
||||
@@ -184,12 +184,13 @@ def test_models_rooms_is_public_property():
|
||||
|
||||
|
||||
@mock.patch.object(Room, "generate_unique_pin_code")
|
||||
def test_telephony_disabled_skips_pin_generation(
|
||||
def test_telephony_and_roomkit_disabled_skips_pin_generation(
|
||||
mock_generate_unique_pin_code, settings
|
||||
):
|
||||
"""Telephony disabled should not generate pin codes."""
|
||||
"""Telephony and roomkit both disabled should not generate pin codes."""
|
||||
|
||||
settings.ROOM_TELEPHONY_ENABLED = False
|
||||
settings.ROOMKIT_ENABLED = False
|
||||
|
||||
room = RoomFactory()
|
||||
|
||||
@@ -197,6 +198,18 @@ def test_telephony_disabled_skips_pin_generation(
|
||||
assert room.pin_code is None
|
||||
|
||||
|
||||
def test_roomkit_enabled_generates_pin_code(settings):
|
||||
"""Roomkit enabled alone should generate pin codes, even without telephony."""
|
||||
|
||||
settings.ROOM_TELEPHONY_ENABLED = False
|
||||
settings.ROOMKIT_ENABLED = True
|
||||
|
||||
room = RoomFactory()
|
||||
|
||||
assert room.pin_code is not None
|
||||
assert len(room.pin_code) == settings.ROOM_TELEPHONY_PIN_LENGTH
|
||||
|
||||
|
||||
def test_default_and_custom_pin_length(settings):
|
||||
"""Pin codes should be created with correct configured length."""
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ from rest_framework.routers import DefaultRouter, SimpleRouter
|
||||
from core.addons import viewsets as addons_viewsets
|
||||
from core.api import get_frontend_configuration, viewsets
|
||||
from core.external_api import viewsets as external_viewsets
|
||||
from core.roomkit import viewsets as roomkit_viewsets
|
||||
|
||||
# - Main endpoints
|
||||
router = DefaultRouter()
|
||||
@@ -19,6 +20,11 @@ router.register("files", viewsets.FileViewSet, basename="files")
|
||||
router.register(
|
||||
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
|
||||
)
|
||||
router.register(
|
||||
"roomkit",
|
||||
roomkit_viewsets.RoomKitViewSet,
|
||||
basename="roomkit",
|
||||
)
|
||||
router.register(
|
||||
"addons/sessions",
|
||||
addons_viewsets.SessionViewSet,
|
||||
|
||||
+49
-58
@@ -17,6 +17,7 @@ from typing import List, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.core.files.storage import default_storage
|
||||
|
||||
import aiohttp
|
||||
@@ -25,13 +26,13 @@ import botocore
|
||||
import magic
|
||||
import phonenumbers
|
||||
from asgiref.sync import async_to_sync
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from livekit.api import ( # pylint: disable=E0611
|
||||
AccessToken,
|
||||
ListRoomsRequest,
|
||||
LiveKitAPI,
|
||||
SendDataRequest,
|
||||
TwirpError,
|
||||
UpdateRoomMetadataRequest,
|
||||
VideoGrants,
|
||||
)
|
||||
|
||||
@@ -66,7 +67,7 @@ def generate_token(
|
||||
username: Optional[str] = None,
|
||||
color: Optional[str] = None,
|
||||
sources: Optional[List[str]] = None,
|
||||
is_admin_or_owner: bool = False,
|
||||
role: Optional[str] = None,
|
||||
participant_id: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Generate a LiveKit access token for a user in a specific room.
|
||||
@@ -80,7 +81,7 @@ def generate_token(
|
||||
If none, a value will be generated
|
||||
sources: (Optional[List[str]]): List of media sources the user can publish
|
||||
If none, defaults to LIVEKIT_DEFAULT_SOURCES.
|
||||
is_admin_or_owner (bool): Whether user has admin privileges
|
||||
role (Optional[str]): Room's access role if any
|
||||
participant_id (Optional[str]): Stable identifier for anonymous users;
|
||||
used as identity when user.is_anonymous.
|
||||
|
||||
@@ -88,6 +89,7 @@ def generate_token(
|
||||
str: The LiveKit JWT access token.
|
||||
"""
|
||||
|
||||
is_admin_or_owner = role in ("owner", "administrator")
|
||||
if is_admin_or_owner:
|
||||
sources = settings.LIVEKIT_DEFAULT_SOURCES
|
||||
|
||||
@@ -128,7 +130,11 @@ def generate_token(
|
||||
.with_identity(identity)
|
||||
.with_name(display_name)
|
||||
.with_attributes(
|
||||
{"color": color, "room_admin": "true" if is_admin_or_owner else "false"}
|
||||
{
|
||||
"color": color,
|
||||
"room_role": role,
|
||||
"is_authenticated": "true" if user.is_authenticated else "false",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -139,7 +145,7 @@ def generate_livekit_config(
|
||||
room_id: str,
|
||||
user,
|
||||
username: str,
|
||||
is_admin_or_owner: bool,
|
||||
role: Optional[str] = None,
|
||||
color: Optional[str] = None,
|
||||
configuration: Optional[dict] = None,
|
||||
participant_id: Optional[str] = None,
|
||||
@@ -150,7 +156,7 @@ def generate_livekit_config(
|
||||
room_id: Room identifier
|
||||
user: User instance requesting access
|
||||
username: Display name in room
|
||||
is_admin_or_owner (bool): Whether the user has admin/owner privileges for this room.
|
||||
role (str): Room's access role if any
|
||||
color (Optional[str]): Optional color to associate with the participant.
|
||||
configuration (Optional[dict]): Room configuration dict that can override default settings.
|
||||
participant_id (Optional[str]): Stable identifier for anonymous users;
|
||||
@@ -173,7 +179,7 @@ def generate_livekit_config(
|
||||
username=username,
|
||||
color=color,
|
||||
sources=sources,
|
||||
is_admin_or_owner=is_admin_or_owner,
|
||||
role=role,
|
||||
participant_id=participant_id,
|
||||
),
|
||||
}
|
||||
@@ -258,57 +264,6 @@ async def notify_participants(room_name: str, notification_data: dict):
|
||||
await lkapi.aclose()
|
||||
|
||||
|
||||
class MetadataUpdateException(Exception):
|
||||
"""Room's metadata update fails."""
|
||||
|
||||
|
||||
@async_to_sync
|
||||
async def update_room_metadata(
|
||||
room_name: str, metadata: dict, remove_keys: Optional[list[str]] = None
|
||||
):
|
||||
"""Update LiveKit room metadata by merging new values with existing metadata.
|
||||
|
||||
Args:
|
||||
room_name: Name of the room to update
|
||||
metadata: Dictionary of metadata key-values to add/update
|
||||
remove_keys: Optional list of keys to remove from existing metadata.
|
||||
"""
|
||||
|
||||
lkapi = create_livekit_client()
|
||||
|
||||
try:
|
||||
response = await lkapi.room.list_rooms(
|
||||
ListRoomsRequest(
|
||||
names=[room_name],
|
||||
)
|
||||
)
|
||||
|
||||
if not response.rooms:
|
||||
return
|
||||
|
||||
room = response.rooms[0]
|
||||
|
||||
existing_metadata = json.loads(room.metadata) if room.metadata else {}
|
||||
|
||||
if remove_keys:
|
||||
for key in remove_keys:
|
||||
existing_metadata.pop(key, None)
|
||||
|
||||
updated_metadata = {**existing_metadata, **metadata}
|
||||
|
||||
await lkapi.room.update_room_metadata(
|
||||
UpdateRoomMetadataRequest(
|
||||
room=room_name, metadata=json.dumps(updated_metadata).encode("utf-8")
|
||||
)
|
||||
)
|
||||
except TwirpError as e:
|
||||
raise MetadataUpdateException(
|
||||
f"Failed to update metadata for room {room_name}: {e}"
|
||||
) from e
|
||||
finally:
|
||||
await lkapi.aclose()
|
||||
|
||||
|
||||
ALPHANUMERIC_CHARSET = string.ascii_letters + string.digits
|
||||
|
||||
|
||||
@@ -464,6 +419,42 @@ def generate_upload_policy(file):
|
||||
return policy
|
||||
|
||||
|
||||
class SecretDecryptionError(Exception):
|
||||
"""Raised when a stored secret cannot be decrypted."""
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_cipher_suite():
|
||||
"""Return the Fernet key used to encrypt secrets at rest.
|
||||
|
||||
The same key as django-lasuite's OIDC token storage.
|
||||
"""
|
||||
|
||||
key = settings.OIDC_STORE_REFRESH_TOKEN_KEY
|
||||
|
||||
if not key:
|
||||
raise ImproperlyConfigured("OIDC_STORE_REFRESH_TOKEN_KEY setting is required.")
|
||||
|
||||
return Fernet(key)
|
||||
|
||||
|
||||
def encrypt_secret(value: str) -> str:
|
||||
"""Encrypt a secret meant to be stored at rest."""
|
||||
|
||||
return get_cipher_suite().encrypt(value.encode()).decode()
|
||||
|
||||
|
||||
def decrypt_secret(value: str) -> str:
|
||||
"""Decrypt a secret stored by `encrypt_secret`."""
|
||||
|
||||
try:
|
||||
return get_cipher_suite().decrypt(value.encode()).decode()
|
||||
except InvalidToken as exc:
|
||||
raise SecretDecryptionError(
|
||||
"The stored secret could not be decrypted."
|
||||
) from exc
|
||||
|
||||
|
||||
def generate_download_s3_url(
|
||||
key: str, *, expires_in: int, override_domain: bool = True
|
||||
):
|
||||
|
||||
@@ -349,6 +349,11 @@ class Base(Configuration):
|
||||
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
"roomkit_join": values.Value(
|
||||
default="300/minute",
|
||||
environ_name="ROOMKIT_JOIN_THROTTLE_RATES",
|
||||
environ_prefix=None,
|
||||
),
|
||||
},
|
||||
}
|
||||
MONITORED_THROTTLE_FAILURE_CALLBACK = (
|
||||
@@ -568,6 +573,20 @@ class Base(Configuration):
|
||||
OIDC_STORE_ID_TOKEN = values.BooleanValue(
|
||||
default=True, environ_name="OIDC_STORE_ID_TOKEN", environ_prefix=None
|
||||
)
|
||||
# Required to call other La Suite applications on behalf of the user, e.g.
|
||||
# to push a recording to their Drive.
|
||||
OIDC_STORE_ACCESS_TOKEN = values.BooleanValue(
|
||||
default=False, environ_name="OIDC_STORE_ACCESS_TOKEN", environ_prefix=None
|
||||
)
|
||||
OIDC_STORE_REFRESH_TOKEN = values.BooleanValue(
|
||||
default=False, environ_name="OIDC_STORE_REFRESH_TOKEN", environ_prefix=None
|
||||
)
|
||||
# Fernet key used to encrypt OIDC tokens at rest, both the refresh token
|
||||
# django-lasuite stores in the session and the access token stored on a
|
||||
# recording. Generate one with `Fernet.generate_key()`.
|
||||
OIDC_STORE_REFRESH_TOKEN_KEY = SecretFileValue(
|
||||
None, environ_name="OIDC_STORE_REFRESH_TOKEN_KEY", environ_prefix=None
|
||||
)
|
||||
ALLOW_LOGOUT_GET_METHOD = values.BooleanValue(
|
||||
default=True, environ_name="ALLOW_LOGOUT_GET_METHOD", environ_prefix=None
|
||||
)
|
||||
@@ -720,6 +739,29 @@ class Base(Configuration):
|
||||
None, environ_name="RECORDING_MAX_DURATION", environ_prefix=None
|
||||
)
|
||||
|
||||
# Push recordings to Drive
|
||||
# Once a recording is over, it is pushed to the user's
|
||||
# started it, using their OIDC access token. It requires OIDC_STORE_ACCESS_TOKEN,
|
||||
# and Drive to be configured as an OIDC resource server accepting Meet's audience.
|
||||
RECORDING_PUSH_TO_DRIVE_ENABLED = values.BooleanValue(
|
||||
False, environ_name="RECORDING_PUSH_TO_DRIVE_ENABLED", environ_prefix=None
|
||||
)
|
||||
# Base URL of Drive's external API, e.g. https://fichiers.numerique.gouv.fr/external_api/v1.0
|
||||
DRIVE_API_BASE_URL = values.Value(
|
||||
None, environ_name="DRIVE_API_BASE_URL", environ_prefix=None
|
||||
)
|
||||
# Lifetime of the signed URL the worker downloads the recording from.
|
||||
RECORDING_PUSH_TO_DRIVE_SIGNED_URL_EXPIRY_SECONDS = values.PositiveIntegerValue(
|
||||
60 * 60,
|
||||
environ_name="RECORDING_PUSH_TO_DRIVE_SIGNED_URL_EXPIRY_SECONDS",
|
||||
environ_prefix=None,
|
||||
)
|
||||
# Development only: host:port to reach Drive's object storage at, when the
|
||||
# domain Drive signs its upload URLs with is only resolvable from a browser.
|
||||
DRIVE_UPLOAD_STORAGE_NETLOC = values.Value(
|
||||
None, environ_name="DRIVE_UPLOAD_STORAGE_NETLOC", environ_prefix=None
|
||||
)
|
||||
|
||||
# Recording encoding options for LiveKit Egress (video composite egress only).
|
||||
# These settings affect screen recordings handled by VideoCompositeEgressService;
|
||||
# they are silently ignored by AudioCompositeEgressService (audio-only transcript
|
||||
@@ -881,6 +923,21 @@ class Base(Configuration):
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Roomkit (meeting-room SIP devices) integration
|
||||
ROOMKIT_ENABLED = values.BooleanValue(
|
||||
False,
|
||||
environ_name="ROOMKIT_ENABLED",
|
||||
environ_prefix=None,
|
||||
)
|
||||
# Server-to-server API token allowing the LiveKit SIP module to call the
|
||||
# roomkit endpoints (e.g. join a room on behalf of a meeting-room device
|
||||
# dialing in before any WebRTC participant).
|
||||
ROOMKIT_SERVER_TO_SERVER_API_TOKEN = SecretFileValue(
|
||||
None,
|
||||
environ_name="ROOMKIT_SERVER_TO_SERVER_API_TOKEN",
|
||||
environ_prefix=None,
|
||||
)
|
||||
|
||||
# Subtitles settings
|
||||
ROOM_SUBTITLE_ENABLED = values.BooleanValue(
|
||||
False, environ_name="ROOM_SUBTITLE_ENABLED", environ_prefix=None
|
||||
|
||||
@@ -40,7 +40,7 @@ dependencies = [
|
||||
"django-storages[s3]==1.14.6",
|
||||
"django-timezone-field>=5.1",
|
||||
"django-pydantic-field==0.5.4",
|
||||
"django==5.2.14",
|
||||
"django==5.2.16",
|
||||
"djangorestframework==3.17.1",
|
||||
"drf_spectacular==0.29.0",
|
||||
"dockerflow==2026.3.4",
|
||||
|
||||
Generated
+4
-4
@@ -586,16 +586,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "django"
|
||||
version = "5.2.14"
|
||||
version = "5.2.16"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "asgiref" },
|
||||
{ name = "sqlparse" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/95/95f7faa0950867afaa0bef2460c6263afd6a2c78cc9434046ed28160b015/django-5.2.14.tar.gz", hash = "sha256:58a63ba841662e5c686b57ba1fec52ddd68c0b93bd96ac3029d55728f00bf8a2", size = 10895118, upload-time = "2026-05-05T13:57:31.104Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a9/26/889449d521ae508b26de715954faecd8bcf3f740affb81b2d146a83b42a5/django-5.2.16.tar.gz", hash = "sha256:59ea02020c3136fce14bef0bbece21a10a4febef5eed1c51c22ae468efa22200", size = 10890894, upload-time = "2026-07-07T13:52:17.005Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/44/f172870cf87aa25afef48fb72adba89ee8b77fcab6f3b23d240b923f1528/django-5.2.14-py3-none-any.whl", hash = "sha256:6f712143bd3064310d1f50fac859c3e9a274bdcfc9595339853be7779297fc76", size = 8311320, upload-time = "2026-05-05T13:57:25.795Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/13/1e5e3e4c15dcecb04281b3cb2a46a4670e1cef131068e202f6040df19224/django-5.2.16-py3-none-any.whl", hash = "sha256:04f354bf9d807a86ad1a8392fe3808d362358a8eafc322848e0e43e59b24371d", size = 8311943, upload-time = "2026-07-07T13:52:11.223Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1260,7 +1260,7 @@ requires-dist = [
|
||||
{ name = "brotli", specifier = "==1.2.0" },
|
||||
{ name = "celery", extras = ["redis"], specifier = "==5.6.3" },
|
||||
{ name = "dj-database-url", specifier = "==3.1.2" },
|
||||
{ name = "django", specifier = "==5.2.14" },
|
||||
{ name = "django", specifier = "==5.2.16" },
|
||||
{ name = "django-configurations", specifier = "==2.5.1" },
|
||||
{ name = "django-cors-headers", specifier = "==4.9.0" },
|
||||
{ name = "django-countries", specifier = "==9.0.0" },
|
||||
|
||||
@@ -114,6 +114,10 @@ const config: Config = {
|
||||
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 0)',
|
||||
},
|
||||
},
|
||||
overlayIn: {
|
||||
from: { opacity: 0 },
|
||||
to: { opacity: 0.6 },
|
||||
},
|
||||
},
|
||||
tokens: defineTokens({
|
||||
/* we take a few things from the panda preset but for now we clear out some stuff.
|
||||
|
||||
@@ -2,6 +2,7 @@ import { fetchApi } from './fetchApi'
|
||||
import { keys } from './queryKeys'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { RecordingMode } from '@/features/recording'
|
||||
import type { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
||||
import type { Track } from 'livekit-client'
|
||||
type Source = Track.Source
|
||||
|
||||
@@ -49,6 +50,9 @@ export interface ApiConfig {
|
||||
international_phone_number?: string
|
||||
default_country?: string
|
||||
}
|
||||
resource?: {
|
||||
default_access_level?: ApiAccessLevel
|
||||
}
|
||||
manifest_link?: string
|
||||
livekit: {
|
||||
url: string
|
||||
|
||||
@@ -28,6 +28,15 @@ const avatar = cva({
|
||||
},
|
||||
})
|
||||
|
||||
const getInitials = (name?: string): string => {
|
||||
if (!name) return ''
|
||||
const words = name.trim().split(/\s+/).filter(Boolean)
|
||||
if (words.length === 0) return ''
|
||||
const first = words[0].charAt(0)
|
||||
const second = words.length > 1 ? words[1].charAt(0) : ''
|
||||
return (first + second).toUpperCase()
|
||||
}
|
||||
|
||||
export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
name?: string
|
||||
bgColor?: string
|
||||
@@ -35,7 +44,7 @@ export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
|
||||
export const Avatar = React.memo(
|
||||
({ name, bgColor, context, notification, style, ...props }: AvatarProps) => {
|
||||
const initial = name?.trim()?.charAt(0) ?? ''
|
||||
const initials = getInitials(name)
|
||||
return (
|
||||
<div
|
||||
style={{ backgroundColor: bgColor, ...style }}
|
||||
@@ -50,13 +59,14 @@ export const Avatar = React.memo(
|
||||
<text
|
||||
x="50"
|
||||
y="50"
|
||||
dy="-0.08em"
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
fontSize="52"
|
||||
fontWeight="500"
|
||||
fill="currentColor"
|
||||
>
|
||||
{initial}
|
||||
{initials.toUpperCase()}
|
||||
</text>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { BackendLanguage } from '@/utils/languages'
|
||||
import type {
|
||||
ApiAccessLevel,
|
||||
RoomConfiguration,
|
||||
} from '@/features/rooms/api/ApiRoom'
|
||||
|
||||
export type ApiUser = {
|
||||
id: string
|
||||
@@ -7,4 +11,6 @@ export type ApiUser = {
|
||||
last_name: string
|
||||
language: BackendLanguage
|
||||
timezone: string
|
||||
default_room_access_level?: ApiAccessLevel | null
|
||||
default_room_configuration?: RoomConfiguration | null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useMutation, type UseMutationOptions } from '@tanstack/react-query'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import type { ApiError } from '@/api/ApiError'
|
||||
import { type ApiUser } from './ApiUser'
|
||||
|
||||
export type PatchUserParams = {
|
||||
userId: string
|
||||
user: Partial<
|
||||
Pick<
|
||||
ApiUser,
|
||||
| 'timezone'
|
||||
| 'language'
|
||||
| 'default_room_access_level'
|
||||
| 'default_room_configuration'
|
||||
>
|
||||
>
|
||||
}
|
||||
|
||||
export const patchUser = ({ userId, user }: PatchUserParams) => {
|
||||
return fetchApi<ApiUser>(`/users/${userId}/`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(user),
|
||||
})
|
||||
}
|
||||
|
||||
export const patchUserMutationKey = ['patchUser']
|
||||
|
||||
export function usePatchUser(
|
||||
options?: UseMutationOptions<ApiUser, ApiError, PatchUserParams>
|
||||
) {
|
||||
return useMutation<ApiUser, ApiError, PatchUserParams>({
|
||||
mutationKey: patchUserMutationKey,
|
||||
mutationFn: patchUser,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { ref } from 'valtio'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import React, { useEffect } from 'react'
|
||||
import { useChat, useRoomContext } from '@livekit/components-react'
|
||||
import { appendRow, chatStore } from '@/stores/chat'
|
||||
import { appendRow, chatStore, resetChatStore } from '@/stores/chat'
|
||||
import type { ChatMessage } from '@livekit/components-core'
|
||||
import {
|
||||
LocalParticipant,
|
||||
@@ -19,6 +19,10 @@ export const ChatProvider = () => {
|
||||
|
||||
const room = useRoomContext()
|
||||
|
||||
useEffect(() => {
|
||||
resetChatStore()
|
||||
}, [])
|
||||
|
||||
// Tigger the message notification (temporary)
|
||||
useEffect(() => {
|
||||
// TEMPORARY: This is a brittle workaround that relies on message count tracking
|
||||
|
||||
@@ -11,6 +11,7 @@ import { PaginationIndicator } from './PaginationIndicator'
|
||||
import { useGridLayout } from '../hooks/useGridLayout'
|
||||
import { PaginationControl } from './PaginationControl'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useSpeakerPromotionTrigger } from '../hooks/useSpeakerPromotionTrigger'
|
||||
|
||||
interface GridLayoutObserverProps {
|
||||
gridEl: React.RefObject<HTMLDivElement>
|
||||
@@ -74,6 +75,7 @@ export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
||||
[props]
|
||||
)
|
||||
const pagination = usePagination(maxTiles, tracks)
|
||||
useSpeakerPromotionTrigger(pagination.tracks)
|
||||
|
||||
useSwipe(gridEl, {
|
||||
onLeftSwipe: pagination.nextPage,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
log,
|
||||
type TrackReferenceOrPlaceholder,
|
||||
} from '@livekit/components-core'
|
||||
import { RoomEvent, Track } from 'livekit-client'
|
||||
import { Track } from 'livekit-client'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
|
||||
import { useEffect, useRef } from 'react'
|
||||
@@ -23,7 +23,7 @@ export const StageLayout = () => {
|
||||
{ source: Track.Source.Camera, withPlaceholder: true },
|
||||
{ source: Track.Source.ScreenShare, withPlaceholder: false },
|
||||
],
|
||||
{ updateOnlyOn: [RoomEvent.ActiveSpeakersChanged], onlySubscribed: false }
|
||||
{ updateOnlyOn: [], onlySubscribed: false }
|
||||
)
|
||||
|
||||
const screenShareTracks = tracks
|
||||
@@ -104,7 +104,7 @@ export const StageLayout = () => {
|
||||
>
|
||||
<ParticipantTile />
|
||||
</CarouselLayout>
|
||||
{pinnedTrackRef && <FocusLayout trackRef={pinnedTrackRef} />}
|
||||
<FocusLayout trackRef={pinnedTrackRef} />
|
||||
</FocusLayoutContainer>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { RoomEvent } from 'livekit-client'
|
||||
import type { Participant } from 'livekit-client'
|
||||
import type { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { useRoomContext } from '@livekit/components-react'
|
||||
import { useEffect, useReducer, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* Tripwire for speaker promotion.
|
||||
*
|
||||
* Listens to `RoomEvent.ActiveSpeakersChanged` imperatively and forces ONE
|
||||
* re-render of the host component only when an active speaker has none of
|
||||
* their tiles are within the visible span (`maxVisibleTiles`). That render
|
||||
* re-runs `useVisualStableUpdate`, which reads live `participant.isSpeaking`
|
||||
* state and performs the actual swap.
|
||||
*
|
||||
* Speakers already visible are ignored. Everything else costs zero React work.
|
||||
*
|
||||
* Requires the parent to NOT re-render on speaker events itself, i.e.
|
||||
* `useTracks(..., { updateOnlyOn: [] })` upstream.
|
||||
*/
|
||||
export function useSpeakerPromotionTrigger(
|
||||
sortedTiles: TrackReferenceOrPlaceholder[]
|
||||
) {
|
||||
const room = useRoomContext()
|
||||
const [, forceRender] = useReducer((n: number) => n + 1, 0)
|
||||
|
||||
// Refs so the listener reads current values without re-subscribing
|
||||
// and without itself being a render dependency.
|
||||
const tilesRef = useRef(sortedTiles)
|
||||
tilesRef.current = sortedTiles
|
||||
|
||||
useEffect(() => {
|
||||
const onActiveSpeakersChanged = (speakers: Participant[]) => {
|
||||
const tiles = tilesRef.current
|
||||
const hiddenSpeakerExists = speakers.some(
|
||||
(speaker) =>
|
||||
!tiles.some((t) => t.participant.identity === speaker.identity)
|
||||
)
|
||||
if (hiddenSpeakerExists) {
|
||||
forceRender()
|
||||
}
|
||||
}
|
||||
room.on(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
||||
return () => {
|
||||
room.off(RoomEvent.ActiveSpeakersChanged, onActiveSpeakersChanged)
|
||||
}
|
||||
}, [room])
|
||||
}
|
||||
@@ -168,6 +168,31 @@ export const MainNotificationToast = () => {
|
||||
}
|
||||
}, [room, triggerNotificationSoundIfRoomIsSmall])
|
||||
|
||||
useEffect(() => {
|
||||
const handleAttributeChanged = (
|
||||
changedAttributes: Record<string, string>,
|
||||
participant: Participant
|
||||
) => {
|
||||
if (!participant.isLocal || !('room_role' in changedAttributes)) return
|
||||
const newRole = changedAttributes['room_role']
|
||||
toastQueue.add(
|
||||
{
|
||||
participant,
|
||||
type: NotificationType.RoleChanged,
|
||||
newRole: newRole,
|
||||
},
|
||||
{
|
||||
timeout: NotificationDuration.ROLE_CHANGED,
|
||||
}
|
||||
)
|
||||
}
|
||||
room.on(RoomEvent.ParticipantAttributesChanged, handleAttributeChanged)
|
||||
|
||||
return () => {
|
||||
room.off(RoomEvent.ParticipantAttributesChanged, handleAttributeChanged)
|
||||
}
|
||||
}, [room])
|
||||
|
||||
useEffect(() => {
|
||||
const removeParticipantNotifications = (participant: Participant) => {
|
||||
toastQueue.visibleToasts.forEach((toast) => {
|
||||
|
||||
@@ -14,4 +14,5 @@ export const NotificationDuration = {
|
||||
RECORDING_SAVING: ToastDuration.EXTRA_LONG,
|
||||
REACTION_RECEIVED: ToastDuration.SHORT,
|
||||
RECORDING_REQUESTED: ToastDuration.LONG,
|
||||
ROLE_CHANGED: ToastDuration.LONG,
|
||||
} as const
|
||||
|
||||
@@ -17,4 +17,5 @@ export enum NotificationType {
|
||||
ScreenRecordingLimitReached = 'screenRecordingLimitReached',
|
||||
RecordingSaving = 'recordingSaving',
|
||||
PermissionsRemoved = 'permissionsRemoved',
|
||||
RoleChanged = 'roleChanged',
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ToastRecordingSaving } from './ToastRecordingSaving'
|
||||
import { ToastPermissionsRemoved } from './ToastPermissionsRemoved'
|
||||
import { ToastRecordingRequest } from './ToastRecordingRequest'
|
||||
import { ToastAutoMuteLargeRoom } from './ToastAutoMuteLargeRoom'
|
||||
import { ToastRoleChanged } from '@/features/notifications/components/ToastRoleChanged'
|
||||
|
||||
interface ToastRegionProps extends AriaToastRegionProps {
|
||||
state: ToastState<ToastData>
|
||||
@@ -70,6 +71,9 @@ const renderToast = (
|
||||
<ToastRecordingSaving key={toast.key} toast={toast} state={state} />
|
||||
)
|
||||
|
||||
case NotificationType.RoleChanged:
|
||||
return <ToastRoleChanged key={toast.key} toast={toast} state={state} />
|
||||
|
||||
default:
|
||||
return <Toast key={toast.key} toast={toast} state={state} />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useToast } from 'react-aria'
|
||||
import { useRef } from 'react'
|
||||
|
||||
import { type ToastProps } from './Toast'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { StyledToastContainer } from './StyledToastContainer'
|
||||
|
||||
export function ToastRoleChanged({ state, ...props }: Readonly<ToastProps>) {
|
||||
const { t } = useTranslation('notifications', { keyPrefix: 'roleChanged' })
|
||||
const ref = useRef(null)
|
||||
const { toastProps, contentProps } = useToast(props, state, ref)
|
||||
const newRole = t(`roles.${props.toast.content.newRole}`)
|
||||
|
||||
return (
|
||||
<StyledToastContainer {...toastProps} ref={ref}>
|
||||
<HStack
|
||||
justify="center"
|
||||
alignItems="center"
|
||||
{...contentProps}
|
||||
padding={14}
|
||||
gap={0}
|
||||
>
|
||||
{t('body', { role: newRole })}
|
||||
</HStack>
|
||||
</StyledToastContainer>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
LockLockedIcon,
|
||||
ScreenShareIcon,
|
||||
useIsEncrypted,
|
||||
useParticipantInfo,
|
||||
} from '@livekit/components-react'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { Participant } from 'livekit-client'
|
||||
@@ -12,13 +11,14 @@ import { ParticipantName } from './ParticipantName'
|
||||
import { RaisedHandMetadataWrapper } from './RaisedHandMetadataWrapper'
|
||||
|
||||
export const ParticipantMetadata = ({
|
||||
displayedName,
|
||||
participant,
|
||||
isScreenShare,
|
||||
}: {
|
||||
displayedName: string
|
||||
participant: Participant
|
||||
isScreenShare: boolean
|
||||
}) => {
|
||||
const { identity, name } = useParticipantInfo({ participant })
|
||||
const isEncrypted = useIsEncrypted(participant)
|
||||
|
||||
return (
|
||||
@@ -42,7 +42,7 @@ export const ParticipantMetadata = ({
|
||||
)}
|
||||
<div className="lk-participant-name-wrapper">
|
||||
<ParticipantName
|
||||
displayedName={name != '' ? name : identity}
|
||||
displayedName={displayedName}
|
||||
isScreenShare={isScreenShare}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import type { Participant } from 'livekit-client'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
import { Avatar } from '@/components/Avatar'
|
||||
import { useIsSpeaking } from '@livekit/components-react'
|
||||
import { getParticipantBackgroundGradient } from '@/features/rooms/utils/getParticipantBackgroundGradient'
|
||||
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
||||
import { useMemo } from 'react'
|
||||
import React, { useMemo } from 'react'
|
||||
|
||||
const StyledParticipantPlaceHolder = styled('div', {
|
||||
base: {
|
||||
@@ -23,39 +20,36 @@ const StyledAvatarWrapper = styled('div', {
|
||||
aspectRatio: '1 / 1',
|
||||
width: 'min(90cqmin, 160px)',
|
||||
fontSize: 'min(27cqmin, 48px)',
|
||||
'[data-lk-speaking="true"] &': {
|
||||
animation: 'pulse 1s infinite',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
type ParticipantPlaceholderProps = {
|
||||
participant: Participant
|
||||
color: string
|
||||
displayedNamed: string
|
||||
}
|
||||
|
||||
export const ParticipantPlaceholder = ({
|
||||
participant,
|
||||
}: ParticipantPlaceholderProps) => {
|
||||
const isSpeaking = useIsSpeaking(participant)
|
||||
const participantColor = getParticipantColor(participant)
|
||||
const backgroundGradient = useMemo(
|
||||
() => getParticipantBackgroundGradient(participantColor),
|
||||
[participantColor]
|
||||
)
|
||||
|
||||
return (
|
||||
<StyledParticipantPlaceHolder
|
||||
style={{
|
||||
backgroundColor: participantColor,
|
||||
backgroundImage: backgroundGradient,
|
||||
}}
|
||||
>
|
||||
<StyledAvatarWrapper
|
||||
style={{ animation: isSpeaking ? 'pulse 1s infinite' : undefined }}
|
||||
export const ParticipantPlaceholder = React.memo(
|
||||
({ color, displayedNamed }: ParticipantPlaceholderProps) => {
|
||||
const backgroundGradient = useMemo(
|
||||
() => getParticipantBackgroundGradient(color),
|
||||
[color]
|
||||
)
|
||||
return (
|
||||
<StyledParticipantPlaceHolder
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
backgroundImage: backgroundGradient,
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
name={participant.name}
|
||||
bgColor={participantColor}
|
||||
context="placeholder"
|
||||
/>
|
||||
</StyledAvatarWrapper>
|
||||
</StyledParticipantPlaceHolder>
|
||||
)
|
||||
}
|
||||
<StyledAvatarWrapper>
|
||||
<Avatar name={displayedNamed} bgColor={color} context="placeholder" />
|
||||
</StyledAvatarWrapper>
|
||||
</StyledParticipantPlaceHolder>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ParticipantPlaceholder.displayName = 'ParticipantPlaceholder'
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
AudioTrack,
|
||||
ParticipantTileProps,
|
||||
@@ -8,8 +9,8 @@ import {
|
||||
VideoTrack,
|
||||
TrackRefContext,
|
||||
ParticipantContextIfNeeded,
|
||||
useParticipantInfo,
|
||||
} from '@livekit/components-react'
|
||||
import React from 'react'
|
||||
import {
|
||||
isEqualTrackRef,
|
||||
isTrackReference,
|
||||
@@ -17,15 +18,15 @@ import {
|
||||
} from '@livekit/components-core'
|
||||
import { Track } from 'livekit-client'
|
||||
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
||||
import { ParticipantTileFocus } from './ParticipantTileFocus'
|
||||
import { ParticipantTileFocus } from './participantTileFocus/ParticipantTileFocus'
|
||||
import { FullScreenShareWarning } from './FullScreenShareWarning'
|
||||
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
|
||||
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
|
||||
import { KeyboardShortcutHint } from './KeyboardShortcutHint'
|
||||
import { layoutStore, clearPinnedTrack } from '@/stores/layout'
|
||||
import { ParticipantMetadata } from './ParticipantMetadata'
|
||||
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
||||
|
||||
export function TrackRefContextIfNeeded(
|
||||
props: React.PropsWithChildren<{
|
||||
@@ -90,7 +91,13 @@ export const ParticipantTile: (
|
||||
const isScreenShare = trackReference.source != Track.Source.Camera
|
||||
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
|
||||
|
||||
const participantName = getParticipantName(trackReference.participant)
|
||||
const participantColor = getParticipantColor(trackReference.participant)
|
||||
|
||||
const { identity, name } = useParticipantInfo({
|
||||
participant: trackReference.participant,
|
||||
})
|
||||
const participantName = name || identity || 'Unknown'
|
||||
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||
|
||||
const interactiveProps = {
|
||||
@@ -141,11 +148,13 @@ export const ParticipantTile: (
|
||||
)}
|
||||
<div className="lk-participant-placeholder">
|
||||
<ParticipantPlaceholder
|
||||
participant={trackReference.participant}
|
||||
color={participantColor}
|
||||
displayedNamed={participantName}
|
||||
/>
|
||||
</div>
|
||||
{!disableMetadata && (
|
||||
<ParticipantMetadata
|
||||
displayedName={participantName}
|
||||
isScreenShare={isScreenShare}
|
||||
participant={trackReference.participant}
|
||||
/>
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { Button } from '@/primitives'
|
||||
import {
|
||||
RiFullscreenLine,
|
||||
RiImageCircleAiFill,
|
||||
RiMicLine,
|
||||
RiMicOffLine,
|
||||
RiPushpin2Line,
|
||||
RiUnpinLine,
|
||||
} from '@remixicon/react'
|
||||
import { useTrackMutedIndicator } from '@livekit/components-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
isEqualTrackRef,
|
||||
TrackReferenceOrPlaceholder,
|
||||
} from '@livekit/components-core'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { type Participant, Track } from 'livekit-client'
|
||||
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
||||
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { layoutStore, setPinnedTrack, clearPinnedTrack } from '@/stores/layout'
|
||||
import { useFullScreen } from '@/features/rooms/livekit/hooks/useFullScreen'
|
||||
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
|
||||
const ZoomButton = ({
|
||||
trackRef,
|
||||
}: {
|
||||
trackRef: TrackReferenceOrPlaceholder
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({
|
||||
trackRef,
|
||||
})
|
||||
|
||||
if (!isFullscreenAvailable) {
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primaryTextDark"
|
||||
square
|
||||
tooltip={t('fullScreen')}
|
||||
onPress={() => toggleFullScreen()}
|
||||
>
|
||||
<RiFullscreenLine />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const FocusButton = ({
|
||||
trackRef,
|
||||
}: {
|
||||
trackRef: TrackReferenceOrPlaceholder
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||
|
||||
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
||||
const inFocus = isEqualTrackRef(trackRef, pinnedTrackRef)
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primaryTextDark"
|
||||
square
|
||||
tooltip={inFocus ? t('pin.disable') : t('pin.enable')}
|
||||
onPress={() => (inFocus ? clearPinnedTrack() : setPinnedTrack(trackRef))}
|
||||
>
|
||||
{inFocus ? <RiUnpinLine /> : <RiPushpin2Line />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const EffectsButton = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||
const { isEffectsOpen, toggleEffects } = useSidePanel()
|
||||
return (
|
||||
<Button
|
||||
size={'sm'}
|
||||
variant={'primaryTextDark'}
|
||||
square
|
||||
tooltip={t('effects')}
|
||||
onPress={() => !isEffectsOpen && toggleEffects()}
|
||||
>
|
||||
<RiImageCircleAiFill />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
const MuteButton = ({ participant }: { participant: Participant }) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||
|
||||
const { isMuted } = useTrackMutedIndicator({
|
||||
participant: participant,
|
||||
source: Track.Source.Microphone,
|
||||
})
|
||||
|
||||
const { muteParticipant } = useMuteParticipant()
|
||||
const [isAlertOpen, setIsAlertOpen] = useState(false)
|
||||
|
||||
const name = participant.name || participant.identity
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
isDisabled={isMuted}
|
||||
size={'sm'}
|
||||
variant={'primaryTextDark'}
|
||||
square
|
||||
onPress={() => setIsAlertOpen(true)}
|
||||
tooltip={t('muteParticipant', { name })}
|
||||
>
|
||||
{!isMuted ? <RiMicLine /> : <RiMicOffLine />}
|
||||
</Button>
|
||||
<MuteAlertDialog
|
||||
isOpen={isAlertOpen}
|
||||
onSubmit={() =>
|
||||
muteParticipant(participant).then(() => setIsAlertOpen(false))
|
||||
}
|
||||
onClose={() => setIsAlertOpen(false)}
|
||||
name={name}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const MOUSE_IDLE_TIME = 3000
|
||||
|
||||
export const ParticipantTileFocus = ({
|
||||
trackRef,
|
||||
hasKeyboardFocus,
|
||||
}: {
|
||||
trackRef: TrackReferenceOrPlaceholder
|
||||
hasKeyboardFocus: boolean
|
||||
}) => {
|
||||
const [hovered, setHovered] = useState(false)
|
||||
const [opacity, setOpacity] = useState(0)
|
||||
|
||||
const idleTimerRef = useRef<number | null>(null)
|
||||
const [isIdleRef, setIsIdleRef] = useState(false)
|
||||
|
||||
const isVisible = hasKeyboardFocus || (hovered && !isIdleRef)
|
||||
|
||||
useEffect(() => {
|
||||
if (isVisible) {
|
||||
// Wait for next frame to ensure element is mounted
|
||||
requestAnimationFrame(() => {
|
||||
setOpacity(0.6)
|
||||
})
|
||||
} else {
|
||||
setOpacity(0)
|
||||
}
|
||||
}, [isVisible])
|
||||
|
||||
const handleMouseMove = () => {
|
||||
if (idleTimerRef.current) {
|
||||
window.clearTimeout(idleTimerRef.current)
|
||||
}
|
||||
idleTimerRef.current = window.setTimeout(() => {
|
||||
setIsIdleRef(true)
|
||||
}, MOUSE_IDLE_TIME)
|
||||
setIsIdleRef(false)
|
||||
}
|
||||
|
||||
const participant = trackRef.participant
|
||||
|
||||
const isScreenShare = trackRef.source == Track.Source.ScreenShare
|
||||
const isLocal = trackRef.participant.isLocal
|
||||
|
||||
const canMute = useCanMute(participant)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
left: '0',
|
||||
top: '0',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
})}
|
||||
aria-hidden={!isVisible}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
onMouseMove={handleMouseMove}
|
||||
>
|
||||
{isVisible && (
|
||||
<div
|
||||
className={css({
|
||||
backgroundColor: 'primaryDark.50',
|
||||
transition: 'opacity 200ms linear',
|
||||
zIndex: 1,
|
||||
borderRadius: '0.25rem',
|
||||
display: 'flex',
|
||||
_hover: {
|
||||
opacity: '0.95 !important',
|
||||
},
|
||||
})}
|
||||
style={{ opacity }}
|
||||
>
|
||||
<HStack
|
||||
gap={0.5}
|
||||
className={css({
|
||||
padding: '0.5rem',
|
||||
_hover: {
|
||||
opacity: '1 !important',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<FocusButton trackRef={trackRef} />
|
||||
{!isScreenShare ? (
|
||||
<>
|
||||
{participant.isLocal ? (
|
||||
<EffectsButton />
|
||||
) : (
|
||||
canMute && <MuteButton participant={participant} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
!isLocal && <ZoomButton trackRef={trackRef} />
|
||||
)}
|
||||
</HStack>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||
import { Button } from '@/primitives'
|
||||
import { RiImageCircleAiFill } from '@remixicon/react'
|
||||
|
||||
export const EffectsButton = React.memo(() => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||
const { isEffectsOpen, toggleEffects } = useSidePanel()
|
||||
return (
|
||||
<Button
|
||||
size={'sm'}
|
||||
variant={'primaryTextDark'}
|
||||
square
|
||||
tooltip={t('effects')}
|
||||
onPress={() => !isEffectsOpen && toggleEffects()}
|
||||
>
|
||||
<RiImageCircleAiFill />
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
|
||||
EffectsButton.displayName = 'EffectsButton'
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
isEqualTrackRef,
|
||||
TrackReferenceOrPlaceholder,
|
||||
} from '@livekit/components-core'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useSnapshot } from 'valtio'
|
||||
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
|
||||
import { Button } from '@/primitives'
|
||||
import { RiPushpin2Line, RiUnpinLine } from '@remixicon/react'
|
||||
|
||||
export const FocusButton = ({
|
||||
trackRef,
|
||||
}: {
|
||||
trackRef: TrackReferenceOrPlaceholder
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||
|
||||
const { pinnedTrackRef } = useSnapshot(layoutStore)
|
||||
const inFocus = isEqualTrackRef(trackRef, pinnedTrackRef)
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primaryTextDark"
|
||||
square
|
||||
tooltip={inFocus ? t('pin.disable') : t('pin.enable')}
|
||||
onPress={() => (inFocus ? clearPinnedTrack() : setPinnedTrack(trackRef))}
|
||||
>
|
||||
{inFocus ? <RiUnpinLine /> : <RiPushpin2Line />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import { Participant, Track } from 'livekit-client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useTrackMutedIndicator } from '@livekit/components-react'
|
||||
import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/primitives'
|
||||
import { RiMicLine, RiMicOffLine } from '@remixicon/react'
|
||||
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
|
||||
|
||||
export const MuteButton = ({ participant }: { participant: Participant }) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||
|
||||
const { isMuted } = useTrackMutedIndicator({
|
||||
participant: participant,
|
||||
source: Track.Source.Microphone,
|
||||
})
|
||||
|
||||
const { muteParticipant } = useMuteParticipant()
|
||||
const [isAlertOpen, setIsAlertOpen] = useState(false)
|
||||
|
||||
const name = participant.name || participant.identity
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
isDisabled={isMuted}
|
||||
size={'sm'}
|
||||
variant={'primaryTextDark'}
|
||||
square
|
||||
onPress={() => setIsAlertOpen(true)}
|
||||
tooltip={t('muteParticipant', { name })}
|
||||
>
|
||||
{!isMuted ? <RiMicLine /> : <RiMicOffLine />}
|
||||
</Button>
|
||||
<MuteAlertDialog
|
||||
isOpen={isAlertOpen}
|
||||
onSubmit={() =>
|
||||
muteParticipant(participant).then(() => setIsAlertOpen(false))
|
||||
}
|
||||
onClose={() => setIsAlertOpen(false)}
|
||||
name={name}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { ReactNode, useEffect, useRef, useState } from 'react'
|
||||
import { Track } from 'livekit-client'
|
||||
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
||||
import { FocusButton } from './FocusButton'
|
||||
import { EffectsButton } from './EffectsButton'
|
||||
import { MuteButton } from './MuteButton'
|
||||
import { ZoomButton } from './ZoomButton'
|
||||
|
||||
const MOUSE_IDLE_TIME = 3000
|
||||
|
||||
type FadeOverlayProps = {
|
||||
children: ReactNode
|
||||
hasKeyboardFocus: boolean
|
||||
}
|
||||
|
||||
const FadeOverlay = ({ children, hasKeyboardFocus }: FadeOverlayProps) => {
|
||||
const [active, setActive] = useState(false)
|
||||
const idleTimerRef = useRef<number | null>(null)
|
||||
|
||||
const clearIdleTimer = () => {
|
||||
if (idleTimerRef.current) window.clearTimeout(idleTimerRef.current)
|
||||
}
|
||||
|
||||
const armIdleTimer = () => {
|
||||
clearIdleTimer()
|
||||
idleTimerRef.current = window.setTimeout(() => {
|
||||
setActive(false)
|
||||
}, MOUSE_IDLE_TIME)
|
||||
}
|
||||
|
||||
const handleActivity = () => {
|
||||
setActive(true)
|
||||
armIdleTimer()
|
||||
}
|
||||
|
||||
useEffect(() => clearIdleTimer, [])
|
||||
|
||||
const isVisible = hasKeyboardFocus || active
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
left: '0',
|
||||
top: '0',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
})}
|
||||
data-visible={isVisible || undefined}
|
||||
aria-hidden={!isVisible}
|
||||
onMouseEnter={handleActivity}
|
||||
onMouseMove={handleActivity}
|
||||
onMouseLeave={() => {
|
||||
clearIdleTimer()
|
||||
setActive(false)
|
||||
}}
|
||||
>
|
||||
{isVisible && children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const ParticipantTileFocus = ({
|
||||
trackRef,
|
||||
hasKeyboardFocus,
|
||||
}: {
|
||||
trackRef: TrackReferenceOrPlaceholder
|
||||
hasKeyboardFocus: boolean
|
||||
}) => {
|
||||
const participant = trackRef.participant
|
||||
const isScreenShare = trackRef.source == Track.Source.ScreenShare
|
||||
const isLocal = participant.isLocal
|
||||
const canMute = useCanMute(participant)
|
||||
|
||||
return (
|
||||
<FadeOverlay hasKeyboardFocus={hasKeyboardFocus}>
|
||||
<div
|
||||
className={css({
|
||||
backgroundColor: 'primaryDark.50',
|
||||
zIndex: 1,
|
||||
borderRadius: '0.25rem',
|
||||
display: 'flex',
|
||||
opacity: 0.6,
|
||||
animation: 'overlayIn 200ms linear 300ms backwards',
|
||||
_hover: {
|
||||
opacity: 0.95,
|
||||
},
|
||||
})}
|
||||
>
|
||||
<HStack gap={0.5} padding={0.5}>
|
||||
<FocusButton trackRef={trackRef} />
|
||||
{!isScreenShare ? (
|
||||
<>
|
||||
{isLocal ? (
|
||||
<EffectsButton />
|
||||
) : (
|
||||
canMute && <MuteButton participant={participant} />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
!isLocal && <ZoomButton trackRef={trackRef} />
|
||||
)}
|
||||
</HStack>
|
||||
</div>
|
||||
</FadeOverlay>
|
||||
)
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
import { TrackReferenceOrPlaceholder } from '@livekit/components-core'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useFullScreen } from '@/features/rooms/livekit/hooks/useFullScreen'
|
||||
import { Button } from '@/primitives'
|
||||
import { RiFullscreenLine } from '@remixicon/react'
|
||||
|
||||
export const ZoomButton = ({
|
||||
trackRef,
|
||||
}: {
|
||||
trackRef: TrackReferenceOrPlaceholder
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||
const { toggleFullScreen, isFullscreenAvailable } = useFullScreen({
|
||||
trackRef,
|
||||
})
|
||||
|
||||
if (!isFullscreenAvailable) {
|
||||
return
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primaryTextDark"
|
||||
square
|
||||
tooltip={t('fullScreen')}
|
||||
onPress={() => toggleFullScreen()}
|
||||
>
|
||||
<RiFullscreenLine />
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { AssignableParticipantRole } from '@/features/rooms/api/ApiRoom'
|
||||
|
||||
export const useParticipantRole = () => {
|
||||
const data = useRoomData()
|
||||
|
||||
const updateParticipantRole = async (
|
||||
identity: string,
|
||||
role: AssignableParticipantRole
|
||||
) => {
|
||||
if (!data?.id) {
|
||||
throw new Error('Room id is not available')
|
||||
}
|
||||
|
||||
try {
|
||||
return fetchApi(`rooms/${data.id}/update-participant-role/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
participant_identity: identity,
|
||||
role: role,
|
||||
}),
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to update participant's role ${identity}: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
return { updateParticipantRole }
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import React, { useLayoutEffect, useRef, useState } from 'react'
|
||||
import { Text } from '@/primitives'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
||||
|
||||
export const ParticipantName = React.memo(
|
||||
({ displayedName, isLocal }: { displayedName: string; isLocal: boolean }) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
|
||||
const nameRef = useRef<HTMLParagraphElement>(null)
|
||||
const [isTruncated, setIsTruncated] = useState(false)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = nameRef.current
|
||||
if (!el) return
|
||||
const truncated = el.scrollWidth > el.clientWidth
|
||||
setIsTruncated((prev) => (prev === truncated ? prev : truncated))
|
||||
}, [displayedName])
|
||||
|
||||
return (
|
||||
<Text
|
||||
as="div"
|
||||
variant="sm"
|
||||
className={css({
|
||||
userSelect: 'none',
|
||||
cursor: 'default',
|
||||
display: 'flex',
|
||||
minWidth: 0,
|
||||
width: 'full',
|
||||
maxWidth: 'full',
|
||||
})}
|
||||
>
|
||||
<VisualOnlyTooltip
|
||||
tooltip={displayedName}
|
||||
disabled={!isTruncated}
|
||||
className={css({ display: 'flex', minWidth: 0, flex: 1 })}
|
||||
>
|
||||
<p
|
||||
className={css({
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: 'block',
|
||||
minWidth: 0,
|
||||
})}
|
||||
ref={nameRef}
|
||||
>
|
||||
{displayedName}
|
||||
</p>
|
||||
</VisualOnlyTooltip>
|
||||
{isLocal && (
|
||||
<span
|
||||
className={css({
|
||||
marginLeft: '.25rem',
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
})}
|
||||
>
|
||||
({t('participants.you')})
|
||||
</span>
|
||||
)}
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ParticipantName.displayName = 'ParticipantName'
|
||||
@@ -5,7 +5,11 @@ import { Text } from '@/primitives/Text'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Avatar } from '@/components/Avatar'
|
||||
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
||||
import { getParticipantIsRoomAdmin } from '@/features/rooms/utils/getParticipantIsRoomAdmin'
|
||||
import {
|
||||
getParticipantIsRoomAdmin,
|
||||
getParticipantIsRoomOwner,
|
||||
getParticipantIsRoomMember,
|
||||
} from '@/features/rooms/utils/getParticipantIsRoomAdminOrOwner'
|
||||
import { type LocalParticipant, type Participant, Track } from 'livekit-client'
|
||||
import { isLocal } from '@/utils/livekit'
|
||||
import {
|
||||
@@ -20,7 +24,9 @@ import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
||||
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
||||
import { ParticipantMenuButton } from './menu/ParticipantMenuButton'
|
||||
import { PinBadge } from './PinBadge'
|
||||
import { UnauthenticatedBadge } from './UnauthenticatedBadge'
|
||||
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
|
||||
import { ParticipantName } from './ParticipantName'
|
||||
|
||||
type MicIndicatorProps = {
|
||||
participant: Participant
|
||||
@@ -106,52 +112,26 @@ export const ParticipantRow = ({ participant }: ParticipantListItemProps) => {
|
||||
width: 'full',
|
||||
})}
|
||||
>
|
||||
<HStack>
|
||||
<div
|
||||
className={css({
|
||||
position: 'relative',
|
||||
})}
|
||||
>
|
||||
<HStack flex="1" minW="0">
|
||||
<div className={css({ position: 'relative', flexShrink: 0 })}>
|
||||
<Avatar name={name} bgColor={getParticipantColor(participant)} />
|
||||
<PinBadge participant={participant} />
|
||||
<UnauthenticatedBadge participant={participant} />
|
||||
</div>
|
||||
<VStack gap={0} alignItems="start">
|
||||
<Text
|
||||
variant="sm"
|
||||
className={css({
|
||||
userSelect: 'none',
|
||||
cursor: 'default',
|
||||
display: 'flex',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '120px',
|
||||
display: 'block',
|
||||
})}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
{isLocal(participant) && (
|
||||
<span
|
||||
className={css({
|
||||
marginLeft: '.25rem',
|
||||
whiteSpace: 'nowrap',
|
||||
})}
|
||||
>
|
||||
({t('participants.you')})
|
||||
</span>
|
||||
)}
|
||||
<VStack gap={0} alignItems="start" minW="0" flex="1">
|
||||
<ParticipantName
|
||||
displayedName={name}
|
||||
isLocal={isLocal(participant)}
|
||||
/>
|
||||
<Text variant="xsNote">
|
||||
{getParticipantIsRoomOwner(participant) && t('participants.host')}
|
||||
{getParticipantIsRoomAdmin(participant) && t('participants.cohost')}
|
||||
{getParticipantIsRoomMember(participant) &&
|
||||
t('participants.member')}
|
||||
</Text>
|
||||
{getParticipantIsRoomAdmin(participant) && (
|
||||
<Text variant="xsNote">{t('participants.host')}</Text>
|
||||
)}
|
||||
</VStack>
|
||||
</HStack>
|
||||
<HStack>
|
||||
<HStack flexShrink={0}>
|
||||
<MicIndicator participant={participant} />
|
||||
<ParticipantMenuButton participant={participant} />
|
||||
</HStack>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { css } from '@/styled-system/css'
|
||||
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { Text } from '@/primitives/Text'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Avatar } from '@/components/Avatar'
|
||||
import { useLowerHandParticipant } from '../api/lowerHandParticipant'
|
||||
@@ -11,6 +10,7 @@ import { isLocal } from '@/utils/livekit'
|
||||
import { RiHand } from '@remixicon/react'
|
||||
import { Button } from '@/primitives'
|
||||
import { AdminOrOwnerOnly } from '@/features/rooms/components/AdminOrOwnerOnly'
|
||||
import { ParticipantName } from './ParticipantName'
|
||||
|
||||
const ActionButton = ({
|
||||
participant,
|
||||
@@ -42,9 +42,7 @@ type HandRaisedListItemProps = {
|
||||
}
|
||||
|
||||
export const RaisedHandRow = ({ participant }: HandRaisedListItemProps) => {
|
||||
const { t } = useTranslation('rooms')
|
||||
const name = participant.name || participant.identity
|
||||
|
||||
return (
|
||||
<HStack
|
||||
role="listitem"
|
||||
@@ -56,42 +54,15 @@ export const RaisedHandRow = ({ participant }: HandRaisedListItemProps) => {
|
||||
width: 'full',
|
||||
})}
|
||||
>
|
||||
<HStack>
|
||||
<HStack flex="1" minW="0" overflow="hidden">
|
||||
<Avatar name={name} bgColor={getParticipantColor(participant)} />
|
||||
<Text
|
||||
variant={'sm'}
|
||||
className={css({
|
||||
userSelect: 'none',
|
||||
cursor: 'default',
|
||||
display: 'flex',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: '120px',
|
||||
display: 'block',
|
||||
})}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
{isLocal(participant) && (
|
||||
<span
|
||||
className={css({
|
||||
marginLeft: '.25rem',
|
||||
whiteSpace: 'nowrap',
|
||||
})}
|
||||
>
|
||||
({t('participants.you')})
|
||||
</span>
|
||||
)}
|
||||
</Text>
|
||||
<ParticipantName displayedName={name} isLocal={isLocal(participant)} />
|
||||
</HStack>
|
||||
<HStack flexShrink={0}>
|
||||
<AdminOrOwnerOnly>
|
||||
<ActionButton participant={participant} name={name} />
|
||||
</AdminOrOwnerOnly>
|
||||
</HStack>
|
||||
<AdminOrOwnerOnly>
|
||||
<ActionButton participant={participant} name={name} />
|
||||
</AdminOrOwnerOnly>
|
||||
</HStack>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Participant } from 'livekit-client'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { useParticipantAttribute } from '@livekit/components-react'
|
||||
import { RiErrorWarningFill } from '@remixicon/react'
|
||||
import { VisualOnlyTooltip } from '@/primitives/VisualOnlyTooltip'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const UnauthenticatedBadge = ({
|
||||
participant,
|
||||
}: {
|
||||
participant: Participant
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'participants.unauthenticated',
|
||||
})
|
||||
const is_authenticated = useParticipantAttribute('is_authenticated', {
|
||||
participant,
|
||||
})
|
||||
|
||||
if (is_authenticated == 'true') return
|
||||
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
height: '18px',
|
||||
width: '18px',
|
||||
borderRadius: '100%',
|
||||
background: 'orange.200',
|
||||
color: 'orange.800',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
position: 'absolute',
|
||||
bottom: '-2px',
|
||||
right: '-4px',
|
||||
})}
|
||||
>
|
||||
<VisualOnlyTooltip tooltip={t('badge')}>
|
||||
<RiErrorWarningFill size={14} aria-hidden />
|
||||
</VisualOnlyTooltip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Button, Text } from '@/primitives'
|
||||
import { Button } from '@/primitives'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Avatar } from '@/components/Avatar'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { WaitingParticipant } from '../api/listWaitingParticipants'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { ParticipantName } from './ParticipantName'
|
||||
|
||||
export const WaitingParticipantRow = ({
|
||||
participant,
|
||||
@@ -26,42 +27,11 @@ export const WaitingParticipantRow = ({
|
||||
width: 'full',
|
||||
})}
|
||||
>
|
||||
<HStack
|
||||
className={css({
|
||||
flex: '1',
|
||||
minWidth: '0',
|
||||
})}
|
||||
>
|
||||
<HStack flex="1" minW="0">
|
||||
<Avatar name={participant.username} bgColor={participant.color} />
|
||||
<Text
|
||||
variant={'sm'}
|
||||
className={css({
|
||||
userSelect: 'none',
|
||||
cursor: 'default',
|
||||
display: 'flex',
|
||||
flex: '1',
|
||||
minWidth: '0',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
width: '100%',
|
||||
display: 'block',
|
||||
})}
|
||||
>
|
||||
{participant.username}
|
||||
</span>
|
||||
</Text>
|
||||
<ParticipantName displayedName={participant.username} isLocal={false} />
|
||||
</HStack>
|
||||
<HStack
|
||||
gap="0.25rem"
|
||||
className={css({
|
||||
flexShrink: '0',
|
||||
})}
|
||||
>
|
||||
<HStack gap="0.25rem" flexShrink={0}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
|
||||
@@ -1,24 +1,46 @@
|
||||
import { Menu as RACMenu } from 'react-aria-components'
|
||||
import type { Participant } from 'livekit-client'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { PinMenuItem } from './items/PinMenuItem'
|
||||
import { RemoveMenuItem } from './items/RemoveMenuItem'
|
||||
import { PromoteMenuItem } from './items/PromoteMenuItem'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import { useParticipantAttributes } from '@livekit/components-react'
|
||||
|
||||
export const ParticipantMenu = ({
|
||||
participant,
|
||||
}: {
|
||||
participant: Participant
|
||||
}) => {
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
const canModerateParticipant = !participant.isLocal && isAdminOrOwner
|
||||
const isLocalUserAdminOrOwner = useIsAdminOrOwner()
|
||||
|
||||
const { attributes } = useParticipantAttributes({ participant })
|
||||
|
||||
const isAdmin = attributes?.room_role === 'administrator'
|
||||
const isOwner = attributes?.room_role === 'owner'
|
||||
const isAuthenticated = attributes?.is_authenticated == 'true'
|
||||
|
||||
const canManage = !participant.isLocal && isLocalUserAdminOrOwner && !isOwner
|
||||
|
||||
return (
|
||||
<RACMenu
|
||||
style={{
|
||||
minWidth: '75px',
|
||||
minWidth: '100px',
|
||||
}}
|
||||
>
|
||||
<PinMenuItem participant={participant} />
|
||||
{canModerateParticipant && <RemoveMenuItem participant={participant} />}
|
||||
{canManage && (
|
||||
<RemoveMenuItem
|
||||
identity={participant.identity}
|
||||
displayedName={participant.name}
|
||||
/>
|
||||
)}
|
||||
{canManage && isAuthenticated && (
|
||||
<PromoteMenuItem
|
||||
identity={participant.identity}
|
||||
isAdmin={isAdmin}
|
||||
displayedName={participant.name}
|
||||
/>
|
||||
)}
|
||||
</RACMenu>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Button, Menu } from '@/primitives'
|
||||
import { RiMore2Fill } from '@remixicon/react'
|
||||
import { ParticipantMenu } from './ParticipantMenu'
|
||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
||||
import type { Participant } from 'livekit-client'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -11,8 +10,6 @@ export const ParticipantMenuButton = ({
|
||||
participant: Participant
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
if (!isAdminOrOwner) return null
|
||||
return (
|
||||
<Menu>
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import { MenuItem } from 'react-aria-components'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiAdminLine, RiUserMinusLine } from '@remixicon/react'
|
||||
import { useParticipantRole } from '@/features/participants/api/updateParticipantRole'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
|
||||
type PromoteMenuItemProps = {
|
||||
identity: string
|
||||
displayedName?: string
|
||||
isAdmin: boolean
|
||||
}
|
||||
|
||||
export const PromoteMenuItem = React.memo(
|
||||
({ identity, displayedName, isAdmin }: PromoteMenuItemProps) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantMenu' })
|
||||
|
||||
const { updateParticipantRole } = useParticipantRole()
|
||||
|
||||
const label = isAdmin ? 'demote' : 'promote'
|
||||
const Icon = isAdmin ? RiUserMinusLine : RiAdminLine
|
||||
|
||||
const toggleRole = useCallback(
|
||||
() =>
|
||||
updateParticipantRole(identity, isAdmin ? 'member' : 'administrator'),
|
||||
[isAdmin, updateParticipantRole, identity]
|
||||
)
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
aria-label={t(`${label}.ariaLabel`, {
|
||||
name: displayedName || identity,
|
||||
})}
|
||||
className={menuRecipe({ icon: true }).item}
|
||||
onAction={toggleRole}
|
||||
>
|
||||
<HStack gap={0.25} minWidth={280}>
|
||||
<Icon size={20} aria-hidden />
|
||||
{t(`${label}.label`)}
|
||||
</HStack>
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
PromoteMenuItem.displayName = 'PromoteMenuItem'
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Participant } from 'livekit-client'
|
||||
import React from 'react'
|
||||
import { menuRecipe } from '@/primitives/menuRecipe'
|
||||
import { HStack } from '@/styled-system/jsx'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
@@ -6,23 +6,30 @@ import { MenuItem } from 'react-aria-components'
|
||||
import { useRemoveParticipant } from '@/features/rooms/api/removeParticipant'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export const RemoveMenuItem = ({
|
||||
participant,
|
||||
}: {
|
||||
participant: Participant
|
||||
}) => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantMenu.remove' })
|
||||
const { removeParticipant } = useRemoveParticipant()
|
||||
return (
|
||||
<MenuItem
|
||||
aria-label={t('ariaLabel', { name: participant.name })}
|
||||
className={menuRecipe({ icon: true }).item}
|
||||
onAction={() => removeParticipant(participant)}
|
||||
>
|
||||
<HStack gap={0.25}>
|
||||
<RiCloseLine size={20} aria-hidden />
|
||||
{t('label')}
|
||||
</HStack>
|
||||
</MenuItem>
|
||||
)
|
||||
type RemoveMenuItemProps = {
|
||||
identity: string
|
||||
displayedName?: string
|
||||
}
|
||||
|
||||
export const RemoveMenuItem = React.memo(
|
||||
({ identity, displayedName }: RemoveMenuItemProps) => {
|
||||
const { t } = useTranslation('rooms', {
|
||||
keyPrefix: 'participantMenu.remove',
|
||||
})
|
||||
const { removeParticipant } = useRemoveParticipant()
|
||||
return (
|
||||
<MenuItem
|
||||
aria-label={t('ariaLabel', { name: displayedName || identity })}
|
||||
className={menuRecipe({ icon: true }).item}
|
||||
onAction={() => removeParticipant(identity)}
|
||||
>
|
||||
<HStack gap={0.25}>
|
||||
<RiCloseLine size={20} aria-hidden />
|
||||
{t('label')}
|
||||
</HStack>
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
RemoveMenuItem.displayName = 'RemoveMenuItem'
|
||||
|
||||
@@ -28,3 +28,6 @@ export type ApiRoom = {
|
||||
livekit?: ApiLiveKit
|
||||
configuration?: RoomConfiguration
|
||||
}
|
||||
|
||||
export type ParticipantRole = 'member' | 'administrator' | 'owner'
|
||||
export type AssignableParticipantRole = Exclude<ParticipantRole, 'owner'>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { Participant } from 'livekit-client'
|
||||
import { useRoomData } from '../livekit/hooks/useRoomData'
|
||||
import { fetchApi } from '@/api/fetchApi'
|
||||
|
||||
export const useRemoveParticipant = () => {
|
||||
const data = useRoomData()
|
||||
|
||||
const removeParticipant = async (participant: Participant) => {
|
||||
const removeParticipant = async (identity: string) => {
|
||||
if (!data?.id) {
|
||||
throw new Error('Room id is not available')
|
||||
}
|
||||
@@ -13,7 +12,7 @@ export const useRemoveParticipant = () => {
|
||||
return fetchApi(`rooms/${data.id}/remove-participant/`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
participant_identity: participant.identity,
|
||||
participant_identity: identity,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -316,7 +316,7 @@ export const Join = ({
|
||||
refetch: refetchRoom,
|
||||
} = useQuery({
|
||||
queryKey: [keys.room, roomId],
|
||||
queryFn: () => fetchRoom({ roomId, username }),
|
||||
queryFn: () => fetchRoom({ roomId, username: username || user?.full_name }),
|
||||
staleTime: 6 * 60 * 60 * 1000, // By default, LiveKit access tokens expire 6 hours after generation
|
||||
retry: false,
|
||||
enabled: false,
|
||||
@@ -339,7 +339,7 @@ export const Join = ({
|
||||
|
||||
const { status, startWaiting } = useLobby({
|
||||
roomId,
|
||||
username,
|
||||
username: username || user?.full_name || 'anonymous',
|
||||
onAccepted: handleAccepted,
|
||||
})
|
||||
|
||||
|
||||
@@ -11,6 +11,9 @@ import { useQuery } from '@tanstack/react-query'
|
||||
import { useParams } from 'wouter'
|
||||
import { usePublishSourcesManager } from '../hooks/usePublishSourcesManager'
|
||||
import { usePermissionsManager } from '../hooks/usePermissionsManager'
|
||||
import { useEffect } from 'react'
|
||||
import { closeSidePanel } from '@/stores/layout'
|
||||
import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner'
|
||||
|
||||
export const Admin = () => {
|
||||
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
|
||||
@@ -23,6 +26,14 @@ export const Admin = () => {
|
||||
|
||||
const { mutateAsync: patchRoom } = usePatchRoom()
|
||||
|
||||
const isAdminOrOwner = useIsAdminOrOwner()
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdminOrOwner) {
|
||||
closeSidePanel()
|
||||
}
|
||||
}, [isAdminOrOwner])
|
||||
|
||||
const { data: readOnlyData } = useQuery({
|
||||
queryKey: [keys.room, roomId],
|
||||
queryFn: () => fetchRoom({ roomId }),
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useLocalParticipant } from '@livekit/components-react'
|
||||
import { useEffect } from 'react'
|
||||
|
||||
export const MEDIA_STATE_ELEMENT_ID = 'media-state'
|
||||
export const MEDIA_STATE_CHANGED_EVENT = 'media-state-changed'
|
||||
|
||||
export type MediaStateChangedDetail = {
|
||||
microphoneEnabled: boolean
|
||||
cameraEnabled: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Exposes the local participant's media state in the DOM so external tools
|
||||
* (e.g. bots automating the frontend) can reliably read the microphone and
|
||||
* camera state, and watch for changes with a MutationObserver:
|
||||
*
|
||||
* const el = document.getElementById('media-state')
|
||||
* new MutationObserver(...).observe(el, { attributes: true })
|
||||
*/
|
||||
export const MediaStateObserver = () => {
|
||||
const { isMicrophoneEnabled, isCameraEnabled } = useLocalParticipant()
|
||||
|
||||
useEffect(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<MediaStateChangedDetail>(MEDIA_STATE_CHANGED_EVENT, {
|
||||
detail: {
|
||||
microphoneEnabled: isMicrophoneEnabled,
|
||||
cameraEnabled: isCameraEnabled,
|
||||
},
|
||||
})
|
||||
)
|
||||
}, [isMicrophoneEnabled, isCameraEnabled])
|
||||
|
||||
return (
|
||||
<div
|
||||
id={MEDIA_STATE_ELEMENT_ID}
|
||||
style={{ display: 'none' }}
|
||||
data-microphone-enabled={isMicrophoneEnabled ? 'true' : 'false'}
|
||||
data-camera-enabled={isCameraEnabled ? 'true' : 'false'}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { useSyncLiveKitMetadata } from '../hooks/useSyncLiveKitMetadata'
|
||||
|
||||
export const RoomMetadataSynchronizer = () => {
|
||||
useSyncLiveKitMetadata()
|
||||
return null
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { layoutStore } from '@/stores/layout'
|
||||
import { closeSidePanel, layoutStore } from '@/stores/layout'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { Heading } from 'react-aria-components'
|
||||
import { text } from '@/primitives/Text'
|
||||
@@ -199,10 +199,7 @@ export const SidePanel = () => {
|
||||
ref={asideRef}
|
||||
title={title}
|
||||
ariaLabel={t('ariaLabel', { title })}
|
||||
onClose={() => {
|
||||
layoutStore.activePanelId = null
|
||||
layoutStore.activeSubPanelId = null
|
||||
}}
|
||||
onClose={closeSidePanel}
|
||||
closeButtonTooltip={t('closeButton', {
|
||||
content: t(`content.${activeSubPanelId || activePanelId}`),
|
||||
})}
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import { useRoomData } from './useRoomData'
|
||||
import {
|
||||
useParticipantAttribute,
|
||||
useRoomContext,
|
||||
} from '@livekit/components-react'
|
||||
import { ParticipantRole } from '@/features/rooms/api/ApiRoom'
|
||||
|
||||
export const useIsAdminOrOwner = () => {
|
||||
const apiRoomData = useRoomData()
|
||||
return apiRoomData?.is_administrable
|
||||
const room = useRoomContext()
|
||||
const localParticipant = room.localParticipant
|
||||
const role = useParticipantAttribute('room_role', {
|
||||
participant: localParticipant,
|
||||
})
|
||||
return (
|
||||
role !== undefined &&
|
||||
['administrator', 'owner'].includes(role as ParticipantRole)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useRemoteParticipants } from '@livekit/components-react'
|
||||
import { useUpdateParticipantsPermissions } from '@/features/rooms/api/updateParticipantsPermissions'
|
||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||
import { isSubsetOf } from '@/features/rooms/utils/isSubsetOf'
|
||||
import { getParticipantIsRoomAdmin } from '@/features/rooms/utils/getParticipantIsRoomAdmin'
|
||||
import { getParticipantIsRoomAdminOrOwner } from '@/features/rooms/utils/getParticipantIsRoomAdminOrOwner'
|
||||
import Source = Track.Source
|
||||
import {
|
||||
NotificationType,
|
||||
@@ -48,7 +48,7 @@ export const usePublishSourcesManager = () => {
|
||||
})
|
||||
|
||||
const unprivilegedRemoteParticipants = remoteParticipants.filter(
|
||||
(participant) => !getParticipantIsRoomAdmin(participant)
|
||||
(participant) => !getParticipantIsRoomAdminOrOwner(participant)
|
||||
)
|
||||
|
||||
const currentSources = useMemo(() => {
|
||||
|
||||
@@ -13,8 +13,7 @@ import { useRoomContext } from '@livekit/components-react'
|
||||
import { useRoomData } from './useRoomData'
|
||||
|
||||
/**
|
||||
* Shape of the LiveKit room metadata blob pushed by the backend.
|
||||
* Matches RoomManagement.update_metadata → {"configuration": room.configuration}
|
||||
* The subset of LiveKit's room metadata this hook actually uses.
|
||||
*/
|
||||
type RoomLiveKitMetadata = {
|
||||
configuration?: RoomConfiguration
|
||||
|
||||
@@ -11,9 +11,10 @@ import { SidePanel } from '../components/SidePanel'
|
||||
import { RecordingProvider } from '@/features/recording'
|
||||
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
||||
import { ConnectionObserver } from '../components/ConnectionObserver'
|
||||
import { MediaStateObserver } from '../components/MediaStateObserver'
|
||||
import { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer'
|
||||
import { useRoomPageTitle } from '../hooks/useRoomPageTitle'
|
||||
import { useNoiseReduction } from '../hooks/useNoiseReduction'
|
||||
import { useSyncLiveKitMetadata } from '../hooks/useSyncLiveKitMetadata'
|
||||
import { VideoResolutionSubscription } from '../components/VideoResolutionSubscription'
|
||||
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
|
||||
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
|
||||
@@ -53,7 +54,6 @@ export interface VideoConferenceProps extends React.HTMLAttributes<HTMLDivElemen
|
||||
*/
|
||||
export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
useRoomPageTitle()
|
||||
useSyncLiveKitMetadata()
|
||||
useNoiseReduction()
|
||||
|
||||
const { isOpen: isPictureInPictureOpen } = usePictureInPicture()
|
||||
@@ -62,7 +62,9 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<RoomMetadataSynchronizer />
|
||||
<ConnectionObserver />
|
||||
<MediaStateObserver />
|
||||
<ChatProvider />
|
||||
<VideoResolutionSubscription />
|
||||
<div
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user