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}",
)
+17 -9
View File
@@ -3,30 +3,32 @@
from celery.schedules import crontab
# Ensure tasks are loaded
from app import tasks # noqa: F401 - Imports app/tasks.py so Celery can register tasks
from app import \
tasks # noqa: F401 - Imports app/tasks.py so Celery can register tasks
# Import the shared Celery instance
from app.celery_app import celery
from app.config import settings
from app.tasks.check_credentials import check_credentials
from app.tasks.convert_to_pdf import convert_to_pdf # noqa: F401
from app.tasks.embed_metadata_into_pdf import embed_metadata_into_pdf # noqa: F401
from app.tasks.extract_metadata_with_gpt import extract_metadata_with_gpt # noqa: F401
from app.tasks.embed_metadata_into_pdf import \
embed_metadata_into_pdf # noqa: F401
from app.tasks.extract_metadata_with_gpt import \
extract_metadata_with_gpt # noqa: F401
from app.tasks.imap_tasks import pull_all_inboxes # noqa: F401
from app.tasks.monitor_stalled_steps import monitor_stalled_steps # noqa: F401
# **Ensure all tasks are imported before Celery starts**
from app.tasks.process_document import process_document # noqa: F401
from app.tasks.process_with_azure_document_intelligence import process_with_azure_document_intelligence # noqa: F401
from app.tasks.process_with_azure_document_intelligence import \
process_with_azure_document_intelligence # noqa: F401
from app.tasks.refine_text_with_gpt import refine_text_with_gpt # noqa: F401
from app.tasks.rotate_pdf_pages import rotate_pdf_pages # noqa: F401
from app.tasks.send_to_all import send_to_all_destinations # noqa: F401
# Import new send tasks
from app.tasks.upload_to_dropbox import upload_to_dropbox # noqa: F401
from app.tasks.upload_to_email import upload_to_email # noqa: F401
from app.tasks.upload_to_ftp import upload_to_ftp # noqa: F401
from app.tasks.upload_to_google_drive import upload_to_google_drive # noqa: F401
from app.tasks.upload_to_google_drive import \
upload_to_google_drive # noqa: F401
from app.tasks.upload_to_nextcloud import upload_to_nextcloud # noqa: F401
from app.tasks.upload_to_onedrive import upload_to_onedrive # noqa: F401
from app.tasks.upload_to_paperless import upload_to_paperless # noqa: F401
@@ -34,6 +36,10 @@ from app.tasks.upload_to_s3 import upload_to_s3 # noqa: F401
from app.tasks.upload_to_sftp import upload_to_sftp # noqa: F401
from app.tasks.upload_to_webdav import upload_to_webdav # noqa: F401
from app.tasks.uptime_kuma_tasks import ping_uptime_kuma # noqa: F401
# Register the settings reload signal handler so workers pick up config changes
from app.utils.settings_sync import register_settings_reload_signal
register_settings_reload_signal()
celery.conf.task_routes = {
"app.tasks.*": {"queue": "default"},
@@ -89,4 +95,6 @@ celery.conf.beat_schedule = {
}
# Remove None entries from beat_schedule
celery.conf.beat_schedule = {k: v for k, v in celery.conf.beat_schedule.items() if v is not None}
celery.conf.beat_schedule = {
k: v for k, v in celery.conf.beat_schedule.items() if v is not None
}
+45 -10
View File
@@ -1,6 +1,7 @@
# app/models.py
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
from sqlalchemy import (Boolean, Column, DateTime, ForeignKey, Integer, String,
Text, UniqueConstraint, func)
from app.database import Base
@@ -69,21 +70,33 @@ class FileProcessingStep(Base):
id = Column(Integer, primary_key=True, index=True)
file_id = Column(Integer, ForeignKey(_FILES_ID_FK), nullable=False, index=True)
step_name = Column(String, nullable=False, index=True) # e.g., "hash_file", "upload_to_dropbox"
status = Column(String, nullable=False) # "pending", "in_progress", "success", "failure", "skipped"
step_name = Column(
String, nullable=False, index=True
) # e.g., "hash_file", "upload_to_dropbox"
status = Column(
String, nullable=False
) # "pending", "in_progress", "success", "failure", "skipped"
started_at = Column(DateTime(timezone=True), nullable=True) # When step started
completed_at = Column(DateTime(timezone=True), nullable=True) # When step finished (success/failure)
completed_at = Column(
DateTime(timezone=True), nullable=True
) # When step finished (success/failure)
error_message = Column(Text, nullable=True) # Error message if status is "failure"
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
updated_at = Column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
__table_args__ = (UniqueConstraint("file_id", "step_name", name="unique_file_step"),)
__table_args__ = (
UniqueConstraint("file_id", "step_name", name="unique_file_step"),
)
class ProcessingLog(Base):
__tablename__ = "processing_logs"
id = Column(Integer, primary_key=True, index=True)
file_id = Column(Integer, ForeignKey(_FILES_ID_FK), nullable=True) # Optional file association
file_id = Column(
Integer, ForeignKey(_FILES_ID_FK), nullable=True
) # Optional file association
task_id = Column(String, index=True) # Celery task ID
step_name = Column(String) # e.g., "OCR", "convert_to_pdf", "upload_s3"
status = Column(String) # "pending", "in_progress", "success", "failure"
@@ -98,7 +111,29 @@ class ApplicationSettings(Base):
__tablename__ = "application_settings"
id = Column(Integer, primary_key=True, index=True)
key = Column(String, unique=True, index=True, nullable=False) # Setting key (e.g., 'database_url')
value = Column(String, nullable=True) # Setting value (stored as string, converted as needed)
key = Column(
String, unique=True, index=True, nullable=False
) # Setting key (e.g., 'database_url')
value = Column(
String, nullable=True
) # Setting value (stored as string, converted as needed)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
updated_at = Column(
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
)
class SettingsAuditLog(Base):
"""Audit log for all configuration changes made via the settings UI."""
__tablename__ = "settings_audit_log"
id = Column(Integer, primary_key=True, index=True)
key = Column(String, nullable=False, index=True) # Setting key that was changed
old_value = Column(String, nullable=True) # Previous value (None if first-time set)
new_value = Column(String, nullable=True) # New value (None if deleted)
changed_by = Column(
String, nullable=False
) # Username of the admin who made the change
changed_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
action = Column(String, nullable=False) # "update" or "delete"
+236 -10
View File
@@ -13,7 +13,7 @@ from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session
from app.models import ApplicationSettings
from app.models import ApplicationSettings, SettingsAuditLog
logger = logging.getLogger(__name__)
@@ -871,7 +871,9 @@ def get_setting_from_db(db: Session, key: str) -> Optional[str]:
Setting value as string (decrypted if necessary), or None if not found
"""
try:
setting = db.query(ApplicationSettings).filter(ApplicationSettings.key == key).first()
setting = (
db.query(ApplicationSettings).filter(ApplicationSettings.key == key).first()
)
if not setting:
return None
@@ -888,16 +890,20 @@ def get_setting_from_db(db: Session, key: str) -> Optional[str]:
return None
def save_setting_to_db(db: Session, key: str, value: Optional[str]) -> bool:
def save_setting_to_db(
db: Session, key: str, value: Optional[str], changed_by: str = "system"
) -> bool:
"""
Save or update a setting in the database.
Automatically encrypts sensitive values if encryption is enabled.
Records an entry in the settings audit log.
Args:
db: Database session
key: Setting key
value: Setting value (as string)
changed_by: Username of the admin performing the change (for audit log)
Returns:
True if successful, False otherwise
@@ -908,22 +914,53 @@ def save_setting_to_db(db: Session, key: str, value: Optional[str]) -> bool:
storage_value = value
if metadata.get("sensitive", False) and value:
from app.utils.encryption import encrypt_value, is_encryption_available
from app.utils.encryption import (encrypt_value,
is_encryption_available)
if is_encryption_available():
storage_value = encrypt_value(value)
logger.debug(f"Encrypted sensitive setting: {key}")
else:
logger.warning(f"Storing sensitive setting {key} in plaintext (encryption unavailable)")
logger.warning(
f"Storing sensitive setting {key} in plaintext (encryption unavailable)"
)
setting = (
db.query(ApplicationSettings).filter(ApplicationSettings.key == key).first()
)
old_storage_value = setting.value if setting else None
setting = db.query(ApplicationSettings).filter(ApplicationSettings.key == key).first()
if setting:
setting.value = storage_value
else:
setting = ApplicationSettings(key=key, value=storage_value)
db.add(setting)
# Determine human-readable old value for audit log (decrypt if needed)
old_display_value = None
if old_storage_value is not None:
if metadata.get("sensitive", False):
try:
from app.utils.encryption import decrypt_value
old_display_value = decrypt_value(old_storage_value)
except Exception:
old_display_value = old_storage_value
else:
old_display_value = old_storage_value
# Write audit log entry
audit_entry = SettingsAuditLog(
key=key,
old_value=old_display_value,
new_value=value,
changed_by=changed_by,
action="update",
)
db.add(audit_entry)
db.commit()
logger.info(f"Saved setting {key} to database")
logger.info(f"Saved setting {key} to database (changed_by={changed_by})")
return True
except SQLAlchemyError as e:
logger.error(f"Error saving setting {key} to database: {e}")
@@ -963,23 +1000,54 @@ def get_all_settings_from_db(db: Session) -> Dict[str, str]:
return {}
def delete_setting_from_db(db: Session, key: str) -> bool:
def delete_setting_from_db(db: Session, key: str, changed_by: str = "system") -> bool:
"""
Delete a setting from the database.
Records an entry in the settings audit log.
Args:
db: Database session
key: Setting key to delete
changed_by: Username of the admin performing the change (for audit log)
Returns:
True if successful, False otherwise
"""
try:
setting = db.query(ApplicationSettings).filter(ApplicationSettings.key == key).first()
setting = (
db.query(ApplicationSettings).filter(ApplicationSettings.key == key).first()
)
if setting:
# Capture old value for audit log (decrypt if sensitive)
metadata = get_setting_metadata(key)
old_display_value = None
if setting.value is not None:
if metadata.get("sensitive", False):
try:
from app.utils.encryption import decrypt_value
old_display_value = decrypt_value(setting.value)
except Exception:
old_display_value = setting.value
else:
old_display_value = setting.value
db.delete(setting)
audit_entry = SettingsAuditLog(
key=key,
old_value=old_display_value,
new_value=None,
changed_by=changed_by,
action="delete",
)
db.add(audit_entry)
db.commit()
logger.info(f"Deleted setting {key} from database")
logger.info(
f"Deleted setting {key} from database (changed_by={changed_by})"
)
return True
return False
except SQLAlchemyError as e:
@@ -1061,3 +1129,161 @@ def validate_setting_value(key: str, value: str) -> Tuple[bool, Optional[str]]:
return False, "session_secret must be at least 32 characters"
return True, None
def get_audit_log(
db: Session, limit: int = 100, offset: int = 0
) -> List[Dict[str, Any]]:
"""
Retrieve the settings audit log, most recent first.
Sensitive values are masked in the returned list so the log is safe to
display in the admin UI without leaking secrets.
Args:
db: Database session
limit: Maximum number of entries to return
offset: Number of entries to skip (for pagination)
Returns:
List of audit log entry dicts ordered by changed_at descending
"""
try:
entries = (
db.query(SettingsAuditLog)
.order_by(SettingsAuditLog.changed_at.desc())
.limit(limit)
.offset(offset)
.all()
)
result = []
for entry in entries:
meta = get_setting_metadata(entry.key)
is_sensitive = meta.get("sensitive", False)
result.append(
{
"id": entry.id,
"key": entry.key,
"old_value": (
"[REDACTED]"
if is_sensitive and entry.old_value
else entry.old_value
),
"new_value": (
"[REDACTED]"
if is_sensitive and entry.new_value
else entry.new_value
),
"changed_by": entry.changed_by,
"changed_at": (
entry.changed_at.isoformat() if entry.changed_at else None
),
"action": entry.action,
}
)
return result
except SQLAlchemyError as e:
logger.error(f"Error retrieving audit log: {e}")
return []
def get_setting_history(db: Session, key: str) -> List[Dict[str, Any]]:
"""
Retrieve the change history for a specific setting key, most recent first.
Sensitive values are masked so the response is safe to surface in the UI.
Args:
db: Database session
key: Setting key
Returns:
List of audit log entry dicts for this key
"""
try:
entries = (
db.query(SettingsAuditLog)
.filter(SettingsAuditLog.key == key)
.order_by(SettingsAuditLog.changed_at.desc())
.all()
)
meta = get_setting_metadata(key)
is_sensitive = meta.get("sensitive", False)
result = []
for entry in entries:
result.append(
{
"id": entry.id,
"key": entry.key,
"old_value": (
"[REDACTED]"
if is_sensitive and entry.old_value
else entry.old_value
),
"new_value": (
"[REDACTED]"
if is_sensitive and entry.new_value
else entry.new_value
),
"changed_by": entry.changed_by,
"changed_at": (
entry.changed_at.isoformat() if entry.changed_at else None
),
"action": entry.action,
}
)
return result
except SQLAlchemyError as e:
logger.error(f"Error retrieving history for setting {key}: {e}")
return []
def rollback_setting(
db: Session, key: str, history_id: int, changed_by: str = "system"
) -> bool:
"""
Revert a setting to the value recorded in a specific audit log entry.
The value stored in the chosen history entry's ``new_value`` field is
re-applied as the current database value. If that value is ``None``
(i.e. the entry recorded a deletion) the setting is removed from the
database entirely, reverting to ENV/defaults.
A new audit log entry is written to record the rollback operation.
Args:
db: Database session
key: Setting key to roll back
history_id: ID of the SettingsAuditLog entry whose ``new_value``
should become the restored value
changed_by: Username performing the rollback (for audit log)
Returns:
True if successful, False if the history entry was not found or an
error occurred
"""
try:
history_entry = (
db.query(SettingsAuditLog)
.filter(SettingsAuditLog.id == history_id, SettingsAuditLog.key == key)
.first()
)
if not history_entry:
logger.warning(
f"Rollback failed: audit log entry {history_id} not found for key '{key}'"
)
return False
target_value = history_entry.new_value
if target_value is None:
# The history entry recorded a deletion reinstate that by deleting the current db value
return delete_setting_from_db(db, key, changed_by=changed_by)
else:
return save_setting_to_db(db, key, target_value, changed_by=changed_by)
except SQLAlchemyError as e:
logger.error(
f"Error rolling back setting {key} to history entry {history_id}: {e}"
)
db.rollback()
return False
+86
View File
@@ -0,0 +1,86 @@
"""
Worker settings synchronisation helper.
When an admin saves a configuration change through the UI, any running Celery
workers still hold the *old* values in their in-process ``settings`` singleton.
This module provides two complementary mechanisms to propagate the change:
1. **Publish** (API side): :func:`notify_settings_updated` writes a monotonically
increasing timestamp to a Redis key. This is called immediately after every
successful ``save_setting_to_db`` / ``delete_setting_from_db`` operation.
2. **Subscribe** (worker side): :func:`register_settings_reload_signal` installs
a Celery ``task_prerun`` signal handler. Before each task begins the handler
reads the Redis version key; if it has changed since the last reload it calls
:func:`~app.utils.config_loader.reload_settings_from_db` so the worker picks
up the new values *before* executing the task body.
The Redis key used is ``docuelevate:settings_version``. Workers cache the last
seen version in a module-level variable to avoid redundant DB round-trips when
nothing has changed.
"""
import logging
import time
import redis
from celery.signals import task_prerun
logger = logging.getLogger(__name__)
#: Redis key that stores the current settings "version" (epoch timestamp string).
SETTINGS_VERSION_KEY = "docuelevate:settings_version"
#: Module-level cache: the settings version seen by *this* process on its last reload.
_last_seen_version: str = ""
def notify_settings_updated() -> None:
"""
Publish a settings-updated signal by updating the Redis version key.
Call this after every successful settings write so that all worker
processes know they need to reload their in-memory configuration.
Errors are caught and logged rather than raised so that a Redis
connectivity issue does not prevent the primary save from succeeding.
"""
try:
from app.config import settings
r = redis.from_url(settings.redis_url, socket_connect_timeout=2)
version = str(time.time())
r.set(SETTINGS_VERSION_KEY, version)
logger.debug(f"Settings version bumped to {version}")
except Exception as exc:
logger.warning(f"Could not publish settings update to Redis: {exc}")
def register_settings_reload_signal() -> None:
"""
Install a Celery ``task_prerun`` signal handler for worker processes.
This should be called once during Celery worker initialisation (e.g. from
``celery_worker.py``). After registration, every task will check the
settings version key in Redis before it starts and reload configuration
from the database if a newer version is detected.
"""
@task_prerun.connect(weak=False)
def _reload_if_stale(sender, **kwargs) -> None: # type: ignore[misc]
"""Reload settings from DB if the Redis version key has changed."""
global _last_seen_version
try:
from app.config import settings
from app.utils.config_loader import reload_settings_from_db
r = redis.from_url(settings.redis_url, socket_connect_timeout=2)
current_version = (r.get(SETTINGS_VERSION_KEY) or b"").decode()
if current_version and current_version != _last_seen_version:
reload_settings_from_db(settings)
_last_seen_version = current_version
logger.info(f"Worker settings reloaded (version={current_version})")
except Exception as exc:
logger.debug(f"Settings version check skipped: {exc}")
logger.info("Settings reload signal handler registered on task_prerun")
+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",
)