feat: editable branding — app name and subtitle configurable in Admin Settings

Adds app_name and app_subtitle as system settings with a dedicated
Branding tab in the Admin panel. Both values are served via a public
GET /api/admin/branding endpoint (no auth) so the login page can also
show the configured name before the user signs in.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-22 11:02:38 +02:00
co-authored by Claude Sonnet 4.6
parent c262b31bc5
commit 4946cc300a
6 changed files with 150 additions and 6 deletions
+33
View File
@@ -23,6 +23,8 @@ VALID_ENGINES = {"cycles", "eevee"}
VALID_FORMATS = {"jpg", "png"}
VALID_CYCLES_DEVICES = {"auto", "gpu", "cpu"}
SETTINGS_DEFAULTS: dict[str, str] = {
"app_name": "Hart.O.Mat",
"app_subtitle": "Hartomatisierung",
"thumbnail_renderer": "blender",
"blender_engine": "cycles",
"blender_cycles_samples": "256",
@@ -59,6 +61,8 @@ SETTINGS_DEFAULTS: dict[str, str] = {
class SettingsOut(BaseModel):
app_name: str = "Hart.O.Mat"
app_subtitle: str = "Hartomatisierung"
thumbnail_renderer: str = "blender"
blender_engine: str = "cycles"
blender_cycles_samples: int = 256
@@ -91,6 +95,8 @@ class SettingsOut(BaseModel):
class SettingsUpdate(BaseModel):
app_name: str | None = None
app_subtitle: str | None = None
thumbnail_renderer: str | None = None
blender_engine: str | None = None
blender_cycles_samples: int | None = None
@@ -209,6 +215,8 @@ async def _save_setting(db: AsyncSession, key: str, value: str) -> None:
def _settings_to_out(raw: dict[str, str]) -> SettingsOut:
return SettingsOut(
app_name=raw.get("app_name", "Hart.O.Mat"),
app_subtitle=raw.get("app_subtitle", "Hartomatisierung"),
thumbnail_renderer=raw["thumbnail_renderer"],
blender_engine=raw["blender_engine"],
blender_cycles_samples=int(raw["blender_cycles_samples"]),
@@ -241,6 +249,19 @@ def _settings_to_out(raw: dict[str, str]) -> SettingsOut:
)
@router.get("/branding")
async def get_branding(db: AsyncSession = Depends(get_db)):
"""Public endpoint — returns configured app name and subtitle without authentication."""
result = await db.execute(
select(SystemSetting).where(SystemSetting.key.in_(["app_name", "app_subtitle"]))
)
rows = {row.key: row.value for row in result.scalars().all()}
return {
"app_name": rows.get("app_name", "Hart.O.Mat"),
"app_subtitle": rows.get("app_subtitle", "Hartomatisierung"),
}
@router.get("/settings", response_model=SettingsOut)
async def get_settings(
admin: User = Depends(require_global_admin),
@@ -255,6 +276,14 @@ async def update_settings(
admin: User = Depends(require_global_admin),
db: AsyncSession = Depends(get_db),
):
if body.app_name is not None:
stripped = body.app_name.strip()
if not stripped:
raise HTTPException(400, detail="app_name cannot be empty")
if len(stripped) > 100:
raise HTTPException(400, detail="app_name must be 100 characters or fewer")
if body.app_subtitle is not None and len(body.app_subtitle) > 100:
raise HTTPException(400, detail="app_subtitle must be 100 characters or fewer")
if body.thumbnail_renderer is not None and body.thumbnail_renderer not in VALID_RENDERERS:
raise HTTPException(400, detail=f"Invalid renderer. Choose: {', '.join(sorted(VALID_RENDERERS))}")
if body.blender_engine is not None and body.blender_engine not in VALID_ENGINES:
@@ -292,6 +321,10 @@ async def update_settings(
raise HTTPException(400, detail=f"Output type '{entry}' not found")
updates: dict[str, str] = {}
if body.app_name is not None:
updates["app_name"] = body.app_name.strip()
if body.app_subtitle is not None:
updates["app_subtitle"] = body.app_subtitle.strip()
if body.thumbnail_renderer is not None:
updates["thumbnail_renderer"] = body.thumbnail_renderer
if body.blender_engine is not None: