From 2e509eff28537b6d616b608a6e9c4c33ef167470 Mon Sep 17 00:00:00 2001 From: lebaudantoine Date: Mon, 3 Aug 2026 11:32:27 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(backend)=20persist=20user=20preferenc?= =?UTF-8?q?es=20for=20room=20defaults=20on=20the=20User=20model?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add attributes on the User model to persist per-user preferences for the default link access level and the default room configuration. The frontend will let users update these preferences and then reuse them when generating a link through the webapp. Persisting them on the backend (rather than in application memory only) ensures the preferences survive across sessions and devices. --- src/backend/core/api/serializers.py | 21 +++- ...user_default_room_access_level_and_more.py | 23 ++++ src/backend/core/models.py | 19 +++ .../tests/rooms/test_api_rooms_retrieve.py | 4 + src/backend/core/tests/test_api_users.py | 2 + .../tests/test_api_users_room_preferences.py | 111 ++++++++++++++++++ src/frontend/src/features/auth/api/ApiUser.ts | 6 + 7 files changed, 185 insertions(+), 1 deletion(-) create mode 100644 src/backend/core/migrations/0022_user_default_room_access_level_and_more.py create mode 100644 src/backend/core/tests/test_api_users_room_preferences.py diff --git a/src/backend/core/api/serializers.py b/src/backend/core/api/serializers.py index 315f8900..9cc2babd 100644 --- a/src/backend/core/api/serializers.py +++ b/src/backend/core/api/serializers.py @@ -31,9 +31,28 @@ class UserSerializer(serializers.ModelSerializer): class Meta: model = models.User - fields = ["id", "email", "full_name", "short_name", "timezone", "language"] + fields = [ + "id", + "email", + "full_name", + "short_name", + "timezone", + "language", + "default_room_access_level", + "default_room_configuration", + ] read_only_fields = ["id", "email", "full_name", "short_name"] + def validate_default_room_configuration(self, value): + """Validate the default room configuration against the RoomConfiguration schema.""" + if value is None or value == {}: + return value + try: + RoomConfiguration.model_validate(value) + except PydanticValidationError as e: + raise serializers.ValidationError(e.errors()) from e + return value + class UserLightSerializer(serializers.ModelSerializer): """Serialize users with limited fields.""" diff --git a/src/backend/core/migrations/0022_user_default_room_access_level_and_more.py b/src/backend/core/migrations/0022_user_default_room_access_level_and_more.py new file mode 100644 index 00000000..b50396f6 --- /dev/null +++ b/src/backend/core/migrations/0022_user_default_room_access_level_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.14 on 2026-08-03 13:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0021_recording_external_process_id_alter_recording_status'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='default_room_access_level', + field=models.CharField(blank=True, choices=[('public', 'Public Access'), ('trusted', 'Trusted Access'), ('restricted', 'Restricted Access')], help_text='Access level applied by default to new rooms created by this user. When empty, the instance default is used.', max_length=50, null=True, verbose_name='default room access level'), + ), + migrations.AddField( + model_name='user', + name='default_room_configuration', + field=models.JSONField(blank=True, default=dict, help_text='Configurations applied by default to new rooms created by this user.', verbose_name='default room configuration'), + ), + ] diff --git a/src/backend/core/models.py b/src/backend/core/models.py index f856cdfc..e8fbb316 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -189,6 +189,25 @@ class User(AbstractBaseUser, BaseModel, auth_models.PermissionsMixin): default=settings.TIME_ZONE, help_text=_("The timezone in which the user wants to see times."), ) + default_room_access_level = models.CharField( + max_length=50, + choices=RoomAccessLevel.choices, + blank=True, + null=True, + verbose_name=_("default room access level"), + help_text=_( + "Access level applied by default to new rooms created by this user. " + "When empty, the instance default is used." + ), + ) + default_room_configuration = models.JSONField( + blank=True, + default=dict, + verbose_name=_("default room configuration"), + help_text=_( + "Configurations applied by default to new rooms created by this user." + ), + ) is_device = models.BooleanField( _("device"), default=False, diff --git a/src/backend/core/tests/rooms/test_api_rooms_retrieve.py b/src/backend/core/tests/rooms/test_api_rooms_retrieve.py index 4a2408b9..e99d2484 100644 --- a/src/backend/core/tests/rooms/test_api_rooms_retrieve.py +++ b/src/backend/core/tests/rooms/test_api_rooms_retrieve.py @@ -453,6 +453,8 @@ def test_api_rooms_retrieve_administrators( { "id": str(other_user_access.id), "user": { + "default_room_access_level": None, + "default_room_configuration": {}, "id": str(other_user_access.user.id), "email": other_user_access.user.email, "full_name": other_user_access.user.full_name, @@ -466,6 +468,8 @@ def test_api_rooms_retrieve_administrators( { "id": str(user_access.id), "user": { + "default_room_access_level": None, + "default_room_configuration": {}, "id": str(user_access.user.id), "email": user_access.user.email, "full_name": user_access.user.full_name, diff --git a/src/backend/core/tests/test_api_users.py b/src/backend/core/tests/test_api_users.py index 37fb32b7..46760b67 100644 --- a/src/backend/core/tests/test_api_users.py +++ b/src/backend/core/tests/test_api_users.py @@ -119,6 +119,8 @@ def test_api_users_retrieve_me_authenticated(settings): assert response.status_code == 200 assert response.json() == { + "default_room_access_level": None, + "default_room_configuration": {}, "id": str(user.id), "email": user.email, "full_name": user.full_name, diff --git a/src/backend/core/tests/test_api_users_room_preferences.py b/src/backend/core/tests/test_api_users_room_preferences.py new file mode 100644 index 00000000..f4ce8fcb --- /dev/null +++ b/src/backend/core/tests/test_api_users_room_preferences.py @@ -0,0 +1,111 @@ +""" +Test the default room preferences exposed on the users API. +""" + +import pytest +from rest_framework.test import APIClient + +from core import factories + +pytestmark = pytest.mark.django_db + + +def test_api_users_me_includes_default_room_preferences(): + """The "me" endpoint should expose the user's default room preferences.""" + user = factories.UserFactory( + default_room_access_level="restricted", + default_room_configuration={"everyone_can_mute": False}, + ) + + client = APIClient() + client.force_login(user) + + response = client.get("/api/v1.0/users/me/") + + assert response.status_code == 200 + content = response.json() + assert content["default_room_access_level"] == "restricted" + assert content["default_room_configuration"] == {"everyone_can_mute": False} + + +def test_api_users_update_default_room_preferences(): + """Users should be able to update their own default room preferences.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + response = client.patch( + f"/api/v1.0/users/{user.id!s}/", + { + "default_room_access_level": "trusted", + "default_room_configuration": { + "can_publish_sources": ["microphone", "camera"], + "everyone_can_mute": False, + }, + }, + format="json", + ) + + assert response.status_code == 200 + user.refresh_from_db() + assert user.default_room_access_level == "trusted" + assert user.default_room_configuration == { + "can_publish_sources": ["microphone", "camera"], + "everyone_can_mute": False, + } + + +def test_api_users_update_default_room_access_level_invalid(): + """An invalid access level should be rejected.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + response = client.patch( + f"/api/v1.0/users/{user.id!s}/", + {"default_room_access_level": "invalid"}, + format="json", + ) + + assert response.status_code == 400 + user.refresh_from_db() + assert user.default_room_access_level is None + + +def test_api_users_update_default_room_configuration_invalid(): + """An invalid room configuration should be rejected.""" + user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + response = client.patch( + f"/api/v1.0/users/{user.id!s}/", + {"default_room_configuration": {"unknown_field": True}}, + format="json", + ) + + assert response.status_code == 400 + user.refresh_from_db() + assert user.default_room_configuration == {} + + +def test_api_users_update_other_user_default_room_preferences_forbidden(): + """Users should not be able to update someone else's preferences.""" + user = factories.UserFactory() + other_user = factories.UserFactory() + + client = APIClient() + client.force_login(user) + + response = client.patch( + f"/api/v1.0/users/{other_user.id!s}/", + {"default_room_access_level": "restricted"}, + format="json", + ) + + assert response.status_code == 403 + other_user.refresh_from_db() + assert other_user.default_room_access_level is None diff --git a/src/frontend/src/features/auth/api/ApiUser.ts b/src/frontend/src/features/auth/api/ApiUser.ts index 5d1e16a1..f050d77c 100644 --- a/src/frontend/src/features/auth/api/ApiUser.ts +++ b/src/frontend/src/features/auth/api/ApiUser.ts @@ -1,4 +1,8 @@ import { BackendLanguage } from '@/utils/languages' +import type { + ApiAccessLevel, + RoomConfiguration, +} from '@/features/rooms/api/ApiRoom' export type ApiUser = { id: string @@ -7,4 +11,6 @@ export type ApiUser = { last_name: string language: BackendLanguage timezone: string + default_room_access_level?: ApiAccessLevel | null + default_room_configuration?: RoomConfiguration | null }