Backend: - Add type validation and coercion for settings API - Implement SettingStorage and SettingType in registry - Improve CRUD operations for settings Frontend: - Refactor Theme, Language, Sidebar, ViewMode contexts - Simplify admin components (GeneralTab, SettingsTab, UsersTab) - Add new settings endpoints to API client - Improve App initialization flow Infrastructure: - Update Dockerfile and docker-compose.yml - Add .dockerignore - Update Makefile and README 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
356 lines
11 KiB
Python
356 lines
11 KiB
Python
"""Settings endpoints."""
|
|
|
|
import json
|
|
from typing import Any
|
|
from fastapi import APIRouter, Depends, HTTPException, status
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app import crud, schemas
|
|
from app.dependencies import get_db, get_current_superuser, get_current_user
|
|
from app.models.user import User
|
|
from app.core.settings_registry import (
|
|
THEME_KEYS,
|
|
MODULE_KEYS,
|
|
UI_KEYS,
|
|
SETTINGS_REGISTRY,
|
|
SettingStorage,
|
|
SettingType,
|
|
get_default_value,
|
|
)
|
|
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
def _require_database_setting(key: str):
|
|
setting_def = SETTINGS_REGISTRY.get(key)
|
|
if not setting_def:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Unknown setting key '{key}'"
|
|
)
|
|
if setting_def.storage != SettingStorage.DATABASE:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Setting '{key}' is not stored in database"
|
|
)
|
|
return setting_def
|
|
|
|
|
|
def _coerce_setting_value(key: str, value: Any) -> Any:
|
|
setting_def = _require_database_setting(key)
|
|
|
|
if setting_def.type == SettingType.BOOLEAN:
|
|
if isinstance(value, bool):
|
|
coerced = value
|
|
elif isinstance(value, int) and not isinstance(value, bool):
|
|
if value in (0, 1):
|
|
coerced = bool(value)
|
|
else:
|
|
raise HTTPException(status_code=400, detail=f"Invalid boolean value for '{key}'")
|
|
elif isinstance(value, str):
|
|
lowered = value.strip().lower()
|
|
if lowered in ("true", "1"):
|
|
coerced = True
|
|
elif lowered in ("false", "0"):
|
|
coerced = False
|
|
else:
|
|
raise HTTPException(status_code=400, detail=f"Invalid boolean value for '{key}'")
|
|
else:
|
|
raise HTTPException(status_code=400, detail=f"Invalid boolean value for '{key}'")
|
|
|
|
elif setting_def.type == SettingType.INTEGER:
|
|
if isinstance(value, bool):
|
|
raise HTTPException(status_code=400, detail=f"Invalid integer value for '{key}'")
|
|
if isinstance(value, int):
|
|
coerced = value
|
|
elif isinstance(value, str):
|
|
try:
|
|
coerced = int(value.strip())
|
|
except ValueError as exc:
|
|
raise HTTPException(status_code=400, detail=f"Invalid integer value for '{key}'") from exc
|
|
else:
|
|
raise HTTPException(status_code=400, detail=f"Invalid integer value for '{key}'")
|
|
|
|
elif setting_def.type == SettingType.STRING:
|
|
if not isinstance(value, str):
|
|
raise HTTPException(status_code=400, detail=f"Invalid string value for '{key}'")
|
|
coerced = value
|
|
|
|
elif setting_def.type == SettingType.JSON:
|
|
if isinstance(value, (dict, list)):
|
|
coerced = value
|
|
elif isinstance(value, str):
|
|
try:
|
|
coerced = json.loads(value)
|
|
except json.JSONDecodeError as exc:
|
|
raise HTTPException(status_code=400, detail=f"Invalid JSON value for '{key}'") from exc
|
|
else:
|
|
raise HTTPException(status_code=400, detail=f"Invalid JSON value for '{key}'")
|
|
|
|
elif setting_def.type == SettingType.LIST:
|
|
if isinstance(value, list):
|
|
coerced = value
|
|
elif isinstance(value, str):
|
|
try:
|
|
parsed = json.loads(value)
|
|
except json.JSONDecodeError as exc:
|
|
raise HTTPException(status_code=400, detail=f"Invalid list value for '{key}'") from exc
|
|
if not isinstance(parsed, list):
|
|
raise HTTPException(status_code=400, detail=f"Invalid list value for '{key}'")
|
|
coerced = parsed
|
|
else:
|
|
raise HTTPException(status_code=400, detail=f"Invalid list value for '{key}'")
|
|
|
|
else:
|
|
coerced = value
|
|
|
|
if setting_def.choices is not None and coerced not in setting_def.choices:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Invalid value for '{key}'. Allowed: {setting_def.choices}"
|
|
)
|
|
|
|
return coerced
|
|
|
|
|
|
def _validate_bulk_keys(payload: dict[str, Any], allowed_keys: set[str]) -> None:
|
|
unknown = [key for key in payload.keys() if key not in allowed_keys]
|
|
if unknown:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_400_BAD_REQUEST,
|
|
detail=f"Unknown/unsupported keys: {sorted(unknown)}"
|
|
)
|
|
|
|
|
|
@router.get("/theme", response_model=dict[str, Any])
|
|
def get_theme_settings(
|
|
*,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
) -> Any:
|
|
"""
|
|
Get theme settings (accessible to all authenticated users).
|
|
|
|
Returns theme-related settings that apply to all users.
|
|
"""
|
|
result = {}
|
|
for key in THEME_KEYS:
|
|
setting = crud.settings.get_setting(db, key=key)
|
|
if setting:
|
|
result[key] = setting.get_value()
|
|
else:
|
|
# Use default from registry
|
|
result[key] = get_default_value(key)
|
|
return result
|
|
|
|
|
|
@router.get("/theme/public", response_model=dict[str, Any])
|
|
def get_theme_settings_public(
|
|
*,
|
|
db: Session = Depends(get_db),
|
|
) -> Any:
|
|
"""
|
|
Get theme settings without authentication.
|
|
|
|
Intended for login/landing pages to keep UI consistent before auth.
|
|
"""
|
|
result = {}
|
|
for key in THEME_KEYS:
|
|
setting = crud.settings.get_setting(db, key=key)
|
|
if setting:
|
|
result[key] = setting.get_value()
|
|
else:
|
|
result[key] = get_default_value(key)
|
|
return result
|
|
|
|
|
|
@router.put("/theme", response_model=dict[str, Any])
|
|
def update_theme_settings(
|
|
*,
|
|
db: Session = Depends(get_db),
|
|
theme_data: dict[str, Any],
|
|
current_user: User = Depends(get_current_superuser)
|
|
) -> Any:
|
|
"""
|
|
Update theme settings (admin only).
|
|
|
|
Updates multiple theme settings at once.
|
|
"""
|
|
_validate_bulk_keys(theme_data, set(THEME_KEYS))
|
|
result = {}
|
|
for key, value in theme_data.items():
|
|
coerced = _coerce_setting_value(key, value)
|
|
setting = crud.settings.update_setting(db, key=key, value=coerced)
|
|
result[key] = setting.get_value()
|
|
return result
|
|
|
|
|
|
@router.get("/modules", response_model=dict[str, Any])
|
|
def get_module_settings(
|
|
*,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
) -> Any:
|
|
"""
|
|
Get module settings (accessible to all authenticated users).
|
|
|
|
Returns module enabled/disabled states.
|
|
"""
|
|
result = {}
|
|
for key in MODULE_KEYS:
|
|
setting = crud.settings.get_setting(db, key=key)
|
|
if setting:
|
|
result[key] = setting.get_value()
|
|
else:
|
|
# Use default from registry
|
|
result[key] = get_default_value(key)
|
|
return result
|
|
|
|
|
|
@router.get("/ui", response_model=dict[str, Any])
|
|
def get_ui_settings(
|
|
*,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
) -> Any:
|
|
"""
|
|
Get UI settings (accessible to all authenticated users).
|
|
|
|
Returns UI/layout settings that apply to all users.
|
|
"""
|
|
result = {}
|
|
for key in UI_KEYS:
|
|
setting = crud.settings.get_setting(db, key=key)
|
|
if setting:
|
|
result[key] = setting.get_value()
|
|
else:
|
|
result[key] = get_default_value(key)
|
|
return result
|
|
|
|
|
|
@router.put("/ui", response_model=dict[str, Any])
|
|
def update_ui_settings(
|
|
*,
|
|
db: Session = Depends(get_db),
|
|
ui_data: dict[str, Any],
|
|
current_user: User = Depends(get_current_superuser)
|
|
) -> Any:
|
|
"""
|
|
Update UI settings (admin only).
|
|
|
|
Updates multiple UI/layout settings at once.
|
|
"""
|
|
_validate_bulk_keys(ui_data, set(UI_KEYS))
|
|
result = {}
|
|
for key, value in ui_data.items():
|
|
coerced = _coerce_setting_value(key, value)
|
|
setting = crud.settings.update_setting(db, key=key, value=coerced)
|
|
result[key] = setting.get_value()
|
|
return result
|
|
|
|
|
|
@router.put("/modules", response_model=dict[str, Any])
|
|
def update_module_settings(
|
|
*,
|
|
db: Session = Depends(get_db),
|
|
module_data: dict[str, Any],
|
|
current_user: User = Depends(get_current_superuser)
|
|
) -> Any:
|
|
"""
|
|
Update module settings (admin only).
|
|
|
|
Enable or disable modules for all users.
|
|
"""
|
|
_validate_bulk_keys(module_data, set(MODULE_KEYS))
|
|
result = {}
|
|
for key, value in module_data.items():
|
|
coerced = _coerce_setting_value(key, value)
|
|
setting = crud.settings.update_setting(db, key=key, value=coerced)
|
|
result[key] = setting.get_value()
|
|
return result
|
|
|
|
|
|
@router.get("/user_mode_enabled", response_model=dict[str, Any])
|
|
def get_user_mode_enabled(
|
|
*,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user)
|
|
) -> Any:
|
|
"""
|
|
Get user mode enabled setting (accessible to all authenticated users).
|
|
"""
|
|
setting = crud.settings.get_setting(db, key="user_mode_enabled")
|
|
if setting:
|
|
return {"key": "user_mode_enabled", "value": setting.get_value()}
|
|
# Use default from registry
|
|
return {"key": "user_mode_enabled", "value": get_default_value("user_mode_enabled")}
|
|
|
|
|
|
@router.put("/user_mode_enabled", response_model=dict[str, Any])
|
|
def update_user_mode_enabled(
|
|
*,
|
|
db: Session = Depends(get_db),
|
|
data: dict[str, Any],
|
|
current_user: User = Depends(get_current_superuser)
|
|
) -> Any:
|
|
"""
|
|
Update user mode enabled setting (admin only).
|
|
"""
|
|
value = data.get("value", True)
|
|
coerced = _coerce_setting_value("user_mode_enabled", value)
|
|
setting = crud.settings.update_setting(db, key="user_mode_enabled", value=coerced)
|
|
return {"key": "user_mode_enabled", "value": setting.get_value()}
|
|
|
|
|
|
@router.get("", response_model=dict[str, Any])
|
|
def get_all_settings(
|
|
*,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_superuser)
|
|
) -> Any:
|
|
"""
|
|
Get all settings (admin only).
|
|
|
|
Returns all application settings as a dictionary.
|
|
"""
|
|
settings_list = crud.settings.get_all_settings(db)
|
|
return {s.key: s.get_value() for s in settings_list}
|
|
|
|
|
|
@router.get("/{key}", response_model=schemas.Setting)
|
|
def get_setting(
|
|
*,
|
|
db: Session = Depends(get_db),
|
|
key: str,
|
|
current_user: User = Depends(get_current_superuser)
|
|
) -> Any:
|
|
"""
|
|
Get a specific setting by key (admin only).
|
|
"""
|
|
setting = crud.settings.get_setting(db, key=key)
|
|
if not setting:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_404_NOT_FOUND,
|
|
detail=f"Setting '{key}' not found"
|
|
)
|
|
return schemas.Setting.from_orm(setting)
|
|
|
|
|
|
@router.put("/{key}", response_model=schemas.Setting)
|
|
def update_setting(
|
|
*,
|
|
db: Session = Depends(get_db),
|
|
key: str,
|
|
setting_in: schemas.SettingUpdate,
|
|
current_user: User = Depends(get_current_superuser)
|
|
) -> Any:
|
|
"""
|
|
Update a setting (admin only).
|
|
|
|
Creates the setting if it doesn't exist.
|
|
"""
|
|
coerced = _coerce_setting_value(key, setting_in.value)
|
|
setting = crud.settings.update_setting(db, key=key, value=coerced)
|
|
return schemas.Setting.from_orm(setting)
|