diff --git a/flexmeasures/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py index fdfb71f151..bee68ad689 100644 --- a/flexmeasures/data/schemas/scheduling/__init__.py +++ b/flexmeasures/data/schemas/scheduling/__init__.py @@ -1033,7 +1033,16 @@ def check_prices(self, data: dict, original_data: dict, **kwargs): }, } -UI_FLEX_MODEL_SCHEMA: Dict[str, Dict[str, Any]] = { +# Per-field UI presentation info for the storage flex-model editor. +# +# This registry does NOT define which flex-model fields exist: the field/key set +# is derived from ``DBStorageFlexModelSchema`` (the single source of truth) by +# ``_build_ui_flex_model_schema`` below. This registry only supplies the UI +# presentation values (default, description, editor type token + help string, and +# example units) for each field. Adding a field to the DB schema without a matching +# entry here therefore fails loudly at import time (see the builder), instead of +# silently breaking DB<->UI parity and only surfacing in a full CI run. +_UI_FLEX_MODEL_PRESENTATION: Dict[str, Dict[str, Any]] = { "consumption": { "default": None, "description": rst_to_openapi(metadata.CONSUMPTION.description), @@ -1226,6 +1235,43 @@ def check_prices(self, data: dict, original_data: dict, **kwargs): } +def _build_ui_flex_model_schema() -> Dict[str, Dict[str, Any]]: + """Build the UI flex-model schema from the DB storage flex-model schema. + + ``DBStorageFlexModelSchema`` is the single source of truth for which flex-model + fields exist. For each of its fields we look up the UI presentation info in + ``_UI_FLEX_MODEL_PRESENTATION``. If a DB field has no presentation entry, we + raise right here (at import time), so a newly added flex-model field can no + longer silently break DB<->UI parity (it fails immediately, with a clear + message pointing at the fix, rather than only in a full CI run). + """ + # Imported lazily to avoid an import cycle at module load time. + from flexmeasures.data.schemas.scheduling.storage import DBStorageFlexModelSchema + + schema: Dict[str, Dict[str, Any]] = {} + missing: list[str] = [] + for field_name, field in DBStorageFlexModelSchema().fields.items(): + key = field.data_key or field_name + entry = _UI_FLEX_MODEL_PRESENTATION.get(key) + if entry is None: + missing.append(key) + continue + schema[key] = entry + if missing: + raise ValueError( + "Missing UI presentation info for DBStorageFlexModelSchema field(s): " + f"{missing}. Add an entry for each in `_UI_FLEX_MODEL_PRESENTATION` " + "(flexmeasures/data/schemas/scheduling/__init__.py) so the UI flex-model " + "editor stays in sync with the DB flex-model schema." + ) + return schema + + +# Derived from the DB schema, so the DB schema is the single source of truth for +# which flex-model fields the UI editor exposes (see _build_ui_flex_model_schema). +UI_FLEX_MODEL_SCHEMA: Dict[str, Dict[str, Any]] = _build_ui_flex_model_schema() + + class DBFlexContextSchema(FlexContextSchema, NoTimeSeriesSpecs): commitments = fields.Nested( diff --git a/flexmeasures/ui/tests/test_utils.py b/flexmeasures/ui/tests/test_utils.py index b9925823b7..b6f18448ad 100644 --- a/flexmeasures/ui/tests/test_utils.py +++ b/flexmeasures/ui/tests/test_utils.py @@ -101,22 +101,41 @@ def test_ui_flexcontext_schema(): def test_ui_flexmodel_schema(): """ - This test ensures that all fields in the DBStorageFlexModelSchema are also in the UI schema and vice versa. - - This is important to keep in mind when updating either schema. We want to avoid a situation - where a field is added to the DB schema but not to the UI schema, as that would lead to - inconsistencies and potential bugs in the application. + UI_FLEX_MODEL_SCHEMA is now derived from DBStorageFlexModelSchema (the single + source of truth) by _build_ui_flex_model_schema, so DB<->UI parity is guaranteed + by construction: a DB field without UI presentation info raises at import time. + + Here we assert the remaining property that cannot be guaranteed structurally: + that every derived UI entry is well-formed (has the expected keys and a + non-empty backend type token). If someone adds a flex-model field but leaves + its UI presentation info incomplete, this fails with a clear message. """ - ui_flexmodel_schema_fields = [key for key, value in UI_FLEX_MODEL_SCHEMA.items()] - - schema_keys = [] - for value in DBStorageFlexModelSchema().fields.values(): - schema_keys.append(value.data_key if value.data_key else value.name) - - schema_keys = set(schema_keys) - ui_flexmodel_schema_fields = set(ui_flexmodel_schema_fields) - - assert schema_keys == ui_flexmodel_schema_fields + schema_keys = { + (value.data_key if value.data_key else value.name) + for value in DBStorageFlexModelSchema().fields.values() + } + + # Parity is by construction: the derived schema covers exactly the DB fields. + assert set(UI_FLEX_MODEL_SCHEMA.keys()) == schema_keys + + for key, entry in UI_FLEX_MODEL_SCHEMA.items(): + assert set(entry.keys()) == { + "default", + "description", + "types", + "example-units", + }, f"UI flex-model entry '{key}' has unexpected keys: {sorted(entry.keys())}" + assert set(entry["types"].keys()) == { + "backend", + "ui", + }, f"UI flex-model entry '{key}' has a malformed 'types' sub-dict." + assert entry["types"][ + "backend" + ], f"UI flex-model entry '{key}' has an empty backend type token." + assert entry["types"][ + "ui" + ], f"UI flex-model entry '{key}' has an empty UI help string." + assert entry["description"], f"UI flex-model entry '{key}' has no description." class NewAsset: