♻️(backend) extract notify_participant to util function

Move from lobby service to utils for reuse across services. Method is
generic enough for utility status. Future: create dedicated LiveKit
service to encapsulate all LiveKit-related utilities.
This commit is contained in:
lebaudantoine
2025-07-14 17:36:34 +02:00
committed by aleb_the_flash
parent 85bde9633f
commit 17c486f7bf
5 changed files with 171 additions and 181 deletions

View File

@@ -1,6 +1,5 @@
"""Lobby Service""" """Lobby Service"""
import json
import logging import logging
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
@@ -11,13 +10,6 @@ from uuid import UUID
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from asgiref.sync import async_to_sync
from livekit.api import ( # pylint: disable=E0611
ListRoomsRequest,
SendDataRequest,
TwirpError,
)
from core import models, utils from core import models, utils
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -46,10 +38,6 @@ class LobbyParticipantNotFound(LobbyError):
"""Raised when participant is not found.""" """Raised when participant is not found."""
class LobbyNotificationError(LobbyError):
"""Raised when LiveKit notification fails."""
@dataclass @dataclass
class LobbyParticipant: class LobbyParticipant:
"""Participant in a lobby system.""" """Participant in a lobby system."""
@@ -211,9 +199,6 @@ class LobbyService:
Create a new participant entry in waiting status and notify room Create a new participant entry in waiting status and notify room
participants of the new entry request. participants of the new entry request.
Raises:
LobbyNotificationError: If room notification fails
""" """
color = utils.generate_color(participant_id) color = utils.generate_color(participant_id)
@@ -226,10 +211,15 @@ class LobbyService:
) )
try: try:
self.notify_participants(room_id=room_id) utils.notify_participants(
except LobbyNotificationError: room_name=room_id,
notification_data={
"type": settings.LOBBY_NOTIFICATION_TYPE,
},
)
except utils.NotificationError:
# If room not created yet, there is no participants to notify # If room not created yet, there is no participants to notify
pass logger.exception("Failed to notify room participants")
cache_key = self._get_cache_key(room_id, participant_id) cache_key = self._get_cache_key(room_id, participant_id)
cache.set( cache.set(
@@ -334,44 +324,6 @@ class LobbyService:
participant.status = status participant.status = status
cache.set(cache_key, participant.to_dict(), timeout=timeout) cache.set(cache_key, participant.to_dict(), timeout=timeout)
@async_to_sync
async def notify_participants(self, room_id: UUID):
"""Notify room participants about a new waiting participant using LiveKit.
Raises:
LobbyNotificationError: If notification fails to send
"""
notification_data = {
"type": settings.LOBBY_NOTIFICATION_TYPE,
}
lkapi = utils.create_livekit_client()
try:
room_response = await lkapi.room.list_rooms(
ListRoomsRequest(
names=[str(room_id)],
)
)
# Check if the room exists
if not room_response.rooms:
return
await lkapi.room.send_data(
SendDataRequest(
room=str(room_id),
data=json.dumps(notification_data).encode("utf-8"),
kind="RELIABLE",
)
)
except TwirpError as e:
logger.exception("Failed to notify room participants")
raise LobbyNotificationError("Failed to notify room participants") from e
finally:
await lkapi.aclose()
def clear_room_cache(self, room_id: UUID) -> None: def clear_room_cache(self, room_id: UUID) -> None:
"""Clear all participant entries from the cache for a specific room.""" """Clear all participant entries from the cache for a specific room."""

View File

@@ -37,7 +37,7 @@ def test_request_entry_anonymous(settings):
assert not lobby_keys assert not lobby_keys
with ( with (
mock.patch.object(LobbyService, "notify_participants", return_value=None), mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(utils, "generate_color", return_value="mocked-color"), mock.patch.object(utils, "generate_color", return_value="mocked-color"),
): ):
response = client.post( response = client.post(
@@ -86,7 +86,7 @@ def test_request_entry_authenticated_user(settings):
assert not lobby_keys assert not lobby_keys
with ( with (
mock.patch.object(LobbyService, "notify_participants", return_value=None), mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(utils, "generate_color", return_value="mocked-color"), mock.patch.object(utils, "generate_color", return_value="mocked-color"),
): ):
response = client.post( response = client.post(
@@ -156,7 +156,7 @@ def test_request_entry_with_existing_participants(settings):
# Mock external service calls to isolate the test # Mock external service calls to isolate the test
with ( with (
mock.patch.object(LobbyService, "notify_participants", return_value=None), mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object(utils, "generate_color", return_value="mocked-color"), mock.patch.object(utils, "generate_color", return_value="mocked-color"),
): ):
# Make request as a new anonymous user # Make request as a new anonymous user
@@ -205,7 +205,7 @@ def test_request_entry_public_room(settings):
assert not lobby_keys assert not lobby_keys
with ( with (
mock.patch.object(LobbyService, "notify_participants", return_value=None), mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object( mock.patch.object(
LobbyService, "_get_or_create_participant_id", return_value="123" LobbyService, "_get_or_create_participant_id", return_value="123"
), ),
@@ -255,7 +255,7 @@ def test_request_entry_authenticated_user_public_room(settings):
assert not lobby_keys assert not lobby_keys
with ( with (
mock.patch.object(LobbyService, "notify_participants", return_value=None), mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object( mock.patch.object(
LobbyService, LobbyService,
"_get_or_create_participant_id", "_get_or_create_participant_id",
@@ -315,7 +315,7 @@ def test_request_entry_waiting_participant_public_room(settings):
client.cookies.load({"mocked-cookie": "2f7f162fe7d1421b90e702bfbfbf8def"}) client.cookies.load({"mocked-cookie": "2f7f162fe7d1421b90e702bfbfbf8def"})
with ( with (
mock.patch.object(LobbyService, "notify_participants", return_value=None), mock.patch.object(utils, "notify_participants", return_value=None),
mock.patch.object( mock.patch.object(
utils, "generate_livekit_config", return_value={"token": "test-token"} utils, "generate_livekit_config", return_value={"token": "test-token"}
), ),

View File

@@ -5,7 +5,6 @@ Test lobby service.
# pylint: disable=W0621,W0613, W0212, R0913 # pylint: disable=W0621,W0613, W0212, R0913
# ruff: noqa: PLR0913 # ruff: noqa: PLR0913
import json
import uuid import uuid
from unittest import mock from unittest import mock
@@ -14,18 +13,17 @@ from django.core.cache import cache
from django.http import HttpResponse from django.http import HttpResponse
import pytest import pytest
from livekit.api import TwirpError
from core.factories import RoomFactory from core.factories import RoomFactory
from core.models import RoomAccessLevel from core.models import RoomAccessLevel
from core.services.lobby import ( from core.services.lobby import (
LobbyNotificationError,
LobbyParticipant, LobbyParticipant,
LobbyParticipantNotFound, LobbyParticipantNotFound,
LobbyParticipantParsingError, LobbyParticipantParsingError,
LobbyParticipantStatus, LobbyParticipantStatus,
LobbyService, LobbyService,
) )
from core.utils import NotificationError
pytestmark = pytest.mark.django_db pytestmark = pytest.mark.django_db
@@ -414,7 +412,7 @@ def test_refresh_waiting_status(mock_cache, lobby_service, participant_id):
# pylint: disable=R0917 # pylint: disable=R0917
@mock.patch("core.services.lobby.cache") @mock.patch("core.services.lobby.cache")
@mock.patch("core.utils.generate_color") @mock.patch("core.utils.generate_color")
@mock.patch("core.services.lobby.LobbyService.notify_participants") @mock.patch("core.utils.notify_participants")
def test_enter_success( def test_enter_success(
mock_notify, mock_notify,
mock_generate_color, mock_generate_color,
@@ -443,13 +441,15 @@ def test_enter_success(
participant.to_dict(), participant.to_dict(),
timeout=settings.LOBBY_WAITING_TIMEOUT, timeout=settings.LOBBY_WAITING_TIMEOUT,
) )
mock_notify.assert_called_once_with(room_id=room.id) mock_notify.assert_called_once_with(
room_name=room.id, notification_data={"type": "participantWaiting"}
)
# pylint: disable=R0917 # pylint: disable=R0917
@mock.patch("core.services.lobby.cache") @mock.patch("core.services.lobby.cache")
@mock.patch("core.utils.generate_color") @mock.patch("core.utils.generate_color")
@mock.patch("core.services.lobby.LobbyService.notify_participants") @mock.patch("core.utils.notify_participants")
def test_enter_with_notification_error( def test_enter_with_notification_error(
mock_notify, mock_notify,
mock_generate_color, mock_generate_color,
@@ -460,7 +460,7 @@ def test_enter_with_notification_error(
): ):
"""Test participant entry with notification error.""" """Test participant entry with notification error."""
mock_generate_color.return_value = "#123456" mock_generate_color.return_value = "#123456"
mock_notify.side_effect = LobbyNotificationError("Error notifying") mock_notify.side_effect = NotificationError("Error notifying")
lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key") lobby_service._get_cache_key = mock.Mock(return_value="mocked_cache_key")
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED) room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
@@ -776,116 +776,6 @@ def test_update_participant_status_success(mock_cache, lobby_service, participan
lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id) lobby_service._get_cache_key.assert_called_once_with(room.id, participant_id)
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_success_no_room(mock_create_livekit_client, lobby_service):
"""Test the notify_participants method when the LiveKit room doesn't exist yet."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
# Set up the mock LiveKitAPI and its behavior
mock_api_instance = mock.Mock()
mock_api_instance.room = mock.Mock()
mock_api_instance.room.send_data = mock.AsyncMock()
# Create a proper response object with an empty rooms list
class MockResponse:
"""LiveKit API response mock with empty rooms list."""
rooms = []
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api_instance
# Act
lobby_service.notify_participants(room.id)
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# Verify the send_data method was not called since no room exists
mock_api_instance.room.send_data.assert_not_called()
# Verify the connection was properly closed
mock_api_instance.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_success(mock_create_livekit_client, lobby_service):
"""Test successful participant notification."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
# Set up the mock LiveKitAPI and its behavior
mock_api_instance = mock.Mock()
mock_api_instance.room = mock.Mock()
mock_api_instance.room.send_data = mock.AsyncMock()
class MockResponse:
"""LiveKit API response mock with non-empty rooms list."""
rooms = ["room-1"]
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api_instance
# Call the function
lobby_service.notify_participants(room.id)
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# Verify the send_data method was called
mock_api_instance.room.send_data.assert_called_once()
send_data_request = mock_api_instance.room.send_data.call_args[0][0]
assert send_data_request.room == str(room.id)
assert (
json.loads(send_data_request.data.decode("utf-8"))["type"]
== settings.LOBBY_NOTIFICATION_TYPE
)
assert send_data_request.kind == 0 # RELIABLE mode in Livekit protocol
# Verify aclose was called
mock_api_instance.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_error(mock_create_livekit_client, lobby_service):
"""Test participant notification with API error."""
room = RoomFactory(access_level=RoomAccessLevel.RESTRICTED)
# Set up the mock LiveKitAPI and its behavior
mock_api_instance = mock.Mock()
mock_api_instance.room = mock.Mock()
mock_api_instance.room.send_data = mock.AsyncMock(
side_effect=TwirpError(msg="test error", code=123, status=123)
)
class MockResponse:
"""LiveKit API response mock with non-empty rooms list."""
rooms = ["room-1"]
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api_instance
# Call the function and expect an exception
with pytest.raises(
LobbyNotificationError, match="Failed to notify room participants"
):
lobby_service.notify_participants(room.id)
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# Verify send_data was called
mock_api_instance.room.send_data.assert_called_once()
# Verify aclose was still called after the exception
mock_api_instance.aclose.assert_called_once()
def test_clear_room_cache(settings, lobby_service): def test_clear_room_cache(settings, lobby_service):
"""Test clearing room cache actually removes entries from cache.""" """Test clearing room cache actually removes entries from cache."""

View File

@@ -2,9 +2,13 @@
Test utils functions Test utils functions
""" """
import json
from unittest import mock from unittest import mock
from core.utils import create_livekit_client import pytest
from livekit.api import TwirpError
from core.utils import NotificationError, create_livekit_client, notify_participants
@mock.patch("asyncio.get_running_loop") @mock.patch("asyncio.get_running_loop")
@@ -60,3 +64,105 @@ def test_create_livekit_client_custom_configuration(
create_livekit_client(custom_configuration) create_livekit_client(custom_configuration)
mock_livekit_api.assert_called_once_with(**custom_configuration, session=None) mock_livekit_api.assert_called_once_with(**custom_configuration, session=None)
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_error(mock_create_livekit_client):
"""Test participant notification with API error."""
# Set up the mock LiveKitAPI and its behavior
mock_api_instance = mock.Mock()
mock_api_instance.room = mock.Mock()
mock_api_instance.room.send_data = mock.AsyncMock(
side_effect=TwirpError(msg="test error", code=123, status=123)
)
class MockResponse:
"""LiveKit API response mock with non-empty rooms list."""
rooms = ["room-1"]
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api_instance
# Call the function and expect an exception
with pytest.raises(NotificationError, match="Failed to notify room participants"):
notify_participants(room_name="room-number-1", notification_data={"foo": "foo"})
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# Verify send_data was called
mock_api_instance.room.send_data.assert_called_once()
# Verify aclose was still called after the exception
mock_api_instance.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_success_no_room(mock_create_livekit_client):
"""Test the notify_participants function when the LiveKit room doesn't exist."""
# Set up the mock LiveKitAPI and its behavior
mock_api_instance = mock.Mock()
mock_api_instance.room = mock.Mock()
mock_api_instance.room.send_data = mock.AsyncMock()
# Create a proper response object with an empty rooms list
class MockResponse:
"""LiveKit API response mock with empty rooms list."""
rooms = []
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api_instance
notify_participants(room_name="room-number-1", notification_data={"foo": "foo"})
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# Verify the send_data method was not called since no room exists
mock_api_instance.room.send_data.assert_not_called()
# Verify the connection was properly closed
mock_api_instance.aclose.assert_called_once()
@mock.patch("core.utils.create_livekit_client")
def test_notify_participants_success(mock_create_livekit_client):
"""Test successful participant notification."""
# Set up the mock LiveKitAPI and its behavior
mock_api_instance = mock.Mock()
mock_api_instance.room = mock.Mock()
mock_api_instance.room.send_data = mock.AsyncMock()
class MockResponse:
"""LiveKit API response mock with non-empty rooms list."""
rooms = ["room-1"]
mock_api_instance.room.list_rooms = mock.AsyncMock(return_value=MockResponse())
mock_api_instance.aclose = mock.AsyncMock()
mock_create_livekit_client.return_value = mock_api_instance
# Call the function
notify_participants(room_name="room-number-1", notification_data={"foo": "foo"})
# Verify that the service checked for existing rooms
mock_api_instance.room.list_rooms.assert_called_once()
# Verify the send_data method was called
mock_api_instance.room.send_data.assert_called_once()
send_data_request = mock_api_instance.room.send_data.call_args[0][0]
assert send_data_request.room == "room-number-1"
assert json.loads(send_data_request.data.decode("utf-8")) == {"foo": "foo"}
assert send_data_request.kind == 0 # RELIABLE mode in Livekit protocol
# Verify aclose was called
mock_api_instance.aclose.assert_called_once()

View File

@@ -15,7 +15,15 @@ from django.core.files.storage import default_storage
import aiohttp import aiohttp
import botocore import botocore
from livekit.api import AccessToken, LiveKitAPI, VideoGrants from asgiref.sync import async_to_sync
from livekit.api import ( # pylint: disable=E0611
AccessToken,
ListRoomsRequest,
LiveKitAPI,
SendDataRequest,
TwirpError,
VideoGrants,
)
def generate_color(identity: str) -> str: def generate_color(identity: str) -> str:
@@ -158,3 +166,37 @@ def create_livekit_client(custom_configuration=None):
configuration = custom_configuration or settings.LIVEKIT_CONFIGURATION configuration = custom_configuration or settings.LIVEKIT_CONFIGURATION
return LiveKitAPI(session=custom_session, **configuration) return LiveKitAPI(session=custom_session, **configuration)
class NotificationError(Exception):
"""Notification delivery to room participants fails."""
@async_to_sync
async def notify_participants(room_name: str, notification_data: dict):
"""Send notification data to all participants in a LiveKit room."""
lkapi = create_livekit_client()
try:
room_response = await lkapi.room.list_rooms(
ListRoomsRequest(
names=[room_name],
)
)
# Check if the room exists
if not room_response.rooms:
return
await lkapi.room.send_data(
SendDataRequest(
room=room_name,
data=json.dumps(notification_data).encode("utf-8"),
kind="RELIABLE",
)
)
except TwirpError as e:
raise NotificationError("Failed to notify room participants") from e
finally:
await lkapi.aclose()