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",
)
+157
View File
@@ -0,0 +1,157 @@
{% extends "base.html" %}
{% block title %}Settings Audit Log - DocuElevate{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8" x-data="auditLogApp()">
<!-- Header -->
<div class="mb-8 flex justify-between items-start">
<div>
<h1 class="text-3xl font-bold mb-2">Settings Audit Log</h1>
<p class="text-gray-600">
Chronological record of all configuration changes made via the settings UI.
Sensitive values are masked. Use the rollback button to revert any setting to a prior value.
</p>
</div>
<a href="/settings"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
<i class="fas fa-cog mr-2"></i> Back to Settings
</a>
</div>
<!-- Alert Messages -->
<div x-show="showAlert" x-transition class="mb-4">
<div :class="alertType === 'success' ? 'bg-green-100 border-green-500 text-green-700' : 'bg-red-100 border-red-500 text-red-700'"
class="border-l-4 p-4" role="alert">
<p class="font-bold" x-text="alertTitle"></p>
<p x-text="alertMessage"></p>
</div>
</div>
{% if entries %}
<div class="bg-white shadow rounded-lg overflow-hidden">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">When</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Changed By</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Setting Key</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Action</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Old Value</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">New Value</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Rollback</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
{% for entry in entries %}
<tr class="hover:bg-gray-50">
<td class="px-4 py-3 text-sm text-gray-600 whitespace-nowrap">{{ entry.changed_at }}</td>
<td class="px-4 py-3 text-sm text-gray-800 font-medium">{{ entry.changed_by }}</td>
<td class="px-4 py-3 text-sm font-mono text-blue-700">{{ entry.key }}</td>
<td class="px-4 py-3 text-sm">
{% if entry.action == 'delete' %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-red-100 text-red-800">delete</span>
{% elif entry.action == 'rollback' %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-800">rollback</span>
{% else %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800">update</span>
{% endif %}
</td>
<td class="px-4 py-3 text-sm text-gray-600 font-mono max-w-xs truncate" title="{{ entry.old_value or '' }}">
{% if entry.old_value %}
<span class="text-gray-500">{{ entry.old_value }}</span>
{% else %}
<span class="italic text-gray-400"></span>
{% endif %}
</td>
<td class="px-4 py-3 text-sm text-gray-800 font-mono max-w-xs truncate" title="{{ entry.new_value or '' }}">
{% if entry.new_value %}
{{ entry.new_value }}
{% else %}
<span class="italic text-gray-400">— (deleted)</span>
{% endif %}
</td>
<td class="px-4 py-3 text-sm">
<button
type="button"
@click="rollback('{{ entry.key }}', {{ entry.id }}, '{{ entry.new_value or '' }}')"
:disabled="rollingBack === {{ entry.id }}"
class="inline-flex items-center px-2 py-1 text-xs font-medium rounded border border-gray-300 text-gray-700 bg-white hover:bg-yellow-50 hover:border-yellow-400 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-yellow-400 disabled:opacity-50 disabled:cursor-not-allowed"
title="Revert '{{ entry.key }}' to the value in this log entry"
>
<span x-show="rollingBack !== {{ entry.id }}"><i class="fas fa-undo mr-1"></i>Rollback</span>
<span x-show="rollingBack === {{ entry.id }}">Working…</span>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="bg-white shadow rounded-lg p-8 text-center text-gray-500">
<i class="fas fa-history text-4xl mb-4 block text-gray-300"></i>
<p class="text-lg">No configuration changes recorded yet.</p>
<p class="text-sm mt-2">Changes you make on the <a href="/settings" class="text-blue-600 hover:underline">Settings page</a> will appear here.</p>
</div>
{% endif %}
</div>
<script>
function auditLogApp() {
return {
rollingBack: null,
showAlert: false,
alertType: 'success',
alertTitle: '',
alertMessage: '',
showSuccessAlert(title, message) {
this.alertType = 'success';
this.alertTitle = title;
this.alertMessage = message;
this.showAlert = true;
setTimeout(() => this.showAlert = false, 6000);
},
showErrorAlert(title, message) {
this.alertType = 'error';
this.alertTitle = title;
this.alertMessage = message;
this.showAlert = true;
setTimeout(() => this.showAlert = false, 10000);
},
async rollback(key, historyId, targetValue) {
const label = targetValue ? `'${targetValue}'` : '(deleted / ENV default)';
if (!confirm(`Revert '${key}' to ${label}?\n\nThis will write a new audit log entry.`)) {
return;
}
this.rollingBack = historyId;
this.showAlert = false;
try {
const response = await fetch(`/api/settings/${key}/rollback/${historyId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
const result = await response.json();
if (response.ok && result.success) {
this.showSuccessAlert('Rollback Successful', `Setting '${key}' has been reverted. Reloading…`);
setTimeout(() => window.location.reload(), 1500);
} else {
this.showErrorAlert('Rollback Failed', result.detail || 'Unknown error');
}
} catch (error) {
console.error('Rollback error:', error);
this.showErrorAlert('Error', 'Failed to perform rollback. Please try again.');
} finally {
this.rollingBack = null;
}
}
};
}
</script>
{% endblock %}
+61 -4
View File
@@ -13,10 +13,18 @@
<div class="container mx-auto px-4 py-8" x-data="settingsApp()">
<!-- Header -->
<div class="mb-8">
<h1 class="text-3xl font-bold mb-2">Application Settings</h1>
<p class="text-gray-600">
This is a convenience feature to view and edit application settings through the web interface.
</p>
<div class="flex justify-between items-start">
<div>
<h1 class="text-3xl font-bold mb-2">Application Settings</h1>
<p class="text-gray-600">
This is a convenience feature to view and edit application settings through the web interface.
</p>
</div>
<a href="/admin/settings/audit-log"
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
<i class="fas fa-history mr-2"></i> Audit Log
</a>
</div>
<div class="bg-blue-50 border-l-4 border-blue-500 text-blue-700 p-4 my-4" role="alert">
<p class="font-bold">📋 Settings Precedence Order:</p>
<ul class="list-disc list-inside ml-4 mt-2">
@@ -150,6 +158,22 @@
</div>
{% endif %}
</div>
<!-- Per-setting Save button (visible only when value has changed) -->
<div class="ml-4 flex-shrink-0 flex flex-col items-end gap-1 pt-1">
<button
type="button"
x-show="formData['{{ setting.key }}'] !== originalData['{{ setting.key }}']"
x-transition
@click="saveSetting('{{ setting.key }}')"
:disabled="savingKey === '{{ setting.key }}'"
class="px-3 py-1 text-sm bg-green-600 text-white rounded-md hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500 disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
title="Save this setting"
>
<span x-show="savingKey !== '{{ setting.key }}'"><i class="fas fa-save mr-1"></i>Save</span>
<span x-show="savingKey === '{{ setting.key }}'">Saving…</span>
</button>
</div>
</div>
</div>
{% endfor %}
@@ -185,6 +209,7 @@ function settingsApp() {
originalData: {},
showPassword: {},
saving: false,
savingKey: null,
showAlert: false,
alertType: 'success',
alertTitle: '',
@@ -230,6 +255,38 @@ function settingsApp() {
this.showAlert = false;
},
async saveSetting(key) {
this.savingKey = key;
this.hideAlert();
try {
const value = this.formData[key];
const response = await fetch(`/api/settings/${key}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value }),
});
const result = await response.json();
if (response.ok && result.success) {
this.originalData[key] = value;
let message = `Setting '${key}' saved successfully.`;
if (result.restart_required) {
message += ' Please restart the application for this change to take effect.';
}
this.showSuccessAlert('Setting Saved', message);
} else {
this.showErrorAlert('Save Failed', result.detail || 'Unknown error');
}
} catch (error) {
console.error('Error saving setting:', error);
this.showErrorAlert('Error', 'Failed to save setting. Please try again.');
} finally {
this.savingKey = null;
}
},
async saveSettings() {
this.saving = true;
this.hideAlert();
+452
View File
@@ -0,0 +1,452 @@
"""Tests for the settings audit log, rollback, per-option save, and worker sync features."""
import asyncio
from unittest.mock import MagicMock, patch
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base
from app.models import SettingsAuditLog
# ---------------------------------------------------------------------------
# Shared DB fixture
# ---------------------------------------------------------------------------
@pytest.fixture()
def db_session():
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
Session = sessionmaker(autocommit=False, autoflush=False, bind=engine)
session = Session()
try:
yield session
finally:
session.close()
Base.metadata.drop_all(bind=engine)
# ===========================================================================
# A) Audit log written on save
# ===========================================================================
@pytest.mark.unit
class TestAuditLogOnSave:
"""Audit log entries are created when settings are saved or deleted."""
def test_save_creates_audit_entry(self, db_session):
from app.utils.settings_service import save_setting_to_db
result = save_setting_to_db(
db_session, "workdir", "/new/path", changed_by="alice"
)
assert result is True
entry = db_session.query(SettingsAuditLog).filter_by(key="workdir").first()
assert entry is not None
assert entry.action == "update"
assert entry.new_value == "/new/path"
assert entry.changed_by == "alice"
assert entry.old_value is None # was not previously set
def test_update_records_old_value(self, db_session):
from app.utils.settings_service import save_setting_to_db
# Set initial value
save_setting_to_db(db_session, "workdir", "/old/path", changed_by="admin")
# Update
save_setting_to_db(db_session, "workdir", "/new/path", changed_by="bob")
entries = db_session.query(SettingsAuditLog).filter_by(key="workdir").all()
assert len(entries) == 2
# Second entry should have old_value from first write
update_entry = entries[1]
assert update_entry.old_value == "/old/path"
assert update_entry.new_value == "/new/path"
def test_delete_creates_audit_entry(self, db_session):
from app.utils.settings_service import (delete_setting_from_db,
save_setting_to_db)
save_setting_to_db(db_session, "workdir", "/some/path", changed_by="admin")
result = delete_setting_from_db(db_session, "workdir", changed_by="carol")
assert result is True
delete_entry = (
db_session.query(SettingsAuditLog)
.filter_by(key="workdir", action="delete")
.first()
)
assert delete_entry is not None
assert delete_entry.old_value == "/some/path"
assert delete_entry.new_value is None
assert delete_entry.changed_by == "carol"
def test_delete_nonexistent_returns_false_no_entry(self, db_session):
from app.utils.settings_service import delete_setting_from_db
result = delete_setting_from_db(
db_session, "nonexistent_key", changed_by="admin"
)
assert result is False
assert db_session.query(SettingsAuditLog).count() == 0
def test_default_changed_by_is_system(self, db_session):
from app.utils.settings_service import save_setting_to_db
save_setting_to_db(db_session, "workdir", "/tmp")
entry = db_session.query(SettingsAuditLog).first()
assert entry.changed_by == "system"
# ===========================================================================
# C) Audit log retrieval
# ===========================================================================
@pytest.mark.unit
class TestGetAuditLog:
"""get_audit_log returns entries, masks sensitive values."""
def test_returns_all_entries_most_recent_first(self, db_session):
from app.utils.settings_service import (get_audit_log,
save_setting_to_db)
save_setting_to_db(db_session, "workdir", "/first", changed_by="u1")
save_setting_to_db(db_session, "workdir", "/second", changed_by="u2")
log = get_audit_log(db_session, limit=100)
assert len(log) == 2
# Most recent first
assert log[0]["new_value"] == "/second"
assert log[1]["new_value"] == "/first"
def test_sensitive_values_are_masked(self, db_session):
from app.utils.settings_service import (get_audit_log,
save_setting_to_db)
save_setting_to_db(
db_session, "openai_api_key", "sk-secret123", changed_by="admin"
)
log = get_audit_log(db_session)
entry = next(e for e in log if e["key"] == "openai_api_key")
assert entry["new_value"] == "[REDACTED]"
def test_required_fields_present(self, db_session):
from app.utils.settings_service import (get_audit_log,
save_setting_to_db)
save_setting_to_db(db_session, "workdir", "/path", changed_by="alice")
log = get_audit_log(db_session)
assert len(log) == 1
entry = log[0]
for field in (
"id",
"key",
"old_value",
"new_value",
"changed_by",
"changed_at",
"action",
):
assert field in entry
def test_limit_and_offset(self, db_session):
from app.utils.settings_service import (get_audit_log,
save_setting_to_db)
for i in range(5):
save_setting_to_db(db_session, "workdir", f"/path{i}", changed_by="admin")
first_page = get_audit_log(db_session, limit=3, offset=0)
second_page = get_audit_log(db_session, limit=3, offset=3)
assert len(first_page) == 3
assert len(second_page) == 2
# ===========================================================================
# C) Per-key history
# ===========================================================================
@pytest.mark.unit
class TestGetSettingHistory:
"""get_setting_history returns only entries for the requested key."""
def test_returns_only_matching_key(self, db_session):
from app.utils.settings_service import (get_setting_history,
save_setting_to_db)
save_setting_to_db(db_session, "workdir", "/wdir", changed_by="admin")
save_setting_to_db(db_session, "debug", "true", changed_by="admin")
history = get_setting_history(db_session, "workdir")
assert len(history) == 1
assert history[0]["key"] == "workdir"
def test_returns_empty_list_for_unknown_key(self, db_session):
from app.utils.settings_service import get_setting_history
history = get_setting_history(db_session, "totally_unknown_key")
assert history == []
# ===========================================================================
# D) Rollback
# ===========================================================================
@pytest.mark.unit
class TestRollbackSetting:
"""rollback_setting reinstates the value from a given audit log entry."""
def test_rollback_to_previous_value(self, db_session):
from app.utils.settings_service import (get_setting_from_db,
rollback_setting,
save_setting_to_db)
save_setting_to_db(
db_session, "workdir", "/v1", changed_by="admin"
) # entry id 1
save_setting_to_db(
db_session, "workdir", "/v2", changed_by="admin"
) # entry id 2
first_entry = (
db_session.query(SettingsAuditLog).filter_by(key="workdir").first()
)
# first entry has new_value="/v1"
success = rollback_setting(
db_session, "workdir", first_entry.id, changed_by="rollbacker"
)
assert success is True
current = get_setting_from_db(db_session, "workdir")
assert current == "/v1"
def test_rollback_creates_new_audit_entry(self, db_session):
from app.utils.settings_service import (rollback_setting,
save_setting_to_db)
save_setting_to_db(db_session, "workdir", "/v1", changed_by="admin")
entry = db_session.query(SettingsAuditLog).filter_by(key="workdir").first()
initial_count = db_session.query(SettingsAuditLog).count()
rollback_setting(db_session, "workdir", entry.id, changed_by="rollbacker")
assert db_session.query(SettingsAuditLog).count() == initial_count + 1
def test_rollback_wrong_history_id_returns_false(self, db_session):
from app.utils.settings_service import (rollback_setting,
save_setting_to_db)
save_setting_to_db(db_session, "workdir", "/v1", changed_by="admin")
result = rollback_setting(db_session, "workdir", 9999, changed_by="admin")
assert result is False
def test_rollback_wrong_key_returns_false(self, db_session):
from app.utils.settings_service import (rollback_setting,
save_setting_to_db)
save_setting_to_db(db_session, "workdir", "/v1", changed_by="admin")
entry = db_session.query(SettingsAuditLog).filter_by(key="workdir").first()
# Pass wrong key for the history ID
result = rollback_setting(db_session, "debug", entry.id, changed_by="admin")
assert result is False
# ===========================================================================
# B) Worker sync settings_sync module
# ===========================================================================
@pytest.mark.unit
class TestNotifySettingsUpdated:
"""notify_settings_updated publishes the settings version key to Redis."""
def test_sets_redis_key(self):
from app.utils.settings_sync import (SETTINGS_VERSION_KEY,
notify_settings_updated)
mock_redis = MagicMock()
mock_redis_instance = MagicMock()
mock_redis.return_value = mock_redis_instance
with patch("app.utils.settings_sync.redis") as mock_redis_module:
mock_redis_module.from_url.return_value = mock_redis_instance
notify_settings_updated()
mock_redis_instance.set.assert_called_once()
call_args = mock_redis_instance.set.call_args[0]
assert call_args[0] == SETTINGS_VERSION_KEY
def test_does_not_raise_on_redis_failure(self):
"""notify_settings_updated must not propagate Redis errors."""
from app.utils.settings_sync import notify_settings_updated
with patch("app.utils.settings_sync.redis") as mock_redis_module:
mock_redis_module.from_url.side_effect = Exception("Redis down")
# Should not raise
notify_settings_updated()
@pytest.mark.unit
class TestRegisterSettingsReloadSignal:
"""register_settings_reload_signal installs a task_prerun handler."""
def test_registers_without_error(self):
from app.utils.settings_sync import register_settings_reload_signal
with patch("app.utils.settings_sync.task_prerun") as mock_signal:
mock_signal.connect = MagicMock()
# Call it the decorator calls task_prerun.connect(weak=False)
register_settings_reload_signal()
# If no exception is raised the registration succeeded
# ===========================================================================
# API endpoint audit log
# ===========================================================================
@pytest.mark.integration
class TestAuditLogEndpoint:
"""GET /api/settings/audit-log requires admin access."""
def test_requires_admin(self, client):
response = client.get("/api/settings/audit-log")
assert response.status_code in [302, 401, 403]
@patch("app.api.settings.get_audit_log")
def test_returns_entries_for_admin(self, mock_get_log):
from app.api.settings import list_audit_log
mock_get_log.return_value = [
{
"id": 1,
"key": "workdir",
"old_value": None,
"new_value": "/tmp",
"changed_by": "admin",
"changed_at": "2024-01-01T00:00:00",
"action": "update",
}
]
mock_request = MagicMock()
mock_db = MagicMock()
mock_admin = {"is_admin": True}
result = asyncio.run(list_audit_log(mock_request, mock_db, mock_admin))
assert "entries" in result
assert len(result["entries"]) == 1
assert result["entries"][0]["key"] == "workdir"
@pytest.mark.integration
class TestHistoryEndpoint:
"""GET /api/settings/{key}/history requires admin access."""
def test_requires_admin(self, client):
response = client.get("/api/settings/workdir/history")
assert response.status_code in [302, 401, 403]
@patch("app.api.settings.get_setting_history")
def test_returns_history_for_admin(self, mock_get_history):
from app.api.settings import get_key_history
mock_get_history.return_value = [
{
"id": 1,
"key": "workdir",
"old_value": None,
"new_value": "/tmp",
"changed_by": "admin",
"changed_at": "2024-01-01T00:00:00",
"action": "update",
}
]
mock_request = MagicMock()
mock_db = MagicMock()
mock_admin = {"is_admin": True}
result = asyncio.run(
get_key_history("workdir", mock_request, mock_db, mock_admin)
)
assert result["key"] == "workdir"
assert len(result["history"]) == 1
@pytest.mark.integration
class TestRollbackEndpoint:
"""POST /api/settings/{key}/rollback/{history_id} requires admin access."""
def test_requires_admin(self, client):
response = client.post("/api/settings/workdir/rollback/1")
assert response.status_code in [302, 401, 403]
@patch("app.api.settings.notify_settings_updated")
@patch("app.api.settings.rollback_setting")
def test_rollback_success(self, mock_rollback, mock_notify):
from app.api.settings import rollback_setting_to_history
mock_rollback.return_value = True
mock_request = MagicMock()
mock_request.session = {"user": {"preferred_username": "admin"}}
mock_db = MagicMock()
mock_admin = {"is_admin": True}
result = asyncio.run(
rollback_setting_to_history("workdir", 1, mock_request, mock_db, mock_admin)
)
assert result["success"] is True
mock_notify.assert_called_once()
@patch("app.api.settings.rollback_setting")
def test_rollback_not_found_raises_404(self, mock_rollback):
import asyncio
from fastapi import HTTPException
from app.api.settings import rollback_setting_to_history
mock_rollback.return_value = False
mock_request = MagicMock()
mock_request.session = {"user": {"preferred_username": "admin"}}
mock_db = MagicMock()
mock_admin = {"is_admin": True}
with pytest.raises(HTTPException) as exc_info:
asyncio.run(
rollback_setting_to_history(
"workdir", 9999, mock_request, mock_db, mock_admin
)
)
assert exc_info.value.status_code == 404