diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index 764dda16..3fd0e72e 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -77,6 +77,11 @@ from core.services.participants_management import ( ParticipantsManagementException, ) from core.services.room_creation import RoomCreation +from core.services.room_management import ( + RoomManagement, + RoomManagementException, + RoomNotFoundException, +) from core.services.subtitle import SubtitleException, SubtitleService from core.tasks.file import process_file_deletion @@ -300,6 +305,33 @@ class RoomViewSet( if callback_id := self.request.data.get("callback_id"): RoomCreation().persist_callback_state(callback_id, room) + def perform_update(self, serializer): + """Persist the room update, then sync metadata to LiveKit.""" + + old_configuration = serializer.instance.configuration + room = serializer.save() + + if room.configuration == old_configuration: + return + + metadata = {"configuration": room.configuration} + + try: + RoomManagement().update_metadata( + room_name=str(room.id), + metadata=metadata, + ) + except RoomNotFoundException: + logger.info( + "LiveKit room %s does not exist yet, skipping metadata sync", + room.id, + ) + except RoomManagementException: + logger.warning( + "Failed to sync metadata to LiveKit for room %s", + room.id, + ) + @decorators.action( detail=True, methods=["post"], diff --git a/src/backend/core/services/room_management.py b/src/backend/core/services/room_management.py new file mode 100644 index 00000000..5934f285 --- /dev/null +++ b/src/backend/core/services/room_management.py @@ -0,0 +1,64 @@ +"""Room management service for LiveKit rooms.""" + +# pylint: disable=no-name-in-module + +import json +from logging import getLogger +from typing import Dict, Optional + +from asgiref.sync import async_to_sync +from livekit.api import ( + TwirpError, + UpdateRoomMetadataRequest, +) + +from core import utils + +logger = getLogger(__name__) + + +class RoomManagementException(Exception): + """Exception raised when a room management operation fails.""" + + +class RoomNotFoundException(RoomManagementException): + """Raised when the target room does not exist in LiveKit.""" + + +class RoomManagement: + """Service for managing LiveKit rooms.""" + + @async_to_sync + async def update_metadata(self, room_name: str, metadata: Optional[Dict] = None): + """Update a LiveKit room's metadata. + + The `room_name` corresponds to the LiveKit room identifier + (i.e. the Room model's UUID as a string). + """ + + lkapi = utils.create_livekit_client() + + try: + await lkapi.room.update_room_metadata( + UpdateRoomMetadataRequest( + room=room_name, + metadata=json.dumps(metadata) if metadata is not None else "", + ) + ) + + except TwirpError as e: + if e.code == "not_found": + logger.warning( + "Room %s not found in LiveKit, skipping metadata update", + room_name, + ) + raise RoomNotFoundException("Room does not exist") from e + + logger.exception( + "Unexpected error updating metadata for room %s", + room_name, + ) + raise RoomManagementException("Could not update room metadata") from e + + finally: + await lkapi.aclose() diff --git a/src/backend/core/tests/rooms/test_api_rooms_update.py b/src/backend/core/tests/rooms/test_api_rooms_update.py index f9f86eec..91cf934e 100644 --- a/src/backend/core/tests/rooms/test_api_rooms_update.py +++ b/src/backend/core/tests/rooms/test_api_rooms_update.py @@ -3,12 +3,18 @@ Test rooms API endpoints in the Meet core app: update. """ import random +from unittest.mock import patch import pytest from rest_framework.test import APIClient from ...factories import RoomFactory, UserFactory from ...models import RoomAccessLevel +from ...services.room_management import ( + RoomManagement, + RoomManagementException, + RoomNotFoundException, +) pytestmark = pytest.mark.django_db @@ -79,12 +85,14 @@ def test_api_rooms_update_members(): assert room.configuration == {} -def test_api_rooms_update_administrators(): - """Administrators or owners of a room should be allowed to update it.""" +@patch.object(RoomManagement, "update_metadata") +def test_api_rooms_update_administrators(mock_update_metadata): + """Should sync LiveKit metadata when both configuration and access level change.""" user = UserFactory() room = RoomFactory( access_level=RoomAccessLevel.RESTRICTED, users=[(user, random.choice(["administrator", "owner"]))], + configuration={"can_publish_sources": ["camera"]}, ) client = APIClient() client.force_login(user) @@ -106,11 +114,112 @@ def test_api_rooms_update_administrators(): assert room.access_level == RoomAccessLevel.PUBLIC assert room.configuration == {"can_publish_sources": ["camera", "microphone"]} + mock_update_metadata.assert_called_once_with( + room_name=str(room.id), + metadata={ + "configuration": {"can_publish_sources": ["camera", "microphone"]}, + }, + ) + + +@patch.object(RoomManagement, "update_metadata") +def test_api_rooms_update_administrators_configuration_only(mock_update_metadata): + """Should sync LiveKit metadata when only configuration changes.""" + user = UserFactory() + room = RoomFactory( + access_level=RoomAccessLevel.RESTRICTED, + users=[(user, random.choice(["administrator", "owner"]))], + configuration={}, + ) + client = APIClient() + client.force_login(user) + + response = client.put( + f"/api/v1.0/rooms/{room.id!s}/", + { + "name": "New name", + "slug": "should-be-ignored", + "configuration": {"can_publish_sources": ["camera", "microphone"]}, + }, + format="json", + ) + assert response.status_code == 200 + room.refresh_from_db() + assert room.name == "New name" + assert room.slug == "new-name" + assert room.access_level == RoomAccessLevel.RESTRICTED + assert room.configuration == {"can_publish_sources": ["camera", "microphone"]} + + mock_update_metadata.assert_called_once_with( + room_name=str(room.id), + metadata={ + "configuration": {"can_publish_sources": ["camera", "microphone"]}, + }, + ) + + +@patch.object(RoomManagement, "update_metadata") +def test_api_rooms_update_administrators_access_level_only(mock_update_metadata): + """Should not sync LiveKit metadata when only access level changes.""" + user = UserFactory() + room = RoomFactory( + access_level=RoomAccessLevel.RESTRICTED, + users=[(user, random.choice(["administrator", "owner"]))], + configuration={"can_publish_sources": ["camera"]}, + ) + client = APIClient() + client.force_login(user) + + response = client.put( + f"/api/v1.0/rooms/{room.id!s}/", + { + "name": "New name", + "access_level": RoomAccessLevel.PUBLIC, + }, + format="json", + ) + assert response.status_code == 200 + room.refresh_from_db() + assert room.name == "New name" + assert room.slug == "new-name" + assert room.access_level == RoomAccessLevel.PUBLIC + assert room.configuration == {"can_publish_sources": ["camera"]} + + mock_update_metadata.assert_not_called() + + +@patch.object(RoomManagement, "update_metadata") +def test_api_rooms_update_administrators_name_only(mock_update_metadata): + """Should not sync LiveKit metadata when neither configuration nor access level changes.""" + user = UserFactory() + room = RoomFactory( + name="Old name", + access_level=RoomAccessLevel.PUBLIC, + configuration={"can_publish_sources": ["camera"]}, + users=[(user, random.choice(["administrator", "owner"]))], + ) + client = APIClient() + client.force_login(user) + + response = client.patch( + f"/api/v1.0/rooms/{room.id!s}/", + {"name": "New name"}, + format="json", + ) + assert response.status_code == 200 + room.refresh_from_db() + assert room.name == "New name" + assert room.slug == "new-name" + # Unrelated fields untouched + assert room.access_level == RoomAccessLevel.PUBLIC + assert room.configuration == {"can_publish_sources": ["camera"]} + + mock_update_metadata.assert_not_called() + @pytest.mark.parametrize( "configuration", [ - {}, {"can_publish_sources": ["camera", "microphone"]}, { "can_publish_sources": [ @@ -128,10 +237,11 @@ def test_api_rooms_update_administrators(): {"can_publish_sources": None, "everyone_can_mute": "1"}, ], ) -def test_api_rooms_update_configuration_valid(configuration): +@patch.object(RoomManagement, "update_metadata") +def test_api_rooms_update_configuration_valid(mock_update_metadata, configuration): """Administrators should be allowed to set valid configurations.""" user = UserFactory() - room = RoomFactory(users=[(user, "owner")]) + room = RoomFactory(users=[(user, "owner")], configuration={}) client = APIClient() client.force_login(user) @@ -144,6 +254,28 @@ def test_api_rooms_update_configuration_valid(configuration): room.refresh_from_db() assert room.configuration == configuration + mock_update_metadata.assert_called_once() + + +@patch.object(RoomManagement, "update_metadata") +def test_api_rooms_update_configuration_unchanged_empty(mock_update_metadata): + """Should not sync LiveKit metadata when patching an already empty configuration.""" + user = UserFactory() + room = RoomFactory(users=[(user, "owner")], configuration={}) + client = APIClient() + client.force_login(user) + + response = client.patch( + f"/api/v1.0/rooms/{room.id!s}/", + {"configuration": {}}, + format="json", + ) + assert response.status_code == 200 + room.refresh_from_db() + assert room.configuration == {} + + mock_update_metadata.assert_not_called() + def test_api_rooms_update_configuration_extra_keys_rejected(): """Extra keys in configuration should be rejected.""" @@ -239,3 +371,55 @@ def test_api_rooms_update_administrators_of_another(): other_room.refresh_from_db() assert other_room.name == "Old name" assert other_room.slug == "old-name" + + +@patch.object(RoomManagement, "update_metadata", side_effect=RoomNotFoundException) +def test_api_rooms_update_livekit_room_not_found(mock_update_metadata): + """Should not fail the API request when the LiveKit room does not exist yet.""" + user = UserFactory() + room = RoomFactory( + users=[(user, random.choice(["administrator", "owner"]))], + configuration={}, + ) + client = APIClient() + client.force_login(user) + + response = client.patch( + f"/api/v1.0/rooms/{room.id!s}/", + {"configuration": {"can_publish_sources": ["camera"]}}, + format="json", + ) + assert response.status_code == 200 + room.refresh_from_db() + assert room.configuration == {"can_publish_sources": ["camera"]} + + mock_update_metadata.assert_called_once_with( + room_name=str(room.id), + metadata={"configuration": {"can_publish_sources": ["camera"]}}, + ) + + +@patch.object(RoomManagement, "update_metadata", side_effect=RoomManagementException) +def test_api_rooms_update_livekit_sync_failure(mock_update_metadata): + """Should not fail the API request when the LiveKit metadata sync fails.""" + user = UserFactory() + room = RoomFactory( + users=[(user, random.choice(["administrator", "owner"]))], + configuration={}, + ) + client = APIClient() + client.force_login(user) + + response = client.patch( + f"/api/v1.0/rooms/{room.id!s}/", + {"configuration": {"can_publish_sources": ["camera"]}}, + format="json", + ) + assert response.status_code == 200 + room.refresh_from_db() + assert room.configuration == {"can_publish_sources": ["camera"]} + + mock_update_metadata.assert_called_once_with( + room_name=str(room.id), + metadata={"configuration": {"can_publish_sources": ["camera"]}}, + )