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:
+209
-31
@@ -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}",
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user