mirror of
https://github.com/suitenumerique/meet.git
synced 2026-08-15 13:13:56 +00:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c75e40b0ee | |||
| fe6c3bacaf | |||
| 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 }}
|
|
||||||
+22
-7
@@ -12,20 +12,35 @@ and this project adheres to
|
|||||||
|
|
||||||
- ✨(summary) report exception type in failure analytics
|
- ✨(summary) report exception type in failure analytics
|
||||||
- ✨(frontend) add configurable documentation menu item
|
- ✨(frontend) add configurable documentation menu item
|
||||||
|
- ✨(frontend) allow promoting authenticated participants
|
||||||
## Fixed
|
- ✨(frontend) introduce an "unauthenticated" participant badge
|
||||||
|
- ✨(backend) add roomkit viewset to start a room without WebRTC join
|
||||||
- 🐛(transcription) fix silent bug in speaker assignment
|
- ✨(frontend) let users set default configuration for generated links
|
||||||
- 🐛(summary) extend tasks auto retry logic
|
- ✨(frontend) expose media state to external gateways
|
||||||
- 🐛(summary) properly detect when failure webhook should be sent
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- ⬆️(frontend) upgrade @mediapipe/tasks-vision from 0.10.14 to 0.10.35
|
- ⬆️(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 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 @tanstack/react-query from 5.101.0 to 5.101.1
|
||||||
- ⬆️(frontend) upgrade livekit-client from 2.19.2 to 2.20.0
|
- ⬆️(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
|
## [1.24.0] - 2026-07-21
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ COMPOSE_EXEC = $(COMPOSE) exec
|
|||||||
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
|
COMPOSE_EXEC_APP = $(COMPOSE_EXEC) app-dev
|
||||||
COMPOSE_RUN = $(COMPOSE) run --rm
|
COMPOSE_RUN = $(COMPOSE) run --rm
|
||||||
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
|
COMPOSE_RUN_APP = $(COMPOSE_RUN) app-dev
|
||||||
|
COMPOSE_RUN_LINT = $(COMPOSE_RUN) --no-deps app-dev
|
||||||
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
|
COMPOSE_RUN_CROWDIN = $(COMPOSE_RUN) crowdin crowdin
|
||||||
WAIT_DB = @$(COMPOSE_RUN) dockerize -wait tcp://$(DB_HOST):$(DB_PORT) -timeout 60s
|
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
|
MANAGE = $(COMPOSE_RUN_APP) python manage.py
|
||||||
MAIL_NPM = $(COMPOSE_RUN) -w /app/src/mail node npm
|
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
|
# -- Frontend
|
||||||
PATH_FRONT = ./src/frontend
|
PATH_FRONT = ./src/frontend
|
||||||
|
|
||||||
@@ -124,6 +133,7 @@ logs: ## display app-dev logs (follow mode)
|
|||||||
run-backend: ## start only the backend application and all needed services
|
run-backend: ## start only the backend application and all needed services
|
||||||
@$(COMPOSE) up --force-recreate -d celery-dev --remove-orphans
|
@$(COMPOSE) up --force-recreate -d celery-dev --remove-orphans
|
||||||
@$(COMPOSE) up --force-recreate -d nginx
|
@$(COMPOSE) up --force-recreate -d nginx
|
||||||
|
@$(COMPOSE) up -d livekit
|
||||||
@echo "Wait for postgresql to be up..."
|
@echo "Wait for postgresql to be up..."
|
||||||
@$(WAIT_DB)
|
@$(WAIT_DB)
|
||||||
.PHONY: run-backend
|
.PHONY: run-backend
|
||||||
@@ -188,27 +198,23 @@ demo: ## flush db then create a demo for load testing purpose
|
|||||||
@$(MANAGE) create_demo
|
@$(MANAGE) create_demo
|
||||||
.PHONY: 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 back-end python sources
|
||||||
lint: \
|
@$(COMPOSE_RUN_LINT) sh -c "$(LINT_BACK)"
|
||||||
lint-ruff-format \
|
|
||||||
lint-ruff-check \
|
|
||||||
lint-pylint
|
|
||||||
.PHONY: lint
|
.PHONY: lint
|
||||||
|
|
||||||
lint-ruff-format: ## format back-end python sources with ruff
|
lint-ruff-format: ## format back-end python sources with ruff
|
||||||
@echo 'lint:ruff-format started…'
|
@echo 'lint:ruff-format started…'
|
||||||
@$(COMPOSE_RUN_APP) ruff format .
|
@$(COMPOSE_RUN_LINT) $(LINT_RUFF_FORMAT)
|
||||||
.PHONY: lint-ruff-format
|
.PHONY: lint-ruff-format
|
||||||
|
|
||||||
lint-ruff-check: ## lint back-end python sources with ruff
|
lint-ruff-check: ## lint back-end python sources with ruff
|
||||||
@echo 'lint:ruff-check started…'
|
@echo 'lint:ruff-check started…'
|
||||||
@$(COMPOSE_RUN_APP) ruff check . --fix
|
@$(COMPOSE_RUN_LINT) $(LINT_RUFF_CHECK)
|
||||||
.PHONY: lint-ruff-check
|
.PHONY: lint-ruff-check
|
||||||
|
|
||||||
lint-pylint: ## lint back-end python sources with pylint only on changed files from main
|
lint-pylint: ## lint back-end python sources with pylint only on changed files from main
|
||||||
@echo 'lint:pylint started…'
|
@echo 'lint:pylint started…'
|
||||||
@$(COMPOSE_RUN_APP) pylint meet demo core
|
@$(COMPOSE_RUN_LINT) $(LINT_PYLINT)
|
||||||
.PHONY: lint-pylint
|
.PHONY: lint-pylint
|
||||||
|
|
||||||
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"`
|
test: ## run project tests; pass extra pytest args via ARGS, e.g. `make test ARGS="-vv"`
|
||||||
|
|||||||
+2
-3
@@ -85,7 +85,6 @@ services:
|
|||||||
- postgresql
|
- postgresql
|
||||||
- mailcatcher
|
- mailcatcher
|
||||||
- redis
|
- redis
|
||||||
- livekit
|
|
||||||
- createbuckets
|
- createbuckets
|
||||||
- createwebhook
|
- createwebhook
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
@@ -97,7 +96,7 @@ services:
|
|||||||
celery-dev:
|
celery-dev:
|
||||||
user: ${DOCKER_USER:-1000}
|
user: ${DOCKER_USER:-1000}
|
||||||
image: meet:backend-development
|
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:
|
environment:
|
||||||
- DJANGO_CONFIGURATION=Development
|
- DJANGO_CONFIGURATION=Development
|
||||||
env_file:
|
env_file:
|
||||||
@@ -132,7 +131,7 @@ services:
|
|||||||
celery:
|
celery:
|
||||||
user: ${DOCKER_USER:-1000}
|
user: ${DOCKER_USER:-1000}
|
||||||
image: meet:backend-production
|
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:
|
environment:
|
||||||
- DJANGO_CONFIGURATION=Demo
|
- DJANGO_CONFIGURATION=Demo
|
||||||
env_file:
|
env_file:
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ ALLOW_UNREGISTERED_ROOMS=False
|
|||||||
|
|
||||||
# Recording
|
# Recording
|
||||||
RECORDING_ENABLE=True
|
RECORDING_ENABLE=True
|
||||||
RECORDING_STORAGE_EVENT_ENABLE=True
|
RECORDING_STORAGE_EVENT_ENABLE=False
|
||||||
RECORDING_STORAGE_EVENT_TOKEN=password
|
RECORDING_STORAGE_EVENT_TOKEN=password
|
||||||
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe/
|
SUMMARY_SERVICE_ENDPOINT=http://app-summary-dev:8000/api/v2/async-jobs/transcribe/
|
||||||
SUMMARY_SERVICE_API_TOKEN=password
|
SUMMARY_SERVICE_API_TOKEN=password
|
||||||
@@ -85,6 +85,10 @@ RECORDING_DOWNLOAD_BASE_URL=http://localhost:3000/recording
|
|||||||
# Telephony
|
# Telephony
|
||||||
ROOM_TELEPHONY_ENABLED=True
|
ROOM_TELEPHONY_ENABLED=True
|
||||||
|
|
||||||
|
# RoomKit
|
||||||
|
# ROOMKIT_ENABLED = True
|
||||||
|
# ROOMKIT_SERVER_TO_SERVER_API_TOKEN = ThisIsAnExampleKeyForDevPurposeOnly
|
||||||
|
|
||||||
# Metadata
|
# Metadata
|
||||||
METADATA_COLLECTOR_ENABLED=True
|
METADATA_COLLECTOR_ENABLED=True
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "3.49.0",
|
"core-js": "3.49.0",
|
||||||
"i18next": "^26.3.2",
|
"i18next": "^26.3.6",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"regenerator-runtime": "0.14.1"
|
"regenerator-runtime": "0.14.1"
|
||||||
},
|
},
|
||||||
@@ -9364,9 +9364,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next": {
|
"node_modules/i18next": {
|
||||||
"version": "26.3.2",
|
"version": "26.3.6",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.6.tgz",
|
||||||
"integrity": "sha512-QQkXAM1sPDHqhxMQuBeHVMUn6mJchF+wdpOoQerciLAFqO3ZYdxO0EUbeEhruyutnNwpUQIITDVzLjwnNL0T1w==",
|
"integrity": "sha512-Bu5Z2nAXgfVyM8xvW3jk9EKRIuX37PudsrBViThNFx7CR7aaYTpP01cxNB/E4c4UUzTDiAZRstEhsRfPOL/8xA==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -9383,7 +9383,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5 || ^6"
|
"typescript": "^5 || ^6 || ^7"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"typescript": {
|
"typescript": {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"core-js": "3.49.0",
|
"core-js": "3.49.0",
|
||||||
"i18next": "26.3.2",
|
"i18next": "26.3.6",
|
||||||
"i18next-browser-languagedetector": "8.2.1",
|
"i18next-browser-languagedetector": "8.2.1",
|
||||||
"regenerator-runtime": "0.14.1"
|
"regenerator-runtime": "0.14.1"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,3 +8,6 @@ class AnalyticsEvent(StrEnum):
|
|||||||
|
|
||||||
# Rooms
|
# Rooms
|
||||||
ROOM_CREATED = "room_created"
|
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(),
|
"telephony": build_telephony_config(),
|
||||||
|
"resource": {
|
||||||
|
"default_access_level": settings.RESOURCE_DEFAULT_ACCESS_LEVEL,
|
||||||
|
},
|
||||||
"subtitle": {"enabled": settings.ROOM_SUBTITLE_ENABLED},
|
"subtitle": {"enabled": settings.ROOM_SUBTITLE_ENABLED},
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
"url": settings.LIVEKIT_CONFIGURATION["url"],
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ class FeatureFlag:
|
|||||||
"file_upload": "FILE_UPLOAD_ENABLED",
|
"file_upload": "FILE_UPLOAD_ENABLED",
|
||||||
"addons": "ADDONS_ENABLED",
|
"addons": "ADDONS_ENABLED",
|
||||||
"application": "APPLICATION_ENABLED",
|
"application": "APPLICATION_ENABLED",
|
||||||
|
"roomkit": "ROOMKIT_ENABLED",
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ from django.http import Http404
|
|||||||
from rest_framework import permissions
|
from rest_framework import permissions
|
||||||
|
|
||||||
from ..models import RoleChoices
|
from ..models import RoleChoices
|
||||||
|
from ..services.participants_management import (
|
||||||
|
ParticipantNotFoundException,
|
||||||
|
ParticipantsManagement,
|
||||||
|
ParticipantsManagementException,
|
||||||
|
)
|
||||||
|
|
||||||
ACTION_FOR_METHOD_TO_PERMISSION = {
|
ACTION_FOR_METHOD_TO_PERMISSION = {
|
||||||
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
|
"versions_detail": {"DELETE": "versions_destroy", "GET": "versions_retrieve"}
|
||||||
@@ -166,3 +171,30 @@ class CanMuteParticipant(permissions.BasePermission):
|
|||||||
|
|
||||||
# LiveKit token scoped to this room
|
# LiveKit token scoped to this room
|
||||||
return request.auth.video.room == str(obj.id)
|
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:
|
class Meta:
|
||||||
model = models.User
|
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"]
|
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):
|
class UserLightSerializer(serializers.ModelSerializer):
|
||||||
"""Serialize users with limited fields."""
|
"""Serialize users with limited fields."""
|
||||||
@@ -183,13 +202,11 @@ class RoomSerializer(serializers.ModelSerializer):
|
|||||||
user=request.user,
|
user=request.user,
|
||||||
username=username,
|
username=username,
|
||||||
configuration=output["configuration"],
|
configuration=output["configuration"],
|
||||||
is_admin_or_owner=is_admin_or_owner,
|
role=role,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
del output["pin_code"]
|
del output["pin_code"]
|
||||||
|
|
||||||
output["is_administrable"] = is_admin_or_owner
|
|
||||||
|
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
|
||||||
@@ -299,8 +316,8 @@ class RoomInviteSerializer(serializers.Serializer):
|
|||||||
class BaseParticipantsManagementSerializer(BaseValidationOnlySerializer):
|
class BaseParticipantsManagementSerializer(BaseValidationOnlySerializer):
|
||||||
"""Base serializer for participant management operations."""
|
"""Base serializer for participant management operations."""
|
||||||
|
|
||||||
participant_identity = serializers.UUIDField(
|
participant_identity = serializers.CharField(
|
||||||
help_text="LiveKit participant identity (UUID format)"
|
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"]
|
TrackSource = Literal["camera", "microphone", "screen_share", "screen_share_audio"]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -73,3 +73,15 @@ class CreationCallbackAnonRateThrottle(MonitoredAnonRateThrottle):
|
|||||||
"""Throttle Anonymous user requesting room generation callback"""
|
"""Throttle Anonymous user requesting room generation callback"""
|
||||||
|
|
||||||
scope = "creation_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,
|
RoomManagementException,
|
||||||
RoomNotFoundException,
|
RoomNotFoundException,
|
||||||
)
|
)
|
||||||
|
from core.services.room_roles import (
|
||||||
|
RoomRoleError,
|
||||||
|
RoomRoleService,
|
||||||
|
)
|
||||||
from core.services.subtitle import SubtitleException, SubtitleService
|
from core.services.subtitle import SubtitleException, SubtitleService
|
||||||
from core.tasks.file import process_file_deletion
|
from core.tasks.file import process_file_deletion
|
||||||
|
|
||||||
@@ -304,8 +308,27 @@ class RoomViewSet(
|
|||||||
return drf_response.Response(serializer.data)
|
return drf_response.Response(serializer.data)
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
"""Set the current user as owner of the newly created room."""
|
"""Set the current user as owner of the newly created room.
|
||||||
room = serializer.save()
|
|
||||||
|
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(
|
models.ResourceAccess.objects.create(
|
||||||
resource=room,
|
resource=room,
|
||||||
user=self.request.user,
|
user=self.request.user,
|
||||||
@@ -639,6 +662,53 @@ class RoomViewSet(
|
|||||||
status=drf_status.HTTP_200_OK,
|
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(
|
@decorators.action(
|
||||||
detail=True,
|
detail=True,
|
||||||
methods=["post"],
|
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,
|
default=settings.TIME_ZONE,
|
||||||
help_text=_("The timezone in which the user wants to see times."),
|
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(
|
is_device = models.BooleanField(
|
||||||
_("device"),
|
_("device"),
|
||||||
default=False,
|
default=False,
|
||||||
@@ -429,7 +448,14 @@ class Room(Resource):
|
|||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
"""Generate a unique n-digit pin code for new rooms."""
|
"""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(
|
self.pin_code = self.generate_unique_pin_code(
|
||||||
length=settings.ROOM_TELEPHONY_PIN_LENGTH
|
length=settings.ROOM_TELEPHONY_PIN_LENGTH
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ from livekit import api
|
|||||||
from core import models, utils
|
from core import models, utils
|
||||||
from core.models import Recording
|
from core.models import Recording
|
||||||
from core.recording.event.notification import notification_service
|
from core.recording.event.notification import notification_service
|
||||||
|
from core.services.room_management import (
|
||||||
|
RoomManagement,
|
||||||
|
RoomManagementException,
|
||||||
|
RoomNotFoundException,
|
||||||
|
)
|
||||||
|
|
||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
@@ -39,10 +44,15 @@ class RecordingEventsService:
|
|||||||
recording_status = status_mapping.get(egress_status)
|
recording_status = status_mapping.get(egress_status)
|
||||||
if recording_status:
|
if recording_status:
|
||||||
try:
|
try:
|
||||||
utils.update_room_metadata(
|
RoomManagement().update_metadata(
|
||||||
room_name, {"recording_status": recording_status}
|
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)
|
logger.exception("Failed to update room's metadata: %s", e)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -2,8 +2,12 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from core import utils
|
|
||||||
from core.models import Recording, RecordingStatusChoices
|
from core.models import Recording, RecordingStatusChoices
|
||||||
|
from core.services.room_management import (
|
||||||
|
RoomManagement,
|
||||||
|
RoomManagementException,
|
||||||
|
RoomNotFoundException,
|
||||||
|
)
|
||||||
|
|
||||||
from .exceptions import (
|
from .exceptions import (
|
||||||
RecordingStartError,
|
RecordingStartError,
|
||||||
@@ -64,10 +68,15 @@ class WorkerServiceMediator:
|
|||||||
mode = recording.options.get("original_mode", None) or recording.mode
|
mode = recording.options.get("original_mode", None) or recording.mode
|
||||||
|
|
||||||
try:
|
try:
|
||||||
utils.update_room_metadata(
|
RoomManagement().update_metadata(
|
||||||
room_name, {"recording_mode": mode, "recording_status": "starting"}
|
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.exception("Failed to update room's metadata: %s", e)
|
||||||
|
|
||||||
logger.info(
|
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,
|
||||||
|
)
|
||||||
@@ -11,7 +11,7 @@ from django.conf import settings
|
|||||||
|
|
||||||
from livekit import api
|
from livekit import api
|
||||||
|
|
||||||
from core import models, utils
|
from core import models
|
||||||
from core.recording.services.metadata_collector import (
|
from core.recording.services.metadata_collector import (
|
||||||
MetadataCollectorException,
|
MetadataCollectorException,
|
||||||
MetadataCollectorService,
|
MetadataCollectorService,
|
||||||
@@ -23,7 +23,12 @@ from core.recording.services.recording_events import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from .lobby import LobbyService
|
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__)
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
@@ -102,7 +107,7 @@ class LiveKitEventsService:
|
|||||||
)
|
)
|
||||||
self.webhook_receiver = api.WebhookReceiver(token_verifier)
|
self.webhook_receiver = api.WebhookReceiver(token_verifier)
|
||||||
self.lobby_service = LobbyService()
|
self.lobby_service = LobbyService()
|
||||||
self.telephony_service = TelephonyService()
|
self.sip_management = SIPManagement()
|
||||||
self.recording_events = RecordingEventsService()
|
self.recording_events = RecordingEventsService()
|
||||||
|
|
||||||
self._filter_regex = None
|
self._filter_regex = None
|
||||||
@@ -177,10 +182,15 @@ class LiveKitEventsService:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
room_name = str(recording.room.id)
|
room_name = str(recording.room.id)
|
||||||
utils.update_room_metadata(
|
RoomManagement().update_metadata(
|
||||||
room_name, {}, ["recording_mode", "recording_status"]
|
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)
|
logger.exception("Failed to update room's metadata: %s", e)
|
||||||
|
|
||||||
if recording.options.get("metadata_collector_dispatch_id", None) is not None:
|
if recording.options.get("metadata_collector_dispatch_id", None) is not None:
|
||||||
@@ -235,12 +245,12 @@ class LiveKitEventsService:
|
|||||||
except models.Room.DoesNotExist as err:
|
except models.Room.DoesNotExist as err:
|
||||||
raise ActionFailedError(f"Room with ID {room_id} does not exist") from 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:
|
try:
|
||||||
self.telephony_service.create_dispatch_rule(room)
|
self.sip_management.ensure_dispatch_rule(room)
|
||||||
except TelephonyException as e:
|
except SIPException as e:
|
||||||
raise ActionFailedError(
|
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
|
) from e
|
||||||
|
|
||||||
def _handle_room_finished(self, data):
|
def _handle_room_finished(self, data):
|
||||||
@@ -255,12 +265,12 @@ class LiveKitEventsService:
|
|||||||
)
|
)
|
||||||
raise ActionFailedError("Failed to process room finished event") from e
|
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:
|
try:
|
||||||
self.telephony_service.delete_dispatch_rule(room_id)
|
self.sip_management.delete_dispatch_rule(room_id)
|
||||||
except TelephonyException as e:
|
except SIPException as e:
|
||||||
raise ActionFailedError(
|
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
|
) from e
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -104,22 +104,31 @@ class LobbyService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@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.
|
"""Determines if a user can bypass the waiting lobby and join a room directly.
|
||||||
|
|
||||||
A user can bypass the lobby if:
|
A user can bypass the lobby if:
|
||||||
1. The room is public (open to everyone)
|
1. The room is public (open to everyone)
|
||||||
2. The room has TRUSTED access level and the user is authenticated
|
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.
|
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
|
This function only checks the current state and should be called each time
|
||||||
a participant requests entry to ensure consistent access control, even for
|
a participant requests entry to ensure consistent access control, even for
|
||||||
participants who have already begun waiting.
|
participants who have already begun waiting.
|
||||||
"""
|
"""
|
||||||
return room.is_public or (
|
return (
|
||||||
|
room.is_public
|
||||||
|
or (
|
||||||
room.access_level == models.RoomAccessLevel.TRUSTED
|
room.access_level == models.RoomAccessLevel.TRUSTED
|
||||||
and user.is_authenticated
|
and user.is_authenticated
|
||||||
)
|
)
|
||||||
|
or (
|
||||||
|
room.access_level == models.RoomAccessLevel.RESTRICTED
|
||||||
|
and user.is_authenticated
|
||||||
|
and role is not None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
def request_entry(
|
def request_entry(
|
||||||
self,
|
self,
|
||||||
@@ -144,8 +153,9 @@ class LobbyService:
|
|||||||
participant = self._get_participant(room.id, participant_id)
|
participant = self._get_participant(room.id, participant_id)
|
||||||
|
|
||||||
room_id = str(room.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:
|
if participant is None:
|
||||||
participant = LobbyParticipant(
|
participant = LobbyParticipant(
|
||||||
status=LobbyParticipantStatus.ACCEPTED,
|
status=LobbyParticipantStatus.ACCEPTED,
|
||||||
@@ -162,8 +172,8 @@ class LobbyService:
|
|||||||
username=username,
|
username=username,
|
||||||
color=participant.color,
|
color=participant.color,
|
||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
is_admin_or_owner=False,
|
|
||||||
participant_id=participant_id,
|
participant_id=participant_id,
|
||||||
|
role=user_role,
|
||||||
)
|
)
|
||||||
return participant, livekit_config
|
return participant, livekit_config
|
||||||
|
|
||||||
@@ -183,8 +193,8 @@ class LobbyService:
|
|||||||
username=username,
|
username=username,
|
||||||
color=participant.color,
|
color=participant.color,
|
||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
is_admin_or_owner=False,
|
|
||||||
participant_id=participant_id,
|
participant_id=participant_id,
|
||||||
|
role=user_role,
|
||||||
)
|
)
|
||||||
|
|
||||||
return participant, livekit_config
|
return participant, livekit_config
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from typing import Dict, Optional
|
|||||||
|
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
from livekit.api import (
|
from livekit.api import (
|
||||||
|
ListRoomsRequest,
|
||||||
TwirpError,
|
TwirpError,
|
||||||
UpdateRoomMetadataRequest,
|
UpdateRoomMetadataRequest,
|
||||||
)
|
)
|
||||||
@@ -29,20 +30,45 @@ class RoomManagement:
|
|||||||
"""Service for managing LiveKit rooms."""
|
"""Service for managing LiveKit rooms."""
|
||||||
|
|
||||||
@async_to_sync
|
@async_to_sync
|
||||||
async def update_metadata(self, room_name: str, metadata: Optional[Dict] = None):
|
async def update_metadata(
|
||||||
"""Update a LiveKit room's 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
|
The `room_name` corresponds to the LiveKit room identifier
|
||||||
(i.e. the Room model's UUID as a string).
|
(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()
|
lkapi = utils.create_livekit_client()
|
||||||
|
|
||||||
try:
|
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(
|
await lkapi.room.update_room_metadata(
|
||||||
UpdateRoomMetadataRequest(
|
UpdateRoomMetadataRequest(
|
||||||
room=room_name,
|
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 logging import getLogger
|
||||||
|
|
||||||
from asgiref.sync import async_to_sync
|
from asgiref.sync import async_to_sync
|
||||||
from livekit.api import TwirpError
|
from livekit.api import TwirpError, TwirpErrorCode
|
||||||
from livekit.protocol.sip import (
|
from livekit.protocol.sip import (
|
||||||
CreateSIPDispatchRuleRequest,
|
CreateSIPDispatchRuleRequest,
|
||||||
DeleteSIPDispatchRuleRequest,
|
DeleteSIPDispatchRuleRequest,
|
||||||
@@ -17,12 +17,16 @@ from core import utils
|
|||||||
logger = getLogger(__name__)
|
logger = getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class TelephonyException(Exception):
|
class SIPException(Exception):
|
||||||
"""Exception raised when telephony operations fail."""
|
"""Exception raised when SIP operations fail."""
|
||||||
|
|
||||||
|
|
||||||
class TelephonyService:
|
class DispatchRuleConflictError(SIPException):
|
||||||
"""Service for managing participant access through the telephony system (SIP)."""
|
"""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):
|
def _rule_name(self, room_id):
|
||||||
"""Generate the rule name for a room based on its 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):
|
async def create_dispatch_rule(self, room):
|
||||||
"""Create a SIP inbound dispatch rule for direct room routing.
|
"""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.
|
using the room's ID and PIN code for authentication.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -51,10 +55,12 @@ class TelephonyService:
|
|||||||
try:
|
try:
|
||||||
await lkapi.sip.create_sip_dispatch_rule(create=request)
|
await lkapi.sip.create_sip_dispatch_rule(create=request)
|
||||||
except TwirpError as e:
|
except TwirpError as e:
|
||||||
|
if e.code == TwirpErrorCode.ALREADY_EXISTS:
|
||||||
|
raise DispatchRuleConflictError("Dispatch rule already exists") from e
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"Unexpected error creating dispatch rule for room %s", room.id
|
"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:
|
finally:
|
||||||
await lkapi.aclose()
|
await lkapi.aclose()
|
||||||
@@ -79,7 +85,7 @@ class TelephonyService:
|
|||||||
)
|
)
|
||||||
except TwirpError as e:
|
except TwirpError as e:
|
||||||
logger.exception("Failed to list dispatch rules for room %s", room_id)
|
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:
|
finally:
|
||||||
await lkapi.aclose()
|
await lkapi.aclose()
|
||||||
|
|
||||||
@@ -94,6 +100,28 @@ class TelephonyService:
|
|||||||
if existing_rule.name == rule_name
|
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_to_sync
|
||||||
async def delete_dispatch_rule(self, room_id):
|
async def delete_dispatch_rule(self, room_id):
|
||||||
"""Delete all SIP inbound dispatch rules associated with a specific room."""
|
"""Delete all SIP inbound dispatch rules associated with a specific room."""
|
||||||
@@ -118,7 +146,7 @@ class TelephonyService:
|
|||||||
|
|
||||||
except TwirpError as e:
|
except TwirpError as e:
|
||||||
logger.exception("Failed to delete dispatch rules for room %s", room_id)
|
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:
|
finally:
|
||||||
await lkapi.aclose()
|
await lkapi.aclose()
|
||||||
@@ -1,4 +1,11 @@
|
|||||||
|
"""
|
||||||
|
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
|
# ruff: noqa: PLC0415
|
||||||
|
# pylint: disable=import-outside-toplevel
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
|
|||||||
@@ -34,10 +34,8 @@ def mediator(mock_worker_service):
|
|||||||
return WorkerServiceMediator(mock_worker_service)
|
return WorkerServiceMediator(mock_worker_service)
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.update_room_metadata")
|
@mock.patch("core.services.room_management.RoomManagement.update_metadata")
|
||||||
def test_start_recording_success(
|
def test_start_recording_success(mock_update_metadata, mediator, mock_worker_service):
|
||||||
mock_update_room_metadata, mediator, mock_worker_service
|
|
||||||
):
|
|
||||||
"""Test successful recording start"""
|
"""Test successful recording start"""
|
||||||
# Setup
|
# Setup
|
||||||
worker_id = "test-worker-123"
|
worker_id = "test-worker-123"
|
||||||
@@ -60,7 +58,7 @@ def test_start_recording_success(
|
|||||||
assert mock_recording.worker_id == worker_id
|
assert mock_recording.worker_id == worker_id
|
||||||
assert mock_recording.status == RecordingStatusChoices.ACTIVE
|
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),
|
str(mock_recording.room.id),
|
||||||
{"recording_mode": mock_recording.mode, "recording_status": "starting"},
|
{"recording_mode": mock_recording.mode, "recording_status": "starting"},
|
||||||
)
|
)
|
||||||
@@ -69,9 +67,9 @@ def test_start_recording_success(
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"error_class", [WorkerRequestError, WorkerConnectionError, WorkerResponseError]
|
"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(
|
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"""
|
"""Test handling of various worker errors during start"""
|
||||||
# Setup
|
# Setup
|
||||||
@@ -89,7 +87,7 @@ def test_mediator_start_recording_worker_errors(
|
|||||||
assert mock_recording.status == RecordingStatusChoices.FAILED_TO_START
|
assert mock_recording.status == RecordingStatusChoices.FAILED_TO_START
|
||||||
assert mock_recording.worker_id is None
|
assert mock_recording.worker_id is None
|
||||||
|
|
||||||
mock_update_room_metadata.assert_not_called()
|
mock_update_metadata.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -103,9 +101,9 @@ def test_mediator_start_recording_worker_errors(
|
|||||||
RecordingStatusChoices.ABORTED,
|
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(
|
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"""
|
"""Test handling of various worker errors during start"""
|
||||||
# Setup
|
# Setup
|
||||||
@@ -119,7 +117,7 @@ def test_mediator_start_recording_from_forbidden_status(
|
|||||||
mock_recording.refresh_from_db()
|
mock_recording.refresh_from_db()
|
||||||
assert mock_recording.status == status
|
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):
|
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
|
# pylint: disable=redefined-outer-name,unused-argument
|
||||||
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from ...factories import RoomFactory, UserFactory
|
from ...factories import RoomFactory, UserFactory
|
||||||
from ...models import Room
|
from ...models import Room, RoomAccessLevel
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -109,3 +110,205 @@ def test_api_rooms_create_authenticated_existing_slug():
|
|||||||
|
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
assert response.json() == {"slug": ["Room with this Slug already exists."]}
|
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()
|
room = RoomFactory()
|
||||||
|
|
||||||
user = AnonymousUser()
|
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})
|
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -106,7 +106,7 @@ def test_mute_participant_with_livekit_token_for_another_room_forbidden(
|
|||||||
other_room = RoomFactory()
|
other_room = RoomFactory()
|
||||||
|
|
||||||
user = AnonymousUser()
|
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})
|
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
|
||||||
response = client.post(
|
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})
|
room = RoomFactory(configuration={"everyone_can_mute": False})
|
||||||
|
|
||||||
user = AnonymousUser()
|
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})
|
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
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
|
# Token identity matches the admin user so LiveKitTokenAuthentication
|
||||||
# resolves request.user back to the admin.
|
# 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})
|
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
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
|
# Token is scoped to a DIFFERENT room, and admin status must only be
|
||||||
# honored when established via session, never via a LiveKit
|
# honored when established via session, never via a LiveKit
|
||||||
# token, which can be replayed off-host.
|
# 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})
|
url = reverse("rooms-mute-participant", kwargs={"pk": target_room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -354,7 +354,7 @@ def test_mute_participant_admin_token_replayed_does_not_grant_admin(
|
|||||||
role=random.choice(["administrator", "owner"]),
|
role=random.choice(["administrator", "owner"]),
|
||||||
)
|
)
|
||||||
# The token is the only credential.
|
# 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})
|
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -374,7 +374,7 @@ def test_mute_participant_livekit_token_triggers_presence_check(mock_livekit_cli
|
|||||||
room = RoomFactory()
|
room = RoomFactory()
|
||||||
|
|
||||||
user = AnonymousUser()
|
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})
|
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -405,7 +405,7 @@ def test_mute_participant_livekit_token_presence_check_returns_participant(
|
|||||||
)
|
)
|
||||||
|
|
||||||
user = AnonymousUser()
|
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})
|
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -433,7 +433,7 @@ def test_mute_participant_livekit_token_presence_check_participant_not_found(
|
|||||||
)
|
)
|
||||||
|
|
||||||
user = AnonymousUser()
|
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})
|
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -462,7 +462,7 @@ def test_mute_participant_livekit_token_presence_check_twirp_error_forbidden(
|
|||||||
)
|
)
|
||||||
|
|
||||||
user = AnonymousUser()
|
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})
|
url = reverse("rooms-mute-participant", kwargs={"pk": room.id})
|
||||||
response = client.post(
|
response = client.post(
|
||||||
@@ -719,13 +719,13 @@ def test_update_participant_invalid_payload():
|
|||||||
)
|
)
|
||||||
client.force_authenticate(user=user)
|
client.force_authenticate(user=user)
|
||||||
|
|
||||||
payload = {"participant_identity": "invalid-uuid"}
|
payload = {"participant_identity": ["test"]}
|
||||||
|
|
||||||
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
url = reverse("rooms-update-participant", kwargs={"pk": room.id})
|
||||||
response = client.post(url, payload, format="json")
|
response = client.post(url, payload, format="json")
|
||||||
|
|
||||||
assert response.status_code == status.HTTP_400_BAD_REQUEST
|
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():
|
def test_update_participant_no_update_fields():
|
||||||
@@ -918,7 +918,7 @@ def test_remove_participant_invalid_payload():
|
|||||||
)
|
)
|
||||||
client.force_authenticate(user=user)
|
client.force_authenticate(user=user)
|
||||||
|
|
||||||
payload = {"participant_identity": "invalid-uuid"}
|
payload = {"participant_identity": ["invalid-uuid"]}
|
||||||
|
|
||||||
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
|
url = reverse("rooms-remove-participant", kwargs={"pk": room.id})
|
||||||
response = client.post(url, payload, format="json")
|
response = client.post(url, payload, format="json")
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import pytest
|
|||||||
from rest_framework.test import APIClient
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
from ...factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
from ...factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||||
from ...models import RoomAccessLevel
|
from ...models import RoleChoices, RoomAccessLevel
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -31,7 +31,6 @@ def test_api_rooms_retrieve_anonymous_private_pk():
|
|||||||
"configuration": {},
|
"configuration": {},
|
||||||
"access_level": "restricted",
|
"access_level": "restricted",
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
"is_administrable": False,
|
|
||||||
"name": room.name,
|
"name": room.name,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
}
|
}
|
||||||
@@ -51,7 +50,6 @@ def test_api_rooms_retrieve_anonymous_trusted_pk():
|
|||||||
"configuration": {},
|
"configuration": {},
|
||||||
"access_level": "trusted",
|
"access_level": "trusted",
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
"is_administrable": False,
|
|
||||||
"name": room.name,
|
"name": room.name,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
}
|
}
|
||||||
@@ -70,7 +68,6 @@ def test_api_rooms_retrieve_anonymous_private_pk_no_dashes():
|
|||||||
"configuration": {},
|
"configuration": {},
|
||||||
"access_level": "restricted",
|
"access_level": "restricted",
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
"is_administrable": False,
|
|
||||||
"name": room.name,
|
"name": room.name,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
}
|
}
|
||||||
@@ -87,7 +84,6 @@ def test_api_rooms_retrieve_anonymous_private_slug():
|
|||||||
"configuration": {},
|
"configuration": {},
|
||||||
"access_level": "restricted",
|
"access_level": "restricted",
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
"is_administrable": False,
|
|
||||||
"name": room.name,
|
"name": room.name,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
}
|
}
|
||||||
@@ -104,7 +100,6 @@ def test_api_rooms_retrieve_anonymous_private_slug_not_normalized():
|
|||||||
"configuration": {},
|
"configuration": {},
|
||||||
"access_level": "restricted",
|
"access_level": "restricted",
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
"is_administrable": False,
|
|
||||||
"name": room.name,
|
"name": room.name,
|
||||||
"slug": room.slug,
|
"slug": room.slug,
|
||||||
}
|
}
|
||||||
@@ -214,7 +209,6 @@ def test_api_rooms_retrieve_anonymous_public(mock_token):
|
|||||||
"configuration": {},
|
"configuration": {},
|
||||||
"access_level": str(room.access_level),
|
"access_level": str(room.access_level),
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
"is_administrable": False,
|
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": "test_url_value",
|
"url": "test_url_value",
|
||||||
"room": expected_name,
|
"room": expected_name,
|
||||||
@@ -261,7 +255,6 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
|||||||
"configuration": {"can_publish_sources": ["camera"]},
|
"configuration": {"can_publish_sources": ["camera"]},
|
||||||
"access_level": str(room.access_level),
|
"access_level": str(room.access_level),
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
"is_administrable": False,
|
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": "test_url_value",
|
"url": "test_url_value",
|
||||||
"room": expected_name,
|
"room": expected_name,
|
||||||
@@ -278,7 +271,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token):
|
|||||||
username=None,
|
username=None,
|
||||||
color=None,
|
color=None,
|
||||||
sources=["camera"],
|
sources=["camera"],
|
||||||
is_admin_or_owner=False,
|
role=None,
|
||||||
participant_id=None,
|
participant_id=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -313,7 +306,6 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
|||||||
"configuration": {},
|
"configuration": {},
|
||||||
"access_level": str(room.access_level),
|
"access_level": str(room.access_level),
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
"is_administrable": False,
|
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": "test_url_value",
|
"url": "test_url_value",
|
||||||
"room": expected_name,
|
"room": expected_name,
|
||||||
@@ -330,7 +322,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token):
|
|||||||
username=None,
|
username=None,
|
||||||
color=None,
|
color=None,
|
||||||
sources=None,
|
sources=None,
|
||||||
is_admin_or_owner=False,
|
role=None,
|
||||||
participant_id=None,
|
participant_id=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -355,7 +347,6 @@ def test_api_rooms_retrieve_authenticated():
|
|||||||
"configuration": {},
|
"configuration": {},
|
||||||
"access_level": "restricted",
|
"access_level": "restricted",
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
"is_administrable": False,
|
|
||||||
"name": room.name,
|
"name": room.name,
|
||||||
"slug": room.slug,
|
"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"]},
|
"configuration": {"can_publish_sources": ["camera"]},
|
||||||
"access_level": str(room.access_level),
|
"access_level": str(room.access_level),
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
"is_administrable": False,
|
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": "test_url_value",
|
"url": "test_url_value",
|
||||||
"room": expected_name,
|
"room": expected_name,
|
||||||
@@ -418,7 +408,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti
|
|||||||
username=None,
|
username=None,
|
||||||
color=None,
|
color=None,
|
||||||
sources=["camera"],
|
sources=["camera"],
|
||||||
is_admin_or_owner=False,
|
role=str(RoleChoices.MEMBER),
|
||||||
participant_id=None,
|
participant_id=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -463,6 +453,8 @@ def test_api_rooms_retrieve_administrators(
|
|||||||
{
|
{
|
||||||
"id": str(other_user_access.id),
|
"id": str(other_user_access.id),
|
||||||
"user": {
|
"user": {
|
||||||
|
"default_room_access_level": None,
|
||||||
|
"default_room_configuration": {},
|
||||||
"id": str(other_user_access.user.id),
|
"id": str(other_user_access.user.id),
|
||||||
"email": other_user_access.user.email,
|
"email": other_user_access.user.email,
|
||||||
"full_name": other_user_access.user.full_name,
|
"full_name": other_user_access.user.full_name,
|
||||||
@@ -476,6 +468,8 @@ def test_api_rooms_retrieve_administrators(
|
|||||||
{
|
{
|
||||||
"id": str(user_access.id),
|
"id": str(user_access.id),
|
||||||
"user": {
|
"user": {
|
||||||
|
"default_room_access_level": None,
|
||||||
|
"default_room_configuration": {},
|
||||||
"id": str(user_access.user.id),
|
"id": str(user_access.user.id),
|
||||||
"email": user_access.user.email,
|
"email": user_access.user.email,
|
||||||
"full_name": user_access.user.full_name,
|
"full_name": user_access.user.full_name,
|
||||||
@@ -493,7 +487,6 @@ def test_api_rooms_retrieve_administrators(
|
|||||||
assert content_dict == {
|
assert content_dict == {
|
||||||
"access_level": str(room.access_level),
|
"access_level": str(room.access_level),
|
||||||
"id": str(room.id),
|
"id": str(room.id),
|
||||||
"is_administrable": True,
|
|
||||||
"configuration": {},
|
"configuration": {},
|
||||||
"livekit": {
|
"livekit": {
|
||||||
"url": "test_url_value",
|
"url": "test_url_value",
|
||||||
@@ -511,6 +504,6 @@ def test_api_rooms_retrieve_administrators(
|
|||||||
username=None,
|
username=None,
|
||||||
color=None,
|
color=None,
|
||||||
sources=None,
|
sources=None,
|
||||||
is_admin_or_owner=True,
|
role=str(user_access.role),
|
||||||
participant_id=None,
|
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,
|
api,
|
||||||
)
|
)
|
||||||
from core.services.lobby import LobbyService
|
from core.services.lobby import LobbyService
|
||||||
from core.services.telephony import TelephonyException, TelephonyService
|
from core.services.room_management import RoomManagementException
|
||||||
from core.utils import MetadataUpdateException, NotificationError
|
from core.services.sip_management import (
|
||||||
|
SIPException,
|
||||||
|
SIPManagement,
|
||||||
|
)
|
||||||
|
from core.utils import NotificationError
|
||||||
|
|
||||||
pytestmark = pytest.mark.django_db
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -58,7 +62,7 @@ def test_initialization(
|
|||||||
mock_token_verifier.assert_called_once_with(api_key, api_secret)
|
mock_token_verifier.assert_called_once_with(api_key, api_secret)
|
||||||
mock_webhook_receiver.assert_called_once_with(mock_token_verifier.return_value)
|
mock_webhook_receiver.assert_called_once_with(mock_token_verifier.return_value)
|
||||||
assert isinstance(service.lobby_service, LobbyService)
|
assert isinstance(service.lobby_service, LobbyService)
|
||||||
assert isinstance(service.telephony_service, TelephonyService)
|
assert isinstance(service.sip_management, SIPManagement)
|
||||||
assert isinstance(service.recording_events, RecordingEventsService)
|
assert isinstance(service.recording_events, RecordingEventsService)
|
||||||
|
|
||||||
|
|
||||||
@@ -70,12 +74,13 @@ def test_initialization(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
@mock.patch("core.utils.notify_participants")
|
@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_success(
|
def test_handle_egress_ended_success( # noqa: PLR0913 # pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||||
mock_update_room_metadata, mock_notify, mode, notification_type, service
|
mock_update_metadata, mock_notify, mode, notification_type, service, settings
|
||||||
):
|
):
|
||||||
"""Should successfully stop recording and notifies all participant."""
|
"""Should successfully stop recording and notifies all participant."""
|
||||||
|
|
||||||
|
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||||
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
mock_data.egress_info.egress_id = recording.worker_id
|
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(
|
mock_notify.assert_called_once_with(
|
||||||
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
room_name=str(recording.room.id), notification_data={"type": notification_type}
|
||||||
)
|
)
|
||||||
mock_update_room_metadata.assert_called_once_with(
|
mock_update_metadata.assert_called_once_with(
|
||||||
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||||
)
|
)
|
||||||
|
|
||||||
recording.refresh_from_db()
|
recording.refresh_from_db()
|
||||||
@@ -104,9 +109,9 @@ def test_handle_egress_ended_success(
|
|||||||
(EgressStatus.EGRESS_ABORTED, "aborted"),
|
(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(
|
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."""
|
"""Should successfully update room's metadata."""
|
||||||
|
|
||||||
@@ -117,7 +122,7 @@ def test_handle_egress_updated_success(
|
|||||||
|
|
||||||
service._handle_egress_updated(mock_data)
|
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}
|
str(recording.room.id), {"recording_status": status}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -129,9 +134,9 @@ def test_handle_egress_updated_success(
|
|||||||
EgressStatus.EGRESS_LIMIT_REACHED,
|
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(
|
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."""
|
"""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)
|
service._handle_egress_updated(mock_data)
|
||||||
|
|
||||||
mock_update_room_metadata.assert_not_called()
|
mock_update_metadata.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -153,18 +158,19 @@ def test_handle_egress_updated_non_handled(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
@mock.patch("core.utils.notify_participants")
|
@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_metadata_update_fails(
|
def test_handle_egress_ended_metadata_update_fails( # noqa: PLR0913 # pylint: disable=too-many-arguments, too-many-positional-arguments
|
||||||
mock_update_room_metadata, mock_notify, mode, notification_type, service
|
mock_update_metadata, mock_notify, mode, notification_type, service, settings
|
||||||
):
|
):
|
||||||
"""Should successfully stop and save recording when metadata's update fails."""
|
"""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")
|
recording = RecordingFactory(worker_id="worker-1", mode=mode, status="active")
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
mock_data.egress_info.egress_id = recording.worker_id
|
mock_data.egress_info.egress_id = recording.worker_id
|
||||||
mock_data.egress_info.status = EgressStatus.EGRESS_LIMIT_REACHED
|
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)
|
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.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(
|
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."""
|
"""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()
|
recording.refresh_from_db()
|
||||||
assert recording.status == "stopped"
|
assert recording.status == "stopped"
|
||||||
|
|
||||||
mock_update_room_metadata.assert_called_once_with(
|
mock_update_metadata.assert_called_once_with(
|
||||||
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.notify_participants")
|
@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(
|
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."""
|
"""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)
|
service._handle_egress_ended(mock_data)
|
||||||
|
|
||||||
mock_notify.assert_not_called()
|
mock_notify.assert_not_called()
|
||||||
mock_update_room_metadata.assert_not_called()
|
mock_update_metadata.assert_not_called()
|
||||||
|
|
||||||
recording.refresh_from_db()
|
recording.refresh_from_db()
|
||||||
assert recording.status == "active"
|
assert recording.status == "active"
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.utils.notify_participants")
|
@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(
|
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."""
|
"""Should ignore non-active recordings."""
|
||||||
|
|
||||||
@@ -244,8 +250,8 @@ def test_handle_egress_ended_recording_not_active(
|
|||||||
service._handle_egress_ended(mock_data)
|
service._handle_egress_ended(mock_data)
|
||||||
|
|
||||||
mock_notify.assert_not_called()
|
mock_notify.assert_not_called()
|
||||||
mock_update_room_metadata.assert_called_once_with(
|
mock_update_metadata.assert_called_once_with(
|
||||||
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||||
)
|
)
|
||||||
|
|
||||||
recording.refresh_from_db()
|
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.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(
|
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."""
|
"""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)
|
service._handle_egress_ended(mock_data)
|
||||||
|
|
||||||
mock_notify.assert_not_called()
|
mock_notify.assert_not_called()
|
||||||
mock_update_room_metadata.assert_called_once_with(
|
mock_update_metadata.assert_called_once_with(
|
||||||
str(recording.room.id), {}, ["recording_mode", "recording_status"]
|
str(recording.room.id), remove_keys=["recording_mode", "recording_status"]
|
||||||
)
|
)
|
||||||
assert recording.status == "stopped"
|
assert recording.status == "stopped"
|
||||||
|
|
||||||
|
|
||||||
@mock.patch("core.services.livekit_events.MetadataCollectorService")
|
@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(
|
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."""
|
"""Should call MetadataCollectorService.stop when it exists."""
|
||||||
settings.METADATA_COLLECTOR_ENABLED = True
|
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.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(
|
def test_handle_egress_ended_does_not_call_metadata_collector_stop_when_conditions_not_met(
|
||||||
_, mock_collector_class, metadata_enabled, options, service, settings
|
_, mock_collector_class, metadata_enabled, options, service, settings
|
||||||
): # pylint: disable=too-many-arguments,too-many-positional-arguments
|
): # 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"
|
"notify_external_services"
|
||||||
)
|
)
|
||||||
@mock.patch("core.utils.notify_participants")
|
@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(
|
@pytest.mark.parametrize(
|
||||||
"egress_status",
|
"egress_status",
|
||||||
[EgressStatus.EGRESS_COMPLETE, EgressStatus.EGRESS_LIMIT_REACHED],
|
[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")],
|
[(True, "notification_succeeded"), (False, "saved")],
|
||||||
)
|
)
|
||||||
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
|
def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
|
||||||
mock_update_room_metadata,
|
mock_update_metadata,
|
||||||
mock_notify,
|
mock_notify,
|
||||||
mock_notify_external_services,
|
mock_notify_external_services,
|
||||||
notify_return_value,
|
notify_return_value,
|
||||||
@@ -378,7 +384,7 @@ def test_handle_egress_ended_finalizes_recording( # noqa: PLR0913
|
|||||||
"notify_external_services"
|
"notify_external_services"
|
||||||
)
|
)
|
||||||
@mock.patch("core.utils.notify_participants")
|
@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(
|
@pytest.mark.parametrize(
|
||||||
"egress_status, expected_status",
|
"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
|
def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: PLR0913
|
||||||
mock_update_room_metadata,
|
mock_update_metadata,
|
||||||
mock_notify,
|
mock_notify,
|
||||||
mock_notify_external_services,
|
mock_notify_external_services,
|
||||||
egress_status,
|
egress_status,
|
||||||
@@ -424,9 +430,9 @@ def test_handle_egress_ended_does_not_finalize_when_webhooks_enabled( # noqa: P
|
|||||||
EgressStatus.EGRESS_ABORTED,
|
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(
|
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."""
|
"""Shouldn't save on invalid status."""
|
||||||
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
settings.RECORDING_STORAGE_EVENT_ENABLE = False
|
||||||
@@ -445,9 +451,9 @@ def test_handle_egress_ended_does_not_save_on_wrong_status(
|
|||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"status", ["failed_to_start", "aborted", "failed_to_stop", "saved", "initiated"]
|
"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(
|
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.
|
"""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(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(
|
def test_handle_room_finished_clears_cache_and_deletes_dispatch_rule(
|
||||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
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
|
settings.ROOM_TELEPHONY_ENABLED = True
|
||||||
mock_room_name = uuid.uuid4()
|
mock_room_name = uuid.uuid4()
|
||||||
mock_data = mock.MagicMock()
|
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(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(
|
def test_handle_room_finished_skips_telephony_when_disabled(
|
||||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
||||||
):
|
):
|
||||||
"""Should clear lobby cache but skip dispatch rule deletion when telephony is disabled."""
|
"""Should clear lobby cache but skip dispatch rule deletion when telephony is disabled."""
|
||||||
settings.ROOM_TELEPHONY_ENABLED = False
|
settings.ROOM_TELEPHONY_ENABLED = False
|
||||||
|
settings.ROOMKIT_ENABLED = False
|
||||||
mock_room_name = uuid.uuid4()
|
mock_room_name = uuid.uuid4()
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
mock_data.room.name = str(mock_room_name)
|
mock_data.room.name = str(mock_room_name)
|
||||||
@@ -504,7 +529,7 @@ def test_handle_room_finished_skips_telephony_when_disabled(
|
|||||||
@mock.patch.object(
|
@mock.patch.object(
|
||||||
LobbyService, "clear_room_cache", side_effect=Exception("Test error")
|
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(
|
def test_handle_room_finished_raises_error_when_cache_clearing_fails(
|
||||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
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(LobbyService, "clear_room_cache")
|
||||||
@mock.patch.object(
|
@mock.patch.object(
|
||||||
TelephonyService,
|
SIPManagement,
|
||||||
"delete_dispatch_rule",
|
"delete_dispatch_rule",
|
||||||
side_effect=TelephonyException("Test error"),
|
side_effect=SIPException("Test error"),
|
||||||
)
|
)
|
||||||
def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
|
def test_handle_room_finished_raises_error_when_telephony_deletion_fails(
|
||||||
mock_delete_dispatch_rule, mock_clear_cache, service, settings
|
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"
|
mock_data.room.name = "00000000-0000-0000-0000-000000000000"
|
||||||
|
|
||||||
expected_error = (
|
expected_error = (
|
||||||
"Failed to delete telephony dispatch rule for room "
|
"Failed to delete sip dispatch rule for room "
|
||||||
"00000000-0000-0000-0000-000000000000"
|
"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)
|
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(
|
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
|
settings.ROOM_TELEPHONY_ENABLED = True
|
||||||
room = RoomFactory()
|
room = RoomFactory()
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
@@ -573,22 +598,75 @@ def test_handle_room_started_creates_dispatch_rule_successfully(
|
|||||||
|
|
||||||
service._handle_room_started(mock_data)
|
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")
|
@mock.patch.object(SIPManagement, "ensure_dispatch_rule")
|
||||||
def test_handle_room_started_skips_dispatch_rule_when_telephony_disabled(
|
def test_handle_room_started_creates_dispatch_rule_when_only_roomkit_enabled(
|
||||||
mock_create_dispatch_rule, service, settings
|
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.ROOM_TELEPHONY_ENABLED = False
|
||||||
|
settings.ROOMKIT_ENABLED = True
|
||||||
room = RoomFactory()
|
room = RoomFactory()
|
||||||
mock_data = mock.MagicMock()
|
mock_data = mock.MagicMock()
|
||||||
mock_data.room.name = str(room.id)
|
mock_data.room.name = str(room.id)
|
||||||
|
|
||||||
service._handle_room_started(mock_data)
|
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):
|
def test_handle_room_started_raises_error_for_invalid_room_name(service):
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ import uuid
|
|||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.auth.models import AnonymousUser
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from core.factories import RoomFactory
|
from core.factories import RoomFactory, UserFactory, UserResourceAccessFactory
|
||||||
from core.models import RoomAccessLevel
|
from core.models import RoleChoices, RoomAccessLevel
|
||||||
from core.services.lobby import (
|
from core.services.lobby import (
|
||||||
LobbyParticipant,
|
LobbyParticipant,
|
||||||
LobbyParticipantNotFound,
|
LobbyParticipantNotFound,
|
||||||
@@ -188,17 +189,17 @@ def test_prepare_response_new_cookie(lobby_service, participant_id):
|
|||||||
|
|
||||||
|
|
||||||
def test_can_bypass_lobby_public_room(lobby_service):
|
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)
|
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||||
|
|
||||||
# Anonymous user
|
# Anonymous user
|
||||||
user = mock.Mock()
|
user = mock.Mock()
|
||||||
user.is_authenticated = False
|
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
|
# Authenticated user
|
||||||
user.is_authenticated = True
|
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):
|
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
|
# Authenticated user
|
||||||
user = mock.Mock()
|
user = mock.Mock()
|
||||||
user.is_authenticated = True
|
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):
|
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
|
# Anonymous user
|
||||||
user = mock.Mock()
|
user = mock.Mock()
|
||||||
user.is_authenticated = False
|
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):
|
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)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
|
||||||
# Anonymous user
|
# Anonymous user
|
||||||
user = mock.Mock()
|
user = mock.Mock()
|
||||||
user.is_authenticated = False
|
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
|
# Authenticated user
|
||||||
user.is_authenticated = True
|
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")
|
@mock.patch("core.utils.generate_livekit_config")
|
||||||
@@ -241,7 +255,7 @@ def test_request_entry_public_room(
|
|||||||
):
|
):
|
||||||
"""Test requesting entry to a public room."""
|
"""Test requesting entry to a public room."""
|
||||||
request = mock.Mock()
|
request = mock.Mock()
|
||||||
request.user = mock.Mock()
|
request.user = AnonymousUser()
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
room = RoomFactory(access_level=RoomAccessLevel.PUBLIC)
|
||||||
|
|
||||||
@@ -266,8 +280,8 @@ def test_request_entry_public_room(
|
|||||||
username=username,
|
username=username,
|
||||||
color=participant.color,
|
color=participant.color,
|
||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
is_admin_or_owner=False,
|
|
||||||
participant_id="test-participant-id",
|
participant_id="test-participant-id",
|
||||||
|
role=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
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."""
|
"""Test requesting entry to a trusted room when the user is authenticated."""
|
||||||
request = mock.Mock()
|
request = mock.Mock()
|
||||||
request.user = mock.Mock()
|
request.user = UserFactory()
|
||||||
request.user.is_authenticated = True
|
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
room = RoomFactory(access_level=RoomAccessLevel.TRUSTED)
|
||||||
|
|
||||||
@@ -305,8 +318,8 @@ def test_request_entry_trusted_room(
|
|||||||
username=username,
|
username=username,
|
||||||
color=participant.color,
|
color=participant.color,
|
||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
is_admin_or_owner=False,
|
|
||||||
participant_id="test-participant-id",
|
participant_id="test-participant-id",
|
||||||
|
role=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
lobby_service._get_participant.assert_called_once_with(room.id, participant_id)
|
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."""
|
"""Test requesting entry for a new participant."""
|
||||||
request = mock.Mock()
|
request = mock.Mock()
|
||||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||||
|
request.user = AnonymousUser()
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
|
||||||
@@ -348,6 +362,7 @@ def test_request_entry_waiting_participant(
|
|||||||
"""Test requesting entry for a waiting participant."""
|
"""Test requesting entry for a waiting participant."""
|
||||||
request = mock.Mock()
|
request = mock.Mock()
|
||||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||||
|
request.user = AnonymousUser()
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
|
|
||||||
@@ -374,7 +389,7 @@ def test_request_entry_accepted_participant(
|
|||||||
):
|
):
|
||||||
"""Test requesting entry for an accepted participant."""
|
"""Test requesting entry for an accepted participant."""
|
||||||
request = mock.Mock()
|
request = mock.Mock()
|
||||||
request.user = mock.Mock()
|
request.user = AnonymousUser()
|
||||||
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
request.COOKIES = {settings.LOBBY_COOKIE_NAME: participant_id}
|
||||||
|
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
|
||||||
@@ -400,8 +415,48 @@ def test_request_entry_accepted_participant(
|
|||||||
username=username,
|
username=username,
|
||||||
color="#123456",
|
color="#123456",
|
||||||
configuration=room.configuration,
|
configuration=room.configuration,
|
||||||
is_admin_or_owner=False,
|
|
||||||
participant_id="test-participant-id",
|
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)
|
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
|
# pylint: disable=W0212
|
||||||
@@ -20,7 +20,11 @@ from livekit.protocol.sip import (
|
|||||||
|
|
||||||
from core.factories import RoomFactory
|
from core.factories import RoomFactory
|
||||||
from core.models import RoomAccessLevel
|
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
|
pytestmark = pytest.mark.django_db
|
||||||
|
|
||||||
@@ -35,9 +39,9 @@ def create_mock_livekit_client():
|
|||||||
|
|
||||||
def test_rule_name():
|
def test_rule_name():
|
||||||
"""Test rule name generation."""
|
"""Test rule name generation."""
|
||||||
telephony_service = TelephonyService()
|
sip_management = SIPManagement()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
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)}"
|
assert rule_name == f"SIP_{str(room.id)}"
|
||||||
|
|
||||||
@@ -45,14 +49,14 @@ def test_rule_name():
|
|||||||
@mock.patch("core.utils.create_livekit_client")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_create_dispatch_rule_success(mock_client_factory):
|
def test_create_dispatch_rule_success(mock_client_factory):
|
||||||
"""Test successful dispatch rule creation."""
|
"""Test successful dispatch rule creation."""
|
||||||
telephony_service = TelephonyService()
|
sip_management = SIPManagement()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_api = create_mock_livekit_client()
|
mock_api = create_mock_livekit_client()
|
||||||
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock()
|
mock_api.sip.create_sip_dispatch_rule = mock.AsyncMock()
|
||||||
mock_client_factory.return_value = mock_api
|
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()
|
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
|
||||||
create_request = mock_api.sip.create_sip_dispatch_rule.call_args[1]["create"]
|
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")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_create_dispatch_rule_api_failure(mock_client_factory):
|
def test_create_dispatch_rule_api_failure(mock_client_factory):
|
||||||
"""Test dispatch rule creation when API fails."""
|
"""Test dispatch rule creation when API fails."""
|
||||||
telephony_service = TelephonyService()
|
sip_management = SIPManagement()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_api = create_mock_livekit_client()
|
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
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
with pytest.raises(TelephonyException, match="Could not create dispatch rule"):
|
with pytest.raises(SIPException, match="Could not create dispatch rule"):
|
||||||
telephony_service.create_dispatch_rule(room)
|
sip_management.create_dispatch_rule(room)
|
||||||
|
|
||||||
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
|
mock_api.sip.create_sip_dispatch_rule.assert_called_once()
|
||||||
mock_api.aclose.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")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_list_dispatch_rules_ids_success(mock_client_factory):
|
def test_list_dispatch_rules_ids_success(mock_client_factory):
|
||||||
"""Test successful listing of dispatch rule IDs."""
|
"""Test successful listing of dispatch rule IDs."""
|
||||||
telephony_service = TelephonyService()
|
sip_management = SIPManagement()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_rules = [
|
mock_rules = [
|
||||||
@@ -111,7 +115,7 @@ def test_list_dispatch_rules_ids_success(mock_client_factory):
|
|||||||
)
|
)
|
||||||
mock_client_factory.return_value = mock_api
|
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 len(result) == 2
|
||||||
assert "rule-1" in result
|
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")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
|
def test_list_dispatch_rules_ids_empty_response(mock_client_factory):
|
||||||
"""Test listing dispatch rule IDs when no rules exist."""
|
"""Test listing dispatch rule IDs when no rules exist."""
|
||||||
telephony_service = TelephonyService()
|
sip_management = SIPManagement()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_api = create_mock_livekit_client()
|
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
|
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 == []
|
assert result == []
|
||||||
mock_api.aclose.assert_called_once()
|
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")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
|
def test_list_dispatch_rules_ids_no_matching_rules(mock_client_factory):
|
||||||
"""Test listing dispatch rule IDs when no rules match the room."""
|
"""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")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_rules = [
|
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
|
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 == []
|
assert result == []
|
||||||
mock_api.aclose.assert_called_once()
|
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")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_list_dispatch_rules_ids_api_failure(mock_client_factory):
|
def test_list_dispatch_rules_ids_api_failure(mock_client_factory):
|
||||||
"""Test listing dispatch rule IDs when API fails."""
|
"""Test listing dispatch rule IDs when API fails."""
|
||||||
telephony_service = TelephonyService()
|
sip_management = SIPManagement()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_api = create_mock_livekit_client()
|
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
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
with pytest.raises(TelephonyException, match="Could not list dispatch rules"):
|
with pytest.raises(SIPException, match="Could not list dispatch rules"):
|
||||||
async_to_sync(telephony_service._list_dispatch_rules_ids)(room.id)
|
async_to_sync(sip_management._list_dispatch_rules_ids)(room.id)
|
||||||
|
|
||||||
mock_api.sip.list_sip_dispatch_rule.assert_called_once()
|
mock_api.sip.list_sip_dispatch_rule.assert_called_once()
|
||||||
mock_api.aclose.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")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_delete_dispatch_rule_no_rules(mock_client_factory, mock_list_rules):
|
def test_delete_dispatch_rule_no_rules(mock_client_factory, mock_list_rules):
|
||||||
"""Test deleting dispatch rules when no rules exist."""
|
"""Test deleting dispatch rules when no rules exist."""
|
||||||
telephony_service = TelephonyService()
|
sip_management = SIPManagement()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_list_rules.return_value = []
|
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
|
assert result is False
|
||||||
mock_list_rules.assert_called_once_with(room.id)
|
mock_list_rules.assert_called_once_with(room.id)
|
||||||
mock_client_factory.assert_not_called()
|
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")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
|
def test_delete_dispatch_rule_single_rule(mock_client_factory, mock_list_rules):
|
||||||
"""Test deleting a single dispatch rule."""
|
"""Test deleting a single dispatch rule."""
|
||||||
telephony_service = TelephonyService()
|
sip_management = SIPManagement()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_list_rules.return_value = ["rule-1"]
|
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_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
|
||||||
mock_client_factory.return_value = mock_api
|
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 result is True
|
||||||
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
|
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_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")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rules):
|
def test_delete_dispatch_rule_multiple_rules(mock_client_factory, mock_list_rules):
|
||||||
"""Test deleting multiple dispatch rules."""
|
"""Test deleting multiple dispatch rules."""
|
||||||
telephony_service = TelephonyService()
|
sip_management = SIPManagement()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
|
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_api.sip.delete_sip_dispatch_rule = mock.AsyncMock()
|
||||||
mock_client_factory.return_value = mock_api
|
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 result is True
|
||||||
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 3
|
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_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")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rules):
|
def test_delete_dispatch_rule_partial_failure(mock_client_factory, mock_list_rules):
|
||||||
"""Test deleting multiple dispatch rules when one deletion fails."""
|
"""Test deleting multiple dispatch rules when one deletion fails."""
|
||||||
telephony_service = TelephonyService()
|
sip_management = SIPManagement()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_list_rules.return_value = ["rule-1", "rule-2", "rule-3"]
|
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
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"):
|
with pytest.raises(SIPException, match="Could not delete dispatch rules"):
|
||||||
telephony_service.delete_dispatch_rule(room.id)
|
sip_management.delete_dispatch_rule(room.id)
|
||||||
|
|
||||||
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 2
|
assert mock_api.sip.delete_sip_dispatch_rule.call_count == 2
|
||||||
mock_api.aclose.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")
|
@mock.patch("core.utils.create_livekit_client")
|
||||||
def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules):
|
def test_delete_dispatch_rule_api_failure(mock_client_factory, mock_list_rules):
|
||||||
"""Test deleting dispatch rules when API fails immediately."""
|
"""Test deleting dispatch rules when API fails immediately."""
|
||||||
telephony_service = TelephonyService()
|
sip_management = SIPManagement()
|
||||||
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED, pin_code="1234")
|
||||||
|
|
||||||
mock_list_rules.return_value = ["rule-1"]
|
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
|
mock_client_factory.return_value = mock_api
|
||||||
|
|
||||||
with pytest.raises(TelephonyException, match="Could not delete dispatch rules"):
|
with pytest.raises(SIPException, match="Could not delete dispatch rules"):
|
||||||
telephony_service.delete_dispatch_rule(room.id)
|
sip_management.delete_dispatch_rule(room.id)
|
||||||
|
|
||||||
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
|
mock_api.sip.delete_sip_dispatch_rule.assert_called_once()
|
||||||
mock_api.aclose.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.status_code == 200
|
||||||
assert response.json() == {
|
assert response.json() == {
|
||||||
|
"default_room_access_level": None,
|
||||||
|
"default_room_configuration": {},
|
||||||
"id": str(user.id),
|
"id": str(user.id),
|
||||||
"email": user.email,
|
"email": user.email,
|
||||||
"full_name": user.full_name,
|
"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")
|
@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
|
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.ROOM_TELEPHONY_ENABLED = False
|
||||||
|
settings.ROOMKIT_ENABLED = False
|
||||||
|
|
||||||
room = RoomFactory()
|
room = RoomFactory()
|
||||||
|
|
||||||
@@ -197,6 +198,18 @@ def test_telephony_disabled_skips_pin_generation(
|
|||||||
assert room.pin_code is None
|
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):
|
def test_default_and_custom_pin_length(settings):
|
||||||
"""Pin codes should be created with correct configured length."""
|
"""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.addons import viewsets as addons_viewsets
|
||||||
from core.api import get_frontend_configuration, viewsets
|
from core.api import get_frontend_configuration, viewsets
|
||||||
from core.external_api import viewsets as external_viewsets
|
from core.external_api import viewsets as external_viewsets
|
||||||
|
from core.roomkit import viewsets as roomkit_viewsets
|
||||||
|
|
||||||
# - Main endpoints
|
# - Main endpoints
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
@@ -19,6 +20,11 @@ router.register("files", viewsets.FileViewSet, basename="files")
|
|||||||
router.register(
|
router.register(
|
||||||
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
|
"resource-accesses", viewsets.ResourceAccessViewSet, basename="resource_accesses"
|
||||||
)
|
)
|
||||||
|
router.register(
|
||||||
|
"roomkit",
|
||||||
|
roomkit_viewsets.RoomKitViewSet,
|
||||||
|
basename="roomkit",
|
||||||
|
)
|
||||||
router.register(
|
router.register(
|
||||||
"addons/sessions",
|
"addons/sessions",
|
||||||
addons_viewsets.SessionViewSet,
|
addons_viewsets.SessionViewSet,
|
||||||
|
|||||||
+11
-58
@@ -31,7 +31,6 @@ from livekit.api import ( # pylint: disable=E0611
|
|||||||
LiveKitAPI,
|
LiveKitAPI,
|
||||||
SendDataRequest,
|
SendDataRequest,
|
||||||
TwirpError,
|
TwirpError,
|
||||||
UpdateRoomMetadataRequest,
|
|
||||||
VideoGrants,
|
VideoGrants,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -66,7 +65,7 @@ def generate_token(
|
|||||||
username: Optional[str] = None,
|
username: Optional[str] = None,
|
||||||
color: Optional[str] = None,
|
color: Optional[str] = None,
|
||||||
sources: Optional[List[str]] = None,
|
sources: Optional[List[str]] = None,
|
||||||
is_admin_or_owner: bool = False,
|
role: Optional[str] = None,
|
||||||
participant_id: Optional[str] = None,
|
participant_id: Optional[str] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Generate a LiveKit access token for a user in a specific room.
|
"""Generate a LiveKit access token for a user in a specific room.
|
||||||
@@ -80,7 +79,7 @@ def generate_token(
|
|||||||
If none, a value will be generated
|
If none, a value will be generated
|
||||||
sources: (Optional[List[str]]): List of media sources the user can publish
|
sources: (Optional[List[str]]): List of media sources the user can publish
|
||||||
If none, defaults to LIVEKIT_DEFAULT_SOURCES.
|
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;
|
participant_id (Optional[str]): Stable identifier for anonymous users;
|
||||||
used as identity when user.is_anonymous.
|
used as identity when user.is_anonymous.
|
||||||
|
|
||||||
@@ -88,6 +87,7 @@ def generate_token(
|
|||||||
str: The LiveKit JWT access token.
|
str: The LiveKit JWT access token.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
is_admin_or_owner = role in ("owner", "administrator")
|
||||||
if is_admin_or_owner:
|
if is_admin_or_owner:
|
||||||
sources = settings.LIVEKIT_DEFAULT_SOURCES
|
sources = settings.LIVEKIT_DEFAULT_SOURCES
|
||||||
|
|
||||||
@@ -128,7 +128,11 @@ def generate_token(
|
|||||||
.with_identity(identity)
|
.with_identity(identity)
|
||||||
.with_name(display_name)
|
.with_name(display_name)
|
||||||
.with_attributes(
|
.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 +143,7 @@ def generate_livekit_config(
|
|||||||
room_id: str,
|
room_id: str,
|
||||||
user,
|
user,
|
||||||
username: str,
|
username: str,
|
||||||
is_admin_or_owner: bool,
|
role: Optional[str] = None,
|
||||||
color: Optional[str] = None,
|
color: Optional[str] = None,
|
||||||
configuration: Optional[dict] = None,
|
configuration: Optional[dict] = None,
|
||||||
participant_id: Optional[str] = None,
|
participant_id: Optional[str] = None,
|
||||||
@@ -150,7 +154,7 @@ def generate_livekit_config(
|
|||||||
room_id: Room identifier
|
room_id: Room identifier
|
||||||
user: User instance requesting access
|
user: User instance requesting access
|
||||||
username: Display name in room
|
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.
|
color (Optional[str]): Optional color to associate with the participant.
|
||||||
configuration (Optional[dict]): Room configuration dict that can override default settings.
|
configuration (Optional[dict]): Room configuration dict that can override default settings.
|
||||||
participant_id (Optional[str]): Stable identifier for anonymous users;
|
participant_id (Optional[str]): Stable identifier for anonymous users;
|
||||||
@@ -173,7 +177,7 @@ def generate_livekit_config(
|
|||||||
username=username,
|
username=username,
|
||||||
color=color,
|
color=color,
|
||||||
sources=sources,
|
sources=sources,
|
||||||
is_admin_or_owner=is_admin_or_owner,
|
role=role,
|
||||||
participant_id=participant_id,
|
participant_id=participant_id,
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -258,57 +262,6 @@ async def notify_participants(room_name: str, notification_data: dict):
|
|||||||
await lkapi.aclose()
|
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
|
ALPHANUMERIC_CHARSET = string.ascii_letters + string.digits
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -349,6 +349,11 @@ class Base(Configuration):
|
|||||||
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
|
environ_name="CREATION_CALLBACK_THROTTLE_RATES",
|
||||||
environ_prefix=None,
|
environ_prefix=None,
|
||||||
),
|
),
|
||||||
|
"roomkit_join": values.Value(
|
||||||
|
default="300/minute",
|
||||||
|
environ_name="ROOMKIT_JOIN_THROTTLE_RATES",
|
||||||
|
environ_prefix=None,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
MONITORED_THROTTLE_FAILURE_CALLBACK = (
|
MONITORED_THROTTLE_FAILURE_CALLBACK = (
|
||||||
@@ -881,6 +886,21 @@ class Base(Configuration):
|
|||||||
environ_prefix=None,
|
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
|
# Subtitles settings
|
||||||
ROOM_SUBTITLE_ENABLED = values.BooleanValue(
|
ROOM_SUBTITLE_ENABLED = values.BooleanValue(
|
||||||
False, environ_name="ROOM_SUBTITLE_ENABLED", environ_prefix=None
|
False, environ_name="ROOM_SUBTITLE_ENABLED", environ_prefix=None
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ dependencies = [
|
|||||||
"django-storages[s3]==1.14.6",
|
"django-storages[s3]==1.14.6",
|
||||||
"django-timezone-field>=5.1",
|
"django-timezone-field>=5.1",
|
||||||
"django-pydantic-field==0.5.4",
|
"django-pydantic-field==0.5.4",
|
||||||
"django==5.2.14",
|
"django==5.2.16",
|
||||||
"djangorestframework==3.17.1",
|
"djangorestframework==3.17.1",
|
||||||
"drf_spectacular==0.29.0",
|
"drf_spectacular==0.29.0",
|
||||||
"dockerflow==2026.3.4",
|
"dockerflow==2026.3.4",
|
||||||
|
|||||||
Generated
+4
-4
@@ -586,16 +586,16 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "django"
|
name = "django"
|
||||||
version = "5.2.14"
|
version = "5.2.16"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "asgiref" },
|
{ name = "asgiref" },
|
||||||
{ name = "sqlparse" },
|
{ name = "sqlparse" },
|
||||||
{ name = "tzdata", marker = "sys_platform == 'win32'" },
|
{ 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 = [
|
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]]
|
[[package]]
|
||||||
@@ -1260,7 +1260,7 @@ requires-dist = [
|
|||||||
{ name = "brotli", specifier = "==1.2.0" },
|
{ name = "brotli", specifier = "==1.2.0" },
|
||||||
{ name = "celery", extras = ["redis"], specifier = "==5.6.3" },
|
{ name = "celery", extras = ["redis"], specifier = "==5.6.3" },
|
||||||
{ name = "dj-database-url", specifier = "==3.1.2" },
|
{ 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-configurations", specifier = "==2.5.1" },
|
||||||
{ name = "django-cors-headers", specifier = "==4.9.0" },
|
{ name = "django-cors-headers", specifier = "==4.9.0" },
|
||||||
{ name = "django-countries", specifier = "==9.0.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)',
|
clipPath: 'polygon(50% 50%, 0 0, 100% 0, 100% 100%, 0 100%, 0 0)',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
overlayIn: {
|
||||||
|
from: { opacity: 0 },
|
||||||
|
to: { opacity: 0.6 },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
tokens: defineTokens({
|
tokens: defineTokens({
|
||||||
/* we take a few things from the panda preset but for now we clear out some stuff.
|
/* 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 { keys } from './queryKeys'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { RecordingMode } from '@/features/recording'
|
import { RecordingMode } from '@/features/recording'
|
||||||
|
import type { ApiAccessLevel } from '@/features/rooms/api/ApiRoom'
|
||||||
import type { Track } from 'livekit-client'
|
import type { Track } from 'livekit-client'
|
||||||
type Source = Track.Source
|
type Source = Track.Source
|
||||||
|
|
||||||
@@ -49,6 +50,9 @@ export interface ApiConfig {
|
|||||||
international_phone_number?: string
|
international_phone_number?: string
|
||||||
default_country?: string
|
default_country?: string
|
||||||
}
|
}
|
||||||
|
resource?: {
|
||||||
|
default_access_level?: ApiAccessLevel
|
||||||
|
}
|
||||||
manifest_link?: string
|
manifest_link?: string
|
||||||
livekit: {
|
livekit: {
|
||||||
url: string
|
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> & {
|
export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||||
name?: string
|
name?: string
|
||||||
bgColor?: string
|
bgColor?: string
|
||||||
@@ -35,7 +44,7 @@ export type AvatarProps = React.HTMLAttributes<HTMLDivElement> & {
|
|||||||
|
|
||||||
export const Avatar = React.memo(
|
export const Avatar = React.memo(
|
||||||
({ name, bgColor, context, notification, style, ...props }: AvatarProps) => {
|
({ name, bgColor, context, notification, style, ...props }: AvatarProps) => {
|
||||||
const initial = name?.trim()?.charAt(0) ?? ''
|
const initials = getInitials(name)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
style={{ backgroundColor: bgColor, ...style }}
|
style={{ backgroundColor: bgColor, ...style }}
|
||||||
@@ -50,13 +59,14 @@ export const Avatar = React.memo(
|
|||||||
<text
|
<text
|
||||||
x="50"
|
x="50"
|
||||||
y="50"
|
y="50"
|
||||||
|
dy="-0.08em"
|
||||||
textAnchor="middle"
|
textAnchor="middle"
|
||||||
dominantBaseline="central"
|
dominantBaseline="central"
|
||||||
fontSize="52"
|
fontSize="52"
|
||||||
fontWeight="500"
|
fontWeight="500"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
>
|
>
|
||||||
{initial}
|
{initials.toUpperCase()}
|
||||||
</text>
|
</text>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { BackendLanguage } from '@/utils/languages'
|
import { BackendLanguage } from '@/utils/languages'
|
||||||
|
import type {
|
||||||
|
ApiAccessLevel,
|
||||||
|
RoomConfiguration,
|
||||||
|
} from '@/features/rooms/api/ApiRoom'
|
||||||
|
|
||||||
export type ApiUser = {
|
export type ApiUser = {
|
||||||
id: string
|
id: string
|
||||||
@@ -7,4 +11,6 @@ export type ApiUser = {
|
|||||||
last_name: string
|
last_name: string
|
||||||
language: BackendLanguage
|
language: BackendLanguage
|
||||||
timezone: string
|
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 { useSidePanel } from '@/features/rooms/livekit/hooks/useSidePanel'
|
||||||
import React, { useEffect } from 'react'
|
import React, { useEffect } from 'react'
|
||||||
import { useChat, useRoomContext } from '@livekit/components-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 type { ChatMessage } from '@livekit/components-core'
|
||||||
import {
|
import {
|
||||||
LocalParticipant,
|
LocalParticipant,
|
||||||
@@ -19,6 +19,10 @@ export const ChatProvider = () => {
|
|||||||
|
|
||||||
const room = useRoomContext()
|
const room = useRoomContext()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
resetChatStore()
|
||||||
|
}, [])
|
||||||
|
|
||||||
// Tigger the message notification (temporary)
|
// Tigger the message notification (temporary)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// TEMPORARY: This is a brittle workaround that relies on message count tracking
|
// TEMPORARY: This is a brittle workaround that relies on message count tracking
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { getScrollBarWidth } from '@livekit/components-core'
|
|||||||
import * as React from 'react'
|
import * as React from 'react'
|
||||||
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
|
import { TrackLoop, useVisualStableUpdate } from '@livekit/components-react'
|
||||||
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
import { useSize } from '@/features/rooms/livekit/hooks/useResizeObserver'
|
||||||
import { useCallback, useEffect, useLayoutEffect } from 'react'
|
|
||||||
|
|
||||||
const MIN_HEIGHT = 130
|
const MIN_HEIGHT = 130
|
||||||
const MIN_WIDTH = 140
|
const MIN_WIDTH = 140
|
||||||
@@ -11,72 +10,6 @@ const MIN_VISIBLE_TILES = 1
|
|||||||
const ASPECT_RATIO = 16 / 10
|
const ASPECT_RATIO = 16 / 10
|
||||||
const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1
|
const ASPECT_RATIO_INVERT = (1 - ASPECT_RATIO) * -1
|
||||||
|
|
||||||
type CarouselOrientation = 'vertical' | 'horizontal'
|
|
||||||
|
|
||||||
interface CarouselLayoutState {
|
|
||||||
orientation: CarouselOrientation
|
|
||||||
maxVisibleTiles: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CarouselLayoutObserverProps {
|
|
||||||
asideEl: React.RefObject<HTMLDivElement>
|
|
||||||
orientation?: CarouselOrientation
|
|
||||||
onLayoutChange: (layout: CarouselLayoutState) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
const CarouselLayoutObserver = ({
|
|
||||||
orientation,
|
|
||||||
asideEl,
|
|
||||||
onLayoutChange,
|
|
||||||
}: CarouselLayoutObserverProps) => {
|
|
||||||
const { width, height } = useSize(asideEl)
|
|
||||||
|
|
||||||
// Hysteresis memory: avoids flapping between N and N+1 tiles when the
|
|
||||||
// container size hovers around a breakpoint. A ref (not state) because
|
|
||||||
// updating it must not trigger a re-render.
|
|
||||||
const prevTilesRef = React.useRef(0)
|
|
||||||
|
|
||||||
const carouselOrientation: CarouselOrientation =
|
|
||||||
orientation ?? (height >= width ? 'vertical' : 'horizontal')
|
|
||||||
|
|
||||||
const tileSpan =
|
|
||||||
carouselOrientation === 'vertical'
|
|
||||||
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
|
|
||||||
: Math.max(height * ASPECT_RATIO, MIN_WIDTH)
|
|
||||||
|
|
||||||
const scrollBarWidth = getScrollBarWidth()
|
|
||||||
|
|
||||||
const availableSpan =
|
|
||||||
(carouselOrientation === 'vertical' ? height : width) - scrollBarWidth
|
|
||||||
|
|
||||||
const tilesThatFit = Math.max(availableSpan / tileSpan, MIN_VISIBLE_TILES)
|
|
||||||
|
|
||||||
let maxVisibleTiles: number
|
|
||||||
if (Math.abs(tilesThatFit - prevTilesRef.current) < 0.5) {
|
|
||||||
// Within the dead zone: keep the previous count.
|
|
||||||
maxVisibleTiles = Math.round(prevTilesRef.current)
|
|
||||||
} else {
|
|
||||||
maxVisibleTiles = Math.round(tilesThatFit)
|
|
||||||
prevTilesRef.current = tilesThatFit
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply cosmetic layout output straight to the DOM.
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const el = asideEl.current
|
|
||||||
if (!el) return
|
|
||||||
el.dataset.lkOrientation = carouselOrientation
|
|
||||||
el.style.setProperty('--lk-max-visible-tiles', maxVisibleTiles.toString())
|
|
||||||
}, [asideEl, carouselOrientation, maxVisibleTiles])
|
|
||||||
|
|
||||||
// Report upward only what the parent actually needs for
|
|
||||||
// `useVisualStableUpdate` (and only when it changes — see parent handler).
|
|
||||||
useEffect(() => {
|
|
||||||
onLayoutChange({ orientation: carouselOrientation, maxVisibleTiles })
|
|
||||||
}, [carouselOrientation, maxVisibleTiles, onLayoutChange])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export interface CarouselLayoutProps extends React.HTMLAttributes<HTMLMediaElement> {
|
export interface CarouselLayoutProps extends React.HTMLAttributes<HTMLMediaElement> {
|
||||||
tracks: TrackReferenceOrPlaceholder[]
|
tracks: TrackReferenceOrPlaceholder[]
|
||||||
@@ -107,42 +40,52 @@ export function CarouselLayout({
|
|||||||
...props
|
...props
|
||||||
}: CarouselLayoutProps) {
|
}: CarouselLayoutProps) {
|
||||||
const asideEl = React.useRef<HTMLDivElement>(null)
|
const asideEl = React.useRef<HTMLDivElement>(null)
|
||||||
|
const [prevTiles, setPrevTiles] = React.useState(0)
|
||||||
|
const { width, height } = useSize(asideEl)
|
||||||
|
const carouselOrientation = orientation
|
||||||
|
? orientation
|
||||||
|
: height >= width
|
||||||
|
? 'vertical'
|
||||||
|
: 'horizontal'
|
||||||
|
|
||||||
const [layout, setLayout] = React.useState<CarouselLayoutState>({
|
const tileSpan =
|
||||||
orientation: orientation ?? 'vertical',
|
carouselOrientation === 'vertical'
|
||||||
maxVisibleTiles: MIN_VISIBLE_TILES,
|
? Math.max(width * ASPECT_RATIO_INVERT, MIN_HEIGHT)
|
||||||
})
|
: Math.max(height * ASPECT_RATIO, MIN_WIDTH)
|
||||||
|
const scrollBarWidth = getScrollBarWidth()
|
||||||
|
|
||||||
// Stable callback + identity check: the parent only re-renders when the
|
const tilesThatFit =
|
||||||
// derived layout genuinely changed, not on every resize tick.
|
carouselOrientation === 'vertical'
|
||||||
const handleLayoutChange = useCallback((next: CarouselLayoutState) => {
|
? Math.max((height - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
|
||||||
setLayout((prev) =>
|
: Math.max((width - scrollBarWidth) / tileSpan, MIN_VISIBLE_TILES)
|
||||||
prev.orientation === next.orientation &&
|
|
||||||
prev.maxVisibleTiles === next.maxVisibleTiles
|
let maxVisibleTiles = Math.round(tilesThatFit)
|
||||||
? prev
|
if (Math.abs(tilesThatFit - prevTiles) < 0.5) {
|
||||||
: next
|
maxVisibleTiles = Math.round(prevTiles)
|
||||||
|
} else if (prevTiles !== tilesThatFit) {
|
||||||
|
setPrevTiles(tilesThatFit)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedTiles = useVisualStableUpdate(tracks, maxVisibleTiles)
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (asideEl.current) {
|
||||||
|
asideEl.current.dataset.lkOrientation = carouselOrientation
|
||||||
|
asideEl.current.style.setProperty(
|
||||||
|
'--lk-max-visible-tiles',
|
||||||
|
maxVisibleTiles.toString()
|
||||||
)
|
)
|
||||||
}, [])
|
}
|
||||||
|
}, [maxVisibleTiles, carouselOrientation])
|
||||||
const sortedTiles = useVisualStableUpdate(tracks, layout.maxVisibleTiles)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<CarouselLayoutObserver
|
|
||||||
asideEl={asideEl}
|
|
||||||
orientation={orientation}
|
|
||||||
onLayoutChange={handleLayoutChange}
|
|
||||||
/>
|
|
||||||
{/* `key` intentionally remounts the container when orientation flips, */}
|
|
||||||
{/* which resets scroll position and re-runs the observer measurement. */}
|
|
||||||
<aside
|
<aside
|
||||||
key={layout.orientation}
|
key={carouselOrientation}
|
||||||
className="lk-carousel"
|
className="lk-carousel"
|
||||||
ref={asideEl}
|
ref={asideEl}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
|
<TrackLoop tracks={sortedTiles}>{props.children}</TrackLoop>
|
||||||
</aside>
|
</aside>
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,35 +10,8 @@ import { mergeProps } from '@/utils/mergeProps'
|
|||||||
import { PaginationIndicator } from './PaginationIndicator'
|
import { PaginationIndicator } from './PaginationIndicator'
|
||||||
import { useGridLayout } from '../hooks/useGridLayout'
|
import { useGridLayout } from '../hooks/useGridLayout'
|
||||||
import { PaginationControl } from './PaginationControl'
|
import { PaginationControl } from './PaginationControl'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useSpeakerPromotionTrigger } from '../hooks/useSpeakerPromotionTrigger'
|
||||||
|
|
||||||
interface GridLayoutObserverProps {
|
|
||||||
gridEl: React.RefObject<HTMLDivElement>
|
|
||||||
trackCount: number
|
|
||||||
onMaxTilesChange: (maxTiles: number) => void
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Headless component that runs the layout calculation in isolation and
|
|
||||||
* reports the resulting tile capacity upward.
|
|
||||||
*
|
|
||||||
* `useGridLayout` re-renders its host on every layout recalculation
|
|
||||||
* (e.g. container resizes). Rendering it in a null child means only this
|
|
||||||
* component churns; the parent `GridLayout` re-renders solely when
|
|
||||||
* `maxTiles` actually changes, since `setState` bails out on equal values.
|
|
||||||
*/
|
|
||||||
const GridLayoutObserver = ({
|
|
||||||
gridEl,
|
|
||||||
trackCount,
|
|
||||||
onMaxTilesChange,
|
|
||||||
}: GridLayoutObserverProps) => {
|
|
||||||
const { layout } = useGridLayout(gridEl, trackCount)
|
|
||||||
useEffect(() => {
|
|
||||||
onMaxTilesChange(layout.maxTiles)
|
|
||||||
}, [onMaxTilesChange, layout.maxTiles])
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @public */
|
/** @public */
|
||||||
export interface GridLayoutProps
|
export interface GridLayoutProps
|
||||||
@@ -66,14 +39,16 @@ export interface GridLayoutProps
|
|||||||
* @public
|
* @public
|
||||||
*/
|
*/
|
||||||
export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
||||||
const gridEl = useRef<HTMLDivElement>(null)
|
const gridEl = React.createRef<HTMLDivElement>()
|
||||||
const [maxTiles, setMaxTiles] = useState(1)
|
|
||||||
|
|
||||||
const elementProps = React.useMemo(
|
const elementProps = React.useMemo(
|
||||||
() => mergeProps(props, { className: 'lk-grid-layout' }),
|
() => mergeProps(props, { className: 'lk-grid-layout' }),
|
||||||
[props]
|
[props]
|
||||||
)
|
)
|
||||||
const pagination = usePagination(maxTiles, tracks)
|
|
||||||
|
const { layout } = useGridLayout(gridEl, tracks.length)
|
||||||
|
const pagination = usePagination(layout.maxTiles, tracks)
|
||||||
|
useSpeakerPromotionTrigger(pagination.tracks)
|
||||||
|
|
||||||
useSwipe(gridEl, {
|
useSwipe(gridEl, {
|
||||||
onLeftSwipe: pagination.nextPage,
|
onLeftSwipe: pagination.nextPage,
|
||||||
@@ -86,13 +61,8 @@ export function GridLayout({ tracks, ...props }: GridLayoutProps) {
|
|||||||
data-lk-pagination={pagination.totalPageCount > 1}
|
data-lk-pagination={pagination.totalPageCount > 1}
|
||||||
{...elementProps}
|
{...elementProps}
|
||||||
>
|
>
|
||||||
<GridLayoutObserver
|
|
||||||
gridEl={gridEl}
|
|
||||||
trackCount={tracks.length}
|
|
||||||
onMaxTilesChange={setMaxTiles}
|
|
||||||
/>
|
|
||||||
<TrackLoop tracks={pagination.tracks}>{props.children}</TrackLoop>
|
<TrackLoop tracks={pagination.tracks}>{props.children}</TrackLoop>
|
||||||
{tracks.length > maxTiles && (
|
{tracks.length > layout.maxTiles && (
|
||||||
<>
|
<>
|
||||||
<PaginationIndicator
|
<PaginationIndicator
|
||||||
totalPageCount={pagination.totalPageCount}
|
totalPageCount={pagination.totalPageCount}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
log,
|
log,
|
||||||
type TrackReferenceOrPlaceholder,
|
type TrackReferenceOrPlaceholder,
|
||||||
} from '@livekit/components-core'
|
} from '@livekit/components-core'
|
||||||
import { RoomEvent, Track } from 'livekit-client'
|
import { Track } from 'livekit-client'
|
||||||
import { useSnapshot } from 'valtio'
|
import { useSnapshot } from 'valtio'
|
||||||
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
|
import { clearPinnedTrack, layoutStore, setPinnedTrack } from '@/stores/layout'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
@@ -23,7 +23,7 @@ export const StageLayout = () => {
|
|||||||
{ source: Track.Source.Camera, withPlaceholder: true },
|
{ source: Track.Source.Camera, withPlaceholder: true },
|
||||||
{ source: Track.Source.ScreenShare, withPlaceholder: false },
|
{ source: Track.Source.ScreenShare, withPlaceholder: false },
|
||||||
],
|
],
|
||||||
{ updateOnlyOn: [RoomEvent.ActiveSpeakersChanged], onlySubscribed: false }
|
{ updateOnlyOn: [], onlySubscribed: false }
|
||||||
)
|
)
|
||||||
|
|
||||||
const screenShareTracks = tracks
|
const screenShareTracks = tracks
|
||||||
@@ -104,7 +104,7 @@ export const StageLayout = () => {
|
|||||||
>
|
>
|
||||||
<ParticipantTile />
|
<ParticipantTile />
|
||||||
</CarouselLayout>
|
</CarouselLayout>
|
||||||
{pinnedTrackRef && <FocusLayout trackRef={pinnedTrackRef} />}
|
<FocusLayout trackRef={pinnedTrackRef} />
|
||||||
</FocusLayoutContainer>
|
</FocusLayoutContainer>
|
||||||
</div>
|
</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])
|
}, [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(() => {
|
useEffect(() => {
|
||||||
const removeParticipantNotifications = (participant: Participant) => {
|
const removeParticipantNotifications = (participant: Participant) => {
|
||||||
toastQueue.visibleToasts.forEach((toast) => {
|
toastQueue.visibleToasts.forEach((toast) => {
|
||||||
|
|||||||
@@ -14,4 +14,5 @@ export const NotificationDuration = {
|
|||||||
RECORDING_SAVING: ToastDuration.EXTRA_LONG,
|
RECORDING_SAVING: ToastDuration.EXTRA_LONG,
|
||||||
REACTION_RECEIVED: ToastDuration.SHORT,
|
REACTION_RECEIVED: ToastDuration.SHORT,
|
||||||
RECORDING_REQUESTED: ToastDuration.LONG,
|
RECORDING_REQUESTED: ToastDuration.LONG,
|
||||||
|
ROLE_CHANGED: ToastDuration.LONG,
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -17,4 +17,5 @@ export enum NotificationType {
|
|||||||
ScreenRecordingLimitReached = 'screenRecordingLimitReached',
|
ScreenRecordingLimitReached = 'screenRecordingLimitReached',
|
||||||
RecordingSaving = 'recordingSaving',
|
RecordingSaving = 'recordingSaving',
|
||||||
PermissionsRemoved = 'permissionsRemoved',
|
PermissionsRemoved = 'permissionsRemoved',
|
||||||
|
RoleChanged = 'roleChanged',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { ToastRecordingSaving } from './ToastRecordingSaving'
|
|||||||
import { ToastPermissionsRemoved } from './ToastPermissionsRemoved'
|
import { ToastPermissionsRemoved } from './ToastPermissionsRemoved'
|
||||||
import { ToastRecordingRequest } from './ToastRecordingRequest'
|
import { ToastRecordingRequest } from './ToastRecordingRequest'
|
||||||
import { ToastAutoMuteLargeRoom } from './ToastAutoMuteLargeRoom'
|
import { ToastAutoMuteLargeRoom } from './ToastAutoMuteLargeRoom'
|
||||||
|
import { ToastRoleChanged } from '@/features/notifications/components/ToastRoleChanged'
|
||||||
|
|
||||||
interface ToastRegionProps extends AriaToastRegionProps {
|
interface ToastRegionProps extends AriaToastRegionProps {
|
||||||
state: ToastState<ToastData>
|
state: ToastState<ToastData>
|
||||||
@@ -70,6 +71,9 @@ const renderToast = (
|
|||||||
<ToastRecordingSaving key={toast.key} toast={toast} state={state} />
|
<ToastRecordingSaving key={toast.key} toast={toast} state={state} />
|
||||||
)
|
)
|
||||||
|
|
||||||
|
case NotificationType.RoleChanged:
|
||||||
|
return <ToastRoleChanged key={toast.key} toast={toast} state={state} />
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return <Toast key={toast.key} toast={toast} state={state} />
|
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,
|
LockLockedIcon,
|
||||||
ScreenShareIcon,
|
ScreenShareIcon,
|
||||||
useIsEncrypted,
|
useIsEncrypted,
|
||||||
useParticipantInfo,
|
|
||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { Participant } from 'livekit-client'
|
import { Participant } from 'livekit-client'
|
||||||
@@ -12,13 +11,14 @@ import { ParticipantName } from './ParticipantName'
|
|||||||
import { RaisedHandMetadataWrapper } from './RaisedHandMetadataWrapper'
|
import { RaisedHandMetadataWrapper } from './RaisedHandMetadataWrapper'
|
||||||
|
|
||||||
export const ParticipantMetadata = ({
|
export const ParticipantMetadata = ({
|
||||||
|
displayedName,
|
||||||
participant,
|
participant,
|
||||||
isScreenShare,
|
isScreenShare,
|
||||||
}: {
|
}: {
|
||||||
|
displayedName: string
|
||||||
participant: Participant
|
participant: Participant
|
||||||
isScreenShare: boolean
|
isScreenShare: boolean
|
||||||
}) => {
|
}) => {
|
||||||
const { identity, name } = useParticipantInfo({ participant })
|
|
||||||
const isEncrypted = useIsEncrypted(participant)
|
const isEncrypted = useIsEncrypted(participant)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -42,7 +42,7 @@ export const ParticipantMetadata = ({
|
|||||||
)}
|
)}
|
||||||
<div className="lk-participant-name-wrapper">
|
<div className="lk-participant-name-wrapper">
|
||||||
<ParticipantName
|
<ParticipantName
|
||||||
displayedName={name != '' ? name : identity}
|
displayedName={displayedName}
|
||||||
isScreenShare={isScreenShare}
|
isScreenShare={isScreenShare}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import type { Participant } from 'livekit-client'
|
|
||||||
import { styled } from '@/styled-system/jsx'
|
import { styled } from '@/styled-system/jsx'
|
||||||
import { Avatar } from '@/components/Avatar'
|
import { Avatar } from '@/components/Avatar'
|
||||||
import { useIsSpeaking } from '@livekit/components-react'
|
|
||||||
import { getParticipantBackgroundGradient } from '@/features/rooms/utils/getParticipantBackgroundGradient'
|
import { getParticipantBackgroundGradient } from '@/features/rooms/utils/getParticipantBackgroundGradient'
|
||||||
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
import React, { useMemo } from 'react'
|
||||||
import { useMemo } from 'react'
|
|
||||||
|
|
||||||
const StyledParticipantPlaceHolder = styled('div', {
|
const StyledParticipantPlaceHolder = styled('div', {
|
||||||
base: {
|
base: {
|
||||||
@@ -23,39 +20,36 @@ const StyledAvatarWrapper = styled('div', {
|
|||||||
aspectRatio: '1 / 1',
|
aspectRatio: '1 / 1',
|
||||||
width: 'min(90cqmin, 160px)',
|
width: 'min(90cqmin, 160px)',
|
||||||
fontSize: 'min(27cqmin, 48px)',
|
fontSize: 'min(27cqmin, 48px)',
|
||||||
|
'[data-lk-speaking="true"] &': {
|
||||||
|
animation: 'pulse 1s infinite',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
type ParticipantPlaceholderProps = {
|
type ParticipantPlaceholderProps = {
|
||||||
participant: Participant
|
color: string
|
||||||
|
displayedNamed: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ParticipantPlaceholder = ({
|
export const ParticipantPlaceholder = React.memo(
|
||||||
participant,
|
({ color, displayedNamed }: ParticipantPlaceholderProps) => {
|
||||||
}: ParticipantPlaceholderProps) => {
|
|
||||||
const isSpeaking = useIsSpeaking(participant)
|
|
||||||
const participantColor = getParticipantColor(participant)
|
|
||||||
const backgroundGradient = useMemo(
|
const backgroundGradient = useMemo(
|
||||||
() => getParticipantBackgroundGradient(participantColor),
|
() => getParticipantBackgroundGradient(color),
|
||||||
[participantColor]
|
[color]
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledParticipantPlaceHolder
|
<StyledParticipantPlaceHolder
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: participantColor,
|
backgroundColor: color,
|
||||||
backgroundImage: backgroundGradient,
|
backgroundImage: backgroundGradient,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<StyledAvatarWrapper
|
<StyledAvatarWrapper>
|
||||||
style={{ animation: isSpeaking ? 'pulse 1s infinite' : undefined }}
|
<Avatar name={displayedNamed} bgColor={color} context="placeholder" />
|
||||||
>
|
|
||||||
<Avatar
|
|
||||||
name={participant.name}
|
|
||||||
bgColor={participantColor}
|
|
||||||
context="placeholder"
|
|
||||||
/>
|
|
||||||
</StyledAvatarWrapper>
|
</StyledAvatarWrapper>
|
||||||
</StyledParticipantPlaceHolder>
|
</StyledParticipantPlaceHolder>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
ParticipantPlaceholder.displayName = 'ParticipantPlaceholder'
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import React from 'react'
|
||||||
import {
|
import {
|
||||||
AudioTrack,
|
AudioTrack,
|
||||||
ParticipantTileProps,
|
ParticipantTileProps,
|
||||||
@@ -8,8 +9,8 @@ import {
|
|||||||
VideoTrack,
|
VideoTrack,
|
||||||
TrackRefContext,
|
TrackRefContext,
|
||||||
ParticipantContextIfNeeded,
|
ParticipantContextIfNeeded,
|
||||||
|
useParticipantInfo,
|
||||||
} from '@livekit/components-react'
|
} from '@livekit/components-react'
|
||||||
import React from 'react'
|
|
||||||
import {
|
import {
|
||||||
isEqualTrackRef,
|
isEqualTrackRef,
|
||||||
isTrackReference,
|
isTrackReference,
|
||||||
@@ -17,15 +18,15 @@ import {
|
|||||||
} from '@livekit/components-core'
|
} from '@livekit/components-core'
|
||||||
import { Track } from 'livekit-client'
|
import { Track } from 'livekit-client'
|
||||||
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
import { ParticipantPlaceholder } from './ParticipantPlaceholder'
|
||||||
import { ParticipantTileFocus } from './ParticipantTileFocus'
|
import { ParticipantTileFocus } from './participantTileFocus/ParticipantTileFocus'
|
||||||
import { FullScreenShareWarning } from './FullScreenShareWarning'
|
import { FullScreenShareWarning } from './FullScreenShareWarning'
|
||||||
import { getParticipantName } from '@/features/rooms/utils/getParticipantName'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
|
import { getShortcutDescriptorById } from '@/features/shortcuts/catalog'
|
||||||
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
|
import { formatShortcutLabel } from '@/features/shortcuts/formatLabels'
|
||||||
import { KeyboardShortcutHint } from './KeyboardShortcutHint'
|
import { KeyboardShortcutHint } from './KeyboardShortcutHint'
|
||||||
import { layoutStore, clearPinnedTrack } from '@/stores/layout'
|
import { layoutStore, clearPinnedTrack } from '@/stores/layout'
|
||||||
import { ParticipantMetadata } from './ParticipantMetadata'
|
import { ParticipantMetadata } from './ParticipantMetadata'
|
||||||
|
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
||||||
|
|
||||||
export function TrackRefContextIfNeeded(
|
export function TrackRefContextIfNeeded(
|
||||||
props: React.PropsWithChildren<{
|
props: React.PropsWithChildren<{
|
||||||
@@ -90,7 +91,13 @@ export const ParticipantTile: (
|
|||||||
const isScreenShare = trackReference.source != Track.Source.Camera
|
const isScreenShare = trackReference.source != Track.Source.Camera
|
||||||
const [hasKeyboardFocus, setHasKeyboardFocus] = React.useState(false)
|
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 { t } = useTranslation('rooms', { keyPrefix: 'participantTileFocus' })
|
||||||
|
|
||||||
const interactiveProps = {
|
const interactiveProps = {
|
||||||
@@ -141,11 +148,13 @@ export const ParticipantTile: (
|
|||||||
)}
|
)}
|
||||||
<div className="lk-participant-placeholder">
|
<div className="lk-participant-placeholder">
|
||||||
<ParticipantPlaceholder
|
<ParticipantPlaceholder
|
||||||
participant={trackReference.participant}
|
color={participantColor}
|
||||||
|
displayedNamed={participantName}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{!disableMetadata && (
|
{!disableMetadata && (
|
||||||
<ParticipantMetadata
|
<ParticipantMetadata
|
||||||
|
displayedName={participantName}
|
||||||
isScreenShare={isScreenShare}
|
isScreenShare={isScreenShare}
|
||||||
participant={trackReference.participant}
|
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 { useTranslation } from 'react-i18next'
|
||||||
import { Avatar } from '@/components/Avatar'
|
import { Avatar } from '@/components/Avatar'
|
||||||
import { getParticipantColor } from '@/features/rooms/utils/getParticipantColor'
|
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 { type LocalParticipant, type Participant, Track } from 'livekit-client'
|
||||||
import { isLocal } from '@/utils/livekit'
|
import { isLocal } from '@/utils/livekit'
|
||||||
import {
|
import {
|
||||||
@@ -20,7 +24,9 @@ import { useMuteParticipant } from '@/features/rooms/api/muteParticipant'
|
|||||||
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
import { useCanMute } from '@/features/rooms/livekit/hooks/useCanMute'
|
||||||
import { ParticipantMenuButton } from './menu/ParticipantMenuButton'
|
import { ParticipantMenuButton } from './menu/ParticipantMenuButton'
|
||||||
import { PinBadge } from './PinBadge'
|
import { PinBadge } from './PinBadge'
|
||||||
|
import { UnauthenticatedBadge } from './UnauthenticatedBadge'
|
||||||
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
|
import { MuteAlertDialog } from '@/features/rooms/livekit/components/MuteAlertDialog'
|
||||||
|
import { ParticipantName } from './ParticipantName'
|
||||||
|
|
||||||
type MicIndicatorProps = {
|
type MicIndicatorProps = {
|
||||||
participant: Participant
|
participant: Participant
|
||||||
@@ -106,52 +112,26 @@ export const ParticipantRow = ({ participant }: ParticipantListItemProps) => {
|
|||||||
width: 'full',
|
width: 'full',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<HStack>
|
<HStack flex="1" minW="0">
|
||||||
<div
|
<div className={css({ position: 'relative', flexShrink: 0 })}>
|
||||||
className={css({
|
|
||||||
position: 'relative',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Avatar name={name} bgColor={getParticipantColor(participant)} />
|
<Avatar name={name} bgColor={getParticipantColor(participant)} />
|
||||||
<PinBadge participant={participant} />
|
<PinBadge participant={participant} />
|
||||||
|
<UnauthenticatedBadge participant={participant} />
|
||||||
</div>
|
</div>
|
||||||
<VStack gap={0} alignItems="start">
|
<VStack gap={0} alignItems="start" minW="0" flex="1">
|
||||||
<Text
|
<ParticipantName
|
||||||
variant="sm"
|
displayedName={name}
|
||||||
className={css({
|
isLocal={isLocal(participant)}
|
||||||
userSelect: 'none',
|
/>
|
||||||
cursor: 'default',
|
<Text variant="xsNote">
|
||||||
display: 'flex',
|
{getParticipantIsRoomOwner(participant) && t('participants.host')}
|
||||||
})}
|
{getParticipantIsRoomAdmin(participant) && t('participants.cohost')}
|
||||||
>
|
{getParticipantIsRoomMember(participant) &&
|
||||||
<span
|
t('participants.member')}
|
||||||
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>
|
</Text>
|
||||||
{getParticipantIsRoomAdmin(participant) && (
|
|
||||||
<Text variant="xsNote">{t('participants.host')}</Text>
|
|
||||||
)}
|
|
||||||
</VStack>
|
</VStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
<HStack>
|
<HStack flexShrink={0}>
|
||||||
<MicIndicator participant={participant} />
|
<MicIndicator participant={participant} />
|
||||||
<ParticipantMenuButton participant={participant} />
|
<ParticipantMenuButton participant={participant} />
|
||||||
</HStack>
|
</HStack>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
|
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { Text } from '@/primitives/Text'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { Avatar } from '@/components/Avatar'
|
import { Avatar } from '@/components/Avatar'
|
||||||
import { useLowerHandParticipant } from '../api/lowerHandParticipant'
|
import { useLowerHandParticipant } from '../api/lowerHandParticipant'
|
||||||
@@ -11,6 +10,7 @@ import { isLocal } from '@/utils/livekit'
|
|||||||
import { RiHand } from '@remixicon/react'
|
import { RiHand } from '@remixicon/react'
|
||||||
import { Button } from '@/primitives'
|
import { Button } from '@/primitives'
|
||||||
import { AdminOrOwnerOnly } from '@/features/rooms/components/AdminOrOwnerOnly'
|
import { AdminOrOwnerOnly } from '@/features/rooms/components/AdminOrOwnerOnly'
|
||||||
|
import { ParticipantName } from './ParticipantName'
|
||||||
|
|
||||||
const ActionButton = ({
|
const ActionButton = ({
|
||||||
participant,
|
participant,
|
||||||
@@ -42,9 +42,7 @@ type HandRaisedListItemProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const RaisedHandRow = ({ participant }: HandRaisedListItemProps) => {
|
export const RaisedHandRow = ({ participant }: HandRaisedListItemProps) => {
|
||||||
const { t } = useTranslation('rooms')
|
|
||||||
const name = participant.name || participant.identity
|
const name = participant.name || participant.identity
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HStack
|
<HStack
|
||||||
role="listitem"
|
role="listitem"
|
||||||
@@ -56,42 +54,15 @@ export const RaisedHandRow = ({ participant }: HandRaisedListItemProps) => {
|
|||||||
width: 'full',
|
width: 'full',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<HStack>
|
<HStack flex="1" minW="0" overflow="hidden">
|
||||||
<Avatar name={name} bgColor={getParticipantColor(participant)} />
|
<Avatar name={name} bgColor={getParticipantColor(participant)} />
|
||||||
<Text
|
<ParticipantName displayedName={name} isLocal={isLocal(participant)} />
|
||||||
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>
|
|
||||||
</HStack>
|
</HStack>
|
||||||
|
<HStack flexShrink={0}>
|
||||||
<AdminOrOwnerOnly>
|
<AdminOrOwnerOnly>
|
||||||
<ActionButton participant={participant} name={name} />
|
<ActionButton participant={participant} name={name} />
|
||||||
</AdminOrOwnerOnly>
|
</AdminOrOwnerOnly>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
</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 { HStack } from '@/styled-system/jsx'
|
||||||
import { css } from '@/styled-system/css'
|
import { css } from '@/styled-system/css'
|
||||||
import { Avatar } from '@/components/Avatar'
|
import { Avatar } from '@/components/Avatar'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { WaitingParticipant } from '../api/listWaitingParticipants'
|
import { WaitingParticipant } from '../api/listWaitingParticipants'
|
||||||
import { RiCloseLine } from '@remixicon/react'
|
import { RiCloseLine } from '@remixicon/react'
|
||||||
|
import { ParticipantName } from './ParticipantName'
|
||||||
|
|
||||||
export const WaitingParticipantRow = ({
|
export const WaitingParticipantRow = ({
|
||||||
participant,
|
participant,
|
||||||
@@ -26,42 +27,11 @@ export const WaitingParticipantRow = ({
|
|||||||
width: 'full',
|
width: 'full',
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<HStack
|
<HStack flex="1" minW="0">
|
||||||
className={css({
|
|
||||||
flex: '1',
|
|
||||||
minWidth: '0',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Avatar name={participant.username} bgColor={participant.color} />
|
<Avatar name={participant.username} bgColor={participant.color} />
|
||||||
<Text
|
<ParticipantName displayedName={participant.username} isLocal={false} />
|
||||||
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>
|
|
||||||
</HStack>
|
</HStack>
|
||||||
<HStack
|
<HStack gap="0.25rem" flexShrink={0}>
|
||||||
gap="0.25rem"
|
|
||||||
className={css({
|
|
||||||
flexShrink: '0',
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="tertiary"
|
variant="tertiary"
|
||||||
|
|||||||
@@ -1,24 +1,46 @@
|
|||||||
import { Menu as RACMenu } from 'react-aria-components'
|
import { Menu as RACMenu } from 'react-aria-components'
|
||||||
import type { Participant } from 'livekit-client'
|
import type { Participant } from 'livekit-client'
|
||||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
|
||||||
import { PinMenuItem } from './items/PinMenuItem'
|
import { PinMenuItem } from './items/PinMenuItem'
|
||||||
import { RemoveMenuItem } from './items/RemoveMenuItem'
|
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 = ({
|
export const ParticipantMenu = ({
|
||||||
participant,
|
participant,
|
||||||
}: {
|
}: {
|
||||||
participant: Participant
|
participant: Participant
|
||||||
}) => {
|
}) => {
|
||||||
const isAdminOrOwner = useIsAdminOrOwner()
|
const isLocalUserAdminOrOwner = useIsAdminOrOwner()
|
||||||
const canModerateParticipant = !participant.isLocal && isAdminOrOwner
|
|
||||||
|
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 (
|
return (
|
||||||
<RACMenu
|
<RACMenu
|
||||||
style={{
|
style={{
|
||||||
minWidth: '75px',
|
minWidth: '100px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<PinMenuItem participant={participant} />
|
<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>
|
</RACMenu>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Button, Menu } from '@/primitives'
|
import { Button, Menu } from '@/primitives'
|
||||||
import { RiMore2Fill } from '@remixicon/react'
|
import { RiMore2Fill } from '@remixicon/react'
|
||||||
import { ParticipantMenu } from './ParticipantMenu'
|
import { ParticipantMenu } from './ParticipantMenu'
|
||||||
import { useIsAdminOrOwner } from '@/features/rooms/livekit/hooks/useIsAdminOrOwner'
|
|
||||||
import type { Participant } from 'livekit-client'
|
import type { Participant } from 'livekit-client'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
@@ -11,8 +10,6 @@ export const ParticipantMenuButton = ({
|
|||||||
participant: Participant
|
participant: Participant
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'participants' })
|
||||||
const isAdminOrOwner = useIsAdminOrOwner()
|
|
||||||
if (!isAdminOrOwner) return null
|
|
||||||
return (
|
return (
|
||||||
<Menu>
|
<Menu>
|
||||||
<Button
|
<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 { menuRecipe } from '@/primitives/menuRecipe'
|
||||||
import { HStack } from '@/styled-system/jsx'
|
import { HStack } from '@/styled-system/jsx'
|
||||||
import { RiCloseLine } from '@remixicon/react'
|
import { RiCloseLine } from '@remixicon/react'
|
||||||
@@ -6,18 +6,22 @@ import { MenuItem } from 'react-aria-components'
|
|||||||
import { useRemoveParticipant } from '@/features/rooms/api/removeParticipant'
|
import { useRemoveParticipant } from '@/features/rooms/api/removeParticipant'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export const RemoveMenuItem = ({
|
type RemoveMenuItemProps = {
|
||||||
participant,
|
identity: string
|
||||||
}: {
|
displayedName?: string
|
||||||
participant: Participant
|
}
|
||||||
}) => {
|
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'participantMenu.remove' })
|
export const RemoveMenuItem = React.memo(
|
||||||
|
({ identity, displayedName }: RemoveMenuItemProps) => {
|
||||||
|
const { t } = useTranslation('rooms', {
|
||||||
|
keyPrefix: 'participantMenu.remove',
|
||||||
|
})
|
||||||
const { removeParticipant } = useRemoveParticipant()
|
const { removeParticipant } = useRemoveParticipant()
|
||||||
return (
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
aria-label={t('ariaLabel', { name: participant.name })}
|
aria-label={t('ariaLabel', { name: displayedName || identity })}
|
||||||
className={menuRecipe({ icon: true }).item}
|
className={menuRecipe({ icon: true }).item}
|
||||||
onAction={() => removeParticipant(participant)}
|
onAction={() => removeParticipant(identity)}
|
||||||
>
|
>
|
||||||
<HStack gap={0.25}>
|
<HStack gap={0.25}>
|
||||||
<RiCloseLine size={20} aria-hidden />
|
<RiCloseLine size={20} aria-hidden />
|
||||||
@@ -26,3 +30,6 @@ export const RemoveMenuItem = ({
|
|||||||
</MenuItem>
|
</MenuItem>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
RemoveMenuItem.displayName = 'RemoveMenuItem'
|
||||||
|
|||||||
@@ -28,3 +28,6 @@ export type ApiRoom = {
|
|||||||
livekit?: ApiLiveKit
|
livekit?: ApiLiveKit
|
||||||
configuration?: RoomConfiguration
|
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 { useRoomData } from '../livekit/hooks/useRoomData'
|
||||||
import { fetchApi } from '@/api/fetchApi'
|
import { fetchApi } from '@/api/fetchApi'
|
||||||
|
|
||||||
export const useRemoveParticipant = () => {
|
export const useRemoveParticipant = () => {
|
||||||
const data = useRoomData()
|
const data = useRoomData()
|
||||||
|
|
||||||
const removeParticipant = async (participant: Participant) => {
|
const removeParticipant = async (identity: string) => {
|
||||||
if (!data?.id) {
|
if (!data?.id) {
|
||||||
throw new Error('Room id is not available')
|
throw new Error('Room id is not available')
|
||||||
}
|
}
|
||||||
@@ -13,7 +12,7 @@ export const useRemoveParticipant = () => {
|
|||||||
return fetchApi(`rooms/${data.id}/remove-participant/`, {
|
return fetchApi(`rooms/${data.id}/remove-participant/`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
participant_identity: participant.identity,
|
participant_identity: identity,
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -316,7 +316,7 @@ export const Join = ({
|
|||||||
refetch: refetchRoom,
|
refetch: refetchRoom,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: [keys.room, roomId],
|
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
|
staleTime: 6 * 60 * 60 * 1000, // By default, LiveKit access tokens expire 6 hours after generation
|
||||||
retry: false,
|
retry: false,
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -339,7 +339,7 @@ export const Join = ({
|
|||||||
|
|
||||||
const { status, startWaiting } = useLobby({
|
const { status, startWaiting } = useLobby({
|
||||||
roomId,
|
roomId,
|
||||||
username,
|
username: username || user?.full_name || 'anonymous',
|
||||||
onAccepted: handleAccepted,
|
onAccepted: handleAccepted,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import { useQuery } from '@tanstack/react-query'
|
|||||||
import { useParams } from 'wouter'
|
import { useParams } from 'wouter'
|
||||||
import { usePublishSourcesManager } from '../hooks/usePublishSourcesManager'
|
import { usePublishSourcesManager } from '../hooks/usePublishSourcesManager'
|
||||||
import { usePermissionsManager } from '../hooks/usePermissionsManager'
|
import { usePermissionsManager } from '../hooks/usePermissionsManager'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { closeSidePanel } from '@/stores/layout'
|
||||||
|
import { useIsAdminOrOwner } from '../hooks/useIsAdminOrOwner'
|
||||||
|
|
||||||
export const Admin = () => {
|
export const Admin = () => {
|
||||||
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
|
const { t } = useTranslation('rooms', { keyPrefix: 'admin' })
|
||||||
@@ -23,6 +26,14 @@ export const Admin = () => {
|
|||||||
|
|
||||||
const { mutateAsync: patchRoom } = usePatchRoom()
|
const { mutateAsync: patchRoom } = usePatchRoom()
|
||||||
|
|
||||||
|
const isAdminOrOwner = useIsAdminOrOwner()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAdminOrOwner) {
|
||||||
|
closeSidePanel()
|
||||||
|
}
|
||||||
|
}, [isAdminOrOwner])
|
||||||
|
|
||||||
const { data: readOnlyData } = useQuery({
|
const { data: readOnlyData } = useQuery({
|
||||||
queryKey: [keys.room, roomId],
|
queryKey: [keys.room, roomId],
|
||||||
queryFn: () => fetchRoom({ 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 { css } from '@/styled-system/css'
|
||||||
import { Heading } from 'react-aria-components'
|
import { Heading } from 'react-aria-components'
|
||||||
import { text } from '@/primitives/Text'
|
import { text } from '@/primitives/Text'
|
||||||
@@ -199,10 +199,7 @@ export const SidePanel = () => {
|
|||||||
ref={asideRef}
|
ref={asideRef}
|
||||||
title={title}
|
title={title}
|
||||||
ariaLabel={t('ariaLabel', { title })}
|
ariaLabel={t('ariaLabel', { title })}
|
||||||
onClose={() => {
|
onClose={closeSidePanel}
|
||||||
layoutStore.activePanelId = null
|
|
||||||
layoutStore.activeSubPanelId = null
|
|
||||||
}}
|
|
||||||
closeButtonTooltip={t('closeButton', {
|
closeButtonTooltip={t('closeButton', {
|
||||||
content: t(`content.${activeSubPanelId || activePanelId}`),
|
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 = () => {
|
export const useIsAdminOrOwner = () => {
|
||||||
const apiRoomData = useRoomData()
|
const room = useRoomContext()
|
||||||
return apiRoomData?.is_administrable
|
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 { useUpdateParticipantsPermissions } from '@/features/rooms/api/updateParticipantsPermissions'
|
||||||
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
import { useRoomData } from '@/features/rooms/livekit/hooks/useRoomData'
|
||||||
import { isSubsetOf } from '@/features/rooms/utils/isSubsetOf'
|
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 Source = Track.Source
|
||||||
import {
|
import {
|
||||||
NotificationType,
|
NotificationType,
|
||||||
@@ -48,7 +48,7 @@ export const usePublishSourcesManager = () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const unprivilegedRemoteParticipants = remoteParticipants.filter(
|
const unprivilegedRemoteParticipants = remoteParticipants.filter(
|
||||||
(participant) => !getParticipantIsRoomAdmin(participant)
|
(participant) => !getParticipantIsRoomAdminOrOwner(participant)
|
||||||
)
|
)
|
||||||
|
|
||||||
const currentSources = useMemo(() => {
|
const currentSources = useMemo(() => {
|
||||||
|
|||||||
@@ -13,8 +13,7 @@ import { useRoomContext } from '@livekit/components-react'
|
|||||||
import { useRoomData } from './useRoomData'
|
import { useRoomData } from './useRoomData'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shape of the LiveKit room metadata blob pushed by the backend.
|
* The subset of LiveKit's room metadata this hook actually uses.
|
||||||
* Matches RoomManagement.update_metadata → {"configuration": room.configuration}
|
|
||||||
*/
|
*/
|
||||||
type RoomLiveKitMetadata = {
|
type RoomLiveKitMetadata = {
|
||||||
configuration?: RoomConfiguration
|
configuration?: RoomConfiguration
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ import { SidePanel } from '../components/SidePanel'
|
|||||||
import { RecordingProvider } from '@/features/recording'
|
import { RecordingProvider } from '@/features/recording'
|
||||||
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
import { ScreenShareErrorModal } from '../components/ScreenShareErrorModal'
|
||||||
import { ConnectionObserver } from '../components/ConnectionObserver'
|
import { ConnectionObserver } from '../components/ConnectionObserver'
|
||||||
|
import { MediaStateObserver } from '../components/MediaStateObserver'
|
||||||
|
import { RoomMetadataSynchronizer } from '../components/RoomMetadataSynchronizer'
|
||||||
import { useRoomPageTitle } from '../hooks/useRoomPageTitle'
|
import { useRoomPageTitle } from '../hooks/useRoomPageTitle'
|
||||||
import { useNoiseReduction } from '../hooks/useNoiseReduction'
|
import { useNoiseReduction } from '../hooks/useNoiseReduction'
|
||||||
import { useSyncLiveKitMetadata } from '../hooks/useSyncLiveKitMetadata'
|
|
||||||
import { VideoResolutionSubscription } from '../components/VideoResolutionSubscription'
|
import { VideoResolutionSubscription } from '../components/VideoResolutionSubscription'
|
||||||
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
|
import { SettingsDialogProvider } from '@/features/settings/components/SettingsDialogProvider'
|
||||||
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
|
import { IsIdleDisconnectModal } from '../components/IsIdleDisconnectModal'
|
||||||
@@ -53,7 +54,6 @@ export interface VideoConferenceProps extends React.HTMLAttributes<HTMLDivElemen
|
|||||||
*/
|
*/
|
||||||
export function VideoConference({ ...props }: VideoConferenceProps) {
|
export function VideoConference({ ...props }: VideoConferenceProps) {
|
||||||
useRoomPageTitle()
|
useRoomPageTitle()
|
||||||
useSyncLiveKitMetadata()
|
|
||||||
useNoiseReduction()
|
useNoiseReduction()
|
||||||
|
|
||||||
const { isOpen: isPictureInPictureOpen } = usePictureInPicture()
|
const { isOpen: isPictureInPictureOpen } = usePictureInPicture()
|
||||||
@@ -62,7 +62,9 @@ export function VideoConference({ ...props }: VideoConferenceProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<RoomMetadataSynchronizer />
|
||||||
<ConnectionObserver />
|
<ConnectionObserver />
|
||||||
|
<MediaStateObserver />
|
||||||
<ChatProvider />
|
<ChatProvider />
|
||||||
<VideoResolutionSubscription />
|
<VideoResolutionSubscription />
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import type { Participant } from 'livekit-client'
|
||||||
|
|
||||||
|
export const getParticipantIsAuthenticated = (
|
||||||
|
participant: Participant
|
||||||
|
): boolean => {
|
||||||
|
const isAuthenticated = participant.attributes?.is_authenticated
|
||||||
|
return isAuthenticated == 'true'
|
||||||
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
import type { Participant } from 'livekit-client'
|
|
||||||
|
|
||||||
export const getParticipantIsRoomAdmin = (
|
|
||||||
participant: Participant
|
|
||||||
): boolean => {
|
|
||||||
const attributes = participant.attributes
|
|
||||||
|
|
||||||
if (!attributes) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return attributes?.room_admin === 'true'
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { Participant } from 'livekit-client'
|
||||||
|
import { ParticipantRole } from '@/features/rooms/api/ApiRoom'
|
||||||
|
|
||||||
|
const participantHasRoomRole = (
|
||||||
|
participant: Participant,
|
||||||
|
roles: ParticipantRole[]
|
||||||
|
): boolean => {
|
||||||
|
const role = participant.attributes?.room_role
|
||||||
|
return role !== undefined && roles.includes(role as ParticipantRole)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getParticipantIsRoomAdmin = (participant: Participant): boolean =>
|
||||||
|
participantHasRoomRole(participant, ['administrator'])
|
||||||
|
|
||||||
|
export const getParticipantIsRoomOwner = (participant: Participant): boolean =>
|
||||||
|
participantHasRoomRole(participant, ['owner'])
|
||||||
|
|
||||||
|
export const getParticipantIsRoomMember = (participant: Participant): boolean =>
|
||||||
|
participantHasRoomRole(participant, ['member'])
|
||||||
|
|
||||||
|
export const getParticipantIsRoomAdminOrOwner = (
|
||||||
|
participant: Participant
|
||||||
|
): boolean => participantHasRoomRole(participant, ['administrator', 'owner'])
|
||||||
@@ -2,9 +2,12 @@ export class CallbackIdHandler {
|
|||||||
private readonly storageKey = 'popup_callback_id'
|
private readonly storageKey = 'popup_callback_id'
|
||||||
|
|
||||||
private generateId(): string {
|
private generateId(): string {
|
||||||
return (
|
// The id is the only thing guarding /rooms/creation-callback/, which is
|
||||||
Math.random().toString(36).substring(2, 15) +
|
// unauthenticated, so it comes from the CSPRNG rather than Math.random.
|
||||||
Math.random().toString(36).substring(2, 15)
|
const bytes = new Uint8Array(16)
|
||||||
|
crypto.getRandomValues(bytes)
|
||||||
|
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(
|
||||||
|
''
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,79 @@
|
|||||||
import { Trans, useTranslation } from 'react-i18next'
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
|
import { useRef } from 'react'
|
||||||
|
import { Heading } from 'react-aria-components'
|
||||||
|
import { RiSettings3Line, RiDoorOpenLine } from '@remixicon/react'
|
||||||
import { useLanguageLabels } from '@/i18n/useLanguageLabels'
|
import { useLanguageLabels } from '@/i18n/useLanguageLabels'
|
||||||
import { A, Badge, Dialog, type DialogProps, Field, H, P } from '@/primitives'
|
import { A, Badge, Dialog, type DialogProps, Field, H, P } from '@/primitives'
|
||||||
|
import { Tab, TabList, TabPanel, Tabs } from '@/primitives/Tabs'
|
||||||
|
import { text } from '@/primitives/Text.tsx'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
import { useUser } from '@/features/auth/api/useUser'
|
import { useUser } from '@/features/auth/api/useUser'
|
||||||
import { LoginButton } from '@/components/LoginButton'
|
import { LoginButton } from '@/components/LoginButton'
|
||||||
import { logout } from '@/features/auth/utils/logout'
|
import { logout } from '@/features/auth/utils/logout'
|
||||||
|
import { useMediaQuery } from '@/features/rooms/livekit/hooks/useMediaQuery'
|
||||||
|
import { RoomsTab } from './tabs/RoomsTab'
|
||||||
|
|
||||||
export type SettingsDialogProps = Pick<DialogProps, 'isOpen' | 'onOpenChange'>
|
export type SettingsDialogProps = Pick<DialogProps, 'isOpen' | 'onOpenChange'>
|
||||||
|
|
||||||
|
enum SettingsDialogTabKey {
|
||||||
|
GENERAL = 'general',
|
||||||
|
ROOMS = 'rooms',
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabsStyle = css({
|
||||||
|
maxHeight: '40.625rem', // fixme size copied from meet settings modal
|
||||||
|
width: '50rem', // fixme size copied from meet settings modal
|
||||||
|
marginY: '-1rem', // fixme hacky solution to cancel modal padding
|
||||||
|
maxWidth: '100%',
|
||||||
|
overflow: 'hidden',
|
||||||
|
height: 'calc(100vh - 2rem)',
|
||||||
|
})
|
||||||
|
|
||||||
|
const tabListContainerStyle = css({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
borderRight: '1px solid lightGray', // fixme poor color management
|
||||||
|
paddingY: '1rem',
|
||||||
|
paddingLeft: '0.2rem',
|
||||||
|
paddingRight: '1.5rem',
|
||||||
|
})
|
||||||
|
|
||||||
|
const tabPanelContainerStyle = css({
|
||||||
|
display: 'flex',
|
||||||
|
flexGrow: '1',
|
||||||
|
marginTop: '3.5rem',
|
||||||
|
minWidth: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const tabPanelStyle = css({
|
||||||
|
flexGrow: '1',
|
||||||
|
minWidth: 0,
|
||||||
|
overflowY: 'auto',
|
||||||
|
paddingRight: '1.5rem',
|
||||||
|
paddingBottom: '1rem',
|
||||||
|
})
|
||||||
|
|
||||||
export const SettingsDialog = (props: SettingsDialogProps) => {
|
export const SettingsDialog = (props: SettingsDialogProps) => {
|
||||||
const { t, i18n } = useTranslation('settings')
|
const { t, i18n } = useTranslation('settings')
|
||||||
const { user, isLoggedIn } = useUser()
|
const { user, isLoggedIn } = useUser()
|
||||||
const { languagesList, currentLanguage } = useLanguageLabels()
|
const { languagesList, currentLanguage } = useLanguageLabels()
|
||||||
|
|
||||||
|
const dialogEl = useRef<HTMLDivElement>(null)
|
||||||
|
const isWideScreen = useMediaQuery('(min-width: 800px)') // fixme - hardcoded 50rem in pixel
|
||||||
|
|
||||||
const userDisplay =
|
const userDisplay =
|
||||||
user?.full_name && user?.email
|
user?.full_name && user?.email
|
||||||
? `${user.full_name} (${user.email})`
|
? `${user.full_name} (${user.email})`
|
||||||
: user?.email
|
: user?.email
|
||||||
return (
|
|
||||||
<Dialog title={t('dialog.heading')} {...props}>
|
const generalContent = (
|
||||||
|
<div
|
||||||
|
className={css({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
minWidth: '360px',
|
||||||
|
})}
|
||||||
|
>
|
||||||
<H lvl={2}>{t('account.heading')}</H>
|
<H lvl={2}>{t('account.heading')}</H>
|
||||||
{isLoggedIn ? (
|
{isLoggedIn ? (
|
||||||
<>
|
<>
|
||||||
@@ -47,6 +104,56 @@ export const SettingsDialog = (props: SettingsDialogProps) => {
|
|||||||
i18n.changeLanguage(lang as string)
|
i18n.changeLanguage(lang as string)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
// Without tabs there is no rail to host the heading, so keep the plain dialog.
|
||||||
|
if (!isLoggedIn) {
|
||||||
|
return (
|
||||||
|
<Dialog title={t('dialog.heading')} {...props} role="dialog" type="flex">
|
||||||
|
{generalContent}
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog innerRef={dialogEl} {...props} role="dialog" type="flex">
|
||||||
|
<Tabs
|
||||||
|
orientation="vertical"
|
||||||
|
className={tabsStyle}
|
||||||
|
defaultSelectedKey={SettingsDialogTabKey.GENERAL}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={tabListContainerStyle}
|
||||||
|
style={{
|
||||||
|
flex: isWideScreen ? '0 0 16rem' : undefined,
|
||||||
|
paddingTop: !isWideScreen ? '64px' : undefined,
|
||||||
|
paddingRight: !isWideScreen ? '1rem' : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isWideScreen && (
|
||||||
|
<Heading slot="title" level={1} className={text({ variant: 'h1' })}>
|
||||||
|
{t('dialog.heading')}
|
||||||
|
</Heading>
|
||||||
|
)}
|
||||||
|
<TabList border={false}>
|
||||||
|
<Tab icon highlight id={SettingsDialogTabKey.GENERAL}>
|
||||||
|
<RiSettings3Line />
|
||||||
|
{isWideScreen && t(`tabs.${SettingsDialogTabKey.GENERAL}`)}
|
||||||
|
</Tab>
|
||||||
|
<Tab icon highlight id={SettingsDialogTabKey.ROOMS}>
|
||||||
|
<RiDoorOpenLine />
|
||||||
|
{isWideScreen && t(`tabs.${SettingsDialogTabKey.ROOMS}`)}
|
||||||
|
</Tab>
|
||||||
|
</TabList>
|
||||||
|
</div>
|
||||||
|
<div className={tabPanelContainerStyle}>
|
||||||
|
<TabPanel id={SettingsDialogTabKey.GENERAL} className={tabPanelStyle}>
|
||||||
|
{generalContent}
|
||||||
|
</TabPanel>
|
||||||
|
<RoomsTab id={SettingsDialogTabKey.ROOMS} />
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useUser } from '@/features/auth/api/useUser'
|
||||||
|
import { useConfig } from '@/api/useConfig'
|
||||||
|
import {
|
||||||
|
usePatchUser,
|
||||||
|
patchUserMutationKey,
|
||||||
|
} from '@/features/auth/api/patchUser'
|
||||||
|
import { type ApiUser } from '@/features/auth/api/ApiUser'
|
||||||
|
import { ApiAccessLevel, RoomConfiguration } from '@/features/rooms/api/ApiRoom'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { queryClient } from '@/api/queryClient'
|
||||||
|
import { keys } from '@/api/queryKeys'
|
||||||
|
import { Track } from 'livekit-client'
|
||||||
|
import Source = Track.Source
|
||||||
|
import { isSubsetOf } from '@/features/rooms/utils/isSubsetOf'
|
||||||
|
import { updatePublishSources } from '@/features/rooms/livekit/hooks/usePublishSourcesManager'
|
||||||
|
import { Field, H, Text } from '@/primitives'
|
||||||
|
import { TabPanel } from '@/primitives/Tabs'
|
||||||
|
import { css } from '@/styled-system/css'
|
||||||
|
import { Separator as RACSeparator } from 'react-aria-components'
|
||||||
|
|
||||||
|
type RoomsTabProps = {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RoomsTab = ({ id }: RoomsTabProps) => {
|
||||||
|
const { t } = useTranslation('settings', { keyPrefix: 'roomDefaults' })
|
||||||
|
const { t: tAdmin } = useTranslation('rooms', {
|
||||||
|
keyPrefix: 'admin',
|
||||||
|
useSuspense: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const { user } = useUser()
|
||||||
|
const { data: configData } = useConfig()
|
||||||
|
|
||||||
|
// Optimistic updates: patch the cache immediately so the UI updates
|
||||||
|
// instantly and concurrent saves always build on the latest local state.
|
||||||
|
// Since each PATCH replaces the full JSON config, this avoids overwriting
|
||||||
|
// earlier changes with a stale snapshot.
|
||||||
|
//
|
||||||
|
// No per-request rollback: later requests already include earlier changes.
|
||||||
|
// Once the last in-flight save completes, re-fetch the server state once to
|
||||||
|
// restore the UI if all saves failed.
|
||||||
|
const { mutate: patchUser } = usePatchUser({
|
||||||
|
onMutate: async ({ user: partialUser }) => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: [keys.user] })
|
||||||
|
queryClient.setQueryData<ApiUser | false>([keys.user], (previous) =>
|
||||||
|
previous ? { ...previous, ...partialUser } : previous
|
||||||
|
)
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
if (queryClient.isMutating({ mutationKey: patchUserMutationKey }) === 1) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [keys.user] })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const configuration: RoomConfiguration = useMemo(
|
||||||
|
() => user?.default_room_configuration ?? {},
|
||||||
|
[user?.default_room_configuration]
|
||||||
|
)
|
||||||
|
|
||||||
|
const currentSources: Source[] = useMemo(() => {
|
||||||
|
const defaultSources = configData?.livekit?.default_sources ?? []
|
||||||
|
if (!Array.isArray(configuration?.can_publish_sources)) {
|
||||||
|
return defaultSources
|
||||||
|
}
|
||||||
|
return configuration.can_publish_sources
|
||||||
|
}, [configData, configuration])
|
||||||
|
|
||||||
|
const accessLevel =
|
||||||
|
user?.default_room_access_level ??
|
||||||
|
configData?.resource?.default_access_level ??
|
||||||
|
ApiAccessLevel.PUBLIC
|
||||||
|
|
||||||
|
// Every change saves immediately; the optimistic onMutate above keeps the
|
||||||
|
// cached user (and therefore `configuration`) in sync right away.
|
||||||
|
const saveConfiguration = (newConfiguration: RoomConfiguration) => {
|
||||||
|
if (!user) return
|
||||||
|
patchUser({
|
||||||
|
userId: user.id,
|
||||||
|
user: { default_room_configuration: newConfiguration },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateSource = (sources: Source[], enabled: boolean) =>
|
||||||
|
saveConfiguration({
|
||||||
|
...configuration,
|
||||||
|
can_publish_sources: updatePublishSources(
|
||||||
|
currentSources,
|
||||||
|
sources,
|
||||||
|
enabled
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
const isMicrophoneEnabled = isSubsetOf([Source.Microphone], currentSources)
|
||||||
|
const isCameraEnabled = isSubsetOf([Source.Camera], currentSources)
|
||||||
|
const isScreenShareEnabled = isSubsetOf(
|
||||||
|
[Source.ScreenShare, Source.ScreenShareAudio],
|
||||||
|
currentSources
|
||||||
|
)
|
||||||
|
const isMutingEnabled = configuration?.everyone_can_mute ?? true
|
||||||
|
|
||||||
|
const saveAccessLevel = (newAccessLevel: ApiAccessLevel) => {
|
||||||
|
if (!user) return
|
||||||
|
patchUser({
|
||||||
|
userId: user.id,
|
||||||
|
user: { default_room_access_level: newAccessLevel },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabPanel padding={'md'} flex id={id}>
|
||||||
|
<H lvl={2}>{t('heading')}</H>
|
||||||
|
<Text variant="note" margin={'md'}>
|
||||||
|
{t('description')}
|
||||||
|
</Text>
|
||||||
|
<RACSeparator
|
||||||
|
className={css({
|
||||||
|
border: 'none',
|
||||||
|
height: '1px',
|
||||||
|
width: '100%',
|
||||||
|
flexShrink: 0,
|
||||||
|
background: 'greyscale.250',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<H
|
||||||
|
lvl={3}
|
||||||
|
variant={'h2'}
|
||||||
|
className={css({
|
||||||
|
fontWeight: 500,
|
||||||
|
})}
|
||||||
|
margin="sm"
|
||||||
|
>
|
||||||
|
{tAdmin('moderation.title')}
|
||||||
|
</H>
|
||||||
|
<Text
|
||||||
|
variant="note"
|
||||||
|
wrap="balance"
|
||||||
|
className={css({
|
||||||
|
textStyle: 'sm',
|
||||||
|
})}
|
||||||
|
margin={'md'}
|
||||||
|
>
|
||||||
|
{tAdmin('moderation.description')}
|
||||||
|
</Text>
|
||||||
|
<Field
|
||||||
|
type="switch"
|
||||||
|
label={tAdmin('moderation.microphone.label')}
|
||||||
|
isSelected={isMicrophoneEnabled}
|
||||||
|
onChange={(enabled) => updateSource([Source.Microphone], enabled)}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
type="switch"
|
||||||
|
label={tAdmin('moderation.camera.label')}
|
||||||
|
isSelected={isCameraEnabled}
|
||||||
|
onChange={(enabled) => updateSource([Source.Camera], enabled)}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
type="switch"
|
||||||
|
label={tAdmin('moderation.screenshare.label')}
|
||||||
|
isSelected={isScreenShareEnabled}
|
||||||
|
onChange={(enabled) =>
|
||||||
|
updateSource([Source.ScreenShare, Source.ScreenShareAudio], enabled)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Field
|
||||||
|
type="switch"
|
||||||
|
label={tAdmin('moderation.mute.label')}
|
||||||
|
isSelected={isMutingEnabled}
|
||||||
|
onChange={(enabled) =>
|
||||||
|
saveConfiguration({ ...configuration, everyone_can_mute: enabled })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<RACSeparator
|
||||||
|
className={css({
|
||||||
|
border: 'none',
|
||||||
|
height: '1px',
|
||||||
|
width: '100%',
|
||||||
|
flexShrink: 0,
|
||||||
|
marginY: '1rem',
|
||||||
|
background: 'greyscale.250',
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
<H
|
||||||
|
lvl={3}
|
||||||
|
variant={'h2'}
|
||||||
|
className={css({
|
||||||
|
fontWeight: 500,
|
||||||
|
})}
|
||||||
|
margin="sm"
|
||||||
|
>
|
||||||
|
{tAdmin('access.title')}
|
||||||
|
</H>
|
||||||
|
<Field
|
||||||
|
type="radioGroup"
|
||||||
|
label={tAdmin('access.type')}
|
||||||
|
value={accessLevel}
|
||||||
|
labelProps={{
|
||||||
|
className: css({
|
||||||
|
fontSize: '1rem',
|
||||||
|
paddingBottom: '1rem',
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
onChange={(value) => saveAccessLevel(value as ApiAccessLevel)}
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
value: ApiAccessLevel.PUBLIC,
|
||||||
|
label: tAdmin('access.levels.public.label'),
|
||||||
|
description: tAdmin('access.levels.public.description'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: ApiAccessLevel.TRUSTED,
|
||||||
|
label: tAdmin('access.levels.trusted.label'),
|
||||||
|
description: tAdmin('access.levels.trusted.description'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: ApiAccessLevel.RESTRICTED,
|
||||||
|
label: tAdmin('access.levels.restricted.label'),
|
||||||
|
description: tAdmin('access.levels.restricted.description'),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</TabPanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -54,5 +54,13 @@
|
|||||||
"default": "Deine Aufzeichnung wird gespeichert! Du erhältst eine E-Mail, sobald sie bereit ist."
|
"default": "Deine Aufzeichnung wird gespeichert! Du erhältst eine E-Mail, sobald sie bereit ist."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"roleChanged": {
|
||||||
|
"body": "Deine Rolle wurde geändert. Du bist jetzt {{role}}.",
|
||||||
|
"roles": {
|
||||||
|
"owner": "Host",
|
||||||
|
"administrator": "Co-host",
|
||||||
|
"member": "Mitglied"
|
||||||
|
}
|
||||||
|
},
|
||||||
"openMenu": "Menü öffnen"
|
"openMenu": "Menü öffnen"
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user