fix(config): Add defaults to schemas and fix config validation issues (#153)

* fix(web): Resolve font display and config API error handling issues

- Fix font catalog display error where path.startsWith fails
  (path is object, not string)
- Update save_main_config to use error_response() helper
- Improve save_raw_main_config error handling consistency
- Add proper error codes and traceback details to API responses

* fix(web): Prevent fontCatalog redeclaration error on HTMX reload

- Use window object to store global font variables
- Check if script has already loaded before declaring variables
- Update both window properties and local references on assignment
- Fixes 'Identifier fontCatalog has already been declared' error

* fix(web): Wrap fonts script in IIFE to prevent all redeclaration errors

- Wrap entire script in IIFE that only runs once
- Check if script already loaded before declaring variables/functions
- Expose initializeFontsTab to window for re-initialization
- Prevents 'Identifier has already been declared' errors on HTMX reload

* fix(web): Exempt config save API endpoints from CSRF protection

- Exempt save_raw_main_config, save_raw_secrets_config, and save_main_config from CSRF
- These endpoints are called via fetch from JavaScript and don't include CSRF tokens
- Fixes 500 error when saving config via raw JSON editor

* fix(web): Exempt system action endpoint from CSRF protection

- Exempt execute_system_action from CSRF
- Fixes 500 error when using system action buttons (restart display, restart Pi, etc.)
- These endpoints are called via HTMX and don't include CSRF tokens

* fix(web): Exempt all API v3 endpoints from CSRF protection

- Add before_request handler to exempt all api_v3.* endpoints
- All API endpoints are programmatic (HTMX/fetch) and don't include CSRF tokens
- Prevents future CSRF errors on any API endpoint
- Cleaner than exempting individual endpoints

* refactor(web): Remove CSRF protection for local-only application

- CSRF is designed for internet-facing apps to prevent cross-site attacks
- For local-only Raspberry Pi app, threat model is different
- All endpoints were exempted anyway, so it wasn't protecting anything
- Forms use HTMX without CSRF tokens
- If exposing to internet later, can re-enable with proper token implementation

* fix(web): Fix font path double-prefixing in font catalog display

- Only prefix with 'assets/fonts/' if path is a bare filename
- If path starts with '/' (absolute) or 'assets/' (already prefixed), use as-is
- Fixes double-prefixing when get_fonts_catalog returns relative paths like 'assets/fonts/press_start.ttf'

* fix(web): Remove fontsTabInitialized guard to allow re-initialization on HTMX reload

- Remove fontsTabInitialized check that prevented re-initialization on HTMX content swap
- The window._fontsScriptLoaded guard is sufficient to prevent function redeclaration
- Allow initializeFontsTab() to run on each HTMX swap to attach listeners to new DOM elements
- Fixes fonts UI breaking after HTMX reload (buttons, upload dropzone, etc. not working)

* fix(api): Preserve empty strings for optional string fields in plugin config

- Add _is_field_required() helper to check if fields are required in schema
- Update _parse_form_value_with_schema() to preserve empty strings for optional string fields
- Fixes 400 error when saving MQTT plugin config with empty username/password
- Resolves validation error: 'Expected type string, got NoneType'

* fix(config): Add defaults to schemas and fix None value handling

- Updated merge_with_defaults to replace None values with defaults
- Fixed form processing to skip empty optional fields without defaults
- Added script to automatically add defaults to all plugin config schemas
- Added defaults to 89 fields across 10 plugin schemas
- Prevents validation errors from None values in configs

Changes:
- schema_manager.py: Enhanced merge_with_defaults to replace None with defaults
- api_v3.py: Added _SKIP_FIELD sentinel to skip optional fields without defaults
- add_defaults_to_schemas.py: Script to add sensible defaults to schemas
- Plugin schemas: Added defaults for number, boolean, and array fields

* fix(config): Fix save button spinner by checking HTTP status code

- Fixed handleConfigSave to check xhr.status instead of event.detail.successful
- With hx-swap="none", HTMX doesn't set event.detail.successful
- Now properly detects successful saves (status 200-299) and stops spinner
- Improved error message extraction from API responses
- Also fixed handleToggleResponse for consistency

---------

Co-authored-by: Chuck <chuck@example.com>
This commit is contained in:
Chuck
2025-12-28 09:18:20 -05:00
committed by GitHub
parent d14b5ffb8f
commit 7ec4323ff4
5 changed files with 392 additions and 22 deletions

View File

@@ -0,0 +1,231 @@
#!/usr/bin/env python3
"""
Script to add default values to plugin config schemas where missing.
This ensures that configs never start with None values, improving user experience
and preventing validation errors.
"""
import json
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
def get_default_for_field(prop: Dict[str, Any]) -> Any:
"""
Determine a sensible default value for a field based on its type and constraints.
Args:
prop: Field property schema
Returns:
Default value or None if no default should be added
"""
prop_type = prop.get('type')
# Handle union types (array with multiple types)
if isinstance(prop_type, list):
# Use the first non-null type
prop_type = next((t for t in prop_type if t != 'null'), prop_type[0] if prop_type else 'string')
if prop_type == 'boolean':
return False
elif prop_type == 'number':
# For numbers, use minimum if available, or a sensible default
minimum = prop.get('minimum')
maximum = prop.get('maximum')
if minimum is not None:
return minimum
elif maximum is not None:
# Use a reasonable fraction of max (like 30% or minimum 1)
return max(1, int(maximum * 0.3))
else:
# No constraints, use 0
return 0
elif prop_type == 'integer':
# Similar to number
minimum = prop.get('minimum')
maximum = prop.get('maximum')
if minimum is not None:
return minimum
elif maximum is not None:
return max(1, int(maximum * 0.3))
else:
return 0
elif prop_type == 'string':
# Only add default for strings if it makes sense
# Check if there's an enum - use first value
enum_values = prop.get('enum')
if enum_values:
return enum_values[0]
# For optional string fields, empty string might be okay, but be cautious
# We'll skip adding defaults for strings unless explicitly needed
return None
elif prop_type == 'array':
# Empty array as default
return []
elif prop_type == 'object':
# Empty object - but we'll handle nested objects separately
return {}
return None
def should_add_default(prop: Dict[str, Any], field_path: str) -> bool:
"""
Determine if we should add a default value to this field.
Args:
prop: Field property schema
field_path: Dot-separated path to the field
Returns:
True if default should be added
"""
# Skip if already has a default
if 'default' in prop:
return False
# Skip secret fields (they should be user-provided)
if prop.get('x-secret', False):
return False
# Skip API keys and similar sensitive fields
field_name = field_path.split('.')[-1].lower()
sensitive_keywords = ['key', 'password', 'secret', 'token', 'auth', 'credential']
if any(keyword in field_name for keyword in sensitive_keywords):
return False
prop_type = prop.get('type')
if isinstance(prop_type, list):
prop_type = next((t for t in prop_type if t != 'null'), prop_type[0] if prop_type else None)
# Only add defaults for certain types
if prop_type in ('boolean', 'number', 'integer', 'array'):
return True
# For strings, only if there's an enum
if prop_type == 'string' and 'enum' in prop:
return True
return False
def add_defaults_recursive(schema: Dict[str, Any], path: str = "", modified: List[str] = None) -> bool:
"""
Recursively add default values to schema fields.
Args:
schema: Schema dictionary to modify
path: Current path in the schema (for logging)
modified: List to track which fields were modified
Returns:
True if any modifications were made
"""
if modified is None:
modified = []
if not isinstance(schema, dict) or 'properties' not in schema:
return False
changes_made = False
for key, prop in schema['properties'].items():
if not isinstance(prop, dict):
continue
current_path = f"{path}.{key}" if path else key
# Check nested objects
if prop.get('type') == 'object' and 'properties' in prop:
if add_defaults_recursive(prop, current_path, modified):
changes_made = True
# Add default if appropriate
if should_add_default(prop, current_path):
default_value = get_default_for_field(prop)
if default_value is not None:
prop['default'] = default_value
modified.append(current_path)
changes_made = True
print(f" Added default to {current_path}: {default_value} (type: {prop.get('type')})")
return changes_made
def process_schema_file(schema_path: Path) -> bool:
"""
Process a single schema file to add defaults.
Args:
schema_path: Path to the schema file
Returns:
True if file was modified
"""
print(f"\nProcessing: {schema_path}")
try:
with open(schema_path, 'r', encoding='utf-8') as f:
schema = json.load(f)
except Exception as e:
print(f" Error reading schema: {e}")
return False
modified_fields = []
changes_made = add_defaults_recursive(schema, modified=modified_fields)
if changes_made:
# Write back with pretty formatting
with open(schema_path, 'w', encoding='utf-8') as f:
json.dump(schema, f, indent=2, ensure_ascii=False)
f.write('\n') # Add trailing newline
print(f" ✓ Modified {len(modified_fields)} fields")
return True
else:
print(f" ✓ No changes needed")
return False
def main():
"""Main entry point."""
project_root = Path(__file__).parent.parent
plugins_dir = project_root / 'plugins'
if not plugins_dir.exists():
print(f"Error: Plugins directory not found: {plugins_dir}")
sys.exit(1)
# Find all config_schema.json files
schema_files = list(plugins_dir.rglob('config_schema.json'))
if not schema_files:
print("No config_schema.json files found")
sys.exit(0)
print(f"Found {len(schema_files)} schema files")
modified_count = 0
for schema_file in sorted(schema_files):
if process_schema_file(schema_file):
modified_count += 1
print(f"\n{'='*60}")
print(f"Summary: Modified {modified_count} out of {len(schema_files)} schema files")
print(f"{'='*60}")
if __name__ == '__main__':
main()

View File

@@ -394,24 +394,54 @@ class SchemaManager:
def merge_with_defaults(self, config: Dict[str, Any], defaults: Dict[str, Any]) -> Dict[str, Any]: def merge_with_defaults(self, config: Dict[str, Any], defaults: Dict[str, Any]) -> Dict[str, Any]:
""" """
Merge configuration with defaults, preserving user values. Merge configuration with defaults, preserving user values.
Also replaces None values with defaults to ensure config never has None from the start.
Args: Args:
config: User configuration config: User configuration
defaults: Default values from schema defaults: Default values from schema
Returns: Returns:
Merged configuration with defaults applied where missing Merged configuration with defaults applied where missing or None
""" """
merged = defaults.copy() merged = copy.deepcopy(defaults)
def deep_merge(target: Dict[str, Any], source: Dict[str, Any]) -> None: def deep_merge(target: Dict[str, Any], source: Dict[str, Any], default_dict: Dict[str, Any]) -> None:
"""Recursively merge source into target.""" """Recursively merge source into target, replacing None with defaults."""
for key, value in source.items(): for key, value in source.items():
default_value = default_dict.get(key)
if key in target and isinstance(target[key], dict) and isinstance(value, dict): if key in target and isinstance(target[key], dict) and isinstance(value, dict):
deep_merge(target[key], value) # Both are dicts, recursively merge
if isinstance(default_value, dict):
deep_merge(target[key], value, default_value)
else:
deep_merge(target[key], value, {})
elif value is None and default_value is not None:
# Value is None and we have a default, use the default
target[key] = copy.deepcopy(default_value) if isinstance(default_value, (dict, list)) else default_value
else:
# Normal merge: user value takes precedence (copy if dict/list)
if isinstance(value, (dict, list)):
target[key] = copy.deepcopy(value)
else: else:
target[key] = value target[key] = value
deep_merge(merged, config) deep_merge(merged, config, defaults)
# Final pass: replace any remaining None values at any level with defaults
def replace_none_with_defaults(target: Dict[str, Any], default_dict: Dict[str, Any]) -> None:
"""Recursively replace None values with defaults."""
for key in list(target.keys()):
value = target[key]
default_value = default_dict.get(key)
if value is None and default_value is not None:
# Replace None with default
target[key] = copy.deepcopy(default_value) if isinstance(default_value, (dict, list)) else default_value
elif isinstance(value, dict) and isinstance(default_value, dict):
# Recursively process nested dicts
replace_none_with_defaults(value, default_value)
replace_none_with_defaults(merged, defaults)
return merged return merged

View File

@@ -2914,6 +2914,43 @@ def _get_schema_property(schema, key_path):
return None return None
def _is_field_required(key_path, schema):
"""
Check if a field is required according to the schema.
Args:
key_path: Dot-separated path like "mqtt.username"
schema: The JSON schema dict
Returns:
True if field is required, False otherwise
"""
if not schema or 'properties' not in schema:
return False
parts = key_path.split('.')
if len(parts) == 1:
# Top-level field
required = schema.get('required', [])
return parts[0] in required
else:
# Nested field - navigate to parent object
parent_path = '.'.join(parts[:-1])
field_name = parts[-1]
# Get parent property
parent_prop = _get_schema_property(schema, parent_path)
if not parent_prop or 'properties' not in parent_prop:
return False
# Check if field is required in parent
required = parent_prop.get('required', [])
return field_name in required
# Sentinel object to indicate a field should be skipped (not set in config)
_SKIP_FIELD = object()
def _parse_form_value_with_schema(value, key_path, schema): def _parse_form_value_with_schema(value, key_path, schema):
""" """
Parse a form value using schema information to determine correct type. Parse a form value using schema information to determine correct type.
@@ -2925,7 +2962,7 @@ def _parse_form_value_with_schema(value, key_path, schema):
schema: The plugin's JSON schema schema: The plugin's JSON schema
Returns: Returns:
Parsed value with correct type Parsed value with correct type, or _SKIP_FIELD to indicate the field should not be set
""" """
import json import json
@@ -2940,6 +2977,22 @@ def _parse_form_value_with_schema(value, key_path, schema):
# If schema says it's an object, return empty dict instead of None # If schema says it's an object, return empty dict instead of None
if prop and prop.get('type') == 'object': if prop and prop.get('type') == 'object':
return {} return {}
# If it's an optional string field, preserve empty string instead of None
if prop and prop.get('type') == 'string':
if not _is_field_required(key_path, schema):
return "" # Return empty string for optional string fields
# For number/integer fields, check if they have defaults or are required
if prop:
prop_type = prop.get('type')
if prop_type in ('number', 'integer'):
# If field has a default, use it
if 'default' in prop:
return prop['default']
# If field is not required and has no default, skip setting it
if not _is_field_required(key_path, schema):
return _SKIP_FIELD
# If field is required but empty, return None (validation will fail, which is correct)
return None
return None return None
# Handle string values # Handle string values
@@ -3029,8 +3082,12 @@ def _set_nested_value(config, key_path, value):
Args: Args:
config: The config dict to modify config: The config dict to modify
key_path: Dot-separated path (e.g., "customization.period_text.font") key_path: Dot-separated path (e.g., "customization.period_text.font")
value: The value to set value: The value to set (or _SKIP_FIELD to skip setting)
""" """
# Skip setting if value is the sentinel
if value is _SKIP_FIELD:
return
parts = key_path.split('.') parts = key_path.split('.')
current = config current = config
@@ -3231,6 +3288,8 @@ def save_plugin_config():
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.debug(f"Combined indexed array field {base_path}: {values} -> {combined_value} -> {parsed_value}") logger.debug(f"Combined indexed array field {base_path}: {values} -> {combined_value} -> {parsed_value}")
# Only set if not skipped
if parsed_value is not _SKIP_FIELD:
_set_nested_value(plugin_config, base_path, parsed_value) _set_nested_value(plugin_config, base_path, parsed_value)
# Process remaining (non-indexed) fields # Process remaining (non-indexed) fields
@@ -3249,7 +3308,8 @@ def save_plugin_config():
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
logger.debug(f"Array field {key}: form value='{value}' -> parsed={parsed_value}") logger.debug(f"Array field {key}: form value='{value}' -> parsed={parsed_value}")
# Use helper to set nested values correctly # Use helper to set nested values correctly (skips if _SKIP_FIELD)
if parsed_value is not _SKIP_FIELD:
_set_nested_value(plugin_config, key, parsed_value) _set_nested_value(plugin_config, key, parsed_value)
# Post-process: Fix array fields that might have been incorrectly structured # Post-process: Fix array fields that might have been incorrectly structured

View File

@@ -4209,13 +4209,28 @@
const btn = event.target.querySelector('[type=submit]'); const btn = event.target.querySelector('[type=submit]');
if (btn) btn.disabled = false; if (btn) btn.disabled = false;
if (event.detail.successful) {
showNotification('Configuration saved successfully!', 'success');
} else {
// Log detailed error information
const xhr = event.detail.xhr; const xhr = event.detail.xhr;
const status = xhr?.status || 0;
// Check if request was successful (2xx status codes)
if (status >= 200 && status < 300) {
// Try to get message from response JSON
let message = 'Configuration saved successfully!';
try {
if (xhr?.responseJSON?.message) {
message = xhr.responseJSON.message;
} else if (xhr?.responseText) {
const responseData = JSON.parse(xhr.responseText);
message = responseData.message || message;
}
} catch (e) {
// Use default message if parsing fails
}
showNotification(message, 'success');
} else {
// Request failed - log detailed error information
console.error('Config save failed:', { console.error('Config save failed:', {
status: xhr?.status, status: status,
statusText: xhr?.statusText, statusText: xhr?.statusText,
responseText: xhr?.responseText responseText: xhr?.responseText
}); });
@@ -4223,7 +4238,13 @@
// Try to parse error response // Try to parse error response
let errorMessage = 'Failed to save configuration'; let errorMessage = 'Failed to save configuration';
try { try {
if (xhr?.responseText) { if (xhr?.responseJSON) {
const errorData = xhr.responseJSON;
errorMessage = errorData.message || errorData.details || errorMessage;
if (errorData.validation_errors) {
errorMessage += ': ' + errorData.validation_errors.join(', ');
}
} else if (xhr?.responseText) {
const errorData = JSON.parse(xhr.responseText); const errorData = JSON.parse(xhr.responseText);
errorMessage = errorData.message || errorData.details || errorMessage; errorMessage = errorData.message || errorData.details || errorMessage;
if (errorData.validation_errors) { if (errorData.validation_errors) {
@@ -4241,7 +4262,10 @@
// Handle toggle response // Handle toggle response
window.handleToggleResponse = function(event, pluginId) { window.handleToggleResponse = function(event, pluginId) {
if (event.detail.successful) { const xhr = event.detail.xhr;
const status = xhr?.status || 0;
if (status >= 200 && status < 300) {
// Update UI in place instead of refreshing to avoid duplication // Update UI in place instead of refreshing to avoid duplication
const checkbox = document.getElementById(`plugin-enabled-${pluginId}`); const checkbox = document.getElementById(`plugin-enabled-${pluginId}`);
const label = checkbox?.nextElementSibling; const label = checkbox?.nextElementSibling;
@@ -4252,14 +4276,39 @@
label.className = `ml-2 text-sm ${isEnabled ? 'text-green-600' : 'text-gray-500'}`; label.className = `ml-2 text-sm ${isEnabled ? 'text-green-600' : 'text-gray-500'}`;
} }
showNotification('Plugin status updated', 'success'); // Try to get message from response
let message = 'Plugin status updated';
try {
if (xhr?.responseJSON?.message) {
message = xhr.responseJSON.message;
} else if (xhr?.responseText) {
const responseData = JSON.parse(xhr.responseText);
message = responseData.message || message;
}
} catch (e) {
// Use default message
}
showNotification(message, 'success');
} else { } else {
// Revert checkbox state on error // Revert checkbox state on error
const checkbox = document.getElementById(`plugin-enabled-${pluginId}`); const checkbox = document.getElementById(`plugin-enabled-${pluginId}`);
if (checkbox) { if (checkbox) {
checkbox.checked = !checkbox.checked; checkbox.checked = !checkbox.checked;
} }
showNotification('Failed to update plugin status', 'error');
// Try to get error message from response
let errorMessage = 'Failed to update plugin status';
try {
if (xhr?.responseJSON?.message) {
errorMessage = xhr.responseJSON.message;
} else if (xhr?.responseText) {
const errorData = JSON.parse(xhr.responseText);
errorMessage = errorData.message || errorData.details || errorMessage;
}
} catch (e) {
// Use default message
}
showNotification(errorMessage, 'error');
} }
}; };