♻️(fullstack) simplify source serialization

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.
This commit is contained in:
lebaudantoine
2026-05-15 17:47:43 +02:00
committed by aleb_the_flash
parent 5a7a0da923
commit 5bac1668fe
6 changed files with 64 additions and 29 deletions
+7 -8
View File
@@ -13,7 +13,7 @@ from django.core.exceptions import SuspiciousOperation
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_pydantic_field.rest_framework import SchemaField 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 pydantic import ValidationError as PydanticValidationError
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
@@ -317,9 +317,7 @@ class MuteParticipantSerializer(BaseParticipantsManagementSerializer):
) )
RoomConfigurationTrackSource = Literal[ TrackSource = Literal["camera", "microphone", "screen_share", "screen_share_audio"]
"camera", "microphone", "screen_share", "screen_share_audio"
]
class RoomConfiguration(BaseModel): class RoomConfiguration(BaseModel):
@@ -328,15 +326,12 @@ class RoomConfiguration(BaseModel):
Unknown fields are rejected. Unknown fields are rejected.
""" """
can_publish_sources: list[RoomConfigurationTrackSource] | None = None can_publish_sources: list[TrackSource] | None = None
everyone_can_mute: bool | None = None everyone_can_mute: bool | None = None
model_config = {"extra": "forbid"} model_config = {"extra": "forbid"}
TrackSource = Literal["SCREEN_SHARE", "SCREEN_SHARE_AUDIO", "CAMERA", "MICROPHONE"]
class ParticipantPermission(BaseModel): class ParticipantPermission(BaseModel):
"""Mirror the LiveKit ParticipantPermission protobuf. """Mirror the LiveKit ParticipantPermission protobuf.
@@ -356,6 +351,10 @@ class ParticipantPermission(BaseModel):
model_config = {"extra": "forbid"} 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): class UpdateParticipantSerializer(BaseParticipantsManagementSerializer):
"""Validate participant update data.""" """Validate participant update data."""
@@ -2,7 +2,7 @@
Test rooms API endpoints in the Meet core app: participants management. 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 import random
from unittest import mock from unittest import mock
@@ -13,7 +13,7 @@ from django.core.exceptions import SuspiciousOperation
from django.urls import reverse from django.urls import reverse
import pytest import pytest
from livekit.api import TwirpError from livekit.api import TwirpError, UpdateParticipantRequest
from rest_framework import status from rest_framework import status
from rest_framework.test import APIClient from rest_framework.test import APIClient
@@ -385,8 +385,8 @@ def test_update_participant_success(mock_livekit_client):
"can_publish": True, "can_publish": True,
"can_publish_data": True, "can_publish_data": True,
"can_publish_sources": [ "can_publish_sources": [
"CAMERA", "camera",
"MICROPHONE", "microphone",
], ],
"can_update_metadata": True, "can_update_metadata": True,
"can_subscribe_metrics": True, "can_subscribe_metrics": True,
@@ -413,8 +413,8 @@ def test_update_participant_success(mock_livekit_client):
{"can_publish_data": True}, {"can_publish_data": True},
{ {
"can_publish_sources": [ "can_publish_sources": [
"CAMERA", "camera",
"MICROPHONE", "microphone",
] ]
}, },
{"can_update_metadata": True}, {"can_update_metadata": True},
@@ -445,9 +445,41 @@ def test_update_participant_permission_fields_are_optional(
assert response.data == {"status": "success"} assert response.data == {"status": "success"}
mock_livekit_client.room.update_participant.assert_called_once() 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() 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( @pytest.mark.parametrize(
"value,permission_key", "value,permission_key",
[ [
+3 -1
View File
@@ -2,6 +2,8 @@ import { fetchApi } from './fetchApi'
import { keys } from './queryKeys' import { keys } from './queryKeys'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { RecordingMode } from '@/features/recording' import { RecordingMode } from '@/features/recording'
import { Track } from 'livekit-client'
import Source = Track.Source
export interface ApiConfig { export interface ApiConfig {
analytics?: { analytics?: {
@@ -50,7 +52,7 @@ export interface ApiConfig {
url: string url: string
force_wss_protocol: boolean force_wss_protocol: boolean
enable_firefox_proxy_workaround: boolean enable_firefox_proxy_workaround: boolean
default_sources: string[] default_sources: Source[]
} }
transcription_destination?: string transcription_destination?: string
} }
@@ -1,3 +1,6 @@
import { Track } from 'livekit-client'
import Source = Track.Source
export type ApiLiveKit = { export type ApiLiveKit = {
url: string url: string
room: string room: string
@@ -10,6 +13,11 @@ export enum ApiAccessLevel {
RESTRICTED = 'restricted', RESTRICTED = 'restricted',
} }
export type RoomConfiguration = {
can_publish_sources?: Source[] | null
everyone_can_mute?: boolean | null
}
export type ApiRoom = { export type ApiRoom = {
id: string id: string
name: string name: string
@@ -18,7 +26,5 @@ export type ApiRoom = {
is_administrable: boolean is_administrable: boolean
access_level: ApiAccessLevel access_level: ApiAccessLevel
livekit?: ApiLiveKit livekit?: ApiLiveKit
configuration?: { configuration?: RoomConfiguration
[key: string]: string | number | boolean | string[]
}
} }
@@ -8,7 +8,7 @@ export const useParticipantPermissions = () => {
const updateParticipantPermissions = async ( const updateParticipantPermissions = async (
participant: Participant, participant: Participant,
sources: Array<Source> sources: Source[]
) => { ) => {
if (!data?.id) { if (!data?.id) {
throw new Error('Room id is not available') throw new Error('Room id is not available')
@@ -20,7 +20,7 @@ export const useParticipantPermissions = () => {
can_update_metadata: participant.permissions?.canUpdateMetadata, can_update_metadata: participant.permissions?.canUpdateMetadata,
can_subscribe_metrics: participant.permissions?.canSubscribeMetrics, can_subscribe_metrics: participant.permissions?.canSubscribeMetrics,
can_publish: sources.length > 0, can_publish: sources.length > 0,
can_publish_sources: sources.map((source) => source.toUpperCase()), can_publish_sources: sources,
} }
try { try {
@@ -39,10 +39,6 @@ export const usePublishSourcesManager = () => {
const { notifyParticipants } = useNotifyParticipants() 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. // The name can be misleading—use the slug instead to ensure the correct React Query key is updated.
const roomId = data?.slug const roomId = data?.slug
@@ -54,16 +50,16 @@ export const usePublishSourcesManager = () => {
) )
const currentSources = useMemo(() => { const currentSources = useMemo(() => {
const defaultSources = configData?.livekit?.default_sources ?? []
if ( if (
configuration?.can_publish_sources == undefined || configuration?.can_publish_sources == undefined ||
!Array.isArray(configuration?.can_publish_sources) !Array.isArray(configuration?.can_publish_sources)
) { ) {
return defaultSources return defaultSources
} }
return configuration.can_publish_sources.map((source) => { return configuration.can_publish_sources
return source as Source }, [configData, configuration?.can_publish_sources])
})
}, [defaultSources, configuration?.can_publish_sources])
const updateSource = useCallback( const updateSource = useCallback(
async (sources: Source[], enabled: boolean) => { async (sources: Source[], enabled: boolean) => {
@@ -78,7 +74,7 @@ export const usePublishSourcesManager = () => {
const newConfiguration = { const newConfiguration = {
...configuration, ...configuration,
can_publish_sources: newSources as string[], can_publish_sources: newSources,
} }
const room = await patchRoom({ const room = await patchRoom({