feat(settings): per-option save, live worker sync, audit log, and rollback

A) Per-option Save Button
- Add per-setting Save button in settings.html (visible only when value changed)
- Button calls POST /api/settings/{key} directly; existing bulk Save retained
- Add Audit Log link in settings page header

B) Immediate Worker Sync
- New app/utils/settings_sync.py with notify_settings_updated() (Redis version key)
  and register_settings_reload_signal() (Celery task_prerun handler)
- Register signal in celery_worker.py at startup
- All API write paths call notify_settings_updated() after successful saves

C) Audit Log
- Add SettingsAuditLog model (key, old_value, new_value, changed_by, changed_at, action)
- save_setting_to_db / delete_setting_from_db accept changed_by and write audit entries
- New get_audit_log() service function (masks sensitive values)
- New GET /api/settings/audit-log endpoint (admin-only)
- New GET /admin/settings/audit-log view + audit_log.html template
- Visible to all admins (per clarified requirement)

D) Config Rollback / History
- New get_setting_history() and rollback_setting() service functions
- New GET /api/settings/{key}/history endpoint
- New POST /api/settings/{key}/rollback/{history_id} endpoint
- Rollback buttons in audit_log.html with confirmation dialog
- Tests: 25 new tests covering audit log, rollback, worker sync helpers, and API endpoints

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-02-23 02:28:08 +00:00
parent 05d03531b9
commit 90e5e0037c
9 changed files with 1321 additions and 76 deletions
+209 -31
View File
@@ -11,16 +11,17 @@ from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.utils.input_validation import validate_setting_key, validate_setting_key_format
from app.utils.settings_service import (
SETTING_METADATA,
delete_setting_from_db,
get_all_settings_from_db,
get_setting_metadata,
get_settings_by_category,
save_setting_to_db,
validate_setting_value,
)
from app.utils.input_validation import (validate_setting_key,
validate_setting_key_format)
from app.utils.settings_service import (SETTING_METADATA,
delete_setting_from_db,
get_all_settings_from_db,
get_audit_log, get_setting_history,
get_setting_metadata,
get_settings_by_category,
rollback_setting, save_setting_to_db,
validate_setting_value)
from app.utils.settings_sync import notify_settings_updated
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/settings", tags=["settings"])
@@ -36,7 +37,9 @@ def require_admin(request: Request) -> dict:
"""
user = request.session.get("user")
if not user or not user.get("is_admin"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required"
)
return user
@@ -79,7 +82,10 @@ async def get_settings(request: Request, db: DbSession, admin: AdminUser):
for key in SETTING_METADATA.keys():
if hasattr(settings, key):
value = getattr(settings, key)
current_settings[key] = {"value": value, "metadata": get_setting_metadata(key)}
current_settings[key] = {
"value": value,
"metadata": get_setting_metadata(key),
}
# Get settings stored in database
db_settings = get_all_settings_from_db(db)
@@ -87,10 +93,15 @@ async def get_settings(request: Request, db: DbSession, admin: AdminUser):
# Get settings organized by category
categories = get_settings_by_category()
return SettingsListResponse(settings=current_settings, categories=categories, db_settings=db_settings)
return SettingsListResponse(
settings=current_settings, categories=categories, db_settings=db_settings
)
except Exception as e:
logger.error(f"Error retrieving settings: {e}")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve settings")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve settings",
)
@router.get("/{key}", response_model=SettingResponse)
@@ -107,11 +118,14 @@ async def get_setting(key: str, request: Request, db: DbSession, admin: AdminUse
# Get metadata
metadata = get_setting_metadata(key)
return SettingResponse(key=key, value=str(value) if value is not None else None, metadata=metadata)
return SettingResponse(
key=key, value=str(value) if value is not None else None, metadata=metadata
)
except Exception as e:
logger.error(f"Error retrieving setting {key}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to retrieve setting: {key}"
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to retrieve setting: {key}",
)
@@ -133,15 +147,31 @@ async def update_setting(
if setting.value is not None:
is_valid, error_message = validate_setting_value(key, setting.value)
if not is_valid:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error_message)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=error_message
)
# Determine the username for the audit log
user = request.session.get("user", {}) if hasattr(request, "session") else {}
changed_by = (
user.get("preferred_username")
or user.get("username")
or user.get("email")
or user.get("id")
or "admin"
)
# Save to database
success = save_setting_to_db(db, key, setting.value)
success = save_setting_to_db(db, key, setting.value, changed_by=changed_by)
if not success:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to save setting to database"
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to save setting to database",
)
# Notify workers that settings have changed
notify_settings_updated()
# Get metadata
metadata = get_setting_metadata(key)
restart_required = metadata.get("restart_required", False)
@@ -158,7 +188,8 @@ async def update_setting(
except Exception as e:
logger.error(f"Error updating setting {key}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to update setting: {key}"
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update setting: {key}",
)
@@ -170,9 +201,23 @@ async def delete_setting(key: str, request: Request, db: DbSession, admin: Admin
"""
validate_setting_key(key)
try:
success = delete_setting_from_db(db, key)
user = request.session.get("user", {}) if hasattr(request, "session") else {}
changed_by = (
user.get("preferred_username")
or user.get("username")
or user.get("email")
or user.get("id")
or "admin"
)
success = delete_setting_from_db(db, key, changed_by=changed_by)
if not success:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Setting '{key}' not found in database")
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Setting '{key}' not found in database",
)
notify_settings_updated()
return {
"success": True,
@@ -183,7 +228,8 @@ async def delete_setting(key: str, request: Request, db: DbSession, admin: Admin
except Exception as e:
logger.error(f"Error deleting setting {key}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to delete setting: {key}"
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to delete setting: {key}",
)
@@ -238,11 +284,16 @@ async def list_credentials(request: Request, db: DbSession, admin: AdminUser):
}
except Exception as e:
logger.error(f"Error retrieving credential list: {e}")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to retrieve credentials")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve credentials",
)
@router.post("/bulk-update")
async def bulk_update_settings(updates: list[SettingUpdate], request: Request, db: DbSession, admin: AdminUser):
async def bulk_update_settings(
updates: list[SettingUpdate], request: Request, db: DbSession, admin: AdminUser
):
"""
Update multiple settings at once.
Admin only.
@@ -250,25 +301,152 @@ async def bulk_update_settings(updates: list[SettingUpdate], request: Request, d
results = []
errors = []
user = request.session.get("user", {}) if hasattr(request, "session") else {}
changed_by = (
user.get("preferred_username")
or user.get("username")
or user.get("email")
or user.get("id")
or "admin"
)
for update in updates:
try:
# Validate the setting value
if update.value is not None:
is_valid, error_message = validate_setting_value(update.key, update.value)
is_valid, error_message = validate_setting_value(
update.key, update.value
)
if not is_valid:
errors.append({"key": update.key, "error": error_message})
continue
# Save to database
success = save_setting_to_db(db, update.key, update.value)
success = save_setting_to_db(
db, update.key, update.value, changed_by=changed_by
)
if success:
results.append({"key": update.key, "value": update.value, "status": "success"})
results.append(
{"key": update.key, "value": update.value, "status": "success"}
)
else:
errors.append({"key": update.key, "error": "Failed to save to database"})
errors.append(
{"key": update.key, "error": "Failed to save to database"}
)
except Exception as e:
logger.error(f"Error updating setting {update.key}: {e}")
errors.append({"key": update.key, "error": str(e)})
restart_required = any(get_setting_metadata(result["key"]).get("restart_required", False) for result in results)
if results:
notify_settings_updated()
return {"success": len(errors) == 0, "updated": results, "errors": errors, "restart_required": restart_required}
restart_required = any(
get_setting_metadata(result["key"]).get("restart_required", False)
for result in results
)
return {
"success": len(errors) == 0,
"updated": results,
"errors": errors,
"restart_required": restart_required,
}
@router.get("/audit-log")
async def list_audit_log(
request: Request,
db: DbSession,
admin: AdminUser,
limit: int = 100,
offset: int = 0,
):
"""
Retrieve the settings audit log (most recent first).
Returns all configuration changes recorded in the audit log.
Sensitive values are masked in the response.
Admin only.
"""
try:
entries = get_audit_log(db, limit=limit, offset=offset)
return {"entries": entries, "limit": limit, "offset": offset}
except Exception as e:
logger.error(f"Error retrieving audit log: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to retrieve audit log",
)
@router.get("/{key}/history")
async def get_key_history(key: str, request: Request, db: DbSession, admin: AdminUser):
"""
Get the change history for a specific setting key.
Returns all audit log entries for that key, most recent first.
Admin only.
"""
validate_setting_key_format(key)
try:
entries = get_setting_history(db, key)
return {"key": key, "history": entries}
except Exception as e:
logger.error(f"Error retrieving history for {key}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to retrieve history for setting: {key}",
)
@router.post("/{key}/rollback/{history_id}")
async def rollback_setting_to_history(
key: str,
history_id: int,
request: Request,
db: DbSession,
admin: AdminUser,
):
"""
Revert a setting to the value it held at a specific point in the audit log.
The ``history_id`` is the ID of the :class:`~app.models.SettingsAuditLog`
entry whose ``new_value`` should be reinstated. If that entry recorded a
deletion (``new_value`` is ``None``), the setting is removed from the
database and reverts to its ENV/default value.
A new audit log entry is written to record the rollback.
Admin only.
"""
validate_setting_key_format(key)
try:
user = request.session.get("user", {}) if hasattr(request, "session") else {}
changed_by = (
user.get("preferred_username")
or user.get("username")
or user.get("email")
or user.get("id")
or "admin"
)
success = rollback_setting(db, key, history_id, changed_by=changed_by)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"History entry {history_id} not found for setting '{key}'",
)
notify_settings_updated()
return {
"success": True,
"message": f"Setting '{key}' rolled back to history entry {history_id}",
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error rolling back setting {key} to history {history_id}: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to roll back setting: {key}",
)