Merge audit16-core/configsave into the September 16 core audit fixes

# Conflicts:
#	src/plugin_system/plugin_manager.py
This commit is contained in:
Chuck
2026-09-17 09:18:17 -04:00
22 changed files with 1953 additions and 834 deletions
+25 -33
View File
@@ -1254,8 +1254,13 @@ def _set_missing_booleans_to_false(plugin_config, schema_props, form_keys, prefi
)
def _enhance_schema_with_core_properties(schema):
"""
Enhance schema with core plugin properties (enabled, display_duration, live_priority).
These properties are system-managed and should always be allowed even if not in the plugin's schema.
Enhance schema with the core-owned per-plugin properties.
``enabled``, ``display_duration``, ``live_priority``, ``skin``,
``skin_options`` and the ``vegas_*`` tuning keys are system-managed and
always allowed, even when the plugin's schema doesn't declare them. The
list is ``schema_manager.CORE_PLUGIN_PROPERTIES``, the one validation uses,
so the save filter keeps exactly what validation accepts.
Args:
schema: The original JSON schema dict
@@ -1263,44 +1268,31 @@ def _enhance_schema_with_core_properties(schema):
Returns:
Enhanced schema dict with core properties injected
"""
import copy
from src.plugin_system.schema_manager import with_core_plugin_properties
if not schema:
return schema
return with_core_plugin_properties(schema)
# Core plugin properties that should always be allowed
# These match the definitions in SchemaManager.validate_config_against_schema()
core_properties = {
"enabled": {
"type": "boolean",
"default": True,
"description": "Enable or disable this plugin"
},
"display_duration": {
"type": "number",
"default": 15,
"minimum": 1,
"maximum": 300,
"description": "How long to display this plugin in seconds"
},
"live_priority": {
"type": "boolean",
"default": False,
"description": "Enable live priority takeover when plugin has live content"
}
}
# Create a deep copy of the schema to modify (to avoid mutating the original)
enhanced_schema = copy.deepcopy(schema)
if "properties" not in enhanced_schema:
enhanced_schema["properties"] = {}
def _prepared_plugin_config(plugin_id, raw_config):
"""A plugin's config section as the plugin runs with it, for on_config_change.
Loading a plugin reads legacy booleans as objects and fills in schema
defaults (PluginManager.prepare_plugin_config); a save's notification must
hand over the same shape. Falls back to the raw section.
"""
prepare = getattr(api_v3.plugin_manager, 'prepare_plugin_config', None)
if callable(prepare):
try:
prepared = prepare(plugin_id, raw_config)
if isinstance(prepared, dict):
return prepared
except Exception:
logger.debug("Could not prepare config for %s", plugin_id, exc_info=True)
return raw_config
# Inject core properties if they're not already defined in the schema
for prop_name, prop_def in core_properties.items():
if prop_name not in enhanced_schema["properties"]:
enhanced_schema["properties"][prop_name] = copy.deepcopy(prop_def)
return enhanced_schema
def _filter_config_by_schema(config, schema, prefix=''):
"""
Filter config to only include fields defined in the schema.
+163 -120
View File
@@ -16,11 +16,33 @@ from src.display_geometry import ORIENTATION_ROTATE_DEGREES
from src.matrix_support import INT_SETTING_LIMITS, describe_range, library_refusals, refusal_message
from src.pi5_matrix_support import is_raspberry_pi_5
import web_interface.blueprints.api_v3 as _pkg
# Read through the module rather than bound by value: tests patch these
# as module attributes, and a value binding would not see the patch.
# Several are also called from helpers that live in __init__, so the
# package is the only patch point that covers every caller.
#: Hidden input the v3 settings forms (general.html, display.html,
#: durations.html) post to /config/main. Its presence tells save_main_config
#: that a missing checkbox was unchecked, not merely left out of an API call.
FORM_SECTION_FIELD = '__form_section'
def _day_setting(data, day, flat_key, nested_key):
"""(present, value) of one per-day schedule setting in a POST body.
The schedule forms post flat keys (``monday_start``), while GET returns
the stored shape, ``days.monday.start_time``. Accept both, so a client can
post back what it read; a flat key wins when a body carries both.
"""
if flat_key in data:
return True, data[flat_key]
days = data.get('days')
day_config = days.get(day) if isinstance(days, dict) else None
if isinstance(day_config, dict) and nested_key in day_config:
return True, day_config[nested_key]
return False, None
@api_v3.route('/config/main', methods=['GET'])
def get_main_config():
@@ -125,8 +147,8 @@ def save_schedule_config():
end_key = f'{day}_end'
# Check if day is enabled
if enabled_key in data:
enabled_val = data[enabled_key]
has_enabled, enabled_val = _day_setting(data, day, enabled_key, 'enabled')
if has_enabled:
# Handle checkbox values that may come as 'on', True, or False
if isinstance(enabled_val, str):
day_config['enabled'] = enabled_val.lower() in ('true', 'on', '1')
@@ -142,15 +164,8 @@ def save_schedule_config():
start_time = None
end_time = None
if start_key in data and data[start_key]:
start_time = data[start_key]
else:
start_time = '07:00'
if end_key in data and data[end_key]:
end_time = data[end_key]
else:
end_time = '23:00'
start_time = _day_setting(data, day, start_key, 'start_time')[1] or '07:00'
end_time = _day_setting(data, day, end_key, 'end_time')[1] or '23:00'
# Validate time formats
is_valid, error_msg = _validate_time_format(start_time)
@@ -354,8 +369,8 @@ def save_dim_schedule_config():
end_key = f'{day}_end'
# Check if day is enabled
if enabled_key in data:
enabled_val = data[enabled_key]
has_enabled, enabled_val = _day_setting(data, day, enabled_key, 'enabled')
if has_enabled:
if isinstance(enabled_val, str):
day_config['enabled'] = enabled_val.lower() in ('true', 'on', '1')
else:
@@ -366,8 +381,8 @@ def save_dim_schedule_config():
# Only add times if day is enabled
if day_config.get('enabled', True):
enabled_days_count += 1
start_time = data.get(start_key) or '20:00'
end_time = data.get(end_key) or '07:00'
start_time = _day_setting(data, day, start_key, 'start_time')[1] or '20:00'
end_time = _day_setting(data, day, end_key, 'end_time')[1] or '07:00'
# Validate time formats
is_valid, error_msg = _validate_time_format(start_time)
@@ -435,8 +450,10 @@ def save_main_config():
# Try to get JSON data first, fallback to form data
data = None
if request.content_type == 'application/json':
if request.is_json:
data = request.get_json()
if data is not None and not isinstance(data, dict):
return jsonify({'status': 'error', 'message': 'Request body must be a JSON object'}), 400
else:
# Handle form data
data = request.form.to_dict()
@@ -448,6 +465,24 @@ def save_main_config():
if not data:
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
# A missing checkbox means different things to the two kinds of caller.
# The settings forms post every field, and a browser leaves an
# unchecked box out entirely, so for them absent means False. A JSON
# API client (the MQTT bridge's brightness slider, a curl call from the
# REST docs) sends only what it is changing, and there absent means
# "leave it alone": treating it as unchecked turned off
# disable_hardware_pulsing and three other settings on every
# brightness change, and weekly auto-updates on every timezone change.
# The v3 forms post JSON too (htmx json-enc), so they identify
# themselves with a hidden FORM_SECTION_FIELD input. A form-encoded
# post is a form by definition.
is_form_submission = bool(data.pop(FORM_SECTION_FIELD, None)) or not request.is_json
def _set_checkbox(section, key, field):
"""Store checkbox ``field`` as ``section[key]``, if this request sets it."""
if is_form_submission or field in data:
section[key] = _coerce_to_bool(data.get(field))
# What arrives here is the config itself, and the headers carry the
# session cookie -- neither belongs in the journal, least of all at
# ERROR on every save. The shape of the request is the part with
@@ -463,15 +498,16 @@ def save_main_config():
# Note: Checkboxes don't send data when unchecked, so we need to check if we're updating general settings
# If any general setting is present, we're updating the general tab
is_general_update = any(k in data for k in ['timezone', 'city', 'state', 'country', 'web_display_autostart',
'auto_discover', 'auto_load_enabled', 'development_mode', 'plugins_directory',
'auto_update_enabled'])
'plugins_directory', 'auto_update_enabled'])
if is_general_update:
# For checkbox: if not present in data during general update, it means unchecked
current_config['web_display_autostart'] = _coerce_to_bool(data.get('web_display_autostart'))
if not isinstance(current_config.get('auto_update'), dict):
current_config['auto_update'] = {}
current_config['auto_update']['enabled'] = _coerce_to_bool(data.get('auto_update_enabled'))
# For checkbox: if not present in data during a general *form*
# update, it means unchecked (see _set_checkbox)
_set_checkbox(current_config, 'web_display_autostart', 'web_display_autostart')
if is_form_submission or 'auto_update_enabled' in data:
if not isinstance(current_config.get('auto_update'), dict):
current_config['auto_update'] = {}
_set_checkbox(current_config['auto_update'], 'enabled', 'auto_update_enabled')
if 'timezone' in data:
current_config['timezone'] = data['timezone']
@@ -641,10 +677,10 @@ def save_main_config():
# Handle checkboxes - coerce to bool to ensure proper JSON types
for checkbox in ['disable_hardware_pulsing', 'inverse_colors', 'show_refresh_rate']:
current_config['display']['hardware'][checkbox] = _coerce_to_bool(data.get(checkbox))
_set_checkbox(current_config['display']['hardware'], checkbox, checkbox)
# Handle display-level checkboxes (always set to handle unchecked state)
current_config['display']['use_short_date_format'] = _coerce_to_bool(data.get('use_short_date_format'))
# Handle display-level checkboxes (unchecked state on form saves)
_set_checkbox(current_config['display'], 'use_short_date_format', 'use_short_date_format')
# Handle dynamic duration settings
if 'max_dynamic_duration_seconds' in data:
@@ -666,8 +702,11 @@ def save_main_config():
# checkbox, so when the feature is off we accept the values without
# rejecting the whole save — otherwise a stale copies/chain_length
# mismatch locks the user out of every other display setting.
enabled = _coerce_to_bool(data.get('double_sided_enabled'))
ds_config['enabled'] = enabled
if is_form_submission or 'double_sided_enabled' in data:
enabled = _coerce_to_bool(data.get('double_sided_enabled'))
ds_config['enabled'] = enabled
else:
enabled = _coerce_to_bool(ds_config.get('enabled'))
def _copies_fits_hardware(copies: int) -> Optional[str]:
"""Error message if copies doesn't divide the panel evenly, else None."""
@@ -737,15 +776,13 @@ def save_main_config():
# Handle enabled checkbox
# HTML checkboxes omit the key entirely when unchecked, so if the form
# was submitted (any vegas field present) but enabled key is missing,
# the checkbox was unchecked and we should set enabled=False
vegas_config['enabled'] = _coerce_to_bool(data.get('vegas_scroll_enabled'))
vegas_config['auto_trim'] = _coerce_to_bool(data.get('vegas_auto_trim'))
vegas_config['dynamic_duration_enabled'] = _coerce_to_bool(
data.get('vegas_dynamic_duration_enabled'))
vegas_config['continuous_scroll'] = _coerce_to_bool(
data.get('vegas_continuous_scroll'))
vegas_config['smooth_scroll'] = _coerce_to_bool(
data.get('vegas_smooth_scroll'))
# the checkbox was unchecked and we should set enabled=False.
# A JSON API call only changes the checkboxes it sends.
_set_checkbox(vegas_config, 'enabled', 'vegas_scroll_enabled')
_set_checkbox(vegas_config, 'auto_trim', 'vegas_auto_trim')
_set_checkbox(vegas_config, 'dynamic_duration_enabled', 'vegas_dynamic_duration_enabled')
_set_checkbox(vegas_config, 'continuous_scroll', 'vegas_continuous_scroll')
_set_checkbox(vegas_config, 'smooth_scroll', 'vegas_smooth_scroll')
# max_plugin_width_ratio is the one fractional setting, so it is
# handled outside the integer loop below.
@@ -911,8 +948,12 @@ def save_main_config():
# them AGAIN as bogus top-level config keys (e.g. "clock_duration": 30
# sitting at config root alongside the correct
# display.display_durations.clock_duration).
# The Vegas cycle-time fields (vegas_min_cycle_duration, ...) share the
# suffix but are Vegas settings, already handled above: counting them
# here wrote junk mode durations, and a blank one 400'd the save.
duration_fields = [k for k in list(data.keys())
if k.endswith('_duration') or k in ('default_duration', 'transition_duration')]
if (k.endswith('_duration') and k not in vegas_fields)
or k in ('default_duration', 'transition_duration')]
if duration_fields:
if 'display' not in current_config:
current_config['display'] = {}
@@ -952,33 +993,34 @@ def save_main_config():
current_config['display']['display_durations'][mode_key] = int_value
# Handle plugin configurations dynamically
# Any key that matches a plugin ID should be saved as plugin config
# This includes proper secret field handling from schema
# Any key that matches a plugin ID is that plugin's settings. They go
# through the same preparation as POST /plugins/config -- merged onto
# the stored section, legacy booleans and schema defaults applied,
# filtered, validated, secrets split out -- so this route can't store
# a config that one rejects (stored verbatim, it left the plugin
# flagged degraded at its next load).
plugin_keys_to_remove = []
plugin_secrets_updates = {}
for key in data:
# Check if this key is a plugin ID
if api_v3.plugin_manager and key in api_v3.plugin_manager.plugin_manifests:
plugin_id = key
plugin_config = data[key]
submitted_config = data[key]
if not isinstance(submitted_config, dict):
return error_response(
ErrorCode.VALIDATION_ERROR,
f"Settings for plugin '{plugin_id}' must be a JSON object",
status_code=400
)
# Load plugin schema to identify secret fields (same logic as save_plugin_config)
secret_fields = set()
if api_v3.plugin_manager:
plugins_dir = api_v3.plugin_manager.plugins_dir
else:
plugin_system_config = current_config.get('plugin_system', {})
plugins_dir_name = plugin_system_config.get('plugins_directory', 'plugin-repos')
if os.path.isabs(plugins_dir_name):
plugins_dir = Path(plugins_dir_name)
else:
plugins_dir = PROJECT_ROOT / plugins_dir_name
# plugin_id is already known to be a loaded plugin (the
# membership test above), so this cannot currently traverse --
# but the path is built from a request key, and the guard and
# the join are far enough apart that a later edit could
# separate them. Build it through the shared helper instead.
schema_path = resolve_under(plugins_dir, plugin_id, 'config_schema.json')
# the schema load are far enough apart that a later edit could
# separate them. Refuse rather than save without knowing which
# fields are secrets.
schema_path = resolve_under(api_v3.plugin_manager.plugins_dir,
plugin_id, 'config_schema.json')
if schema_path is None:
return error_response(
ErrorCode.VALIDATION_ERROR,
@@ -986,77 +1028,48 @@ def save_main_config():
status_code=400
)
if schema_path.exists():
try:
with open(schema_path, 'r', encoding='utf-8') as f:
schema = json.load(f)
if 'properties' in schema:
secret_fields = find_secret_fields(schema['properties'])
except Exception as e:
logger.debug("Error reading schema for secret detection: %s", e)
schema_mgr = api_v3.schema_manager
if not schema_mgr:
return error_response(
ErrorCode.SYSTEM_ERROR,
'Schema manager not initialized',
status_code=500
)
schema = schema_mgr.load_schema(plugin_id, use_cache=False)
# Separate secrets from regular config (same logic as save_plugin_config)
regular_config, secrets_config = separate_secrets(plugin_config, secret_fields)
# The config form renders secrets masked, so every save posts
# them back blank. Without this the blank is merged over the
# stored value and the credential is destroyed by the act of
# changing an unrelated setting. A blank means "unchanged".
secrets_config = remove_empty_secrets(secrets_config)
# PRE-PROCESSING: Preserve 'enabled' state if not in regular_config
# This prevents overwriting the enabled state when saving config from a form that doesn't include the toggle
if 'enabled' not in regular_config:
try:
if plugin_id in current_config and 'enabled' in current_config[plugin_id]:
regular_config['enabled'] = current_config[plugin_id]['enabled']
elif api_v3.plugin_manager:
# Fallback to plugin instance if config doesn't have it
plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id)
if plugin_instance:
regular_config['enabled'] = plugin_instance.enabled
# Final fallback: default to True if plugin is loaded (matches BasePlugin default)
if 'enabled' not in regular_config:
regular_config['enabled'] = True
except Exception as e:
logger.debug("Error preserving enabled state: %s", e)
# Default to True on error to avoid disabling plugins
regular_config['enabled'] = True
# Get current secrets config
current_secrets = api_v3.config_manager.get_raw_file_content('secrets')
from web_interface.blueprints.api_v3.plugins import (
_merge_onto_stored_plugin_config, _prepare_plugin_config_for_save,
)
plugin_config = _merge_onto_stored_plugin_config(
plugin_id, submitted_config, current_config)
regular_config, secrets_config, error = _prepare_plugin_config_for_save(
plugin_id, plugin_config, schema, schema_mgr, is_json=True)
if error:
return error
# Deep merge regular config into main config
if plugin_id not in current_config:
current_config[plugin_id] = {}
current_config[plugin_id] = deep_merge(current_config[plugin_id], regular_config)
# Deep merge secrets into secrets config
stored_section = current_config.get(plugin_id)
current_config[plugin_id] = deep_merge(
stored_section if isinstance(stored_section, dict) else {}, regular_config)
if secrets_config:
if plugin_id not in current_secrets:
current_secrets[plugin_id] = {}
# Lists merge by replacement, so deep_merge here wrote a
# blanked array straight over the stored credentials.
current_secrets[plugin_id] = merge_secrets(
current_secrets[plugin_id], secrets_config)
# Save secrets file
api_v3.config_manager.save_raw_file_content('secrets', current_secrets)
plugin_secrets_updates[plugin_id] = secrets_config
# Mark for removal from data dict (already processed)
plugin_keys_to_remove.append(key)
# Notify plugin of config change if loaded (with merged config including secrets)
try:
if api_v3.plugin_manager:
plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id)
if plugin_instance:
# Reload merged config (includes secrets) and pass the plugin-specific section
merged_config = api_v3.config_manager.load_config()
plugin_full_config = merged_config.get(plugin_id, {})
if hasattr(plugin_instance, 'on_config_change'):
plugin_instance.on_config_change(plugin_full_config)
except Exception as hook_err:
# Don't fail the save if hook fails
logger.warning("on_config_change failed: %s", hook_err)
# Deep merge secrets into secrets config, once every plugin section
# has validated
if plugin_secrets_updates:
current_secrets = api_v3.config_manager.get_raw_file_content('secrets')
for plugin_id, secrets_config in plugin_secrets_updates.items():
if plugin_id not in current_secrets:
current_secrets[plugin_id] = {}
# Lists merge by replacement, so deep_merge here wrote a
# blanked array straight over the stored credentials.
current_secrets[plugin_id] = merge_secrets(
current_secrets[plugin_id], secrets_config)
# Save secrets file
api_v3.config_manager.save_raw_file_content('secrets', current_secrets)
# Remove processed plugin keys from data (they're already in current_config)
for key in plugin_keys_to_remove:
@@ -1105,6 +1118,19 @@ def save_main_config():
except ImportError:
pass
# Notify saved plugins of their new config (with secrets merged), now
# that it is on disk.
for plugin_id in plugin_keys_to_remove:
try:
plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id)
if plugin_instance and hasattr(plugin_instance, 'on_config_change'):
merged_config = api_v3.config_manager.load_config()
plugin_instance.on_config_change(_pkg._prepared_plugin_config(
plugin_id, merged_config.get(plugin_id, {})))
except Exception as hook_err:
# Don't fail the save if hook fails
logger.warning("on_config_change failed: %s", hook_err)
message = 'Configuration saved successfully'
# Switching automatic updates on finishes their setup, which needs
# the display service to restart (web_interface/auto_update.py).
@@ -1157,10 +1183,27 @@ def save_raw_main_config():
if not data:
return jsonify({'status': 'error', 'message': 'No data provided'}), 400
was_auto_update_enabled = False
try:
previous = api_v3.config_manager.get_raw_file_content('main') or {}
was_auto_update_enabled = bool((previous.get('auto_update') or {}).get('enabled'))
except Exception:
logger.debug("Could not read the previous auto_update setting", exc_info=True)
# Save the raw config file
api_v3.config_manager.save_raw_file_content('main', data)
return jsonify({'status': 'success', 'message': 'Main configuration saved successfully'})
message = 'Main configuration saved successfully'
# Same hook as save_main_config: switching automatic updates on here
# must finish their setup too, not wait for the next service restart.
try:
from web_interface import auto_update
note = auto_update.start_setup_if_needed(was_auto_update_enabled, data)
if note:
message = f'{message}. {note}'
except Exception:
logger.warning("Automatic update setup could not be started", exc_info=True)
return jsonify({'status': 'success', 'message': message})
except Exception as e:
from src.exceptions import ConfigError
logger.error("Error saving raw main config", exc_info=True)
+434 -396
View File
@@ -792,12 +792,16 @@ def get_plugin_config():
main_config = api_v3.config_manager.load_config()
plugin_config = main_config.get(plugin_id, {})
# Merge with defaults from schema so form shows default values for missing fields
# Merge with defaults from schema so form shows default values for
# missing fields, reading legacy booleans as objects first: what the
# plugin runs with, and what posts back through the JSON save
schema_mgr = api_v3.schema_manager
if schema_mgr:
try:
from src.plugin_system.schema_manager import prepare_plugin_config
defaults = schema_mgr.generate_default_config(plugin_id, use_cache=True)
plugin_config = schema_mgr.merge_with_defaults(plugin_config, defaults)
plugin_config = prepare_plugin_config(
plugin_config, schema_mgr.load_schema(plugin_id, use_cache=True), defaults)
except Exception as e:
# Log but don't fail - defaults merge is best effort
import logging
@@ -1746,7 +1750,14 @@ def save_plugin_config():
if error:
return error
plugin_id = data['plugin_id']
plugin_config = data.get('config', {})
submitted_config = data.get('config', {})
if not isinstance(submitted_config, dict):
return error_response(
ErrorCode.INVALID_INPUT,
'config must be a JSON object',
status_code=400
)
plugin_config = _merge_onto_stored_plugin_config(plugin_id, submitted_config)
else:
# Form data (HTMX submission)
# plugin_id comes from query string, config from form fields
@@ -2183,398 +2194,11 @@ def save_plugin_config():
if 'application/json' in content_type:
schema = schema_mgr.load_schema(plugin_id, use_cache=False)
# JSON path: fix numeric-keyed dicts that should be arrays.
# JS dotToNested() converts feeds.custom_feeds.0.name → {'0': {name:...}}
# instead of [{name:...}]. The form-data path has fix_array_structures for this;
# mirror that logic here for JSON submissions.
if 'application/json' in content_type and schema and 'properties' in schema:
def _fix_json_arrays(cfg, props):
for k, ps in props.items():
if not isinstance(cfg, dict) or k not in cfg:
continue
pt = ps.get('type')
val = cfg[k]
if pt == 'array':
items_schema = ps.get('items', {})
item_type = items_schema.get('type')
if isinstance(val, dict):
keys = list(val.keys())
if keys and all(str(x).isdigit() for x in keys):
sorted_keys = sorted(keys, key=lambda x: int(str(x)))
arr = [val[sk] for sk in sorted_keys]
if item_type in ('integer', 'number'):
converted = []
for v in arr:
if isinstance(v, str):
try:
converted.append(int(v) if item_type == 'integer' else float(v))
except (ValueError, TypeError, OverflowError):
converted.append(v)
else:
converted.append(v)
arr = converted
cfg[k] = arr
elif not keys:
cfg[k] = []
# Recurse into each element when items are objects with properties,
# covering both freshly-converted and already-list values.
if item_type == 'object' and 'properties' in items_schema:
for elem in (cfg[k] if isinstance(cfg[k], list) else []):
if isinstance(elem, dict):
_fix_json_arrays(elem, items_schema['properties'])
elif pt == 'object' and 'properties' in ps and isinstance(val, dict):
_fix_json_arrays(val, ps['properties'])
_fix_json_arrays(plugin_config, schema['properties'])
# PRE-PROCESSING: Preserve 'enabled' state if not in request
# This prevents overwriting the enabled state when saving config from a form that doesn't include the toggle
if 'enabled' not in plugin_config:
try:
current_config = api_v3.config_manager.load_config()
if plugin_id in current_config and 'enabled' in current_config[plugin_id]:
plugin_config['enabled'] = current_config[plugin_id]['enabled']
# logger.debug(f"Preserving enabled state for {plugin_id}: {plugin_config['enabled']}")
elif api_v3.plugin_manager:
# Fallback to plugin instance if config doesn't have it
plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id)
if plugin_instance:
plugin_config['enabled'] = plugin_instance.enabled
# Final fallback: default to True if plugin is loaded (matches BasePlugin default)
if 'enabled' not in plugin_config:
plugin_config['enabled'] = True
except Exception as e:
logger.debug("Error preserving enabled state: %s", e)
# Default to True on error to avoid disabling plugins
plugin_config['enabled'] = True
# Find secret fields (supports nested schemas and array-item secrets)
secret_fields = set()
if schema and 'properties' in schema:
secret_fields = find_secret_fields(schema['properties'])
# Apply defaults from schema to config BEFORE validation
# This ensures required fields with defaults are present before validation
# Store preserved enabled value before merge to protect it from defaults
preserved_enabled = None
if 'enabled' in plugin_config:
preserved_enabled = plugin_config['enabled']
if schema:
defaults = schema_mgr.generate_default_config(plugin_id, use_cache=True)
plugin_config = schema_mgr.merge_with_defaults(plugin_config, defaults)
# After merging defaults, replace any None array values with their schema defaults.
# merge_with_defaults gives user config higher priority, so a None submitted by
# the client can survive the merge — this pass cleans those up.
def _fix_none_arrays(cfg, props):
for k, pschema in props.items():
if pschema.get('type') == 'array':
if isinstance(cfg, dict) and (k not in cfg or cfg[k] is None):
cfg[k] = pschema.get('default', [])
elif pschema.get('type') == 'object' and 'properties' in pschema:
if isinstance(cfg, dict) and isinstance(cfg.get(k), dict):
_fix_none_arrays(cfg[k], pschema['properties'])
if schema and 'properties' in schema and isinstance(plugin_config, dict):
_fix_none_arrays(plugin_config, schema['properties'])
# Ensure enabled state is preserved after defaults merge
# Defaults should not overwrite an explicitly preserved enabled value
if preserved_enabled is not None:
# Restore preserved value if it was changed by defaults merge
if plugin_config.get('enabled') != preserved_enabled:
plugin_config['enabled'] = preserved_enabled
# Normalize config data: convert string numbers to integers/floats where schema expects numbers
# This handles form data which sends everything as strings
def normalize_config_values(config, schema_props, prefix=''):
"""Recursively normalize config values based on schema types"""
if not isinstance(config, dict) or not isinstance(schema_props, dict):
return config
normalized = {}
for key, value in config.items():
field_path = f"{prefix}.{key}" if prefix else key
if key not in schema_props:
# Field not in schema, keep as-is (will be caught by additionalProperties check if needed)
normalized[key] = value
continue
prop_schema = schema_props[key]
prop_type = prop_schema.get('type')
# Handle union types (e.g., ["integer", "null"])
if isinstance(prop_type, list):
# Check if null is allowed and value is empty/null
if 'null' in prop_type:
# Handle various representations of null/empty
if value is None:
normalized[key] = None
continue
elif isinstance(value, str):
# Strip whitespace and check for null representations
value_stripped = value.strip()
if value_stripped == '' or value_stripped.lower() in ('null', 'none', 'undefined'):
normalized[key] = None
continue
# Try to normalize based on non-null types in the union
# Check integer first (more specific than number)
if 'integer' in prop_type:
if isinstance(value, str):
value_stripped = value.strip()
if value_stripped == '':
# Empty string with null allowed - already handled above, but double-check
if 'null' in prop_type:
normalized[key] = None
continue
try:
normalized[key] = int(value_stripped)
continue
except (ValueError, TypeError, OverflowError):
pass
elif isinstance(value, (int, float)):
normalized[key] = int(value)
continue
# Check number (less specific, but handles floats)
if 'number' in prop_type:
if isinstance(value, str):
value_stripped = value.strip()
if value_stripped == '':
# Empty string with null allowed - already handled above, but double-check
if 'null' in prop_type:
normalized[key] = None
continue
try:
normalized[key] = float(value_stripped)
continue
except (ValueError, TypeError, OverflowError):
pass
elif isinstance(value, (int, float)):
normalized[key] = float(value)
continue
# Check boolean
if 'boolean' in prop_type:
if isinstance(value, str):
normalized[key] = value.strip().lower() in ('true', '1', 'on', 'yes')
continue
# If no conversion worked and null is allowed, try to set to None
# This handles cases where the value is an empty string or can't be converted
if 'null' in prop_type:
if isinstance(value, str):
value_stripped = value.strip()
if value_stripped == '' or value_stripped.lower() in ('null', 'none', 'undefined'):
normalized[key] = None
continue
# If it's already None, keep it
if value is None:
normalized[key] = None
continue
# If no conversion worked, keep original value (will fail validation, but that's expected)
# Log a warning for debugging
logger.warning(f"Could not normalize field {field_path}: value={repr(value)}, type={type(value)}, schema_type={prop_type}")
normalized[key] = value
continue
if isinstance(value, dict) and prop_type == 'object' and 'properties' in prop_schema:
# Recursively normalize nested objects
normalized[key] = normalize_config_values(value, prop_schema['properties'], field_path)
elif isinstance(value, list) and prop_type == 'array' and 'items' in prop_schema:
# Normalize array items
items_schema = prop_schema['items']
item_type = items_schema.get('type')
# Handle union types in array items
if isinstance(item_type, list):
normalized_array = []
for v in value:
# Check if null is allowed
if 'null' in item_type:
if v is None or v == '' or (isinstance(v, str) and v.lower() in ('null', 'none')):
normalized_array.append(None)
continue
# Try to normalize based on non-null types
if 'integer' in item_type:
if isinstance(v, str):
try:
normalized_array.append(int(v))
continue
except (ValueError, TypeError, OverflowError):
pass
elif isinstance(v, (int, float)):
normalized_array.append(int(v))
continue
elif 'number' in item_type:
if isinstance(v, str):
try:
normalized_array.append(float(v))
continue
except (ValueError, TypeError, OverflowError):
pass
elif isinstance(v, (int, float)):
normalized_array.append(float(v))
continue
# If no conversion worked, keep original value
normalized_array.append(v)
normalized[key] = normalized_array
elif item_type == 'integer':
# Convert string numbers to integers
normalized_array = []
for v in value:
if isinstance(v, str):
try:
normalized_array.append(int(v))
except (ValueError, TypeError, OverflowError):
normalized_array.append(v)
elif isinstance(v, (int, float)):
normalized_array.append(int(v))
else:
normalized_array.append(v)
normalized[key] = normalized_array
elif item_type == 'number':
# Convert string numbers to floats
normalized_array = []
for v in value:
if isinstance(v, str):
try:
normalized_array.append(float(v))
except (ValueError, TypeError, OverflowError):
normalized_array.append(v)
else:
normalized_array.append(v)
normalized[key] = normalized_array
elif item_type == 'object' and 'properties' in items_schema:
# Recursively normalize array of objects
normalized_array = []
for v in value:
if isinstance(v, dict):
normalized_array.append(
normalize_config_values(v, items_schema['properties'], f"{field_path}[]")
)
else:
normalized_array.append(v)
normalized[key] = normalized_array
else:
normalized[key] = value
elif prop_type == 'integer':
# Convert string to integer
if isinstance(value, str):
try:
normalized[key] = int(value)
except (ValueError, TypeError, OverflowError):
normalized[key] = value
else:
normalized[key] = value
elif prop_type == 'number':
# Convert string to float
if isinstance(value, str):
try:
normalized[key] = float(value)
except (ValueError, TypeError, OverflowError):
normalized[key] = value
else:
normalized[key] = value
elif prop_type == 'boolean':
# Convert string booleans
if isinstance(value, str):
normalized[key] = value.lower() in ('true', '1', 'on', 'yes')
else:
normalized[key] = value
else:
normalized[key] = value
return normalized
# Normalize config before validation
if schema and 'properties' in schema:
plugin_config = normalize_config_values(plugin_config, schema['properties'])
# Filter config to only include schema-defined fields (important when additionalProperties is false)
# Use enhanced schema with core properties to ensure core properties are preserved during filtering
if schema and 'properties' in schema:
enhanced_schema_for_filtering = _enhance_schema_with_core_properties(schema)
plugin_config = _filter_config_by_schema(plugin_config, enhanced_schema_for_filtering)
# Debug logging for union type fields (temporary)
if 'rotation_settings' in plugin_config and 'random_seed' in plugin_config.get('rotation_settings', {}):
seed_value = plugin_config['rotation_settings']['random_seed']
logger.debug(f"After normalization, random_seed value: {repr(seed_value)}, type: {type(seed_value)}")
# Validate configuration against schema before saving
if schema:
# Log what we're validating for debugging
logger.info(f"Validating config for {plugin_id}")
# Only the shape. plugin_config still holds the submitted secret
# values at this point -- separate_secrets does not run until
# below -- so logging it wrote live credentials to the journal.
logger.info(f"Config keys being validated: {list(plugin_config.keys())}")
# Get enhanced schema keys (including injected core properties)
# We need to create an enhanced schema to get the actual allowed keys
import copy
enhanced_schema = copy.deepcopy(schema)
if "properties" not in enhanced_schema:
enhanced_schema["properties"] = {}
# Core properties that are always injected during validation
core_properties = ["enabled", "display_duration", "live_priority"]
for prop_name in core_properties:
if prop_name not in enhanced_schema["properties"]:
# Add placeholder to get the full list of allowed keys
enhanced_schema["properties"][prop_name] = {"type": "any"}
is_valid, validation_errors = schema_mgr.validate_config_against_schema(
plugin_config, schema, plugin_id
)
if not is_valid:
# Log validation errors for debugging
logger.error(f"Config validation failed for {plugin_id}")
logger.error(f"Validation errors: {validation_errors}")
# Keys only, for the same reason as above.
logger.error(f"Config keys that failed: {list(plugin_config.keys())}")
logger.error(f"Schema properties: {list(enhanced_schema.get('properties', {}).keys())}")
# Also print to console for immediate visibility
import json
logger.warning("Config validation failed for plugin (see debug logs)")
# Log raw form data if this was a form submission
if 'application/json' not in (request.content_type or ''):
form_data = request.form.to_dict()
return error_response(
ErrorCode.CONFIG_VALIDATION_FAILED,
'Configuration validation failed',
details='; '.join(validation_errors) if validation_errors else 'Unknown validation error',
context={
'plugin_id': plugin_id,
'validation_errors': validation_errors,
'config_keys': list(plugin_config.keys()),
'schema_keys': list(enhanced_schema.get('properties', {}).keys())
},
suggested_fixes=[
'Review validation errors above',
'Check config against schema',
'Verify all required fields are present'
],
status_code=400
)
# Separate secrets from regular config (handles nested configs and
# array-item secrets — see src/web_interface/secret_helpers.py)
regular_config, secrets_config = separate_secrets(plugin_config, secret_fields)
# The config form renders secrets masked, so every save posts
# them back blank. Without this the blank is merged over the
# stored value and the credential is destroyed by the act of
# changing an unrelated setting. A blank means "unchanged".
secrets_config = remove_empty_secrets(secrets_config)
regular_config, secrets_config, error = _prepare_plugin_config_for_save(
plugin_id, plugin_config, schema, schema_mgr,
is_json='application/json' in content_type)
if error:
return error
# Get current configs
current_config = api_v3.config_manager.load_config()
@@ -2647,7 +2271,8 @@ def save_plugin_config():
if plugin_instance:
# Reload merged config (includes secrets) and pass the plugin-specific section
merged_config = api_v3.config_manager.load_config()
plugin_full_config = merged_config.get(plugin_id, {})
plugin_full_config = _pkg._prepared_plugin_config(
plugin_id, merged_config.get(plugin_id, {}))
if hasattr(plugin_instance, 'on_config_change'):
plugin_instance.on_config_change(plugin_full_config)
@@ -2698,6 +2323,419 @@ def save_plugin_config():
context=error.context,
status_code=500
)
def _merge_onto_stored_plugin_config(plugin_id, submitted_config, current_config=None):
"""A JSON plugin-config body merged onto the plugin's stored section.
A JSON body carries the settings being changed, as a form post does: the
rest keep their stored values. Built from defaults alone, a body such as
``{"enabled": true}`` reset every unsent setting of the plugin.
``current_config`` is the loaded main config when the caller has one.
"""
import copy
if current_config is None:
current_config = api_v3.config_manager.load_config()
stored = current_config.get(plugin_id)
base = copy.deepcopy(stored) if isinstance(stored, dict) else {}
return deep_merge(base, submitted_config)
def _prepare_plugin_config_for_save(plugin_id, plugin_config, schema, schema_mgr, is_json):
"""Turn a submitted plugin config into what gets stored.
Fixes array shapes, keeps the enabled state, reads legacy booleans and
applies schema defaults, normalizes value types, filters to the schema plus
the core-owned per-plugin properties, validates, and splits out secrets.
Shared by POST /plugins/config and plugin sections posted to
POST /config/main, so both store the same thing.
Returns ``(regular_config, secrets_config, None)``, or
``(None, None, error_response)`` when validation fails.
"""
# JSON path: fix numeric-keyed dicts that should be arrays.
# JS dotToNested() converts feeds.custom_feeds.0.name → {'0': {name:...}}
# instead of [{name:...}]. The form-data path has fix_array_structures for this;
# mirror that logic here for JSON submissions.
if is_json and schema and 'properties' in schema:
def _fix_json_arrays(cfg, props):
for k, ps in props.items():
if not isinstance(cfg, dict) or k not in cfg:
continue
pt = ps.get('type')
val = cfg[k]
if pt == 'array':
items_schema = ps.get('items', {})
item_type = items_schema.get('type')
if isinstance(val, dict):
keys = list(val.keys())
if keys and all(str(x).isdigit() for x in keys):
sorted_keys = sorted(keys, key=lambda x: int(str(x)))
arr = [val[sk] for sk in sorted_keys]
if item_type in ('integer', 'number'):
converted = []
for v in arr:
if isinstance(v, str):
try:
converted.append(int(v) if item_type == 'integer' else float(v))
except (ValueError, TypeError, OverflowError):
converted.append(v)
else:
converted.append(v)
arr = converted
cfg[k] = arr
elif not keys:
cfg[k] = []
# Recurse into each element when items are objects with properties,
# covering both freshly-converted and already-list values.
if item_type == 'object' and 'properties' in items_schema:
for elem in (cfg[k] if isinstance(cfg[k], list) else []):
if isinstance(elem, dict):
_fix_json_arrays(elem, items_schema['properties'])
elif pt == 'object' and 'properties' in ps and isinstance(val, dict):
_fix_json_arrays(val, ps['properties'])
_fix_json_arrays(plugin_config, schema['properties'])
# PRE-PROCESSING: Preserve 'enabled' state if not in request
# This prevents overwriting the enabled state when saving config from a form that doesn't include the toggle
if 'enabled' not in plugin_config:
try:
current_config = api_v3.config_manager.load_config()
if plugin_id in current_config and 'enabled' in current_config[plugin_id]:
plugin_config['enabled'] = current_config[plugin_id]['enabled']
# logger.debug(f"Preserving enabled state for {plugin_id}: {plugin_config['enabled']}")
elif api_v3.plugin_manager:
# Fallback to plugin instance if config doesn't have it
plugin_instance = api_v3.plugin_manager.get_plugin(plugin_id)
if plugin_instance:
plugin_config['enabled'] = plugin_instance.enabled
# Final fallback: default to True if plugin is loaded (matches BasePlugin default)
if 'enabled' not in plugin_config:
plugin_config['enabled'] = True
except Exception as e:
logger.debug("Error preserving enabled state: %s", e)
# Default to True on error to avoid disabling plugins
plugin_config['enabled'] = True
# Find secret fields (supports nested schemas and array-item secrets)
secret_fields = set()
if schema and 'properties' in schema:
secret_fields = find_secret_fields(schema['properties'])
# Apply defaults from schema to config BEFORE validation
# This ensures required fields with defaults are present before validation
# Store preserved enabled value before merge to protect it from defaults
preserved_enabled = None
if 'enabled' in plugin_config:
preserved_enabled = plugin_config['enabled']
if schema:
# Legacy booleans read as {"enabled": ...} objects first (#588), the
# same preparation loading applies, so a config the device reads
# without complaint also saves.
from src.plugin_system.schema_manager import prepare_plugin_config
defaults = schema_mgr.generate_default_config(plugin_id, use_cache=True)
plugin_config = prepare_plugin_config(plugin_config, schema, defaults)
# After merging defaults, replace any None array values with their schema defaults.
# merge_with_defaults gives user config higher priority, so a None submitted by
# the client can survive the merge — this pass cleans those up.
def _fix_none_arrays(cfg, props):
for k, pschema in props.items():
if pschema.get('type') == 'array':
if isinstance(cfg, dict) and (k not in cfg or cfg[k] is None):
cfg[k] = pschema.get('default', [])
elif pschema.get('type') == 'object' and 'properties' in pschema:
if isinstance(cfg, dict) and isinstance(cfg.get(k), dict):
_fix_none_arrays(cfg[k], pschema['properties'])
if schema and 'properties' in schema and isinstance(plugin_config, dict):
_fix_none_arrays(plugin_config, schema['properties'])
# Ensure enabled state is preserved after defaults merge
# Defaults should not overwrite an explicitly preserved enabled value
if preserved_enabled is not None:
# Restore preserved value if it was changed by defaults merge
if plugin_config.get('enabled') != preserved_enabled:
plugin_config['enabled'] = preserved_enabled
# Normalize config data: convert string numbers to integers/floats where schema expects numbers
# This handles form data which sends everything as strings
def normalize_config_values(config, schema_props, prefix=''):
"""Recursively normalize config values based on schema types"""
if not isinstance(config, dict) or not isinstance(schema_props, dict):
return config
normalized = {}
for key, value in config.items():
field_path = f"{prefix}.{key}" if prefix else key
if key not in schema_props:
# Field not in schema, keep as-is (will be caught by additionalProperties check if needed)
normalized[key] = value
continue
prop_schema = schema_props[key]
prop_type = prop_schema.get('type')
# Handle union types (e.g., ["integer", "null"])
if isinstance(prop_type, list):
# Check if null is allowed and value is empty/null
if 'null' in prop_type:
# Handle various representations of null/empty
if value is None:
normalized[key] = None
continue
elif isinstance(value, str):
# Strip whitespace and check for null representations
value_stripped = value.strip()
if value_stripped == '' or value_stripped.lower() in ('null', 'none', 'undefined'):
normalized[key] = None
continue
# Try to normalize based on non-null types in the union
# Check integer first (more specific than number)
if 'integer' in prop_type:
if isinstance(value, str):
value_stripped = value.strip()
if value_stripped == '':
# Empty string with null allowed - already handled above, but double-check
if 'null' in prop_type:
normalized[key] = None
continue
try:
normalized[key] = int(value_stripped)
continue
except (ValueError, TypeError, OverflowError):
pass
elif isinstance(value, (int, float)):
normalized[key] = int(value)
continue
# Check number (less specific, but handles floats)
if 'number' in prop_type:
if isinstance(value, str):
value_stripped = value.strip()
if value_stripped == '':
# Empty string with null allowed - already handled above, but double-check
if 'null' in prop_type:
normalized[key] = None
continue
try:
normalized[key] = float(value_stripped)
continue
except (ValueError, TypeError, OverflowError):
pass
elif isinstance(value, (int, float)):
normalized[key] = float(value)
continue
# Check boolean
if 'boolean' in prop_type:
if isinstance(value, str):
normalized[key] = value.strip().lower() in ('true', '1', 'on', 'yes')
continue
# If no conversion worked and null is allowed, try to set to None
# This handles cases where the value is an empty string or can't be converted
if 'null' in prop_type:
if isinstance(value, str):
value_stripped = value.strip()
if value_stripped == '' or value_stripped.lower() in ('null', 'none', 'undefined'):
normalized[key] = None
continue
# If it's already None, keep it
if value is None:
normalized[key] = None
continue
# If no conversion worked, keep original value (will fail validation, but that's expected)
# Log a warning for debugging
logger.warning(f"Could not normalize field {field_path}: value={repr(value)}, type={type(value)}, schema_type={prop_type}")
normalized[key] = value
continue
if isinstance(value, dict) and prop_type == 'object' and 'properties' in prop_schema:
# Recursively normalize nested objects
normalized[key] = normalize_config_values(value, prop_schema['properties'], field_path)
elif isinstance(value, list) and prop_type == 'array' and 'items' in prop_schema:
# Normalize array items
items_schema = prop_schema['items']
item_type = items_schema.get('type')
# Handle union types in array items
if isinstance(item_type, list):
normalized_array = []
for v in value:
# Check if null is allowed
if 'null' in item_type:
if v is None or v == '' or (isinstance(v, str) and v.lower() in ('null', 'none')):
normalized_array.append(None)
continue
# Try to normalize based on non-null types
if 'integer' in item_type:
if isinstance(v, str):
try:
normalized_array.append(int(v))
continue
except (ValueError, TypeError, OverflowError):
pass
elif isinstance(v, (int, float)):
normalized_array.append(int(v))
continue
elif 'number' in item_type:
if isinstance(v, str):
try:
normalized_array.append(float(v))
continue
except (ValueError, TypeError, OverflowError):
pass
elif isinstance(v, (int, float)):
normalized_array.append(float(v))
continue
# If no conversion worked, keep original value
normalized_array.append(v)
normalized[key] = normalized_array
elif item_type == 'integer':
# Convert string numbers to integers
normalized_array = []
for v in value:
if isinstance(v, str):
try:
normalized_array.append(int(v))
except (ValueError, TypeError, OverflowError):
normalized_array.append(v)
elif isinstance(v, (int, float)):
normalized_array.append(int(v))
else:
normalized_array.append(v)
normalized[key] = normalized_array
elif item_type == 'number':
# Convert string numbers to floats
normalized_array = []
for v in value:
if isinstance(v, str):
try:
normalized_array.append(float(v))
except (ValueError, TypeError, OverflowError):
normalized_array.append(v)
else:
normalized_array.append(v)
normalized[key] = normalized_array
elif item_type == 'object' and 'properties' in items_schema:
# Recursively normalize array of objects
normalized_array = []
for v in value:
if isinstance(v, dict):
normalized_array.append(
normalize_config_values(v, items_schema['properties'], f"{field_path}[]")
)
else:
normalized_array.append(v)
normalized[key] = normalized_array
else:
normalized[key] = value
elif prop_type == 'integer':
# Convert string to integer
if isinstance(value, str):
try:
normalized[key] = int(value)
except (ValueError, TypeError, OverflowError):
normalized[key] = value
else:
normalized[key] = value
elif prop_type == 'number':
# Convert string to float
if isinstance(value, str):
try:
normalized[key] = float(value)
except (ValueError, TypeError, OverflowError):
normalized[key] = value
else:
normalized[key] = value
elif prop_type == 'boolean':
# Convert string booleans
if isinstance(value, str):
normalized[key] = value.lower() in ('true', '1', 'on', 'yes')
else:
normalized[key] = value
else:
normalized[key] = value
return normalized
# Normalize config before validation
if schema and 'properties' in schema:
plugin_config = normalize_config_values(plugin_config, schema['properties'])
# Filter config to only include schema-defined fields (important when additionalProperties is false)
# Use enhanced schema with core properties to ensure core properties are preserved during filtering
if schema and 'properties' in schema:
enhanced_schema_for_filtering = _enhance_schema_with_core_properties(schema)
plugin_config = _filter_config_by_schema(plugin_config, enhanced_schema_for_filtering)
# Debug logging for union type fields (temporary)
if 'rotation_settings' in plugin_config and 'random_seed' in plugin_config.get('rotation_settings', {}):
seed_value = plugin_config['rotation_settings']['random_seed']
logger.debug(f"After normalization, random_seed value: {repr(seed_value)}, type: {type(seed_value)}")
# Validate configuration against schema before saving
if schema:
# Log what we're validating for debugging
logger.info(f"Validating config for {plugin_id}")
# Only the shape. plugin_config still holds the submitted secret
# values at this point -- separate_secrets does not run until
# below -- so logging it wrote live credentials to the journal.
logger.info(f"Config keys being validated: {list(plugin_config.keys())}")
# Schema keys including the injected core properties, for the error
enhanced_schema = _enhance_schema_with_core_properties(schema)
is_valid, validation_errors = schema_mgr.validate_config_against_schema(
plugin_config, schema, plugin_id
)
if not is_valid:
# Log validation errors for debugging
logger.error(f"Config validation failed for {plugin_id}")
logger.error(f"Validation errors: {validation_errors}")
# Keys only, for the same reason as above.
logger.error(f"Config keys that failed: {list(plugin_config.keys())}")
logger.error(f"Schema properties: {list(enhanced_schema.get('properties', {}).keys())}")
# Also print to console for immediate visibility
logger.warning("Config validation failed for plugin (see debug logs)")
return None, None, error_response(
ErrorCode.CONFIG_VALIDATION_FAILED,
'Configuration validation failed',
details='; '.join(validation_errors) if validation_errors else 'Unknown validation error',
context={
'plugin_id': plugin_id,
'validation_errors': validation_errors,
'config_keys': list(plugin_config.keys()),
'schema_keys': list(enhanced_schema.get('properties', {}).keys())
},
suggested_fixes=[
'Review validation errors above',
'Check config against schema',
'Verify all required fields are present'
],
status_code=400
)
# Separate secrets from regular config (handles nested configs and
# array-item secrets — see src/web_interface/secret_helpers.py)
regular_config, secrets_config = separate_secrets(plugin_config, secret_fields)
# The config form renders secrets masked, so every save posts
# them back blank. Without this the blank is merged over the
# stored value and the credential is destroyed by the act of
# changing an unrelated setting. A blank means "unchanged".
secrets_config = remove_empty_secrets(secrets_config)
return regular_config, secrets_config, None
@api_v3.route('/plugins/schema', methods=['GET'])
def get_plugin_schema():
"""Get plugin configuration schema"""