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:
@@ -33,12 +33,17 @@ def register(
|
||||
|
||||
# If not the first user, check if registration is enabled
|
||||
if not is_first_user:
|
||||
registration_enabled = crud.settings.get_setting_value(
|
||||
registration_enabled_raw = crud.settings.get_setting_value(
|
||||
db,
|
||||
key="registration_enabled",
|
||||
default=True # Default to enabled if setting doesn't exist
|
||||
)
|
||||
|
||||
if isinstance(registration_enabled_raw, str):
|
||||
registration_enabled = registration_enabled_raw.strip().lower() in ("true", "1")
|
||||
else:
|
||||
registration_enabled = bool(registration_enabled_raw)
|
||||
|
||||
if not registration_enabled:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
@@ -146,10 +151,15 @@ def get_registration_status(
|
||||
This is a public endpoint that doesn't require authentication.
|
||||
Returns the registration_enabled setting value.
|
||||
"""
|
||||
registration_enabled = crud.settings.get_setting_value(
|
||||
registration_enabled_raw = crud.settings.get_setting_value(
|
||||
db,
|
||||
key="registration_enabled",
|
||||
default=True
|
||||
)
|
||||
|
||||
if isinstance(registration_enabled_raw, str):
|
||||
registration_enabled = registration_enabled_raw.strip().lower() in ("true", "1")
|
||||
else:
|
||||
registration_enabled = bool(registration_enabled_raw)
|
||||
|
||||
return {"registration_enabled": registration_enabled}
|
||||
|
||||
@@ -12,7 +12,7 @@ router = APIRouter()
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def health_check(db: Session = Depends(get_db)):
|
||||
def health_check(db: Session = Depends(get_db)):
|
||||
"""
|
||||
Health check endpoint that verifies database connectivity.
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -88,10 +88,14 @@ def update_user(
|
||||
|
||||
# Only superusers can change is_superuser and is_active
|
||||
if not current_user.is_superuser:
|
||||
if user_in.is_superuser is not None or user_in.is_active is not None:
|
||||
if (
|
||||
user_in.is_superuser is not None
|
||||
or user_in.is_active is not None
|
||||
or user_in.permissions is not None
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Only admins can change user status"
|
||||
detail="Only admins can change user status or permissions"
|
||||
)
|
||||
|
||||
user = crud.user.update(db, db_obj=user, obj_in=user_in)
|
||||
|
||||
@@ -328,15 +328,16 @@ register_setting(SettingDefinition(
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# USER-SPECIFIC SETTINGS (Per-user, Database)
|
||||
# These settings can be different for each user
|
||||
# USER-SPECIFIC SETTINGS (Frontend localStorage)
|
||||
# These settings are managed by the frontend per-browser/user-session.
|
||||
# If you need cross-device persistence, implement a per-user DB table + API.
|
||||
# =============================================================================
|
||||
|
||||
register_setting(SettingDefinition(
|
||||
key="user_theme_mode",
|
||||
type=SettingType.STRING,
|
||||
scope=SettingScope.USER_SPECIFIC,
|
||||
storage=SettingStorage.DATABASE,
|
||||
storage=SettingStorage.LOCAL_STORAGE,
|
||||
default="system",
|
||||
description="User's preferred theme mode (light/dark/system)",
|
||||
category="user_preferences",
|
||||
@@ -348,7 +349,7 @@ register_setting(SettingDefinition(
|
||||
key="user_language",
|
||||
type=SettingType.STRING,
|
||||
scope=SettingScope.USER_SPECIFIC,
|
||||
storage=SettingStorage.DATABASE,
|
||||
storage=SettingStorage.LOCAL_STORAGE,
|
||||
default="en",
|
||||
description="User's preferred language",
|
||||
category="user_preferences",
|
||||
@@ -360,7 +361,7 @@ register_setting(SettingDefinition(
|
||||
key="user_sidebar_collapsed",
|
||||
type=SettingType.BOOLEAN,
|
||||
scope=SettingScope.USER_SPECIFIC,
|
||||
storage=SettingStorage.DATABASE,
|
||||
storage=SettingStorage.LOCAL_STORAGE,
|
||||
default=False,
|
||||
description="User's sidebar collapsed state preference",
|
||||
category="user_preferences",
|
||||
@@ -371,7 +372,7 @@ register_setting(SettingDefinition(
|
||||
key="user_view_mode",
|
||||
type=SettingType.STRING,
|
||||
scope=SettingScope.USER_SPECIFIC,
|
||||
storage=SettingStorage.DATABASE,
|
||||
storage=SettingStorage.LOCAL_STORAGE,
|
||||
default="admin",
|
||||
description="User's current view mode (admin/user)",
|
||||
category="user_preferences",
|
||||
|
||||
@@ -71,7 +71,7 @@ class CRUDBase(Generic[ModelType, CreateSchemaType, UpdateSchemaType]):
|
||||
|
||||
def delete(self, db: Session, *, id: Any) -> ModelType:
|
||||
"""Delete a record."""
|
||||
obj = db.query(self.model).get(id)
|
||||
obj = db.get(self.model, id)
|
||||
db.delete(obj)
|
||||
db.commit()
|
||||
return obj
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""CRUD operations for Settings."""
|
||||
|
||||
import json
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -27,13 +28,16 @@ def update_setting(db: Session, key: str, value: any) -> Settings:
|
||||
setting = Settings(key=key)
|
||||
db.add(setting)
|
||||
|
||||
# Determine if value is bool or string
|
||||
# Determine how to store the value (DB has only bool + string columns)
|
||||
if isinstance(value, bool):
|
||||
setting.value_bool = value
|
||||
setting.value_str = None
|
||||
elif isinstance(value, str) and value.lower() in ('true', 'false'):
|
||||
setting.value_bool = value.lower() == 'true'
|
||||
elif isinstance(value, str) and value.lower() in ("true", "false", "1", "0"):
|
||||
setting.value_bool = value.lower() in ("true", "1")
|
||||
setting.value_str = None
|
||||
elif isinstance(value, (dict, list)):
|
||||
setting.value_str = json.dumps(value)
|
||||
setting.value_bool = None
|
||||
else:
|
||||
setting.value_str = str(value)
|
||||
setting.value_bool = None
|
||||
|
||||
@@ -138,16 +138,17 @@ async def startup_event():
|
||||
# Seed default settings from registry
|
||||
from sqlalchemy.orm import Session
|
||||
from app import crud
|
||||
from app.core.settings_registry import get_database_defaults
|
||||
from app.core.settings_registry import SettingScope, get_database_settings
|
||||
|
||||
with Session(engine) as db:
|
||||
# Get all database settings with defaults from registry
|
||||
default_settings = get_database_defaults()
|
||||
# Seed only GLOBAL database settings with defaults from registry
|
||||
for setting_def in get_database_settings():
|
||||
if setting_def.scope != SettingScope.GLOBAL:
|
||||
continue
|
||||
|
||||
for key, value in default_settings.items():
|
||||
if crud.settings.get_setting(db, key) is None:
|
||||
logger.info(f"Seeding default setting: {key}={value}")
|
||||
crud.settings.update_setting(db, key, value)
|
||||
if crud.settings.get_setting(db, setting_def.key) is None:
|
||||
logger.info(f"Seeding default setting: {setting_def.key}={setting_def.default}")
|
||||
crud.settings.update_setting(db, setting_def.key, setting_def.default)
|
||||
|
||||
|
||||
# Shutdown event
|
||||
|
||||
Reference in New Issue
Block a user