♻️(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_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."""
@@ -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",
[
+3 -1
View File
@@ -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
}
@@ -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
}
@@ -8,7 +8,7 @@ export const useParticipantPermissions = () => {
const updateParticipantPermissions = async (
participant: Participant,
sources: Array<Source>
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 {
@@ -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({