Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 39 additions & 3 deletions app/common/livekit_ext.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
CreateRoomRequest,
ListParticipantsRequest,
ListRoomsRequest,
UpdateParticipantRequest,
UpdateRoomMetadataRequest,
)


Expand Down Expand Up @@ -50,23 +52,57 @@ def api(self) -> LiveKitAPI:
def room(self) -> RoomService:
return self.api.room

def generate_access_token(self, identity: str, name: str, room_name: str) -> str:
def generate_access_token(
self,
room_name: str,
identity: str,
name: str,
metadata: str = "",
) -> str:
return (
AccessToken(self.api_key, self.api_secret)
.with_identity(identity=identity)
.with_name(name=name)
.with_grants(VideoGrants(room_join=True, room=room_name))
.with_metadata(metadata=metadata)
).to_jwt()

async def list_rooms(self, room_names: list[str]) -> Iterator[Room]:
response = await self.room.list_rooms(ListRoomsRequest(names=room_names))
return (room for room in response.rooms)

async def find_or_create_room(self, room_name: str) -> Room:
return await self.room.create_room(CreateRoomRequest(name=room_name))
async def find_or_create_room(self, room_name: str, metadata: str) -> Room:
return await self.room.create_room(
CreateRoomRequest(
name=room_name,
metadata=metadata,
)
)

async def update_room_metadata(self, room_name: str, metadata: str) -> Room:
return await self.room.update_room_metadata(
UpdateRoomMetadataRequest(
room=room_name,
metadata=metadata,
)
)

async def list_room_participants(self, room_name: str) -> Iterator[ParticipantInfo]:
response = await self.room.list_participants(
ListParticipantsRequest(room=room_name)
)
return (participant for participant in response.participants)

async def update_participant_metadata(
self,
room_name: str,
identity: str,
metadata: str,
) -> ParticipantInfo:
return await self.room.update_participant(
UpdateParticipantRequest(
room=room_name,
identity=identity,
metadata=metadata,
)
)
76 changes: 74 additions & 2 deletions app/conferences/routes/classroom_conferences_rst.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

from app.common.config_bdg import classrooms_bridge, notifications_bridge
from app.common.dependencies.authorization_dep import AuthorizationData
from app.common.fastapi_ext import APIRouterExt
from app.common.fastapi_ext import APIRouterExt, Responses
from app.common.schemas.notifications_sch import (
ClassroomNotificationPayloadSchema,
NotificationInputSchema,
Expand All @@ -15,7 +15,11 @@
LivekitRoomByClassroomID,
LivekitRoomNameByClassroomID,
)
from app.conferences.schemas.conferences_sch import ConferenceParticipantSchema
from app.conferences.schemas.conferences_sch import (
ConferenceParticipantSchema,
ParticipantMetadataSchema,
RoomMetadataSchema,
)
from app.conferences.services import conferences_svc

router = APIRouterExt(tags=["classroom conferences"])
Expand Down Expand Up @@ -50,6 +54,21 @@ async def reactivate_classroom_conference(
)


@router.put(
path="/roles/tutor/classrooms/{classroom_id}/conference/metadata/",
status_code=status.HTTP_204_NO_CONTENT,
summary="Update metadata of a conference in a classroom by id",
)
async def update_classroom_conference_metadata(
livekit_room: LivekitRoomByClassroomID,
metadata: RoomMetadataSchema,
) -> None:
await conferences_svc.update_room_metadata(
livekit_room=livekit_room,
metadata=metadata,
)


@router.post(
path="/roles/tutor/classrooms/{classroom_id}/conference/access-tokens/",
summary="Create a tutor access token for a conference in a classroom by id",
Expand Down Expand Up @@ -82,3 +101,56 @@ async def list_classroom_conference_participants(
return await conferences_svc.list_room_participants(
livekit_room_name=livekit_room.name
)


class ConferenceParticipantResponses(Responses):
CONFERENCE_PARTICIPANT_NOT_FOUND = (
status.HTTP_404_NOT_FOUND,
"Conference participant not found",
)


@router.put(
path="/roles/tutor/classrooms/{classroom_id}/conference/participants/current/metadata/",
status_code=status.HTTP_204_NO_CONTENT,
responses=ConferenceParticipantResponses.responses(),
summary="Update metadata of the current participant of a conference in a classroom by id",
)
@router.put( # TODO split this if metadata will become different
path="/roles/student/classrooms/{classroom_id}/conference/participants/current/metadata/",
status_code=status.HTTP_204_NO_CONTENT,
responses=ConferenceParticipantResponses.responses(),
summary="Update metadata of the current participant of a conference in a classroom by id",
)
async def update_classroom_conference_current_participant_metadata(
auth_data: AuthorizationData,
livekit_room: LivekitRoomByClassroomID,
metadata: ParticipantMetadataSchema,
) -> None:
participant_info = await conferences_svc.update_participant_metadata(
livekit_room=livekit_room,
user_id=auth_data.user_id,
metadata=metadata,
)
if participant_info is None:
raise ConferenceParticipantResponses.CONFERENCE_PARTICIPANT_NOT_FOUND


@router.put(
path="/roles/tutor/classrooms/{classroom_id}/conference/participants/{participant_user_id}/metadata/",
status_code=status.HTTP_204_NO_CONTENT,
responses=ConferenceParticipantResponses.responses(),
summary="Update metadata of a participant of a conference in a classroom by ids",
)
async def update_classroom_conference_other_participant_metadata(
livekit_room: LivekitRoomByClassroomID,
participant_user_id: int,
metadata: ParticipantMetadataSchema,
) -> None:
participant_info = await conferences_svc.update_participant_metadata(
livekit_room=livekit_room,
user_id=participant_user_id,
metadata=metadata,
)
if participant_info is None:
raise ConferenceParticipantResponses.CONFERENCE_PARTICIPANT_NOT_FOUND
13 changes: 13 additions & 0 deletions app/conferences/schemas/conferences_sch.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
from pydantic import BaseModel


class BaseMetadataSchema(BaseModel):
def model_dump_metadata_json(self) -> str:
return self.model_dump_json(exclude_none=True)


class RoomMetadataSchema(BaseMetadataSchema):
active_material_id: int | None = None


class ParticipantMetadataSchema(BaseMetadataSchema):
is_hand_raised: bool = False


class ConferenceParticipantSchema(BaseModel):
user_id: int
display_name: str
45 changes: 41 additions & 4 deletions app/conferences/services/conferences_svc.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
from livekit.protocol.models import Room
from livekit.api import TwirpError
from livekit.protocol.models import ParticipantInfo, Room
from starlette import status

from app.common.config import livekit
from app.common.config_bdg import users_internal_bridge
from app.conferences.schemas.conferences_sch import ConferenceParticipantSchema
from app.conferences.schemas.conferences_sch import (
ConferenceParticipantSchema,
ParticipantMetadataSchema,
RoomMetadataSchema,
)


async def reactivate_room(livekit_room_name: str) -> Room:
return await livekit.find_or_create_room(room_name=livekit_room_name)
return await livekit.find_or_create_room(
room_name=livekit_room_name,
metadata=RoomMetadataSchema().model_dump_metadata_json(),
)


async def find_room_by_name(livekit_room_name: str) -> Room | None:
Expand All @@ -16,13 +25,24 @@ async def find_room_by_name(livekit_room_name: str) -> Room | None:
return None


async def update_room_metadata(
livekit_room: Room,
metadata: RoomMetadataSchema,
) -> Room:
return await livekit.update_room_metadata(
room_name=livekit_room.name,
metadata=metadata.model_dump_metadata_json(),
)


async def generate_access_token(livekit_room: Room, user_id: int) -> str:
current_user_profile = await users_internal_bridge.retrieve_user(user_id=user_id)

return livekit.generate_access_token(
room_name=livekit_room.name,
identity=str(user_id),
name=current_user_profile.display_name,
room_name=livekit_room.name,
metadata=ParticipantMetadataSchema().model_dump_metadata_json(),
)


Expand All @@ -38,3 +58,20 @@ async def list_room_participants(
room_name=livekit_room_name
)
]


async def update_participant_metadata(
livekit_room: Room,
user_id: int,
metadata: ParticipantMetadataSchema,
) -> ParticipantInfo | None:
try:
return await livekit.update_participant_metadata(
room_name=livekit_room.name,
identity=str(user_id),
metadata=metadata.model_dump_metadata_json(),
)
except TwirpError as e:
if e.status == status.HTTP_404_NOT_FOUND:
return None
raise e # pragma: no cover # undocumented exceptions from livekit
8 changes: 4 additions & 4 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ dlint = "0.14.0"
[tool.poetry.group.tests.dependencies]
pytest = "^8.3.5"
pytest-cov = "^6.1.1"
pytest-lazy-fixtures = "^1.3.4"
pytest-lazy-fixtures = "^1.4.0"
pydantic-marshals = {extras = ["assert-contains"], version = "0.3.18"}
freezegun = "^1.5.1"
respx = "^0.22.0"
Expand Down
21 changes: 16 additions & 5 deletions tests/common/livekit_testing.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,31 @@

import pytest
from google.protobuf.message import Message
from livekit.api.twirp_client import TwirpClient
from livekit.api.twirp_client import TwirpClient, TwirpError

from tests.common.mock_stack import MockStack


class LiveKitRouteMock:
def __init__(self, response_data: Message) -> None:
def __init__(
self,
response_data: Message | None = None,
side_effect: TwirpError | None = None,
) -> None:
self.request_data: Message | None = None
self.response_data = response_data
self.side_effect = side_effect

def request(
self,
request_data: Message,
response_class: type[Message],
) -> Message:
assert self.request_data is None, "LiveKit mock has been called before"
assert isinstance(self.response_data, response_class)
self.request_data = request_data
if self.side_effect is not None:
raise self.side_effect
assert isinstance(self.response_data, response_class)
return self.response_data

def assert_requested_once_with(self, expected_data: Message) -> None:
Expand All @@ -33,9 +40,13 @@ def __init__(self) -> None:
self.route_mocks: dict[tuple[str, str], LiveKitRouteMock] = {}

def route(
self, service: str, method: str, response_data: Message
self,
service: str,
method: str,
response_data: Message | None = None,
side_effect: TwirpError | None = None,
) -> LiveKitRouteMock:
route_mock = LiveKitRouteMock(response_data)
route_mock = LiveKitRouteMock(response_data, side_effect)
self.route_mocks[(service, method)] = route_mock
return route_mock

Expand Down
16 changes: 15 additions & 1 deletion tests/conferences/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from starlette.testclient import TestClient

from app.common.dependencies.authorization_dep import ProxyAuthData
from app.conferences.schemas.conferences_sch import RoomMetadataSchema
from tests.common.types import PytestRequest
from tests.factories import ProxyAuthDataFactory

Expand All @@ -27,6 +28,16 @@ def outsider_user_id(outsider_auth_data: ProxyAuthData) -> int:
return outsider_auth_data.user_id


@pytest.fixture()
def other_auth_data() -> ProxyAuthData:
return ProxyAuthDataFactory.build()


@pytest.fixture()
def other_user_id(other_auth_data: ProxyAuthData) -> int:
return other_auth_data.user_id


ClassroomRoleType = Literal["tutor", "student"]


Expand All @@ -49,4 +60,7 @@ async def classroom_conference_room_name(classroom_id: int) -> str:

@pytest.fixture()
async def classroom_conference_room(classroom_conference_room_name: str) -> Room:
return Room(name=classroom_conference_room_name)
return Room(
name=classroom_conference_room_name,
metadata=RoomMetadataSchema().model_dump_metadata_json(),
)
Loading