Files
app-service/backend/app/api/v1/settings.py
matteoscrugli ba53e0eff0 Refactor settings system and improve context initialization
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>
2025-12-15 18:14:47 +01:00

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)