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
+58 -12
View File
@@ -12,13 +12,12 @@ from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from app.utils.config_validator.masking import mask_sensitive_value
from app.utils.settings_service import (
SETTING_METADATA,
get_all_settings_from_db,
get_setting_metadata,
get_settings_by_category,
)
from app.views.base import APIRouter, get_db, require_login, settings, templates
from app.utils.settings_service import (SETTING_METADATA,
get_all_settings_from_db,
get_setting_metadata,
get_settings_by_category)
from app.views.base import (APIRouter, get_db, require_login, settings,
templates)
logger = logging.getLogger(__name__)
router = APIRouter()
@@ -103,7 +102,9 @@ async def settings_page(request: Request, db: Session = Depends(get_db)):
settings_data[category].append(
{
"key": key,
"display_value": display_value if display_value is not None else "",
"display_value": (
display_value if display_value is not None else ""
),
"metadata": metadata,
"source": source,
"source_label": source_label,
@@ -112,11 +113,19 @@ async def settings_page(request: Request, db: Session = Depends(get_db)):
)
return templates.TemplateResponse(
"settings.html", {"request": request, "settings_data": settings_data, "app_version": settings.version}
"settings.html",
{
"request": request,
"settings_data": settings_data,
"app_version": settings.version,
},
)
except Exception as e:
logger.error(f"Error loading settings page: {e}")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to load settings page")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to load settings page",
)
@router.get("/admin/credentials")
@@ -166,7 +175,9 @@ async def credentials_page(request: Request, db: Session = Depends(get_db)):
)
total = sum(len(v) for v in categories.values())
configured_count = sum(1 for creds in categories.values() for c in creds if c["configured"])
configured_count = sum(
1 for creds in categories.values() for c in creds if c["configured"]
)
return templates.TemplateResponse(
"credentials.html",
@@ -181,4 +192,39 @@ async def credentials_page(request: Request, db: Session = Depends(get_db)):
)
except Exception as e:
logger.error(f"Error loading credentials page: {e}")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to load credentials page")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to load credentials page",
)
@router.get("/admin/settings/audit-log")
@require_login
@require_admin_access
async def audit_log_page(request: Request, db: Session = Depends(get_db)):
"""
Settings audit log page - admin only.
Displays a chronological log of all configuration changes made via the
settings UI, including who made the change and what the old/new values
were. Sensitive values are masked. Provides rollback buttons to revert
any setting to a previous value.
"""
from app.utils.settings_service import get_audit_log
try:
entries = get_audit_log(db, limit=200)
return templates.TemplateResponse(
"audit_log.html",
{
"request": request,
"entries": entries,
"app_version": settings.version,
},
)
except Exception as e:
logger.error(f"Error loading audit log page: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to load audit log page",
)