feat(config): add automatic persistence of default values in config validation

- Enhance config validation to automatically add and persist default values
- Use temporary files for validation to prevent overwriting original config
- Update UI to reload configuration data when defaults are added during validation
- Improve user experience by eliminating manual addition of missing defaults

The validation process now detects when defaults are added during validation and automatically saves them to the original config file, with the web UI updating to reflect these changes immediately.
This commit is contained in:
bobokun 2025-08-28 14:31:09 -04:00
parent 0b540b8d8a
commit 7cdd31d2c7
No known key found for this signature in database
GPG key ID: B73932169607D927
4 changed files with 169 additions and 36 deletions

View file

@ -1 +1 @@
4.5.6-develop6
4.5.6-develop7

View file

@ -5,14 +5,17 @@ from __future__ import annotations
import asyncio
import json
import logging
import math
import os
import re
import shutil
import tempfile
import uuid
from contextlib import asynccontextmanager
from dataclasses import dataclass
from dataclasses import field
from datetime import datetime
from datetime import timedelta
from multiprocessing import Queue
from multiprocessing.sharedctypes import Synchronized
from pathlib import Path
@ -27,12 +30,18 @@ from fastapi import HTTPException
from fastapi import Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastapi.responses import PlainTextResponse
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from humanize import precisedelta
from pydantic import BaseModel
from modules import util
from modules.config import Config
from modules.scheduler import Scheduler
from modules.util import YAML
from modules.util import EnvStr
from modules.util import execute_qbit_commands
from modules.util import format_stats_summary
from modules.util import get_matching_config_files
@ -85,6 +94,7 @@ class ValidationResponse(BaseModel):
valid: bool
errors: list[str] = []
warnings: list[str] = []
config_modified: bool = False
class HealthCheckResponse(BaseModel):
@ -276,8 +286,6 @@ class WebAPI:
async def serve_index():
# If base URL is configured, redirect to the base URL path
if base_url:
from fastapi.responses import RedirectResponse
return RedirectResponse(url=base_url + "/", status_code=302)
# Otherwise, serve the web UI normally
@ -341,7 +349,6 @@ class WebAPI:
if qbit_manager:
# Execute qBittorrent commands using shared function
from modules.util import execute_qbit_commands
execute_qbit_commands(qbit_manager, args, stats, hashes=hashes)
@ -764,25 +771,58 @@ class WebAPI:
raise HTTPException(status_code=500, detail=str(e))
async def validate_config(self, filename: str, request: ConfigRequest) -> ValidationResponse:
"""Validate a configuration."""
"""Validate a configuration using a temporary file, but persist changes if defaults are added."""
try:
errors = []
warnings = []
config_modified = False
# Create temporary config for validation
# Get the actual config file path
config_path = self.config_path / filename
if not config_path.exists():
raise HTTPException(status_code=404, detail=f"Config file '{filename}' not found")
# Load original config
original_yaml = None
try:
original_yaml = YAML(str(config_path))
except Exception as e:
logger.error(f"Error reading original config: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to read original config: {str(e)}")
# Create temporary config file for validation
temp_config_path = None
try:
# Create a temporary file in the same directory as the config
temp_fd, temp_path = tempfile.mkstemp(suffix=".yml", dir=str(config_path.parent))
temp_config_path = Path(temp_path)
# Convert !ENV strings back to EnvStr objects before saving
processed_data = self._restore_env_objects(request.data)
# Write to temporary file for validation
temp_yaml = YAML(str(temp_config_path))
temp_yaml.data = processed_data
temp_yaml.save_preserving_format(processed_data)
# Close the file descriptor
os.close(temp_fd)
except Exception as e:
logger.error(f"Error creating temporary config: {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to create temporary config: {str(e)}")
# Create validation args using the temporary file
now = datetime.now()
temp_args = self.args.copy()
temp_args["config_file"] = filename
temp_args["config_file"] = temp_config_path.name # Use temp file name
temp_args["_from_web_api"] = True
temp_args["time"] = now.strftime("%H:%M")
temp_args["time_obj"] = now
temp_args["run"] = True
# Write temporary config file for validation
temp_config_path = self.config_path / f".temp_{filename}"
try:
logger.separator("Configuration Validation Check", space=False, border=False)
self._write_yaml_config(temp_config_path, request.data)
# Try to load config using existing validation logic
try:
@ -794,19 +834,51 @@ class WebAPI:
if valid:
logger.separator("Configuration Valid", space=False, border=False)
# Check if temp config was modified during validation
try:
# Reload the temp config to see if it was modified
modified_temp_yaml = YAML(str(temp_config_path))
modified_temp_data = modified_temp_yaml.data.copy() if modified_temp_yaml.data else {}
# Compare the data structures
if processed_data != modified_temp_data:
config_modified = True
logger.info("Configuration was modified during validation (defaults added)")
# If config was modified, copy the changes to the original file
try:
original_yaml.data = modified_temp_data
original_yaml.save_preserving_format(modified_temp_data)
logger.info("Successfully applied validation changes to original config")
except Exception as copy_error:
logger.error(f"Failed to copy changes to original config: {str(copy_error)}")
# Don't fail the validation if we can't copy changes
except Exception as e:
logger.warning(f"Error checking if config was modified: {str(e)}")
except Exception as e:
logger.error(f"Validation failed: {str(e)}")
raise
finally:
# Clean up temporary file
if temp_config_path.exists():
temp_config_path.unlink()
try:
if temp_config_path and temp_config_path.exists():
temp_config_path.unlink()
logger.debug(f"Cleaned up temporary config file: {temp_config_path}")
except Exception as cleanup_error:
logger.warning(f"Failed to clean up temporary config file: {str(cleanup_error)}")
return ValidationResponse(valid=valid, errors=errors, warnings=warnings)
# Create response with modification info
response_data = {"valid": valid, "errors": errors, "warnings": warnings, "config_modified": config_modified}
logger.info(f"Validation response: {response_data}")
return ValidationResponse(**response_data)
except Exception as e:
logger.error(f"Error validating config '{filename}': {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
def _write_yaml_config(self, config_path: Path, data: dict[str, Any]):
"""Write configuration data to YAML file while preserving formatting and comments."""
from modules.util import YAML
try:
logger.trace(f"Attempting to write config to: {config_path}")
@ -814,15 +886,18 @@ class WebAPI:
logger.trace(f"Data to write: {data}")
# Convert !ENV strings back to EnvStr objects
processed_data = self._convert_env_strings_to_objects(data)
# Use the custom YAML class with format preservation
if config_path.exists():
# Load existing file to preserve formatting
yaml_writer = YAML(path=str(config_path))
yaml_writer.save_preserving_format(data)
yaml_writer.save_preserving_format(processed_data)
else:
# Create new file with standard formatting
yaml_writer = YAML(input_data="")
yaml_writer.data = data
yaml_writer.data = processed_data
yaml_writer.path = str(config_path)
yaml_writer.save()
@ -1026,8 +1101,6 @@ class WebAPI:
with open(docs_path, encoding="utf-8") as f:
content = f.read()
from fastapi.responses import PlainTextResponse
return PlainTextResponse(content=content, media_type="text/markdown")
except HTTPException:
@ -1174,7 +1247,6 @@ class WebAPI:
def _preserve_env_syntax(self, data):
"""Convert EnvStr objects back to !ENV syntax for frontend display"""
from modules.util import EnvStr
if isinstance(data, EnvStr):
# Return the original !ENV syntax
@ -1191,9 +1263,6 @@ class WebAPI:
def _restore_env_objects(self, data):
"""Convert !ENV syntax back to EnvStr objects for proper YAML serialization."""
import os
from modules.util import EnvStr
if isinstance(data, str) and data.startswith("!ENV "):
env_var = data[5:] # Remove "!ENV " prefix
@ -1208,7 +1277,6 @@ class WebAPI:
def _log_env_str_values(self, data, path):
"""Helper method to log EnvStr values for debugging"""
from modules.util import EnvStr
if isinstance(data, dict):
for key, value in data.items():
@ -1229,7 +1297,6 @@ class WebAPI:
"""Get complete scheduler status including schedule configuration and persistence information."""
try:
# Always create a fresh scheduler instance to get current state
from modules.scheduler import Scheduler
fresh_scheduler = Scheduler(self.default_dir, suppress_logging=True, read_only=True)
@ -1279,8 +1346,6 @@ class WebAPI:
async def update_schedule(self, request: Request) -> dict:
"""Update and persist schedule configuration with diagnostic instrumentation."""
try:
from modules.scheduler import Scheduler
correlation_id = uuid.uuid4().hex[:12]
client_host = "n/a"
if getattr(request, "client", None):
@ -1385,8 +1450,6 @@ class WebAPI:
Toggle persistent schedule enable/disable (non-destructive) with diagnostics.
"""
try:
from modules.scheduler import Scheduler
correlation_id = uuid.uuid4().hex[:12]
scheduler = Scheduler(self.default_dir, suppress_logging=True, read_only=True)
file_exists_before = scheduler.schedule_file.exists()
@ -1453,11 +1516,6 @@ class WebAPI:
def _update_next_run_info(self, next_run: datetime):
"""Update the shared next run info dictionary."""
try:
import math
from datetime import timedelta
from humanize import precisedelta
current_time = datetime.now()
current = current_time.strftime("%I:%M %p")
time_to_run_str = next_run.strftime("%Y-%m-%d %I:%M %p")

View file

@ -635,7 +635,34 @@ class QbitManageApp {
hideLoading();
if (response.valid) {
showToast('Configuration is valid', 'success');
// Check if config was modified during validation
if (response.config_modified) {
showToast('Configuration validated successfully! Default values have been added.', 'success');
// Reload the configuration data from the server to reflect changes
try {
const configResponse = await this.api.getConfig(this.currentConfig);
if (configResponse && configResponse.data) {
// Update the app's config data
this.configData = configResponse.data;
// Reload the current section to reflect changes
if (this.configForm && this.currentSection) {
await this.configForm.loadSection(this.currentSection, this.configData[this.currentSection] || {});
}
// Update YAML preview if it's open
if (this.yamlPreviewVisible) {
this.updateYamlPreview();
}
}
} catch (reloadError) {
console.error('Error reloading config after main validation:', reloadError);
showToast('Configuration validated but failed to reload updated data.', 'warning');
}
} else {
showToast('Configuration is valid', 'success');
}
} else {
// Pass the errors array directly instead of converting to string
this.showValidationModal('Configuration Validation Failed', response.errors, response.warnings);

View file

@ -1391,7 +1391,7 @@ class ConfigForm {
return null;
}
validateSection() {
async validateSection() {
const sectionConfig = this.schemas[this.currentSection];
this.validationState = { valid: true, errors: [], warnings: [] };
@ -1417,7 +1417,55 @@ class ConfigForm {
this.updateValidationDisplay();
if (this.validationState.errors.length === 0) {
showToast('Section is valid!', 'success');
// Perform backend validation which may add default values
try {
const response = await this.api.validateConfig(this.currentSection, this.currentData);
if (response.valid) {
// Check if config was modified during validation
if (response.config_modified) {
showToast('Configuration validated successfully! Default values have been added.', 'success');
// Reload the configuration data from the server to reflect changes
try {
const configResponse = await this.api.getConfig(this.currentSection);
if (configResponse && configResponse.data) {
// Update current data with the modified config
this.currentData = this._preprocessComplexObjectData(this.currentSection, configResponse.data);
// Store initial data only once per section
if (!this.initialSectionData[this.currentSection]) {
this.initialSectionData[this.currentSection] = JSON.parse(JSON.stringify(this.currentData));
}
// Always reset to initial data when loading a section
this.originalData = JSON.parse(JSON.stringify(this.initialSectionData[this.currentSection]));
// Re-render the section with updated data
await this.renderSection();
// Notify parent component of data change
this.onDataChange(this.currentData);
}
} catch (reloadError) {
console.error('Error reloading config after validation:', reloadError);
showToast('Configuration validated but failed to reload updated data.', 'warning');
}
} else {
showToast('Configuration validated successfully!', 'success');
}
} else {
// Handle validation errors from backend
this.validationState.valid = false;
this.validationState.errors = response.errors || [];
this.validationState.warnings = response.warnings || [];
this.onValidationChange(this.validationState);
this.updateValidationDisplay();
}
} catch (error) {
console.error('Error during backend validation:', error);
showToast('Failed to validate configuration with backend.', 'error');
}
}
}