diff --git a/src/palace/manager/integration/settings.py b/src/palace/manager/integration/settings.py index 07dd1f6193..364aa8fa90 100644 --- a/src/palace/manager/integration/settings.py +++ b/src/palace/manager/integration/settings.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import typing from collections.abc import Callable, Mapping from dataclasses import dataclass @@ -52,6 +53,7 @@ class FormFieldType(Enum): ANNOUNCEMENTS = "announcements" COLOR = "color-picker" IMAGE = "image" + JSON = "json" FormOptionsType = Mapping[Enum | str | bool | None, str | LazyString] @@ -137,7 +139,10 @@ def to_dict( ) if default is not None and default is not PydanticUndefined: - form_entry["default"] = self.get_form_value(default) + if self.type == FormFieldType.JSON: + form_entry["default"] = default + else: + form_entry["default"] = self.get_form_value(default) if self.type.value is not None: form_entry["type"] = self.type.value if self.description is not None: @@ -200,6 +205,29 @@ def extra_args(cls, values: dict[str, Any]) -> dict[str, Any]: if isinstance(value, str) and value.strip() == "": values[key] = None + # The admin interface submits JSON field values as raw strings. + for name, field_info in cls.model_fields.items(): + fm = _get_form_metadata(field_info) + if fm is None or fm.type != FormFieldType.JSON: + continue + key = ( + field_info.alias + if field_info.alias is not None and field_info.alias in values + else name + ) + if key not in values: + continue + v = values[key] + if isinstance(v, str): + try: + values[key] = json.loads(v) + except json.JSONDecodeError as exc: + raise SettingsValidationError( + problem_detail=INVALID_CONFIGURATION_OPTION.detailed( + f"'{fm.label}' must be valid JSON: {exc}" + ) + ) + return values # Custom validation can be done by adding additional validation methods diff --git a/tests/manager/integration/test_settings.py b/tests/manager/integration/test_settings.py index f115af5784..c68ca6be6f 100644 --- a/tests/manager/integration/test_settings.py +++ b/tests/manager/integration/test_settings.py @@ -1,6 +1,6 @@ import logging from functools import partial -from typing import Annotated, Self +from typing import Annotated, Any, Self from unittest.mock import MagicMock import pytest @@ -64,6 +64,13 @@ def secret_number(self) -> Self: ] = Field(default=-1.1, alias="foo") +class JsonMockSettings(BaseSettings): + data: Annotated[ + dict | list | str | int | float | bool | None, + FormMetadata(label="Data", type=FormFieldType.JSON), + ] = None + + class BaseSettingsFixture: def __init__(self): self.test_config_dict = { @@ -131,6 +138,10 @@ def test_settings_validation( settings = base_settings_fixture.settings(test=" foo ") assert settings.model_dump() == {"test": "foo", "number": 1} + # Empty string is normalized to None for all fields, including JSON fields. + settings = JsonMockSettings(data="") + assert settings.data is None + def test_whitespace_only_required_field_treated_as_missing(self) -> None: # A whitespace-only string submitted for a required field should be # treated the same as an empty string (i.e. missing), because the admin @@ -145,6 +156,74 @@ class RequiredStrSettings(BaseSettings): with raises_problem_detail(detail="Required field 'Username' is missing."): RequiredStrSettings(username=" ") + @pytest.mark.parametrize( + "input_val, expected_val", + [ + ('{"a": 1}', {"a": 1}), + ("[1, 2, 3]", [1, 2, 3]), + ("42", 42), + ("true", True), + ({"a": 1}, {"a": 1}), + ("null", None), + (None, None), + ], + ids=[ + "dict-string", + "list-string", + "int-string", + "bool-string", + "already-parsed-dict", + "json-null-string", + "none-passthrough", + ], + ) + def test_json_field_parsing(self, input_val: Any, expected_val: Any) -> None: + settings = JsonMockSettings(data=input_val) + assert settings.data == expected_val + + def test_json_field_omitted_uses_default_without_raising(self) -> None: + settings = JsonMockSettings() + assert settings.data is None + + def test_json_field_invalid_raises_error(self) -> None: + with raises_problem_detail() as info: + JsonMockSettings(data="not json") + assert ( + info.value.detail is not None + and "'Data' must be valid JSON:" in info.value.detail + ) + + def test_json_field_alias_parsing(self) -> None: + class AliasedJsonSettings(BaseSettings): + data: Annotated[ + dict | None, + FormMetadata(label="Data", type=FormFieldType.JSON), + ] = Field(default=None, alias="cfg") + + settings = AliasedJsonSettings(**{"cfg": '{"x": 1}'}) + assert settings.data == {"x": 1} + + @pytest.mark.parametrize( + "default_val", + [ + {"key": "value"}, + [1, 2, 3], + "hello", + 42, + True, + ], + ids=["dict", "list", "str", "int", "bool"], + ) + def test_json_field_default_passed_through_in_form(self, default_val: Any) -> None: + class DefaultJsonSettings(BaseSettings): + data: Annotated[ + Any, + FormMetadata(label="Data", type=FormFieldType.JSON), + ] = default_val + + form = DefaultJsonSettings.configuration_form(MagicMock()) + assert form[0]["default"] == default_val + def test_field_validator_return_pd_exception( self, base_settings_fixture: BaseSettingsFixture ) -> None: