feat(settings): persist storage provider settings to DB, add export endpoint, enrich setup wizard

- dropbox/google_drive/onedrive save-settings: switch to DB as primary,
  .env write as best-effort (no longer fails when .env is absent)
- onedrive/google_drive update-settings: persist changes to DB alongside
  in-memory update; call notify_settings_updated()
- onedrive test_onedrive_token: persist rotated refresh token to DB
- settings_service: add get_settings_for_export() (db / effective modes)
- settings API: add GET /api/settings/export-env (admin-only, downloads .env)
- wizard: enrich settings with current values (DB > ENV > default) and
  value_source badges; pass setup_skipped to template; call
  notify_settings_updated() on save; add /setup/undo-skip route
- setup_wizard.html: pre-populate inputs with current_value; show
  DB/ENV/DEFAULT source badges; skip/undo-skip messaging
- settings.html: replace single Audit Log button with Setup Wizard link,
  Export .env dropdown, and Audit Log button group

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-02-23 02:41:04 +00:00
parent 0d5c20f685
commit ecc8100e24
8 changed files with 651 additions and 180 deletions
+126 -71
View File
@@ -49,7 +49,9 @@ async def exchange_dropbox_token(
}
# Use shared OAuth helper (handles secure logging and error handling)
token_data = exchange_oauth_token(provider_name="Dropbox", token_url=token_url, payload=payload)
token_data = exchange_oauth_token(
provider_name="Dropbox", token_url=token_url, payload=payload
)
# Return just what's needed by the frontend
return {
@@ -77,13 +79,19 @@ async def update_dropbox_settings(
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"
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
save_setting_to_db(db, "dropbox_refresh_token", refresh_token, changed_by=changed_by)
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:
@@ -93,7 +101,9 @@ async def update_dropbox_settings(
if app_secret:
settings.dropbox_app_secret = app_secret
save_setting_to_db(db, "dropbox_app_secret", app_secret, changed_by=changed_by)
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:
@@ -103,12 +113,16 @@ async def update_dropbox_settings(
notify_settings_updated()
return {"status": "success", "message": "Dropbox settings have been updated in memory and saved to database"}
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)}",
)
@@ -121,9 +135,16 @@ async def test_dropbox_token(request: Request):
try:
logger.info("Testing Dropbox token validity")
if not settings.dropbox_refresh_token or not settings.dropbox_app_key or not settings.dropbox_app_secret:
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}"}
@@ -146,11 +167,19 @@ async def test_dropbox_token(request: Request):
"client_secret": settings.dropbox_app_secret,
}
refresh_response = requests.post(refresh_url, data=refresh_data, timeout=settings.http_request_timeout)
refresh_response = requests.post(
refresh_url, data=refresh_data, timeout=settings.http_request_timeout
)
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}
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,
}
token_info = refresh_response.json()
access_token = token_info.get("access_token")
@@ -164,7 +193,9 @@ async def test_dropbox_token(request: Request):
)
if response.status_code != 200:
logger.error(f"Dropbox token test failed: {response.status_code} {response.text}")
logger.error(
f"Dropbox token test failed: {response.status_code} {response.text}"
)
return {
"status": "error",
"message": f"Token validation failed with status {response.status_code}: {response.text}",
@@ -176,7 +207,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}")
@@ -201,65 +235,22 @@ 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:
@@ -269,14 +260,78 @@ 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)}",
)
+125 -24
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__)
@@ -45,7 +49,9 @@ async def exchange_google_drive_token(
}
# Use shared OAuth helper (handles secure logging and error handling)
token_data = exchange_oauth_token(provider_name="Google Drive", token_url=token_url, payload=payload)
token_data = exchange_oauth_token(
provider_name="Google Drive", token_url=token_url, payload=payload
)
# Return just what's needed by the frontend
return {
@@ -64,38 +70,73 @@ 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)}")
@@ -113,7 +154,8 @@ async def test_google_drive_token(request: Request):
Tests both OAuth and service account approaches based on configuration.
"""
try:
from app.tasks.upload_to_google_drive import get_drive_service_oauth, get_google_drive_service
from app.tasks.upload_to_google_drive import (get_drive_service_oauth,
get_google_drive_service)
logger.info("Testing Google Drive token validity")
@@ -125,7 +167,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
@@ -187,8 +232,13 @@ async def test_google_drive_token(request: Request):
else:
# 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"}
logger.warning(
"Google Drive service account credentials not configured"
)
return {
"status": "error",
"message": "Google Drive service account credentials are not configured",
}
try:
service = get_google_drive_service()
@@ -203,7 +253,9 @@ async def test_google_drive_token(request: Request):
else:
user_display = user_email
logger.info(f"Successfully connected to Google Drive using service account as {user_display}")
logger.info(
f"Successfully connected to Google Drive using service account as {user_display}"
)
return {
"status": "success",
@@ -214,7 +266,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 +301,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,17 +391,29 @@ 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
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env")
env_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env"
)
# 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()}
@@ -376,7 +446,9 @@ async def save_dropbox_settings(
stripped_line = line.rstrip()
is_updated = False
for key, value in drive_settings.items():
if stripped_line.startswith(f"{key}=") or stripped_line.startswith(f"# {key}="):
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)
@@ -396,7 +468,9 @@ async def save_dropbox_settings(
logger.info("Successfully updated Google Drive settings in .env file")
except Exception as e:
logger.warning(f"Failed to update .env file: {str(e)}, but will continue with in-memory update")
logger.warning(
f"Failed to update .env file: {str(e)}, but will continue with in-memory update"
)
else:
logger.warning(
f".env file not found at {env_path}, skipping file update but continuing with in-memory update"
@@ -415,7 +489,33 @@ 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 +526,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)}",
)
+190 -75
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__)
@@ -47,10 +51,15 @@ async def exchange_onedrive_token(
}
# Use shared OAuth helper (handles secure logging and error handling)
token_data = exchange_oauth_token(provider_name="OneDrive", token_url=token_url, payload=payload)
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 +77,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"
@@ -82,27 +94,39 @@ async def test_onedrive_token(request: Request):
"scope": "offline_access Files.ReadWrite",
}
response = requests.post(token_url, data=refresh_data, timeout=settings.http_request_timeout)
response = requests.post(
token_url, data=refresh_data, timeout=settings.http_request_timeout
)
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")
expires_in = token_data.get("expires_in", 3600) # Default to 1 hour if not specified
expires_in = token_data.get(
"expires_in", 3600
) # Default to 1 hour if not specified
# Check if we got a new refresh token (Microsoft sometimes issues a new one)
new_refresh_token = token_data.get("refresh_token")
if new_refresh_token and new_refresh_token != settings.onedrive_refresh_token:
logger.info("Received new refresh token from Microsoft - will update configuration")
logger.info(
"Received new refresh token from Microsoft - will update configuration"
)
# Update refresh token in memory
settings.onedrive_refresh_token = new_refresh_token
# Also try to update .env file if it exists
try:
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env")
env_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env"
)
if os.path.exists(env_path):
with open(env_path, "r") as f:
env_lines = f.readlines()
@@ -112,13 +136,17 @@ async def test_onedrive_token(request: Request):
for line in env_lines:
if line.startswith("ONEDRIVE_REFRESH_TOKEN="):
updated_lines.append(f"ONEDRIVE_REFRESH_TOKEN={new_refresh_token}\n")
updated_lines.append(
f"ONEDRIVE_REFRESH_TOKEN={new_refresh_token}\n"
)
updated = True
else:
updated_lines.append(line)
if not updated:
updated_lines.append(f"ONEDRIVE_REFRESH_TOKEN={new_refresh_token}\n")
updated_lines.append(
f"ONEDRIVE_REFRESH_TOKEN={new_refresh_token}\n"
)
with open(env_path, "w") as f:
f.writelines(updated_lines)
@@ -128,14 +156,38 @@ 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}"}
user_response = requests.get(user_info_url, headers=headers, timeout=settings.http_request_timeout)
user_response = requests.get(
user_info_url, headers=headers, timeout=settings.http_request_timeout
)
if user_response.status_code != 200:
logger.error(f"OneDrive token test failed: {user_response.status_code} {user_response.text}")
logger.error(
f"OneDrive token test failed: {user_response.status_code} {user_response.text}"
)
return {
"status": "error",
"message": f"Token validation failed with status {user_response.status_code}: {user_response.text}",
@@ -203,65 +255,72 @@ 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 +334,38 @@ 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 +378,60 @@ 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 +441,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)}",
)
+58
View File
@@ -450,3 +450,61 @@ async def rollback_setting_to_history(
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",
)
+35
View File
@@ -1287,3 +1287,38 @@ def rollback_setting(
)
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}
+66 -4
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.
@@ -39,7 +40,38 @@ async def setup_wizard(request: Request, step: int = 1):
current_settings = wizard_steps.get(step, [])
# 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"
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,12 +82,15 @@ 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")),
},
)
@router.post("/setup")
async def setup_wizard_save(request: Request, step: int = Form(...), db: Session = Depends(get_db)):
async def setup_wizard_save(
request: Request, step: int = Form(...), db: Session = Depends(get_db)
):
"""
Save settings from the current wizard step.
"""
@@ -87,6 +122,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
@@ -100,7 +138,9 @@ async def setup_wizard_save(request: Request, step: int = Form(...), db: Session
except Exception as e:
logger.error(f"Error saving wizard settings: {e}")
return RedirectResponse(url=f"/setup?step={step}&error=save_failed", status_code=303)
return RedirectResponse(
url=f"/setup?step={step}&error=save_failed", status_code=303
)
@router.get("/setup/skip")
@@ -122,3 +162,25 @@ 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)