From 5bac1668fe7c91861d9293a7c399ceffa51ea1ed Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Fri, 15 May 2026 17:47:43 +0200 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F(fullstack)=20simplify=20sour?= =?UTF-8?q?ce=20serialization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplify source serialization and validation logic while improving type safety around room configuration handling. Introduce a dedicated TypeScript type matching the backend Pydantic model more precisely. Also harmonize track source casing between frontend and backend to remove redundant conversion logic and resolve #1282. --- src/backend/core/api/serializers.py | 15 +++---- .../test_api_rooms_participants_management.py | 44 ++++++++++++++++--- src/frontend/src/api/useConfig.ts | 4 +- .../src/features/rooms/api/ApiRoom.ts | 12 +++-- .../rooms/api/updateParticipantPermissions.ts | 4 +- .../livekit/hooks/usePublishSourcesManager.ts | 14 +++--- 6 files changed, 64 insertions(+), 29 deletions(-) diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index bd40c632..2801fd86 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -13,7 +13,7 @@ from django.core.exceptions import SuspiciousOperation from django.utils.translation import gettext_lazy as _ from django_pydantic_field.rest_framework import SchemaField -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_serializer from pydantic import ValidationError as PydanticValidationError from rest_framework import serializers from rest_framework.exceptions import PermissionDenied @@ -317,9 +317,7 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer): ) -RoomConfigurationTrackSource = Literal[ - "camera", "microphone", "screen_share", "screen_share_audio" -] +TrackSource = Literal["camera", "microphone", "screen_share", "screen_share_audio"] class RoomConfiguration(BaseModel): @@ -328,15 +326,12 @@ class RoomConfiguration(BaseModel): Unknown fields are rejected. """ - can_publish_sources: list[RoomConfigurationTrackSource] | None = None + can_publish_sources: list[TrackSource] | None = None everyone_can_mute: bool | None = None model_config = {"extra": "forbid"} -TrackSource = Literal["SCREEN_SHARE", "SCREEN_SHARE_AUDIO", "CAMERA", "MICROPHONE"] - - class ParticipantPermission(BaseModel): """Mirror the LiveKit ParticipantPermission protobuf. @@ -356,6 +351,10 @@ class ParticipantPermission(BaseModel): model_config = {"extra": "forbid"} + @field_serializer("can_publish_sources") + def _serialize_sources(self, sources: list[str]) -> list[str]: + return [s.upper() for s in sources] + class UpdateParticipantSerializer(BaseParticipantsManagementSerializer): """Validate participant update data.""" diff --git a/src/backend/core/tests/rooms/test_api_rooms_participants_management.py b/src/backend/core/tests/rooms/test_api_rooms_participants_management.py index bc72e135..0a9d1e35 100644 --- a/src/backend/core/tests/rooms/test_api_rooms_participants_management.py +++ b/src/backend/core/tests/rooms/test_api_rooms_participants_management.py @@ -2,7 +2,7 @@ Test rooms API endpoints in the Meet core app: participants management. """ -# pylint: disable=redefined-outer-name,unused-argument,protected-access +# pylint: disable=redefined-outer-name,unused-argument,protected-access,no-name-in-module import random from unittest import mock @@ -13,7 +13,7 @@ from django.core.exceptions import SuspiciousOperation from django.urls import reverse import pytest -from livekit.api import TwirpError +from livekit.api import TwirpError, UpdateParticipantRequest from rest_framework import status from rest_framework.test import APIClient @@ -385,8 +385,8 @@ def test_update_participant_success(mock_livekit_client): "can_publish": True, "can_publish_data": True, "can_publish_sources": [ - "CAMERA", - "MICROPHONE", + "camera", + "microphone", ], "can_update_metadata": True, "can_subscribe_metrics": True, @@ -413,8 +413,8 @@ def test_update_participant_success(mock_livekit_client): {"can_publish_data": True}, { "can_publish_sources": [ - "CAMERA", - "MICROPHONE", + "camera", + "microphone", ] }, {"can_update_metadata": True}, @@ -445,9 +445,41 @@ def test_update_participant_permission_fields_are_optional( assert response.data == {"status": "success"} mock_livekit_client.room.update_participant.assert_called_once() + + (request_arg,), _ = mock_livekit_client.room.update_participant.call_args + assert isinstance(request_arg, UpdateParticipantRequest) + mock_livekit_client.aclose.assert_called_once() +def test_update_participant_permission_fields_invalid_case(mock_livekit_client): + """Should raise bad request when can_publish_sources is uppercase.""" + client = APIClient() + room = RoomFactory() + user = UserFactory() + UserResourceAccessFactory( + resource=room, user=user, role=random.choice(["administrator", "owner"]) + ) + client.force_authenticate(user=user) + + payload = { + "participant_identity": str(uuid4()), + "permission": { + "can_publish_sources": [ + "CAMERA", + "microphone", + ] + }, + } + + url = reverse("rooms-update-participant", kwargs={"pk": room.id}) + response = client.post(url, payload, format="json") + + assert response.status_code == status.HTTP_400_BAD_REQUEST + mock_livekit_client.room.update_participant.assert_not_called() + mock_livekit_client.aclose.assert_not_called() + + @pytest.mark.parametrize( "value,permission_key", [ diff --git a/src/frontend/src/api/useConfig.ts b/src/frontend/src/api/useConfig.ts index e2a255e9..c8b3fc16 100644 --- a/src/frontend/src/api/useConfig.ts +++ b/src/frontend/src/api/useConfig.ts @@ -2,6 +2,8 @@ import { fetchApi } from './fetchApi' import { keys } from './queryKeys' import { useQuery } from '@tanstack/react-query' import { RecordingMode } from '@/features/recording' +import { Track } from 'livekit-client' +import Source = Track.Source export interface ApiConfig { analytics?: { @@ -50,7 +52,7 @@ export interface ApiConfig { url: string force_wss_protocol: boolean enable_firefox_proxy_workaround: boolean - default_sources: string[] + default_sources: Source[] } transcription_destination?: string } diff --git a/src/frontend/src/features/rooms/api/ApiRoom.ts b/src/frontend/src/features/rooms/api/ApiRoom.ts index 061fa0ef..777c15c8 100644 --- a/src/frontend/src/features/rooms/api/ApiRoom.ts +++ b/src/frontend/src/features/rooms/api/ApiRoom.ts @@ -1,3 +1,6 @@ +import { Track } from 'livekit-client' +import Source = Track.Source + export type ApiLiveKit = { url: string room: string @@ -10,6 +13,11 @@ export enum ApiAccessLevel { RESTRICTED = 'restricted', } +export type RoomConfiguration = { + can_publish_sources?: Source[] | null + everyone_can_mute?: boolean | null +} + export type ApiRoom = { id: string name: string @@ -18,7 +26,5 @@ export type ApiRoom = { is_administrable: boolean access_level: ApiAccessLevel livekit?: ApiLiveKit - configuration?: { - [key: string]: string | number | boolean | string[] - } + configuration?: RoomConfiguration } diff --git a/src/frontend/src/features/rooms/api/updateParticipantPermissions.ts b/src/frontend/src/features/rooms/api/updateParticipantPermissions.ts index 8db03289..8f33adfb 100644 --- a/src/frontend/src/features/rooms/api/updateParticipantPermissions.ts +++ b/src/frontend/src/features/rooms/api/updateParticipantPermissions.ts @@ -8,7 +8,7 @@ export const useParticipantPermissions = () => { const updateParticipantPermissions = async ( participant: Participant, - sources: Array + sources: Source[] ) => { if (!data?.id) { throw new Error('Room id is not available') @@ -20,7 +20,7 @@ export const useParticipantPermissions = () => { can_update_metadata: participant.permissions?.canUpdateMetadata, can_subscribe_metrics: participant.permissions?.canSubscribeMetrics, can_publish: sources.length > 0, - can_publish_sources: sources.map((source) => source.toUpperCase()), + can_publish_sources: sources, } try { diff --git a/src/frontend/src/features/rooms/livekit/hooks/usePublishSourcesManager.ts b/src/frontend/src/features/rooms/livekit/hooks/usePublishSourcesManager.ts index 3ce92da8..6d2ee4d2 100644 --- a/src/frontend/src/features/rooms/livekit/hooks/usePublishSourcesManager.ts +++ b/src/frontend/src/features/rooms/livekit/hooks/usePublishSourcesManager.ts @@ -39,10 +39,6 @@ export const usePublishSourcesManager = () => { const { notifyParticipants } = useNotifyParticipants() - const defaultSources = configData?.livekit?.default_sources?.map((source) => { - return source as Source - }) - // The name can be misleading—use the slug instead to ensure the correct React Query key is updated. const roomId = data?.slug @@ -54,16 +50,16 @@ export const usePublishSourcesManager = () => { ) const currentSources = useMemo(() => { + const defaultSources = configData?.livekit?.default_sources ?? [] + if ( configuration?.can_publish_sources == undefined || !Array.isArray(configuration?.can_publish_sources) ) { return defaultSources } - return configuration.can_publish_sources.map((source) => { - return source as Source - }) - }, [defaultSources, configuration?.can_publish_sources]) + return configuration.can_publish_sources + }, [configData, configuration?.can_publish_sources]) const updateSource = useCallback( async (sources: Source[], enabled: boolean) => { @@ -78,7 +74,7 @@ export const usePublishSourcesManager = () => { const newConfiguration = { ...configuration, - can_publish_sources: newSources as string[], + can_publish_sources: newSources, } const room = await patchRoom({