diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 7715b769..7653728f 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -2,8 +2,6 @@ # pylint: disable=W0223 -import uuid - from django.utils.translation import gettext_lazy as _ from rest_framework import serializers @@ -222,17 +220,9 @@ class RequestEntrySerializer(BaseValidationOnlySerializer): class ParticipantEntrySerializer(BaseValidationOnlySerializer): """Validate participant entry decision data.""" - participant_id = serializers.CharField(required=True) + participant_id = serializers.UUIDField(required=True) allow_entry = serializers.BooleanField(required=True) - def validate_participant_id(self, value): - """Validate that the participant_id is a valid UUID hex string.""" - try: - uuid.UUID(hex=value, version=4) - except (ValueError, TypeError) as e: - raise serializers.ValidationError("Invalid UUID hex format") from e - return value - class CreationCallbackSerializer(BaseValidationOnlySerializer): """Validate room creation callback data.""" diff --git a/src/backend/core/api/viewsets.py b/src/backend/core/api/viewsets.py index b2ef65b4..6e5b6a14 100644 --- a/src/backend/core/api/viewsets.py +++ b/src/backend/core/api/viewsets.py @@ -419,7 +419,8 @@ class RoomViewSet( try: lobby_service.handle_participant_entry( room_id=room.id, - **serializer.validated_data, + participant_id=str(serializer.validated_data.get("participant_id")), + allow_entry=serializer.validated_data.get("allow_entry"), ) return drf_response.Response({"message": "Participant was updated."}) diff --git a/src/backend/core/services/lobby.py b/src/backend/core/services/lobby.py index d4ea5aba..bce85eae 100644 --- a/src/backend/core/services/lobby.py +++ b/src/backend/core/services/lobby.py @@ -89,7 +89,7 @@ class LobbyService: @staticmethod def _get_or_create_participant_id(request) -> str: """Extract unique participant identifier from the request.""" - return request.COOKIES.get(settings.LOBBY_COOKIE_NAME, uuid.uuid4().hex) + return request.COOKIES.get(settings.LOBBY_COOKIE_NAME, str(uuid.uuid4())) @staticmethod def prepare_response(response, participant_id): @@ -163,6 +163,7 @@ class LobbyService: color=participant.color, configuration=room.configuration, is_admin_or_owner=False, + participant_id=participant_id, ) return participant, livekit_config @@ -183,6 +184,7 @@ class LobbyService: color=participant.color, configuration=room.configuration, is_admin_or_owner=False, + participant_id=participant_id, ) return participant, livekit_config diff --git a/src/backend/core/tests/rooms/test_api_rooms_lobby.py b/src/backend/core/tests/rooms/test_api_rooms_lobby.py index 22bc601f..625f6051 100644 --- a/src/backend/core/tests/rooms/test_api_rooms_lobby.py +++ b/src/backend/core/tests/rooms/test_api_rooms_lobby.py @@ -132,9 +132,9 @@ def test_request_entry_with_existing_participants(settings): # Add two participants already waiting in the lobby cache.set( - f"mocked-cache-prefix_{room.id}_2f7f162fe7d1421b90e702bfbfbf8def", + f"mocked-cache-prefix_{room.id}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def", { - "id": "2f7f162fe7d1421b90e702bfbfbf8def", + "id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "username": "user1", "status": "waiting", "color": "#123456", @@ -259,7 +259,7 @@ def test_request_entry_authenticated_user_public_room(settings): mock.patch.object( LobbyService, "_get_or_create_participant_id", - return_value="2f7f162fe7d1421b90e702bfbfbf8def", + return_value="2f7f162f-e7d1-421b-90e7-02bfbfbf8def", ), mock.patch.object( utils, "generate_livekit_config", return_value={"token": "test-token"} @@ -276,11 +276,11 @@ def test_request_entry_authenticated_user_public_room(settings): # Verify the lobby cookie was set cookie = response.cookies.get("mocked-cookie") assert cookie is not None - assert cookie.value == "2f7f162fe7d1421b90e702bfbfbf8def" + assert cookie.value == "2f7f162f-e7d1-421b-90e7-02bfbfbf8def" # Verify response content matches expected structure and values assert response.json() == { - "id": "2f7f162fe7d1421b90e702bfbfbf8def", + "id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "username": "test_user", "status": "accepted", "color": "mocked-color", @@ -302,9 +302,9 @@ def test_request_entry_waiting_participant_public_room(settings): # Add a waiting participant to the room's lobby cache cache.set( - f"mocked-cache-prefix_{room.id}_2f7f162fe7d1421b90e702bfbfbf8def", + f"mocked-cache-prefix_{room.id}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def", { - "id": "2f7f162fe7d1421b90e702bfbfbf8def", + "id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "username": "user1", "status": "waiting", "color": "#123456", @@ -312,7 +312,7 @@ def test_request_entry_waiting_participant_public_room(settings): ) # Simulate a browser with existing participant cookie - client.cookies.load({"mocked-cookie": "2f7f162fe7d1421b90e702bfbfbf8def"}) + client.cookies.load({"mocked-cookie": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def"}) with ( mock.patch.object(utils, "notify_participants", return_value=None), @@ -330,11 +330,11 @@ def test_request_entry_waiting_participant_public_room(settings): # Verify the lobby cookie was set cookie = response.cookies.get("mocked-cookie") assert cookie is not None - assert cookie.value == "2f7f162fe7d1421b90e702bfbfbf8def" + assert cookie.value == "2f7f162f-e7d1-421b-90e7-02bfbfbf8def" # Verify response content matches expected structure and values assert response.json() == { - "id": "2f7f162fe7d1421b90e702bfbfbf8def", + "id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "username": "user1", "status": "accepted", "color": "#123456", @@ -381,7 +381,7 @@ def test_allow_participant_to_enter_anonymous(): response = client.post( f"/api/v1.0/rooms/{room.id}/enter/", - {"participant_id": "2f7f162fe7d1421b90e702bfbfbf8def", "allow_entry": True}, + {"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True}, ) assert response.status_code == 401 @@ -396,7 +396,7 @@ def test_allow_participant_to_enter_non_owner(): response = client.post( f"/api/v1.0/rooms/{room.id}/enter/", - {"participant_id": "2f7f162fe7d1421b90e702bfbfbf8def", "allow_entry": True}, + {"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True}, ) assert response.status_code == 403 @@ -414,7 +414,7 @@ def test_allow_participant_to_enter_public_room(): response = client.post( f"/api/v1.0/rooms/{room.id}/enter/", - {"participant_id": "2f7f162fe7d1421b90e702bfbfbf8def", "allow_entry": True}, + {"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True}, ) assert response.status_code == 404 @@ -437,9 +437,9 @@ def test_allow_participant_to_enter_success(settings, allow_entry, updated_statu settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix" cache.set( - f"mocked-cache-prefix_{room.id!s}_2f7f162fe7d1421b90e702bfbfbf8def", + f"mocked-cache-prefix_{room.id!s}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def", { - "id": "2f7f162fe7d1421b90e702bfbfbf8def", + "id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "status": "waiting", "username": "foo", "color": "123", @@ -449,7 +449,7 @@ def test_allow_participant_to_enter_success(settings, allow_entry, updated_statu response = client.post( f"/api/v1.0/rooms/{room.id}/enter/", { - "participant_id": "2f7f162fe7d1421b90e702bfbfbf8def", + "participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": allow_entry, }, ) @@ -458,7 +458,7 @@ def test_allow_participant_to_enter_success(settings, allow_entry, updated_statu assert response.json() == {"message": "Participant was updated."} participant_data = cache.get( - f"mocked-cache-prefix_{room.id!s}_2f7f162fe7d1421b90e702bfbfbf8def" + f"mocked-cache-prefix_{room.id!s}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def" ) assert participant_data.get("status") == updated_status @@ -476,13 +476,13 @@ def test_allow_participant_to_enter_participant_not_found(settings): settings.LOBBY_KEY_PREFIX = "mocked-cache-prefix" participant_data = cache.get( - f"mocked-cache-prefix_{room.id!s}_2f7f162fe7d1421b90e702bfbfbf8def" + f"mocked-cache-prefix_{room.id!s}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def" ) assert participant_data is None response = client.post( f"/api/v1.0/rooms/{room.id}/enter/", - {"participant_id": "2f7f162fe7d1421b90e702bfbfbf8def", "allow_entry": True}, + {"participant_id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "allow_entry": True}, ) assert response.status_code == 404 @@ -572,9 +572,9 @@ def test_list_waiting_participants_success(settings): # Add participants in the lobby cache.set( - f"mocked-cache-prefix_{room.id}_2f7f162fe7d1421b90e702bfbfbf8def", + f"mocked-cache-prefix_{room.id}_2f7f162f-e7d1-421b-90e7-02bfbfbf8def", { - "id": "2f7f162fe7d1421b90e702bfbfbf8def", + "id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "username": "user1", "status": "waiting", "color": "#123456", @@ -597,7 +597,7 @@ def test_list_waiting_participants_success(settings): participants = response.json().get("participants") assert sorted(participants, key=lambda p: p["id"]) == [ { - "id": "2f7f162fe7d1421b90e702bfbfbf8def", + "id": "2f7f162f-e7d1-421b-90e7-02bfbfbf8def", "username": "user1", "status": "waiting", "color": "#123456", diff --git a/src/backend/core/tests/rooms/test_api_rooms_retrieve.py b/src/backend/core/tests/rooms/test_api_rooms_retrieve.py index 191ccaa1..eb93ccf2 100644 --- a/src/backend/core/tests/rooms/test_api_rooms_retrieve.py +++ b/src/backend/core/tests/rooms/test_api_rooms_retrieve.py @@ -271,6 +271,7 @@ def test_api_rooms_retrieve_authenticated_public(mock_token): color=None, sources=["mock-source"], is_admin_or_owner=False, + participant_id=None, ) @@ -321,6 +322,7 @@ def test_api_rooms_retrieve_authenticated_trusted(mock_token): color=None, sources=None, is_admin_or_owner=False, + participant_id=None, ) @@ -407,6 +409,7 @@ def test_api_rooms_retrieve_members(mock_token, django_assert_num_queries, setti color=None, sources=["mock-source"], is_admin_or_owner=False, + participant_id=None, ) @@ -499,4 +502,5 @@ def test_api_rooms_retrieve_administrators( color=None, sources=None, is_admin_or_owner=True, + participant_id=None, ) diff --git a/src/backend/core/tests/services/test_lobby.py b/src/backend/core/tests/services/test_lobby.py index aeba4448..600d3220 100644 --- a/src/backend/core/tests/services/test_lobby.py +++ b/src/backend/core/tests/services/test_lobby.py @@ -144,10 +144,9 @@ def test_get_or_create_participant_id_from_cookie(lobby_service): assert participant_id == "existing-id" -@mock.patch("uuid.uuid4") +@mock.patch.object(uuid, "uuid4", return_value="generated-id") def test_get_or_create_participant_id_new(mock_uuid4, lobby_service): """Test creating new participant ID when cookie is missing.""" - mock_uuid4.return_value = mock.Mock(hex="generated-id") request = mock.Mock() request.COOKIES = {} @@ -268,6 +267,7 @@ def test_request_entry_public_room( color=participant.color, configuration=room.configuration, is_admin_or_owner=False, + participant_id="test-participant-id", ) lobby_service._get_participant.assert_called_once_with(room.id, participant_id) @@ -306,6 +306,7 @@ def test_request_entry_trusted_room( color=participant.color, configuration=room.configuration, is_admin_or_owner=False, + participant_id="test-participant-id", ) lobby_service._get_participant.assert_called_once_with(room.id, participant_id) @@ -400,6 +401,7 @@ def test_request_entry_accepted_participant( color="#123456", configuration=room.configuration, is_admin_or_owner=False, + participant_id="test-participant-id", ) lobby_service._get_participant.assert_called_once_with(room.id, participant_id) diff --git a/src/backend/core/utils.py b/src/backend/core/utils.py index c6455665..ce6e17c9 100644 --- a/src/backend/core/utils.py +++ b/src/backend/core/utils.py @@ -56,6 +56,7 @@ def generate_token( color: Optional[str] = None, sources: Optional[List[str]] = None, is_admin_or_owner: bool = False, + participant_id: Optional[str] = None, ) -> str: """Generate a LiveKit access token for a user in a specific room. @@ -69,6 +70,8 @@ def generate_token( sources: (Optional[List[str]]): List of media sources the user can publish If none, defaults to LIVEKIT_DEFAULT_SOURCES. is_admin_or_owner (bool): Whether user has admin privileges + participant_id (Optional[str]): Stable identifier for anonymous users; + used as identity when user.is_anonymous. Returns: str: The LiveKit JWT access token. @@ -90,7 +93,7 @@ def generate_token( ) if user.is_anonymous: - identity = str(uuid4()) + identity = participant_id or str(uuid4()) default_username = "Anonymous" else: identity = str(user.sub) @@ -120,6 +123,7 @@ def generate_livekit_config( is_admin_or_owner: bool, color: Optional[str] = None, configuration: Optional[dict] = None, + participant_id: Optional[str] = None, ) -> dict: """Generate LiveKit configuration for room access. @@ -130,6 +134,8 @@ def generate_livekit_config( is_admin_or_owner (bool): Whether the user has admin/owner privileges for this room. color (Optional[str]): Optional color to associate with the participant. configuration (Optional[dict]): Room configuration dict that can override default settings. + participant_id (Optional[str]): Stable identifier for anonymous users; + used as identity when user.is_anonymous. Returns: dict: LiveKit configuration with URL, room and access token @@ -149,6 +155,7 @@ def generate_livekit_config( color=color, sources=sources, is_admin_or_owner=is_admin_or_owner, + participant_id=participant_id, ), }