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>
This commit is contained in:
2025-12-15 18:14:47 +01:00
parent 04a0fe4b27
commit ba53e0eff0
31 changed files with 4277 additions and 374 deletions

View File

@@ -1,5 +1,6 @@
"""Settings endpoints."""
import json
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
@@ -12,6 +13,8 @@ from app.core.settings_registry import (
MODULE_KEYS,
UI_KEYS,
SETTINGS_REGISTRY,
SettingStorage,
SettingType,
get_default_value,
)
@@ -19,6 +22,107 @@ from app.core.settings_registry import (
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(
*,
@@ -41,6 +145,26 @@ def get_theme_settings(
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(
*,
@@ -53,11 +177,12 @@ def update_theme_settings(
Updates multiple theme settings at once.
"""
_validate_bulk_keys(theme_data, set(THEME_KEYS))
result = {}
for key, value in theme_data.items():
if key in THEME_KEYS or key.startswith("theme_"):
setting = crud.settings.update_setting(db, key=key, value=value)
result[key] = setting.get_value()
coerced = _coerce_setting_value(key, value)
setting = crud.settings.update_setting(db, key=key, value=coerced)
result[key] = setting.get_value()
return result
@@ -116,11 +241,12 @@ def update_ui_settings(
Updates multiple UI/layout settings at once.
"""
_validate_bulk_keys(ui_data, set(UI_KEYS))
result = {}
for key, value in ui_data.items():
if key in UI_KEYS:
setting = crud.settings.update_setting(db, key=key, value=value)
result[key] = setting.get_value()
coerced = _coerce_setting_value(key, value)
setting = crud.settings.update_setting(db, key=key, value=coerced)
result[key] = setting.get_value()
return result
@@ -136,11 +262,12 @@ def update_module_settings(
Enable or disable modules for all users.
"""
_validate_bulk_keys(module_data, set(MODULE_KEYS))
result = {}
for key, value in module_data.items():
if key in MODULE_KEYS or key.startswith("module_"):
setting = crud.settings.update_setting(db, key=key, value=value)
result[key] = setting.get_value()
coerced = _coerce_setting_value(key, value)
setting = crud.settings.update_setting(db, key=key, value=coerced)
result[key] = setting.get_value()
return result
@@ -171,7 +298,8 @@ def update_user_mode_enabled(
Update user mode enabled setting (admin only).
"""
value = data.get("value", True)
setting = crud.settings.update_setting(db, key="user_mode_enabled", value=value)
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()}
@@ -222,5 +350,6 @@ def update_setting(
Creates the setting if it doesn't exist.
"""
setting = crud.settings.update_setting(db, key=key, value=setting_in.value)
coerced = _coerce_setting_value(key, setting_in.value)
setting = crud.settings.update_setting(db, key=key, value=coerced)
return schemas.Setting.from_orm(setting)