Merge pull request #357 from christianlouis/copilot/enhance-settings-management

feat(settings): per-option save, audit log, rollback, live worker sync, wizard DB persistence, and ENV exporter
This commit is contained in:
Christian Krakau-Louis
2026-02-23 10:37:22 +01:00
committed by GitHub
17 changed files with 2211 additions and 218 deletions
+107 -72
View File
@@ -7,11 +7,15 @@ import os
from typing import Annotated, Optional
import requests
from fastapi import APIRouter, Form, HTTPException, Request, status
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from sqlalchemy.orm import Session
from app.auth import require_login
from app.config import settings
from app.database import get_db
from app.utils.oauth_helper import exchange_oauth_token
from app.utils.settings_service import save_setting_to_db
from app.utils.settings_sync import notify_settings_updated
# Set up logging
logger = logging.getLogger(__name__)
@@ -63,38 +67,52 @@ async def update_dropbox_settings(
app_key: Annotated[Optional[str], Form()] = None,
app_secret: Annotated[Optional[str], Form()] = None,
folder_path: Annotated[Optional[str], Form()] = None,
db: Session = Depends(get_db),
):
"""
Update Dropbox settings in memory
Update Dropbox settings in memory and persist to the database.
"""
try:
logger.info("Updating Dropbox settings in memory")
logger.info("Updating Dropbox settings in memory and database")
# Update settings in memory
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 "wizard"
)
# Update settings in memory and persist to database
if refresh_token:
settings.dropbox_refresh_token = refresh_token
logger.info("Updated DROPBOX_REFRESH_TOKEN in memory")
save_setting_to_db(db, "dropbox_refresh_token", refresh_token, changed_by=changed_by)
logger.info("Updated DROPBOX_REFRESH_TOKEN in memory and database")
if app_key:
settings.dropbox_app_key = app_key
logger.info("Updated DROPBOX_APP_KEY in memory")
save_setting_to_db(db, "dropbox_app_key", app_key, changed_by=changed_by)
logger.info("Updated DROPBOX_APP_KEY in memory and database")
if app_secret:
settings.dropbox_app_secret = app_secret
logger.info("Updated DROPBOX_APP_SECRET in memory")
save_setting_to_db(db, "dropbox_app_secret", app_secret, changed_by=changed_by)
logger.info("Updated DROPBOX_APP_SECRET in memory and database")
if folder_path:
settings.dropbox_folder = folder_path
logger.info("Updated DROPBOX_FOLDER in memory")
save_setting_to_db(db, "dropbox_folder", folder_path, changed_by=changed_by)
logger.info("Updated DROPBOX_FOLDER in memory and database")
# Test token validity would be here, but we'll skip it for now
notify_settings_updated()
return {"status": "success", "message": "Dropbox settings have been updated in memory"}
return {
"status": "success",
"message": "Dropbox settings have been updated in memory and saved to database",
}
except Exception as e:
logger.exception(f"Unexpected error updating Dropbox settings: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to update Dropbox settings: {str(e)}"
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update Dropbox settings: {str(e)}",
)
@@ -109,7 +127,10 @@ async def test_dropbox_token(request: Request):
if not settings.dropbox_refresh_token or not settings.dropbox_app_key or not settings.dropbox_app_secret:
logger.warning("Dropbox credentials not fully configured")
return {"status": "error", "message": "Dropbox credentials are not fully configured"}
return {
"status": "error",
"message": "Dropbox credentials are not fully configured",
}
# Check token validity by getting current account info
headers = {"Authorization": f"Bearer {settings.dropbox_refresh_token}"}
@@ -136,7 +157,11 @@ async def test_dropbox_token(request: Request):
if refresh_response.status_code != 200:
logger.error(f"Failed to refresh Dropbox token: {refresh_response.text}")
return {"status": "error", "message": "Refresh token has expired or is invalid", "needs_reauth": True}
return {
"status": "error",
"message": "Refresh token has expired or is invalid",
"needs_reauth": True,
}
token_info = refresh_response.json()
access_token = token_info.get("access_token")
@@ -162,7 +187,10 @@ async def test_dropbox_token(request: Request):
account_name = account_info.get("name", {}).get("display_name", "Unknown user")
# Dropbox refresh tokens don't expire, but we should note that in our response
token_info = {"expires_in_human": "Never expires (perpetual token)", "is_perpetual": True}
token_info = {
"expires_in_human": "Never expires (perpetual token)",
"is_perpetual": True,
}
logger.info(f"Successfully connected to Dropbox as {account_email}")
@@ -187,65 +215,18 @@ async def save_dropbox_settings(
app_key: Annotated[Optional[str], Form()] = None,
app_secret: Annotated[Optional[str], Form()] = None,
folder_path: Annotated[Optional[str], Form()] = None,
db: Session = Depends(get_db),
):
"""
Save Dropbox settings to the .env file
Save Dropbox settings to database (primary) and .env file (best-effort).
"""
try:
# Get the path to the .env file
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env")
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 "wizard"
)
if not os.path.exists(env_path):
logger.error(f".env file not found at {env_path}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Could not find .env file to update"
)
logger.info(f"Updating Dropbox settings in {env_path}")
# Read the current .env file
with open(env_path, "r") as f:
env_lines = f.readlines()
# Define settings to update
dropbox_settings = {
"DROPBOX_REFRESH_TOKEN": refresh_token,
}
# Only update these if provided
if app_key:
dropbox_settings["DROPBOX_APP_KEY"] = app_key
if app_secret:
dropbox_settings["DROPBOX_APP_SECRET"] = app_secret
if folder_path:
dropbox_settings["DROPBOX_FOLDER"] = folder_path
# Process each line and update or add settings
updated = set()
new_env_lines = []
for line in env_lines:
stripped_line = line.rstrip()
is_updated = False
for key, value in dropbox_settings.items():
if stripped_line.startswith(f"{key}=") or stripped_line.startswith(f"# {key}="):
# Uncomment if commented out - check the original stripped line
new_env_lines.append(f"{key}={value}")
updated.add(key)
is_updated = True
break
if not is_updated:
new_env_lines.append(stripped_line)
# Add any settings that weren't updated (they weren't in the file)
for key, value in dropbox_settings.items():
if key not in updated:
new_env_lines.append(f"{key}={value}")
# Write the updated .env file
with open(env_path, "w") as f:
f.write("\n".join(new_env_lines) + "\n")
# Update the settings in memory
# Update settings in memory
if refresh_token:
settings.dropbox_refresh_token = refresh_token
if app_key:
@@ -255,14 +236,68 @@ async def save_dropbox_settings(
if folder_path:
settings.dropbox_folder = folder_path
logger.info("Successfully updated Dropbox settings")
# Persist to database (primary storage)
if refresh_token:
save_setting_to_db(db, "dropbox_refresh_token", refresh_token, changed_by=changed_by)
if app_key:
save_setting_to_db(db, "dropbox_app_key", app_key, changed_by=changed_by)
if app_secret:
save_setting_to_db(db, "dropbox_app_secret", app_secret, changed_by=changed_by)
if folder_path:
save_setting_to_db(db, "dropbox_folder", folder_path, changed_by=changed_by)
# Best-effort .env file write
try:
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env")
if not os.path.exists(env_path):
logger.warning(f".env file not found at {env_path}, skipping file write")
else:
logger.info(f"Updating Dropbox settings in {env_path}")
with open(env_path, "r") as f:
env_lines = f.readlines()
dropbox_settings = {"DROPBOX_REFRESH_TOKEN": refresh_token}
if app_key:
dropbox_settings["DROPBOX_APP_KEY"] = app_key
if app_secret:
dropbox_settings["DROPBOX_APP_SECRET"] = app_secret
if folder_path:
dropbox_settings["DROPBOX_FOLDER"] = folder_path
updated = set()
new_env_lines = []
for line in env_lines:
stripped_line = line.rstrip()
is_updated = False
for key, value in dropbox_settings.items():
if stripped_line.startswith(f"{key}=") or stripped_line.startswith(f"# {key}="):
new_env_lines.append(f"{key}={value}")
updated.add(key)
is_updated = True
break
if not is_updated:
new_env_lines.append(stripped_line)
for key, value in dropbox_settings.items():
if key not in updated:
new_env_lines.append(f"{key}={value}")
with open(env_path, "w") as f:
f.write("\n".join(new_env_lines) + "\n")
logger.info("Successfully updated Dropbox settings in .env file")
except Exception as env_err:
logger.warning(f"Failed to write .env file (non-fatal): {env_err}")
notify_settings_updated()
logger.info("Successfully saved Dropbox settings")
return {"status": "success", "message": "Dropbox settings have been saved"}
except HTTPException:
raise
except Exception as e:
logger.exception(f"Unexpected error saving Dropbox settings: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to save Dropbox settings: {str(e)}"
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to save Dropbox settings: {str(e)}",
)
+79 -17
View File
@@ -7,11 +7,15 @@ import os
from datetime import datetime
from typing import Annotated, Optional
from fastapi import APIRouter, Form, HTTPException, Request, status
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from sqlalchemy.orm import Session
from app.auth import require_login
from app.config import settings
from app.database import get_db
from app.utils.oauth_helper import exchange_oauth_token
from app.utils.settings_service import save_setting_to_db
from app.utils.settings_sync import notify_settings_updated
# Set up logging
logger = logging.getLogger(__name__)
@@ -64,38 +68,59 @@ async def update_google_drive_settings(
client_secret: Annotated[Optional[str], Form()] = None,
folder_id: Annotated[Optional[str], Form()] = None,
use_oauth: Annotated[str, Form()] = "true",
db: Session = Depends(get_db),
):
"""
Update Google Drive settings in memory
Update Google Drive settings in memory and persist to database
"""
try:
logger.info("Updating Google Drive settings in memory")
logger.info("Updating Google Drive settings in memory and database")
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 "wizard"
)
# Convert use_oauth string to boolean
use_oauth_bool = use_oauth.lower() in ("true", "1", "yes", "y", "t")
# Update settings in memory
# Update settings in memory and persist to database
if refresh_token:
settings.google_drive_refresh_token = refresh_token
logger.info("Updated GOOGLE_DRIVE_REFRESH_TOKEN in memory")
save_setting_to_db(db, "google_drive_refresh_token", refresh_token, changed_by=changed_by)
logger.info("Updated GOOGLE_DRIVE_REFRESH_TOKEN in memory and database")
if client_id:
settings.google_drive_client_id = client_id
logger.info("Updated GOOGLE_DRIVE_CLIENT_ID in memory")
save_setting_to_db(db, "google_drive_client_id", client_id, changed_by=changed_by)
logger.info("Updated GOOGLE_DRIVE_CLIENT_ID in memory and database")
if client_secret:
settings.google_drive_client_secret = client_secret
logger.info("Updated GOOGLE_DRIVE_CLIENT_SECRET in memory")
save_setting_to_db(db, "google_drive_client_secret", client_secret, changed_by=changed_by)
logger.info("Updated GOOGLE_DRIVE_CLIENT_SECRET in memory and database")
if folder_id:
settings.google_drive_folder_id = folder_id
logger.info("Updated GOOGLE_DRIVE_FOLDER_ID in memory")
save_setting_to_db(db, "google_drive_folder_id", folder_id, changed_by=changed_by)
logger.info("Updated GOOGLE_DRIVE_FOLDER_ID in memory and database")
# Set the OAuth flag
settings.google_drive_use_oauth = use_oauth_bool
logger.info(f"Updated GOOGLE_DRIVE_USE_OAUTH in memory to {use_oauth_bool}")
save_setting_to_db(
db,
"google_drive_use_oauth",
str(use_oauth_bool).lower(),
changed_by=changed_by,
)
logger.info(f"Updated GOOGLE_DRIVE_USE_OAUTH in memory and database to {use_oauth_bool}")
return {"status": "success", "message": "Google Drive settings have been updated in memory"}
notify_settings_updated()
return {
"status": "success",
"message": "Google Drive settings have been updated in memory and database",
}
except Exception as e:
logger.exception(f"Unexpected error updating Google Drive settings: {str(e)}")
@@ -125,7 +150,10 @@ async def test_google_drive_token(request: Request):
and settings.google_drive_refresh_token
):
logger.warning("Google Drive OAuth credentials not fully configured")
return {"status": "error", "message": "Google Drive OAuth credentials are not fully configured"}
return {
"status": "error",
"message": "Google Drive OAuth credentials are not fully configured",
}
try:
# Test OAuth connection
@@ -188,7 +216,10 @@ async def test_google_drive_token(request: Request):
# Test service account connection
if not settings.google_drive_credentials_json:
logger.warning("Google Drive service account credentials not configured")
return {"status": "error", "message": "Google Drive service account credentials are not configured"}
return {
"status": "error",
"message": "Google Drive service account credentials are not configured",
}
try:
service = get_google_drive_service()
@@ -214,7 +245,10 @@ async def test_google_drive_token(request: Request):
except Exception as e:
error_msg = str(e)
logger.error(f"Google Drive service account test failed: {error_msg}")
return {"status": "error", "message": f"Service account validation failed: {error_msg}"}
return {
"status": "error",
"message": f"Service account validation failed: {error_msg}",
}
except Exception as e:
logger.exception("Unexpected error testing Google Drive token")
@@ -246,7 +280,10 @@ async def get_google_drive_token_info(request: Request):
and settings.google_drive_refresh_token
):
logger.warning("Google Drive OAuth credentials not fully configured")
return {"status": "error", "message": "Google Drive OAuth credentials are not fully configured"}
return {
"status": "error",
"message": "Google Drive OAuth credentials are not fully configured",
}
try:
# Get credentials and access token
@@ -333,9 +370,10 @@ async def save_dropbox_settings(
client_secret: Annotated[Optional[str], Form()] = None,
folder_id: Annotated[Optional[str], Form()] = None,
use_oauth: Annotated[str, Form()] = "true",
db: Session = Depends(get_db),
):
"""
Save Google Drive settings to the .env file
Save Google Drive settings to the .env file (best-effort) and persist to database.
"""
try:
# Get the path to the .env file
@@ -344,6 +382,11 @@ async def save_dropbox_settings(
# Convert use_oauth string to boolean
use_oauth_bool = use_oauth.lower() in ("true", "1", "yes", "y", "t")
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 "wizard"
)
# Define settings to update
drive_settings = {"GOOGLE_DRIVE_USE_OAUTH": str(use_oauth_bool).lower()}
@@ -415,7 +458,25 @@ async def save_dropbox_settings(
# Set OAuth flag
settings.google_drive_use_oauth = use_oauth_bool
logger.info("Successfully updated Google Drive settings in memory")
# Persist to database
save_setting_to_db(
db,
"google_drive_use_oauth",
str(use_oauth_bool).lower(),
changed_by=changed_by,
)
if refresh_token:
save_setting_to_db(db, "google_drive_refresh_token", refresh_token, changed_by=changed_by)
if client_id:
save_setting_to_db(db, "google_drive_client_id", client_id, changed_by=changed_by)
if client_secret:
save_setting_to_db(db, "google_drive_client_secret", client_secret, changed_by=changed_by)
if folder_id:
save_setting_to_db(db, "google_drive_folder_id", folder_id, changed_by=changed_by)
notify_settings_updated()
logger.info("Successfully updated Google Drive settings in memory and database")
return {
"status": "success",
@@ -426,5 +487,6 @@ async def save_dropbox_settings(
except Exception as e:
logger.exception(f"Unexpected error saving Google Drive settings: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to save Google Drive settings: {str(e)}"
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to save Google Drive settings: {str(e)}",
)
+128 -67
View File
@@ -8,11 +8,15 @@ from datetime import datetime, timedelta
from typing import Annotated, Optional
import requests
from fastapi import APIRouter, Form, HTTPException, Request, status
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from sqlalchemy.orm import Session
from app.auth import require_login
from app.config import settings
from app.database import get_db
from app.utils.oauth_helper import exchange_oauth_token
from app.utils.settings_service import save_setting_to_db
from app.utils.settings_sync import notify_settings_updated
# Set up logging
logger = logging.getLogger(__name__)
@@ -50,7 +54,10 @@ async def exchange_onedrive_token(
token_data = exchange_oauth_token(provider_name="OneDrive", token_url=token_url, payload=payload)
# Return just what's needed by the frontend
return {"refresh_token": token_data["refresh_token"], "expires_in": token_data.get("expires_in", 3600)}
return {
"refresh_token": token_data["refresh_token"],
"expires_in": token_data.get("expires_in", 3600),
}
@router.get("/onedrive/test-token")
@@ -68,7 +75,10 @@ async def test_onedrive_token(request: Request):
or not settings.onedrive_client_secret
):
logger.warning("OneDrive credentials not fully configured")
return {"status": "error", "message": "OneDrive credentials are not fully configured"}
return {
"status": "error",
"message": "OneDrive credentials are not fully configured",
}
# Refresh token to get a new access token and expiration info
tenant_id = settings.onedrive_tenant_id or "common"
@@ -86,7 +96,11 @@ async def test_onedrive_token(request: Request):
if response.status_code != 200:
logger.error(f"Failed to refresh OneDrive token: {response.text}")
return {"status": "error", "message": "Refresh token has expired or is invalid", "needs_reauth": True}
return {
"status": "error",
"message": "Refresh token has expired or is invalid",
"needs_reauth": True,
}
token_data = response.json()
access_token = token_data.get("access_token")
@@ -128,6 +142,24 @@ async def test_onedrive_token(request: Request):
except Exception as e:
logger.warning(f"Failed to update refresh token in .env file: {e}")
# Persist the rotated refresh token to the database
try:
from app.database import SessionLocal
_db = SessionLocal()
try:
save_setting_to_db(
_db,
"onedrive_refresh_token",
new_refresh_token,
changed_by="onedrive_token_rotation",
)
notify_settings_updated()
finally:
_db.close()
except Exception as _e:
logger.warning(f"Failed to persist rotated OneDrive refresh token to database: {_e}")
# Test the access token by getting user information
user_info_url = "https://graph.microsoft.com/v1.0/me"
headers = {"Authorization": f"Bearer {access_token}"}
@@ -203,65 +235,62 @@ async def save_onedrive_settings(
client_secret: Annotated[Optional[str], Form()] = None,
tenant_id: Annotated[str, Form()] = "common",
folder_path: Annotated[Optional[str], Form()] = None,
db: Session = Depends(get_db),
):
"""
Save OneDrive settings to the .env file
Saves to database (primary) and .env file (best-effort).
"""
try:
# Get the path to the .env file
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env")
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 "wizard"
)
if not os.path.exists(env_path):
logger.error(f".env file not found at {env_path}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Could not find .env file to update"
)
# Best-effort .env file write
try:
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env")
if not os.path.exists(env_path):
logger.warning(f".env file not found at {env_path}, skipping file write")
else:
logger.info(f"Updating OneDrive settings in {env_path}")
logger.info(f"Updating OneDrive settings in {env_path}")
with open(env_path, "r") as f:
env_lines = f.readlines()
# Read the current .env file
with open(env_path, "r") as f:
env_lines = f.readlines()
onedrive_settings = {"ONEDRIVE_REFRESH_TOKEN": refresh_token}
if client_id:
onedrive_settings["ONEDRIVE_CLIENT_ID"] = client_id
if client_secret:
onedrive_settings["ONEDRIVE_CLIENT_SECRET"] = client_secret
if tenant_id:
onedrive_settings["ONEDRIVE_TENANT_ID"] = tenant_id
if folder_path:
onedrive_settings["ONEDRIVE_FOLDER_PATH"] = folder_path
# Define settings to update
onedrive_settings = {
"ONEDRIVE_REFRESH_TOKEN": refresh_token,
}
updated = set()
new_env_lines = []
for line in env_lines:
stripped_line = line.rstrip()
is_updated = False
for key, value in onedrive_settings.items():
if stripped_line.startswith(f"{key}=") or stripped_line.startswith(f"# {key}="):
new_env_lines.append(f"{key}={value}")
updated.add(key)
is_updated = True
break
if not is_updated:
new_env_lines.append(stripped_line)
# Only update these if provided
if client_id:
onedrive_settings["ONEDRIVE_CLIENT_ID"] = client_id
if client_secret:
onedrive_settings["ONEDRIVE_CLIENT_SECRET"] = client_secret
if tenant_id:
onedrive_settings["ONEDRIVE_TENANT_ID"] = tenant_id
if folder_path:
onedrive_settings["ONEDRIVE_FOLDER_PATH"] = folder_path
for key, value in onedrive_settings.items():
if key not in updated:
new_env_lines.append(f"{key}={value}")
# Process each line and update or add settings
updated = set()
new_env_lines = []
for line in env_lines:
stripped_line = line.rstrip()
is_updated = False
for key, value in onedrive_settings.items():
if stripped_line.startswith(f"{key}=") or stripped_line.startswith(f"# {key}="):
# Uncomment if commented out - check the original stripped line
new_env_lines.append(f"{key}={value}")
updated.add(key)
is_updated = True
break
if not is_updated:
new_env_lines.append(stripped_line)
with open(env_path, "w") as f:
f.write("\n".join(new_env_lines) + "\n")
# Add any settings that weren't updated (they weren't in the file)
for key, value in onedrive_settings.items():
if key not in updated:
new_env_lines.append(f"{key}={value}")
# Write the updated .env file
with open(env_path, "w") as f:
f.write("\n".join(new_env_lines) + "\n")
logger.info("Successfully updated OneDrive settings in .env file")
except Exception as env_err:
logger.warning(f"Failed to write .env file (non-fatal): {env_err}")
# Update the settings in memory
if refresh_token:
@@ -275,16 +304,28 @@ async def save_onedrive_settings(
if folder_path:
settings.onedrive_folder_path = folder_path
logger.info("Successfully updated OneDrive settings")
# Persist to database (primary)
if refresh_token:
save_setting_to_db(db, "onedrive_refresh_token", refresh_token, changed_by=changed_by)
if client_id:
save_setting_to_db(db, "onedrive_client_id", client_id, changed_by=changed_by)
if client_secret:
save_setting_to_db(db, "onedrive_client_secret", client_secret, changed_by=changed_by)
if tenant_id:
save_setting_to_db(db, "onedrive_tenant_id", tenant_id, changed_by=changed_by)
if folder_path:
save_setting_to_db(db, "onedrive_folder_path", folder_path, changed_by=changed_by)
notify_settings_updated()
logger.info("Successfully saved OneDrive settings")
return {"status": "success", "message": "OneDrive settings have been saved"}
except HTTPException:
raise
except Exception as e:
logger.exception(f"Unexpected error saving OneDrive settings: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to save OneDrive settings: {str(e)}"
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to save OneDrive settings: {str(e)}",
)
@@ -297,33 +338,46 @@ async def update_onedrive_settings(
client_secret: Annotated[Optional[str], Form()] = None,
tenant_id: Annotated[str, Form()] = "common",
folder_path: Annotated[Optional[str], Form()] = None,
db: Session = Depends(get_db),
):
"""
Update OneDrive settings in memory (without modifying .env file)
Update OneDrive settings in memory and persist to database
"""
try:
logger.info("Updating OneDrive settings in memory")
logger.info("Updating OneDrive settings in memory and database")
# Update settings in memory
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 "wizard"
)
# Update settings in memory and persist to database
if refresh_token:
settings.onedrive_refresh_token = refresh_token
logger.info("Updated ONEDRIVE_REFRESH_TOKEN in memory")
save_setting_to_db(db, "onedrive_refresh_token", refresh_token, changed_by=changed_by)
logger.info("Updated ONEDRIVE_REFRESH_TOKEN in memory and database")
if client_id:
settings.onedrive_client_id = client_id
logger.info("Updated ONEDRIVE_CLIENT_ID in memory")
save_setting_to_db(db, "onedrive_client_id", client_id, changed_by=changed_by)
logger.info("Updated ONEDRIVE_CLIENT_ID in memory and database")
if client_secret:
settings.onedrive_client_secret = client_secret
logger.info("Updated ONEDRIVE_CLIENT_SECRET in memory")
save_setting_to_db(db, "onedrive_client_secret", client_secret, changed_by=changed_by)
logger.info("Updated ONEDRIVE_CLIENT_SECRET in memory and database")
if tenant_id:
settings.onedrive_tenant_id = tenant_id
logger.info("Updated ONEDRIVE_TENANT_ID in memory")
save_setting_to_db(db, "onedrive_tenant_id", tenant_id, changed_by=changed_by)
logger.info("Updated ONEDRIVE_TENANT_ID in memory and database")
if folder_path:
settings.onedrive_folder_path = folder_path
logger.info("Updated ONEDRIVE_FOLDER_PATH in memory")
save_setting_to_db(db, "onedrive_folder_path", folder_path, changed_by=changed_by)
logger.info("Updated ONEDRIVE_FOLDER_PATH in memory and database")
notify_settings_updated()
# Test the token to make sure it works
try:
@@ -333,14 +387,21 @@ async def update_onedrive_settings(
logger.info("Successfully tested OneDrive token")
except Exception as e:
logger.error(f"Token test failed after updating settings: {str(e)}")
return {"status": "warning", "message": "Settings updated but token test failed: " + str(e)}
return {
"status": "warning",
"message": "Settings updated but token test failed: " + str(e),
}
return {"status": "success", "message": "OneDrive settings have been updated in memory"}
return {
"status": "success",
"message": "OneDrive settings have been updated in memory and database",
}
except Exception as e:
logger.exception(f"Unexpected error updating OneDrive settings: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to update OneDrive settings: {str(e)}"
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to update OneDrive settings: {str(e)}",
)
+212 -12
View File
@@ -16,11 +16,15 @@ 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"])
@@ -79,7 +83,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)
@@ -90,7 +97,10 @@ async def get_settings(request: Request, db: DbSession, admin: AdminUser):
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)
@@ -111,7 +121,8 @@ async def get_setting(key: str, request: Request, db: DbSession, admin: AdminUse
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}",
)
@@ -135,13 +146,23 @@ async def update_setting(
if not is_valid:
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 +179,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 +192,19 @@ 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 +215,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,7 +271,10 @@ 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")
@@ -250,6 +286,11 @@ 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
@@ -260,7 +301,7 @@ async def bulk_update_settings(updates: list[SettingUpdate], request: Request, d
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"})
else:
@@ -269,6 +310,165 @@ async def bulk_update_settings(updates: list[SettingUpdate], request: Request, d
logger.error(f"Error updating setting {update.key}: {e}")
errors.append({"key": update.key, "error": str(e)})
if results:
notify_settings_updated()
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}
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}",
)
@router.get("/export-env")
async def export_env_settings(
request: Request,
db: DbSession,
admin: AdminUser,
source: str = "db",
):
"""
Export current settings as a ``.env`` file.
Query params:
- ``source=db`` (default) only settings explicitly saved to the database.
- ``source=effective`` full runtime configuration (DB > ENV > defaults) for
every key defined in SETTING_METADATA.
Returns a downloadable plain-text file suitable for bootstrapping another
installation. All values — including sensitive ones — are included; only
admins can access this endpoint.
"""
from fastapi.responses import Response as FastAPIResponse
from app.utils.settings_service import get_settings_for_export
if source not in ("db", "effective"):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="source must be 'db' or 'effective'",
)
try:
export_data = get_settings_for_export(db, source=source)
lines = [
"# DocuElevate configuration export",
f"# Source: {source}",
"# Generated by DocuElevate Settings Export",
"# WARNING: This file contains sensitive values. Handle with care.",
"",
]
for env_key, value in export_data.items():
lines.append(f"{env_key}={value}")
lines.append("") # trailing newline
content = "\n".join(lines)
return FastAPIResponse(
content=content,
media_type="text/plain",
headers={"Content-Disposition": f'attachment; filename="docuelevate-{source}.env"'},
)
except Exception as e:
logger.error(f"Error exporting settings: {e}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to export settings",
)
+5
View File
@@ -35,6 +35,11 @@ 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"},
}
+14
View File
@@ -102,3 +102,17 @@ class ApplicationSettings(Base):
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())
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"
+219 -5
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__)
@@ -888,16 +888,18 @@ 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
@@ -917,13 +919,39 @@ def save_setting_to_db(db: Session, key: str, value: Optional[str]) -> bool:
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
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,13 +991,16 @@ 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
@@ -977,9 +1008,33 @@ def delete_setting_from_db(db: Session, key: str) -> bool:
try:
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 +1116,162 @@ 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
def get_settings_for_export(db: Session, source: str = "db") -> Dict[str, str]:
"""
Collect settings for export as environment variables.
Args:
db: Database session
source: ``"db"`` to export only database-persisted settings (default);
``"effective"`` to export the full current runtime configuration
(DB overrides ENV overrides application defaults) for every key
listed in SETTING_METADATA.
Returns:
Ordered dict mapping uppercase ENV variable names to their string values.
Sensitive values are included (the caller is responsible for access control).
"""
if source == "effective":
from app.config import settings as app_settings
db_settings = get_all_settings_from_db(db)
result = {}
for key in sorted(SETTING_METADATA.keys()):
# DB wins, then live settings object (ENV/default)
if key in db_settings and db_settings[key] is not None:
value = db_settings[key]
else:
value = getattr(app_settings, key, None)
if value is not None:
result[key.upper()] = str(value)
return result
else:
# DB only
db_settings = get_all_settings_from_db(db)
return {k.upper(): v for k, v in sorted(db_settings.items()) if v is not None}
+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")
+47 -4
View File
@@ -103,7 +103,7 @@ 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 +112,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")
@@ -181,4 +189,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",
)
+51 -1
View File
@@ -10,6 +10,7 @@ from fastapi.responses import RedirectResponse
from sqlalchemy.orm import Session
from app.utils.settings_service import save_setting_to_db
from app.utils.settings_sync import notify_settings_updated
from app.utils.setup_wizard import get_wizard_steps
from app.views.base import APIRouter, get_db, templates
@@ -18,7 +19,7 @@ router = APIRouter()
@router.get("/setup")
async def setup_wizard(request: Request, step: int = 1):
async def setup_wizard(request: Request, step: int = 1, db: Session = Depends(get_db)):
"""
Setup wizard for first-time configuration.
@@ -41,6 +42,31 @@ async def setup_wizard(request: Request, step: int = 1):
# Get step category (all settings in a step should have same category)
step_category = current_settings[0].get("wizard_category", "Configuration") if current_settings else "Configuration"
# Enrich settings with current live values
from app.config import settings as app_settings
from app.utils.settings_service import get_setting_from_db
enriched_settings = []
for s in current_settings:
key = s["key"]
db_val = get_setting_from_db(db, key)
env_val = getattr(app_settings, key, None)
# Determine current_value and source
if db_val is not None:
current_value = db_val
value_source = "db"
elif env_val is not None and str(env_val).strip():
current_value = str(env_val)
value_source = "env"
elif s.get("default") is not None:
current_value = s["default"]
value_source = "default"
else:
current_value = ""
value_source = "none"
enriched_settings.append({**s, "current_value": current_value, "value_source": value_source})
current_settings = enriched_settings
return templates.TemplateResponse(
"setup_wizard.html",
{
@@ -50,6 +76,7 @@ async def setup_wizard(request: Request, step: int = 1):
"settings": current_settings,
"step_category": step_category,
"progress_percent": int((step / max_step) * 100),
"setup_skipped": bool(get_setting_from_db(db, "_setup_wizard_skipped")),
},
)
@@ -87,6 +114,9 @@ async def setup_wizard_save(request: Request, step: int = Form(...), db: Session
logger.info(f"Setup wizard step {step}: Saved {saved_count} settings")
if saved_count > 0:
notify_settings_updated()
# Determine next step
max_step = max(wizard_steps.keys())
next_step = step + 1
@@ -122,3 +152,23 @@ async def setup_wizard_skip(request: Request):
except Exception as e:
logger.error(f"Error skipping setup wizard: {e}")
return RedirectResponse(url="/", status_code=303)
@router.get("/setup/undo-skip")
async def setup_wizard_undo_skip(request: Request, db: Session = Depends(get_db)):
"""
Undo a previously skipped setup wizard.
Removes the skip marker from the database so the wizard will be
presented again on next visit to the home page. Redirects to
step 1 of the wizard immediately.
"""
try:
from app.utils.settings_service import delete_setting_from_db
delete_setting_from_db(db, "_setup_wizard_skipped", changed_by="wizard_undo_skip")
logger.info("Setup wizard skip marker removed; redirecting to wizard")
return RedirectResponse(url="/setup?step=1", status_code=303)
except Exception as e:
logger.error(f"Error undoing setup wizard skip: {e}")
return RedirectResponse(url="/settings", status_code=303)
+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 %}
+87 -4
View File
@@ -13,10 +13,44 @@
<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>
<div class="flex items-center gap-2">
<a href="/setup?step=1"
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">
<i class="fas fa-magic mr-2"></i> Setup Wizard
</a>
<div class="relative" x-data="{ exportOpen: false }">
<button @click="exportOpen = !exportOpen"
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">
<i class="fas fa-download mr-2"></i> Export .env
<i class="fas fa-chevron-down ml-1 text-xs"></i>
</button>
<div x-show="exportOpen" @click.outside="exportOpen = false" x-transition
class="absolute right-0 mt-1 w-52 bg-white border border-gray-200 rounded-md shadow-lg z-10">
<a href="/api/settings/export-env?source=db"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
@click="exportOpen = false">
<i class="fas fa-database mr-2 text-green-600"></i>DB settings only
</a>
<a href="/api/settings/export-env?source=effective"
class="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-50"
@click="exportOpen = false">
<i class="fas fa-layer-group mr-2 text-blue-600"></i>Full effective config
</a>
</div>
</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>
<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 +184,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 +235,7 @@ function settingsApp() {
originalData: {},
showPassword: {},
saving: false,
savingKey: null,
showAlert: false,
alertType: 'success',
alertTitle: '',
@@ -230,6 +281,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();
+21 -2
View File
@@ -134,13 +134,21 @@
type="{% if setting.sensitive %}password{% else %}text{% endif %}"
id="{{ setting.key }}"
name="{{ setting.key }}"
value="{{ setting.default if setting.default else '' }}"
value="{{ setting.current_value if setting.current_value else '' }}"
class="wizard-input w-full px-4 py-3 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
placeholder="{{ setting.description }}"
{% if setting.default is none or setting.key in ['admin_password', 'openai_api_key', 'azure_ai_key', 'azure_endpoint'] %}required{% endif %}
/>
{% endif %}
{% if setting.value_source == 'db' %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-100 text-green-800 mt-1">DB</span>
{% elif setting.value_source == 'env' %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 mt-1">ENV</span>
{% elif setting.value_source == 'default' %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800 mt-1">DEFAULT</span>
{% endif %}
{% if setting.key == 'admin_password' %}
<p class="mt-2 text-xs text-amber-600">
<i class="fas fa-exclamation-triangle"></i>
@@ -159,7 +167,12 @@
<!-- Navigation Buttons -->
<div class="flex justify-between items-center mt-8 pt-6 border-t border-gray-200">
<div>
{% if current_step == 1 %}
{% if setup_skipped %}
<span class="text-sm text-amber-600 font-medium">
<i class="fas fa-exclamation-triangle"></i>
Setup was previously skipped.
</span>
{% elif current_step == 1 %}
<a href="/setup/skip" class="text-sm text-gray-600 hover:text-gray-900">
<i class="fas fa-forward"></i>
Skip setup (advanced users)
@@ -200,6 +213,12 @@
<p class="text-xs text-gray-500 mt-2">
Fields marked with <span class="text-red-600">*</span> are required.
</p>
{% if setup_skipped %}
<p class="text-xs text-amber-600 mt-2">
<i class="fas fa-info-circle"></i>
You previously skipped the setup wizard. <a href="/setup/undo-skip" class="underline hover:text-amber-800">Click here to re-run it.</a>
</p>
{% endif %}
</div>
</div>
+10 -8
View File
@@ -107,7 +107,8 @@ class TestUpdateDropboxSettings:
"""Test that exceptions return 500 error."""
# Make setting the attribute raise an exception
type(mock_settings).dropbox_refresh_token = property(
lambda self: "", lambda self, v: (_ for _ in ()).throw(RuntimeError("forced"))
lambda self: "",
lambda self, v: (_ for _ in ()).throw(RuntimeError("forced")),
)
response = client.post(
@@ -264,15 +265,16 @@ class TestSaveDropboxSettings:
@patch("app.api.dropbox.settings")
def test_save_settings_env_not_found(self, mock_settings, client):
"""Test error when .env file is not found."""
# The endpoint constructs the env path using __file__
"""Test that missing .env file is non-fatal — DB write still succeeds."""
with patch("os.path.exists", return_value=False):
response = client.post(
"/api/dropbox/save-settings",
data={"refresh_token": "test-token"},
)
assert response.status_code == 500
# .env write is best-effort; endpoint should still succeed via DB write
assert response.status_code == 200
assert response.json()["status"] == "success"
@patch("app.api.dropbox.settings")
def test_save_settings_success(self, mock_settings, client, tmp_path):
@@ -389,7 +391,7 @@ class TestSaveDropboxSettings:
@patch("app.api.dropbox.settings")
def test_save_settings_io_error(self, mock_settings, client, tmp_path):
"""Test handling of I/O errors when saving settings."""
"""Test that I/O errors on .env write are non-fatal — DB write still succeeds."""
mock_settings.dropbox_refresh_token = ""
# Create a temporary .env file
@@ -407,6 +409,6 @@ class TestSaveDropboxSettings:
data={"refresh_token": "new-token"},
)
assert response.status_code == 500
data = response.json()
assert "Failed to save Dropbox settings" in data["detail"]
# .env write is best-effort; endpoint should still succeed via DB write
assert response.status_code == 200
assert response.json()["status"] == "success"
+89 -26
View File
@@ -119,13 +119,19 @@ class TestTestOneDriveToken:
# Mock token refresh response
mock_post_response = Mock()
mock_post_response.status_code = 200
mock_post_response.json.return_value = {"access_token": "test_access_token", "expires_in": 3600}
mock_post_response.json.return_value = {
"access_token": "test_access_token",
"expires_in": 3600,
}
mock_post.return_value = mock_post_response
# Mock user info response
mock_get_response = Mock()
mock_get_response.status_code = 200
mock_get_response.json.return_value = {"displayName": "Test User", "userPrincipalName": "test@example.com"}
mock_get_response.json.return_value = {
"displayName": "Test User",
"userPrincipalName": "test@example.com",
}
mock_get.return_value = mock_get_response
response = client.get("/api/onedrive/test-token")
@@ -198,7 +204,10 @@ class TestTestOneDriveToken:
# Mock user info
mock_get_response = Mock()
mock_get_response.status_code = 200
mock_get_response.json.return_value = {"displayName": "Test User", "userPrincipalName": "test@example.com"}
mock_get_response.json.return_value = {
"displayName": "Test User",
"userPrincipalName": "test@example.com",
}
mock_get.return_value = mock_get_response
with patch("os.path.exists", return_value=False):
@@ -209,12 +218,23 @@ class TestTestOneDriveToken:
@patch("requests.post")
@patch("requests.get")
@patch("builtins.open", new_callable=mock_open, read_data="ONEDRIVE_REFRESH_TOKEN=old_token\n")
@patch(
"builtins.open",
new_callable=mock_open,
read_data="ONEDRIVE_REFRESH_TOKEN=old_token\n",
)
@patch("os.path.exists")
@patch("os.path.dirname")
@patch("app.config.settings")
def test_test_token_updates_env_file(
self, mock_settings, mock_dirname, mock_exists, mock_file, mock_get, mock_post, client: TestClient
self,
mock_settings,
mock_dirname,
mock_exists,
mock_file,
mock_get,
mock_post,
client: TestClient,
):
"""Test that new refresh token is saved to .env file."""
mock_settings.onedrive_refresh_token = "old_token"
@@ -238,7 +258,10 @@ class TestTestOneDriveToken:
# Mock user info
mock_get_response = Mock()
mock_get_response.status_code = 200
mock_get_response.json.return_value = {"displayName": "Test User", "userPrincipalName": "test@example.com"}
mock_get_response.json.return_value = {
"displayName": "Test User",
"userPrincipalName": "test@example.com",
}
mock_get.return_value = mock_get_response
response = client.get("/api/onedrive/test-token")
@@ -258,7 +281,10 @@ class TestTestOneDriveToken:
# Mock successful refresh
mock_post_response = Mock()
mock_post_response.status_code = 200
mock_post_response.json.return_value = {"access_token": "test_access_token", "expires_in": 3600}
mock_post_response.json.return_value = {
"access_token": "test_access_token",
"expires_in": 3600,
}
mock_post.return_value = mock_post_response
# Mock failed user info
@@ -343,17 +369,24 @@ class TestSaveOneDriveSettings:
@patch("os.path.exists")
@patch("os.path.dirname")
def test_save_settings_env_file_not_found(self, mock_dirname, mock_exists, client: TestClient):
"""Test save when .env file doesn't exist."""
"""Test that missing .env file is non-fatal — DB write still succeeds."""
mock_exists.return_value = False
mock_dirname.return_value = "/app"
response = client.post("/api/onedrive/save-settings", data={"refresh_token": "token", "tenant_id": "common"})
response = client.post(
"/api/onedrive/save-settings",
data={"refresh_token": "token", "tenant_id": "common"},
)
assert response.status_code == 500
data = response.json()
assert "could not find .env file" in data["detail"].lower()
# .env write is best-effort; endpoint should still succeed via DB write
assert response.status_code == 200
assert response.json()["status"] == "success"
@patch("builtins.open", new_callable=mock_open, read_data="ONEDRIVE_REFRESH_TOKEN=old_token\n")
@patch(
"builtins.open",
new_callable=mock_open,
read_data="ONEDRIVE_REFRESH_TOKEN=old_token\n",
)
@patch("os.path.exists")
@patch("os.path.dirname")
@patch("app.config.settings")
@@ -365,12 +398,17 @@ class TestSaveOneDriveSettings:
mock_dirname.return_value = "/app"
response = client.post(
"/api/onedrive/save-settings", data={"refresh_token": "updated_token", "tenant_id": "common"}
"/api/onedrive/save-settings",
data={"refresh_token": "updated_token", "tenant_id": "common"},
)
assert response.status_code == 200
@patch("builtins.open", new_callable=mock_open, read_data="# ONEDRIVE_CLIENT_ID=commented\n")
@patch(
"builtins.open",
new_callable=mock_open,
read_data="# ONEDRIVE_CLIENT_ID=commented\n",
)
@patch("os.path.exists")
@patch("os.path.dirname")
@patch("app.config.settings")
@@ -383,7 +421,11 @@ class TestSaveOneDriveSettings:
response = client.post(
"/api/onedrive/save-settings",
data={"refresh_token": "token", "client_id": "new_client_id", "tenant_id": "common"},
data={
"refresh_token": "token",
"client_id": "new_client_id",
"tenant_id": "common",
},
)
assert response.status_code == 200
@@ -401,7 +443,11 @@ class TestSaveOneDriveSettings:
response = client.post(
"/api/onedrive/save-settings",
data={"refresh_token": "new_token", "folder_path": "/New/Path", "tenant_id": "common"},
data={
"refresh_token": "new_token",
"folder_path": "/New/Path",
"tenant_id": "common",
},
)
assert response.status_code == 200
@@ -415,12 +461,17 @@ class TestSaveOneDriveSettings:
@patch("os.path.exists")
@patch("os.path.dirname")
def test_save_settings_exception_handling(self, mock_dirname, mock_exists, client: TestClient):
"""Test exception handling in save settings."""
"""Test that exceptions in .env write are non-fatal — DB write still succeeds."""
mock_exists.side_effect = Exception("Unexpected error")
response = client.post("/api/onedrive/save-settings", data={"refresh_token": "token", "tenant_id": "common"})
response = client.post(
"/api/onedrive/save-settings",
data={"refresh_token": "token", "tenant_id": "common"},
)
assert response.status_code == 500
# .env write exception is caught; endpoint succeeds via DB write
assert response.status_code == 200
assert response.json()["status"] == "success"
@pytest.mark.unit
@@ -455,7 +506,8 @@ class TestUpdateOneDriveSettings:
mock_get_token.return_value = "test_token"
response = client.post(
"/api/onedrive/update-settings", data={"refresh_token": "new_token", "tenant_id": "common"}
"/api/onedrive/update-settings",
data={"refresh_token": "new_token", "tenant_id": "common"},
)
assert response.status_code == 200
@@ -467,7 +519,8 @@ class TestUpdateOneDriveSettings:
mock_get_token.side_effect = Exception("Token invalid")
response = client.post(
"/api/onedrive/update-settings", data={"refresh_token": "bad_token", "tenant_id": "common"}
"/api/onedrive/update-settings",
data={"refresh_token": "bad_token", "tenant_id": "common"},
)
assert response.status_code == 200
@@ -486,9 +539,13 @@ class TestUpdateOneDriveSettings:
"""Test exception handling in update settings."""
mock_settings.onedrive_refresh_token = None
with patch("app.tasks.upload_to_onedrive.get_onedrive_token", side_effect=Exception("Fatal error")):
with patch(
"app.tasks.upload_to_onedrive.get_onedrive_token",
side_effect=Exception("Fatal error"),
):
response = client.post(
"/api/onedrive/update-settings", data={"refresh_token": "token", "tenant_id": "common"}
"/api/onedrive/update-settings",
data={"refresh_token": "token", "tenant_id": "common"},
)
# Should still update settings even if test fails
@@ -578,7 +635,10 @@ class TestOneDriveIntegration:
with patch("app.tasks.upload_to_onedrive.get_onedrive_token"):
response = client.post(
"/api/onedrive/update-settings",
data={"refresh_token": token_data["refresh_token"], "tenant_id": "common"},
data={
"refresh_token": token_data["refresh_token"],
"tenant_id": "common",
},
)
assert response.status_code == 200
@@ -606,7 +666,10 @@ class TestOneDriveIntegration:
mock_get_response = Mock()
mock_get_response.status_code = 200
mock_get_response.json.return_value = {"displayName": "Test User", "userPrincipalName": "test@example.com"}
mock_get_response.json.return_value = {
"displayName": "Test User",
"userPrincipalName": "test@example.com",
}
mock_get.return_value = mock_get_response
with patch("os.path.exists", return_value=False):
+414
View File
@@ -0,0 +1,414 @@
"""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
+485
View File
@@ -0,0 +1,485 @@
"""
Tests for wizard DB persistence, settings export, and related functionality.
"""
from unittest.mock import MagicMock, patch
import pytest
from app.utils.settings_service import get_setting_from_db, save_setting_to_db
# ---------------------------------------------------------------------------
# TestSetupWizardDbPersist
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestSetupWizardDbPersist:
"""Unit tests for setup_wizard_save POST handler DB persistence."""
@patch("app.views.wizard.notify_settings_updated")
@patch("app.views.wizard.save_setting_to_db")
def test_settings_saved_to_db(self, mock_save, mock_notify, client):
"""Test that settings are saved to DB via save_setting_to_db."""
mock_save.return_value = True
response = client.post(
"/setup",
data={"step": "1", "database_url": "sqlite:///test.db"},
follow_redirects=False,
)
assert response.status_code == 303
mock_save.assert_called()
@patch("app.views.wizard.notify_settings_updated")
@patch("app.views.wizard.save_setting_to_db")
def test_notify_called_when_settings_saved(self, mock_save, mock_notify, client):
"""Test that notify_settings_updated is called when settings are saved."""
mock_save.return_value = True
client.post(
"/setup",
data={"step": "1", "database_url": "sqlite:///test.db"},
follow_redirects=False,
)
mock_notify.assert_called_once()
@patch("app.views.wizard.notify_settings_updated")
@patch("app.views.wizard.save_setting_to_db")
def test_notify_not_called_when_no_settings_saved(self, mock_save, mock_notify, client):
"""Test that notify_settings_updated is NOT called when saved_count == 0."""
mock_save.return_value = False
client.post(
"/setup",
data={"step": "1"}, # no values provided
follow_redirects=False,
)
mock_notify.assert_not_called()
@patch("app.views.wizard.notify_settings_updated")
@patch("app.views.wizard.secrets.token_hex")
@patch("app.views.wizard.save_setting_to_db")
def test_auto_generate_session_secret(self, mock_save, mock_token, mock_notify, client):
"""Test that session_secret auto-generate path produces a real token."""
mock_save.return_value = True
mock_token.return_value = "deadbeef" * 8
client.post(
"/setup",
data={"step": "2", "session_secret": "auto-generate"},
follow_redirects=False,
)
mock_token.assert_called_once()
# Ensure save was called with the generated token, not 'auto-generate'
for call_args in mock_save.call_args_list:
args = call_args[0]
if len(args) >= 2 and args[1] == "session_secret":
assert args[2] != "auto-generate"
# ---------------------------------------------------------------------------
# TestSetupWizardUndoSkip
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestSetupWizardUndoSkip:
"""Tests for /setup/undo-skip route."""
def test_undo_skip_removes_marker(self, client, db_session):
"""Test that undo-skip removes the _setup_wizard_skipped marker from DB."""
# First, put the marker in DB
save_setting_to_db(db_session, "_setup_wizard_skipped", "true")
assert get_setting_from_db(db_session, "_setup_wizard_skipped") == "true"
# Undo skip via the route
response = client.get("/setup/undo-skip", follow_redirects=False)
# Should redirect
assert response.status_code in (303, 200)
def test_undo_skip_redirects_to_wizard(self, client):
"""Test that undo-skip redirects to /setup?step=1."""
response = client.get("/setup/undo-skip", follow_redirects=False)
# The redirect should go to /setup?step=1 or /settings on error
assert response.status_code in (303, 302)
location = response.headers.get("location", "")
assert "/setup" in location or "/settings" in location
@patch("app.utils.settings_service.delete_setting_from_db")
def test_undo_skip_calls_delete(self, mock_delete, client):
"""Test that undo-skip calls delete_setting_from_db."""
mock_delete.return_value = True
# Just ensure the route exists and does not 404
response = client.get("/setup/undo-skip", follow_redirects=False)
assert response.status_code != 404
# ---------------------------------------------------------------------------
# TestDropboxSaveSettingsDbPersist
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestDropboxSaveSettingsDbPersist:
"""Unit tests for save_dropbox_settings DB persistence."""
@patch("app.api.dropbox.settings")
@patch("app.api.dropbox.notify_settings_updated")
@patch("app.api.dropbox.save_setting_to_db")
def test_db_written_even_when_env_missing(self, mock_save, mock_notify, mock_settings, client):
"""Test that DB is written even when .env doesn't exist (no exception)."""
mock_save.return_value = True
with patch("os.path.exists", return_value=False):
response = client.post(
"/api/dropbox/save-settings",
data={"refresh_token": "test-refresh-token"},
follow_redirects=False,
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
mock_save.assert_called()
@patch("app.api.dropbox.settings")
@patch("app.api.dropbox.notify_settings_updated")
@patch("app.api.dropbox.save_setting_to_db")
def test_notify_settings_updated_called(self, mock_save, mock_notify, mock_settings, client):
"""Test that notify_settings_updated is called."""
mock_save.return_value = True
with patch("os.path.exists", return_value=False):
client.post(
"/api/dropbox/save-settings",
data={"refresh_token": "test-refresh-token"},
follow_redirects=False,
)
mock_notify.assert_called_once()
@patch("app.api.dropbox.settings")
@patch("app.api.dropbox.notify_settings_updated")
@patch("app.api.dropbox.save_setting_to_db")
def test_all_provided_values_persisted(self, mock_save, mock_notify, mock_settings, client):
"""Test that all provided values are persisted to DB."""
mock_save.return_value = True
with patch("os.path.exists", return_value=False):
client.post(
"/api/dropbox/save-settings",
data={
"refresh_token": "tok",
"app_key": "key",
"app_secret": "secret",
"folder_path": "/uploads",
},
follow_redirects=False,
)
keys_saved = [call[0][1] for call in mock_save.call_args_list]
assert "dropbox_refresh_token" in keys_saved
assert "dropbox_app_key" in keys_saved
assert "dropbox_app_secret" in keys_saved
assert "dropbox_folder" in keys_saved
# ---------------------------------------------------------------------------
# TestGoogleDriveUpdateSettingsDbPersist
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestGoogleDriveUpdateSettingsDbPersist:
"""Unit tests for update_google_drive_settings DB persistence."""
@patch("app.api.google_drive.settings")
@patch("app.api.google_drive.notify_settings_updated")
@patch("app.api.google_drive.save_setting_to_db")
def test_db_written_for_each_provided_field(self, mock_save, mock_notify, mock_settings, client):
"""Test that DB is written for each provided field."""
mock_save.return_value = True
response = client.post(
"/api/google-drive/update-settings",
data={
"refresh_token": "gdrive-refresh",
"client_id": "client-id",
"client_secret": "client-secret",
"folder_id": "folder-123",
"use_oauth": "true",
},
follow_redirects=False,
)
assert response.status_code == 200
keys_saved = [call[0][1] for call in mock_save.call_args_list]
assert "google_drive_refresh_token" in keys_saved
assert "google_drive_client_id" in keys_saved
assert "google_drive_client_secret" in keys_saved
assert "google_drive_folder_id" in keys_saved
assert "google_drive_use_oauth" in keys_saved
@patch("app.api.google_drive.settings")
@patch("app.api.google_drive.notify_settings_updated")
@patch("app.api.google_drive.save_setting_to_db")
def test_use_oauth_saved_as_lowercase_string(self, mock_save, mock_notify, mock_settings, client):
"""Test that use_oauth is saved as 'true' or 'false' string."""
mock_save.return_value = True
client.post(
"/api/google-drive/update-settings",
data={"refresh_token": "tok", "use_oauth": "true"},
follow_redirects=False,
)
use_oauth_calls = [call for call in mock_save.call_args_list if call[0][1] == "google_drive_use_oauth"]
assert len(use_oauth_calls) == 1
assert use_oauth_calls[0][0][2] in ("true", "false")
@patch("app.api.google_drive.settings")
@patch("app.api.google_drive.notify_settings_updated")
@patch("app.api.google_drive.save_setting_to_db")
def test_notify_called(self, mock_save, mock_notify, mock_settings, client):
"""Test that notify_settings_updated is called."""
mock_save.return_value = True
client.post(
"/api/google-drive/update-settings",
data={"refresh_token": "tok"},
follow_redirects=False,
)
mock_notify.assert_called_once()
# ---------------------------------------------------------------------------
# TestOneDriveSaveSettingsDbPersist
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestOneDriveSaveSettingsDbPersist:
"""Unit tests for save_onedrive_settings DB persistence."""
@patch("app.api.onedrive.settings")
@patch("app.api.onedrive.notify_settings_updated")
@patch("app.api.onedrive.save_setting_to_db")
def test_db_written_even_without_env_file(self, mock_save, mock_notify, mock_settings, client):
"""Test that DB is written even when .env file does not exist."""
mock_save.return_value = True
with patch("os.path.exists", return_value=False):
response = client.post(
"/api/onedrive/save-settings",
data={"refresh_token": "od-refresh", "tenant_id": "common"},
follow_redirects=False,
)
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
mock_save.assert_called()
@patch("app.api.onedrive.settings")
@patch("app.api.onedrive.notify_settings_updated")
@patch("app.api.onedrive.save_setting_to_db")
def test_all_fields_persisted(self, mock_save, mock_notify, mock_settings, client):
"""Test that all provided fields are persisted to DB."""
mock_save.return_value = True
with patch("os.path.exists", return_value=False):
client.post(
"/api/onedrive/save-settings",
data={
"refresh_token": "tok",
"client_id": "cid",
"client_secret": "csec",
"tenant_id": "my-tenant",
"folder_path": "/docs",
},
follow_redirects=False,
)
keys_saved = [call[0][1] for call in mock_save.call_args_list]
assert "onedrive_refresh_token" in keys_saved
assert "onedrive_client_id" in keys_saved
assert "onedrive_client_secret" in keys_saved
assert "onedrive_tenant_id" in keys_saved
assert "onedrive_folder_path" in keys_saved
@patch("app.api.onedrive.settings")
@patch("app.api.onedrive.notify_settings_updated")
@patch("app.api.onedrive.save_setting_to_db")
def test_notify_called(self, mock_save, mock_notify, mock_settings, client):
"""Test that notify_settings_updated is called."""
mock_save.return_value = True
with patch("os.path.exists", return_value=False):
client.post(
"/api/onedrive/save-settings",
data={"refresh_token": "tok"},
follow_redirects=False,
)
mock_notify.assert_called_once()
# ---------------------------------------------------------------------------
# TestGetSettingsForExport
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestGetSettingsForExport:
"""Unit tests for the get_settings_for_export service function."""
def test_source_db_returns_only_db_settings(self, db_session):
"""Test that source=db returns only DB-persisted settings."""
from app.utils.settings_service import get_settings_for_export
save_setting_to_db(db_session, "workdir", "/tmp/test", changed_by="test")
result = get_settings_for_export(db_session, source="db")
assert "WORKDIR" in result
assert result["WORKDIR"] == "/tmp/test"
def test_source_effective_includes_metadata_keys(self, db_session):
"""Test that source=effective includes keys from SETTING_METADATA."""
from app.utils.settings_service import get_settings_for_export
result = get_settings_for_export(db_session, source="effective")
# The effective export should include keys from SETTING_METADATA that have values
# At a minimum check it returns a dict
assert isinstance(result, dict)
# Keys should be uppercase
for k in result:
assert k == k.upper()
def test_keys_are_uppercase(self, db_session):
"""Test that all keys are returned in uppercase."""
from app.utils.settings_service import get_settings_for_export
save_setting_to_db(db_session, "workdir", "/tmp", changed_by="test")
result = get_settings_for_export(db_session, source="db")
for k in result:
assert k == k.upper(), f"Key {k!r} is not uppercase"
def test_none_values_excluded(self, db_session):
"""Test that None values are excluded from the export."""
from app.utils.settings_service import get_settings_for_export
result = get_settings_for_export(db_session, source="db")
for v in result.values():
assert v is not None
def test_db_only_excludes_env_only_values(self, db_session):
"""Test that source=db does NOT include ENV-only values (only DB rows)."""
from app.utils.settings_service import get_settings_for_export
# Ensure no settings in DB
result = get_settings_for_export(db_session, source="db")
# DB is empty so result should be empty
assert len(result) == 0
# ---------------------------------------------------------------------------
# TestExportEnvEndpoint
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestExportEnvEndpoint:
"""Unit tests for export_env_settings endpoint function."""
def test_requires_admin(self, client):
"""Test that the endpoint requires admin access (no session)."""
response = client.get("/api/settings/export-env")
assert response.status_code in (302, 401, 403)
def test_returns_text_plain(self, db_session):
"""Test that the endpoint returns text/plain response."""
import asyncio
from app.api.settings import export_env_settings
mock_request = MagicMock()
mock_admin = {"id": "admin", "is_admin": True}
result = asyncio.run(export_env_settings(mock_request, db_session, mock_admin, source="db"))
assert result.media_type == "text/plain"
def test_content_disposition_header(self, db_session):
"""Test that the response includes a content-disposition attachment header."""
import asyncio
from app.api.settings import export_env_settings
mock_request = MagicMock()
mock_admin = {"id": "admin", "is_admin": True}
result = asyncio.run(export_env_settings(mock_request, db_session, mock_admin, source="db"))
cd = result.headers.get("content-disposition", "")
assert "attachment" in cd
assert ".env" in cd
def test_invalid_source_returns_400(self, db_session):
"""Test that an invalid source parameter raises HTTPException 400."""
import asyncio
from fastapi import HTTPException
from app.api.settings import export_env_settings
mock_request = MagicMock()
mock_admin = {"id": "admin", "is_admin": True}
with pytest.raises(HTTPException) as exc_info:
asyncio.run(export_env_settings(mock_request, db_session, mock_admin, source="invalid"))
assert exc_info.value.status_code == 400
def test_default_source_is_db(self, db_session):
"""Test that default source is db (filename contains 'db')."""
import asyncio
from app.api.settings import export_env_settings
mock_request = MagicMock()
mock_admin = {"id": "admin", "is_admin": True}
result = asyncio.run(export_env_settings(mock_request, db_session, mock_admin))
cd = result.headers.get("content-disposition", "")
assert "db" in cd
def test_effective_source_returns_response(self, db_session):
"""Test that source=effective returns a valid response."""
import asyncio
from app.api.settings import export_env_settings
mock_request = MagicMock()
mock_admin = {"id": "admin", "is_admin": True}
result = asyncio.run(export_env_settings(mock_request, db_session, mock_admin, source="effective"))
assert result.media_type == "text/plain"
def test_output_contains_docuelevate_header(self, db_session):
"""Test that the export output contains a DocuElevate header comment."""
import asyncio
from app.api.settings import export_env_settings
mock_request = MagicMock()
mock_admin = {"id": "admin", "is_admin": True}
result = asyncio.run(export_env_settings(mock_request, db_session, mock_admin, source="db"))
assert b"DocuElevate" in result.body